@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 +6 -0
- package/dist/command-catalog.generated.d.ts +31 -0
- package/dist/command-catalog.generated.js +41 -0
- package/dist/commands/bot-jobs.d.ts +127 -0
- package/dist/commands/bot-jobs.js +445 -0
- package/dist/commands/bot.js +3 -0
- package/dist/lib/bot/api.d.ts +66 -0
- package/dist/lib/bot/api.js +38 -0
- package/dist/lib/bot/index.d.ts +3 -0
- package/dist/lib/bot/index.js +3 -0
- package/dist/lib/bot/inflight.d.ts +42 -0
- package/dist/lib/bot/job-completions.d.ts +91 -0
- package/dist/lib/bot/job-completions.js +132 -0
- package/dist/lib/bot/job-run.d.ts +109 -0
- package/dist/lib/bot/job-run.js +246 -0
- package/dist/lib/bot/paths.d.ts +9 -1
- package/dist/lib/bot/paths.js +14 -0
- package/dist/lib/bot/prompt.d.ts +12 -0
- package/dist/lib/bot/prompt.js +31 -0
- package/dist/lib/bot/room-policy.d.ts +18 -1
- package/dist/lib/bot/room-policy.js +20 -2
- package/dist/lib/bot/run.d.ts +23 -1
- package/dist/lib/bot/run.js +404 -7
- package/package.json +1 -1
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
|