@indigoai-us/hq-cli 5.135.0 → 5.136.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.136.0] — 2026-09-23
6
+
7
+ ### Added
8
+
9
+ - Your local bots can now do work on a repeating schedule. Ask a company bot for something regular — "check the overnight errors every weekday morning" — and it sets the schedule up itself; you can also see and remove its schedules with `hq bot jobs <name>`. The schedule is kept in the cloud, not on your computer. If the Mac is asleep when a run is due, the run happens as a catch-up when it wakes, as long as that is within about an hour, and the bot's report says it ran late; after a longer outage that run is skipped and the job carries on at its next scheduled time. Personal bots can't schedule work yet.
10
+
5
11
  ## [5.135.0] — 2026-09-23
6
12
 
7
13
  ### Added
@@ -7318,6 +7318,37 @@ export declare const COMMAND_CATALOG: readonly [{
7318
7318
  readonly description: "Expected transfer size";
7319
7319
  }];
7320
7320
  readonly subcommands: readonly [];
7321
+ }, {
7322
+ readonly name: "jobs";
7323
+ readonly description: "Show, schedule, or remove a bot's recurring jobs (the schedule runs in the cloud, not on this computer)";
7324
+ readonly aliases: readonly [];
7325
+ readonly hidden: false;
7326
+ readonly usage: "[options] <name>";
7327
+ readonly arguments: readonly [{
7328
+ readonly name: "name";
7329
+ readonly required: true;
7330
+ readonly variadic: false;
7331
+ }];
7332
+ readonly options: readonly [{
7333
+ readonly flags: "--add <what>";
7334
+ readonly description: "Schedule new work, in plain language";
7335
+ }, {
7336
+ readonly flags: "--every <when>";
7337
+ readonly description: "How often, in plain language (e.g. \"every weekday at 9am\")";
7338
+ }, {
7339
+ readonly flags: "--cron <expr>";
7340
+ readonly description: "How often, as a cron expression (e.g. \"0 9 * * 1-5\")";
7341
+ }, {
7342
+ readonly flags: "--tz <zone>";
7343
+ readonly description: "IANA timezone for the schedule (default: this computer's)";
7344
+ }, {
7345
+ readonly flags: "--rm <jobId>";
7346
+ readonly description: "Remove a scheduled job";
7347
+ }, {
7348
+ readonly flags: "--json";
7349
+ readonly description: "Print machine-readable JSON";
7350
+ }];
7351
+ readonly subcommands: readonly [];
7321
7352
  }, {
7322
7353
  readonly name: "promote";
7323
7354
  readonly description: "Move this local bot to cloud compute, preserving its identity and private context";
@@ -9463,6 +9463,47 @@ export const COMMAND_CATALOG = [
9463
9463
  ],
9464
9464
  "subcommands": []
9465
9465
  },
9466
+ {
9467
+ "name": "jobs",
9468
+ "description": "Show, schedule, or remove a bot's recurring jobs (the schedule runs in the cloud, not on this computer)",
9469
+ "aliases": [],
9470
+ "hidden": false,
9471
+ "usage": "[options] <name>",
9472
+ "arguments": [
9473
+ {
9474
+ "name": "name",
9475
+ "required": true,
9476
+ "variadic": false
9477
+ }
9478
+ ],
9479
+ "options": [
9480
+ {
9481
+ "flags": "--add <what>",
9482
+ "description": "Schedule new work, in plain language"
9483
+ },
9484
+ {
9485
+ "flags": "--every <when>",
9486
+ "description": "How often, in plain language (e.g. \"every weekday at 9am\")"
9487
+ },
9488
+ {
9489
+ "flags": "--cron <expr>",
9490
+ "description": "How often, as a cron expression (e.g. \"0 9 * * 1-5\")"
9491
+ },
9492
+ {
9493
+ "flags": "--tz <zone>",
9494
+ "description": "IANA timezone for the schedule (default: this computer's)"
9495
+ },
9496
+ {
9497
+ "flags": "--rm <jobId>",
9498
+ "description": "Remove a scheduled job"
9499
+ },
9500
+ {
9501
+ "flags": "--json",
9502
+ "description": "Print machine-readable JSON"
9503
+ }
9504
+ ],
9505
+ "subcommands": []
9506
+ },
9466
9507
  {
9467
9508
  "name": "promote",
9468
9509
  "description": "Move this local bot to cloud compute, preserving its identity and private context",
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `hq bot jobs` — a local bot's recurring scheduled work.
3
+ *
4
+ * hq bot jobs <botName> list
5
+ * hq bot jobs <botName> --add "<what to do>" --every "<plain english>"
6
+ * hq bot jobs <botName> --add "<what to do>" --cron "0 9 * * 1-5"
7
+ * hq bot jobs <botName> --rm <jobId>
8
+ *
9
+ * The schedule lives in the cloud, never on this computer: hq-pro-agents holds
10
+ * the job and fires it on an EventBridge schedule, and the bot's inbox loop
11
+ * runs the fired run (see lib/bot/job-run.ts). Nothing here writes a crontab,
12
+ * a launchd plist, or a systemd unit — by rule.
13
+ *
14
+ * The routes are self-only per agent, so every call uses the BOT's own machine
15
+ * identity — the same credential path its turns use — not the owner's session.
16
+ */
17
+ import { execFileSync } from "node:child_process";
18
+ import type { Command } from "commander";
19
+ import { BotApi, type BotJobRecord, type CreateJobInput } from "../lib/bot/api.js";
20
+ import { type BotConfig, type BotKind } from "../lib/bot/config.js";
21
+ /** Mirrors hq-pro-agents `MIN_RECURRING_INTERVAL_SECONDS`. */
22
+ export declare const MIN_RECURRING_INTERVAL_SECONDS: number;
23
+ /** Mirrors hq-pro-agents `DEFAULT_MAX_ACTIVE_JOBS_PER_AGENT`. */
24
+ export declare const MAX_ACTIVE_JOBS_PER_AGENT = 25;
25
+ /** Mirrors hq-pro-agents `MAX_PROMPT_BYTES`. */
26
+ export declare const MAX_PROMPT_BYTES: number;
27
+ export declare class BotJobsError extends Error {
28
+ readonly expected: true;
29
+ }
30
+ /** The machine's own IANA zone, so "9am" means 9am where the owner lives. */
31
+ export declare function machineTimezone(): string;
32
+ /**
33
+ * Standard 5-field cron → the EventBridge form the cloud stores.
34
+ *
35
+ * EventBridge cron has SIX fields (a trailing year), numbers its days of week
36
+ * 1–7 with SUNDAY = 1 (standard cron uses 0–6 with SUNDAY = 0), and refuses
37
+ * `*` in BOTH the day-of-month and day-of-week fields — exactly one must be
38
+ * `?`. An expression that is already `cron(...)` or `rate(...)` is passed
39
+ * through untouched.
40
+ */
41
+ export declare function toEventBridgeCron(expr: string): string;
42
+ /**
43
+ * The smallest gap a recurring expression can produce, in seconds, or null
44
+ * when it is not a recognised rate()/cron() form. Mirrors hq-pro-agents
45
+ * `recurringIntervalSeconds` so the CLI can refuse a sub-floor schedule with
46
+ * the server's own reason instead of a round-trip and a raw 400.
47
+ */
48
+ export declare function recurringIntervalSeconds(expr: string): number | null;
49
+ /** Refuse a schedule the cloud would refuse, with the cloud's own reason. */
50
+ export declare function assertScheduleWithinFloor(expr: string): void;
51
+ export interface ParsedSchedule {
52
+ /** Standard 5-field cron, as the HQ helper emits it. */
53
+ cron: string;
54
+ timezone: string;
55
+ /** Plain words for the schedule, when the helper supplied them. */
56
+ human?: string;
57
+ }
58
+ export declare function scheduleHelperPath(hqRoot: string): string;
59
+ /**
60
+ * Plain English → cron, using HQ's OWN helper (`core/scripts/jobs-schedule-parse.sh`)
61
+ * so `hq bot jobs --every` and the `/schedule` command agree on what "every
62
+ * weekday morning" means. When the helper is not in this HQ root, the caller is
63
+ * told to pass `--cron` instead rather than getting a second, divergent parser.
64
+ */
65
+ export declare function parseScheduleText(text: string, opts: {
66
+ hqRoot: string;
67
+ timezone: string;
68
+ exec?: typeof execFileSync;
69
+ exists?: (p: string) => boolean;
70
+ }): ParsedSchedule;
71
+ /** The create body the cloud expects, with the client-side limits enforced first. */
72
+ export declare function buildCreateJobBody(input: {
73
+ prompt: string;
74
+ cron: string;
75
+ timezone: string;
76
+ context?: Record<string, unknown>;
77
+ }): CreateJobInput;
78
+ /** A schedule in plain words, falling back to the raw expression. */
79
+ export declare function describeSchedule(job: BotJobRecord): string;
80
+ /** A small humaniser for the shapes `--every` actually produces. */
81
+ export declare function cronInWords(expr: string): string | null;
82
+ /** "never run yet" / "succeeded 2026-09-15 09:00 UTC". */
83
+ export declare function describeLastRun(job: BotJobRecord): string;
84
+ export declare function formatJobsList(botName: string, jobs: BotJobRecord[]): string;
85
+ /**
86
+ * Turn a jobs-route failure into one plain line.
87
+ *
88
+ * Personal bots are refused by the cloud today: their JWT carries the owner's
89
+ * `per_…` company claim and the jobs routes require a `cmp_…`, so the fail-closed
90
+ * guard answers 403 `CROSS_AGENT_JOB` (hq-pro-agents jobs/errors.ts
91
+ * `CrossAgentJobError`). That server fix is a separate change; until it lands the
92
+ * refusal must read as "not available yet for this kind of bot", not as a raw 403.
93
+ * Once the server accepts personal bots the refusal simply stops happening — no
94
+ * client change needed.
95
+ *
96
+ * ONLY that exact code is rewritten. Every other 403, and every 404, keeps its
97
+ * real message: masking them would hide a genuinely missing job or a different
98
+ * permission problem behind copy about the wrong thing.
99
+ */
100
+ export declare function jobsErrorMessage(err: unknown, ctx: {
101
+ botName: string;
102
+ kind: BotKind;
103
+ }): string;
104
+ export interface BotJobsDeps {
105
+ requireBot(name: string): {
106
+ name: string;
107
+ dir: string;
108
+ config: BotConfig;
109
+ };
110
+ botTokenSupplier(dir: string): () => Promise<string>;
111
+ /** Test seam: the jobs client for a bot directory. */
112
+ apiFor?(dir: string): Pick<BotApi, "listJobs" | "createJob" | "cancelJob">;
113
+ parseSchedule?: typeof parseScheduleText;
114
+ timezone?: () => string;
115
+ out?: (line: string) => void;
116
+ }
117
+ export interface BotJobsOptions {
118
+ add?: string;
119
+ every?: string;
120
+ cron?: string;
121
+ tz?: string;
122
+ rm?: string;
123
+ json?: boolean;
124
+ }
125
+ export declare function runBotJobs(nameArg: string, opts: BotJobsOptions, deps: BotJobsDeps): Promise<void>;
126
+ export declare function registerBotJobsCommand(bot: Command, deps: BotJobsDeps): void;
127
+ //# sourceMappingURL=bot-jobs.d.ts.map
@@ -0,0 +1,445 @@
1
+ /**
2
+ * `hq bot jobs` — a local bot's recurring scheduled work.
3
+ *
4
+ * hq bot jobs <botName> list
5
+ * hq bot jobs <botName> --add "<what to do>" --every "<plain english>"
6
+ * hq bot jobs <botName> --add "<what to do>" --cron "0 9 * * 1-5"
7
+ * hq bot jobs <botName> --rm <jobId>
8
+ *
9
+ * The schedule lives in the cloud, never on this computer: hq-pro-agents holds
10
+ * the job and fires it on an EventBridge schedule, and the bot's inbox loop
11
+ * runs the fired run (see lib/bot/job-run.ts). Nothing here writes a crontab,
12
+ * a launchd plist, or a systemd unit — by rule.
13
+ *
14
+ * The routes are self-only per agent, so every call uses the BOT's own machine
15
+ * identity — the same credential path its turns use — not the owner's session.
16
+ */
17
+ import chalk from "chalk";
18
+ import { execFileSync } from "node:child_process";
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import { BotApi, BotApiError } from "../lib/bot/api.js";
22
+ import { effectiveBotKind } from "../lib/bot/config.js";
23
+ // ─── Schedule translation ────────────────────────────────────────────────────
24
+ /** Mirrors hq-pro-agents `MIN_RECURRING_INTERVAL_SECONDS`. */
25
+ export const MIN_RECURRING_INTERVAL_SECONDS = 5 * 60;
26
+ /** Mirrors hq-pro-agents `DEFAULT_MAX_ACTIVE_JOBS_PER_AGENT`. */
27
+ export const MAX_ACTIVE_JOBS_PER_AGENT = 25;
28
+ /** Mirrors hq-pro-agents `MAX_PROMPT_BYTES`. */
29
+ export const MAX_PROMPT_BYTES = 16 * 1024;
30
+ export class BotJobsError extends Error {
31
+ expected = true;
32
+ }
33
+ function refuse(message) {
34
+ throw new BotJobsError(message);
35
+ }
36
+ /** The machine's own IANA zone, so "9am" means 9am where the owner lives. */
37
+ export function machineTimezone() {
38
+ try {
39
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
40
+ }
41
+ catch {
42
+ return "UTC";
43
+ }
44
+ }
45
+ /**
46
+ * Standard 5-field cron → the EventBridge form the cloud stores.
47
+ *
48
+ * EventBridge cron has SIX fields (a trailing year), numbers its days of week
49
+ * 1–7 with SUNDAY = 1 (standard cron uses 0–6 with SUNDAY = 0), and refuses
50
+ * `*` in BOTH the day-of-month and day-of-week fields — exactly one must be
51
+ * `?`. An expression that is already `cron(...)` or `rate(...)` is passed
52
+ * through untouched.
53
+ */
54
+ export function toEventBridgeCron(expr) {
55
+ const trimmed = expr.trim();
56
+ // An explicit cron(...) / rate(...) is the caller saying "this is already the
57
+ // cloud's own syntax" — passed through untranslated, on purpose.
58
+ if (/^(cron|rate)\s*\(/i.test(trimmed))
59
+ return trimmed;
60
+ const fields = trimmed.split(/\s+/).filter(Boolean);
61
+ if (fields.length === 6) {
62
+ // Six BARE fields are ambiguous: EventBridge's sixth field is a trailing
63
+ // year, Quartz's first is leading seconds. Guessing wrong silently moves
64
+ // every run. Make the caller say which they mean.
65
+ refuse(`"${trimmed}" has six fields, and I can't tell whether that is an EventBridge expression ` +
66
+ `(minute hour day-of-month month day-of-week year) or a Quartz one (leading seconds) — ` +
67
+ `reading it the wrong way would schedule the job at the wrong time. Pass five fields ` +
68
+ `("0 9 * * 1-5"), or wrap it yourself as cron(...) if it is already an EventBridge expression.`);
69
+ }
70
+ if (fields.length !== 5) {
71
+ refuse(`"${trimmed}" is not a cron expression. A cron expression has five fields: ` +
72
+ `minute hour day-of-month month day-of-week (for example "0 9 * * 1-5").`);
73
+ }
74
+ const [minute, hour, month] = [fields[0], fields[1], fields[3]];
75
+ let dayOfMonth = fields[2];
76
+ let dayOfWeek = fields[4];
77
+ if (dayOfWeek === "*" || dayOfWeek === "?") {
78
+ dayOfWeek = "?";
79
+ if (dayOfMonth === "?")
80
+ dayOfMonth = "*";
81
+ }
82
+ else {
83
+ dayOfWeek = shiftDayOfWeek(dayOfWeek);
84
+ if (dayOfMonth === "*")
85
+ dayOfMonth = "?";
86
+ }
87
+ return `cron(${minute} ${hour} ${dayOfMonth} ${month} ${dayOfWeek} *)`;
88
+ }
89
+ /**
90
+ * Standard 0–6 (SUN=0, and 7 also SUN) → EventBridge 1–7 (SUN=1).
91
+ *
92
+ * Mapped per TERM, not per digit. Shifting digits individually turned a range
93
+ * that wraps past Sunday — "6-0" (Sat–Sun), "5-1" (Fri–Mon) — into "7-1" /
94
+ * "6-2": reversed or plain wrong, and silently so. Such a range is refused with
95
+ * the list form that does work.
96
+ */
97
+ function shiftDayOfWeek(field) {
98
+ return field
99
+ .split(",")
100
+ .map((term) => shiftDayOfWeekTerm(term.trim()))
101
+ .join(",");
102
+ }
103
+ function shiftDay(n) {
104
+ return (n % 7) + 1;
105
+ }
106
+ function shiftDayOfWeekTerm(term) {
107
+ const step = term.match(/^(.+)\/(\d+)$/);
108
+ if (step)
109
+ return `${shiftDayOfWeekTerm(step[1])}/${step[2]}`;
110
+ const range = term.match(/^(\d+)-(\d+)$/);
111
+ if (range) {
112
+ const from = shiftDay(Number(range[1]));
113
+ const to = shiftDay(Number(range[2]));
114
+ if (from > to) {
115
+ const days = [];
116
+ for (let d = Number(range[1]);; d = (d + 1) % 7) {
117
+ days.push(d);
118
+ if (d === Number(range[2]) % 7)
119
+ break;
120
+ if (days.length > 7)
121
+ break;
122
+ }
123
+ refuse(`"${term}" is a day-of-week range that wraps past Sunday, which the schedule cannot express. ` +
124
+ `List the days instead, for example "${days.join(",")}".`);
125
+ }
126
+ return `${from}-${to}`;
127
+ }
128
+ if (/^\d+$/.test(term))
129
+ return String(shiftDay(Number(term)));
130
+ // Day NAMES (MON, MON-FRI) mean the same thing in both syntaxes.
131
+ return term;
132
+ }
133
+ /**
134
+ * The smallest gap a recurring expression can produce, in seconds, or null
135
+ * when it is not a recognised rate()/cron() form. Mirrors hq-pro-agents
136
+ * `recurringIntervalSeconds` so the CLI can refuse a sub-floor schedule with
137
+ * the server's own reason instead of a round-trip and a raw 400.
138
+ */
139
+ export function recurringIntervalSeconds(expr) {
140
+ const rate = expr.trim().match(/^rate\(\s*(\d+)\s+(minute|minutes|hour|hours|day|days)\s*\)$/i);
141
+ if (rate) {
142
+ const n = Number(rate[1]);
143
+ if (!Number.isFinite(n) || n <= 0)
144
+ return null;
145
+ const unit = rate[2].toLowerCase();
146
+ if (unit.startsWith("minute"))
147
+ return n * 60;
148
+ if (unit.startsWith("hour"))
149
+ return n * 3600;
150
+ return n * 86400;
151
+ }
152
+ const cron = expr.trim().match(/^cron\(\s*(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*\)$/i);
153
+ if (!cron)
154
+ return null;
155
+ const minute = cron[1];
156
+ if (minute === "*" || minute === "?")
157
+ return 60;
158
+ // EventBridge also writes a step as "start/every" ("0/10" = every 10 minutes
159
+ // from :00), which the rate()-style regex alone rejected as unparseable.
160
+ const step = minute.match(/^(?:\*|\d+|\d+-\d+)\/(\d+)$/);
161
+ if (step) {
162
+ const n = Number(step[1]);
163
+ return Number.isFinite(n) && n > 0 ? n * 60 : null;
164
+ }
165
+ if (/^\d+-\d+$/.test(minute))
166
+ return 60;
167
+ if (/^\d+(,\d+)*$/.test(minute)) {
168
+ const values = minute.split(",").map(Number).sort((a, b) => a - b);
169
+ if (values.some((v) => !Number.isFinite(v) || v < 0 || v > 59))
170
+ return null;
171
+ if (values.length === 1)
172
+ return 3600;
173
+ let smallest = 60 - values[values.length - 1] + values[0]; // wrap to the next hour
174
+ for (let i = 1; i < values.length; i += 1)
175
+ smallest = Math.min(smallest, values[i] - values[i - 1]);
176
+ return smallest * 60;
177
+ }
178
+ return null;
179
+ }
180
+ /** Refuse a schedule the cloud would refuse, with the cloud's own reason. */
181
+ export function assertScheduleWithinFloor(expr) {
182
+ const interval = recurringIntervalSeconds(expr);
183
+ if (interval === null) {
184
+ refuse("schedule.cron must be a valid rate(...) or cron(...) expression");
185
+ }
186
+ if (interval < MIN_RECURRING_INTERVAL_SECONDS) {
187
+ refuse(`recurring jobs may not fire more often than every ${MIN_RECURRING_INTERVAL_SECONDS / 60} minutes`);
188
+ }
189
+ }
190
+ export function scheduleHelperPath(hqRoot) {
191
+ return path.join(hqRoot, "core", "scripts", "jobs-schedule-parse.sh");
192
+ }
193
+ /**
194
+ * Plain English → cron, using HQ's OWN helper (`core/scripts/jobs-schedule-parse.sh`)
195
+ * so `hq bot jobs --every` and the `/schedule` command agree on what "every
196
+ * weekday morning" means. When the helper is not in this HQ root, the caller is
197
+ * told to pass `--cron` instead rather than getting a second, divergent parser.
198
+ */
199
+ export function parseScheduleText(text, opts) {
200
+ const helper = scheduleHelperPath(opts.hqRoot);
201
+ const exists = opts.exists ?? ((p) => fs.existsSync(p));
202
+ if (!exists(helper)) {
203
+ refuse(`I can't turn "${text}" into a schedule: HQ's schedule helper is not in this HQ folder ` +
204
+ `(${helper}). Pass the schedule directly instead, for example --cron "0 9 * * 1-5".`);
205
+ }
206
+ const exec = opts.exec ?? execFileSync;
207
+ let raw;
208
+ try {
209
+ raw = String(exec("bash", [helper, "--tz", opts.timezone, text], { encoding: "utf8" })).trim();
210
+ }
211
+ catch (err) {
212
+ const out = err.stdout;
213
+ raw = typeof out === "string" ? out.trim() : "";
214
+ if (!raw) {
215
+ refuse(`I couldn't work out a schedule from "${text}". Pass --cron "<expression>" instead.`);
216
+ }
217
+ }
218
+ let parsed;
219
+ try {
220
+ parsed = JSON.parse(raw);
221
+ }
222
+ catch {
223
+ refuse(`I couldn't work out a schedule from "${text}". Pass --cron "<expression>" instead.`);
224
+ }
225
+ if (!parsed.ok || !parsed.cron) {
226
+ refuse(`I couldn't work out a schedule from "${text}"${parsed.error ? ` (${parsed.error})` : ""}. Pass --cron "<expression>" instead.`);
227
+ }
228
+ return {
229
+ cron: parsed.cron,
230
+ timezone: parsed.timezone?.trim() || opts.timezone,
231
+ ...(parsed.human?.trim() ? { human: parsed.human.trim() } : {}),
232
+ };
233
+ }
234
+ /** The create body the cloud expects, with the client-side limits enforced first. */
235
+ export function buildCreateJobBody(input) {
236
+ const prompt = input.prompt.trim();
237
+ if (!prompt)
238
+ refuse("Say what the job should do: --add \"<what to do>\".");
239
+ if (Buffer.byteLength(prompt, "utf-8") > MAX_PROMPT_BYTES) {
240
+ refuse(`That instruction is too long (${Buffer.byteLength(prompt, "utf-8")} bytes; the limit is ${MAX_PROMPT_BYTES}). ` +
241
+ `Shorten it, or put the detail in a file the bot reads when the job runs.`);
242
+ }
243
+ const cron = toEventBridgeCron(input.cron);
244
+ assertScheduleWithinFloor(cron);
245
+ return {
246
+ prompt,
247
+ ...(input.context ? { context: input.context } : {}),
248
+ schedule: { kind: "recurring", cron, timezone: input.timezone },
249
+ };
250
+ }
251
+ // ─── Reading jobs back ───────────────────────────────────────────────────────
252
+ /** A schedule in plain words, falling back to the raw expression. */
253
+ export function describeSchedule(job) {
254
+ const schedule = job.schedule;
255
+ if (!schedule)
256
+ return "on a schedule";
257
+ if (schedule.kind === "once")
258
+ return `once, at ${schedule.at ?? "an unknown time"}${schedule.timezone ? ` (${schedule.timezone})` : ""}`;
259
+ const expr = schedule.cron ?? "";
260
+ const zone = schedule.timezone ? ` (${schedule.timezone})` : "";
261
+ const words = cronInWords(expr);
262
+ return words ? `${words}${zone}` : `${expr}${zone}`;
263
+ }
264
+ const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
265
+ /** A small humaniser for the shapes `--every` actually produces. */
266
+ export function cronInWords(expr) {
267
+ const rate = expr.trim().match(/^rate\(\s*(\d+)\s+(minute|minutes|hour|hours|day|days)\s*\)$/i);
268
+ if (rate)
269
+ return `every ${rate[1]} ${rate[2].toLowerCase()}`;
270
+ const cron = expr.trim().match(/^cron\(\s*(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+\S+\s*\)$/i);
271
+ if (!cron)
272
+ return null;
273
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = [cron[1], cron[2], cron[3], cron[4], cron[5]];
274
+ const step = minute.match(/^(?:\*|\d+)\/(\d+)$/);
275
+ if (step && hour === "*")
276
+ return `every ${step[1]} minutes`;
277
+ const hourStep = hour.match(/^(?:\*|\d+)\/(\d+)$/);
278
+ if (/^\d+$/.test(minute) && hourStep)
279
+ return `every ${hourStep[1]} hours`;
280
+ if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour))
281
+ return null;
282
+ const at = `${String(Number(hour)).padStart(2, "0")}:${String(Number(minute)).padStart(2, "0")}`;
283
+ if (month !== "*")
284
+ return null;
285
+ if (dayOfWeek === "?" || dayOfWeek === "*") {
286
+ if (dayOfMonth === "*" || dayOfMonth === "?")
287
+ return `every day at ${at}`;
288
+ if (/^\d+$/.test(dayOfMonth))
289
+ return `on day ${dayOfMonth} of each month at ${at}`;
290
+ return null;
291
+ }
292
+ // EventBridge days of week: SUNDAY = 1.
293
+ if (dayOfWeek === "2-6")
294
+ return `every weekday at ${at}`;
295
+ if (/^\d$/.test(dayOfWeek))
296
+ return `every ${DAY_NAMES[(Number(dayOfWeek) + 6) % 7]} at ${at}`;
297
+ if (/^\d(,\d)+$/.test(dayOfWeek)) {
298
+ const days = dayOfWeek.split(",").map((d) => DAY_NAMES[(Number(d) + 6) % 7]);
299
+ return `every ${days.join(", ")} at ${at}`;
300
+ }
301
+ return null;
302
+ }
303
+ /** "never run yet" / "succeeded 2026-09-15 09:00 UTC". */
304
+ export function describeLastRun(job) {
305
+ if (!job.lastStatus && !job.lastRunAt)
306
+ return "not run yet";
307
+ const when = job.lastRunAt ? ` ${job.lastRunAt.slice(0, 16).replace("T", " ")} UTC` : "";
308
+ return `${job.lastStatus ?? "ran"}${when}`;
309
+ }
310
+ export function formatJobsList(botName, jobs) {
311
+ if (jobs.length === 0) {
312
+ return `${botName} has no scheduled jobs. Add one with: hq bot jobs ${botName} --add "<what to do>" --every "<when>"`;
313
+ }
314
+ const lines = [`${botName} has ${jobs.length} scheduled job${jobs.length === 1 ? "" : "s"}:`];
315
+ for (const job of jobs) {
316
+ lines.push("");
317
+ lines.push(` ${chalk.bold(job.jobId)}${job.status && job.status !== "active" ? chalk.yellow(` (${job.status})`) : ""}`);
318
+ lines.push(` ${job.prompt.replace(/\s+/g, " ").slice(0, 120)}`);
319
+ lines.push(` runs ${describeSchedule(job)}`);
320
+ lines.push(` next run: ${job.nextRunAt ? `${job.nextRunAt.slice(0, 16).replace("T", " ")} UTC` : "not scheduled yet"}`);
321
+ lines.push(` last run: ${describeLastRun(job)}`);
322
+ }
323
+ return lines.join("\n");
324
+ }
325
+ // ─── Errors a person can act on ──────────────────────────────────────────────
326
+ /**
327
+ * Turn a jobs-route failure into one plain line.
328
+ *
329
+ * Personal bots are refused by the cloud today: their JWT carries the owner's
330
+ * `per_…` company claim and the jobs routes require a `cmp_…`, so the fail-closed
331
+ * guard answers 403 `CROSS_AGENT_JOB` (hq-pro-agents jobs/errors.ts
332
+ * `CrossAgentJobError`). That server fix is a separate change; until it lands the
333
+ * refusal must read as "not available yet for this kind of bot", not as a raw 403.
334
+ * Once the server accepts personal bots the refusal simply stops happening — no
335
+ * client change needed.
336
+ *
337
+ * ONLY that exact code is rewritten. Every other 403, and every 404, keeps its
338
+ * real message: masking them would hide a genuinely missing job or a different
339
+ * permission problem behind copy about the wrong thing.
340
+ */
341
+ export function jobsErrorMessage(err, ctx) {
342
+ if (!(err instanceof BotApiError))
343
+ return err instanceof Error ? err.message : String(err);
344
+ if (ctx.kind === "personal" && err.status === 403 && err.code === "CROSS_AGENT_JOB") {
345
+ return (`Scheduled jobs aren't available for personal bots yet — the cloud only accepts them from ` +
346
+ `company bots. Create ${ctx.botName} as a company bot (hq bot create ${ctx.botName} --kind company ` +
347
+ `--company <slug>) to schedule work, or wait for personal-bot support.`);
348
+ }
349
+ if (err.status === 401)
350
+ return "Not authenticated — the bot's credentials were refused. Try: hq bot restart " + ctx.botName;
351
+ if (err.code === "JOB_QUOTA_EXCEEDED" || err.status === 409) {
352
+ return (`${ctx.botName} already has the maximum of ${MAX_ACTIVE_JOBS_PER_AGENT} scheduled jobs. ` +
353
+ `Remove one first: hq bot jobs ${ctx.botName} --rm <jobId>`);
354
+ }
355
+ if (err.status === 404 && err.code === "JOB_NOT_FOUND")
356
+ return `${ctx.botName} has no job with that id.`;
357
+ if (err.code === "INVALID_SCHEDULE" || err.code === "JOB_VALIDATION") {
358
+ return err.message.replace(/^HQ API [^:]+: /, "");
359
+ }
360
+ return err.message;
361
+ }
362
+ export async function runBotJobs(nameArg, opts, deps) {
363
+ const { name, dir, config } = deps.requireBot(nameArg);
364
+ const kind = effectiveBotKind(config);
365
+ const out = deps.out ?? ((line) => console.log(line));
366
+ const api = deps.apiFor
367
+ ? deps.apiFor(dir)
368
+ : new BotApi({ token: deps.botTokenSupplier(dir) });
369
+ const ctx = { botName: name, kind };
370
+ if (opts.add !== undefined && opts.rm !== undefined) {
371
+ refuse("Add a job or remove one, not both in the same command.");
372
+ }
373
+ if (opts.rm !== undefined) {
374
+ const jobId = opts.rm.trim();
375
+ if (!jobId)
376
+ refuse("Say which job to remove: --rm <jobId>");
377
+ try {
378
+ await api.cancelJob(config.agentUid, jobId);
379
+ }
380
+ catch (err) {
381
+ refuse(jobsErrorMessage(err, ctx));
382
+ }
383
+ out(opts.json ? JSON.stringify({ ok: true, removed: jobId }, null, 2) : chalk.green(`Removed ${jobId}. ${name} will not run it again.`));
384
+ return;
385
+ }
386
+ if (opts.add !== undefined) {
387
+ if (!opts.every && !opts.cron) {
388
+ refuse(`Say how often it should run: --every "every weekday at 9am", or --cron "0 9 * * 1-5".`);
389
+ }
390
+ if (opts.every && opts.cron)
391
+ refuse("Use --every or --cron, not both.");
392
+ const timezone = opts.tz?.trim() || (deps.timezone ?? machineTimezone)();
393
+ const parse = deps.parseSchedule ?? parseScheduleText;
394
+ const schedule = opts.every
395
+ ? parse(opts.every, { hqRoot: config.hqRoot, timezone })
396
+ : { cron: opts.cron, timezone };
397
+ const body = buildCreateJobBody({ prompt: opts.add, cron: schedule.cron, timezone: schedule.timezone });
398
+ let created;
399
+ try {
400
+ created = await api.createJob(config.agentUid, body);
401
+ }
402
+ catch (err) {
403
+ refuse(jobsErrorMessage(err, ctx));
404
+ }
405
+ if (opts.json) {
406
+ out(JSON.stringify({ ok: true, job: created }, null, 2));
407
+ return;
408
+ }
409
+ const words = ("human" in schedule && schedule.human) || describeSchedule(created);
410
+ out(chalk.green(`Scheduled ${created.jobId}: ${name} will ${body.prompt.replace(/\s+/g, " ").slice(0, 120)} — ${words}.`));
411
+ out(chalk.gray("If this computer is asleep or off when a run is due, it is picked up as a catch-up when the computer " +
412
+ "wakes, as long as it wakes within about an hour. After a longer outage that run is skipped and the " +
413
+ "job carries on at its next scheduled time."));
414
+ return;
415
+ }
416
+ let jobs;
417
+ try {
418
+ jobs = await api.listJobs(config.agentUid);
419
+ }
420
+ catch (err) {
421
+ refuse(jobsErrorMessage(err, ctx));
422
+ }
423
+ out(opts.json ? JSON.stringify(jobs, null, 2) : formatJobsList(name, jobs));
424
+ }
425
+ export function registerBotJobsCommand(bot, deps) {
426
+ bot
427
+ .command("jobs <name>")
428
+ .description("Show, schedule, or remove a bot's recurring jobs (the schedule runs in the cloud, not on this computer)")
429
+ .option("--add <what>", "Schedule new work, in plain language")
430
+ .option("--every <when>", 'How often, in plain language (e.g. "every weekday at 9am")')
431
+ .option("--cron <expr>", 'How often, as a cron expression (e.g. "0 9 * * 1-5")')
432
+ .option("--tz <zone>", "IANA timezone for the schedule (default: this computer's)")
433
+ .option("--rm <jobId>", "Remove a scheduled job")
434
+ .option("--json", "Print machine-readable JSON")
435
+ .action(async (name, opts) => {
436
+ try {
437
+ await runBotJobs(name, opts, deps);
438
+ }
439
+ catch (err) {
440
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
441
+ process.exit(1);
442
+ }
443
+ });
444
+ }
445
+ //# sourceMappingURL=bot-jobs.js.map