diff --git a/CHANGELOG.md b/CHANGELOG.md index fcde2b446..9ea3fea46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,8 @@ - Memory: embedding providers support OpenAI or local `node-llama-cpp`; config adds defaults + per-agent overrides, provider/fallback metadata surfaced in tools/CLI. - CLI/Tools: new `clawdbot memory` commands plus `memory_search`/`memory_get` tools returning snippets + line ranges and provider info. - Runtime: memory index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default; inline status replies now stay auth-gated while inline prompts continue to the agent. +- Doctor: rebuild Control UI assets when protocol schema is newer to avoid stale UI connect errors. (#786) — thanks @meaningfool. +- Cron: allow jobs to target a specific agent and expose agent selection in the macOS app + Control UI. - Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker. - CLI/Onboarding: `clawdbot dashboard` prints/copies the tokenized Control UI link and opens it; onboarding now auto-opens the dashboard with your token and keeps the link in the summary. - Commands: native slash commands now default to `"auto"` (on for Discord/Telegram, off for Slack) with per-provider overrides (`discord/telegram/slack.commands.native`) and docs updated. diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift index 877c0c6c7..19f07ebad 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift @@ -13,6 +13,7 @@ extension CronJobEditor { guard let job else { return } self.name = job.name self.description = job.description ?? "" + self.agentId = job.agentId ?? "" self.enabled = job.enabled self.sessionTarget = job.sessionTarget self.wakeMode = job.wakeMode @@ -67,6 +68,7 @@ extension CronJobEditor { userInfo: [NSLocalizedDescriptionKey: "Name is required."]) } let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines) + let agentId = self.agentId.trimmingCharacters(in: .whitespacesAndNewlines) let schedule: [String: Any] switch self.scheduleKind { case .at: @@ -148,6 +150,11 @@ extension CronJobEditor { "payload": payload, ] if !description.isEmpty { root["description"] = description } + if !agentId.isEmpty { + root["agentId"] = agentId + } else if self.job?.agentId != nil { + root["agentId"] = NSNull() + } if self.sessionTarget == .isolated { let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift index 75b5ed6b6..12f8d4903 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift @@ -3,6 +3,7 @@ extension CronJobEditor { mutating func exerciseForTesting() { self.name = "Test job" self.description = "Test description" + self.agentId = "ops" self.enabled = true self.sessionTarget = .isolated self.wakeMode = .now diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift index 144368bf1..c86d581b9 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift @@ -27,6 +27,7 @@ struct CronJobEditor: View { @State var name: String = "" @State var description: String = "" + @State var agentId: String = "" @State var enabled: Bool = true @State var sessionTarget: CronSessionTarget = .main @State var wakeMode: CronWakeMode = .nextHeartbeat @@ -77,6 +78,12 @@ struct CronJobEditor: View { .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) } + GridRow { + self.gridLabel("Agent ID") + TextField("Optional (default agent)", text: self.$agentId) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } GridRow { self.gridLabel("Enabled") Toggle("", isOn: self.$enabled) diff --git a/apps/macos/Sources/Clawdbot/CronModels.swift b/apps/macos/Sources/Clawdbot/CronModels.swift index 388ef8afb..0c43049fd 100644 --- a/apps/macos/Sources/Clawdbot/CronModels.swift +++ b/apps/macos/Sources/Clawdbot/CronModels.swift @@ -145,6 +145,7 @@ struct CronJobState: Codable, Equatable { struct CronJob: Identifiable, Codable, Equatable { let id: String + let agentId: String? var name: String var description: String? var enabled: Bool diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift b/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift index fc5ceb51f..526020492 100644 --- a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift +++ b/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift @@ -20,6 +20,9 @@ extension CronSettings { HStack(spacing: 6) { StatusPill(text: job.sessionTarget.rawValue, tint: .secondary) StatusPill(text: job.wakeMode.rawValue, tint: .secondary) + if let agentId = job.agentId, !agentId.isEmpty { + StatusPill(text: "agent \(agentId)", tint: .secondary) + } if let status = job.state.lastStatus { StatusPill(text: status, tint: status == "ok" ? .green : .orange) } @@ -94,6 +97,9 @@ extension CronSettings { if let desc = job.description, !desc.isEmpty { LabeledContent("Description") { Text(desc).font(.callout) } } + if let agentId = job.agentId, !agentId.isEmpty { + LabeledContent("Agent") { Text(agentId) } + } LabeledContent("Session") { Text(job.sessionTarget.rawValue) } LabeledContent("Wake") { Text(job.wakeMode.rawValue) } LabeledContent("Next run") { diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift index 788ec9fdb..7b82248b3 100644 --- a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift +++ b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift @@ -7,6 +7,7 @@ struct CronSettings_Previews: PreviewProvider { store.jobs = [ CronJob( id: "job-1", + agentId: "ops", name: "Daily summary", description: nil, enabled: true, @@ -59,6 +60,7 @@ extension CronSettings { let job = CronJob( id: "job-1", + agentId: "ops", name: "Daily summary", description: "Summary job", enabled: true, diff --git a/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift index ea5f86579..75b646d3a 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift @@ -23,7 +23,9 @@ struct CronJobEditorSmokeTests { @Test func cronJobEditorBuildsBodyForExistingJob() { let job = CronJob( id: "job-1", + agentId: "ops", name: "Daily summary", + description: nil, enabled: true, createdAtMs: 1_700_000_000_000, updatedAtMs: 1_700_000_000_000, diff --git a/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift index 0a78674f3..197861570 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift @@ -60,6 +60,7 @@ struct CronModelsTests { @Test func displayNameTrimsWhitespaceAndFallsBack() { let base = CronJob( id: "x", + agentId: nil, name: " hello ", description: nil, enabled: true, @@ -81,6 +82,7 @@ struct CronModelsTests { @Test func nextRunDateAndLastRunDateDeriveFromState() { let job = CronJob( id: "x", + agentId: nil, name: "t", description: nil, enabled: true, diff --git a/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift index 93b88d5ea..42a2411f9 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift @@ -12,6 +12,7 @@ struct SettingsViewSmokeTests { let job1 = CronJob( id: "job-1", + agentId: "ops", name: " Morning Check-in ", description: nil, enabled: true, @@ -32,6 +33,7 @@ struct SettingsViewSmokeTests { let job2 = CronJob( id: "job-2", + agentId: nil, name: "", description: nil, enabled: false, diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 7ff6826a0..152a72ab8 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -56,6 +56,7 @@ cat ~/.clawdbot/clawdbot.json ## What it does (summary) - Optional pre-flight update for git installs (interactive only). +- UI protocol freshness check (rebuilds Control UI when the protocol schema is newer). - Health check + restart prompt. - Skills status summary (eligible/missing/blocked). - Legacy config migration and normalization. diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index b28ea7c51..247c24321 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; import { + type AuthProfileStore, ensureAuthProfileStore, listProfilesForProvider, } from "./auth-profiles.js"; @@ -70,9 +71,10 @@ async function readJson(pathname: string): Promise { } } -function buildMinimaxApiProvider(): ProviderConfig { +function buildMinimaxApiProvider(apiKey?: string): ProviderConfig { return { baseUrl: MINIMAX_API_BASE_URL, + ...(apiKey ? { apiKey } : {}), api: "anthropic-messages", models: [ { @@ -88,6 +90,35 @@ function buildMinimaxApiProvider(): ProviderConfig { }; } +function resolveMinimaxApiKeyFromStore( + store: AuthProfileStore, +): string | undefined { + const profileIds = listProfilesForProvider(store, "minimax"); + for (const profileId of profileIds) { + const cred = store.profiles[profileId]; + if (!cred) continue; + if (cred.type === "api_key") { + const key = cred.key?.trim(); + if (key) return key; + continue; + } + if (cred.type === "token") { + const token = cred.token?.trim(); + if (!token) continue; + if ( + typeof cred.expires === "number" && + Number.isFinite(cred.expires) && + cred.expires > 0 && + Date.now() >= cred.expires + ) { + continue; + } + return token; + } + } + return undefined; +} + function resolveImplicitProviders(params: { cfg: ClawdbotConfig; agentDir: string; @@ -95,10 +126,10 @@ function resolveImplicitProviders(params: { const providers: Record = {}; const minimaxEnv = resolveEnvApiKey("minimax"); const authStore = ensureAuthProfileStore(params.agentDir); - const hasMinimaxProfile = - listProfilesForProvider(authStore, "minimax").length > 0; - if (minimaxEnv || hasMinimaxProfile) { - providers.minimax = buildMinimaxApiProvider(); + const minimaxKey = + minimaxEnv?.apiKey ?? resolveMinimaxApiKeyFromStore(authStore); + if (minimaxKey) { + providers.minimax = buildMinimaxApiProvider(minimaxKey); } return providers; } diff --git a/src/auto-reply/heartbeat.test.ts b/src/auto-reply/heartbeat.test.ts index 5a465fb1c..6bc066d1d 100644 --- a/src/auto-reply/heartbeat.test.ts +++ b/src/auto-reply/heartbeat.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { stripHeartbeatToken } from "./heartbeat.js"; +import { + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + stripHeartbeatToken, +} from "./heartbeat.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; describe("stripHeartbeatToken", () => { @@ -52,7 +55,7 @@ describe("stripHeartbeatToken", () => { }); it("keeps heartbeat replies when remaining content exceeds threshold", () => { - const long = "A".repeat(400); + const long = "A".repeat(DEFAULT_HEARTBEAT_ACK_MAX_CHARS + 1); expect( stripHeartbeatToken(`${long} ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }), ).toEqual({ diff --git a/src/commands/doctor-ui.ts b/src/commands/doctor-ui.ts index 2cf8478a1..b83f7e281 100644 --- a/src/commands/doctor-ui.ts +++ b/src/commands/doctor-ui.ts @@ -43,7 +43,7 @@ export async function maybeRepairUiProtocolFreshness( { timeoutMs: 5000 }, ).catch(() => null); - if (gitLog?.stdout.trim()) { + if (gitLog && gitLog.code === 0 && gitLog.stdout.trim()) { note( `UI assets are older than the protocol schema.\nFunctional changes since last build:\n${gitLog.stdout .trim() @@ -64,7 +64,7 @@ export async function maybeRepairUiProtocolFreshness( // Use scripts/ui.js to build, assuming node is available as we are running in it. // We use the same node executable to run the script. const uiScriptPath = path.join(root, "scripts/ui.js"); - await runCommandWithTimeout( + const buildResult = await runCommandWithTimeout( [process.execPath, uiScriptPath, "build"], { cwd: root, @@ -72,11 +72,21 @@ export async function maybeRepairUiProtocolFreshness( env: { ...process.env, FORCE_COLOR: "1" }, }, ); - note("UI rebuild complete.", "UI"); + if (buildResult.code === 0) { + note("UI rebuild complete.", "UI"); + } else { + const details = [ + `UI rebuild failed (exit ${buildResult.code ?? "unknown"}).`, + buildResult.stderr.trim() ? buildResult.stderr.trim() : null, + ] + .filter(Boolean) + .join("\n"); + note(details, "UI"); + } } } } - } catch (_err) { + } catch { // If files don't exist, we can't check. // If git fails, we silently skip. // runtime.debug(`UI freshness check failed: ${String(err)}`); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 09d6b6869..6eead0dce 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -66,7 +66,6 @@ import { maybeMigrateLegacyConfigFile, normalizeLegacyConfigValues, } from "./doctor-legacy-config.js"; -import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js"; import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; import { maybeRepairSandboxImages, @@ -81,6 +80,7 @@ import { detectLegacyStateMigrations, runLegacyStateMigrations, } from "./doctor-state-migrations.js"; +import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js"; import { detectLegacyWorkspaceDirs, formatLegacyWorkspaceWarning, @@ -249,6 +249,8 @@ export async function doctorCommand( } } + await maybeRepairUiProtocolFreshness(runtime, prompter); + await maybeMigrateLegacyConfigFile(runtime); const snapshot = await readConfigFileSnapshot(); @@ -272,9 +274,9 @@ export async function doctorCommand( options.nonInteractive === true ? true : await prompter.confirm({ - message: "Migrate legacy config entries now?", - initialValue: true, - }); + message: "Migrate legacy config entries now?", + initialValue: true, + }); if (migrate) { // Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. const { config: migrated, changes } = migrateLegacyConfig( @@ -327,9 +329,9 @@ export async function doctorCommand( : options.nonInteractive === true ? false : await prompter.confirmRepair({ - message: "Generate and configure a gateway token now?", - initialValue: true, - }); + message: "Generate and configure a gateway token now?", + initialValue: true, + }); if (shouldSetToken) { const nextToken = randomToken(); cfg = { @@ -355,9 +357,9 @@ export async function doctorCommand( options.nonInteractive === true ? true : await prompter.confirm({ - message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", - initialValue: true, - }); + message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", + initialValue: true, + }); if (migrate) { const migrated = await runLegacyStateMigrations({ detected: legacyState, @@ -479,11 +481,13 @@ export async function doctorCommand( note( [ `Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`, - `Missing requirements: ${skillsReport.skills.filter( - (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, - ).length + `Missing requirements: ${ + skillsReport.skills.filter( + (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, + ).length }`, - `Blocked by allowlist: ${skillsReport.skills.filter((s) => s.blockedByAllowlist).length + `Blocked by allowlist: ${ + skillsReport.skills.filter((s) => s.blockedByAllowlist).length }`, ].join("\n"), "Skills status", @@ -493,10 +497,10 @@ export async function doctorCommand( config: cfg, workspaceDir, logger: { - info: () => { }, - warn: () => { }, - error: () => { }, - debug: () => { }, + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, }, }); if (pluginRegistry.plugins.length > 0) { @@ -512,9 +516,9 @@ export async function doctorCommand( `Errors: ${errored.length}`, errored.length > 0 ? `- ${errored - .slice(0, 10) - .map((p) => p.id) - .join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}` + .slice(0, 10) + .map((p) => p.id) + .join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}` : null, ].filter((line): line is string => Boolean(line)); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 3c8e67038..d01715d85 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -175,6 +175,7 @@ declare global { const DEFAULT_CRON_FORM: CronFormState = { name: "", description: "", + agentId: "", enabled: true, scheduleKind: "every", scheduleAt: "", diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 342a1c8f0..d1c889e2b 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -99,9 +99,11 @@ export async function addCronJob(state: CronState) { try { const schedule = buildCronSchedule(state.cronForm); const payload = buildCronPayload(state.cronForm); + const agentId = state.cronForm.agentId.trim(); const job = { name: state.cronForm.name.trim(), description: state.cronForm.description.trim() || undefined, + agentId: agentId || undefined, enabled: state.cronForm.enabled, schedule, sessionTarget: state.cronForm.sessionTarget, diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 6e90728cf..7140f47a3 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -157,6 +157,7 @@ export type IMessageForm = { export type CronFormState = { name: string; description: string; + agentId: string; enabled: boolean; scheduleKind: "at" | "every" | "cron"; scheduleAt: string; diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 920941e1d..881271882 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -82,6 +82,15 @@ export function renderCron(props: CronProps) { props.onFormChange({ description: (e.target as HTMLInputElement).value })} /> +