chore: migrate to oxlint and oxfmt

Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
Peter Steinberger
2026-01-14 14:31:43 +00:00
parent 912ebffc63
commit c379191f80
1480 changed files with 28608 additions and 43547 deletions

View File

@@ -1,9 +1,4 @@
export type AgentEventStream =
| "lifecycle"
| "tool"
| "assistant"
| "error"
| (string & {});
export type AgentEventStream = "lifecycle" | "tool" | "assistant" | "error" | (string & {});
export type AgentEventPayload = {
runId: string;
@@ -24,10 +19,7 @@ const seqByRun = new Map<string, number>();
const listeners = new Set<(evt: AgentEventPayload) => void>();
const runContextById = new Map<string, AgentRunContext>();
export function registerAgentRunContext(
runId: string,
context: AgentRunContext,
) {
export function registerAgentRunContext(runId: string, context: AgentRunContext) {
if (!runId) return;
const existing = runContextById.get(runId);
if (!existing) {

View File

@@ -20,19 +20,15 @@ describe("ensureBinary", () => {
});
it("logs and exits when missing", async () => {
const exec: typeof runExec = vi
.fn()
.mockRejectedValue(new Error("missing"));
const exec: typeof runExec = vi.fn().mockRejectedValue(new Error("missing"));
const error = vi.fn();
const exit = vi.fn(() => {
throw new Error("exit");
});
await expect(
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
).rejects.toThrow("exit");
expect(error).toHaveBeenCalledWith(
"Missing required binary: ghost. Please install it.",
await expect(ensureBinary("ghost", exec, { log: vi.fn(), error, exit })).rejects.toThrow(
"exit",
);
expect(error).toHaveBeenCalledWith("Missing required binary: ghost. Please install it.");
expect(exit).toHaveBeenCalledWith(1);
});
});

View File

@@ -7,8 +7,6 @@ export function ignoreCiaoCancellationRejection(reason: unknown): boolean {
if (!message.includes("CIAO ANNOUNCEMENT CANCELLED")) {
return false;
}
logDebug(
`bonjour: ignoring unhandled ciao rejection: ${formatBonjourError(reason)}`,
);
logDebug(`bonjour: ignoring unhandled ciao rejection: ${formatBonjourError(reason)}`);
return true;
}

View File

@@ -9,67 +9,16 @@ describe("bonjour-discovery", () => {
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
const studioInstance = "Peters Mac Studio Bridge";
const run = vi.fn(
async (argv: string[], options: { timeoutMs: number }) => {
calls.push({ argv, timeoutMs: options.timeoutMs });
const domain = argv[3] ?? "";
if (argv[0] === "dns-sd" && argv[1] === "-B") {
if (domain === "local.") {
return {
stdout: [
"Add 2 3 local. _clawdbot-bridge._tcp. Peter\\226\\128\\153s Mac Studio Bridge",
"Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
"",
].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
return {
stdout: [
`Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-bridge._tcp. Tailnet Bridge`,
"",
].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
}
if (argv[0] === "dns-sd" && argv[1] === "-L") {
const instance = argv[2] ?? "";
const host =
instance === studioInstance
? "studio.local"
: instance === "Laptop Bridge"
? "laptop.local"
: "tailnet.local";
const tailnetDns =
instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
const displayName =
instance === studioInstance
? "Peters\\032Mac\\032Studio"
: instance.replace(" Bridge", "");
const txtParts = [
"txtvers=1",
`displayName=${displayName}`,
`lanHost=${host}`,
"gatewayPort=18789",
"bridgePort=18790",
"sshPort=22",
tailnetDns ? `tailnetDns=${tailnetDns}` : null,
].filter((v): v is string => Boolean(v));
const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
calls.push({ argv, timeoutMs: options.timeoutMs });
const domain = argv[3] ?? "";
if (argv[0] === "dns-sd" && argv[1] === "-B") {
if (domain === "local.") {
return {
stdout: [
`${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
txtParts.join(" "),
"Add 2 3 local. _clawdbot-bridge._tcp. Peter\\226\\128\\153s Mac Studio Bridge",
"Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
"",
].join("\n"),
stderr: "",
@@ -78,10 +27,58 @@ describe("bonjour-discovery", () => {
killed: false,
};
}
if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
return {
stdout: [
`Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-bridge._tcp. Tailnet Bridge`,
"",
].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
}
throw new Error(`unexpected argv: ${argv.join(" ")}`);
},
);
if (argv[0] === "dns-sd" && argv[1] === "-L") {
const instance = argv[2] ?? "";
const host =
instance === studioInstance
? "studio.local"
: instance === "Laptop Bridge"
? "laptop.local"
: "tailnet.local";
const tailnetDns = instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
const displayName =
instance === studioInstance
? "Peters\\032Mac\\032Studio"
: instance.replace(" Bridge", "");
const txtParts = [
"txtvers=1",
`displayName=${displayName}`,
`lanHost=${host}`,
"gatewayPort=18789",
"bridgePort=18790",
"sshPort=22",
tailnetDns ? `tailnetDns=${tailnetDns}` : null,
].filter((v): v is string => Boolean(v));
return {
stdout: [
`${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
txtParts.join(" "),
"",
].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
throw new Error(`unexpected argv: ${argv.join(" ")}`);
});
const beacons = await discoverGatewayBeacons({
platform: "darwin",
@@ -102,9 +99,7 @@ describe("bonjour-discovery", () => {
expect.arrayContaining(["local.", WIDE_AREA_DISCOVERY_DOMAIN]),
);
const browseCalls = calls.filter(
(c) => c.argv[0] === "dns-sd" && c.argv[1] === "-B",
);
const browseCalls = calls.filter((c) => c.argv[0] === "dns-sd" && c.argv[1] === "-B");
expect(browseCalls.map((c) => c.argv[3])).toEqual(
expect.arrayContaining(["local.", WIDE_AREA_DISCOVERY_DOMAIN]),
);
@@ -112,47 +107,42 @@ describe("bonjour-discovery", () => {
});
it("decodes dns-sd octal escapes in TXT displayName", async () => {
const run = vi.fn(
async (argv: string[], options: { timeoutMs: number }) => {
if (options.timeoutMs < 0) throw new Error("invalid timeout");
const domain = argv[3] ?? "";
if (argv[0] === "dns-sd" && argv[1] === "-B" && domain === "local.") {
return {
stdout: [
"Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge",
"",
].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
if (argv[0] === "dns-sd" && argv[1] === "-L") {
return {
stdout: [
"Studio Bridge._clawdbot-bridge._tcp. can be reached at studio.local:18790",
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 bridgePort=18790 sshPort=22",
"",
].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
if (options.timeoutMs < 0) throw new Error("invalid timeout");
const domain = argv[3] ?? "";
if (argv[0] === "dns-sd" && argv[1] === "-B" && domain === "local.") {
return {
stdout: "",
stdout: ["Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge", ""].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
},
);
}
if (argv[0] === "dns-sd" && argv[1] === "-L") {
return {
stdout: [
"Studio Bridge._clawdbot-bridge._tcp. can be reached at studio.local:18790",
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 bridgePort=18790 sshPort=22",
"",
].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
};
});
const beacons = await discoverGatewayBeacons({
platform: "darwin",
@@ -176,14 +166,48 @@ describe("bonjour-discovery", () => {
it("falls back to tailnet DNS probing for wide-area when split DNS is not configured", async () => {
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
const run = vi.fn(
async (argv: string[], options: { timeoutMs: number }) => {
calls.push({ argv, timeoutMs: options.timeoutMs });
const cmd = argv[0];
const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
calls.push({ argv, timeoutMs: options.timeoutMs });
const cmd = argv[0];
if (cmd === "dns-sd" && argv[1] === "-B") {
if (cmd === "dns-sd" && argv[1] === "-B") {
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
if (cmd === "tailscale" && argv[1] === "status" && argv[2] === "--json") {
return {
stdout: JSON.stringify({
Self: { TailscaleIPs: ["100.69.232.64"] },
Peer: {
"peer-1": { TailscaleIPs: ["100.123.224.76"] },
},
}),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
if (cmd === "dig") {
const at = argv.find((a) => a.startsWith("@")) ?? "";
const server = at.replace(/^@/, "");
const qname = argv[argv.length - 2] ?? "";
const qtype = argv[argv.length - 1] ?? "";
if (
server === "100.123.224.76" &&
qtype === "PTR" &&
qname === "_clawdbot-bridge._tcp.clawdbot.internal"
) {
return {
stdout: "",
stdout: `studio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n`,
stderr: "",
code: 0,
signal: null,
@@ -192,17 +216,12 @@ describe("bonjour-discovery", () => {
}
if (
cmd === "tailscale" &&
argv[1] === "status" &&
argv[2] === "--json"
server === "100.123.224.76" &&
qtype === "SRV" &&
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
) {
return {
stdout: JSON.stringify({
Self: { TailscaleIPs: ["100.69.232.64"] },
Peer: {
"peer-1": { TailscaleIPs: ["100.123.224.76"] },
},
}),
stdout: `0 0 18790 studio.clawdbot.internal.\n`,
stderr: "",
code: 0,
signal: null,
@@ -210,67 +229,32 @@ describe("bonjour-discovery", () => {
};
}
if (cmd === "dig") {
const at = argv.find((a) => a.startsWith("@")) ?? "";
const server = at.replace(/^@/, "");
const qname = argv[argv.length - 2] ?? "";
const qtype = argv[argv.length - 1] ?? "";
if (
server === "100.123.224.76" &&
qtype === "PTR" &&
qname === "_clawdbot-bridge._tcp.clawdbot.internal"
) {
return {
stdout: `studio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n`,
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
if (
server === "100.123.224.76" &&
qtype === "SRV" &&
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
) {
return {
stdout: `0 0 18790 studio.clawdbot.internal.\n`,
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
if (
server === "100.123.224.76" &&
qtype === "TXT" &&
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
) {
return {
stdout: [
`"displayName=Studio"`,
`"transport=bridge"`,
`"bridgePort=18790"`,
`"gatewayPort=18789"`,
`"sshPort=22"`,
`"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net"`,
`"cliPath=/opt/homebrew/bin/clawdbot"`,
"",
].join(" "),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
if (
server === "100.123.224.76" &&
qtype === "TXT" &&
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
) {
return {
stdout: [
`"displayName=Studio"`,
`"transport=bridge"`,
`"bridgePort=18790"`,
`"gatewayPort=18789"`,
`"sshPort=22"`,
`"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net"`,
`"cliPath=/opt/homebrew/bin/clawdbot"`,
"",
].join(" "),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
}
throw new Error(`unexpected argv: ${argv.join(" ")}`);
},
);
throw new Error(`unexpected argv: ${argv.join(" ")}`);
});
const beacons = await discoverGatewayBeacons({
platform: "darwin",
@@ -293,9 +277,7 @@ describe("bonjour-discovery", () => {
}),
]);
expect(
calls.some((c) => c.argv[0] === "tailscale" && c.argv[1] === "status"),
).toBe(true);
expect(calls.some((c) => c.argv[0] === "tailscale" && c.argv[1] === "status")).toBe(true);
expect(calls.some((c) => c.argv[0] === "dig")).toBe(true);
});

View File

@@ -89,10 +89,7 @@ function parseDigTxt(stdout: string): string[] {
if (!line) continue;
const matches = Array.from(line.matchAll(/"([^"]*)"/g), (m) => m[1] ?? "");
for (const m of matches) {
const unescaped = m
.replaceAll("\\\\", "\\")
.replaceAll('\\"', '"')
.replaceAll("\\n", "\n");
const unescaped = m.replaceAll("\\\\", "\\").replaceAll('\\"', '"').replaceAll("\\n", "\n");
tokens.push(unescaped);
}
}
@@ -176,10 +173,7 @@ function parseDnsSdBrowse(stdout: string): string[] {
return Array.from(instances.values());
}
function parseDnsSdResolve(
stdout: string,
instanceName: string,
): GatewayBonjourBeacon | null {
function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjourBeacon | null {
const decodedInstanceName = decodeDnsSdEscapes(instanceName);
const beacon: GatewayBonjourBeacon = { instanceName: decodedInstanceName };
let txt: Record<string, string> = {};
@@ -228,10 +222,9 @@ async function discoverViaDnsSd(
const instances = parseDnsSdBrowse(browse.stdout);
const results: GatewayBonjourBeacon[] = [];
for (const instance of instances) {
const resolved = await run(
["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", domain],
{ timeoutMs },
);
const resolved = await run(["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", domain], {
timeoutMs,
});
const parsed = parseDnsSdResolve(resolved.stdout, instance);
if (parsed) results.push({ ...parsed, domain });
}
@@ -247,10 +240,7 @@ async function discoverWideAreaViaTailnetDns(
const startedAt = Date.now();
const remainingMs = () => timeoutMs - (Date.now() - startedAt);
const tailscaleCandidates = [
"tailscale",
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
];
const tailscaleCandidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"];
let ips: string[] = [];
for (const candidate of tailscaleCandidates) {
try {
@@ -301,9 +291,7 @@ async function discoverWideAreaViaTailnetDns(
}
};
await Promise.all(
Array.from({ length: Math.min(concurrency, ips.length) }, () => worker()),
);
await Promise.all(Array.from({ length: Math.min(concurrency, ips.length) }, () => worker()));
if (!nameserver || ptrs.length === 0) return [];
if (remainingMs() <= 0) return [];
@@ -317,10 +305,9 @@ async function discoverWideAreaViaTailnetDns(
if (!ptrName) continue;
const instanceName = ptrName.replace(/\.?_clawdbot-bridge\._tcp\..*$/, "");
const srv = await run(
["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "SRV"],
{ timeoutMs: Math.max(1, Math.min(350, budget)) },
).catch(() => null);
const srv = await run(["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "SRV"], {
timeoutMs: Math.max(1, Math.min(350, budget)),
}).catch(() => null);
const srvParsed = srv ? parseDigSrv(srv.stdout) : null;
if (!srvParsed) continue;
@@ -336,10 +323,9 @@ async function discoverWideAreaViaTailnetDns(
continue;
}
const txt = await run(
["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "TXT"],
{ timeoutMs: Math.max(1, Math.min(350, txtBudget)) },
).catch(() => null);
const txt = await run(["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "TXT"], {
timeoutMs: Math.max(1, Math.min(350, txtBudget)),
}).catch(() => null);
const txtTokens = txt ? parseDigTxt(txt.stdout) : [];
const txtMap = txtTokens.length > 0 ? parseTxtTokens(txtTokens) : {};
@@ -449,18 +435,12 @@ export async function discoverGatewayBeacons(
try {
if (platform === "darwin") {
const perDomain = await Promise.allSettled(
domains.map(
async (domain) => await discoverViaDnsSd(domain, timeoutMs, run),
),
);
const discovered = perDomain.flatMap((r) =>
r.status === "fulfilled" ? r.value : [],
domains.map(async (domain) => await discoverViaDnsSd(domain, timeoutMs, run)),
);
const discovered = perDomain.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
const wantsWideArea = domains.includes(WIDE_AREA_DISCOVERY_DOMAIN);
const hasWideArea = discovered.some(
(b) => b.domain === WIDE_AREA_DISCOVERY_DOMAIN,
);
const hasWideArea = discovered.some((b) => b.domain === WIDE_AREA_DISCOVERY_DOMAIN);
if (wantsWideArea && !hasWideArea) {
const fallback = await discoverWideAreaViaTailnetDns(
@@ -475,13 +455,9 @@ export async function discoverGatewayBeacons(
}
if (platform === "linux") {
const perDomain = await Promise.allSettled(
domains.map(
async (domain) => await discoverViaAvahi(domain, timeoutMs, run),
),
);
return perDomain.flatMap((r) =>
r.status === "fulfilled" ? r.value : [],
domains.map(async (domain) => await discoverViaAvahi(domain, timeoutMs, run)),
);
return perDomain.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
}
} catch {
return [];

View File

@@ -41,9 +41,8 @@ vi.mock("@homebridge/ciao", () => {
vi.mock("./unhandled-rejections.js", () => {
return {
registerUnhandledRejectionHandler: (
handler: (reason: unknown) => boolean,
) => registerUnhandledRejectionHandler(handler),
registerUnhandledRejectionHandler: (handler: (reason: unknown) => boolean) =>
registerUnhandledRejectionHandler(handler),
};
});
@@ -98,8 +97,7 @@ describe("gateway bonjour advertiser", () => {
destroy,
serviceState: "announced",
on: vi.fn(),
getFQDN: () =>
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
@@ -114,28 +112,18 @@ describe("gateway bonjour advertiser", () => {
});
expect(createService).toHaveBeenCalledTimes(1);
const [bridgeCall] = createService.mock.calls as Array<
[Record<string, unknown>]
>;
const [bridgeCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
expect(bridgeCall?.[0]?.type).toBe("clawdbot-bridge");
expect(bridgeCall?.[0]?.port).toBe(18790);
expect(bridgeCall?.[0]?.domain).toBe("local");
expect(bridgeCall?.[0]?.hostname).toBe("test-host");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
"test-host.local",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe(
"18790",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe(
"2222",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe("18790");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
"/opt/homebrew/bin/clawdbot",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe(
"bridge",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe("bridge");
// We don't await `advertise()`, but it should still be called for each service.
expect(advertise).toHaveBeenCalledTimes(1);
@@ -165,8 +153,7 @@ describe("gateway bonjour advertiser", () => {
destroy,
serviceState: "announced",
on,
getFQDN: () =>
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
@@ -179,10 +166,7 @@ describe("gateway bonjour advertiser", () => {
});
// 1 service × 2 listeners
expect(onCalls.map((c) => c.event)).toEqual([
"name-change",
"hostname-change",
]);
expect(onCalls.map((c) => c.event)).toEqual(["name-change", "hostname-change"]);
await started.stop();
});
@@ -207,8 +191,7 @@ describe("gateway bonjour advertiser", () => {
destroy,
serviceState: "announced",
on: vi.fn(),
getFQDN: () =>
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
@@ -252,8 +235,7 @@ describe("gateway bonjour advertiser", () => {
destroy,
serviceState: "unannounced",
on: vi.fn(),
getFQDN: () =>
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
@@ -270,9 +252,7 @@ describe("gateway bonjour advertiser", () => {
// allow promise rejection handler to run
await Promise.resolve();
expect(logWarn).toHaveBeenCalledWith(
expect.stringContaining("advertise failed"),
);
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("advertise failed"));
// watchdog should attempt re-advertise at the 60s interval tick
await vi.advanceTimersByTimeAsync(60_000);
@@ -302,8 +282,7 @@ describe("gateway bonjour advertiser", () => {
destroy,
serviceState: "unannounced",
on: vi.fn(),
getFQDN: () =>
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
@@ -316,9 +295,7 @@ describe("gateway bonjour advertiser", () => {
});
expect(advertise).toHaveBeenCalledTimes(1);
expect(logWarn).toHaveBeenCalledWith(
expect.stringContaining("advertise threw"),
);
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("advertise threw"));
await started.stop();
});
@@ -338,8 +315,7 @@ describe("gateway bonjour advertiser", () => {
destroy,
serviceState: "announced",
on: vi.fn(),
getFQDN: () =>
`${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
@@ -355,9 +331,7 @@ describe("gateway bonjour advertiser", () => {
expect(bridgeCall?.[0]?.name).toBe("Mac (Clawdbot)");
expect(bridgeCall?.[0]?.domain).toBe("local");
expect(bridgeCall?.[0]?.hostname).toBe("Mac");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
"Mac.local",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
await started.stop();
});

View File

@@ -66,8 +66,7 @@ function serviceSummary(label: string, svc: BonjourService): string {
} catch {
// ignore
}
const state =
typeof svc.serviceState === "string" ? svc.serviceState : "unknown";
const state = typeof svc.serviceState === "string" ? svc.serviceState : "unknown";
return `${label} fqdn=${fqdn} host=${hostname} port=${port} state=${state}`;
}
@@ -157,23 +156,16 @@ export async function startGatewayBonjourAdvertiser(
try {
svc.on("name-change", (name: unknown) => {
const next = typeof name === "string" ? name : String(name);
logWarn(
`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`,
);
logWarn(`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`);
});
svc.on("hostname-change", (nextHostname: unknown) => {
const next =
typeof nextHostname === "string"
? nextHostname
: String(nextHostname);
const next = typeof nextHostname === "string" ? nextHostname : String(nextHostname);
logWarn(
`bonjour: ${label} hostname conflict resolved; newHostname=${JSON.stringify(next)}`,
);
});
} catch (err) {
logDebug(
`bonjour: failed to attach listeners for ${label}: ${String(err)}`,
);
logDebug(`bonjour: failed to attach listeners for ${label}: ${String(err)}`);
}
}
@@ -207,8 +199,7 @@ export async function startGatewayBonjourAdvertiser(
for (const { label, svc } of services) {
const stateUnknown = (svc as { serviceState?: unknown }).serviceState;
if (typeof stateUnknown !== "string") continue;
if (stateUnknown === "announced" || stateUnknown === "announcing")
continue;
if (stateUnknown === "announced" || stateUnknown === "announcing") continue;
let key = label;
try {

View File

@@ -33,10 +33,7 @@ export function resolveBrewPathDirs(opts?: {
// Linuxbrew defaults.
dirs.push(path.join(homeDir, ".linuxbrew", "bin"));
dirs.push(path.join(homeDir, ".linuxbrew", "sbin"));
dirs.push(
"/home/linuxbrew/.linuxbrew/bin",
"/home/linuxbrew/.linuxbrew/sbin",
);
dirs.push("/home/linuxbrew/.linuxbrew/bin", "/home/linuxbrew/.linuxbrew/sbin");
// macOS defaults (also used by some Linux setups).
dirs.push("/opt/homebrew/bin", "/usr/local/bin");

View File

@@ -62,8 +62,7 @@ describe("node bridge server", () => {
const ifaces = os.networkInterfaces();
for (const entries of Object.values(ifaces)) {
for (const info of entries ?? []) {
if (info.family === "IPv4" && info.internal === false)
return info.address;
if (info.family === "IPv4" && info.internal === false) return info.address;
}
}
return null;
@@ -231,8 +230,7 @@ describe("node bridge server", () => {
});
it("handles req/res RPC after authentication", async () => {
let lastRequest: { nodeId?: string; id?: string; method?: string } | null =
null;
let lastRequest: { nodeId?: string; id?: string; method?: string } | null = null;
const server = await startNodeBridgeServer({
host: "127.0.0.1",
@@ -386,10 +384,9 @@ describe("node bridge server", () => {
const line3 = JSON.parse(await readLine2()) as { type: string };
expect(line3.type).toBe("hello-ok");
await pollUntil(
async () => (lastAuthed?.nodeId === "n4" ? lastAuthed : null),
{ timeoutMs: 3000 },
);
await pollUntil(async () => (lastAuthed?.nodeId === "n4" ? lastAuthed : null), {
timeoutMs: 3000,
});
expect(lastAuthed?.nodeId).toBe("n4");
// Prefer paired metadata over hello payload (token verifies the stored node record).

View File

@@ -62,8 +62,7 @@ describe("node bridge server", () => {
const ifaces = os.networkInterfaces();
for (const entries of Object.values(ifaces)) {
for (const info of entries ?? []) {
if (info.family === "IPv4" && info.internal === false)
return info.address;
if (info.family === "IPv4" && info.internal === false) return info.address;
}
}
return null;
@@ -206,21 +205,13 @@ describe("node bridge server", () => {
expect(node?.deviceFamily).toBe("iPad");
expect(node?.modelIdentifier).toBe("iPad14,5");
expect(node?.caps).toEqual(["canvas", "camera"]);
expect(node?.commands).toEqual([
"canvas.eval",
"canvas.snapshot",
"camera.snap",
]);
expect(node?.commands).toEqual(["canvas.eval", "canvas.snapshot", "camera.snap"]);
expect(node?.permissions).toEqual({ accessibility: true });
const after = await listNodePairing(baseDir);
const paired = after.paired.find((p) => p.nodeId === "n-caps");
expect(paired?.caps).toEqual(["canvas", "camera"]);
expect(paired?.commands).toEqual([
"canvas.eval",
"canvas.snapshot",
"camera.snap",
]);
expect(paired?.commands).toEqual(["canvas.eval", "canvas.snapshot", "camera.snap"]);
expect(paired?.permissions).toEqual({ accessibility: true });
socket.destroy();

View File

@@ -101,20 +101,15 @@ export function createNodeBridgeConnectionHandler(params: {
const family = String(frame.deviceFamily ?? "")
.trim()
.toLowerCase();
if (platform.includes("ios") || platform.includes("ipados"))
return ["canvas", "camera"];
if (platform.includes("ios") || platform.includes("ipados")) return ["canvas", "camera"];
if (platform.includes("android")) return ["canvas", "camera"];
if (family === "ipad" || family === "iphone" || family === "ios")
return ["canvas", "camera"];
if (family === "ipad" || family === "iphone" || family === "ios") return ["canvas", "camera"];
if (family === "android") return ["canvas", "camera"];
return undefined;
};
const normalizePermissions = (
raw: unknown,
): Record<string, boolean> | undefined => {
if (!raw || typeof raw !== "object" || Array.isArray(raw))
return undefined;
const normalizePermissions = (raw: unknown): Record<string, boolean> | undefined => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const entries = Object.entries(raw as Record<string, unknown>)
.map(([key, value]) => [String(key).trim(), value === true] as const)
.filter(([key]) => key.length > 0);
@@ -136,11 +131,7 @@ export function createNodeBridgeConnectionHandler(params: {
return;
}
const verified = await verifyNodeToken(
nodeId,
token,
opts.pairingBaseDir,
);
const verified = await verifyNodeToken(nodeId, token, opts.pairingBaseDir);
if (!verified.ok || !verified.node) {
sendError("UNAUTHORIZED", "invalid token");
return;
@@ -212,15 +203,11 @@ export function createNodeBridgeConnectionHandler(params: {
requestId: string;
nodeId: string;
ts: number;
}): Promise<
{ ok: true; token: string } | { ok: false; reason: string }
> => {
}): Promise<{ ok: true; token: string } | { ok: false; reason: string }> => {
const deadline = Date.now() + 5 * 60 * 1000;
while (!abort.signal.aborted && Date.now() < deadline) {
const list = await listNodePairing(opts.pairingBaseDir);
const stillPending = list.pending.some(
(p) => p.requestId === request.requestId,
);
const stillPending = list.pending.some((p) => p.requestId === request.requestId);
if (stillPending) {
await sleep(250);
continue;
@@ -301,9 +288,7 @@ export function createNodeBridgeConnectionHandler(params: {
version: req.version,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: Array.isArray(req.caps)
? req.caps.map((c) => String(c)).filter(Boolean)
: undefined,
caps: Array.isArray(req.caps) ? req.caps.map((c) => String(c)).filter(Boolean) : undefined,
commands: Array.isArray(req.commands)
? req.commands.map((c) => String(c)).filter(Boolean)
: undefined,

View File

@@ -4,10 +4,7 @@ import os from "node:os";
import { resolveCanvasHostUrl } from "../../canvas-host-url.js";
import {
type ConnectionState,
createNodeBridgeConnectionHandler,
} from "./connection.js";
import { type ConnectionState, createNodeBridgeConnectionHandler } from "./connection.js";
import { createDisabledNodeBridgeServer } from "./disabled.js";
import { encodeLine } from "./encode.js";
import { shouldAlsoListenOnLoopback } from "./loopback.js";
@@ -20,13 +17,8 @@ import type {
NodeBridgeServerOpts,
} from "./types.js";
export async function startNodeBridgeServer(
opts: NodeBridgeServerOpts,
): Promise<NodeBridgeServer> {
if (
isNodeBridgeTestEnv() &&
process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS !== "1"
) {
export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise<NodeBridgeServer> {
if (isNodeBridgeTestEnv() && process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS !== "1") {
return createDisabledNodeBridgeServer();
}
@@ -70,8 +62,7 @@ export async function startNodeBridgeServer(
});
const address = primary.address();
const port =
typeof address === "object" && address ? address.port : opts.port;
const port = typeof address === "object" && address ? address.port : opts.port;
if (shouldAlsoListenOnLoopback(opts.host)) {
const loopback = net.createServer(onConnection);
@@ -137,16 +128,11 @@ export async function startNodeBridgeServer(
invoke: async ({ nodeId, command, paramsJSON, timeoutMs }) => {
const normalizedNodeId = String(nodeId ?? "").trim();
const normalizedCommand = String(command ?? "").trim();
if (!normalizedNodeId)
throw new Error("INVALID_REQUEST: nodeId required");
if (!normalizedCommand)
throw new Error("INVALID_REQUEST: command required");
if (!normalizedNodeId) throw new Error("INVALID_REQUEST: nodeId required");
if (!normalizedCommand) throw new Error("INVALID_REQUEST: command required");
const conn = connections.get(normalizedNodeId);
if (!conn)
throw new Error(
`UNAVAILABLE: node not connected (${normalizedNodeId})`,
);
if (!conn) throw new Error(`UNAVAILABLE: node not connected (${normalizedNodeId})`);
const id = randomUUID();
const timeout = Number.isFinite(timeoutMs) ? Number(timeoutMs) : 15_000;

View File

@@ -101,11 +101,7 @@ export type NodeBridgeServer = {
paramsJSON?: string | null;
timeoutMs?: number;
}) => Promise<BridgeInvokeResponseFrame>;
sendEvent: (opts: {
nodeId: string;
event: string;
payloadJSON?: string | null;
}) => void;
sendEvent: (opts: { nodeId: string; event: string; payloadJSON?: string | null }) => void;
listConnected: () => NodeBridgeClientInfo[];
listeners: Array<{ host: string; port: number }>;
};
@@ -139,8 +135,6 @@ export type NodeBridgeServerOpts = {
>;
onAuthenticated?: (node: NodeBridgeClientInfo) => Promise<void> | void;
onDisconnected?: (node: NodeBridgeClientInfo) => Promise<void> | void;
onPairRequested?: (
request: NodePairingPendingRequest,
) => Promise<void> | void;
onPairRequested?: (request: NodePairingPendingRequest) => Promise<void> | void;
serverName?: string;
};

View File

@@ -46,19 +46,11 @@ export function resolveCanvasHostUrl(params: CanvasHostUrlParams) {
const scheme =
params.scheme ??
(parseForwardedProto(params.forwardedProto)?.trim() === "https"
? "https"
: "http");
(parseForwardedProto(params.forwardedProto)?.trim() === "https" ? "https" : "http");
const override = normalizeHost(params.hostOverride, true);
const requestHost = normalizeHost(
parseHostHeader(params.requestHost),
!!override,
);
const localAddress = normalizeHost(
params.localAddress,
Boolean(override || requestHost),
);
const requestHost = normalizeHost(parseHostHeader(params.requestHost), !!override);
const localAddress = normalizeHost(params.localAddress, Boolean(override || requestHost));
const host = override || requestHost || localAddress;
if (!host) return undefined;

View File

@@ -39,17 +39,13 @@ describe("channel activity", () => {
direction: "inbound",
at: 2,
});
expect(getChannelActivity({ channel: "whatsapp", accountId: "a" })).toEqual(
{
inboundAt: 1,
outboundAt: null,
},
);
expect(getChannelActivity({ channel: "whatsapp", accountId: "b" })).toEqual(
{
inboundAt: 2,
outboundAt: null,
},
);
expect(getChannelActivity({ channel: "whatsapp", accountId: "a" })).toEqual({
inboundAt: 1,
outboundAt: null,
});
expect(getChannelActivity({ channel: "whatsapp", accountId: "b" })).toEqual({
inboundAt: 2,
outboundAt: null,
});
});
});

View File

@@ -1,8 +1,5 @@
import { listChannelPlugins } from "../channels/plugins/index.js";
import type {
ChannelAccountSnapshot,
ChannelPlugin,
} from "../channels/plugins/types.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js";
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { theme } from "../terminal/theme.js";
@@ -144,21 +141,14 @@ export async function buildChannelSummary(
for (const plugin of listChannelPlugins()) {
const accountIds = plugin.config.listAccountIds(effective);
const defaultAccountId =
plugin.config.defaultAccountId?.(effective) ??
accountIds[0] ??
DEFAULT_ACCOUNT_ID;
const resolvedAccountIds =
accountIds.length > 0 ? accountIds : [defaultAccountId];
plugin.config.defaultAccountId?.(effective) ?? accountIds[0] ?? DEFAULT_ACCOUNT_ID;
const resolvedAccountIds = accountIds.length > 0 ? accountIds : [defaultAccountId];
const entries: ChannelAccountEntry[] = [];
for (const accountId of resolvedAccountIds) {
const account = plugin.config.resolveAccount(effective, accountId);
const enabled = resolveAccountEnabled(plugin, account, effective);
const configured = await resolveAccountConfigured(
plugin,
account,
effective,
);
const configured = await resolveAccountConfigured(plugin, account, effective);
const snapshot = buildAccountSnapshot({
plugin,
account,
@@ -173,24 +163,20 @@ export async function buildChannelSummary(
const configuredEntries = entries.filter((entry) => entry.configured);
const anyEnabled = entries.some((entry) => entry.enabled);
const fallbackEntry =
entries.find((entry) => entry.accountId === defaultAccountId) ??
entries[0];
entries.find((entry) => entry.accountId === defaultAccountId) ?? entries[0];
const summary = plugin.status?.buildChannelSummary
? await plugin.status.buildChannelSummary({
account: fallbackEntry?.account ?? {},
cfg: effective,
defaultAccountId,
snapshot:
fallbackEntry?.snapshot ??
({ accountId: defaultAccountId } as ChannelAccountSnapshot),
fallbackEntry?.snapshot ?? ({ accountId: defaultAccountId } as ChannelAccountSnapshot),
})
: undefined;
const summaryRecord = summary as Record<string, unknown> | undefined;
const linked =
summaryRecord && typeof summaryRecord.linked === "boolean"
? summaryRecord.linked
: null;
summaryRecord && typeof summaryRecord.linked === "boolean" ? summaryRecord.linked : null;
const configured =
summaryRecord && typeof summaryRecord.configured === "boolean"
? summaryRecord.configured
@@ -216,9 +202,7 @@ export async function buildChannelSummary(
let line = `${baseLabel}: ${status}`;
const authAgeMs =
summaryRecord && typeof summaryRecord.authAgeMs === "number"
? summaryRecord.authAgeMs
: null;
summaryRecord && typeof summaryRecord.authAgeMs === "number" ? summaryRecord.authAgeMs : null;
const self = summaryRecord?.self as { e164?: string | null } | undefined;
if (self?.e164) line += ` ${self.e164}`;
if (authAgeMs != null && authAgeMs >= 0) {

View File

@@ -1,16 +1,9 @@
import { listChannelPlugins } from "../channels/plugins/index.js";
import type {
ChannelAccountSnapshot,
ChannelStatusIssue,
} from "../channels/plugins/types.js";
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../channels/plugins/types.js";
export function collectChannelStatusIssues(
payload: Record<string, unknown>,
): ChannelStatusIssue[] {
export function collectChannelStatusIssues(payload: Record<string, unknown>): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
const accountsByChannel = payload.channelAccounts as
| Record<string, unknown>
| undefined;
const accountsByChannel = payload.channelAccounts as Record<string, unknown> | undefined;
for (const plugin of listChannelPlugins()) {
const collect = plugin.status?.collectStatusIssues;
if (!collect) continue;

View File

@@ -12,10 +12,7 @@ async function readPackageName(dir: string): Promise<string | null> {
}
}
async function findPackageRoot(
startDir: string,
maxDepth = 12,
): Promise<string | null> {
async function findPackageRoot(startDir: string, maxDepth = 12): Promise<string | null> {
let current = path.resolve(startDir);
for (let i = 0; i < maxDepth; i += 1) {
const name = await readPackageName(current);

View File

@@ -4,27 +4,19 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
resolveControlUiDistIndexPath,
resolveControlUiRepoRoot,
} from "./control-ui-assets.js";
import { resolveControlUiDistIndexPath, resolveControlUiRepoRoot } from "./control-ui-assets.js";
describe("control UI assets helpers", () => {
it("resolves repo root from src argv1", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ui-"));
try {
await fs.mkdir(path.join(tmp, "ui"), { recursive: true });
await fs.writeFile(
path.join(tmp, "ui", "vite.config.ts"),
"export {};\n",
);
await fs.writeFile(path.join(tmp, "ui", "vite.config.ts"), "export {};\n");
await fs.writeFile(path.join(tmp, "package.json"), "{}\n");
await fs.mkdir(path.join(tmp, "src"), { recursive: true });
await fs.writeFile(path.join(tmp, "src", "index.ts"), "export {};\n");
expect(resolveControlUiRepoRoot(path.join(tmp, "src", "index.ts"))).toBe(
tmp,
);
expect(resolveControlUiRepoRoot(path.join(tmp, "src", "index.ts"))).toBe(tmp);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
@@ -34,17 +26,12 @@ describe("control UI assets helpers", () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ui-"));
try {
await fs.mkdir(path.join(tmp, "ui"), { recursive: true });
await fs.writeFile(
path.join(tmp, "ui", "vite.config.ts"),
"export {};\n",
);
await fs.writeFile(path.join(tmp, "ui", "vite.config.ts"), "export {};\n");
await fs.writeFile(path.join(tmp, "package.json"), "{}\n");
await fs.mkdir(path.join(tmp, "dist"), { recursive: true });
await fs.writeFile(path.join(tmp, "dist", "index.js"), "export {};\n");
expect(resolveControlUiRepoRoot(path.join(tmp, "dist", "index.js"))).toBe(
tmp,
);
expect(resolveControlUiRepoRoot(path.join(tmp, "dist", "index.js"))).toBe(tmp);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}

View File

@@ -94,17 +94,12 @@ export async function ensureControlUiAssetsBuilt(
};
}
runtime.log(
"Control UI assets missing; building (ui:build, auto-installs UI deps)…",
);
runtime.log("Control UI assets missing; building (ui:build, auto-installs UI deps)…");
const build = await runCommandWithTimeout(
[process.execPath, uiScript, "build"],
{
cwd: repoRoot,
timeoutMs: opts?.timeoutMs ?? 10 * 60_000,
},
);
const build = await runCommandWithTimeout([process.execPath, uiScript, "build"], {
cwd: repoRoot,
timeoutMs: opts?.timeoutMs ?? 10 * 60_000,
});
if (build.code !== 0) {
return {
ok: false,

View File

@@ -16,9 +16,7 @@ describe("loadDotEnv", () => {
const prevEnv = { ...process.env };
const prevCwd = process.cwd();
const base = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-dotenv-test-"),
);
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-dotenv-test-"));
const cwdDir = path.join(base, "cwd");
const stateDir = path.join(base, "state");
@@ -50,9 +48,7 @@ describe("loadDotEnv", () => {
const prevEnv = { ...process.env };
const prevCwd = process.cwd();
const base = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-dotenv-test-"),
);
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-dotenv-test-"));
const cwdDir = path.join(base, "cwd");
const stateDir = path.join(base, "state");

View File

@@ -11,11 +11,7 @@ export function formatErrorMessage(err: unknown): string {
return err.message || err.name || "Error";
}
if (typeof err === "string") return err;
if (
typeof err === "number" ||
typeof err === "boolean" ||
typeof err === "bigint"
) {
if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
return String(err);
}
try {

View File

@@ -9,9 +9,7 @@ function isLikelyPath(value: string): boolean {
return /^[A-Za-z]:[\\/]/.test(value);
}
export function isSafeExecutableValue(
value: string | null | undefined,
): boolean {
export function isSafeExecutableValue(value: string | null | undefined): boolean {
if (!value) return false;
const trimmed = value.trim();
if (!trimmed) return false;

View File

@@ -21,10 +21,7 @@ export type FormatDurationMsOptions = {
unit?: "s" | "seconds";
};
export function formatDurationMs(
ms: number,
options: FormatDurationMsOptions = {},
): string {
export function formatDurationMs(ms: number, options: FormatDurationMsOptions = {}): string {
if (!Number.isFinite(ms)) return "unknown";
if (ms < 1000) return `${ms}ms`;
return formatDurationSeconds(ms, {

View File

@@ -51,9 +51,7 @@ const readCommitFromPackageJson = () => {
}
};
export const resolveCommitHash = (
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
) => {
export const resolveCommitHash = (options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) => {
if (cachedCommit !== undefined) return cachedCommit;
const env = options.env ?? process.env;
const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();

View File

@@ -23,9 +23,7 @@ export function emitHeartbeatEvent(evt: Omit<HeartbeatEventPayload, "ts">) {
}
}
export function onHeartbeatEvent(
listener: (evt: HeartbeatEventPayload) => void,
): () => void {
export function onHeartbeatEvent(listener: (evt: HeartbeatEventPayload) => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}

View File

@@ -217,10 +217,7 @@ describe("runHeartbeatOnce", () => {
session: { store: storePath },
};
replySpy.mockResolvedValue([
{ text: "Let me check..." },
{ text: "Final alert" },
]);
replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]);
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
toJid: "jid",
@@ -238,11 +235,7 @@ describe("runHeartbeatOnce", () => {
});
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
expect(sendWhatsApp).toHaveBeenCalledWith(
"+1555",
"Final alert",
expect.any(Object),
);
expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object));
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
@@ -312,12 +305,7 @@ describe("runHeartbeatOnce", () => {
"Reasoning:\n_Because it helps_",
expect.any(Object),
);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
2,
"+1555",
"Final alert",
expect.any(Object),
);
expect(sendWhatsApp).toHaveBeenNthCalledWith(2, "+1555", "Final alert", expect.any(Object));
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
@@ -395,12 +383,7 @@ describe("runHeartbeatOnce", () => {
it("loads the default agent session from templated stores", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storeTemplate = path.join(
tmpDir,
"agents",
"{agentId}",
"sessions.json",
);
const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: ClawdbotConfig = {

View File

@@ -7,10 +7,7 @@ import {
} from "../auto-reply/heartbeat.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import {
getChannelPlugin,
normalizeChannelId,
} from "../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import type { ChannelHeartbeatDeps } from "../channels/plugins/types.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import type { ClawdbotConfig } from "../config/config.js";
@@ -51,14 +48,8 @@ export function setHeartbeatsEnabled(enabled: boolean) {
heartbeatsEnabled = enabled;
}
export function resolveHeartbeatIntervalMs(
cfg: ClawdbotConfig,
overrideEvery?: string,
) {
const raw =
overrideEvery ??
cfg.agents?.defaults?.heartbeat?.every ??
DEFAULT_HEARTBEAT_EVERY;
export function resolveHeartbeatIntervalMs(cfg: ClawdbotConfig, overrideEvery?: string) {
const raw = overrideEvery ?? cfg.agents?.defaults?.heartbeat?.every ?? DEFAULT_HEARTBEAT_EVERY;
if (!raw) return null;
const trimmed = String(raw).trim();
if (!trimmed) return null;
@@ -79,8 +70,7 @@ export function resolveHeartbeatPrompt(cfg: ClawdbotConfig) {
function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig) {
return Math.max(
0,
cfg.agents?.defaults?.heartbeat?.ackMaxChars ??
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
);
}
@@ -103,11 +93,7 @@ function resolveHeartbeatReplyPayload(
for (let idx = replyResult.length - 1; idx >= 0; idx -= 1) {
const payload = replyResult[idx];
if (!payload) continue;
if (
payload.text ||
payload.mediaUrl ||
(payload.mediaUrls && payload.mediaUrls.length > 0)
) {
if (payload.text || payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0)) {
return payload;
}
}
@@ -117,11 +103,7 @@ function resolveHeartbeatReplyPayload(
function resolveHeartbeatReasoningPayloads(
replyResult: ReplyPayload | ReplyPayload[] | undefined,
): ReplyPayload[] {
const payloads = Array.isArray(replyResult)
? replyResult
: replyResult
? [replyResult]
: [];
const payloads = Array.isArray(replyResult) ? replyResult : replyResult ? [replyResult] : [];
return payloads.filter((payload) => {
const text = typeof payload.text === "string" ? payload.text : "";
return text.trimStart().startsWith("Reasoning:");
@@ -146,9 +128,7 @@ function resolveHeartbeatSender(params: {
return candidates[0] ?? "heartbeat";
}
if (candidates.length > 0 && allowList.length > 0) {
const matched = candidates.find((candidate) =>
allowList.includes(candidate),
);
const matched = candidates.find((candidate) => allowList.includes(candidate));
if (matched) return matched;
}
if (candidates.length > 0 && allowList.length === 0) {
@@ -182,9 +162,7 @@ function normalizeHeartbeatReply(
mode: "heartbeat",
maxAckChars: ackMaxChars,
});
const hasMedia = Boolean(
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
);
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
if (stripped.shouldSkip && !hasMedia) {
return {
shouldSkip: true,
@@ -225,13 +203,11 @@ export async function runHeartbeatOnce(opts: {
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
? normalizeChannelId(entry.lastChannel)
: undefined;
const senderProvider =
delivery.channel !== "none" ? delivery.channel : lastChannel;
const senderProvider = delivery.channel !== "none" ? delivery.channel : lastChannel;
const senderAllowFrom = senderProvider
? (getChannelPlugin(senderProvider)?.config.resolveAllowFrom?.({
cfg,
accountId:
senderProvider === lastChannel ? entry?.lastAccountId : undefined,
accountId: senderProvider === lastChannel ? entry?.lastAccountId : undefined,
}) ?? [])
: [];
const sender = resolveHeartbeatSender({
@@ -248,25 +224,16 @@ export async function runHeartbeatOnce(opts: {
};
try {
const replyResult = await getReplyFromConfig(
ctx,
{ isHeartbeat: true },
cfg,
);
const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
const includeReasoning =
cfg.agents?.defaults?.heartbeat?.includeReasoning === true;
const includeReasoning = cfg.agents?.defaults?.heartbeat?.includeReasoning === true;
const reasoningPayloads = includeReasoning
? resolveHeartbeatReasoningPayloads(replyResult).filter(
(payload) => payload !== replyPayload,
)
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
: [];
if (
!replyPayload ||
(!replyPayload.text &&
!replyPayload.mediaUrl &&
!replyPayload.mediaUrls?.length)
(!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length)
) {
await restoreHeartbeatUpdatedAt({
storePath,
@@ -284,10 +251,7 @@ export async function runHeartbeatOnce(opts: {
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg);
const normalized = normalizeHeartbeatReply(
replyPayload,
resolveEffectiveMessagesConfig(
cfg,
resolveAgentIdFromSessionKey(sessionKey),
).responsePrefix,
resolveEffectiveMessagesConfig(cfg, resolveAgentIdFromSessionKey(sessionKey)).responsePrefix,
ackMaxChars,
);
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
@@ -306,8 +270,7 @@ export async function runHeartbeatOnce(opts: {
}
const mediaUrls =
replyPayload.mediaUrls ??
(replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []);
replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []);
// Reasoning payloads are text-only; any attachments stay on the main reply.
const previewText = shouldSkipMain
? reasoningPayloads
@@ -327,8 +290,7 @@ export async function runHeartbeatOnce(opts: {
return { status: "ran", durationMs: Date.now() - startedAt };
}
const deliveryAccountId =
delivery.channel === lastChannel ? entry?.lastAccountId : undefined;
const deliveryAccountId = delivery.channel === lastChannel ? entry?.lastAccountId : undefined;
const heartbeatPlugin = getChannelPlugin(delivery.channel);
if (heartbeatPlugin?.heartbeat?.checkReady) {
const readiness = await heartbeatPlugin.heartbeat.checkReady({

View File

@@ -3,9 +3,7 @@ export type HeartbeatRunResult =
| { status: "skipped"; reason: string }
| { status: "failed"; reason: string };
export type HeartbeatWakeHandler = (opts: {
reason?: string;
}) => Promise<HeartbeatRunResult>;
export type HeartbeatWakeHandler = (opts: { reason?: string }) => Promise<HeartbeatRunResult>;
let handler: HeartbeatWakeHandler | null = null;
let pendingReason: string | null = null;
@@ -58,10 +56,7 @@ export function setHeartbeatWakeHandler(next: HeartbeatWakeHandler | null) {
}
}
export function requestHeartbeatNow(opts?: {
reason?: string;
coalesceMs?: number;
}) {
export function requestHeartbeatNow(opts?: { reason?: string; coalesceMs?: number }) {
pendingReason = opts?.reason ?? pendingReason ?? "requested";
schedule(opts?.coalesceMs ?? DEFAULT_COALESCE_MS);
}

View File

@@ -8,10 +8,7 @@ type IsMainModuleOptions = {
cwd?: string;
};
function normalizePathCandidate(
candidate: string | undefined,
cwd: string,
): string | undefined {
function normalizePathCandidate(candidate: string | undefined, cwd: string): string | undefined {
if (!candidate) return undefined;
const resolved = path.resolve(cwd, candidate);
@@ -31,22 +28,14 @@ export function isMainModule({
const normalizedCurrent = normalizePathCandidate(currentFile, cwd);
const normalizedArgv1 = normalizePathCandidate(argv[1], cwd);
if (
normalizedCurrent &&
normalizedArgv1 &&
normalizedCurrent === normalizedArgv1
) {
if (normalizedCurrent && normalizedArgv1 && normalizedCurrent === normalizedArgv1) {
return true;
}
// PM2 runs the script via an internal wrapper; `argv[1]` points at the wrapper.
// PM2 exposes the actual script path in `pm_exec_path`.
const normalizedPmExecPath = normalizePathCandidate(env.pm_exec_path, cwd);
if (
normalizedCurrent &&
normalizedPmExecPath &&
normalizedCurrent === normalizedPmExecPath
) {
if (normalizedCurrent && normalizedPmExecPath && normalizedCurrent === normalizedPmExecPath) {
return true;
}

View File

@@ -131,9 +131,7 @@ function newToken() {
return randomUUID().replaceAll("-", "");
}
export async function listNodePairing(
baseDir?: string,
): Promise<NodePairingList> {
export async function listNodePairing(baseDir?: string): Promise<NodePairingList> {
const state = await loadState(baseDir);
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
const paired = Object.values(state.pairedByNodeId).sort(
@@ -165,9 +163,7 @@ export async function requestNodePairing(
throw new Error("nodeId required");
}
const existing = Object.values(state.pendingById).find(
(p) => p.nodeId === nodeId,
);
const existing = Object.values(state.pendingById).find((p) => p.nodeId === nodeId);
if (existing) {
return { status: "pending", request: existing, created: false };
}
@@ -257,12 +253,7 @@ export async function verifyNodeToken(
export async function updatePairedNodeMetadata(
nodeId: string,
patch: Partial<
Omit<
NodePairingPairedNode,
"nodeId" | "token" | "createdAtMs" | "approvedAtMs"
>
>,
patch: Partial<Omit<NodePairingPairedNode, "nodeId" | "token" | "createdAtMs" | "approvedAtMs">>,
baseDir?: string,
) {
await withLock(async () => {

View File

@@ -21,10 +21,7 @@ function isAccountEnabled(account: unknown): boolean {
return enabled !== false;
}
async function isPluginConfigured(
plugin: ChannelPlugin,
cfg: ClawdbotConfig,
): Promise<boolean> {
async function isPluginConfigured(plugin: ChannelPlugin, cfg: ClawdbotConfig): Promise<boolean> {
const accountIds = plugin.config.listAccountIds(cfg);
if (accountIds.length === 0) return false;
@@ -78,8 +75,6 @@ export async function resolveMessageChannelSelection(params: {
throw new Error("Channel is required (no configured channels detected).");
}
throw new Error(
`Channel is required when multiple channels are configured: ${configured.join(
", ",
)}`,
`Channel is required when multiple channels are configured: ${configured.join(", ")}`,
);
}

View File

@@ -1,16 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import {
deliverOutboundPayloads,
normalizeOutboundPayloads,
} from "./deliver.js";
import { deliverOutboundPayloads, normalizeOutboundPayloads } from "./deliver.js";
describe("deliverOutboundPayloads", () => {
it("chunks telegram markdown and passes through accountId", async () => {
const sendTelegram = vi
.fn()
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: ClawdbotConfig = {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
@@ -27,9 +22,7 @@ describe("deliverOutboundPayloads", () => {
expect(sendTelegram).toHaveBeenCalledTimes(2);
for (const call of sendTelegram.mock.calls) {
expect(call[2]).toEqual(
expect.objectContaining({ accountId: undefined, verbose: false }),
);
expect(call[2]).toEqual(expect.objectContaining({ accountId: undefined, verbose: false }));
}
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({ channel: "telegram", chatId: "c1" });
@@ -43,9 +36,7 @@ describe("deliverOutboundPayloads", () => {
});
it("passes explicit accountId to sendTelegram", async () => {
const sendTelegram = vi
.fn()
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: ClawdbotConfig = {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
@@ -67,9 +58,7 @@ describe("deliverOutboundPayloads", () => {
});
it("uses signal media maxBytes from config", async () => {
const sendSignal = vi
.fn()
.mockResolvedValue({ messageId: "s1", timestamp: 123 });
const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 });
const cfg: ClawdbotConfig = { channels: { signal: { mediaMaxMb: 2 } } };
const results = await deliverOutboundPayloads({
@@ -166,8 +155,6 @@ describe("deliverOutboundPayloads", () => {
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledTimes(1);
expect(results).toEqual([
{ channel: "whatsapp", messageId: "w2", toJid: "jid" },
]);
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
});
});

View File

@@ -49,10 +49,7 @@ type ChannelHandler = {
chunker: Chunker | null;
textChunkLimit?: number;
sendText: (text: string) => Promise<OutboundDeliveryResult>;
sendMedia: (
caption: string,
mediaUrl: string,
) => Promise<OutboundDeliveryResult>;
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
};
function throwIfAborted(abortSignal?: AbortSignal): void {

View File

@@ -1,9 +1,6 @@
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OutboundDeliveryJson } from "./format.js";
import {
normalizeOutboundPayloadsForJson,
type OutboundPayloadJson,
} from "./payloads.js";
import { normalizeOutboundPayloadsForJson, type OutboundPayloadJson } from "./payloads.js";
export type OutboundResultEnvelope = {
payloads?: OutboundPayloadJson[];
@@ -35,12 +32,7 @@ export function buildOutboundResultEnvelope(
? (params.payloads as OutboundPayloadJson[])
: normalizeOutboundPayloadsForJson(params.payloads as ReplyPayload[]);
if (
params.flattenDelivery !== false &&
params.delivery &&
!params.meta &&
!hasPayloads
) {
if (params.flattenDelivery !== false && params.delivery && !params.meta && !hasPayloads) {
return params.delivery;
}

View File

@@ -42,8 +42,7 @@ export function formatOutboundDeliverySummary(
if ("chatId" in result) return `${base} (chat ${result.chatId})`;
if ("channelId" in result) return `${base} (channel ${result.channelId})`;
if ("conversationId" in result)
return `${base} (conversation ${result.conversationId})`;
if ("conversationId" in result) return `${base} (conversation ${result.conversationId})`;
return base;
}
@@ -70,11 +69,7 @@ export function buildOutboundDeliveryJson(params: {
if (result && "channelId" in result && result.channelId !== undefined) {
payload.channelId = result.channelId;
}
if (
result &&
"conversationId" in result &&
result.conversationId !== undefined
) {
if (result && "conversationId" in result && result.conversationId !== undefined) {
payload.conversationId = result.conversationId;
}
if (result && "timestamp" in result && result.timestamp !== undefined) {

View File

@@ -13,10 +13,7 @@ import type {
ChannelThreadingToolContext,
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type {
GatewayClientMode,
GatewayClientName,
} from "../../utils/message-channel.js";
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
import { resolveMessageChannelSelection } from "./channel-selection.js";
import type { OutboundSendDeps } from "./deliver.js";
import type { MessagePollResult, MessageSendResult } from "./message.js";
@@ -97,10 +94,7 @@ function extractToolPayload(result: AgentToolResult<unknown>): unknown {
return result.content ?? result;
}
function readBooleanParam(
params: Record<string, unknown>,
key: string,
): boolean | undefined {
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
const raw = params[key];
if (typeof raw === "boolean") return raw;
if (typeof raw === "string") {
@@ -141,9 +135,7 @@ function resolveContextGuardTarget(
if (!CONTEXT_GUARDED_ACTIONS.has(action)) return undefined;
if (action === "thread-reply" || action === "thread-create") {
return (
readStringParam(params, "channelId") ?? readStringParam(params, "to")
);
return readStringParam(params, "channelId") ?? readStringParam(params, "to");
}
return readStringParam(params, "to") ?? readStringParam(params, "channelId");
@@ -165,8 +157,7 @@ function enforceContextIsolation(params: {
const normalizedTarget =
normalizeTargetForProvider(params.channel, target) ?? target.toLowerCase();
const normalizedCurrent =
normalizeTargetForProvider(params.channel, currentTarget) ??
currentTarget.toLowerCase();
normalizeTargetForProvider(params.channel, currentTarget) ?? currentTarget.toLowerCase();
if (!normalizedTarget || !normalizedCurrent) return;
if (normalizedTarget === normalizedCurrent) return;
@@ -176,10 +167,7 @@ function enforceContextIsolation(params: {
);
}
async function resolveChannel(
cfg: ClawdbotConfig,
params: Record<string, unknown>,
) {
async function resolveChannel(cfg: ClawdbotConfig, params: Record<string, unknown>) {
const channelHint = readStringParam(params, "channel");
const selection = await resolveMessageChannelSelection({
cfg,
@@ -197,8 +185,7 @@ export async function runMessageAction(
const action = input.action;
const channel = await resolveChannel(cfg, params);
const accountId =
readStringParam(params, "accountId") ?? input.defaultAccountId;
const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
enforceContextIsolation({
@@ -294,8 +281,7 @@ export async function runMessageAction(
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const options =
readStringArrayParam(params, "pollOption", { required: true }) ?? [];
const options = readStringArrayParam(params, "pollOption", { required: true }) ?? [];
if (options.length < 2) {
throw new Error("pollOption requires at least two values");
}
@@ -376,9 +362,7 @@ export async function runMessageAction(
dryRun,
});
if (!handled) {
throw new Error(
`Message action ${action} not supported for channel ${channel}.`,
);
throw new Error(`Message action ${action} not supported for channel ${channel}.`);
}
return {
kind: "action",

View File

@@ -26,10 +26,7 @@ describe("sendMessage channel normalization", () => {
deps: { sendMSTeams },
});
expect(sendMSTeams).toHaveBeenCalledWith(
"conversation:19:abc@thread.tacv2",
"hi",
);
expect(sendMSTeams).toHaveBeenCalledWith("conversation:19:abc@thread.tacv2", "hi");
expect(result.channel).toBe("msteams");
});
@@ -43,11 +40,7 @@ describe("sendMessage channel normalization", () => {
deps: { sendIMessage },
});
expect(sendIMessage).toHaveBeenCalledWith(
"someone@example.com",
"hi",
expect.any(Object),
);
expect(sendIMessage).toHaveBeenCalledWith("someone@example.com", "hi", expect.any(Object));
expect(result.channel).toBe("imessage");
});
});

View File

@@ -1,7 +1,4 @@
import {
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
@@ -101,9 +98,7 @@ function resolveGatewayOptions(opts?: MessageGatewayOptions) {
};
}
export async function sendMessage(
params: MessageSendParams,
): Promise<MessageSendResult> {
export async function sendMessage(params: MessageSendParams): Promise<MessageSendResult> {
const cfg = params.cfg ?? loadConfig();
const channel = params.channel?.trim()
? normalizeChannelId(params.channel)
@@ -187,9 +182,7 @@ export async function sendMessage(
};
}
export async function sendPoll(
params: MessagePollParams,
): Promise<MessagePollResult> {
export async function sendPoll(params: MessagePollParams): Promise<MessagePollResult> {
const cfg = params.cfg ?? loadConfig();
const channel = params.channel?.trim()
? normalizeChannelId(params.channel)

View File

@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
formatOutboundPayloadLog,
normalizeOutboundPayloadsForJson,
} from "./payloads.js";
import { formatOutboundPayloadLog, normalizeOutboundPayloadsForJson } from "./payloads.js";
describe("normalizeOutboundPayloadsForJson", () => {
it("normalizes payloads with mediaUrl and mediaUrls", () => {

View File

@@ -11,32 +11,24 @@ export type OutboundPayloadJson = {
mediaUrls?: string[];
};
export function normalizeOutboundPayloads(
payloads: ReplyPayload[],
): NormalizedOutboundPayload[] {
export function normalizeOutboundPayloads(payloads: ReplyPayload[]): NormalizedOutboundPayload[] {
return payloads
.map((payload) => ({
text: payload.text ?? "",
mediaUrls:
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
}))
.filter((payload) => payload.text || payload.mediaUrls.length > 0);
}
export function normalizeOutboundPayloadsForJson(
payloads: ReplyPayload[],
): OutboundPayloadJson[] {
export function normalizeOutboundPayloadsForJson(payloads: ReplyPayload[]): OutboundPayloadJson[] {
return payloads.map((payload) => ({
text: payload.text ?? "",
mediaUrl: payload.mediaUrl ?? null,
mediaUrls:
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
}));
}
export function formatOutboundPayloadLog(
payload: NormalizedOutboundPayload,
): string {
export function formatOutboundPayloadLog(payload: NormalizedOutboundPayload): string {
const lines: string[] = [];
if (payload.text) lines.push(payload.text.trimEnd());
for (const url of payload.mediaUrls) lines.push(`MEDIA:${url}`);

View File

@@ -1,11 +1,5 @@
import {
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import type {
ChannelId,
ChannelOutboundTargetMode,
} from "../../channels/plugins/types.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ChannelId, ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import type {
@@ -24,9 +18,7 @@ export type OutboundTarget = {
reason?: string;
};
export type OutboundTargetResolution =
| { ok: true; to: string }
| { ok: false; error: Error };
export type OutboundTargetResolution = { ok: true; to: string } | { ok: false; error: Error };
// Channel docking: prefer plugin.outbound.resolveTarget + allowFrom to normalize destinations.
export function resolveOutboundTarget(params: {
@@ -151,7 +143,5 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
}
return reason
? { channel, to: resolved.to, reason }
: { channel, to: resolved.to };
return reason ? { channel, to: resolved.to, reason } : { channel, to: resolved.to };
}

View File

@@ -31,8 +31,7 @@ describe("ensureClawdbotCliOnPath", () => {
expect(updated.split(path.delimiter)[0]).toBe(relayDir);
} finally {
process.env.PATH = originalPath;
if (originalFlag === undefined)
delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
if (originalFlag === undefined) delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
else process.env.CLAWDBOT_PATH_BOOTSTRAPPED = originalFlag;
}
} finally {
@@ -55,8 +54,7 @@ describe("ensureClawdbotCliOnPath", () => {
expect(process.env.PATH).toBe("/bin");
} finally {
process.env.PATH = originalPath;
if (originalFlag === undefined)
delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
if (originalFlag === undefined) delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
else process.env.CLAWDBOT_PATH_BOOTSTRAPPED = originalFlag;
}
});
@@ -103,8 +101,7 @@ describe("ensureClawdbotCliOnPath", () => {
expect(shimsIndex).toBeGreaterThan(localIndex);
} finally {
process.env.PATH = originalPath;
if (originalFlag === undefined)
delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
if (originalFlag === undefined) delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
else process.env.CLAWDBOT_PATH_BOOTSTRAPPED = originalFlag;
if (originalMiseDataDir === undefined) delete process.env.MISE_DATA_DIR;
else process.env.MISE_DATA_DIR = originalMiseDataDir;
@@ -147,14 +144,11 @@ describe("ensureClawdbotCliOnPath", () => {
expect(parts[1]).toBe(linuxbrewSbin);
} finally {
process.env.PATH = originalPath;
if (originalFlag === undefined)
delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
if (originalFlag === undefined) delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
else process.env.CLAWDBOT_PATH_BOOTSTRAPPED = originalFlag;
if (originalHomebrewPrefix === undefined)
delete process.env.HOMEBREW_PREFIX;
if (originalHomebrewPrefix === undefined) delete process.env.HOMEBREW_PREFIX;
else process.env.HOMEBREW_PREFIX = originalHomebrewPrefix;
if (originalHomebrewBrewFile === undefined)
delete process.env.HOMEBREW_BREW_FILE;
if (originalHomebrewBrewFile === undefined) delete process.env.HOMEBREW_BREW_FILE;
else process.env.HOMEBREW_BREW_FILE = originalHomebrewBrewFile;
if (originalXdgBinHome === undefined) delete process.env.XDG_BIN_HOME;
else process.env.XDG_BIN_HOME = originalXdgBinHome;

View File

@@ -34,9 +34,7 @@ function mergePath(params: { existing: string; prepend: string[] }): string {
.split(path.delimiter)
.map((part) => part.trim())
.filter(Boolean);
const partsPrepend = params.prepend
.map((part) => part.trim())
.filter(Boolean);
const partsPrepend = params.prepend.map((part) => part.trim()).filter(Boolean);
const seen = new Set<string>();
const merged: string[] = [];
@@ -69,11 +67,9 @@ function candidateBinDirs(opts: EnsureClawdbotPathOpts): string[] {
// Project-local installs (best effort): if a `node_modules/.bin/clawdbot` exists near cwd,
// include it. This helps when running under launchd or other minimal PATH environments.
const localBinDir = path.join(cwd, "node_modules", ".bin");
if (isExecutable(path.join(localBinDir, "clawdbot")))
candidates.push(localBinDir);
if (isExecutable(path.join(localBinDir, "clawdbot"))) candidates.push(localBinDir);
const miseDataDir =
process.env.MISE_DATA_DIR ?? path.join(homeDir, ".local", "share", "mise");
const miseDataDir = process.env.MISE_DATA_DIR ?? path.join(homeDir, ".local", "share", "mise");
const miseShims = path.join(miseDataDir, "shims");
if (isDirectory(miseShims)) candidates.push(miseShims);

View File

@@ -1,16 +1,7 @@
import type {
PortListener,
PortListenerKind,
PortUsage,
} from "./ports-types.js";
import type { PortListener, PortListenerKind, PortUsage } from "./ports-types.js";
export function classifyPortListener(
listener: PortListener,
port: number,
): PortListenerKind {
const raw = `${listener.commandLine ?? ""} ${listener.command ?? ""}`
.trim()
.toLowerCase();
export function classifyPortListener(listener: PortListener, port: number): PortListenerKind {
const raw = `${listener.commandLine ?? ""} ${listener.command ?? ""}`.trim().toLowerCase();
if (raw.includes("clawdbot") || raw.includes("clawdis")) return "gateway";
if (raw.includes("ssh")) {
const portToken = String(port);
@@ -23,14 +14,9 @@ export function classifyPortListener(
return "unknown";
}
export function buildPortHints(
listeners: PortListener[],
port: number,
): string[] {
export function buildPortHints(listeners: PortListener[], port: number): string[] {
if (listeners.length === 0) return [];
const kinds = new Set(
listeners.map((listener) => classifyPortListener(listener, port)),
);
const kinds = new Set(listeners.map((listener) => classifyPortListener(listener, port)));
const hints: string[] = [];
if (kinds.has("gateway")) {
hints.push(

View File

@@ -1,11 +1,7 @@
import net from "node:net";
import { runCommandWithTimeout } from "../process/exec.js";
import { buildPortHints } from "./ports-format.js";
import type {
PortListener,
PortUsage,
PortUsageStatus,
} from "./ports-types.js";
import type { PortListener, PortUsage, PortUsageStatus } from "./ports-types.js";
type CommandResult = {
stdout: string;
@@ -18,10 +14,7 @@ function isErrno(err: unknown): err is NodeJS.ErrnoException {
return Boolean(err && typeof err === "object" && "code" in err);
}
async function runCommandSafe(
argv: string[],
timeoutMs = 5_000,
): Promise<CommandResult> {
async function runCommandSafe(argv: string[], timeoutMs = 5_000): Promise<CommandResult> {
try {
const res = await runCommandWithTimeout(argv, { timeoutMs });
return {
@@ -60,9 +53,7 @@ function parseLsofFieldOutput(output: string): PortListener[] {
return listeners;
}
async function resolveUnixCommandLine(
pid: number,
): Promise<string | undefined> {
async function resolveUnixCommandLine(pid: number): Promise<string | undefined> {
const res = await runCommandSafe(["ps", "-p", String(pid), "-o", "command="]);
if (res.code !== 0) return undefined;
const line = res.stdout.trim();
@@ -80,13 +71,7 @@ async function readUnixListeners(
port: number,
): Promise<{ listeners: PortListener[]; detail?: string; errors: string[] }> {
const errors: string[] = [];
const res = await runCommandSafe([
"lsof",
"-nP",
`-iTCP:${port}`,
"-sTCP:LISTEN",
"-FpFcn",
]);
const res = await runCommandSafe(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFcn"]);
if (res.code === 0) {
const listeners = parseLsofFieldOutput(res.stdout);
await Promise.all(
@@ -106,9 +91,7 @@ async function readUnixListeners(
return { listeners: [], detail: undefined, errors };
}
if (res.error) errors.push(res.error);
const detail = [res.stderr.trim(), res.stdout.trim()]
.filter(Boolean)
.join("\n");
const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n");
if (detail) errors.push(detail);
return { listeners: [], detail: undefined, errors };
}
@@ -134,16 +117,8 @@ function parseNetstatListeners(output: string, port: number): PortListener[] {
return listeners;
}
async function resolveWindowsImageName(
pid: number,
): Promise<string | undefined> {
const res = await runCommandSafe([
"tasklist",
"/FI",
`PID eq ${pid}`,
"/FO",
"LIST",
]);
async function resolveWindowsImageName(pid: number): Promise<string | undefined> {
const res = await runCommandSafe(["tasklist", "/FI", `PID eq ${pid}`, "/FO", "LIST"]);
if (res.code !== 0) return undefined;
for (const rawLine of res.stdout.split(/\r?\n/)) {
const line = rawLine.trim();
@@ -154,9 +129,7 @@ async function resolveWindowsImageName(
return undefined;
}
async function resolveWindowsCommandLine(
pid: number,
): Promise<string | undefined> {
async function resolveWindowsCommandLine(pid: number): Promise<string | undefined> {
const res = await runCommandSafe([
"wmic",
"process",
@@ -183,9 +156,7 @@ async function readWindowsListeners(
const res = await runCommandSafe(["netstat", "-ano", "-p", "tcp"]);
if (res.code !== 0) {
if (res.error) errors.push(res.error);
const detail = [res.stderr.trim(), res.stdout.trim()]
.filter(Boolean)
.join("\n");
const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n");
if (detail) errors.push(detail);
return { listeners: [], errors };
}
@@ -225,9 +196,7 @@ async function checkPortInUse(port: number): Promise<PortUsageStatus> {
export async function inspectPortUsage(port: number): Promise<PortUsage> {
const errors: string[] = [];
const result =
process.platform === "win32"
? await readWindowsListeners(port)
: await readUnixListeners(port);
process.platform === "win32" ? await readWindowsListeners(port) : await readUnixListeners(port);
errors.push(...result.errors);
let listeners = result.listeners;
let status: PortUsageStatus = listeners.length > 0 ? "busy" : "unknown";

View File

@@ -15,9 +15,7 @@ describe("ports helpers", () => {
const server = net.createServer();
await new Promise((resolve) => server.listen(0, resolve));
const port = (server.address() as net.AddressInfo).port;
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
PortInUseError,
);
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(PortInUseError);
server.close();
});
@@ -27,22 +25,14 @@ describe("ports helpers", () => {
log: vi.fn(),
exit: vi.fn() as unknown as (code: number) => never,
};
await handlePortError(
{ code: "EADDRINUSE" },
1234,
"context",
runtime,
).catch(() => {});
await handlePortError({ code: "EADDRINUSE" }, 1234, "context", runtime).catch(() => {});
expect(runtime.error).toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("classifies ssh and gateway listeners", () => {
expect(
classifyPortListener(
{ commandLine: "ssh -N -L 18789:127.0.0.1:18789 user@host" },
18789,
),
classifyPortListener({ commandLine: "ssh -N -L 18789:127.0.0.1:18789 user@host" }, 18789),
).toBe("ssh");
expect(
classifyPortListener(
@@ -59,10 +49,7 @@ describe("ports helpers", () => {
port: 18789,
status: "busy" as const,
listeners: [{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }],
hints: buildPortHints(
[{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }],
18789,
),
hints: buildPortHints([{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }], 18789),
};
const lines = formatPortDiagnostics(diagnostics);
expect(lines[0]).toContain("Port 18789 is already in use");

View File

@@ -5,12 +5,7 @@ import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { formatPortDiagnostics } from "./ports-format.js";
import { inspectPortUsage } from "./ports-inspect.js";
import type {
PortListener,
PortListenerKind,
PortUsage,
PortUsageStatus,
} from "./ports-types.js";
import type { PortListener, PortListenerKind, PortUsage, PortUsageStatus } from "./ports-types.js";
class PortInUseError extends Error {
port: number;
@@ -28,9 +23,7 @@ function isErrno(err: unknown): err is NodeJS.ErrnoException {
return Boolean(err && typeof err === "object" && "code" in err);
}
export async function describePortOwner(
port: number,
): Promise<string | undefined> {
export async function describePortOwner(port: number): Promise<string | undefined> {
const diagnostics = await inspectPortUsage(port);
if (diagnostics.listeners.length === 0) return undefined;
return formatPortDiagnostics(diagnostics).join("\n");
@@ -64,14 +57,8 @@ export async function handlePortError(
runtime: RuntimeEnv = defaultRuntime,
): Promise<never> {
// Uniform messaging for EADDRINUSE with optional owner details.
if (
err instanceof PortInUseError ||
(isErrno(err) && err.code === "EADDRINUSE")
) {
const details =
err instanceof PortInUseError
? err.details
: await describePortOwner(port);
if (err instanceof PortInUseError || (isErrno(err) && err.code === "EADDRINUSE")) {
const details = err instanceof PortInUseError ? err.details : await describePortOwner(port);
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
if (details) {
runtime.error(info("Port listener details:"));
@@ -85,9 +72,7 @@ export async function handlePortError(
}
}
runtime.error(
info(
"Resolve by stopping the process using the port or passing --port <free-port>.",
),
info("Resolve by stopping the process using the port or passing --port <free-port>."),
);
runtime.exit(1);
}
@@ -103,9 +88,5 @@ export async function handlePortError(
export { PortInUseError };
export type { PortListener, PortListenerKind, PortUsage, PortUsageStatus };
export {
buildPortHints,
classifyPortListener,
formatPortDiagnostics,
} from "./ports-format.js";
export { buildPortHints, classifyPortListener, formatPortDiagnostics } from "./ports-format.js";
export { inspectPortUsage } from "./ports-inspect.js";

View File

@@ -9,10 +9,7 @@ import {
resolveApiKeyForProfile,
resolveAuthProfileOrder,
} from "../agents/auth-profiles.js";
import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../agents/model-auth.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { loadConfig } from "../config/config.js";
import type { UsageProviderId } from "./provider-usage.types.js";
@@ -37,16 +34,14 @@ function parseGoogleToken(apiKey: string): { token: string } | null {
}
function resolveZaiApiKey(): string | undefined {
const envDirect =
process.env.ZAI_API_KEY?.trim() || process.env.Z_AI_API_KEY?.trim();
const envDirect = process.env.ZAI_API_KEY?.trim() || process.env.Z_AI_API_KEY?.trim();
if (envDirect) return envDirect;
const envResolved = resolveEnvApiKey("zai");
if (envResolved?.apiKey) return envResolved.apiKey;
const cfg = loadConfig();
const key =
getCustomProviderApiKey(cfg, "zai") || getCustomProviderApiKey(cfg, "z-ai");
const key = getCustomProviderApiKey(cfg, "zai") || getCustomProviderApiKey(cfg, "z-ai");
if (key) return key;
const store = ensureAuthProfileStore();
@@ -76,8 +71,7 @@ function resolveZaiApiKey(): string | undefined {
function resolveMinimaxApiKey(): string | undefined {
const envDirect =
process.env.MINIMAX_CODE_PLAN_KEY?.trim() ||
process.env.MINIMAX_API_KEY?.trim();
process.env.MINIMAX_CODE_PLAN_KEY?.trim() || process.env.MINIMAX_API_KEY?.trim();
if (envDirect) return envDirect;
const envResolved = resolveEnvApiKey("minimax");
@@ -119,8 +113,7 @@ async function resolveOAuthToken(params: {
// Claude CLI creds are the only Anthropic tokens that reliably include the
// `user:profile` scope required for the OAuth usage endpoint.
const candidates =
params.provider === "anthropic" ? [CLAUDE_CLI_PROFILE_ID, ...order] : order;
const candidates = params.provider === "anthropic" ? [CLAUDE_CLI_PROFILE_ID, ...order] : order;
const deduped: string[] = [];
for (const entry of candidates) {
if (!deduped.includes(entry)) deduped.push(entry);
@@ -140,10 +133,7 @@ async function resolveOAuthToken(params: {
});
if (!resolved?.apiKey) continue;
let token = resolved.apiKey;
if (
params.provider === "google-gemini-cli" ||
params.provider === "google-antigravity"
) {
if (params.provider === "google-gemini-cli" || params.provider === "google-antigravity") {
const parsed = parseGoogleToken(resolved.apiKey);
token = parsed?.token ?? resolved.apiKey;
}
@@ -180,15 +170,11 @@ function resolveOAuthProviders(agentDir?: string): UsageProviderId[] {
return cred?.type === "oauth" || cred?.type === "token";
};
return providers.filter((provider) => {
const profiles = listProfilesForProvider(store, provider).filter(
isOAuthLikeCredential,
);
const profiles = listProfilesForProvider(store, provider).filter(isOAuthLikeCredential);
if (profiles.length > 0) return true;
const normalized = normalizeProviderId(provider);
const configuredProfiles = Object.entries(cfg.auth?.profiles ?? {})
.filter(
([, profile]) => normalizeProviderId(profile.provider) === normalized,
)
.filter(([, profile]) => normalizeProviderId(profile.provider) === normalized)
.map(([id]) => id)
.filter(isOAuthLikeCredential);
return configuredProfiles.length > 0;

View File

@@ -1,9 +1,6 @@
import { fetchJson } from "./provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type {
ProviderUsageSnapshot,
UsageWindow,
} from "./provider-usage.types.js";
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
type ClaudeUsageResponse = {
five_hour?: { utilization?: number; resets_at?: string };
@@ -21,8 +18,7 @@ type ClaudeWebUsageResponse = ClaudeUsageResponse;
function resolveClaudeWebSessionKey(): string | undefined {
const direct =
process.env.CLAUDE_AI_SESSION_KEY?.trim() ??
process.env.CLAUDE_WEB_SESSION_KEY?.trim();
process.env.CLAUDE_AI_SESSION_KEY?.trim() ?? process.env.CLAUDE_WEB_SESSION_KEY?.trim();
if (direct?.startsWith("sk-ant-")) return direct;
const cookieHeader = process.env.CLAUDE_WEB_COOKIE?.trim();
@@ -70,9 +66,7 @@ async function fetchClaudeWebUsage(
windows.push({
label: "5h",
usedPercent: clampPercent(data.five_hour.utilization),
resetAt: data.five_hour.resets_at
? new Date(data.five_hour.resets_at).getTime()
: undefined,
resetAt: data.five_hour.resets_at ? new Date(data.five_hour.resets_at).getTime() : undefined,
});
}
@@ -80,9 +74,7 @@ async function fetchClaudeWebUsage(
windows.push({
label: "Week",
usedPercent: clampPercent(data.seven_day.utilization),
resetAt: data.seven_day.resets_at
? new Date(data.seven_day.resets_at).getTime()
: undefined,
resetAt: data.seven_day.resets_at ? new Date(data.seven_day.resets_at).getTime() : undefined,
});
}
@@ -137,10 +129,7 @@ export async function fetchClaudeUsage(
// Claude CLI setup-token yields tokens that can be used for inference, but may not
// include user:profile scope required by the OAuth usage endpoint. When a claude.ai
// browser sessionKey is available, fall back to the web API.
if (
res.status === 403 &&
message?.includes("scope requirement user:profile")
) {
if (res.status === 403 && message?.includes("scope requirement user:profile")) {
const sessionKey = resolveClaudeWebSessionKey();
if (sessionKey) {
const web = await fetchClaudeWebUsage(sessionKey, timeoutMs, fetchFn);
@@ -164,9 +153,7 @@ export async function fetchClaudeUsage(
windows.push({
label: "5h",
usedPercent: clampPercent(data.five_hour.utilization),
resetAt: data.five_hour.resets_at
? new Date(data.five_hour.resets_at).getTime()
: undefined,
resetAt: data.five_hour.resets_at ? new Date(data.five_hour.resets_at).getTime() : undefined,
});
}
@@ -174,9 +161,7 @@ export async function fetchClaudeUsage(
windows.push({
label: "Week",
usedPercent: clampPercent(data.seven_day.utilization),
resetAt: data.seven_day.resets_at
? new Date(data.seven_day.resets_at).getTime()
: undefined,
resetAt: data.seven_day.resets_at ? new Date(data.seven_day.resets_at).getTime() : undefined,
});
}

View File

@@ -1,9 +1,6 @@
import { fetchJson } from "./provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type {
ProviderUsageSnapshot,
UsageWindow,
} from "./provider-usage.types.js";
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
type CodexUsageResponse = {
rate_limit?: {

View File

@@ -1,9 +1,6 @@
import { fetchJson } from "./provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type {
ProviderUsageSnapshot,
UsageWindow,
} from "./provider-usage.types.js";
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
type CopilotUsageResponse = {
quota_snapshots?: {
@@ -45,8 +42,7 @@ export async function fetchCopilotUsage(
const windows: UsageWindow[] = [];
if (data.quota_snapshots?.premium_interactions) {
const remaining =
data.quota_snapshots.premium_interactions.percent_remaining;
const remaining = data.quota_snapshots.premium_interactions.percent_remaining;
windows.push({
label: "Premium",
usedPercent: clampPercent(100 - (remaining ?? 0)),

View File

@@ -1,9 +1,6 @@
import { fetchJson } from "./provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type {
ProviderUsageSnapshot,
UsageWindow,
} from "./provider-usage.types.js";
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
type MinimaxBaseResp = {
status_code?: number;
@@ -115,10 +112,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function pickNumber(
record: Record<string, unknown>,
keys: readonly string[],
): number | undefined {
function pickNumber(record: Record<string, unknown>, keys: readonly string[]): number | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -130,10 +124,7 @@ function pickNumber(
return undefined;
}
function pickString(
record: Record<string, unknown>,
keys: readonly string[],
): string | undefined {
function pickString(record: Record<string, unknown>, keys: readonly string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value.trim();
@@ -217,14 +208,8 @@ export async function fetchMinimaxUsage(
};
}
const baseResp = isRecord(data.base_resp)
? (data.base_resp as MinimaxBaseResp)
: undefined;
if (
baseResp &&
typeof baseResp.status_code === "number" &&
baseResp.status_code !== 0
) {
const baseResp = isRecord(data.base_resp) ? (data.base_resp as MinimaxBaseResp) : undefined;
if (baseResp && typeof baseResp.status_code === "number" && baseResp.status_code !== 0) {
return {
provider: "minimax",
displayName: PROVIDER_LABELS.minimax,
@@ -245,8 +230,7 @@ export async function fetchMinimaxUsage(
}
const resetAt =
parseEpoch(pickString(payload, RESET_KEYS)) ??
parseEpoch(pickNumber(payload, RESET_KEYS));
parseEpoch(pickString(payload, RESET_KEYS)) ?? parseEpoch(pickNumber(payload, RESET_KEYS));
const windows: UsageWindow[] = [
{
label: deriveWindowLabel(payload),

View File

@@ -1,9 +1,6 @@
import { fetchJson } from "./provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type {
ProviderUsageSnapshot,
UsageWindow,
} from "./provider-usage.types.js";
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
type ZaiUsageResponse = {
success?: boolean;
@@ -64,9 +61,7 @@ export async function fetchZaiUsage(
for (const limit of limits) {
const percent = clampPercent(limit.percentage || 0);
const nextReset = limit.nextResetTime
? new Date(limit.nextResetTime).getTime()
: undefined;
const nextReset = limit.nextResetTime ? new Date(limit.nextResetTime).getTime() : undefined;
let windowLabel = "Limit";
if (limit.unit === 1) windowLabel = `${limit.number}d`;
else if (limit.unit === 3) windowLabel = `${limit.number}h`;

View File

@@ -25,9 +25,7 @@ function formatResetRemaining(targetMs?: number, now?: number): string | null {
function pickPrimaryWindow(windows: UsageWindow[]): UsageWindow | undefined {
if (windows.length === 0) return undefined;
return windows.reduce((best, next) =>
next.usedPercent > best.usedPercent ? next : best,
);
return windows.reduce((best, next) => (next.usedPercent > best.usedPercent ? next : best));
}
function formatWindowShort(window: UsageWindow, now?: number): string {
@@ -58,10 +56,7 @@ export function formatUsageSummaryLine(
return `📊 Usage: ${parts.join(" · ")}`;
}
export function formatUsageReportLines(
summary: UsageSummary,
opts?: { now?: number },
): string[] {
export function formatUsageReportLines(summary: UsageSummary, opts?: { now?: number }): string[] {
if (summary.providers.length === 0) {
return ["Usage: no provider usage available."];
}
@@ -82,9 +77,7 @@ export function formatUsageReportLines(
const remaining = clampPercent(100 - window.usedPercent);
const reset = formatResetRemaining(window.resetAt, opts?.now);
const resetSuffix = reset ? ` · resets ${reset}` : "";
lines.push(
` ${window.label}: ${remaining.toFixed(0)}% left${resetSuffix}`,
);
lines.push(` ${window.label}: ${remaining.toFixed(0)}% left${resetSuffix}`);
}
}
return lines;

View File

@@ -1,7 +1,4 @@
import {
type ProviderAuth,
resolveProviderAuths,
} from "./provider-usage.auth.js";
import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js";
import {
fetchClaudeUsage,
fetchCodexUsage,
@@ -58,19 +55,9 @@ export async function loadProviderUsageSummary(
return await fetchCopilotUsage(auth.token, timeoutMs, fetchFn);
case "google-gemini-cli":
case "google-antigravity":
return await fetchGeminiUsage(
auth.token,
timeoutMs,
fetchFn,
auth.provider,
);
return await fetchGeminiUsage(auth.token, timeoutMs, fetchFn, auth.provider);
case "openai-codex":
return await fetchCodexUsage(
auth.token,
auth.accountId,
timeoutMs,
fetchFn,
);
return await fetchCodexUsage(auth.token, auth.accountId, timeoutMs, fetchFn);
case "minimax":
return await fetchMinimaxUsage(auth.token, timeoutMs, fetchFn);
case "zai":

View File

@@ -23,9 +23,7 @@ export const usageProviders: UsageProviderId[] = [
"zai",
];
export function resolveUsageProviderId(
provider?: string | null,
): UsageProviderId | undefined {
export function resolveUsageProviderId(provider?: string | null): UsageProviderId | undefined {
if (!provider) return undefined;
const normalized = normalizeProviderId(provider);
return usageProviders.includes(normalized as UsageProviderId)
@@ -44,11 +42,7 @@ export const ignoredErrors = new Set([
export const clampPercent = (value: number) =>
Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
export const withTimeout = async <T>(
work: Promise<T>,
ms: number,
fallback: T,
): Promise<T> => {
export const withTimeout = async <T>(work: Promise<T>, ms: number, fallback: T): Promise<T> => {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([

View File

@@ -2,10 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "../agents/auth-profiles.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js";
import {
formatUsageReportLines,
formatUsageSummaryLine,
@@ -76,58 +73,49 @@ describe("provider usage loading", () => {
it("loads usage snapshots with injected auth", async () => {
const makeResponse = (status: number, body: unknown): Response => {
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers =
typeof body === "string"
? undefined
: { "Content-Type": "application/json" };
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
async (input) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes("api.anthropic.com")) {
return makeResponse(200, {
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
});
}
if (url.includes("api.z.ai")) {
return makeResponse(200, {
success: true,
code: 200,
data: {
planName: "Pro",
limits: [
{
type: "TOKENS_LIMIT",
percentage: 25,
unit: 3,
number: 6,
nextResetTime: "2026-01-07T06:00:00Z",
},
],
},
});
}
if (url.includes("api.minimax.io/v1/coding_plan/remains")) {
return makeResponse(200, {
base_resp: { status_code: 0, status_msg: "ok" },
data: {
total: 200,
remain: 50,
reset_at: "2026-01-07T05:00:00Z",
plan_name: "Coding Plan",
},
});
}
return makeResponse(404, "not found");
},
);
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.anthropic.com")) {
return makeResponse(200, {
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
});
}
if (url.includes("api.z.ai")) {
return makeResponse(200, {
success: true,
code: 200,
data: {
planName: "Pro",
limits: [
{
type: "TOKENS_LIMIT",
percentage: 25,
unit: 3,
number: 6,
nextResetTime: "2026-01-07T06:00:00Z",
},
],
},
});
}
if (url.includes("api.minimax.io/v1/coding_plan/remains")) {
return makeResponse(200, {
base_resp: { status_code: 0, status_msg: "ok" },
data: {
total: 200,
remain: 50,
reset_at: "2026-01-07T05:00:00Z",
plan_name: "Coding Plan",
},
});
}
return makeResponse(404, "not found");
});
const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0),
@@ -182,42 +170,36 @@ describe("provider usage loading", () => {
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
expect(listProfilesForProvider(store, "anthropic")).toContain(
"anthropic:default",
);
expect(listProfilesForProvider(store, "anthropic")).toContain("anthropic:default");
const makeResponse = (status: number, body: unknown): Response => {
const payload =
typeof body === "string" ? body : JSON.stringify(body);
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers =
typeof body === "string"
? undefined
: { "Content-Type": "application/json" };
typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<
Parameters<typeof fetch>,
ReturnType<typeof fetch>
>(async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes("api.anthropic.com/api/oauth/usage")) {
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer token-1");
return makeResponse(200, {
five_hour: {
utilization: 20,
resets_at: "2026-01-07T01:00:00Z",
},
});
}
return makeResponse(404, "not found");
});
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes("api.anthropic.com/api/oauth/usage")) {
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer token-1");
return makeResponse(200, {
five_hour: {
utilization: 20,
resets_at: "2026-01-07T01:00:00Z",
},
});
}
return makeResponse(404, "not found");
},
);
const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0),
@@ -275,34 +257,30 @@ describe("provider usage loading", () => {
);
const makeResponse = (status: number, body: unknown): Response => {
const payload =
typeof body === "string" ? body : JSON.stringify(body);
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers =
typeof body === "string"
? undefined
: { "Content-Type": "application/json" };
typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<
Parameters<typeof fetch>,
ReturnType<typeof fetch>
>(async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes("api.anthropic.com/api/oauth/usage")) {
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer token-cli");
return makeResponse(200, {
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
});
}
return makeResponse(404, "not found");
});
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes("api.anthropic.com/api/oauth/usage")) {
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer token-cli");
return makeResponse(200, {
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
});
}
return makeResponse(404, "not found");
},
);
const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0),
@@ -327,29 +305,19 @@ describe("provider usage loading", () => {
const makeResponse = (status: number, body: unknown): Response => {
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers =
typeof body === "string"
? undefined
: { "Content-Type": "application/json" };
typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<
Parameters<typeof fetch>,
ReturnType<typeof fetch>
>(async (input) => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.anthropic.com/api/oauth/usage")) {
return makeResponse(403, {
type: "error",
error: {
type: "permission_error",
message:
"OAuth token does not meet scope requirement user:profile",
message: "OAuth token does not meet scope requirement user:profile",
},
});
}
@@ -378,8 +346,7 @@ describe("provider usage loading", () => {
expect(claude?.windows.some((w) => w.label === "5h")).toBe(true);
expect(claude?.windows.some((w) => w.label === "Week")).toBe(true);
} finally {
if (cookieSnapshot === undefined)
delete process.env.CLAUDE_AI_SESSION_KEY;
if (cookieSnapshot === undefined) delete process.env.CLAUDE_AI_SESSION_KEY;
else process.env.CLAUDE_AI_SESSION_KEY = cookieSnapshot;
}
});

View File

@@ -1,7 +1,4 @@
export {
formatUsageReportLines,
formatUsageSummaryLine,
} from "./provider-usage.format.js";
export { formatUsageReportLines, formatUsageSummaryLine } from "./provider-usage.format.js";
export { loadProviderUsageSummary } from "./provider-usage.load.js";
export { resolveUsageProviderId } from "./provider-usage.shared.js";
export type {

View File

@@ -44,12 +44,9 @@ export type RestartSentinel = {
const SENTINEL_FILENAME = "restart-sentinel.json";
export const DOCTOR_NONINTERACTIVE_HINT =
"Run: clawdbot doctor --non-interactive";
export const DOCTOR_NONINTERACTIVE_HINT = "Run: clawdbot doctor --non-interactive";
export function resolveRestartSentinelPath(
env: NodeJS.ProcessEnv = process.env,
): string {
export function resolveRestartSentinelPath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateDir(env), SENTINEL_FILENAME);
}
@@ -97,15 +94,11 @@ export async function consumeRestartSentinel(
return parsed;
}
export function formatRestartSentinelMessage(
payload: RestartSentinelPayload,
): string {
export function formatRestartSentinelMessage(payload: RestartSentinelPayload): string {
return `GatewayRestart:\n${JSON.stringify(payload, null, 2)}`;
}
export function summarizeRestartSentinel(
payload: RestartSentinelPayload,
): string {
export function summarizeRestartSentinel(payload: RestartSentinelPayload): string {
const kind = payload.kind;
const status = payload.status;
const mode = payload.stats?.mode ? ` (${payload.stats.mode})` : "";

View File

@@ -20,8 +20,7 @@ function formatSpawnDetail(result: {
stderr?: string | Buffer | null;
}): string {
const clean = (value: string | Buffer | null | undefined) => {
const text =
typeof value === "string" ? value : value ? value.toString() : "";
const text = typeof value === "string" ? value : value ? value.toString() : "";
return text.replace(/\s+/g, " ").trim();
};
if (result.error) {
@@ -94,8 +93,7 @@ export function triggerClawdbotRestart(): RestartAttempt {
const label =
process.env.CLAWDBOT_LAUNCHD_LABEL ||
resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE);
const uid =
typeof process.getuid === "function" ? process.getuid() : undefined;
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
const target = uid !== undefined ? `gui/${uid}/${label}` : label;
const args = ["kickstart", "-k", target];
tried.push(`launchctl ${args.join(" ")}`);

View File

@@ -3,10 +3,7 @@ import { RateLimitError } from "@buape/carbon";
import { formatErrorMessage } from "./errors.js";
import { type RetryConfig, resolveRetryConfig, retryAsync } from "./retry.js";
export type RetryRunner = <T>(
fn: () => Promise<T>,
label?: string,
) => Promise<T>;
export type RetryRunner = <T>(fn: () => Promise<T>, label?: string) => Promise<T>;
export const DISCORD_RETRY_DEFAULTS = {
attempts: 3,
@@ -22,8 +19,7 @@ export const TELEGRAM_RETRY_DEFAULTS = {
jitter: 0.1,
};
const TELEGRAM_RETRY_RE =
/429|timeout|connect|reset|closed|unavailable|temporarily/i;
const TELEGRAM_RETRY_RE = /429|timeout|connect|reset|closed|unavailable|temporarily/i;
function getTelegramRetryAfterMs(err: unknown): number | undefined {
if (!err || typeof err !== "object") return undefined;
@@ -39,16 +35,10 @@ function getTelegramRetryAfterMs(err: unknown): number | undefined {
parameters?: { retry_after?: unknown };
}
).parameters?.retry_after
: "error" in err &&
err.error &&
typeof err.error === "object" &&
"parameters" in err.error
? (err.error as { parameters?: { retry_after?: unknown } }).parameters
?.retry_after
: "error" in err && err.error && typeof err.error === "object" && "parameters" in err.error
? (err.error as { parameters?: { retry_after?: unknown } }).parameters?.retry_after
: undefined;
return typeof candidate === "number" && Number.isFinite(candidate)
? candidate * 1000
: undefined;
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate * 1000 : undefined;
}
export function createDiscordRetryRunner(params: {
@@ -65,8 +55,7 @@ export function createDiscordRetryRunner(params: {
...retryConfig,
label,
shouldRetry: (err) => err instanceof RateLimitError,
retryAfterMs: (err) =>
err instanceof RateLimitError ? err.retryAfter * 1000 : undefined,
retryAfterMs: (err) => (err instanceof RateLimitError ? err.retryAfter * 1000 : undefined),
onRetry: params.verbose
? (info) => {
const labelText = info.label ?? "request";

View File

@@ -11,10 +11,7 @@ describe("retryAsync", () => {
});
it("retries then succeeds", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("fail1"))
.mockResolvedValueOnce("ok");
const fn = vi.fn().mockRejectedValueOnce(new Error("fail1")).mockResolvedValueOnce("ok");
const result = await retryAsync(fn, 3, 1);
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
@@ -28,17 +25,12 @@ describe("retryAsync", () => {
it("stops when shouldRetry returns false", async () => {
const fn = vi.fn().mockRejectedValue(new Error("boom"));
await expect(
retryAsync(fn, { attempts: 3, shouldRetry: () => false }),
).rejects.toThrow("boom");
await expect(retryAsync(fn, { attempts: 3, shouldRetry: () => false })).rejects.toThrow("boom");
expect(fn).toHaveBeenCalledTimes(1);
});
it("calls onRetry before retrying", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce("ok");
const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce("ok");
const onRetry = vi.fn();
const res = await retryAsync(fn, {
attempts: 2,
@@ -47,25 +39,20 @@ describe("retryAsync", () => {
onRetry,
});
expect(res).toBe("ok");
expect(onRetry).toHaveBeenCalledWith(
expect.objectContaining({ attempt: 1, maxAttempts: 2 }),
);
expect(onRetry).toHaveBeenCalledWith(expect.objectContaining({ attempt: 1, maxAttempts: 2 }));
});
it("clamps attempts to at least 1", async () => {
const fn = vi.fn().mockRejectedValue(new Error("boom"));
await expect(
retryAsync(fn, { attempts: 0, minDelayMs: 0, maxDelayMs: 0 }),
).rejects.toThrow("boom");
await expect(retryAsync(fn, { attempts: 0, minDelayMs: 0, maxDelayMs: 0 })).rejects.toThrow(
"boom",
);
expect(fn).toHaveBeenCalledTimes(1);
});
it("uses retryAfterMs when provided", async () => {
vi.useFakeTimers();
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce("ok");
const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce("ok");
const delays: number[] = [];
const promise = retryAsync(fn, {
attempts: 2,
@@ -83,10 +70,7 @@ describe("retryAsync", () => {
it("clamps retryAfterMs to maxDelayMs", async () => {
vi.useFakeTimers();
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce("ok");
const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce("ok");
const delays: number[] = [];
const promise = retryAsync(fn, {
attempts: 2,

View File

@@ -32,12 +32,7 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const asFiniteNumber = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) ? value : undefined;
const clampNumber = (
value: unknown,
fallback: number,
min?: number,
max?: number,
) => {
const clampNumber = (value: unknown, fallback: number, min?: number, max?: number) => {
const next = asFiniteNumber(value);
if (next === undefined) return fallback;
const floor = typeof min === "number" ? min : Number.NEGATIVE_INFINITY;
@@ -49,10 +44,7 @@ export function resolveRetryConfig(
defaults: Required<RetryConfig> = DEFAULT_RETRY_CONFIG,
overrides?: RetryConfig,
): Required<RetryConfig> {
const attempts = Math.max(
1,
Math.round(clampNumber(overrides?.attempts, defaults.attempts, 1)),
);
const attempts = Math.max(1, Math.round(clampNumber(overrides?.attempts, defaults.attempts, 1)));
const minDelayMs = Math.max(
0,
Math.round(clampNumber(overrides?.minDelayMs, defaults.minDelayMs, 0)),
@@ -113,8 +105,7 @@ export async function retryAsync<T>(
if (attempt >= maxAttempts || !shouldRetry(err, attempt)) break;
const retryAfterMs = options.retryAfterMs?.(err);
const hasRetryAfter =
typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs);
const hasRetryAfter = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs);
const baseDelay = hasRetryAfter
? Math.max(retryAfterMs, minDelayMs)
: minDelayMs * 2 ** (attempt - 1);

View File

@@ -17,24 +17,15 @@ describe("runtime-guard", () => {
});
it("compares versions correctly", () => {
expect(
isAtLeast(
{ major: 22, minor: 0, patch: 0 },
{ major: 22, minor: 0, patch: 0 },
),
).toBe(true);
expect(
isAtLeast(
{ major: 22, minor: 1, patch: 0 },
{ major: 22, minor: 0, patch: 0 },
),
).toBe(true);
expect(
isAtLeast(
{ major: 21, minor: 9, patch: 0 },
{ major: 22, minor: 0, patch: 0 },
),
).toBe(false);
expect(isAtLeast({ major: 22, minor: 0, patch: 0 }, { major: 22, minor: 0, patch: 0 })).toBe(
true,
);
expect(isAtLeast({ major: 22, minor: 1, patch: 0 }, { major: 22, minor: 0, patch: 0 })).toBe(
true,
);
expect(isAtLeast({ major: 21, minor: 9, patch: 0 }, { major: 22, minor: 0, patch: 0 })).toBe(
false,
);
});
it("validates runtime thresholds", () => {
@@ -71,9 +62,7 @@ describe("runtime-guard", () => {
pathEnv: "/usr/bin",
};
expect(() => assertSupportedRuntime(runtime, details)).toThrow("exit");
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("requires Node"),
);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("requires Node"));
});
it("returns silently when runtime meets requirements", () => {

View File

@@ -70,9 +70,7 @@ export function assertSupportedRuntime(
const versionLabel = details.version ?? "unknown";
const runtimeLabel =
details.kind === "unknown"
? "unknown runtime"
: `${details.kind} ${versionLabel}`;
details.kind === "unknown" ? "unknown runtime" : `${details.kind} ${versionLabel}`;
const execLabel = details.execPath ?? "unknown";
runtime.error(

View File

@@ -9,21 +9,13 @@ import {
describe("shell env fallback", () => {
it("is disabled by default", () => {
expect(shouldEnableShellEnvFallback({} as NodeJS.ProcessEnv)).toBe(false);
expect(shouldEnableShellEnvFallback({ CLAWDBOT_LOAD_SHELL_ENV: "0" })).toBe(
false,
);
expect(shouldEnableShellEnvFallback({ CLAWDBOT_LOAD_SHELL_ENV: "1" })).toBe(
true,
);
expect(shouldEnableShellEnvFallback({ CLAWDBOT_LOAD_SHELL_ENV: "0" })).toBe(false);
expect(shouldEnableShellEnvFallback({ CLAWDBOT_LOAD_SHELL_ENV: "1" })).toBe(true);
});
it("resolves timeout from env with default fallback", () => {
expect(resolveShellEnvFallbackTimeoutMs({} as NodeJS.ProcessEnv)).toBe(
15000,
);
expect(
resolveShellEnvFallbackTimeoutMs({ CLAWDBOT_SHELL_ENV_TIMEOUT_MS: "42" }),
).toBe(42);
expect(resolveShellEnvFallbackTimeoutMs({} as NodeJS.ProcessEnv)).toBe(15000);
expect(resolveShellEnvFallbackTimeoutMs({ CLAWDBOT_SHELL_ENV_TIMEOUT_MS: "42" })).toBe(42);
expect(
resolveShellEnvFallbackTimeoutMs({
CLAWDBOT_SHELL_ENV_TIMEOUT_MS: "nope",
@@ -39,9 +31,7 @@ describe("shell env fallback", () => {
enabled: true,
env,
expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"],
exec: exec as unknown as Parameters<
typeof loadShellEnvFallback
>[0]["exec"],
exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
});
expect(res.ok).toBe(true);
@@ -52,17 +42,13 @@ describe("shell env fallback", () => {
it("imports expected keys without overriding existing env", () => {
const env: NodeJS.ProcessEnv = {};
const exec = vi.fn(() =>
Buffer.from("OPENAI_API_KEY=from-shell\0DISCORD_BOT_TOKEN=discord\0"),
);
const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0DISCORD_BOT_TOKEN=discord\0"));
const res1 = loadShellEnvFallback({
enabled: true,
env,
expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"],
exec: exec as unknown as Parameters<
typeof loadShellEnvFallback
>[0]["exec"],
exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
});
expect(res1.ok).toBe(true);
@@ -78,9 +64,7 @@ describe("shell env fallback", () => {
enabled: true,
env,
expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"],
exec: exec2 as unknown as Parameters<
typeof loadShellEnvFallback
>[0]["exec"],
exec: exec2 as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
});
expect(res2.ok).toBe(true);

View File

@@ -29,9 +29,7 @@ export type ShellEnvFallbackOptions = {
exec?: typeof execFileSync;
};
export function loadShellEnvFallback(
opts: ShellEnvFallbackOptions,
): ShellEnvFallbackResult {
export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFallbackResult {
const logger = opts.logger ?? console;
const exec = opts.exec ?? execFileSync;
@@ -40,9 +38,7 @@ export function loadShellEnvFallback(
return { ok: true, applied: [], skippedReason: "disabled" };
}
const hasAnyKey = opts.expectedKeys.some((key) =>
Boolean(opts.env[key]?.trim()),
);
const hasAnyKey = opts.expectedKeys.some((key) => Boolean(opts.env[key]?.trim()));
if (hasAnyKey) {
lastAppliedKeys = [];
return { ok: true, applied: [], skippedReason: "already-has-keys" };
@@ -100,9 +96,7 @@ export function shouldEnableShellEnvFallback(env: NodeJS.ProcessEnv): boolean {
return isTruthy(env.CLAWDBOT_LOAD_SHELL_ENV);
}
export function resolveShellEnvFallbackTimeoutMs(
env: NodeJS.ProcessEnv,
): number {
export function resolveShellEnvFallbackTimeoutMs(env: NodeJS.ProcessEnv): number {
const raw = env.CLAWDBOT_SHELL_ENV_TIMEOUT_MS?.trim();
if (!raw) return DEFAULT_TIMEOUT_MS;
const parsed = Number.parseInt(raw, 10);

View File

@@ -79,10 +79,7 @@ async function canConnectLocal(port: number): Promise<boolean> {
});
}
async function waitForLocalListener(
port: number,
timeoutMs: number,
): Promise<void> {
async function waitForLocalListener(port: number, timeoutMs: number): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await canConnectLocal(port)) return;
@@ -175,20 +172,14 @@ export async function startSshPortForward(opts: {
waitForLocalListener(localPort, Math.max(250, opts.timeoutMs)),
new Promise<void>((_, reject) => {
child.once("exit", (code, signal) => {
reject(
new Error(
`ssh exited (${code ?? "null"}${signal ? `/${signal}` : ""})`,
),
);
reject(new Error(`ssh exited (${code ?? "null"}${signal ? `/${signal}` : ""})`));
});
}),
]);
} catch (err) {
await stop();
const suffix = stderr.length > 0 ? `\n${stderr.join("\n")}` : "";
throw new Error(
`${err instanceof Error ? err.message : String(err)}${suffix}`,
);
throw new Error(`${err instanceof Error ? err.message : String(err)}${suffix}`);
}
return {

View File

@@ -101,8 +101,7 @@ function pickLatestLegacyDirectEntry(
}
function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
const sessionId =
typeof entry.sessionId === "string" ? entry.sessionId : null;
const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : null;
if (!sessionId) return null;
const updatedAt =
typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
@@ -153,12 +152,7 @@ export async function detectLegacyStateMigrations(params: {
const sessionsLegacyDir = path.join(stateDir, "sessions");
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
const sessionsTargetDir = path.join(
stateDir,
"agents",
targetAgentId,
"sessions",
);
const sessionsTargetDir = path.join(stateDir, "agents", targetAgentId, "sessions");
const sessionsTargetStorePath = path.join(sessionsTargetDir, "sessions.json");
const legacySessionEntries = safeReadDir(sessionsLegacyDir);
const hasLegacySessions =
@@ -169,11 +163,7 @@ export async function detectLegacyStateMigrations(params: {
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
const hasLegacyAgentDir = existsDir(legacyAgentDir);
const targetWhatsAppAuthDir = path.join(
oauthDir,
"whatsapp",
DEFAULT_ACCOUNT_ID,
);
const targetWhatsAppAuthDir = path.join(oauthDir, "whatsapp", DEFAULT_ACCOUNT_ID);
const hasLegacyWhatsAppAuth =
fileExists(path.join(oauthDir, "creds.json")) &&
!fileExists(path.join(targetWhatsAppAuthDir, "creds.json"));
@@ -186,9 +176,7 @@ export async function detectLegacyStateMigrations(params: {
preview.push(`- Agent dir: ${legacyAgentDir}${targetAgentDir}`);
}
if (hasLegacyWhatsAppAuth) {
preview.push(
`- WhatsApp auth: ${oauthDir}${targetWhatsAppAuthDir} (keep oauth.json)`,
);
preview.push(`- WhatsApp auth: ${oauthDir}${targetWhatsAppAuthDir} (keep oauth.json)`);
}
return {
@@ -277,9 +265,7 @@ async function migrateLegacySessions(
normalized[key] = normalizedEntry;
}
await saveSessionStore(detected.sessions.targetStorePath, normalized);
changes.push(
`Merged sessions store → ${detected.sessions.targetStorePath}`,
);
changes.push(`Merged sessions store → ${detected.sessions.targetStorePath}`);
}
const entries = safeReadDir(detected.sessions.legacyDir);
@@ -291,9 +277,7 @@ async function migrateLegacySessions(
if (fileExists(to)) continue;
try {
fs.renameSync(from, to);
changes.push(
`Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`,
);
changes.push(`Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
@@ -310,9 +294,7 @@ async function migrateLegacySessions(
}
removeDirIfEmpty(detected.sessions.legacyDir);
const legacyLeft = safeReadDir(detected.sessions.legacyDir).filter((e) =>
e.isFile(),
);
const legacyLeft = safeReadDir(detected.sessions.legacyDir).filter((e) => e.isFile());
if (legacyLeft.length > 0) {
const backupDir = `${detected.sessions.legacyDir}.legacy-${now()}`;
try {
@@ -343,9 +325,7 @@ export async function migrateLegacyAgentDir(
if (fs.existsSync(to)) continue;
try {
fs.renameSync(from, to);
changes.push(
`Moved agent file ${entry.name} → agents/${detected.targetAgentId}/agent`,
);
changes.push(`Moved agent file ${entry.name} → agents/${detected.targetAgentId}/agent`);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
@@ -408,16 +388,8 @@ export async function runLegacyStateMigrations(params: {
const agentDir = await migrateLegacyAgentDir(detected, now);
const whatsappAuth = await migrateLegacyWhatsAppAuth(detected);
return {
changes: [
...sessions.changes,
...agentDir.changes,
...whatsappAuth.changes,
],
warnings: [
...sessions.warnings,
...agentDir.warnings,
...whatsappAuth.warnings,
],
changes: [...sessions.changes, ...agentDir.changes, ...whatsappAuth.changes],
warnings: [...sessions.warnings, ...agentDir.warnings, ...whatsappAuth.warnings],
};
}
@@ -475,17 +447,11 @@ export async function autoMigrateLegacyState(params: {
const logger = params.log ?? createSubsystemLogger("state-migrations");
if (changes.length > 0) {
logger.info(
`Auto-migrated legacy state:\n${changes
.map((entry) => `- ${entry}`)
.join("\n")}`,
);
logger.info(`Auto-migrated legacy state:\n${changes.map((entry) => `- ${entry}`).join("\n")}`);
}
if (warnings.length > 0) {
logger.warn(
`Legacy state migration warnings:\n${warnings
.map((entry) => `- ${entry}`)
.join("\n")}`,
`Legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}

View File

@@ -3,11 +3,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { prependSystemEvents } from "../auto-reply/reply/session-updates.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import {
enqueueSystemEvent,
peekSystemEvents,
resetSystemEventsForTest,
} from "./system-events.js";
import { enqueueSystemEvent, peekSystemEvents, resetSystemEventsForTest } from "./system-events.js";
const cfg = {} as unknown as ClawdbotConfig;
const mainKey = resolveMainSessionKey(cfg);
@@ -24,9 +20,7 @@ describe("system events (session routing)", () => {
});
expect(peekSystemEvents(mainKey)).toEqual([]);
expect(peekSystemEvents("discord:group:123")).toEqual([
"Discord reaction added: ✅",
]);
expect(peekSystemEvents("discord:group:123")).toEqual(["Discord reaction added: ✅"]);
const main = await prependSystemEvents({
cfg,
@@ -36,9 +30,7 @@ describe("system events (session routing)", () => {
prefixedBodyBase: "hello",
});
expect(main).toBe("hello");
expect(peekSystemEvents("discord:group:123")).toEqual([
"Discord reaction added: ✅",
]);
expect(peekSystemEvents("discord:group:123")).toEqual(["Discord reaction added: ✅"]);
const discord = await prependSystemEvents({
cfg,
@@ -47,15 +39,11 @@ describe("system events (session routing)", () => {
isNewSession: false,
prefixedBodyBase: "hi",
});
expect(discord).toMatch(
/^System: \[[^\]]+\] Discord reaction added: ✅\n\nhi$/,
);
expect(discord).toMatch(/^System: \[[^\]]+\] Discord reaction added: ✅\n\nhi$/);
expect(peekSystemEvents("discord:group:123")).toEqual([]);
});
it("requires an explicit session key", () => {
expect(() =>
enqueueSystemEvent("Node: Mac Studio", { sessionKey: " " }),
).toThrow("sessionKey");
expect(() => enqueueSystemEvent("Node: Mac Studio", { sessionKey: " " })).toThrow("sessionKey");
});
});

View File

@@ -1,10 +1,6 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
listSystemPresence,
updateSystemPresence,
upsertPresence,
} from "./system-presence.js";
import { listSystemPresence, updateSystemPresence, upsertPresence } from "./system-presence.js";
describe("system-presence", () => {
it("dedupes entries across sources by case-insensitive instanceId key", () => {

View File

@@ -56,10 +56,7 @@ function resolvePrimaryIPv4(): string | undefined {
function initSelfPresence() {
const host = os.hostname();
const ip = resolvePrimaryIPv4() ?? undefined;
const version =
process.env.CLAWDBOT_VERSION ??
process.env.npm_package_version ??
"unknown";
const version = process.env.CLAWDBOT_VERSION ?? process.env.npm_package_version ?? "unknown";
const modelIdentifier = (() => {
const p = os.platform();
if (p === "darwin") {
@@ -146,9 +143,7 @@ function parsePresence(text: string): SystemPresence {
host: host.trim(),
ip: ip.trim(),
version: version.trim(),
lastInputSeconds: Number.isFinite(lastInputSeconds)
? lastInputSeconds
: undefined,
lastInputSeconds: Number.isFinite(lastInputSeconds) ? lastInputSeconds : undefined,
mode: mode.trim(),
reason,
text: trimmed,
@@ -171,9 +166,7 @@ type SystemPresencePayload = {
tags?: string[];
};
export function updateSystemPresence(
payload: SystemPresencePayload,
): SystemPresenceUpdate {
export function updateSystemPresence(payload: SystemPresencePayload): SystemPresenceUpdate {
ensureSelfPresence();
const parsed = parsePresence(payload.text);
const key =
@@ -196,9 +189,7 @@ export function updateSystemPresence(
modelIdentifier: payload.modelIdentifier ?? existing.modelIdentifier,
mode: payload.mode ?? parsed.mode ?? existing.mode,
lastInputSeconds:
payload.lastInputSeconds ??
parsed.lastInputSeconds ??
existing.lastInputSeconds,
payload.lastInputSeconds ?? parsed.lastInputSeconds ?? existing.lastInputSeconds,
reason: payload.reason ?? parsed.reason ?? existing.reason,
instanceId: payload.instanceId ?? parsed.instanceId ?? existing.instanceId,
text: payload.text || parsed.text || existing.text,
@@ -228,8 +219,7 @@ export function updateSystemPresence(
export function upsertPresence(key: string, presence: Partial<SystemPresence>) {
ensureSelfPresence();
const normalizedKey =
normalizePresenceKey(key) ?? os.hostname().toLowerCase();
const normalizedKey = normalizePresenceKey(key) ?? os.hostname().toLowerCase();
const existing = entries.get(normalizedKey) ?? ({} as SystemPresence);
const merged: SystemPresence = {
...existing,

View File

@@ -1,10 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
ensureGoInstalled,
ensureTailscaledInstalled,
getTailnetHostname,
} from "./tailscale.js";
import { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./tailscale.js";
describe("tailscale helpers", () => {
it("parses DNS name from tailscale status", async () => {
@@ -26,10 +22,7 @@ describe("tailscale helpers", () => {
});
it("ensureGoInstalled installs when missing and user agrees", async () => {
const exec = vi
.fn()
.mockRejectedValueOnce(new Error("no go"))
.mockResolvedValue({}); // brew install go
const exec = vi.fn().mockRejectedValueOnce(new Error("no go")).mockResolvedValue({}); // brew install go
const prompt = vi.fn().mockResolvedValue(true);
const runtime = {
error: vi.fn(),
@@ -43,10 +36,7 @@ describe("tailscale helpers", () => {
});
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
const exec = vi
.fn()
.mockRejectedValueOnce(new Error("missing"))
.mockResolvedValue({});
const exec = vi.fn().mockRejectedValueOnce(new Error("missing")).mockResolvedValue({});
const prompt = vi.fn().mockResolvedValue(true);
const runtime = {
error: vi.fn(),

View File

@@ -1,12 +1,6 @@
import { existsSync } from "node:fs";
import { promptYesNo } from "../cli/prompt.js";
import {
danger,
info,
logVerbose,
shouldLogVerbose,
warn,
} from "../globals.js";
import { danger, info, logVerbose, shouldLogVerbose, warn } from "../globals.js";
import { runExec } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
@@ -39,9 +33,7 @@ export async function findTailscaleBinary(): Promise<string | null> {
// Use Promise.race with runExec to implement timeout
await Promise.race([
runExec(path, ["--version"], { timeoutMs: 3000 }),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), 3000),
),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), 3000)),
]);
return true;
} catch {
@@ -95,9 +87,7 @@ export async function findTailscaleBinary(): Promise<string | null> {
const candidates = stdout
.trim()
.split("\n")
.filter((line) =>
line.includes("/Tailscale.app/Contents/MacOS/Tailscale"),
);
.filter((line) => line.includes("/Tailscale.app/Contents/MacOS/Tailscale"));
for (const candidate of candidates) {
if (await checkBinary(candidate)) {
return candidate;
@@ -110,10 +100,7 @@ export async function findTailscaleBinary(): Promise<string | null> {
return null;
}
export async function getTailnetHostname(
exec: typeof runExec = runExec,
detectedBinary?: string,
) {
export async function getTailnetHostname(exec: typeof runExec = runExec, detectedBinary?: string) {
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
const candidates = detectedBinary
? [detectedBinary]
@@ -132,10 +119,7 @@ export async function getTailnetHostname(
typeof parsed.Self === "object" && parsed.Self !== null
? (parsed.Self as Record<string, unknown>)
: undefined;
const dns =
typeof self?.DNSName === "string"
? (self.DNSName as string)
: undefined;
const dns = typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
const ips = Array.isArray(self?.TailscaleIPs)
? ((parsed.Self as { TailscaleIPs?: string[] }).TailscaleIPs ?? [])
: [];
@@ -230,16 +214,10 @@ export async function ensureFunnel(
// Ensure Funnel is enabled and publish the webhook port.
try {
const tailscaleBin = await getTailscaleBinary();
const statusOut = (
await exec(tailscaleBin, ["funnel", "status", "--json"])
).stdout.trim();
const parsed = statusOut
? (JSON.parse(statusOut) as Record<string, unknown>)
: {};
const statusOut = (await exec(tailscaleBin, ["funnel", "status", "--json"])).stdout.trim();
const parsed = statusOut ? (JSON.parse(statusOut) as Record<string, unknown>) : {};
if (!parsed || Object.keys(parsed).length === 0) {
runtime.error(
danger("Tailscale Funnel is not enabled on this tailnet/device."),
);
runtime.error(danger("Tailscale Funnel is not enabled on this tailnet/device."));
runtime.error(
info(
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
@@ -250,10 +228,7 @@ export async function ensureFunnel(
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
),
);
const proceed = await prompt(
"Attempt local setup with user-space tailscaled?",
true,
);
const proceed = await prompt("Attempt local setup with user-space tailscaled?", true);
if (!proceed) runtime.exit(1);
await ensureBinary("brew", exec, runtime);
await ensureGoInstalled(exec, prompt, runtime);
@@ -261,14 +236,10 @@ export async function ensureFunnel(
}
logVerbose(`Enabling funnel on port ${port}`);
const { stdout } = await exec(
tailscaleBin,
["funnel", "--yes", "--bg", `${port}`],
{
maxBuffer: 200_000,
timeoutMs: 15_000,
},
);
const { stdout } = await exec(tailscaleBin, ["funnel", "--yes", "--bg", `${port}`], {
maxBuffer: 200_000,
timeoutMs: 15_000,
});
if (stdout.trim()) console.log(stdout.trim());
} catch (err) {
const errOutput = err as { stdout?: unknown; stderr?: unknown };
@@ -287,19 +258,14 @@ export async function ensureFunnel(
);
}
}
if (
stderr.includes("client version") ||
stdout.includes("client version")
) {
if (stderr.includes("client version") || stdout.includes("client version")) {
console.error(
warn(
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
),
);
}
runtime.error(
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
);
runtime.error("Failed to enable Tailscale Funnel. Is it allowed on your tailnet?");
runtime.error(
info(
"Tip: Funnel is optional for CLAWDBOT. You can keep running the web gateway without it: `pnpm clawdbot gateway`",
@@ -319,10 +285,7 @@ export async function ensureFunnel(
}
}
export async function enableTailscaleServe(
port: number,
exec: typeof runExec = runExec,
) {
export async function enableTailscaleServe(port: number, exec: typeof runExec = runExec) {
const tailscaleBin = await getTailscaleBinary();
await exec(tailscaleBin, ["serve", "--bg", "--yes", `${port}`], {
maxBuffer: 200_000,
@@ -338,10 +301,7 @@ export async function disableTailscaleServe(exec: typeof runExec = runExec) {
});
}
export async function enableTailscaleFunnel(
port: number,
exec: typeof runExec = runExec,
) {
export async function enableTailscaleFunnel(port: number, exec: typeof runExec = runExec) {
const tailscaleBin = await getTailscaleBinary();
await exec(tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], {
maxBuffer: 200_000,

View File

@@ -4,9 +4,7 @@ type UnhandledRejectionHandler = (reason: unknown) => boolean;
const handlers = new Set<UnhandledRejectionHandler>();
export function registerUnhandledRejectionHandler(
handler: UnhandledRejectionHandler,
): () => void {
export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHandler): () => void {
handlers.add(handler);
return () => {
handlers.delete(handler);

View File

@@ -66,10 +66,9 @@ async function detectPackageManager(root: string): Promise<PackageManager> {
}
async function detectGitRoot(root: string): Promise<string | null> {
const res = await runCommandWithTimeout(
["git", "-C", root, "rev-parse", "--show-toplevel"],
{ timeoutMs: 4000 },
).catch(() => null);
const res = await runCommandWithTimeout(["git", "-C", root, "rev-parse", "--show-toplevel"], {
timeoutMs: 4000,
}).catch(() => null);
if (!res || res.code !== 0) return null;
const top = res.stdout.trim();
return top ? path.resolve(top) : null;
@@ -106,21 +105,15 @@ export async function checkGitUpdateStatus(params: {
["git", "-C", root, "rev-parse", "--abbrev-ref", "@{upstream}"],
{ timeoutMs },
).catch(() => null);
const upstream =
upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
const dirtyRes = await runCommandWithTimeout(
["git", "-C", root, "status", "--porcelain"],
{ timeoutMs },
).catch(() => null);
const dirty =
dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
const dirtyRes = await runCommandWithTimeout(["git", "-C", root, "status", "--porcelain"], {
timeoutMs,
}).catch(() => null);
const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
const fetchOk = params.fetch
? await runCommandWithTimeout(
["git", "-C", root, "fetch", "--quiet", "--prune"],
{ timeoutMs },
)
? await runCommandWithTimeout(["git", "-C", root, "fetch", "--quiet", "--prune"], { timeoutMs })
.then((r) => r.code === 0)
.catch(() => false)
: null;
@@ -128,22 +121,12 @@ export async function checkGitUpdateStatus(params: {
const counts =
upstream && upstream.length > 0
? await runCommandWithTimeout(
[
"git",
"-C",
root,
"rev-list",
"--left-right",
"--count",
`HEAD...${upstream}`,
],
["git", "-C", root, "rev-list", "--left-right", "--count", `HEAD...${upstream}`],
{ timeoutMs },
).catch(() => null)
: null;
const parseCounts = (
raw: string,
): { ahead: number; behind: number } | null => {
const parseCounts = (raw: string): { ahead: number; behind: number } | null => {
const parts = raw.trim().split(/\s+/);
if (parts.length < 2) return null;
const ahead = Number.parseInt(parts[0] ?? "", 10);
@@ -151,8 +134,7 @@ export async function checkGitUpdateStatus(params: {
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) return null;
return { ahead, behind };
};
const parsed =
counts && counts.code === 0 ? parseCounts(counts.stdout) : null;
const parsed = counts && counts.code === 0 ? parseCounts(counts.stdout) : null;
return {
root,
@@ -268,10 +250,7 @@ export async function checkDepsStatus(params: {
};
}
async function fetchWithTimeout(
url: string,
timeoutMs: number,
): Promise<Response> {
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), Math.max(250, timeoutMs));
try {
@@ -286,26 +265,19 @@ export async function fetchNpmLatestVersion(params?: {
}): Promise<RegistryStatus> {
const timeoutMs = params?.timeoutMs ?? 3500;
try {
const res = await fetchWithTimeout(
"https://registry.npmjs.org/clawdbot/latest",
timeoutMs,
);
const res = await fetchWithTimeout("https://registry.npmjs.org/clawdbot/latest", timeoutMs);
if (!res.ok) {
return { latestVersion: null, error: `HTTP ${res.status}` };
}
const json = (await res.json()) as { version?: unknown };
const latestVersion =
typeof json?.version === "string" ? json.version : null;
const latestVersion = typeof json?.version === "string" ? json.version : null;
return { latestVersion };
} catch (err) {
return { latestVersion: null, error: String(err) };
}
}
export function compareSemverStrings(
a: string | null,
b: string | null,
): number | null {
export function compareSemverStrings(a: string | null, b: string | null): number | null {
const pa = parseSemver(a);
const pb = parseSemver(b);
if (!pa || !pb) return null;
@@ -328,9 +300,7 @@ export async function checkUpdateStatus(params: {
root: null,
installKind: "unknown",
packageManager: "unknown",
registry: params.includeRegistry
? await fetchNpmLatestVersion({ timeoutMs })
: undefined,
registry: params.includeRegistry ? await fetchNpmLatestVersion({ timeoutMs }) : undefined,
};
}
@@ -338,9 +308,7 @@ export async function checkUpdateStatus(params: {
const gitRoot = await detectGitRoot(root);
const isGit = gitRoot && path.resolve(gitRoot) === root;
const installKind: UpdateCheckResult["installKind"] = isGit
? "git"
: "package";
const installKind: UpdateCheckResult["installKind"] = isGit ? "git" : "package";
const git = isGit
? await checkGitUpdateStatus({
root,
@@ -349,9 +317,7 @@ export async function checkUpdateStatus(params: {
})
: undefined;
const deps = await checkDepsStatus({ root, manager: pm });
const registry = params.includeRegistry
? await fetchNpmLatestVersion({ timeoutMs })
: undefined;
const registry = params.includeRegistry ? await fetchNpmLatestVersion({ timeoutMs }) : undefined;
return {
root,

View File

@@ -68,8 +68,9 @@ describe("runGatewayUpdate", () => {
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} status --porcelain`]: { stdout: "" },
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]:
{ stdout: "origin/main" },
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: {
stdout: "origin/main",
},
[`git -C ${tempDir} fetch --all --prune`]: { stdout: "" },
[`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" },
[`git -C ${tempDir} rebase --abort`]: { stdout: "" },
@@ -127,8 +128,6 @@ describe("runGatewayUpdate", () => {
expect(result.status).toBe("error");
expect(result.reason).toBe("not-clawdbot-root");
expect(calls.some((call) => call.includes("status --porcelain"))).toBe(
false,
);
expect(calls.some((call) => call.includes("status --porcelain"))).toBe(false);
});
});

View File

@@ -110,12 +110,9 @@ async function resolveGitRoot(
timeoutMs: number,
): Promise<string | null> {
for (const dir of candidates) {
const res = await runCommand(
["git", "-C", dir, "rev-parse", "--show-toplevel"],
{
timeoutMs,
},
);
const res = await runCommand(["git", "-C", dir, "rev-parse", "--show-toplevel"], {
timeoutMs,
});
if (res.code === 0) {
const root = res.stdout.trim();
if (root) return root;
@@ -174,17 +171,7 @@ type RunStepOptions = {
};
async function runStep(opts: RunStepOptions): Promise<UpdateStepResult> {
const {
runCommand,
name,
argv,
cwd,
timeoutMs,
env,
progress,
stepIndex,
totalSteps,
} = opts;
const { runCommand, name, argv, cwd, timeoutMs, env, progress, stepIndex, totalSteps } = opts;
const command = argv.join(" ");
const stepInfo: UpdateStepInfo = {
@@ -220,11 +207,7 @@ async function runStep(opts: RunStepOptions): Promise<UpdateStepResult> {
};
}
function managerScriptArgs(
manager: "pnpm" | "bun" | "npm",
script: string,
args: string[] = [],
) {
function managerScriptArgs(manager: "pnpm" | "bun" | "npm", script: string, args: string[] = []) {
if (manager === "pnpm") return ["pnpm", script, ...args];
if (manager === "bun") return ["bun", "run", script, ...args];
if (args.length > 0) return ["npm", "run", script, "--", ...args];
@@ -240,9 +223,7 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
// Total number of visible steps in a successful git update flow
const GIT_UPDATE_TOTAL_STEPS = 9;
export async function runGatewayUpdate(
opts: UpdateRunnerOptions = {},
): Promise<UpdateRunResult> {
export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {
const startedAt = Date.now();
const runCommand =
opts.runCommand ??
@@ -298,19 +279,15 @@ export async function runGatewayUpdate(
if (gitRoot && pkgRoot && path.resolve(gitRoot) === path.resolve(pkgRoot)) {
// Get current SHA (not a visible step, no progress)
const beforeShaResult = await runCommand(
["git", "-C", gitRoot, "rev-parse", "HEAD"],
{ cwd: gitRoot, timeoutMs },
);
const beforeShaResult = await runCommand(["git", "-C", gitRoot, "rev-parse", "HEAD"], {
cwd: gitRoot,
timeoutMs,
});
const beforeSha = beforeShaResult.stdout.trim() || null;
const beforeVersion = await readPackageVersion(gitRoot);
const statusCheck = await runStep(
step(
"clean check",
["git", "-C", gitRoot, "status", "--porcelain"],
gitRoot,
),
step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot),
);
steps.push(statusCheck);
const hasUncommittedChanges =
@@ -330,15 +307,7 @@ export async function runGatewayUpdate(
const upstreamStep = await runStep(
step(
"upstream check",
[
"git",
"-C",
gitRoot,
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
["git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
gitRoot,
),
);
@@ -356,27 +325,19 @@ export async function runGatewayUpdate(
}
const fetchStep = await runStep(
step(
"git fetch",
["git", "-C", gitRoot, "fetch", "--all", "--prune"],
gitRoot,
),
step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune"], gitRoot),
);
steps.push(fetchStep);
const rebaseStep = await runStep(
step(
"git rebase",
["git", "-C", gitRoot, "rebase", "@{upstream}"],
gitRoot,
),
step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot),
);
steps.push(rebaseStep);
if (rebaseStep.exitCode !== 0) {
const abortResult = await runCommand(
["git", "-C", gitRoot, "rebase", "--abort"],
{ cwd: gitRoot, timeoutMs },
);
const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], {
cwd: gitRoot,
timeoutMs,
});
steps.push({
name: "git rebase --abort",
command: "git rebase --abort",
@@ -399,14 +360,10 @@ export async function runGatewayUpdate(
const manager = await detectPackageManager(gitRoot);
const depsStep = await runStep(
step("deps install", managerInstallArgs(manager), gitRoot),
);
const depsStep = await runStep(step("deps install", managerInstallArgs(manager), gitRoot));
steps.push(depsStep);
const buildStep = await runStep(
step("build", managerScriptArgs(manager, "build"), gitRoot),
);
const buildStep = await runStep(step("build", managerScriptArgs(manager, "build"), gitRoot));
steps.push(buildStep);
const uiBuildStep = await runStep(
@@ -426,11 +383,7 @@ export async function runGatewayUpdate(
const failedStep = steps.find((s) => s.exitCode !== 0);
const afterShaStep = await runStep(
step(
"git rev-parse HEAD (after)",
["git", "-C", gitRoot, "rev-parse", "HEAD"],
gitRoot,
),
step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot),
);
steps.push(afterShaStep);
const afterVersion = await readPackageVersion(gitRoot);

View File

@@ -12,22 +12,15 @@ import {
describe("voicewake store", () => {
it("returns defaults when missing", async () => {
const baseDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-voicewake-"),
);
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-voicewake-"));
const cfg = await loadVoiceWakeConfig(baseDir);
expect(cfg.triggers).toEqual(defaultVoiceWakeTriggers());
expect(cfg.updatedAtMs).toBe(0);
});
it("sanitizes and persists triggers", async () => {
const baseDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-voicewake-"),
);
const saved = await setVoiceWakeTriggers(
[" hi ", "", " there "],
baseDir,
);
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-voicewake-"));
const saved = await setVoiceWakeTriggers([" hi ", "", " there "], baseDir);
expect(saved.triggers).toEqual(["hi", "there"]);
expect(saved.updatedAtMs).toBeGreaterThan(0);
@@ -37,9 +30,7 @@ describe("voicewake store", () => {
});
it("falls back to defaults when triggers empty", async () => {
const baseDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-voicewake-"),
);
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-voicewake-"));
const saved = await setVoiceWakeTriggers(["", " "], baseDir);
expect(saved.triggers).toEqual(defaultVoiceWakeTriggers());
});

View File

@@ -58,9 +58,7 @@ export function defaultVoiceWakeTriggers() {
return [...DEFAULT_TRIGGERS];
}
export async function loadVoiceWakeConfig(
baseDir?: string,
): Promise<VoiceWakeConfig> {
export async function loadVoiceWakeConfig(baseDir?: string): Promise<VoiceWakeConfig> {
const filePath = resolvePath(baseDir);
const existing = await readJSON<VoiceWakeConfig>(filePath);
if (!existing) {

View File

@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
renderWideAreaBridgeZoneText,
WIDE_AREA_DISCOVERY_DOMAIN,
} from "./widearea-dns.js";
import { renderWideAreaBridgeZoneText, WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
describe("wide-area DNS-SD zone rendering", () => {
it("renders a clawdbot.internal zone with bridge PTR/SRV/TXT records", () => {
@@ -23,12 +20,8 @@ describe("wide-area DNS-SD zone rendering", () => {
expect(txt).toContain(`$ORIGIN ${WIDE_AREA_DISCOVERY_DOMAIN}`);
expect(txt).toContain(`studio-london IN A 100.123.224.76`);
expect(txt).toContain(`studio-london IN AAAA fd7a:115c:a1e0::8801:e04c`);
expect(txt).toContain(
`_clawdbot-bridge._tcp IN PTR studio-london._clawdbot-bridge._tcp`,
);
expect(txt).toContain(
`studio-london._clawdbot-bridge._tcp IN SRV 0 0 18790 studio-london`,
);
expect(txt).toContain(`_clawdbot-bridge._tcp IN PTR studio-london._clawdbot-bridge._tcp`);
expect(txt).toContain(`studio-london._clawdbot-bridge._tcp IN SRV 0 0 18790 studio-london`);
expect(txt).toContain(`displayName=Mac Studio (Clawdbot)`);
expect(txt).toContain(`gatewayPort=18789`);
expect(txt).toContain(`sshPort=22`);

View File

@@ -23,10 +23,7 @@ function dnsLabel(raw: string, fallback: string): string {
}
function txtQuote(value: string): string {
const escaped = value
.replaceAll("\\", "\\\\")
.replaceAll('"', '\\"')
.replaceAll("\n", "\\n");
const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
return `"${escaped}"`;
}
@@ -84,10 +81,7 @@ export type WideAreaBridgeZoneOpts = {
function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
const hostname = os.hostname().split(".")[0] ?? "clawdbot";
const hostLabel = dnsLabel(opts.hostLabel ?? hostname, "clawdbot");
const instanceLabel = dnsLabel(
opts.instanceLabel ?? `${hostname}-bridge`,
"clawdbot-bridge",
);
const instanceLabel = dnsLabel(opts.instanceLabel ?? `${hostname}-bridge`, "clawdbot-bridge");
const txt = [
`displayName=${opts.displayName.trim() || hostname}`,
@@ -120,22 +114,14 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
records.push(`${hostLabel} IN AAAA ${opts.tailnetIPv6}`);
}
records.push(
`_clawdbot-bridge._tcp IN PTR ${instanceLabel}._clawdbot-bridge._tcp`,
);
records.push(
`${instanceLabel}._clawdbot-bridge._tcp IN SRV 0 0 ${opts.bridgePort} ${hostLabel}`,
);
records.push(
`${instanceLabel}._clawdbot-bridge._tcp IN TXT ${txt.map(txtQuote).join(" ")}`,
);
records.push(`_clawdbot-bridge._tcp IN PTR ${instanceLabel}._clawdbot-bridge._tcp`);
records.push(`${instanceLabel}._clawdbot-bridge._tcp IN SRV 0 0 ${opts.bridgePort} ${hostLabel}`);
records.push(`${instanceLabel}._clawdbot-bridge._tcp IN TXT ${txt.map(txtQuote).join(" ")}`);
const contentBody = `${records.join("\n")}\n`;
const hashBody = `${records
.map((line) =>
line === soaLine
? `@ IN SOA ns1 hostmaster SERIAL 7200 3600 1209600 60`
: line,
line === soaLine ? `@ IN SOA ns1 hostmaster SERIAL 7200 3600 1209600 60` : line,
)
.join("\n")}\n`;
const contentHash = computeContentHash(hashBody);