diff --git a/CHANGELOG.md b/CHANGELOG.md index b8c7e254f..4b6b9539d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.clawd.bot - Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) - Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338. - Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev. +- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal - Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco. - Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz. - Agents: use the active auth profile for auto-compaction recovery. diff --git a/docs/channels/signal.md b/docs/channels/signal.md index d1d17426b..0ba89385d 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -74,6 +74,22 @@ Example: Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. +## External daemon mode (httpUrl) +If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it: + +```json5 +{ + channels: { + signal: { + httpUrl: "http://127.0.0.1:8080", + autoStart: false + } + } +} +``` + +This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`. + ## Access control (DMs + groups) DMs: - Default: `channels.signal.dmPolicy = "pairing"`. @@ -142,6 +158,7 @@ Provider options: - `channels.signal.httpUrl`: full daemon URL (overrides host/port). - `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080). - `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset). +- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000). - `channels.signal.receiveMode`: `on-start | manual`. - `channels.signal.ignoreAttachments`: skip attachment downloads. - `channels.signal.ignoreStories`: ignore stories from the daemon. diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index d5614bda3..014f62841 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -33,6 +33,8 @@ export type SignalAccountConfig = { cliPath?: string; /** Auto-start signal-cli daemon (default: true if httpUrl not set). */ autoStart?: boolean; + /** Max time to wait for signal-cli daemon startup (ms, cap 120000). */ + startupTimeoutMs?: number; receiveMode?: "on-start" | "manual"; ignoreAttachments?: boolean; ignoreStories?: boolean; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 1f5860f45..2bf1876d7 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -486,6 +486,7 @@ export const SignalAccountSchemaBase = z httpPort: z.number().int().positive().optional(), cliPath: ExecutableTokenSchema.optional(), autoStart: z.boolean().optional(), + startupTimeoutMs: z.number().int().min(1000).max(120000).optional(), receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(), ignoreAttachments: z.boolean().optional(), ignoreStories: z.boolean().optional(), diff --git a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index eda540b7d..c3709890e 100644 --- a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -126,6 +126,90 @@ describe("monitorSignalProvider tool results", () => { }), ); }); + + it("uses startupTimeoutMs override when provided", async () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: ((code: number): never => { + throw new Error(`exit ${code}`); + }) as (code: number) => never, + }; + config = { + ...config, + channels: { + ...config.channels, + signal: { + autoStart: true, + dmPolicy: "open", + allowFrom: ["*"], + startupTimeoutMs: 60_000, + }, + }, + }; + const abortController = new AbortController(); + streamMock.mockImplementation(async () => { + abortController.abort(); + return; + }); + + await monitorSignalProvider({ + autoStart: true, + baseUrl: "http://127.0.0.1:8080", + abortSignal: abortController.signal, + runtime, + startupTimeoutMs: 90_000, + }); + + expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1); + expect(waitForTransportReadyMock).toHaveBeenCalledWith( + expect.objectContaining({ + timeoutMs: 90_000, + }), + ); + }); + + it("caps startupTimeoutMs at 2 minutes", async () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: ((code: number): never => { + throw new Error(`exit ${code}`); + }) as (code: number) => never, + }; + config = { + ...config, + channels: { + ...config.channels, + signal: { + autoStart: true, + dmPolicy: "open", + allowFrom: ["*"], + startupTimeoutMs: 180_000, + }, + }, + }; + const abortController = new AbortController(); + streamMock.mockImplementation(async () => { + abortController.abort(); + return; + }); + + await monitorSignalProvider({ + autoStart: true, + baseUrl: "http://127.0.0.1:8080", + abortSignal: abortController.signal, + runtime, + }); + + expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1); + expect(waitForTransportReadyMock).toHaveBeenCalledWith( + expect.objectContaining({ + timeoutMs: 120_000, + }), + ); + }); + it("sends tool summaries with responsePrefix", async () => { const abortController = new AbortController(); replyMock.mockImplementation(async (_ctx, opts) => { diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 4215a668f..66b023047 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -43,6 +43,7 @@ export type MonitorSignalOpts = { config?: ClawdbotConfig; baseUrl?: string; autoStart?: boolean; + startupTimeoutMs?: number; cliPath?: string; httpHost?: string; httpPort?: number; @@ -285,6 +286,10 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts); const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl; + const startupTimeoutMs = Math.min( + 120_000, + Math.max(1_000, opts.startupTimeoutMs ?? accountInfo.config.startupTimeoutMs ?? 30_000), + ); const readReceiptsViaDaemon = Boolean(autoStart && sendReadReceipts); let daemonHandle: ReturnType | null = null; @@ -315,7 +320,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi await waitForSignalDaemonReady({ baseUrl, abortSignal: opts.abortSignal, - timeoutMs: 30_000, + timeoutMs: startupTimeoutMs, logAfterMs: 10_000, logIntervalMs: 10_000, runtime,