Cron: add scheduler, wakeups, and run history

This commit is contained in:
Peter Steinberger
2025-12-13 02:34:11 +00:00
parent 572d17f46b
commit f9409cbe43
26 changed files with 3401 additions and 342 deletions

414
src/cli/cron-cli.ts Normal file
View File

@@ -0,0 +1,414 @@
import type { Command } from "commander";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
function parseDurationMs(input: string): number | null {
const raw = input.trim();
if (!raw) return null;
const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/i);
if (!match) return null;
const n = Number.parseFloat(match[1] ?? "");
if (!Number.isFinite(n) || n <= 0) return null;
const unit = (match[2] ?? "").toLowerCase();
const factor =
unit === "ms"
? 1
: unit === "s"
? 1000
: unit === "m"
? 60_000
: unit === "h"
? 3_600_000
: 86_400_000;
return Math.floor(n * factor);
}
function parseAtMs(input: string): number | null {
const raw = input.trim();
if (!raw) return null;
const asNum = Number(raw);
if (Number.isFinite(asNum) && asNum > 0) return Math.floor(asNum);
const parsed = Date.parse(raw);
if (Number.isFinite(parsed)) return parsed;
const dur = parseDurationMs(raw);
if (dur) return Date.now() + dur;
return null;
}
export function registerCronCli(program: Command) {
addGatewayClientOptions(
program
.command("wake")
.description(
"Enqueue a system event and optionally trigger an immediate heartbeat",
)
.requiredOption("--text <text>", "System event text")
.option(
"--mode <mode>",
"Wake mode (now|next-heartbeat)",
"next-heartbeat",
)
.option("--json", "Output JSON", false),
).action(async (opts) => {
try {
const result = await callGatewayFromCli(
"wake",
opts,
{ mode: opts.mode, text: opts.text },
{ expectFinal: false },
);
if (opts.json) defaultRuntime.log(JSON.stringify(result, null, 2));
else defaultRuntime.log("ok");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
const cron = program
.command("cron")
.description("Manage cron jobs (via Gateway)");
addGatewayClientOptions(
cron
.command("list")
.description("List cron jobs")
.option("--all", "Include disabled jobs", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
const res = await callGatewayFromCli("cron.list", opts, {
includeDisabled: Boolean(opts.all),
});
defaultRuntime.log(JSON.stringify(res, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
}),
);
addGatewayClientOptions(
cron
.command("add")
.description("Add a cron job")
.option("--name <name>", "Optional name")
.option("--disabled", "Create job disabled", false)
.option("--session <target>", "Session target (main|isolated)", "main")
.option(
"--wake <mode>",
"Wake mode (now|next-heartbeat)",
"next-heartbeat",
)
.option("--at <when>", "Run once at time (ISO) or +duration (e.g. 20m)")
.option("--every <duration>", "Run every duration (e.g. 10m, 1h)")
.option("--cron <expr>", "Cron expression (5-field)")
.option("--tz <iana>", "Timezone for cron expressions (IANA)", "")
.option("--system-event <text>", "System event payload (main session)")
.option("--message <text>", "Agent message payload")
.option(
"--thinking <level>",
"Thinking level for agent jobs (off|minimal|low|medium|high)",
)
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false)
.option(
"--channel <channel>",
"Delivery channel (last|whatsapp|telegram)",
"last",
)
.option("--to <dest>", "Delivery destination (E.164 or Telegram chatId)")
.option(
"--best-effort-deliver",
"Do not fail the job if delivery fails",
false,
)
.option("--post-to-main", "Post a 1-line summary to main session", false)
.option(
"--post-prefix <prefix>",
"Prefix for summary system event",
"Cron",
)
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
const schedule = (() => {
const at = typeof opts.at === "string" ? opts.at : "";
const every = typeof opts.every === "string" ? opts.every : "";
const cronExpr = typeof opts.cron === "string" ? opts.cron : "";
const chosen = [
Boolean(at),
Boolean(every),
Boolean(cronExpr),
].filter(Boolean).length;
if (chosen !== 1) {
throw new Error(
"Choose exactly one schedule: --at, --every, or --cron",
);
}
if (at) {
const atMs = parseAtMs(at);
if (!atMs)
throw new Error(
"Invalid --at; use ISO time or duration like 20m",
);
return { kind: "at" as const, atMs };
}
if (every) {
const everyMs = parseDurationMs(every);
if (!everyMs)
throw new Error("Invalid --every; use e.g. 10m, 1h, 1d");
return { kind: "every" as const, everyMs };
}
return {
kind: "cron" as const,
expr: cronExpr,
tz:
typeof opts.tz === "string" && opts.tz.trim()
? opts.tz.trim()
: undefined,
};
})();
const sessionTarget = String(opts.session ?? "main");
if (sessionTarget !== "main" && sessionTarget !== "isolated") {
throw new Error("--session must be main or isolated");
}
const wakeMode = String(opts.wake ?? "next-heartbeat");
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
throw new Error("--wake must be now or next-heartbeat");
}
const payload = (() => {
const systemEvent =
typeof opts.systemEvent === "string"
? opts.systemEvent.trim()
: "";
const message =
typeof opts.message === "string" ? opts.message.trim() : "";
const chosen = [Boolean(systemEvent), Boolean(message)].filter(
Boolean,
).length;
if (chosen !== 1) {
throw new Error(
"Choose exactly one payload: --system-event or --message",
);
}
if (systemEvent)
return { kind: "systemEvent" as const, text: systemEvent };
const timeoutSeconds = opts.timeoutSeconds
? Number.parseInt(String(opts.timeoutSeconds), 10)
: undefined;
return {
kind: "agentTurn" as const,
message,
thinking:
typeof opts.thinking === "string" && opts.thinking.trim()
? opts.thinking.trim()
: undefined,
timeoutSeconds:
timeoutSeconds && Number.isFinite(timeoutSeconds)
? timeoutSeconds
: undefined,
deliver: Boolean(opts.deliver),
channel: typeof opts.channel === "string" ? opts.channel : "last",
to:
typeof opts.to === "string" && opts.to.trim()
? opts.to.trim()
: undefined,
bestEffortDeliver: Boolean(opts.bestEffortDeliver),
};
})();
if (sessionTarget === "isolated" && payload.kind !== "agentTurn") {
throw new Error(
"Isolated jobs require --message (agentTurn payload).",
);
}
const isolation = opts.postToMain
? {
postToMain: true,
postToMainPrefix: String(opts.postPrefix ?? "Cron"),
}
: undefined;
const params = {
name:
typeof opts.name === "string" && opts.name.trim()
? opts.name.trim()
: undefined,
enabled: !opts.disabled,
schedule,
sessionTarget,
wakeMode,
payload,
isolation,
};
const res = await callGatewayFromCli("cron.add", opts, params);
defaultRuntime.log(JSON.stringify(res, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
}),
);
addGatewayClientOptions(
cron
.command("rm")
.description("Remove a cron job")
.argument("<id>", "Job id")
.option("--json", "Output JSON", false)
.action(async (id, opts) => {
try {
const res = await callGatewayFromCli("cron.remove", opts, { id });
defaultRuntime.log(JSON.stringify(res, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
}),
);
addGatewayClientOptions(
cron
.command("edit")
.description("Edit a cron job (patch fields)")
.argument("<id>", "Job id")
.option("--name <name>", "Set name")
.option("--enable", "Enable job", false)
.option("--disable", "Disable job", false)
.option("--session <target>", "Session target (main|isolated)")
.option("--wake <mode>", "Wake mode (now|next-heartbeat)")
.option("--at <when>", "Set one-shot time (ISO) or duration like 20m")
.option("--every <duration>", "Set interval duration like 10m")
.option("--cron <expr>", "Set cron expression")
.option("--tz <iana>", "Timezone for cron expressions (IANA)")
.option("--system-event <text>", "Set systemEvent payload")
.option("--message <text>", "Set agentTurn payload message")
.option("--thinking <level>", "Thinking level for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false)
.option(
"--channel <channel>",
"Delivery channel (last|whatsapp|telegram)",
)
.option("--to <dest>", "Delivery destination")
.option(
"--best-effort-deliver",
"Do not fail job if delivery fails",
false,
)
.option("--post-to-main", "Post a 1-line summary to main session", false)
.option("--post-prefix <prefix>", "Prefix for summary system event")
.action(async (id, opts) => {
try {
const patch: Record<string, unknown> = {};
if (typeof opts.name === "string") patch.name = opts.name;
if (opts.enable && opts.disable)
throw new Error("Choose --enable or --disable, not both");
if (opts.enable) patch.enabled = true;
if (opts.disable) patch.enabled = false;
if (typeof opts.session === "string")
patch.sessionTarget = opts.session;
if (typeof opts.wake === "string") patch.wakeMode = opts.wake;
const scheduleChosen = [opts.at, opts.every, opts.cron].filter(
Boolean,
).length;
if (scheduleChosen > 1)
throw new Error("Choose at most one schedule change");
if (opts.at) {
const atMs = parseAtMs(String(opts.at));
if (!atMs) throw new Error("Invalid --at");
patch.schedule = { kind: "at", atMs };
} else if (opts.every) {
const everyMs = parseDurationMs(String(opts.every));
if (!everyMs) throw new Error("Invalid --every");
patch.schedule = { kind: "every", everyMs };
} else if (opts.cron) {
patch.schedule = {
kind: "cron",
expr: String(opts.cron),
tz:
typeof opts.tz === "string" && opts.tz.trim()
? opts.tz.trim()
: undefined,
};
}
const payloadChosen = [opts.systemEvent, opts.message].filter(
Boolean,
).length;
if (payloadChosen > 1)
throw new Error("Choose at most one payload change");
if (opts.systemEvent) {
patch.payload = {
kind: "systemEvent",
text: String(opts.systemEvent),
};
} else if (opts.message) {
const timeoutSeconds = opts.timeoutSeconds
? Number.parseInt(String(opts.timeoutSeconds), 10)
: undefined;
patch.payload = {
kind: "agentTurn",
message: String(opts.message),
thinking:
typeof opts.thinking === "string" ? opts.thinking : undefined,
timeoutSeconds:
timeoutSeconds && Number.isFinite(timeoutSeconds)
? timeoutSeconds
: undefined,
deliver: Boolean(opts.deliver),
channel:
typeof opts.channel === "string" ? opts.channel : undefined,
to: typeof opts.to === "string" ? opts.to : undefined,
bestEffortDeliver: Boolean(opts.bestEffortDeliver),
};
}
if (opts.postToMain) {
patch.isolation = {
postToMain: true,
postToMainPrefix:
typeof opts.postPrefix === "string" ? opts.postPrefix : "Cron",
};
}
const res = await callGatewayFromCli("cron.update", opts, {
id,
patch,
});
defaultRuntime.log(JSON.stringify(res, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
}),
);
addGatewayClientOptions(
cron
.command("run")
.description("Run a cron job now (debug)")
.argument("<id>", "Job id")
.option("--force", "Run even if not due", false)
.action(async (id, opts) => {
try {
const res = await callGatewayFromCli("cron.run", opts, {
id,
mode: opts.force ? "force" : "due",
});
defaultRuntime.log(JSON.stringify(res, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
}),
);
}

275
src/cli/gateway-cli.ts Normal file
View File

@@ -0,0 +1,275 @@
import type { Command } from "commander";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { startGatewayServer } from "../gateway/server.js";
import { info, setVerbose } from "../globals.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { defaultRuntime } from "../runtime.js";
import { createDefaultDeps } from "./deps.js";
import { forceFreePort } from "./ports.js";
type GatewayRpcOpts = {
url?: string;
token?: string;
timeout?: string;
expectFinal?: boolean;
};
const gatewayCallOpts = (cmd: Command) =>
cmd
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
.option("--token <token>", "Gateway token (if required)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--expect-final", "Wait for final response (agent)", false);
const callGatewayCli = async (
method: string,
opts: GatewayRpcOpts,
params?: unknown,
) =>
callGateway({
url: opts.url,
token: opts.token,
method,
params,
expectFinal: Boolean(opts.expectFinal),
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: "cli",
mode: "cli",
});
export function registerGatewayCli(program: Command) {
const gateway = program
.command("gateway")
.description("Run the WebSocket Gateway")
.option("--port <port>", "Port for the gateway WebSocket", "18789")
.option(
"--webchat-port <port>",
"Port for the loopback WebChat HTTP server (default 18788)",
)
.option(
"--token <token>",
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
)
.option(
"--force",
"Kill any existing listener on the target port before starting",
false,
)
.option("--verbose", "Verbose logging to stdout/stderr", false)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const port = Number.parseInt(String(opts.port ?? "18789"), 10);
if (Number.isNaN(port) || port <= 0) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
}
const webchatPort = opts.webchatPort
? Number.parseInt(String(opts.webchatPort), 10)
: undefined;
if (
webchatPort !== undefined &&
(Number.isNaN(webchatPort) || webchatPort <= 0)
) {
defaultRuntime.error("Invalid webchat port");
defaultRuntime.exit(1);
}
if (opts.force) {
try {
const killed = forceFreePort(port);
if (killed.length === 0) {
defaultRuntime.log(info(`Force: no listeners on port ${port}`));
} else {
for (const proc of killed) {
defaultRuntime.log(
info(
`Force: killed pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""} on port ${port}`,
),
);
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
} catch (err) {
defaultRuntime.error(`Force: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
}
if (opts.token) {
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
}
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
let shuttingDown = false;
let forceExitTimer: ReturnType<typeof setTimeout> | null = null;
const onSigterm = () => shutdown("SIGTERM");
const onSigint = () => shutdown("SIGINT");
const shutdown = (signal: string) => {
// Ensure we don't leak listeners across restarts/tests.
process.removeListener("SIGTERM", onSigterm);
process.removeListener("SIGINT", onSigint);
if (shuttingDown) {
defaultRuntime.log(
info(`gateway: received ${signal} during shutdown; exiting now`),
);
defaultRuntime.exit(0);
}
shuttingDown = true;
defaultRuntime.log(info(`gateway: received ${signal}; shutting down`));
// Avoid hanging forever if a provider task ignores abort.
forceExitTimer = setTimeout(() => {
defaultRuntime.error(
"gateway: shutdown timed out; exiting without full cleanup",
);
defaultRuntime.exit(0);
}, 5000);
void (async () => {
try {
await server?.close();
} catch (err) {
defaultRuntime.error(`gateway: shutdown error: ${String(err)}`);
} finally {
if (forceExitTimer) clearTimeout(forceExitTimer);
defaultRuntime.exit(0);
}
})();
};
process.once("SIGTERM", onSigterm);
process.once("SIGINT", onSigint);
try {
server = await startGatewayServer(port, { webchatPort });
} catch (err) {
if (err instanceof GatewayLockError) {
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
defaultRuntime.exit(1);
return;
}
defaultRuntime.error(`Gateway failed to start: ${String(err)}`);
defaultRuntime.exit(1);
}
// Keep process alive
await new Promise<never>(() => {});
});
gatewayCallOpts(
gateway
.command("call")
.description("Call a Gateway method and print JSON")
.argument(
"<method>",
"Method name (health/status/system-presence/send/agent/cron.*)",
)
.option("--params <json>", "JSON object string for params", "{}")
.action(async (method, opts) => {
try {
const params = JSON.parse(String(opts.params ?? "{}"));
const result = await callGatewayCli(method, opts, params);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(`Gateway call failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
gatewayCallOpts(
gateway
.command("health")
.description("Fetch Gateway health")
.action(async (opts) => {
try {
const result = await callGatewayCli("health", opts);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
gatewayCallOpts(
gateway
.command("status")
.description("Fetch Gateway status")
.action(async (opts) => {
try {
const result = await callGatewayCli("status", opts);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
gatewayCallOpts(
gateway
.command("send")
.description("Send a message via the Gateway")
.requiredOption("--to <jidOrPhone>", "Destination (E.164 or jid)")
.requiredOption("--message <text>", "Message text")
.option("--media-url <url>", "Optional media URL")
.option("--idempotency-key <key>", "Idempotency key")
.action(async (opts) => {
try {
const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey();
const result = await callGatewayCli("send", opts, {
to: opts.to,
message: opts.message,
mediaUrl: opts.mediaUrl,
idempotencyKey,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
gatewayCallOpts(
gateway
.command("agent")
.description("Run an agent turn via the Gateway (waits for final)")
.requiredOption("--message <text>", "User message")
.option("--to <jidOrPhone>", "Destination")
.option("--session-id <id>", "Session id")
.option("--thinking <level>", "Thinking level")
.option("--deliver", "Deliver response", false)
.option("--timeout-seconds <n>", "Agent timeout seconds")
.option("--idempotency-key <key>", "Idempotency key")
.action(async (opts) => {
try {
const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey();
const result = await callGatewayCli(
"agent",
{ ...opts, expectFinal: true },
{
message: opts.message,
to: opts.to,
sessionId: opts.sessionId,
thinking: opts.thinking,
deliver: Boolean(opts.deliver),
timeout: opts.timeoutSeconds
? Number.parseInt(String(opts.timeoutSeconds), 10)
: undefined,
idempotencyKey,
},
);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
// Build default deps (keeps parity with other commands; future-proofing).
void createDefaultDeps();
}

35
src/cli/gateway-rpc.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { Command } from "commander";
import { callGateway } from "../gateway/call.js";
export type GatewayRpcOpts = {
url?: string;
token?: string;
timeout?: string;
expectFinal?: boolean;
};
export function addGatewayClientOptions(cmd: Command) {
return cmd
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
.option("--token <token>", "Gateway token (if required)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--expect-final", "Wait for final response (agent)", false);
}
export async function callGatewayFromCli(
method: string,
opts: GatewayRpcOpts,
params?: unknown,
extra?: { expectFinal?: boolean },
) {
return await callGateway({
url: opts.url,
token: opts.token,
method,
params,
expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal),
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: "cli",
mode: "cli",
});
}

View File

@@ -5,15 +5,14 @@ import { healthCommand } from "../commands/health.js";
import { sendCommand } from "../commands/send.js";
import { sessionsCommand } from "../commands/sessions.js";
import { statusCommand } from "../commands/status.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { startGatewayServer } from "../gateway/server.js";
import { danger, info, setVerbose } from "../globals.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { loginWeb, logoutWeb } from "../provider-web.js";
import { defaultRuntime } from "../runtime.js";
import { VERSION } from "../version.js";
import { startWebChatServer } from "../webchat/server.js";
import { registerCronCli } from "./cron-cli.js";
import { createDefaultDeps } from "./deps.js";
import { registerGatewayCli } from "./gateway-cli.js";
import { forceFreePort } from "./ports.js";
export { forceFreePort };
@@ -209,266 +208,8 @@ Examples:
}
});
program;
const gateway = program
.command("gateway")
.description("Run the WebSocket Gateway")
.option("--port <port>", "Port for the gateway WebSocket", "18789")
.option(
"--webchat-port <port>",
"Port for the loopback WebChat HTTP server (default 18788)",
)
.option(
"--token <token>",
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
)
.option(
"--force",
"Kill any existing listener on the target port before starting",
false,
)
.option("--verbose", "Verbose logging to stdout/stderr", false)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const port = Number.parseInt(String(opts.port ?? "18789"), 10);
if (Number.isNaN(port) || port <= 0) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
}
const webchatPort = opts.webchatPort
? Number.parseInt(String(opts.webchatPort), 10)
: undefined;
if (
webchatPort !== undefined &&
(Number.isNaN(webchatPort) || webchatPort <= 0)
) {
defaultRuntime.error("Invalid webchat port");
defaultRuntime.exit(1);
}
if (opts.force) {
try {
const killed = forceFreePort(port);
if (killed.length === 0) {
defaultRuntime.log(info(`Force: no listeners on port ${port}`));
} else {
for (const proc of killed) {
defaultRuntime.log(
info(
`Force: killed pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""} on port ${port}`,
),
);
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
} catch (err) {
defaultRuntime.error(`Force: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
}
if (opts.token) {
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
}
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
let shuttingDown = false;
let forceExitTimer: ReturnType<typeof setTimeout> | null = null;
const onSigterm = () => shutdown("SIGTERM");
const onSigint = () => shutdown("SIGINT");
const shutdown = (signal: string) => {
// Ensure we don't leak listeners across restarts/tests.
process.removeListener("SIGTERM", onSigterm);
process.removeListener("SIGINT", onSigint);
if (shuttingDown) {
defaultRuntime.log(
info(`gateway: received ${signal} during shutdown; exiting now`),
);
defaultRuntime.exit(0);
}
shuttingDown = true;
defaultRuntime.log(info(`gateway: received ${signal}; shutting down`));
// Avoid hanging forever if a provider task ignores abort.
forceExitTimer = setTimeout(() => {
defaultRuntime.error(
"gateway: shutdown timed out; exiting without full cleanup",
);
defaultRuntime.exit(0);
}, 5000);
void (async () => {
try {
await server?.close();
} catch (err) {
defaultRuntime.error(`gateway: shutdown error: ${String(err)}`);
} finally {
if (forceExitTimer) clearTimeout(forceExitTimer);
defaultRuntime.exit(0);
}
})();
};
process.once("SIGTERM", onSigterm);
process.once("SIGINT", onSigint);
try {
server = await startGatewayServer(port, { webchatPort });
} catch (err) {
if (err instanceof GatewayLockError) {
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
defaultRuntime.exit(1);
return;
}
defaultRuntime.error(`Gateway failed to start: ${String(err)}`);
defaultRuntime.exit(1);
}
// Keep process alive
await new Promise<never>(() => {});
});
const gatewayCallOpts = (cmd: Command) =>
cmd
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
.option("--token <token>", "Gateway token (if required)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--expect-final", "Wait for final response (agent)", false);
const callGatewayCli = async (
method: string,
opts: {
url?: string;
token?: string;
timeout?: string;
expectFinal?: boolean;
},
params?: unknown,
) =>
callGateway({
url: opts.url,
token: opts.token,
method,
params,
expectFinal: Boolean(opts.expectFinal),
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: "cli",
mode: "cli",
});
gatewayCallOpts(
gateway
.command("call")
.description("Call a Gateway method and print JSON")
.argument(
"<method>",
"Method name (health/status/system-presence/send/agent)",
)
.option("--params <json>", "JSON object string for params", "{}")
.action(async (method, opts) => {
try {
const params = JSON.parse(String(opts.params ?? "{}"));
const result = await callGatewayCli(method, opts, params);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(`Gateway call failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
gatewayCallOpts(
gateway
.command("health")
.description("Fetch Gateway health")
.action(async (opts) => {
try {
const result = await callGatewayCli("health", opts);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
gatewayCallOpts(
gateway
.command("status")
.description("Fetch Gateway status")
.action(async (opts) => {
try {
const result = await callGatewayCli("status", opts);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
gatewayCallOpts(
gateway
.command("send")
.description("Send a message via the Gateway")
.requiredOption("--to <jidOrPhone>", "Destination (E.164 or jid)")
.requiredOption("--message <text>", "Message text")
.option("--media-url <url>", "Optional media URL")
.option("--idempotency-key <key>", "Idempotency key")
.action(async (opts) => {
try {
const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey();
const result = await callGatewayCli("send", opts, {
to: opts.to,
message: opts.message,
mediaUrl: opts.mediaUrl,
idempotencyKey,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
gatewayCallOpts(
gateway
.command("agent")
.description("Run an agent turn via the Gateway (waits for final)")
.requiredOption("--message <text>", "User message")
.option("--to <jidOrPhone>", "Destination")
.option("--session-id <id>", "Session id")
.option("--thinking <level>", "Thinking level")
.option("--deliver", "Deliver response", false)
.option("--timeout-seconds <n>", "Agent timeout seconds")
.option("--idempotency-key <key>", "Idempotency key")
.action(async (opts) => {
try {
const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey();
const result = await callGatewayCli(
"agent",
{ ...opts, expectFinal: true },
{
message: opts.message,
to: opts.to,
sessionId: opts.sessionId,
thinking: opts.thinking,
deliver: Boolean(opts.deliver),
timeout: opts.timeoutSeconds
? Number.parseInt(String(opts.timeoutSeconds), 10)
: undefined,
idempotencyKey,
},
);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
registerGatewayCli(program);
registerCronCli(program);
program
.command("status")
.description("Show web session health and recent session recipients")