feat: cron agent binding + doctor UI refresh

This commit is contained in:
Peter Steinberger
2026-01-13 03:23:36 +00:00
parent bfdbaa5ab6
commit b41e75a15d
19 changed files with 127 additions and 32 deletions

View File

@@ -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. - 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. - 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. - 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. - 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. - 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. - 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.

View File

@@ -13,6 +13,7 @@ extension CronJobEditor {
guard let job else { return } guard let job else { return }
self.name = job.name self.name = job.name
self.description = job.description ?? "" self.description = job.description ?? ""
self.agentId = job.agentId ?? ""
self.enabled = job.enabled self.enabled = job.enabled
self.sessionTarget = job.sessionTarget self.sessionTarget = job.sessionTarget
self.wakeMode = job.wakeMode self.wakeMode = job.wakeMode
@@ -67,6 +68,7 @@ extension CronJobEditor {
userInfo: [NSLocalizedDescriptionKey: "Name is required."]) userInfo: [NSLocalizedDescriptionKey: "Name is required."])
} }
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines) let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
let agentId = self.agentId.trimmingCharacters(in: .whitespacesAndNewlines)
let schedule: [String: Any] let schedule: [String: Any]
switch self.scheduleKind { switch self.scheduleKind {
case .at: case .at:
@@ -148,6 +150,11 @@ extension CronJobEditor {
"payload": payload, "payload": payload,
] ]
if !description.isEmpty { root["description"] = description } 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 { if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -3,6 +3,7 @@ extension CronJobEditor {
mutating func exerciseForTesting() { mutating func exerciseForTesting() {
self.name = "Test job" self.name = "Test job"
self.description = "Test description" self.description = "Test description"
self.agentId = "ops"
self.enabled = true self.enabled = true
self.sessionTarget = .isolated self.sessionTarget = .isolated
self.wakeMode = .now self.wakeMode = .now

View File

@@ -27,6 +27,7 @@ struct CronJobEditor: View {
@State var name: String = "" @State var name: String = ""
@State var description: String = "" @State var description: String = ""
@State var agentId: String = ""
@State var enabled: Bool = true @State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main @State var sessionTarget: CronSessionTarget = .main
@State var wakeMode: CronWakeMode = .nextHeartbeat @State var wakeMode: CronWakeMode = .nextHeartbeat
@@ -77,6 +78,12 @@ struct CronJobEditor: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
GridRow {
self.gridLabel("Agent ID")
TextField("Optional (default agent)", text: self.$agentId)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow { GridRow {
self.gridLabel("Enabled") self.gridLabel("Enabled")
Toggle("", isOn: self.$enabled) Toggle("", isOn: self.$enabled)

View File

@@ -145,6 +145,7 @@ struct CronJobState: Codable, Equatable {
struct CronJob: Identifiable, Codable, Equatable { struct CronJob: Identifiable, Codable, Equatable {
let id: String let id: String
let agentId: String?
var name: String var name: String
var description: String? var description: String?
var enabled: Bool var enabled: Bool

View File

@@ -20,6 +20,9 @@ extension CronSettings {
HStack(spacing: 6) { HStack(spacing: 6) {
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary) StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
StatusPill(text: job.wakeMode.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 { if let status = job.state.lastStatus {
StatusPill(text: status, tint: status == "ok" ? .green : .orange) StatusPill(text: status, tint: status == "ok" ? .green : .orange)
} }
@@ -94,6 +97,9 @@ extension CronSettings {
if let desc = job.description, !desc.isEmpty { if let desc = job.description, !desc.isEmpty {
LabeledContent("Description") { Text(desc).font(.callout) } LabeledContent("Description") { Text(desc).font(.callout) }
} }
if let agentId = job.agentId, !agentId.isEmpty {
LabeledContent("Agent") { Text(agentId) }
}
LabeledContent("Session") { Text(job.sessionTarget.rawValue) } LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
LabeledContent("Wake") { Text(job.wakeMode.rawValue) } LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
LabeledContent("Next run") { LabeledContent("Next run") {

View File

@@ -7,6 +7,7 @@ struct CronSettings_Previews: PreviewProvider {
store.jobs = [ store.jobs = [
CronJob( CronJob(
id: "job-1", id: "job-1",
agentId: "ops",
name: "Daily summary", name: "Daily summary",
description: nil, description: nil,
enabled: true, enabled: true,
@@ -59,6 +60,7 @@ extension CronSettings {
let job = CronJob( let job = CronJob(
id: "job-1", id: "job-1",
agentId: "ops",
name: "Daily summary", name: "Daily summary",
description: "Summary job", description: "Summary job",
enabled: true, enabled: true,

View File

@@ -23,7 +23,9 @@ struct CronJobEditorSmokeTests {
@Test func cronJobEditorBuildsBodyForExistingJob() { @Test func cronJobEditorBuildsBodyForExistingJob() {
let job = CronJob( let job = CronJob(
id: "job-1", id: "job-1",
agentId: "ops",
name: "Daily summary", name: "Daily summary",
description: nil,
enabled: true, enabled: true,
createdAtMs: 1_700_000_000_000, createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_000_000, updatedAtMs: 1_700_000_000_000,

View File

@@ -60,6 +60,7 @@ struct CronModelsTests {
@Test func displayNameTrimsWhitespaceAndFallsBack() { @Test func displayNameTrimsWhitespaceAndFallsBack() {
let base = CronJob( let base = CronJob(
id: "x", id: "x",
agentId: nil,
name: " hello ", name: " hello ",
description: nil, description: nil,
enabled: true, enabled: true,
@@ -81,6 +82,7 @@ struct CronModelsTests {
@Test func nextRunDateAndLastRunDateDeriveFromState() { @Test func nextRunDateAndLastRunDateDeriveFromState() {
let job = CronJob( let job = CronJob(
id: "x", id: "x",
agentId: nil,
name: "t", name: "t",
description: nil, description: nil,
enabled: true, enabled: true,

View File

@@ -12,6 +12,7 @@ struct SettingsViewSmokeTests {
let job1 = CronJob( let job1 = CronJob(
id: "job-1", id: "job-1",
agentId: "ops",
name: " Morning Check-in ", name: " Morning Check-in ",
description: nil, description: nil,
enabled: true, enabled: true,
@@ -32,6 +33,7 @@ struct SettingsViewSmokeTests {
let job2 = CronJob( let job2 = CronJob(
id: "job-2", id: "job-2",
agentId: nil,
name: "", name: "",
description: nil, description: nil,
enabled: false, enabled: false,

View File

@@ -56,6 +56,7 @@ cat ~/.clawdbot/clawdbot.json
## What it does (summary) ## What it does (summary)
- Optional pre-flight update for git installs (interactive only). - 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. - Health check + restart prompt.
- Skills status summary (eligible/missing/blocked). - Skills status summary (eligible/missing/blocked).
- Legacy config migration and normalization. - Legacy config migration and normalization.

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { type ClawdbotConfig, loadConfig } from "../config/config.js"; import { type ClawdbotConfig, loadConfig } from "../config/config.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js";
import { import {
type AuthProfileStore,
ensureAuthProfileStore, ensureAuthProfileStore,
listProfilesForProvider, listProfilesForProvider,
} from "./auth-profiles.js"; } from "./auth-profiles.js";
@@ -70,9 +71,10 @@ async function readJson(pathname: string): Promise<unknown> {
} }
} }
function buildMinimaxApiProvider(): ProviderConfig { function buildMinimaxApiProvider(apiKey?: string): ProviderConfig {
return { return {
baseUrl: MINIMAX_API_BASE_URL, baseUrl: MINIMAX_API_BASE_URL,
...(apiKey ? { apiKey } : {}),
api: "anthropic-messages", api: "anthropic-messages",
models: [ 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: { function resolveImplicitProviders(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
agentDir: string; agentDir: string;
@@ -95,10 +126,10 @@ function resolveImplicitProviders(params: {
const providers: Record<string, ProviderConfig> = {}; const providers: Record<string, ProviderConfig> = {};
const minimaxEnv = resolveEnvApiKey("minimax"); const minimaxEnv = resolveEnvApiKey("minimax");
const authStore = ensureAuthProfileStore(params.agentDir); const authStore = ensureAuthProfileStore(params.agentDir);
const hasMinimaxProfile = const minimaxKey =
listProfilesForProvider(authStore, "minimax").length > 0; minimaxEnv?.apiKey ?? resolveMinimaxApiKeyFromStore(authStore);
if (minimaxEnv || hasMinimaxProfile) { if (minimaxKey) {
providers.minimax = buildMinimaxApiProvider(); providers.minimax = buildMinimaxApiProvider(minimaxKey);
} }
return providers; return providers;
} }

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; 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"; import { HEARTBEAT_TOKEN } from "./tokens.js";
describe("stripHeartbeatToken", () => { describe("stripHeartbeatToken", () => {
@@ -52,7 +55,7 @@ describe("stripHeartbeatToken", () => {
}); });
it("keeps heartbeat replies when remaining content exceeds threshold", () => { 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( expect(
stripHeartbeatToken(`${long} ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }), stripHeartbeatToken(`${long} ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
).toEqual({ ).toEqual({

View File

@@ -43,7 +43,7 @@ export async function maybeRepairUiProtocolFreshness(
{ timeoutMs: 5000 }, { timeoutMs: 5000 },
).catch(() => null); ).catch(() => null);
if (gitLog?.stdout.trim()) { if (gitLog && gitLog.code === 0 && gitLog.stdout.trim()) {
note( note(
`UI assets are older than the protocol schema.\nFunctional changes since last build:\n${gitLog.stdout `UI assets are older than the protocol schema.\nFunctional changes since last build:\n${gitLog.stdout
.trim() .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. // 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. // We use the same node executable to run the script.
const uiScriptPath = path.join(root, "scripts/ui.js"); const uiScriptPath = path.join(root, "scripts/ui.js");
await runCommandWithTimeout( const buildResult = await runCommandWithTimeout(
[process.execPath, uiScriptPath, "build"], [process.execPath, uiScriptPath, "build"],
{ {
cwd: root, cwd: root,
@@ -72,11 +72,21 @@ export async function maybeRepairUiProtocolFreshness(
env: { ...process.env, FORCE_COLOR: "1" }, 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 files don't exist, we can't check.
// If git fails, we silently skip. // If git fails, we silently skip.
// runtime.debug(`UI freshness check failed: ${String(err)}`); // runtime.debug(`UI freshness check failed: ${String(err)}`);

View File

@@ -66,7 +66,6 @@ import {
maybeMigrateLegacyConfigFile, maybeMigrateLegacyConfigFile,
normalizeLegacyConfigValues, normalizeLegacyConfigValues,
} from "./doctor-legacy-config.js"; } from "./doctor-legacy-config.js";
import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js";
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
import { import {
maybeRepairSandboxImages, maybeRepairSandboxImages,
@@ -81,6 +80,7 @@ import {
detectLegacyStateMigrations, detectLegacyStateMigrations,
runLegacyStateMigrations, runLegacyStateMigrations,
} from "./doctor-state-migrations.js"; } from "./doctor-state-migrations.js";
import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js";
import { import {
detectLegacyWorkspaceDirs, detectLegacyWorkspaceDirs,
formatLegacyWorkspaceWarning, formatLegacyWorkspaceWarning,
@@ -249,6 +249,8 @@ export async function doctorCommand(
} }
} }
await maybeRepairUiProtocolFreshness(runtime, prompter);
await maybeMigrateLegacyConfigFile(runtime); await maybeMigrateLegacyConfigFile(runtime);
const snapshot = await readConfigFileSnapshot(); const snapshot = await readConfigFileSnapshot();
@@ -272,9 +274,9 @@ export async function doctorCommand(
options.nonInteractive === true options.nonInteractive === true
? true ? true
: await prompter.confirm({ : await prompter.confirm({
message: "Migrate legacy config entries now?", message: "Migrate legacy config entries now?",
initialValue: true, initialValue: true,
}); });
if (migrate) { if (migrate) {
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. // Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
const { config: migrated, changes } = migrateLegacyConfig( const { config: migrated, changes } = migrateLegacyConfig(
@@ -327,9 +329,9 @@ export async function doctorCommand(
: options.nonInteractive === true : options.nonInteractive === true
? false ? false
: await prompter.confirmRepair({ : await prompter.confirmRepair({
message: "Generate and configure a gateway token now?", message: "Generate and configure a gateway token now?",
initialValue: true, initialValue: true,
}); });
if (shouldSetToken) { if (shouldSetToken) {
const nextToken = randomToken(); const nextToken = randomToken();
cfg = { cfg = {
@@ -355,9 +357,9 @@ export async function doctorCommand(
options.nonInteractive === true options.nonInteractive === true
? true ? true
: await prompter.confirm({ : await prompter.confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true, initialValue: true,
}); });
if (migrate) { if (migrate) {
const migrated = await runLegacyStateMigrations({ const migrated = await runLegacyStateMigrations({
detected: legacyState, detected: legacyState,
@@ -479,11 +481,13 @@ export async function doctorCommand(
note( note(
[ [
`Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`, `Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`,
`Missing requirements: ${skillsReport.skills.filter( `Missing requirements: ${
(s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, skillsReport.skills.filter(
).length (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"), ].join("\n"),
"Skills status", "Skills status",
@@ -493,10 +497,10 @@ export async function doctorCommand(
config: cfg, config: cfg,
workspaceDir, workspaceDir,
logger: { logger: {
info: () => { }, info: () => {},
warn: () => { }, warn: () => {},
error: () => { }, error: () => {},
debug: () => { }, debug: () => {},
}, },
}); });
if (pluginRegistry.plugins.length > 0) { if (pluginRegistry.plugins.length > 0) {
@@ -512,9 +516,9 @@ export async function doctorCommand(
`Errors: ${errored.length}`, `Errors: ${errored.length}`,
errored.length > 0 errored.length > 0
? `- ${errored ? `- ${errored
.slice(0, 10) .slice(0, 10)
.map((p) => p.id) .map((p) => p.id)
.join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}` .join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`
: null, : null,
].filter((line): line is string => Boolean(line)); ].filter((line): line is string => Boolean(line));

View File

@@ -175,6 +175,7 @@ declare global {
const DEFAULT_CRON_FORM: CronFormState = { const DEFAULT_CRON_FORM: CronFormState = {
name: "", name: "",
description: "", description: "",
agentId: "",
enabled: true, enabled: true,
scheduleKind: "every", scheduleKind: "every",
scheduleAt: "", scheduleAt: "",

View File

@@ -99,9 +99,11 @@ export async function addCronJob(state: CronState) {
try { try {
const schedule = buildCronSchedule(state.cronForm); const schedule = buildCronSchedule(state.cronForm);
const payload = buildCronPayload(state.cronForm); const payload = buildCronPayload(state.cronForm);
const agentId = state.cronForm.agentId.trim();
const job = { const job = {
name: state.cronForm.name.trim(), name: state.cronForm.name.trim(),
description: state.cronForm.description.trim() || undefined, description: state.cronForm.description.trim() || undefined,
agentId: agentId || undefined,
enabled: state.cronForm.enabled, enabled: state.cronForm.enabled,
schedule, schedule,
sessionTarget: state.cronForm.sessionTarget, sessionTarget: state.cronForm.sessionTarget,

View File

@@ -157,6 +157,7 @@ export type IMessageForm = {
export type CronFormState = { export type CronFormState = {
name: string; name: string;
description: string; description: string;
agentId: string;
enabled: boolean; enabled: boolean;
scheduleKind: "at" | "every" | "cron"; scheduleKind: "at" | "every" | "cron";
scheduleAt: string; scheduleAt: string;

View File

@@ -82,6 +82,15 @@ export function renderCron(props: CronProps) {
props.onFormChange({ description: (e.target as HTMLInputElement).value })} props.onFormChange({ description: (e.target as HTMLInputElement).value })}
/> />
</label> </label>
<label class="field">
<span>Agent ID</span>
<input
.value=${props.form.agentId}
@input=${(e: Event) =>
props.onFormChange({ agentId: (e.target as HTMLInputElement).value })}
placeholder="default"
/>
</label>
<label class="field checkbox"> <label class="field checkbox">
<span>Enabled</span> <span>Enabled</span>
<input <input
@@ -338,6 +347,7 @@ function renderJob(job: CronJob, props: CronProps) {
<div class="list-title">${job.name}</div> <div class="list-title">${job.name}</div>
<div class="list-sub">${formatCronSchedule(job)}</div> <div class="list-sub">${formatCronSchedule(job)}</div>
<div class="muted">${formatCronPayload(job)}</div> <div class="muted">${formatCronPayload(job)}</div>
${job.agentId ? html`<div class="muted">Agent: ${job.agentId}</div>` : nothing}
<div class="chip-row" style="margin-top: 6px;"> <div class="chip-row" style="margin-top: 6px;">
<span class="chip">${job.enabled ? "enabled" : "disabled"}</span> <span class="chip">${job.enabled ? "enabled" : "disabled"}</span>
<span class="chip">${job.sessionTarget}</span> <span class="chip">${job.sessionTarget}</span>