@indigoai-us/hq-cli 5.135.0 → 5.137.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 +11 -0
- package/dist/command-catalog.generated.d.ts +47 -0
- package/dist/command-catalog.generated.js +62 -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/commands/channels.d.ts +9 -1
- package/dist/commands/channels.js +56 -1
- package/dist/commands/dm.d.ts +6 -0
- package/dist/commands/dm.js +1 -1
- 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
|
@@ -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
|
package/dist/commands/bot.js
CHANGED
|
@@ -18,6 +18,7 @@ import { hasPromotionHold, promotionHoldSummary } from "../lib/bot/promotion-hol
|
|
|
18
18
|
* hq bot daemon install|uninstall|status <name>
|
|
19
19
|
* hq bot rm <name> [--yes]
|
|
20
20
|
* hq bot intro <name> (re-send the introduction DM)
|
|
21
|
+
* hq bot jobs <name> [--add "…" --every "…"] [--rm <jobId>] [--json]
|
|
21
22
|
*
|
|
22
23
|
* The bot's own identity (agt_) authenticates with 0600 machine creds under
|
|
23
24
|
* ~/.hq/bots/<name>/; the OWNER's cached session is used only for create,
|
|
@@ -26,6 +27,7 @@ import { hasPromotionHold, promotionHoldSummary } from "../lib/bot/promotion-hol
|
|
|
26
27
|
import { promoteLocalBot } from "../lib/bot/promote.js";
|
|
27
28
|
import { addBotToCompanies, describeCompanyFailures, removeBotFromCompanies } from "./bot-companies.js";
|
|
28
29
|
import { registerBotContinuityCommand } from "./bot-continuity.js";
|
|
30
|
+
import { registerBotJobsCommand } from "./bot-jobs.js";
|
|
29
31
|
import chalk from "chalk";
|
|
30
32
|
import { execFileSync, spawn } from "node:child_process";
|
|
31
33
|
import * as fs from "node:fs";
|
|
@@ -1543,6 +1545,7 @@ export function registerBotCommand(program) {
|
|
|
1543
1545
|
.command("bot")
|
|
1544
1546
|
.description("Personal local bots that run on this computer with your own model login");
|
|
1545
1547
|
registerBotContinuityCommand(bot);
|
|
1548
|
+
registerBotJobsCommand(bot, { requireBot, botTokenSupplier });
|
|
1546
1549
|
bot.command("promote <name>")
|
|
1547
1550
|
.description("Move this local bot to cloud compute, preserving its identity and private context")
|
|
1548
1551
|
.requiredOption("--company <uid>", "Destination company identity")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { type ChannelSummary } from "./dm.js";
|
|
2
|
+
import { type CallerIdentity, type ChannelMember, type ChannelSummary } from "./dm.js";
|
|
3
3
|
/**
|
|
4
4
|
* Human label for a channel row. Named channels (personal/company) show their
|
|
5
5
|
* name + a `hq dm <slug>` hint; unnamed group DMs are identified by their
|
|
@@ -12,5 +12,13 @@ export declare function describeChannel(c: ChannelSummary): string;
|
|
|
12
12
|
* fetched list) so the formatting is unit-testable without network/auth.
|
|
13
13
|
*/
|
|
14
14
|
export declare function formatChannelsList(channels: ChannelSummary[]): string;
|
|
15
|
+
/**
|
|
16
|
+
* Remove the caller from a channel roster before it is used for mentions.
|
|
17
|
+
* Machine principals identify themselves with the JWT uid; human sessions can
|
|
18
|
+
* also match the roster's email when the uid claim is absent.
|
|
19
|
+
*/
|
|
20
|
+
export declare function excludeCallerFromChannelMembers(members: ChannelMember[], caller: CallerIdentity): ChannelMember[];
|
|
21
|
+
/** Render the roster as either dm-bind's tab-separated format or JSON. */
|
|
22
|
+
export declare function formatChannelMembers(members: ChannelMember[], json: boolean): string;
|
|
15
23
|
export declare function registerChannelsCommand(program: Command): void;
|
|
16
24
|
//# sourceMappingURL=channels.d.ts.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
3
3
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
4
|
-
import { channelSlug } from "./dm.js";
|
|
4
|
+
import { callerFromToken, channelSlug, fetchChannelMembers, fetchChannels, matchChannelsByName, } from "./dm.js";
|
|
5
5
|
/**
|
|
6
6
|
* Human label for a channel row. Named channels (personal/company) show their
|
|
7
7
|
* name + a `hq dm <slug>` hint; unnamed group DMs are identified by their
|
|
@@ -42,6 +42,31 @@ export function formatChannelsList(channels) {
|
|
|
42
42
|
}
|
|
43
43
|
return lines.join("\n");
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Remove the caller from a channel roster before it is used for mentions.
|
|
47
|
+
* Machine principals identify themselves with the JWT uid; human sessions can
|
|
48
|
+
* also match the roster's email when the uid claim is absent.
|
|
49
|
+
*/
|
|
50
|
+
export function excludeCallerFromChannelMembers(members, caller) {
|
|
51
|
+
return members.filter((member) => {
|
|
52
|
+
if (caller.personUid && member.personUid === caller.personUid)
|
|
53
|
+
return false;
|
|
54
|
+
if (caller.email &&
|
|
55
|
+
member.email &&
|
|
56
|
+
member.email.trim().toLowerCase() === caller.email) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Render the roster as either dm-bind's tab-separated format or JSON. */
|
|
63
|
+
export function formatChannelMembers(members, json) {
|
|
64
|
+
if (json)
|
|
65
|
+
return JSON.stringify(members);
|
|
66
|
+
return members
|
|
67
|
+
.map((member) => `${member.displayName ?? ""}\t${member.personUid}`)
|
|
68
|
+
.join("\n");
|
|
69
|
+
}
|
|
45
70
|
async function runChannelsList() {
|
|
46
71
|
try {
|
|
47
72
|
const token = await ensureCognitoToken();
|
|
@@ -64,6 +89,29 @@ async function runChannelsList() {
|
|
|
64
89
|
process.exit(1);
|
|
65
90
|
}
|
|
66
91
|
}
|
|
92
|
+
async function runChannelsMembers(channelName, opts) {
|
|
93
|
+
try {
|
|
94
|
+
const token = await ensureCognitoToken();
|
|
95
|
+
const channels = await fetchChannels(token);
|
|
96
|
+
const matches = matchChannelsByName(channels, channelName);
|
|
97
|
+
if (matches.length === 0) {
|
|
98
|
+
console.error(chalk.red(`No channel named '${channelName}' — run \`hq channels\` to see your channels.`));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
if (matches.length > 1) {
|
|
102
|
+
const scopes = matches.map((match) => match.scope ?? "?").join(", ");
|
|
103
|
+
console.error(chalk.red(`'${channelName}' matches ${matches.length} channels (${scopes}) — this is ambiguous.`));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const members = await fetchChannelMembers(token, matches[0].channelId);
|
|
107
|
+
const visibleMembers = excludeCallerFromChannelMembers(members, callerFromToken(token));
|
|
108
|
+
console.log(formatChannelMembers(visibleMembers, opts.json === true));
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
67
115
|
export function registerChannelsCommand(program) {
|
|
68
116
|
const channels = program
|
|
69
117
|
.command("channels")
|
|
@@ -74,5 +122,12 @@ export function registerChannelsCommand(program) {
|
|
|
74
122
|
.action(async () => {
|
|
75
123
|
await runChannelsList();
|
|
76
124
|
});
|
|
125
|
+
channels
|
|
126
|
+
.command("members <channel>")
|
|
127
|
+
.description("Print the members of one of your DM channels.")
|
|
128
|
+
.option("--json", "Output the member array as JSON.")
|
|
129
|
+
.action(async (channelName, opts) => {
|
|
130
|
+
await runChannelsMembers(channelName, opts);
|
|
131
|
+
});
|
|
77
132
|
}
|
|
78
133
|
//# sourceMappingURL=channels.js.map
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -192,6 +192,12 @@ export declare function buildConnectionActionBody(identifier: string, matched: C
|
|
|
192
192
|
};
|
|
193
193
|
/** Fetch the caller's channels (GET /v1/notify/channels). */
|
|
194
194
|
export declare function fetchChannels(token: string): Promise<ChannelSummary[]>;
|
|
195
|
+
/**
|
|
196
|
+
* Fetch a channel's roster (GET /v1/notify/channels/{id}/members). Any member
|
|
197
|
+
* who can read the channel may read its members, so this is the same gate the
|
|
198
|
+
* post itself passes. Used to resolve `@name` mentions into participant uids.
|
|
199
|
+
*/
|
|
200
|
+
export declare function fetchChannelMembers(token: string, channelId: string): Promise<ChannelMember[]>;
|
|
195
201
|
/** One incoming DM as returned by GET /v1/notify/inbox. */
|
|
196
202
|
export interface DmInboxEvent {
|
|
197
203
|
eventId: string;
|
package/dist/commands/dm.js
CHANGED
|
@@ -503,7 +503,7 @@ export async function fetchChannels(token) {
|
|
|
503
503
|
* who can read the channel may read its members, so this is the same gate the
|
|
504
504
|
* post itself passes. Used to resolve `@name` mentions into participant uids.
|
|
505
505
|
*/
|
|
506
|
-
async function fetchChannelMembers(token, channelId) {
|
|
506
|
+
export async function fetchChannelMembers(token, channelId) {
|
|
507
507
|
const res = await vaultApiFetch({
|
|
508
508
|
token,
|
|
509
509
|
path: `/v1/notify/channels/${encodeURIComponent(channelId)}/members`,
|
package/dist/lib/bot/api.d.ts
CHANGED
|
@@ -29,6 +29,59 @@ export interface InboxItem {
|
|
|
29
29
|
mentionedMe?: boolean;
|
|
30
30
|
memberCount?: number;
|
|
31
31
|
senderTrust?: "member";
|
|
32
|
+
/**
|
|
33
|
+
* Scheduled-job run items only (channel === "job"). The cloud fires the job
|
|
34
|
+
* on its own schedule and writes this durable item; the run carries its own
|
|
35
|
+
* prompt instead of `text` (server: hq-pro-agents jobs/run-record.ts
|
|
36
|
+
* `JobRunInboxItem`).
|
|
37
|
+
*/
|
|
38
|
+
jobId?: string;
|
|
39
|
+
prompt?: string;
|
|
40
|
+
context?: Record<string, unknown> | string;
|
|
41
|
+
script?: string;
|
|
42
|
+
firedAt?: string;
|
|
43
|
+
staleAfter?: string;
|
|
44
|
+
requestId?: string;
|
|
45
|
+
}
|
|
46
|
+
/** The terminal outcome a box reports for one fired run. */
|
|
47
|
+
export type JobRunStatus = "succeeded" | "failed" | "skipped-precondition";
|
|
48
|
+
export interface JobRunCompletion {
|
|
49
|
+
status: JobRunStatus;
|
|
50
|
+
output?: string;
|
|
51
|
+
disposition?: string;
|
|
52
|
+
}
|
|
53
|
+
/** A job record as the cloud returns it (hq-pro-agents jobs/schema.ts `JobRecord`). */
|
|
54
|
+
export interface BotJobRecord {
|
|
55
|
+
jobId: string;
|
|
56
|
+
prompt: string;
|
|
57
|
+
status?: string;
|
|
58
|
+
schedule?: {
|
|
59
|
+
kind?: string;
|
|
60
|
+
cron?: string;
|
|
61
|
+
at?: string;
|
|
62
|
+
timezone?: string;
|
|
63
|
+
};
|
|
64
|
+
context?: Record<string, unknown>;
|
|
65
|
+
script?: string;
|
|
66
|
+
createdAt?: string;
|
|
67
|
+
nextRunAt?: string;
|
|
68
|
+
lastRunAt?: string;
|
|
69
|
+
lastStatus?: string;
|
|
70
|
+
runCount?: number;
|
|
71
|
+
}
|
|
72
|
+
export interface CreateJobInput {
|
|
73
|
+
prompt: string;
|
|
74
|
+
context?: Record<string, unknown>;
|
|
75
|
+
script?: string;
|
|
76
|
+
schedule: {
|
|
77
|
+
kind: "once";
|
|
78
|
+
at: string;
|
|
79
|
+
timezone: string;
|
|
80
|
+
} | {
|
|
81
|
+
kind: "recurring";
|
|
82
|
+
cron: string;
|
|
83
|
+
timezone: string;
|
|
84
|
+
};
|
|
32
85
|
}
|
|
33
86
|
/** @deprecated use InboxItem */
|
|
34
87
|
export type DmInboxItem = InboxItem;
|
|
@@ -132,6 +185,19 @@ export declare class BotApi {
|
|
|
132
185
|
pullInbox(agentUid: string): Promise<InboxItem[]>;
|
|
133
186
|
/** POST /v1/agents/{uid}/inbox/{messageId}/ack */
|
|
134
187
|
ackInbox(agentUid: string, messageId: string): Promise<void>;
|
|
188
|
+
/**
|
|
189
|
+
* POST /v1/agents/{uid}/jobs/{jobId}/runs/{messageId}/complete — report one
|
|
190
|
+
* fired run as finished. MANDATORY: nothing else clears the job's
|
|
191
|
+
* in-progress flag, so a run that is never reported makes every later fire
|
|
192
|
+
* of that job `skipped-overlap` for up to an hour.
|
|
193
|
+
*/
|
|
194
|
+
completeJobRun(agentUid: string, jobId: string, messageId: string, completion: JobRunCompletion): Promise<void>;
|
|
195
|
+
/** GET /v1/agents/{uid}/jobs — the bot's own scheduled jobs (self-only). */
|
|
196
|
+
listJobs(agentUid: string): Promise<BotJobRecord[]>;
|
|
197
|
+
/** POST /v1/agents/{uid}/jobs — schedule a new job for the bot itself. */
|
|
198
|
+
createJob(agentUid: string, input: CreateJobInput): Promise<BotJobRecord>;
|
|
199
|
+
/** POST /v1/agents/{uid}/jobs/{jobId}/cancel — cancel and delete its schedule. */
|
|
200
|
+
cancelJob(agentUid: string, jobId: string): Promise<void>;
|
|
135
201
|
/** POST /v1/notify/dm — send as the bot (agent identity). */
|
|
136
202
|
sendDm(input: {
|
|
137
203
|
toPersonUid: string;
|