@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.
@@ -101,6 +101,44 @@ export class BotApi {
101
101
  async ackInbox(agentUid, messageId) {
102
102
  await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/inbox/${encodeURIComponent(messageId)}/ack`, { method: "POST", body: {} });
103
103
  }
104
+ /**
105
+ * POST /v1/agents/{uid}/jobs/{jobId}/runs/{messageId}/complete — report one
106
+ * fired run as finished. MANDATORY: nothing else clears the job's
107
+ * in-progress flag, so a run that is never reported makes every later fire
108
+ * of that job `skipped-overlap` for up to an hour.
109
+ */
110
+ async completeJobRun(agentUid, jobId, messageId, completion) {
111
+ await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/jobs/${encodeURIComponent(jobId)}/runs/${encodeURIComponent(messageId)}/complete`, {
112
+ method: "POST",
113
+ body: {
114
+ status: completion.status,
115
+ ...(completion.output !== undefined ? { output: completion.output } : {}),
116
+ ...(completion.disposition !== undefined ? { disposition: completion.disposition } : {}),
117
+ },
118
+ }, [200, 201, 202, 204]);
119
+ }
120
+ /** GET /v1/agents/{uid}/jobs — the bot's own scheduled jobs (self-only). */
121
+ async listJobs(agentUid) {
122
+ const { body } = await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/jobs`);
123
+ return body.jobs ?? [];
124
+ }
125
+ /** POST /v1/agents/{uid}/jobs — schedule a new job for the bot itself. */
126
+ async createJob(agentUid, input) {
127
+ const { body } = await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/jobs`, {
128
+ method: "POST",
129
+ body: {
130
+ prompt: input.prompt,
131
+ ...(input.context ? { context: input.context } : {}),
132
+ ...(input.script ? { script: input.script } : {}),
133
+ schedule: input.schedule,
134
+ },
135
+ });
136
+ return body.job;
137
+ }
138
+ /** POST /v1/agents/{uid}/jobs/{jobId}/cancel — cancel and delete its schedule. */
139
+ async cancelJob(agentUid, jobId) {
140
+ await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/jobs/${encodeURIComponent(jobId)}/cancel`, { method: "POST", body: {} }, [200, 201, 202, 204]);
141
+ }
104
142
  /** POST /v1/notify/dm — send as the bot (agent identity). */
105
143
  async sendDm(input) {
106
144
  const payload = { toPersonUid: input.toPersonUid, body: input.body };
@@ -17,4 +17,7 @@ export * from "./local-config.js";
17
17
  export * from "./self-heal.js";
18
18
  export * from "./runnable.js";
19
19
  export * from "./local-name.js";
20
+ export * from "./job-run.js";
21
+ export * from "./job-completions.js";
22
+ export * from "./inflight.js";
20
23
  //# sourceMappingURL=index.d.ts.map
@@ -17,4 +17,7 @@ export * from "./local-config.js";
17
17
  export * from "./self-heal.js";
18
18
  export * from "./runnable.js";
19
19
  export * from "./local-name.js";
20
+ export * from "./job-run.js";
21
+ export * from "./job-completions.js";
22
+ export * from "./inflight.js";
20
23
  //# sourceMappingURL=index.js.map
@@ -26,6 +26,48 @@ export interface InflightTurn {
26
26
  kind: "room";
27
27
  channelId: string;
28
28
  };
29
+ /**
30
+ * Scheduled-job runs: the job this message is a run of, so a turn cut off by
31
+ * a restart is still reported to the cloud (which is the only thing that
32
+ * clears the job's in-progress flag).
33
+ */
34
+ jobId?: string;
35
+ /**
36
+ * Set as soon as this run's outcome has been REPORTED to the cloud (accepted,
37
+ * or durably queued for retry). A recovery pass must not report it again: the
38
+ * server's complete is last-write-wins, so a second "failed" would overwrite
39
+ * a recorded "succeeded" after a crash between reporting and acking.
40
+ */
41
+ jobCompleted?: "succeeded" | "failed" | "skipped-precondition";
42
+ /**
43
+ * The "scheduled job" header this run's FIRST message carries, so a report
44
+ * posted by the recovery path still reads as a scheduled run rather than an
45
+ * unsolicited DM.
46
+ */
47
+ jobHeader?: string;
48
+ /**
49
+ * The bot a scheduled run's marker belongs to. `hq bot adopt` can place a
50
+ * DIFFERENT bot into a folder that survived (bot.json gone, the rest left);
51
+ * recovery drops a job marker stamped with another agent instead of posting
52
+ * its report into this bot's DM and completing it under this identity.
53
+ * Absent on markers written before it was recorded: those behave as before.
54
+ */
55
+ agentUid?: string;
56
+ /**
57
+ * The run's own report, as it is recorded with the cloud, kept apart from the
58
+ * body saved for resending (which may carry the header or earlier progress).
59
+ */
60
+ jobOutput?: string;
61
+ /**
62
+ * Why a started scheduled run did not finish (the runtime's error, or the
63
+ * owner stopping it), saved BEFORE the "did not finish" post is attempted. If
64
+ * that post fails, recovery reports and tells the owner THIS reason rather
65
+ * than claiming the bot was restarted.
66
+ */
67
+ jobFailure?: {
68
+ reason: string;
69
+ excerpt?: string;
70
+ };
29
71
  rootEventId?: string;
30
72
  /** DM message carrying the thinking reaction, so a restart can clear it. */
31
73
  reactionEventId?: string;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * job-completions.json — run completions the cloud has not accepted yet.
3
+ *
4
+ * Reporting a finished run is MANDATORY: nothing else clears the job's
5
+ * in-progress flag, and until it is cleared every later fire of that job is
6
+ * skipped as an overlap for up to an hour. So when the completion call fails
7
+ * (the network was down, the token was stale), the bot logs it loudly, still
8
+ * acks the inbox item — the work is done and must never run twice — and keeps
9
+ * the completion here to retry.
10
+ *
11
+ * A retry only re-sends a small report; it never re-runs the work.
12
+ *
13
+ * Retries are spaced by EXPONENTIAL BACKOFF and given up on by WALL-CLOCK AGE,
14
+ * not by a count of attempts. The inbox polls every 2 seconds, so a fixed
15
+ * ten-attempt budget was spent in about twenty seconds — an ordinary sixty-
16
+ * second network drop permanently lost the completion and wedged that job for
17
+ * the rest of the server's in-progress hour. Giving up is pegged to that same
18
+ * hour, after which the cloud releases the slot by itself anyway.
19
+ *
20
+ * ── After a reinstall: restore, adopt, repair (decision, 2026-09-16) ─────────
21
+ *
22
+ * This file lives in the bot's own folder (~/.hq/bots/<name>/), which is never
23
+ * synced. What `hq bot restore` / `hq bot adopt` do to it decides what happens
24
+ * to a completion owed by a run on the OLD machine:
25
+ *
26
+ * - REPAIR (the folder is still here; only the credentials are re-issued): the
27
+ * file survives and its completions are still reported, with the new
28
+ * credentials. That is correct — it is the same agent, and the complete route
29
+ * authorizes by agent identity, not by machine.
30
+ * - ADOPT onto a fresh machine: there is no file, so nothing is reported from
31
+ * here. That is not a lost report: the old machine ACKED the run before it
32
+ * queued the completion (the work must never run twice), so the cloud will
33
+ * not redeliver it, and the job's in-progress slot is released by the cloud
34
+ * itself within the hour. Not duplicated, and not jammed for longer than the
35
+ * outage already cost.
36
+ * - ADOPT into a folder that SURVIVED (bot.json gone, the rest left behind):
37
+ * completions stamped with THIS bot's agentUid are reported as normal. A
38
+ * completion stamped with a DIFFERENT agentUid — a same-named bot that was
39
+ * deleted and re-created — is dropped, never sent: it belongs to another
40
+ * identity, and reporting it against this one would be a cross-identity
41
+ * write that can only fail or, worse, land on the wrong run.
42
+ *
43
+ * Per-job model SESSIONS (session-job-<jobId>.json) need no such care: a job
44
+ * that finds no session simply starts a fresh one, and a scheduled run carries
45
+ * everything it needs in its own prompt.
46
+ */
47
+ import type { JobRunStatus } from "./api.js";
48
+ export declare const BOT_JOB_COMPLETIONS_NAME = "job-completions.json";
49
+ /** First retry gap; each failure doubles it. */
50
+ export declare const COMPLETION_RETRY_BASE_MS = 2000;
51
+ /** Never wait longer than this between retries. */
52
+ export declare const COMPLETION_RETRY_MAX_MS: number;
53
+ /**
54
+ * Stop retrying a completion older than this. Matches the server's
55
+ * DEFAULT_JOBS_MAX_IN_PROGRESS_SECONDS: past it the cloud reclaims the job's
56
+ * in-progress slot on its own, so a later report changes nothing.
57
+ */
58
+ export declare const MAX_COMPLETION_AGE_MS: number;
59
+ export interface PendingJobCompletion {
60
+ v: 1;
61
+ /**
62
+ * The agent that owes this report. Checked before every retry so a folder
63
+ * that outlived its bot never reports under a different identity. Absent on
64
+ * entries written before it was recorded; those are treated as this bot's.
65
+ */
66
+ agentUid?: string;
67
+ jobId: string;
68
+ /** The inbox item id — the run id in the completion route. */
69
+ messageId: string;
70
+ status: JobRunStatus;
71
+ output?: string;
72
+ disposition?: string;
73
+ firstFailedAt: string;
74
+ attempts: number;
75
+ /** Epoch ms before which this must not be retried (exponential backoff). */
76
+ nextAttemptAt: number;
77
+ }
78
+ export declare function botJobCompletionsPath(dir: string): string;
79
+ export declare function readPendingJobCompletions(dir: string): PendingJobCompletion[];
80
+ /** The gap before attempt N (1-based), doubling and capped. */
81
+ export declare function completionRetryDelayMs(attempts: number): number;
82
+ /** True when this entry is due for another attempt. */
83
+ export declare function isCompletionDue(entry: PendingJobCompletion, nowMs: number): boolean;
84
+ /** True when this entry was owed by a different bot identity than `agentUid`. */
85
+ export declare function isCompletionForOtherAgent(entry: PendingJobCompletion, agentUid: string): boolean;
86
+ /** True when the cloud has already reclaimed the slot; retrying changes nothing. */
87
+ export declare function isCompletionExpired(entry: PendingJobCompletion, nowMs: number): boolean;
88
+ /** Record (or back off) a completion the cloud has not taken. */
89
+ export declare function recordPendingJobCompletion(dir: string, entry: Omit<PendingJobCompletion, "v" | "firstFailedAt" | "attempts" | "nextAttemptAt">, now: Date): PendingJobCompletion;
90
+ export declare function clearPendingJobCompletion(dir: string, messageId: string): void;
91
+ //# sourceMappingURL=job-completions.d.ts.map
@@ -0,0 +1,132 @@
1
+ /**
2
+ * job-completions.json — run completions the cloud has not accepted yet.
3
+ *
4
+ * Reporting a finished run is MANDATORY: nothing else clears the job's
5
+ * in-progress flag, and until it is cleared every later fire of that job is
6
+ * skipped as an overlap for up to an hour. So when the completion call fails
7
+ * (the network was down, the token was stale), the bot logs it loudly, still
8
+ * acks the inbox item — the work is done and must never run twice — and keeps
9
+ * the completion here to retry.
10
+ *
11
+ * A retry only re-sends a small report; it never re-runs the work.
12
+ *
13
+ * Retries are spaced by EXPONENTIAL BACKOFF and given up on by WALL-CLOCK AGE,
14
+ * not by a count of attempts. The inbox polls every 2 seconds, so a fixed
15
+ * ten-attempt budget was spent in about twenty seconds — an ordinary sixty-
16
+ * second network drop permanently lost the completion and wedged that job for
17
+ * the rest of the server's in-progress hour. Giving up is pegged to that same
18
+ * hour, after which the cloud releases the slot by itself anyway.
19
+ *
20
+ * ── After a reinstall: restore, adopt, repair (decision, 2026-09-16) ─────────
21
+ *
22
+ * This file lives in the bot's own folder (~/.hq/bots/<name>/), which is never
23
+ * synced. What `hq bot restore` / `hq bot adopt` do to it decides what happens
24
+ * to a completion owed by a run on the OLD machine:
25
+ *
26
+ * - REPAIR (the folder is still here; only the credentials are re-issued): the
27
+ * file survives and its completions are still reported, with the new
28
+ * credentials. That is correct — it is the same agent, and the complete route
29
+ * authorizes by agent identity, not by machine.
30
+ * - ADOPT onto a fresh machine: there is no file, so nothing is reported from
31
+ * here. That is not a lost report: the old machine ACKED the run before it
32
+ * queued the completion (the work must never run twice), so the cloud will
33
+ * not redeliver it, and the job's in-progress slot is released by the cloud
34
+ * itself within the hour. Not duplicated, and not jammed for longer than the
35
+ * outage already cost.
36
+ * - ADOPT into a folder that SURVIVED (bot.json gone, the rest left behind):
37
+ * completions stamped with THIS bot's agentUid are reported as normal. A
38
+ * completion stamped with a DIFFERENT agentUid — a same-named bot that was
39
+ * deleted and re-created — is dropped, never sent: it belongs to another
40
+ * identity, and reporting it against this one would be a cross-identity
41
+ * write that can only fail or, worse, land on the wrong run.
42
+ *
43
+ * Per-job model SESSIONS (session-job-<jobId>.json) need no such care: a job
44
+ * that finds no session simply starts a fresh one, and a scheduled run carries
45
+ * everything it needs in its own prompt.
46
+ */
47
+ import * as fs from "node:fs";
48
+ import * as path from "node:path";
49
+ export const BOT_JOB_COMPLETIONS_NAME = "job-completions.json";
50
+ /** First retry gap; each failure doubles it. */
51
+ export const COMPLETION_RETRY_BASE_MS = 2_000;
52
+ /** Never wait longer than this between retries. */
53
+ export const COMPLETION_RETRY_MAX_MS = 5 * 60_000;
54
+ /**
55
+ * Stop retrying a completion older than this. Matches the server's
56
+ * DEFAULT_JOBS_MAX_IN_PROGRESS_SECONDS: past it the cloud reclaims the job's
57
+ * in-progress slot on its own, so a later report changes nothing.
58
+ */
59
+ export const MAX_COMPLETION_AGE_MS = 60 * 60_000;
60
+ export function botJobCompletionsPath(dir) {
61
+ return path.join(dir, BOT_JOB_COMPLETIONS_NAME);
62
+ }
63
+ function isPending(raw) {
64
+ const c = raw;
65
+ return c?.v === 1 && typeof c.jobId === "string" && typeof c.messageId === "string" && typeof c.status === "string";
66
+ }
67
+ export function readPendingJobCompletions(dir) {
68
+ try {
69
+ const raw = JSON.parse(fs.readFileSync(botJobCompletionsPath(dir), "utf8"));
70
+ if (raw?.v !== 1 || !Array.isArray(raw.completions))
71
+ return [];
72
+ return raw.completions.filter(isPending);
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ }
78
+ /** The gap before attempt N (1-based), doubling and capped. */
79
+ export function completionRetryDelayMs(attempts) {
80
+ const exponent = Math.max(0, attempts - 1);
81
+ // Bound the shift so a long-lived entry cannot overflow into Infinity.
82
+ const raw = COMPLETION_RETRY_BASE_MS * 2 ** Math.min(exponent, 20);
83
+ return Math.min(COMPLETION_RETRY_MAX_MS, raw);
84
+ }
85
+ /** True when this entry is due for another attempt. */
86
+ export function isCompletionDue(entry, nowMs) {
87
+ return nowMs >= (entry.nextAttemptAt ?? 0);
88
+ }
89
+ /** True when this entry was owed by a different bot identity than `agentUid`. */
90
+ export function isCompletionForOtherAgent(entry, agentUid) {
91
+ return typeof entry.agentUid === "string" && entry.agentUid !== "" && entry.agentUid !== agentUid;
92
+ }
93
+ /** True when the cloud has already reclaimed the slot; retrying changes nothing. */
94
+ export function isCompletionExpired(entry, nowMs) {
95
+ const first = new Date(entry.firstFailedAt).getTime();
96
+ if (Number.isNaN(first))
97
+ return false;
98
+ return nowMs - first >= MAX_COMPLETION_AGE_MS;
99
+ }
100
+ function write(dir, completions) {
101
+ if (completions.length === 0) {
102
+ fs.rmSync(botJobCompletionsPath(dir), { force: true });
103
+ return;
104
+ }
105
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
106
+ const tmp = path.join(dir, `.job-completions.tmp.${process.pid}.${Date.now()}`);
107
+ const file = { v: 1, completions };
108
+ fs.writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
109
+ fs.renameSync(tmp, botJobCompletionsPath(dir));
110
+ }
111
+ /** Record (or back off) a completion the cloud has not taken. */
112
+ export function recordPendingJobCompletion(dir, entry, now) {
113
+ const rest = readPendingJobCompletions(dir);
114
+ const existing = rest.find((c) => c.messageId === entry.messageId);
115
+ const attempts = (existing?.attempts ?? 0) + 1;
116
+ const next = {
117
+ v: 1,
118
+ ...entry,
119
+ firstFailedAt: existing?.firstFailedAt ?? now.toISOString(),
120
+ attempts,
121
+ nextAttemptAt: now.getTime() + completionRetryDelayMs(attempts),
122
+ };
123
+ write(dir, [...rest.filter((c) => c.messageId !== entry.messageId), next]);
124
+ return next;
125
+ }
126
+ export function clearPendingJobCompletion(dir, messageId) {
127
+ const rest = readPendingJobCompletions(dir);
128
+ const kept = rest.filter((c) => c.messageId !== messageId);
129
+ if (kept.length !== rest.length)
130
+ write(dir, kept);
131
+ }
132
+ //# sourceMappingURL=job-completions.js.map
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Scheduled job runs: the third kind of inbox item a bot can be handed.
3
+ *
4
+ * The cloud does all the scheduling. hq-pro-agents fires a job on an
5
+ * EventBridge schedule and writes a DURABLE inbox item for the agent
6
+ * (`channel: "job"`) carrying the job's own prompt. Nothing is scheduled on
7
+ * this computer — there is no crontab, no launchd timer, no local clock.
8
+ *
9
+ * Two rules shape everything here (owner decisions, 2026-09-15):
10
+ *
11
+ * - CATCH-UP, never skip. A run whose `staleAfter` has passed is still run,
12
+ * once, and the bot says in its report that it is a catch-up and names the
13
+ * time the run was originally due. A Mac that was asleep overnight wakes up
14
+ * and does the morning job late rather than silently dropping it.
15
+ * - ONE catch-up per job per wake. If several runs of the SAME job are pending
16
+ * (a long sleep over many fires), only the newest runs; the older ones are
17
+ * reported to the cloud as `skipped-precondition` with a plain disposition,
18
+ * so the job's in-progress flag is released and the history is honest.
19
+ *
20
+ * Everything in this file is pure so the rules are unit-testable.
21
+ */
22
+ import type { InboxItem } from "./api.js";
23
+ /** The job fields an inbox item carries when `channel === "job"`. */
24
+ export interface JobRunFields {
25
+ jobId: string;
26
+ prompt: string;
27
+ /** Opaque metadata from the job record (an object on the wire). */
28
+ context?: Record<string, unknown> | string;
29
+ script?: string;
30
+ /** When the schedule fired (ISO-8601) — the time the run was due. */
31
+ firedAt?: string;
32
+ /** Delivery deadline (ISO-8601); past it, this is a catch-up. */
33
+ staleAfter?: string;
34
+ requestId?: string;
35
+ }
36
+ export declare function isJobItem(item: Pick<InboxItem, "channel">): boolean;
37
+ /**
38
+ * The job fields of an inbox item, or null when it is not a usable job run.
39
+ * A job item with no jobId or no prompt is nothing the bot can run; the caller
40
+ * acks it rather than looping on it forever.
41
+ */
42
+ export declare function readJobRun(item: InboxItem): JobRunFields | null;
43
+ /** The run's extra context as text for the turn, or null when there is none. */
44
+ export declare function jobContextText(context: JobRunFields["context"]): string | null;
45
+ /** The zone this computer is in — where the owner scheduled the job. */
46
+ export declare function machineTimeZone(): string;
47
+ /**
48
+ * A readable instant for a person: "2026-09-15 09:00 EDT".
49
+ *
50
+ * Rendered in the OWNER's zone, not UTC. Schedules are created in the machine's
51
+ * zone ("every weekday at 9am" means 9am where the owner lives), so reporting
52
+ * the same run as "due 13:00 UTC" reads as the wrong time to the only person
53
+ * who sees it. The format is fixed (en-CA + explicit parts) rather than
54
+ * locale-default, so the bot reads the same on every machine and the output is
55
+ * testable.
56
+ */
57
+ export declare function formatJobTime(iso: string | undefined, timeZone?: string): string;
58
+ /** True when the delivery deadline has passed: this run is late, not skipped. */
59
+ export declare function isCatchUpRun(job: JobRunFields, now: Date): boolean;
60
+ /**
61
+ * The user turn for a fired job. It says plainly that this is a scheduled run
62
+ * so the model does not answer as if a person had just spoken, and (for a late
63
+ * run) that it is a catch-up, naming the time the run was originally due.
64
+ */
65
+ export declare function buildJobTurnPrompt(job: JobRunFields, opts: {
66
+ now: Date;
67
+ timeZone?: string;
68
+ }): string;
69
+ /**
70
+ * The line the bot's DM report starts with, so the owner sees at a glance that
71
+ * this is a scheduled run and not a reply to something they said.
72
+ */
73
+ export declare function jobReportHeader(job: JobRunFields, catchUp: boolean, timeZone?: string): string;
74
+ /**
75
+ * Sort key for "which pending run of this job is the newest".
76
+ *
77
+ * A run with NO timestamp must sort LOWEST, never highest. Falling back to the
78
+ * messageId was a real bug: an id like `jobrun-…` sorts above any ISO timestamp
79
+ * (letters beat digits), so a run missing `firedAt` would win as "newest" and
80
+ * supersede a genuinely newer run — the newer work would be skipped and the
81
+ * older one would run.
82
+ */
83
+ export declare function jobRunOrderKey(item: InboxItem): string;
84
+ /** The disposition reported for an older pending run of the same job. */
85
+ export declare const SUPERSEDED_DISPOSITION = "a newer pending run of this job ran instead (the computer was asleep through several fires)";
86
+ /**
87
+ * Decide which pending job runs to actually run. `handled` maps jobId to the
88
+ * newest run key this process has already picked up; it is UPDATED in place so
89
+ * a later poll cannot resurrect an older run of a job whose newer run already
90
+ * finished and was acked.
91
+ *
92
+ * Returns the message ids that must NOT be run — they are reported to the
93
+ * cloud as `skipped-precondition` instead.
94
+ */
95
+ export declare function planJobRuns(items: InboxItem[], handled: Map<string, string>): {
96
+ superseded: Set<string>;
97
+ };
98
+ /**
99
+ * The most run output the cloud keeps (hq-pro-agents jobs/results.ts
100
+ * `MAX_JOB_RUN_OUTPUT_BYTES`). The server bounds it itself; the client sends up
101
+ * to the same bound rather than an arbitrary shorter cut, so a long report is
102
+ * recorded in full wherever the cloud can hold it.
103
+ */
104
+ export declare const MAX_JOB_RUN_OUTPUT_BYTES: number;
105
+ /** Cut `text` to at most `maxBytes` of UTF-8 without splitting a character. */
106
+ export declare function jobRunOutput(text: string, maxBytes?: number): string;
107
+ /** A saved report body with the run's header taken back off, for the cloud's record. */
108
+ export declare function stripJobHeader(body: string, header: string | undefined): string;
109
+ //# sourceMappingURL=job-run.d.ts.map