From f14d622c0f6406254a49c4324252c49fe2e36d6e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 01:13:46 +0000 Subject: [PATCH] refactor: centralize account bindings + health probes --- src/commands/health.ts | 562 +++++++++++++++++++++---- src/commands/status.command.ts | 23 +- src/commands/status.summary.ts | 144 ++++--- src/commands/status.types.ts | 21 +- src/config/legacy.migrations.part-1.ts | 28 ++ src/gateway/server-methods/health.ts | 7 +- src/infra/heartbeat-runner.ts | 86 +++- src/routing/bindings.ts | 84 ++++ src/routing/resolve-route.ts | 6 +- src/telegram/accounts.ts | 34 +- src/tui/tui-status-summary.ts | 30 +- src/tui/tui-types.ts | 16 +- 12 files changed, 877 insertions(+), 164 deletions(-) create mode 100644 src/routing/bindings.ts diff --git a/src/commands/health.ts b/src/commands/health.ts index dcc788ab1..3202e7ad0 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,3 +1,4 @@ +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelAccountSnapshot } from "../channels/plugins/types.js"; @@ -7,11 +8,20 @@ import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { + type HeartbeatSummary, + resolveHeartbeatSummaryForAgent, +} from "../infra/heartbeat-runner.js"; import type { RuntimeEnv } from "../runtime.js"; +import { + buildChannelAccountBindings, + resolvePreferredAccountId, +} from "../routing/bindings.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { theme } from "../terminal/theme.js"; -import { resolveHeartbeatSeconds } from "../web/reconnect.js"; -export type ChannelHealthSummary = { +export type ChannelAccountHealthSummary = { + accountId: string; configured?: boolean; linked?: boolean; authAgeMs?: number | null; @@ -20,6 +30,20 @@ export type ChannelHealthSummary = { [key: string]: unknown; }; +export type ChannelHealthSummary = ChannelAccountHealthSummary & { + accounts?: Record; +}; + +export type AgentHeartbeatSummary = HeartbeatSummary; + +export type AgentHealthSummary = { + agentId: string; + name?: string; + isDefault: boolean; + heartbeat: AgentHeartbeatSummary; + sessions: HealthSummary["sessions"]; +}; + export type HealthSummary = { /** * Convenience top-level flag for UIs (e.g. WebChat) that only need a binary @@ -32,7 +56,10 @@ export type HealthSummary = { channels: Record; channelOrder: string[]; channelLabels: Record; + /** Legacy: default agent heartbeat seconds (rounded). */ heartbeatSeconds: number; + defaultAgentId: string; + agents: AgentHealthSummary[]; sessions: { path: string; count: number; @@ -46,6 +73,82 @@ export type HealthSummary = { const DEFAULT_TIMEOUT_MS = 10_000; +const debugHealth = (...args: unknown[]) => { + if (process.env.CLAWDBOT_DEBUG_HEALTH === "1") { + console.warn("[health:debug]", ...args); + } +}; + +const formatDurationParts = (ms: number): string => { + if (!Number.isFinite(ms)) return "unknown"; + if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`; + const units: Array<{ label: string; size: number }> = [ + { label: "w", size: 7 * 24 * 60 * 60 * 1000 }, + { label: "d", size: 24 * 60 * 60 * 1000 }, + { label: "h", size: 60 * 60 * 1000 }, + { label: "m", size: 60 * 1000 }, + { label: "s", size: 1000 }, + ]; + let remaining = Math.max(0, Math.floor(ms)); + const parts: string[] = []; + for (const unit of units) { + const value = Math.floor(remaining / unit.size); + if (value > 0) { + parts.push(`${value}${unit.label}`); + remaining -= value * unit.size; + } + } + if (parts.length === 0) return "0s"; + return parts.join(" "); +}; + +const resolveHeartbeatSummary = (cfg: ReturnType, agentId: string) => + resolveHeartbeatSummaryForAgent(cfg, agentId); + +const resolveAgentOrder = (cfg: ReturnType) => { + const defaultAgentId = resolveDefaultAgentId(cfg); + const entries = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const seen = new Set(); + const ordered: Array<{ id: string; name?: string }> = []; + + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + if (typeof entry.id !== "string" || !entry.id.trim()) continue; + const id = normalizeAgentId(entry.id); + if (!id || seen.has(id)) continue; + seen.add(id); + ordered.push({ id, name: typeof entry.name === "string" ? entry.name : undefined }); + } + + if (!seen.has(defaultAgentId)) { + ordered.unshift({ id: defaultAgentId }); + } + + if (ordered.length === 0) { + ordered.push({ id: defaultAgentId }); + } + + return { defaultAgentId, ordered }; +}; + +const buildSessionSummary = (storePath: string) => { + const store = loadSessionStore(storePath); + const sessions = Object.entries(store) + .filter(([key]) => key !== "global" && key !== "unknown") + .map(([key, entry]) => ({ key, updatedAt: entry?.updatedAt ?? 0 })) + .sort((a, b) => b.updatedAt - a.updatedAt); + const recent = sessions.slice(0, 5).map((s) => ({ + key: s.key, + updatedAt: s.updatedAt || null, + age: s.updatedAt ? Date.now() - s.updatedAt : null, + })); + return { + path: storePath, + count: sessions.length, + recent, + } satisfies HealthSummary["sessions"]; +}; + const isAccountEnabled = (account: unknown): boolean => { if (!account || typeof account !== "object") return true; const enabled = (account as { enabled?: boolean }).enabled; @@ -55,7 +158,10 @@ const isAccountEnabled = (account: unknown): boolean => { const asRecord = (value: unknown): Record | null => value && typeof value === "object" ? (value as Record) : null; -const formatProbeLine = (probe: unknown): string | null => { +const formatProbeLine = ( + probe: unknown, + opts: { botUsernames?: string[] } = {}, +): string | null => { const record = asRecord(probe); if (!record) return null; const ok = typeof record.ok === "boolean" ? record.ok : undefined; @@ -68,9 +174,17 @@ const formatProbeLine = (probe: unknown): string | null => { const webhook = asRecord(record.webhook); const webhookUrl = webhook && typeof webhook.url === "string" ? webhook.url : null; + const usernames = new Set(); + if (botUsername) usernames.add(botUsername); + for (const extra of opts.botUsernames ?? []) { + if (extra) usernames.add(extra); + } + if (ok) { let label = "ok"; - if (botUsername) label += ` (@${botUsername})`; + if (usernames.size > 0) { + label += ` (@${Array.from(usernames).join(", @")})`; + } if (elapsedMs != null) label += ` (${elapsedMs}ms)`; if (webhookUrl) label += ` - webhook ${webhookUrl}`; return label; @@ -80,6 +194,29 @@ const formatProbeLine = (probe: unknown): string | null => { return label; }; +const formatAccountProbeTiming = (summary: ChannelAccountHealthSummary): string | null => { + const probe = asRecord(summary.probe); + if (!probe) return null; + const elapsedMs = typeof probe.elapsedMs === "number" ? Math.round(probe.elapsedMs) : null; + const ok = typeof probe.ok === "boolean" ? probe.ok : null; + if (elapsedMs == null && ok !== true) return null; + + const accountId = summary.accountId || "default"; + const botRecord = asRecord(probe.bot); + const botUsername = botRecord && typeof botRecord.username === "string" ? botRecord.username : null; + const handle = botUsername ? `@${botUsername}` : accountId; + const timing = elapsedMs != null ? `${elapsedMs}ms` : "ok"; + + return `${handle}:${accountId}:${timing}`; +}; + +const isProbeFailure = (summary: ChannelAccountHealthSummary): boolean => { + const probe = asRecord(summary.probe); + if (!probe) return false; + const ok = typeof probe.ok === "boolean" ? probe.ok : null; + return ok === false; +}; + function styleHealthChannelLine(line: string): string { const colon = line.indexOf(":"); if (colon === -1) return line; @@ -102,10 +239,17 @@ function styleHealthChannelLine(line: string): string { return line; } -export const formatHealthChannelLines = (summary: HealthSummary): string[] => { +export const formatHealthChannelLines = ( + summary: HealthSummary, + opts: { + accountMode?: "default" | "all"; + accountIdsByChannel?: Record; + } = {}, +): string[] => { const channels = summary.channels ?? {}; const channelOrder = summary.channelOrder?.length > 0 ? summary.channelOrder : Object.keys(channels); + const accountMode = opts.accountMode ?? "default"; const lines: string[] = []; for (const channelId of channelOrder) { @@ -113,11 +257,36 @@ export const formatHealthChannelLines = (summary: HealthSummary): string[] => { if (!channelSummary) continue; const plugin = getChannelPlugin(channelId as never); const label = summary.channelLabels?.[channelId] ?? plugin?.meta.label ?? channelId; - const linked = typeof channelSummary.linked === "boolean" ? channelSummary.linked : null; + const accountSummaries = channelSummary.accounts ?? {}; + const accountIds = opts.accountIdsByChannel?.[channelId]; + const filteredSummaries = + accountIds && accountIds.length > 0 + ? accountIds + .map((accountId) => accountSummaries[accountId]) + .filter((entry): entry is ChannelAccountHealthSummary => Boolean(entry)) + : undefined; + const listSummaries = + accountMode === "all" + ? Object.values(accountSummaries) + : filteredSummaries ?? (channelSummary.accounts ? Object.values(accountSummaries) : []); + const baseSummary = + filteredSummaries && filteredSummaries.length > 0 + ? filteredSummaries[0] + : channelSummary; + const botUsernames = listSummaries + ? listSummaries + .map((account) => { + const probeRecord = asRecord(account.probe); + const bot = probeRecord ? asRecord(probeRecord.bot) : null; + return bot && typeof bot.username === "string" ? bot.username : null; + }) + .filter((value): value is string => Boolean(value)) + : []; + const linked = typeof baseSummary.linked === "boolean" ? baseSummary.linked : null; if (linked !== null) { if (linked) { const authAgeMs = - typeof channelSummary.authAgeMs === "number" ? channelSummary.authAgeMs : null; + typeof baseSummary.authAgeMs === "number" ? baseSummary.authAgeMs : null; const authLabel = authAgeMs != null ? ` (auth age ${Math.round(authAgeMs / 60000)}m)` : ""; lines.push(`${label}: linked${authLabel}`); } else { @@ -126,14 +295,33 @@ export const formatHealthChannelLines = (summary: HealthSummary): string[] => { continue; } - const configured = - typeof channelSummary.configured === "boolean" ? channelSummary.configured : null; + const configured = typeof baseSummary.configured === "boolean" ? baseSummary.configured : null; if (configured === false) { lines.push(`${label}: not configured`); continue; } - const probeLine = formatProbeLine(channelSummary.probe); + const accountTimings = + accountMode === "all" + ? listSummaries + .map((account) => formatAccountProbeTiming(account)) + .filter((value): value is string => Boolean(value)) + : []; + const failedSummary = listSummaries.find((summary) => isProbeFailure(summary)); + if (failedSummary) { + const failureLine = formatProbeLine(failedSummary.probe, { botUsernames }); + if (failureLine) { + lines.push(`${label}: ${failureLine}`); + continue; + } + } + + if (accountTimings.length > 0) { + lines.push(`${label}: ok (${accountTimings.join(", ")})`); + continue; + } + + const probeLine = formatProbeLine(baseSummary.probe, { botUsernames }); if (probeLine) { lines.push(`${label}: ${probeLine}`); continue; @@ -154,18 +342,28 @@ export async function getHealthSnapshot(params?: { }): Promise { const timeoutMs = params?.timeoutMs; const cfg = loadConfig(); - const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); - const sessions = Object.entries(store) - .filter(([key]) => key !== "global" && key !== "unknown") - .map(([key, entry]) => ({ key, updatedAt: entry?.updatedAt ?? 0 })) - .sort((a, b) => b.updatedAt - a.updatedAt); - const recent = sessions.slice(0, 5).map((s) => ({ - key: s.key, - updatedAt: s.updatedAt || null, - age: s.updatedAt ? Date.now() - s.updatedAt : null, - })); + const { defaultAgentId, ordered } = resolveAgentOrder(cfg); + const channelBindings = buildChannelAccountBindings(cfg); + const sessionCache = new Map(); + const agents: AgentHealthSummary[] = ordered.map((entry) => { + const storePath = resolveStorePath(cfg.session?.store, { agentId: entry.id }); + const sessions = sessionCache.get(storePath) ?? buildSessionSummary(storePath); + sessionCache.set(storePath, sessions); + return { + agentId: entry.id, + name: entry.name, + isDefault: entry.id === defaultAgentId, + heartbeat: resolveHeartbeatSummary(cfg, entry.id), + sessions, + } satisfies AgentHealthSummary; + }); + const defaultAgent = agents.find((agent) => agent.isDefault) ?? agents[0]; + const heartbeatSeconds = defaultAgent?.heartbeat.everyMs + ? Math.round(defaultAgent.heartbeat.everyMs / 1000) + : 0; + const sessions = + defaultAgent?.sessions ?? + buildSessionSummary(resolveStorePath(cfg.session?.store, { agentId: defaultAgentId })); const start = Date.now(); const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS); @@ -182,59 +380,110 @@ export async function getHealthSnapshot(params?: { cfg, accountIds, }); - const account = plugin.config.resolveAccount(cfg, defaultAccountId); - const enabled = plugin.config.isEnabled - ? plugin.config.isEnabled(account, cfg) - : isAccountEnabled(account); - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; + const boundAccounts = channelBindings.get(plugin.id)?.get(defaultAgentId) ?? []; + const preferredAccountId = resolvePreferredAccountId({ + accountIds, + defaultAccountId, + boundAccounts, + }); + const boundAccountIdsAll = Array.from( + new Set(Array.from(channelBindings.get(plugin.id)?.values() ?? []).flatMap((ids) => ids)), + ); + const accountIdsToProbe = Array.from( + new Set( + [preferredAccountId, defaultAccountId, ...accountIds, ...boundAccountIdsAll].filter( + (value) => value && value.trim(), + ), + ), + ); + debugHealth("channel", { + id: plugin.id, + accountIds, + defaultAccountId, + boundAccounts, + preferredAccountId, + accountIdsToProbe, + }); + const accountSummaries: Record = {}; - let probe: unknown; - let lastProbeAt: number | null = null; - if (enabled && configured && doProbe && plugin.status?.probeAccount) { - try { - probe = await plugin.status.probeAccount({ - account, - timeoutMs: cappedTimeout, - cfg, - }); - lastProbeAt = Date.now(); - } catch (err) { - probe = { ok: false, error: formatErrorMessage(err) }; - lastProbeAt = Date.now(); + for (const accountId of accountIdsToProbe) { + const account = plugin.config.resolveAccount(cfg, accountId); + const enabled = plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : isAccountEnabled(account); + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, cfg) + : true; + + let probe: unknown; + let lastProbeAt: number | null = null; + if (enabled && configured && doProbe && plugin.status?.probeAccount) { + try { + probe = await plugin.status.probeAccount({ + account, + timeoutMs: cappedTimeout, + cfg, + }); + lastProbeAt = Date.now(); + } catch (err) { + probe = { ok: false, error: formatErrorMessage(err) }; + lastProbeAt = Date.now(); + } } + + const probeRecord = probe && typeof probe === "object" ? (probe as Record) : null; + const bot = + probeRecord && typeof probeRecord.bot === "object" + ? (probeRecord.bot as { username?: string | null }) + : null; + if (bot?.username) { + debugHealth("probe.bot", { channel: plugin.id, accountId, username: bot.username }); + } + + const snapshot: ChannelAccountSnapshot = { + accountId, + enabled, + configured, + }; + if (probe !== undefined) snapshot.probe = probe; + if (lastProbeAt) snapshot.lastProbeAt = lastProbeAt; + + const summary = plugin.status?.buildChannelSummary + ? await plugin.status.buildChannelSummary({ + account, + cfg, + defaultAccountId: accountId, + snapshot, + }) + : undefined; + const record = + summary && typeof summary === "object" + ? (summary as ChannelAccountHealthSummary) + : ({ + accountId, + configured, + probe, + lastProbeAt, + } satisfies ChannelAccountHealthSummary); + if (record.configured === undefined) record.configured = configured; + if (record.lastProbeAt === undefined && lastProbeAt) { + record.lastProbeAt = lastProbeAt; + } + record.accountId = accountId; + accountSummaries[accountId] = record; } - const snapshot: ChannelAccountSnapshot = { - accountId: defaultAccountId, - enabled, - configured, - }; - if (probe !== undefined) snapshot.probe = probe; - if (lastProbeAt) snapshot.lastProbeAt = lastProbeAt; - - const summary = plugin.status?.buildChannelSummary - ? await plugin.status.buildChannelSummary({ - account, - cfg, - defaultAccountId, - snapshot, - }) - : undefined; - const record = - summary && typeof summary === "object" - ? (summary as ChannelHealthSummary) - : ({ - configured, - probe, - lastProbeAt, - } satisfies ChannelHealthSummary); - if (record.configured === undefined) record.configured = configured; - if (record.lastProbeAt === undefined && lastProbeAt) { - record.lastProbeAt = lastProbeAt; + const defaultSummary = + accountSummaries[preferredAccountId] ?? + accountSummaries[defaultAccountId] ?? + accountSummaries[accountIdsToProbe[0] ?? preferredAccountId]; + const fallbackSummary = defaultSummary ?? accountSummaries[Object.keys(accountSummaries)[0]]; + if (fallbackSummary) { + channels[plugin.id] = { + ...fallbackSummary, + accounts: accountSummaries, + } satisfies ChannelHealthSummary; } - channels[plugin.id] = record; } const summary: HealthSummary = { @@ -245,10 +494,12 @@ export async function getHealthSnapshot(params?: { channelOrder, channelLabels, heartbeatSeconds, + defaultAgentId, + agents, sessions: { - path: storePath, - count: sessions.length, - recent, + path: sessions.path, + count: sessions.count, + recent: sessions.recent, }, }; @@ -269,6 +520,7 @@ export async function healthCommand( async () => await callGateway({ method: "health", + params: opts.verbose ? { probe: true } : undefined, timeoutMs: opts.timeoutMs, }), ); @@ -278,6 +530,7 @@ export async function healthCommand( if (opts.json) { runtime.log(JSON.stringify(summary, null, 2)); } else { + const debugEnabled = process.env.CLAWDBOT_DEBUG_HEALTH === "1"; if (opts.verbose) { const details = buildGatewayConnectionDetails(); runtime.log(info("Gateway connection:")); @@ -285,21 +538,133 @@ export async function healthCommand( runtime.log(` ${line}`); } } - for (const line of formatHealthChannelLines(summary)) { + const cfg = loadConfig(); + const localAgents = resolveAgentOrder(cfg); + const defaultAgentId = summary.defaultAgentId ?? localAgents.defaultAgentId; + const agents = Array.isArray(summary.agents) ? summary.agents : []; + const fallbackAgents = localAgents.ordered.map((entry) => { + const storePath = resolveStorePath(cfg.session?.store, { agentId: entry.id }); + return { + agentId: entry.id, + name: entry.name, + isDefault: entry.id === localAgents.defaultAgentId, + heartbeat: resolveHeartbeatSummary(cfg, entry.id), + sessions: buildSessionSummary(storePath), + } satisfies AgentHealthSummary; + }); + const resolvedAgents = agents.length > 0 ? agents : fallbackAgents; + const displayAgents = opts.verbose + ? resolvedAgents + : resolvedAgents.filter((agent) => agent.agentId === defaultAgentId); + const channelBindings = buildChannelAccountBindings(cfg); + if (debugEnabled) { + runtime.log(info("[debug] local channel accounts")); + for (const plugin of listChannelPlugins()) { + const accountIds = plugin.config.listAccountIds(cfg); + const defaultAccountId = resolveChannelDefaultAccountId({ + plugin, + cfg, + accountIds, + }); + runtime.log( + ` ${plugin.id}: accounts=${accountIds.join(", ") || "(none)"} default=${defaultAccountId}`, + ); + for (const accountId of accountIds) { + const account = plugin.config.resolveAccount(cfg, accountId); + const record = asRecord(account); + const tokenSource = + record && typeof record.tokenSource === "string" ? record.tokenSource : undefined; + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, cfg) + : true; + runtime.log( + ` - ${accountId}: configured=${configured}${tokenSource ? ` tokenSource=${tokenSource}` : ""}`, + ); + } + } + runtime.log(info("[debug] bindings map")); + for (const [channelId, byAgent] of channelBindings.entries()) { + const entries = Array.from(byAgent.entries()).map( + ([agentId, ids]) => `${agentId}=[${ids.join(", ")}]`, + ); + runtime.log(` ${channelId}: ${entries.join(" ")}`); + } + runtime.log(info("[debug] gateway channel probes")); + for (const [channelId, channelSummary] of Object.entries(summary.channels ?? {})) { + const accounts = channelSummary.accounts ?? {}; + const probes = Object.entries(accounts).map(([accountId, accountSummary]) => { + const probe = asRecord(accountSummary.probe); + const bot = probe ? asRecord(probe.bot) : null; + const username = bot && typeof bot.username === "string" ? bot.username : null; + return `${accountId}=${username ?? "(no bot)"}`; + }); + runtime.log(` ${channelId}: ${probes.join(", ") || "(none)"}`); + } + } + const channelAccountFallbacks = Object.fromEntries( + listChannelPlugins().map((plugin) => { + const accountIds = plugin.config.listAccountIds(cfg); + const defaultAccountId = resolveChannelDefaultAccountId({ + plugin, + cfg, + accountIds, + }); + const preferred = resolvePreferredAccountId({ + accountIds, + defaultAccountId, + boundAccounts: channelBindings.get(plugin.id)?.get(defaultAgentId) ?? [], + }); + return [plugin.id, [preferred] as string[]] as const; + }), + ); + const accountIdsByChannel = (() => { + const entries = displayAgents.length > 0 ? displayAgents : resolvedAgents; + const byChannel: Record = {}; + for (const [channelId, byAgent] of channelBindings.entries()) { + const accountIds: string[] = []; + for (const agent of entries) { + const ids = byAgent.get(agent.agentId) ?? []; + for (const id of ids) { + if (!accountIds.includes(id)) accountIds.push(id); + } + } + if (accountIds.length > 0) byChannel[channelId] = accountIds; + } + for (const [channelId, fallbackIds] of Object.entries(channelAccountFallbacks)) { + if (!byChannel[channelId] || byChannel[channelId].length === 0) { + byChannel[channelId] = fallbackIds; + } + } + return byChannel; + })(); + const channelLines = Object.keys(accountIdsByChannel).length > 0 + ? formatHealthChannelLines(summary, { + accountMode: opts.verbose ? "all" : "default", + accountIdsByChannel, + }) + : formatHealthChannelLines(summary, { + accountMode: opts.verbose ? "all" : "default", + }); + for (const line of channelLines) { runtime.log(styleHealthChannelLine(line)); } - const cfg = loadConfig(); for (const plugin of listChannelPlugins()) { const channelSummary = summary.channels?.[plugin.id]; if (!channelSummary || channelSummary.linked !== true) continue; if (!plugin.status?.logSelfId) continue; + const boundAccounts = channelBindings.get(plugin.id)?.get(defaultAgentId) ?? []; const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg, accountIds, }); - const account = plugin.config.resolveAccount(cfg, defaultAccountId); + const accountId = resolvePreferredAccountId({ + accountIds, + defaultAccountId, + boundAccounts, + }); + const account = plugin.config.resolveAccount(cfg, accountId); plugin.status.logSelfId({ account, cfg, @@ -308,16 +673,45 @@ export async function healthCommand( }); } - runtime.log(info(`Heartbeat interval: ${summary.heartbeatSeconds}s`)); - runtime.log( - info(`Session store: ${summary.sessions.path} (${summary.sessions.count} entries)`), - ); - if (summary.sessions.recent.length > 0) { - runtime.log("Recent sessions:"); - for (const r of summary.sessions.recent) { + if (resolvedAgents.length > 0) { + const agentLabels = resolvedAgents.map((agent) => + agent.isDefault ? `${agent.agentId} (default)` : agent.agentId, + ); + runtime.log(info(`Agents: ${agentLabels.join(", ")}`)); + } + const heartbeatParts = displayAgents + .map((agent) => { + const everyMs = agent.heartbeat?.everyMs; + const label = everyMs ? formatDurationParts(everyMs) : "disabled"; + return `${label} (${agent.agentId})`; + }) + .filter(Boolean); + if (heartbeatParts.length > 0) { + runtime.log(info(`Heartbeat interval: ${heartbeatParts.join(", ")}`)); + } + if (displayAgents.length === 0) { + runtime.log(info(`Session store: ${summary.sessions.path} (${summary.sessions.count} entries)`)); + if (summary.sessions.recent.length > 0) { + for (const r of summary.sessions.recent) { + runtime.log( + `- ${r.key} (${r.updatedAt ? `${Math.round((Date.now() - r.updatedAt) / 60000)}m ago` : "no activity"})`, + ); + } + } + } else { + for (const agent of displayAgents) { runtime.log( - `- ${r.key} (${r.updatedAt ? `${Math.round((Date.now() - r.updatedAt) / 60000)}m ago` : "no activity"})`, + info( + `Session store (${agent.agentId}): ${agent.sessions.path} (${agent.sessions.count} entries)`, + ), ); + if (agent.sessions.recent.length > 0) { + for (const r of agent.sessions.recent) { + runtime.log( + `- ${r.key} (${r.updatedAt ? `${Math.round((Date.now() - r.updatedAt) / 60000)}m ago` : "no activity"})`, + ); + } + } } } } diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index c17a9e4e8..60b5af23c 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -97,6 +97,7 @@ export async function statusCommand( async () => await callGateway({ method: "health", + params: { probe: true }, timeoutMs: opts.timeoutMs, }), ) @@ -211,6 +212,22 @@ export async function statusCommand( const probesValue = health ? ok("enabled") : muted("skipped (use --deep)"); + const heartbeatValue = (() => { + const parts = summary.heartbeat.agents + .map((agent) => { + if (!agent.enabled || !agent.everyMs) return `disabled (${agent.agentId})`; + const everyLabel = agent.every; + return `${everyLabel} (${agent.agentId})`; + }) + .filter(Boolean); + return parts.length > 0 ? parts.join(", ") : "disabled"; + })(); + + const storeLabel = + summary.sessions.paths.length > 1 + ? `${summary.sessions.paths.length} stores` + : summary.sessions.paths[0] ?? "unknown"; + const overviewRows = [ { Item: "Dashboard", Value: dashboard }, { Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` }, @@ -232,10 +249,10 @@ export async function statusCommand( { Item: "Agents", Value: agentsValue }, { Item: "Probes", Value: probesValue }, { Item: "Events", Value: eventsValue }, - { Item: "Heartbeat", Value: `${summary.heartbeatSeconds}s` }, + { Item: "Heartbeat", Value: heartbeatValue }, { Item: "Sessions", - Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · store ${summary.sessions.path}`, + Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · ${storeLabel}`, }, ]; @@ -396,7 +413,7 @@ export async function statusCommand( Detail: `${health.durationMs}ms`, }); - for (const line of formatHealthChannelLines(health)) { + for (const line of formatHealthChannelLines(health, { accountMode: "all" })) { const colon = line.indexOf(":"); if (colon === -1) continue; const item = line.slice(0, colon).trim(); diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index e7f28abed..3f6d5ea94 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -8,11 +8,13 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; +import { listAgentsForGateway } from "../gateway/session-utils.js"; import { buildChannelSummary } from "../infra/channel-summary.js"; +import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-runner.js"; import { peekSystemEvents } from "../infra/system-events.js"; -import { resolveHeartbeatSeconds } from "../web/reconnect.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { resolveLinkChannelContext } from "./status.link-channel.js"; -import type { SessionStatus, StatusSummary } from "./status.types.js"; +import type { HeartbeatStatus, SessionStatus, StatusSummary } from "./status.types.js"; const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] => { if (key === "global") return "global"; @@ -24,7 +26,8 @@ const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] = return "direct"; }; -const buildFlags = (entry: SessionEntry): string[] => { +const buildFlags = (entry?: SessionEntry): string[] => { + if (!entry) return []; const flags: string[] = []; const think = entry?.thinkingLevel; if (typeof think === "string" && think.length > 0) flags.push(`think:${think}`); @@ -44,7 +47,16 @@ const buildFlags = (entry: SessionEntry): string[] => { export async function getStatusSummary(): Promise { const cfg = loadConfig(); const linkContext = await resolveLinkChannelContext(cfg); - const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); + const agentList = listAgentsForGateway(cfg); + const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => { + const summary = resolveHeartbeatSummaryForAgent(cfg, agent.id); + return { + agentId: agent.id, + enabled: summary.enabled, + every: summary.every, + everyMs: summary.everyMs, + } satisfies HeartbeatStatus; + }); const channelSummary = await buildChannelSummary(cfg, { colorize: true, includeAllowFrom: true, @@ -63,50 +75,82 @@ export async function getStatusSummary(): Promise { lookupContextTokens(configModel) ?? DEFAULT_CONTEXT_TOKENS; - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); const now = Date.now(); - const sessions = Object.entries(store) - .filter(([key]) => key !== "global" && key !== "unknown") - .map(([key, entry]) => { - const updatedAt = entry?.updatedAt ?? null; - const age = updatedAt ? now - updatedAt : null; - const model = entry?.model ?? configModel ?? null; - const contextTokens = - entry?.contextTokens ?? lookupContextTokens(model) ?? configContextTokens ?? null; - const input = entry?.inputTokens ?? 0; - const output = entry?.outputTokens ?? 0; - const total = entry?.totalTokens ?? input + output; - const remaining = contextTokens != null ? Math.max(0, contextTokens - total) : null; - const pct = - contextTokens && contextTokens > 0 - ? Math.min(999, Math.round((total / contextTokens) * 100)) - : null; + const storeCache = new Map>(); + const loadStore = (storePath: string) => { + const cached = storeCache.get(storePath); + if (cached) return cached; + const store = loadSessionStore(storePath); + storeCache.set(storePath, store); + return store; + }; + const buildSessionRows = ( + store: Record, + opts: { agentIdOverride?: string } = {}, + ) => + Object.entries(store) + .filter(([key]) => key !== "global" && key !== "unknown") + .map(([key, entry]) => { + const updatedAt = entry?.updatedAt ?? null; + const age = updatedAt ? now - updatedAt : null; + const model = entry?.model ?? configModel ?? null; + const contextTokens = + entry?.contextTokens ?? lookupContextTokens(model) ?? configContextTokens ?? null; + const input = entry?.inputTokens ?? 0; + const output = entry?.outputTokens ?? 0; + const total = entry?.totalTokens ?? input + output; + const remaining = contextTokens != null ? Math.max(0, contextTokens - total) : null; + const pct = + contextTokens && contextTokens > 0 + ? Math.min(999, Math.round((total / contextTokens) * 100)) + : null; + const parsedAgentId = parseAgentSessionKey(key)?.agentId; + const agentId = opts.agentIdOverride ?? parsedAgentId; - return { - key, - kind: classifyKey(key, entry), - sessionId: entry?.sessionId, - updatedAt, - age, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - reasoningLevel: entry?.reasoningLevel, - elevatedLevel: entry?.elevatedLevel, - systemSent: entry?.systemSent, - abortedLastRun: entry?.abortedLastRun, - inputTokens: entry?.inputTokens, - outputTokens: entry?.outputTokens, - totalTokens: total ?? null, - remainingTokens: remaining, - percentUsed: pct, - model, - contextTokens, - flags: buildFlags(entry), - } satisfies SessionStatus; - }) + return { + agentId, + key, + kind: classifyKey(key, entry), + sessionId: entry?.sessionId, + updatedAt, + age, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, + elevatedLevel: entry?.elevatedLevel, + systemSent: entry?.systemSent, + abortedLastRun: entry?.abortedLastRun, + inputTokens: entry?.inputTokens, + outputTokens: entry?.outputTokens, + totalTokens: total ?? null, + remainingTokens: remaining, + percentUsed: pct, + model, + contextTokens, + flags: buildFlags(entry), + } satisfies SessionStatus; + }) + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + + const paths = new Set(); + const byAgent = agentList.agents.map((agent) => { + const storePath = resolveStorePath(cfg.session?.store, { agentId: agent.id }); + paths.add(storePath); + const store = loadStore(storePath); + const sessions = buildSessionRows(store, { agentIdOverride: agent.id }); + return { + agentId: agent.id, + path: storePath, + count: sessions.length, + recent: sessions.slice(0, 10), + }; + }); + + const allSessions = Array.from(paths) + .flatMap((storePath) => buildSessionRows(loadStore(storePath))) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); - const recent = sessions.slice(0, 5); + const recent = allSessions.slice(0, 10); + const totalSessions = allSessions.length; return { linkChannel: linkContext @@ -117,17 +161,21 @@ export async function getStatusSummary(): Promise { authAgeMs: linkContext.authAgeMs, } : undefined, - heartbeatSeconds, + heartbeat: { + defaultAgentId: agentList.defaultId, + agents: heartbeatAgents, + }, channelSummary, queuedSystemEvents, sessions: { - path: storePath, - count: sessions.length, + paths: Array.from(paths), + count: totalSessions, defaults: { model: configModel ?? null, contextTokens: configContextTokens ?? null, }, recent, + byAgent, }, }; } diff --git a/src/commands/status.types.ts b/src/commands/status.types.ts index f63c5b3e8..dba1e19e8 100644 --- a/src/commands/status.types.ts +++ b/src/commands/status.types.ts @@ -1,6 +1,7 @@ import type { ChannelId } from "../channels/plugins/types.js"; export type SessionStatus = { + agentId?: string; key: string; kind: "direct" | "group" | "global" | "unknown"; sessionId?: string; @@ -22,6 +23,13 @@ export type SessionStatus = { flags: string[]; }; +export type HeartbeatStatus = { + agentId: string; + enabled: boolean; + every: string; + everyMs: number | null; +}; + export type StatusSummary = { linkChannel?: { id: ChannelId; @@ -29,13 +37,22 @@ export type StatusSummary = { linked: boolean; authAgeMs: number | null; }; - heartbeatSeconds: number; + heartbeat: { + defaultAgentId: string; + agents: HeartbeatStatus[]; + }; channelSummary: string[]; queuedSystemEvents: string[]; sessions: { - path: string; + paths: string[]; count: number; defaults: { model: string | null; contextTokens: number | null }; recent: SessionStatus[]; + byAgent: Array<{ + agentId: string; + path: string; + count: number; + recent: SessionStatus[]; + }>; }; }; diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.part-1.ts index f37222730..d1d0a57e7 100644 --- a/src/config/legacy.migrations.part-1.ts +++ b/src/config/legacy.migrations.part-1.ts @@ -34,6 +34,34 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ } }, }, + { + id: "bindings.match.accountID->bindings.match.accountId", + describe: "Move bindings[].match.accountID to bindings[].match.accountId", + apply: (raw, changes) => { + const bindings = Array.isArray(raw.bindings) ? raw.bindings : null; + if (!bindings) return; + + let touched = false; + for (const entry of bindings) { + if (!isRecord(entry)) continue; + const match = getRecord(entry.match); + if (!match) continue; + if (match.accountId !== undefined) continue; + const accountID = + typeof match.accountID === "string" ? match.accountID.trim() : match.accountID; + if (!accountID) continue; + match.accountId = accountID; + delete match.accountID; + entry.match = match; + touched = true; + } + + if (touched) { + raw.bindings = bindings; + changes.push("Moved bindings[].match.accountID → bindings[].match.accountId."); + } + }, + }, { id: "session.sendPolicy.rules.match.provider->match.channel", describe: "Move session.sendPolicy.rules[].match.provider to match.channel", diff --git a/src/gateway/server-methods/health.ts b/src/gateway/server-methods/health.ts index 5706e2e51..d03a468ee 100644 --- a/src/gateway/server-methods/health.ts +++ b/src/gateway/server-methods/health.ts @@ -6,11 +6,12 @@ import { formatForLog } from "../ws-log.js"; import type { GatewayRequestHandlers } from "./types.js"; export const healthHandlers: GatewayRequestHandlers = { - health: async ({ respond, context }) => { + health: async ({ respond, context, params }) => { const { getHealthCache, refreshHealthSnapshot, logHealth } = context; + const wantsProbe = params?.probe === true; const now = Date.now(); const cached = getHealthCache(); - if (cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) { + if (!wantsProbe && cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) { respond(true, cached, undefined, { cached: true }); void refreshHealthSnapshot({ probe: false }).catch((err) => logHealth.error(`background health refresh failed: ${formatError(err)}`), @@ -18,7 +19,7 @@ export const healthHandlers: GatewayRequestHandlers = { return; } try { - const snap = await refreshHealthSnapshot({ probe: false }); + const snap = await refreshHealthSnapshot({ probe: wantsProbe }); respond(true, snap, undefined); } catch (err) { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 6b4ff02ea..001a0abeb 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -58,6 +58,35 @@ type HeartbeatAgent = { heartbeat?: HeartbeatConfig; }; +export type HeartbeatSummary = { + enabled: boolean; + every: string; + everyMs: number | null; + prompt: string; + target: string; + model?: string; + ackMaxChars: number; +}; + +const DEFAULT_HEARTBEAT_TARGET = "last"; + +function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) { + const list = cfg.agents?.list ?? []; + return list.some((entry) => Boolean(entry?.heartbeat)); +} + +export function isHeartbeatEnabledForAgent(cfg: ClawdbotConfig, agentId?: string): boolean { + const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); + const list = cfg.agents?.list ?? []; + const hasExplicit = hasExplicitHeartbeatAgents(cfg); + if (hasExplicit) { + return list.some( + (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId, + ); + } + return resolvedAgentId === resolveDefaultAgentId(cfg); +} + function resolveHeartbeatConfig( cfg: ClawdbotConfig, agentId?: string, @@ -69,11 +98,59 @@ function resolveHeartbeatConfig( return { ...defaults, ...overrides }; } +export function resolveHeartbeatSummaryForAgent( + cfg: ClawdbotConfig, + agentId?: string, +): HeartbeatSummary { + const defaults = cfg.agents?.defaults?.heartbeat; + const overrides = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined; + const enabled = isHeartbeatEnabledForAgent(cfg, agentId); + + if (!enabled) { + return { + enabled: false, + every: "disabled", + everyMs: null, + prompt: resolveHeartbeatPromptText(defaults?.prompt), + target: defaults?.target ?? DEFAULT_HEARTBEAT_TARGET, + model: defaults?.model, + ackMaxChars: Math.max(0, defaults?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS), + }; + } + + const merged = defaults || overrides ? { ...defaults, ...overrides } : undefined; + const every = merged?.every ?? defaults?.every ?? overrides?.every ?? DEFAULT_HEARTBEAT_EVERY; + const everyMs = resolveHeartbeatIntervalMs(cfg, undefined, merged); + const prompt = resolveHeartbeatPromptText( + merged?.prompt ?? defaults?.prompt ?? overrides?.prompt, + ); + const target = + merged?.target ?? defaults?.target ?? overrides?.target ?? DEFAULT_HEARTBEAT_TARGET; + const model = merged?.model ?? defaults?.model ?? overrides?.model; + const ackMaxChars = Math.max( + 0, + merged?.ackMaxChars ?? + defaults?.ackMaxChars ?? + overrides?.ackMaxChars ?? + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + ); + + return { + enabled: true, + every, + everyMs, + prompt, + target, + model, + ackMaxChars, + }; +} + function resolveHeartbeatAgents(cfg: ClawdbotConfig): HeartbeatAgent[] { const list = cfg.agents?.list ?? []; - const explicit = list.filter((entry) => entry?.heartbeat); - if (explicit.length > 0) { - return explicit + if (hasExplicitHeartbeatAgents(cfg)) { + return list + .filter((entry) => entry?.heartbeat) .map((entry) => { const id = normalizeAgentId(entry.id); return { agentId: id, heartbeat: resolveHeartbeatConfig(cfg, id) }; @@ -244,6 +321,9 @@ export async function runHeartbeatOnce(opts: { if (!heartbeatsEnabled) { return { status: "skipped", reason: "disabled" }; } + if (!isHeartbeatEnabledForAgent(cfg, agentId)) { + return { status: "skipped", reason: "disabled" }; + } if (!resolveHeartbeatIntervalMs(cfg, undefined, heartbeat)) { return { status: "skipped", reason: "disabled" }; } diff --git a/src/routing/bindings.ts b/src/routing/bindings.ts new file mode 100644 index 000000000..e2d09bec7 --- /dev/null +++ b/src/routing/bindings.ts @@ -0,0 +1,84 @@ +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { normalizeChatChannelId } from "../channels/registry.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { AgentBinding } from "../config/types.agents.js"; +import { normalizeAccountId, normalizeAgentId } from "./session-key.js"; + +function normalizeBindingChannelId(raw?: string | null): string | null { + const normalized = normalizeChatChannelId(raw); + if (normalized) return normalized; + const fallback = (raw ?? "").trim().toLowerCase(); + return fallback || null; +} + +export function listBindings(cfg: ClawdbotConfig): AgentBinding[] { + return Array.isArray(cfg.bindings) ? cfg.bindings : []; +} + +export function listBoundAccountIds(cfg: ClawdbotConfig, channelId: string): string[] { + const normalizedChannel = normalizeBindingChannelId(channelId); + if (!normalizedChannel) return []; + const ids = new Set(); + for (const binding of listBindings(cfg)) { + if (!binding || typeof binding !== "object") continue; + const match = binding.match; + if (!match || typeof match !== "object") continue; + const channel = normalizeBindingChannelId(match.channel); + if (!channel || channel !== normalizedChannel) continue; + const accountId = typeof match.accountId === "string" ? match.accountId.trim() : ""; + if (!accountId || accountId === "*") continue; + ids.add(normalizeAccountId(accountId)); + } + return Array.from(ids).sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultAgentBoundAccountId( + cfg: ClawdbotConfig, + channelId: string, +): string | null { + const normalizedChannel = normalizeBindingChannelId(channelId); + if (!normalizedChannel) return null; + const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); + for (const binding of listBindings(cfg)) { + if (!binding || typeof binding !== "object") continue; + if (normalizeAgentId(binding.agentId) !== defaultAgentId) continue; + const match = binding.match; + if (!match || typeof match !== "object") continue; + const channel = normalizeBindingChannelId(match.channel); + if (!channel || channel !== normalizedChannel) continue; + const accountId = typeof match.accountId === "string" ? match.accountId.trim() : ""; + if (!accountId || accountId === "*") continue; + return normalizeAccountId(accountId); + } + return null; +} + +export function buildChannelAccountBindings(cfg: ClawdbotConfig) { + const map = new Map>(); + for (const binding of listBindings(cfg)) { + if (!binding || typeof binding !== "object") continue; + const match = binding.match; + if (!match || typeof match !== "object") continue; + const channelId = normalizeBindingChannelId(match.channel); + if (!channelId) continue; + const accountId = typeof match.accountId === "string" ? match.accountId.trim() : ""; + if (!accountId || accountId === "*") continue; + const agentId = normalizeAgentId(binding.agentId); + const byAgent = map.get(channelId) ?? new Map(); + const list = byAgent.get(agentId) ?? []; + const normalizedAccountId = normalizeAccountId(accountId); + if (!list.includes(normalizedAccountId)) list.push(normalizedAccountId); + byAgent.set(agentId, list); + map.set(channelId, byAgent); + } + return map; +} + +export function resolvePreferredAccountId(params: { + accountIds: string[]; + defaultAccountId: string; + boundAccounts: string[]; +}): string { + if (params.boundAccounts.length > 0) return params.boundAccounts[0]; + return params.defaultAccountId; +} diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 15d51923b..26f405176 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -1,5 +1,6 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { ClawdbotConfig } from "../config/config.js"; +import { listBindings } from "./bindings.js"; import { buildAgentMainSessionKey, buildAgentPeerSessionKey, @@ -85,11 +86,6 @@ export function buildAgentSessionKey(params: { }); } -function listBindings(cfg: ClawdbotConfig) { - const bindings = cfg.bindings; - return Array.isArray(bindings) ? bindings : []; -} - function listAgents(cfg: ClawdbotConfig) { const agents = cfg.agents?.list; return Array.isArray(agents) ? agents : []; diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index d13b30440..75cd57fcc 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -1,8 +1,15 @@ import type { ClawdbotConfig } from "../config/config.js"; import type { TelegramAccountConfig } from "../config/types.js"; +import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { resolveTelegramToken } from "./token.js"; +const debugAccounts = (...args: unknown[]) => { + if (process.env.CLAWDBOT_DEBUG_TELEGRAM_ACCOUNTS === "1") { + console.warn("[telegram:accounts]", ...args); + } +}; + export type ResolvedTelegramAccount = { accountId: string; enabled: boolean; @@ -15,16 +22,26 @@ export type ResolvedTelegramAccount = { function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { const accounts = cfg.channels?.telegram?.accounts; if (!accounts || typeof accounts !== "object") return []; - return Object.keys(accounts).filter(Boolean); + const ids = new Set(); + for (const key of Object.keys(accounts)) { + if (!key) continue; + ids.add(normalizeAccountId(key)); + } + return [...ids]; } export function listTelegramAccountIds(cfg: ClawdbotConfig): string[] { - const ids = listConfiguredAccountIds(cfg); + const ids = Array.from( + new Set([...listConfiguredAccountIds(cfg), ...listBoundAccountIds(cfg, "telegram")]), + ); + debugAccounts("listTelegramAccountIds", ids); if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; return ids.sort((a, b) => a.localeCompare(b)); } export function resolveDefaultTelegramAccountId(cfg: ClawdbotConfig): string { + const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram"); + if (boundDefault) return boundDefault; const ids = listTelegramAccountIds(cfg); if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; return ids[0] ?? DEFAULT_ACCOUNT_ID; @@ -36,7 +53,13 @@ function resolveAccountConfig( ): TelegramAccountConfig | undefined { const accounts = cfg.channels?.telegram?.accounts; if (!accounts || typeof accounts !== "object") return undefined; - return accounts[accountId] as TelegramAccountConfig | undefined; + const direct = accounts[accountId] as TelegramAccountConfig | undefined; + if (direct) return direct; + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find( + (key) => normalizeAccountId(key) === normalized, + ); + return matchKey ? (accounts[matchKey] as TelegramAccountConfig | undefined) : undefined; } function mergeTelegramAccountConfig(cfg: ClawdbotConfig, accountId: string): TelegramAccountConfig { @@ -58,6 +81,11 @@ export function resolveTelegramAccount(params: { const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; const tokenResolution = resolveTelegramToken(params.cfg, { accountId }); + debugAccounts("resolve", { + accountId, + enabled, + tokenSource: tokenResolution.source, + }); return { accountId, enabled, diff --git a/src/tui/tui-status-summary.ts b/src/tui/tui-status-summary.ts index 0882b5085..2c0402fac 100644 --- a/src/tui/tui-status-summary.ts +++ b/src/tui/tui-status-summary.ts @@ -7,14 +7,14 @@ export function formatStatusSummary(summary: GatewayStatusSummary) { const lines: string[] = []; lines.push("Gateway status"); - if (!summary.linkProvider) { - lines.push("Link provider: unknown"); + if (!summary.linkChannel) { + lines.push("Link channel: unknown"); } else { - const linkLabel = summary.linkProvider.label ?? "Link provider"; - const linked = summary.linkProvider.linked === true; + const linkLabel = summary.linkChannel.label ?? "Link channel"; + const linked = summary.linkChannel.linked === true; const authAge = - linked && typeof summary.linkProvider.authAgeMs === "number" - ? ` (last refreshed ${formatAge(summary.linkProvider.authAgeMs)})` + linked && typeof summary.linkChannel.authAgeMs === "number" + ? ` (last refreshed ${formatAge(summary.linkChannel.authAgeMs)})` : ""; lines.push(`${linkLabel}: ${linked ? "linked" : "not linked"}${authAge}`); } @@ -28,13 +28,23 @@ export function formatStatusSummary(summary: GatewayStatusSummary) { } } - if (typeof summary.heartbeatSeconds === "number") { + const heartbeatAgents = summary.heartbeat?.agents ?? []; + if (heartbeatAgents.length > 0) { + const heartbeatParts = heartbeatAgents.map((agent) => { + const agentId = agent.agentId ?? "unknown"; + if (!agent.enabled || !agent.everyMs) return `disabled (${agentId})`; + return `${agent.every ?? "unknown"} (${agentId})`; + }); lines.push(""); - lines.push(`Heartbeat: ${summary.heartbeatSeconds}s`); + lines.push(`Heartbeat: ${heartbeatParts.join(", ")}`); } - const sessionPath = summary.sessions?.path; - if (sessionPath) lines.push(`Session store: ${sessionPath}`); + const sessionPaths = summary.sessions?.paths ?? []; + if (sessionPaths.length === 1) { + lines.push(`Session store: ${sessionPaths[0]}`); + } else if (sessionPaths.length > 1) { + lines.push(`Session stores: ${sessionPaths.length}`); + } const defaults = summary.sessions?.defaults; const defaultModel = defaults?.model ?? "unknown"; diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index 22ec1c365..8914d5d12 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -47,19 +47,29 @@ export type AgentSummary = { }; export type GatewayStatusSummary = { - linkProvider?: { + linkChannel?: { + id?: string; label?: string; linked?: boolean; authAgeMs?: number | null; }; - heartbeatSeconds?: number; + heartbeat?: { + defaultAgentId?: string; + agents?: Array<{ + agentId?: string; + enabled?: boolean; + every?: string; + everyMs?: number | null; + }>; + }; providerSummary?: string[]; queuedSystemEvents?: string[]; sessions?: { - path?: string; + paths?: string[]; count?: number; defaults?: { model?: string | null; contextTokens?: number | null }; recent?: Array<{ + agentId?: string; key: string; kind?: string; updatedAt?: number | null;