@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.
@@ -0,0 +1,246 @@
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
+ export function isJobItem(item) {
23
+ return item.channel === "job";
24
+ }
25
+ /**
26
+ * The job fields of an inbox item, or null when it is not a usable job run.
27
+ * A job item with no jobId or no prompt is nothing the bot can run; the caller
28
+ * acks it rather than looping on it forever.
29
+ */
30
+ export function readJobRun(item) {
31
+ if (!isJobItem(item))
32
+ return null;
33
+ const jobId = typeof item.jobId === "string" ? item.jobId.trim() : "";
34
+ const prompt = typeof item.prompt === "string" ? item.prompt.trim() : "";
35
+ if (!jobId || !prompt)
36
+ return null;
37
+ return {
38
+ jobId,
39
+ prompt,
40
+ ...(item.context !== undefined && item.context !== null ? { context: item.context } : {}),
41
+ ...(typeof item.script === "string" && item.script.trim() ? { script: item.script } : {}),
42
+ ...(typeof item.firedAt === "string" && item.firedAt.trim() ? { firedAt: item.firedAt.trim() } : {}),
43
+ ...(typeof item.staleAfter === "string" && item.staleAfter.trim() ? { staleAfter: item.staleAfter.trim() } : {}),
44
+ ...(typeof item.requestId === "string" && item.requestId.trim() ? { requestId: item.requestId.trim() } : {}),
45
+ };
46
+ }
47
+ /** The run's extra context as text for the turn, or null when there is none. */
48
+ export function jobContextText(context) {
49
+ if (context === undefined || context === null)
50
+ return null;
51
+ if (typeof context === "string")
52
+ return context.trim() || null;
53
+ try {
54
+ const keys = Object.keys(context);
55
+ if (keys.length === 0)
56
+ return null;
57
+ return JSON.stringify(context, null, 2);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /** The zone this computer is in — where the owner scheduled the job. */
64
+ export function machineTimeZone() {
65
+ try {
66
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
67
+ }
68
+ catch {
69
+ return "UTC";
70
+ }
71
+ }
72
+ /**
73
+ * A readable instant for a person: "2026-09-15 09:00 EDT".
74
+ *
75
+ * Rendered in the OWNER's zone, not UTC. Schedules are created in the machine's
76
+ * zone ("every weekday at 9am" means 9am where the owner lives), so reporting
77
+ * the same run as "due 13:00 UTC" reads as the wrong time to the only person
78
+ * who sees it. The format is fixed (en-CA + explicit parts) rather than
79
+ * locale-default, so the bot reads the same on every machine and the output is
80
+ * testable.
81
+ */
82
+ export function formatJobTime(iso, timeZone = machineTimeZone()) {
83
+ if (!iso)
84
+ return "an unknown time";
85
+ const d = new Date(iso);
86
+ if (Number.isNaN(d.getTime()))
87
+ return iso;
88
+ try {
89
+ const parts = new Intl.DateTimeFormat("en-CA", {
90
+ timeZone,
91
+ year: "numeric",
92
+ month: "2-digit",
93
+ day: "2-digit",
94
+ hour: "2-digit",
95
+ minute: "2-digit",
96
+ hour12: false,
97
+ timeZoneName: "short",
98
+ }).formatToParts(d);
99
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
100
+ const [year, month, day, hour, minute, zone] = [
101
+ get("year"),
102
+ get("month"),
103
+ get("day"),
104
+ get("hour"),
105
+ get("minute"),
106
+ get("timeZoneName"),
107
+ ];
108
+ if (!year || !month || !day || !hour || !minute)
109
+ throw new Error("incomplete");
110
+ // Intl renders midnight as "24" in some hourCycles; normalise to 00.
111
+ const h = hour === "24" ? "00" : hour;
112
+ return `${year}-${month}-${day} ${h}:${minute}${zone ? ` ${zone}` : ""}`;
113
+ }
114
+ catch {
115
+ return `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`;
116
+ }
117
+ }
118
+ /** True when the delivery deadline has passed: this run is late, not skipped. */
119
+ export function isCatchUpRun(job, now) {
120
+ if (!job.staleAfter)
121
+ return false;
122
+ const deadline = new Date(job.staleAfter);
123
+ if (Number.isNaN(deadline.getTime()))
124
+ return false;
125
+ return now.getTime() > deadline.getTime();
126
+ }
127
+ /**
128
+ * The user turn for a fired job. It says plainly that this is a scheduled run
129
+ * so the model does not answer as if a person had just spoken, and (for a late
130
+ * run) that it is a catch-up, naming the time the run was originally due.
131
+ */
132
+ export function buildJobTurnPrompt(job, opts) {
133
+ const catchUp = isCatchUpRun(job, opts.now);
134
+ const due = formatJobTime(job.firedAt, opts.timeZone);
135
+ const lines = [];
136
+ // The due time is stated EXACTLY once: in the late sentence for a catch-up,
137
+ // in the opening sentence otherwise. Saying it twice reads as two times.
138
+ lines.push(`[Scheduled job · ${job.jobId}]`);
139
+ lines.push(`This is a scheduled run that fired on its own${catchUp ? "" : `, due at ${due}`}. Nobody just sent you a ` +
140
+ "message and nobody is waiting in a chat. Do the work described below, then finish with a short " +
141
+ "plain-language report of what you did and what you found — that report is what your owner reads.");
142
+ if (catchUp) {
143
+ lines.push(`This run is late: it was due at ${due} and is only running now because this computer was asleep ` +
144
+ "or switched off. Do it as a catch-up, skip anything that no longer makes sense at this hour, " +
145
+ "and say in your report that it ran late.");
146
+ }
147
+ const body = [lines.join("\n"), job.prompt];
148
+ const context = jobContextText(job.context);
149
+ if (context)
150
+ body.push(`Extra context for this run:\n${context}`);
151
+ return body.join("\n\n");
152
+ }
153
+ /**
154
+ * The line the bot's DM report starts with, so the owner sees at a glance that
155
+ * this is a scheduled run and not a reply to something they said.
156
+ */
157
+ export function jobReportHeader(job, catchUp, timeZone) {
158
+ const base = `⏰ Scheduled job \`${job.jobId}\``;
159
+ return catchUp
160
+ ? `${base} — catch-up for the run due ${formatJobTime(job.firedAt, timeZone)}\n\n`
161
+ : `${base}\n\n`;
162
+ }
163
+ /**
164
+ * Sort key for "which pending run of this job is the newest".
165
+ *
166
+ * A run with NO timestamp must sort LOWEST, never highest. Falling back to the
167
+ * messageId was a real bug: an id like `jobrun-…` sorts above any ISO timestamp
168
+ * (letters beat digits), so a run missing `firedAt` would win as "newest" and
169
+ * supersede a genuinely newer run — the newer work would be skipped and the
170
+ * older one would run.
171
+ */
172
+ export function jobRunOrderKey(item) {
173
+ const fired = typeof item.firedAt === "string" ? item.firedAt.trim() : "";
174
+ if (fired)
175
+ return fired;
176
+ const received = typeof item.receivedAt === "string" ? item.receivedAt.trim() : "";
177
+ if (received)
178
+ return received;
179
+ return "";
180
+ }
181
+ /** The disposition reported for an older pending run of the same job. */
182
+ export const SUPERSEDED_DISPOSITION = "a newer pending run of this job ran instead (the computer was asleep through several fires)";
183
+ /**
184
+ * Decide which pending job runs to actually run. `handled` maps jobId to the
185
+ * newest run key this process has already picked up; it is UPDATED in place so
186
+ * a later poll cannot resurrect an older run of a job whose newer run already
187
+ * finished and was acked.
188
+ *
189
+ * Returns the message ids that must NOT be run — they are reported to the
190
+ * cloud as `skipped-precondition` instead.
191
+ */
192
+ export function planJobRuns(items, handled) {
193
+ const superseded = new Set();
194
+ const byJob = new Map();
195
+ for (const item of items) {
196
+ const job = readJobRun(item);
197
+ if (!job)
198
+ continue;
199
+ const list = byJob.get(job.jobId);
200
+ if (list)
201
+ list.push(item);
202
+ else
203
+ byJob.set(job.jobId, [item]);
204
+ }
205
+ for (const [jobId, runs] of byJob) {
206
+ let newest = handled.get(jobId) ?? "";
207
+ for (const run of runs) {
208
+ const key = jobRunOrderKey(run);
209
+ if (key > newest)
210
+ newest = key;
211
+ }
212
+ handled.set(jobId, newest);
213
+ let keptNewest = false;
214
+ for (const run of runs) {
215
+ if (jobRunOrderKey(run) === newest && !keptNewest) {
216
+ keptNewest = true;
217
+ continue;
218
+ }
219
+ superseded.add(run.messageId);
220
+ }
221
+ }
222
+ return { superseded };
223
+ }
224
+ /**
225
+ * The most run output the cloud keeps (hq-pro-agents jobs/results.ts
226
+ * `MAX_JOB_RUN_OUTPUT_BYTES`). The server bounds it itself; the client sends up
227
+ * to the same bound rather than an arbitrary shorter cut, so a long report is
228
+ * recorded in full wherever the cloud can hold it.
229
+ */
230
+ export const MAX_JOB_RUN_OUTPUT_BYTES = 64 * 1024;
231
+ /** Cut `text` to at most `maxBytes` of UTF-8 without splitting a character. */
232
+ export function jobRunOutput(text, maxBytes = MAX_JOB_RUN_OUTPUT_BYTES) {
233
+ const bytes = Buffer.from(text, "utf8");
234
+ if (bytes.byteLength <= maxBytes)
235
+ return text;
236
+ let end = maxBytes;
237
+ // Step back over UTF-8 continuation bytes (10xxxxxx) to a character boundary.
238
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80)
239
+ end -= 1;
240
+ return bytes.subarray(0, end).toString("utf8");
241
+ }
242
+ /** A saved report body with the run's header taken back off, for the cloud's record. */
243
+ export function stripJobHeader(body, header) {
244
+ return header && body.startsWith(header) ? body.slice(header.length) : body;
245
+ }
246
+ //# sourceMappingURL=job-run.js.map
@@ -11,6 +11,8 @@
11
11
  * session.json model-CLI session id for the owner DM
12
12
  * session-dm-<hexPeerUid>.json one session per other DM correspondent
13
13
  * session-room-<channelId>.json one session per room the bot answers in
14
+ * session-job-<jobId>.json one session per scheduled job the bot runs
15
+ * job-completions.json run completions the cloud has not accepted yet
14
16
  * pid single-file pid lock
15
17
  * logs/bot.log rotated at 5MB
16
18
  *
@@ -39,10 +41,16 @@ export declare function botInboxStatePath(dir: string): string;
39
41
  * Model conversations are scoped to a DM correspondent or room. The owner's
40
42
  * legacy "dm" scope remains stable; other senders use "dm:<peerUid>".
41
43
  */
42
- export type BotSessionScope = "dm" | `dm:${string}` | `room:${string}`;
44
+ export type BotSessionScope = "dm" | `dm:${string}` | `room:${string}` | `job:${string}`;
43
45
  /** Preserve the owner session; keep every other correspondent separate. */
44
46
  export declare function dmSessionScope(peerUid: string, ownerUid: string): BotSessionScope;
45
47
  export declare function roomSessionScope(channelId: string): BotSessionScope;
48
+ /**
49
+ * One session per scheduled job. A job turn must never resume the owner's DM
50
+ * conversation (a scheduled run is not a reply), and two different jobs must
51
+ * not share a session either — each recurring routine keeps its own history.
52
+ */
53
+ export declare function jobSessionScope(jobId: string): BotSessionScope;
46
54
  export declare function botSessionPath(dir: string, scope?: BotSessionScope): string;
47
55
  export declare function botTokenStateDir(dir: string): string;
48
56
  /**
@@ -11,6 +11,8 @@
11
11
  * session.json model-CLI session id for the owner DM
12
12
  * session-dm-<hexPeerUid>.json one session per other DM correspondent
13
13
  * session-room-<channelId>.json one session per room the bot answers in
14
+ * session-job-<jobId>.json one session per scheduled job the bot runs
15
+ * job-completions.json run completions the cloud has not accepted yet
14
16
  * pid single-file pid lock
15
17
  * logs/bot.log rotated at 5MB
16
18
  *
@@ -69,11 +71,23 @@ export function dmSessionScope(peerUid, ownerUid) {
69
71
  export function roomSessionScope(channelId) {
70
72
  return `room:${channelId}`;
71
73
  }
74
+ /**
75
+ * One session per scheduled job. A job turn must never resume the owner's DM
76
+ * conversation (a scheduled run is not a reply), and two different jobs must
77
+ * not share a session either — each recurring routine keeps its own history.
78
+ */
79
+ export function jobSessionScope(jobId) {
80
+ return `job:${jobId}`;
81
+ }
72
82
  export function botSessionPath(dir, scope = "dm") {
73
83
  if (scope === "dm")
74
84
  return path.join(dir, BOT_SESSION_NAME);
75
85
  if (scope.startsWith("dm:"))
76
86
  return path.join(dir, `session-dm-${Buffer.from(scope.slice(3)).toString("hex")}.json`);
87
+ if (scope.startsWith("job:")) {
88
+ const jobId = scope.slice("job:".length).replace(/[^A-Za-z0-9_-]/g, "_");
89
+ return path.join(dir, `session-job-${jobId}.json`);
90
+ }
77
91
  const channelId = scope.slice("room:".length).replace(/[^A-Za-z0-9_-]/g, "_");
78
92
  return path.join(dir, `session-room-${channelId}.json`);
79
93
  }
@@ -105,6 +105,18 @@ export declare function botKindParagraph(input: Pick<BuildSystemPromptInput, "ag
105
105
  * preamble (grok).
106
106
  */
107
107
  export declare function buildSystemPrompt(input: BuildSystemPromptInput): string;
108
+ /**
109
+ * What the bot knows about putting its own work on a schedule. The schedule
110
+ * lives in the cloud; the bot never writes a crontab or a launchd timer.
111
+ *
112
+ * The catch-up wording is deliberately precise. The cloud only dead-letters a
113
+ * stale run on the job's NEXT fire, and the staleness window is an hour, so a
114
+ * missed run is picked up on wake only if the wake happens within about an
115
+ * hour; a longer outage means that run is gone and the job simply resumes at
116
+ * its next scheduled time. Promising an unconditional catch-up would be the bot
117
+ * telling its owner something untrue about work they are relying on.
118
+ */
119
+ export declare function scheduledJobsParagraph(botName: string): string;
108
120
  /**
109
121
  * The one-time introduction the bot DMs its owner when it first comes online.
110
122
  * A bot created with `--intro` sends that text verbatim instead.
@@ -289,6 +289,12 @@ export function buildSystemPrompt(input) {
289
289
  `comes from a room with other people in it (a "Recent messages:" block may follow for context; the last part is the message you are answering). ` +
290
290
  `Answer for the room: reply to the person who asked, keep it short, and never repeat your private memory, your owner's files, ` +
291
291
  `or anything from your DMs to other people.`);
292
+ // Only company bots: the cloud's jobs routes refuse a personal bot's per_
293
+ // company claim today, so telling a personal bot it can schedule work makes
294
+ // it promise its owner something that then fails. Drop this condition when
295
+ // the server accepts personal bots — the paragraph itself needs no change.
296
+ if (kind === "company")
297
+ parts.push(scheduledJobsParagraph(input.botName));
292
298
  if (workerMode && worker) {
293
299
  const yamlPath = path.join(input.workerDir, "worker.yaml");
294
300
  if (worker.instructions) {
@@ -333,6 +339,31 @@ export function buildSystemPrompt(input) {
333
339
  }
334
340
  return `${parts.join("\n\n")}\n`;
335
341
  }
342
+ /**
343
+ * What the bot knows about putting its own work on a schedule. The schedule
344
+ * lives in the cloud; the bot never writes a crontab or a launchd timer.
345
+ *
346
+ * The catch-up wording is deliberately precise. The cloud only dead-letters a
347
+ * stale run on the job's NEXT fire, and the staleness window is an hour, so a
348
+ * missed run is picked up on wake only if the wake happens within about an
349
+ * hour; a longer outage means that run is gone and the job simply resumes at
350
+ * its next scheduled time. Promising an unconditional catch-up would be the bot
351
+ * telling its owner something untrue about work they are relying on.
352
+ */
353
+ export function scheduledJobsParagraph(botName) {
354
+ return (`## Scheduled jobs\n\n` +
355
+ `You can put your own work on a recurring schedule. When your owner asks for something regular ` +
356
+ `("check X every weekday morning", "send me a summary every Friday"), run ` +
357
+ `\`hq bot jobs ${botName} --add "<what to do>" --every "<plain English, e.g. every weekday at 9am>"\`. ` +
358
+ `See what you have scheduled with \`hq bot jobs ${botName}\`, and remove one with ` +
359
+ `\`hq bot jobs ${botName} --rm <jobId>\`.\n` +
360
+ `After you schedule something, say back in plain words what you set up and when it will run. Also say what ` +
361
+ `happens if this computer is asleep or switched off when a run is due: it is picked up as a catch-up when the ` +
362
+ `computer wakes, as long as it wakes within about an hour; after a longer outage that run is skipped and the ` +
363
+ `job carries on at its next scheduled time. A schedule cannot fire more often than every 5 minutes.\n` +
364
+ `When one of these fires you get a turn that starts with "[Scheduled job · …]". Nobody is waiting in a chat: ` +
365
+ `do the work and finish with a short report — that report is what your owner reads.`);
366
+ }
336
367
  /**
337
368
  * The one-time introduction the bot DMs its owner when it first comes online.
338
369
  * A bot created with `--intro` sends that text verbatim instead.
@@ -1,5 +1,10 @@
1
- /** Reply to any correspondent or delivered room message, except our own posts. */
1
+ /** Reply to any correspondent or delivered room message, except our own posts.
2
+ * Jobs (channel "job") are scheduled runs the cloud fired: not addressed to
3
+ * anyone, they resolve to their own lane and model session, and report in the
4
+ * owner's DM.
5
+ */
2
6
  import type { InboxItem } from "./api.js";
7
+ import { type JobRunFields } from "./job-run.js";
3
8
  import { type BotSessionScope } from "./paths.js";
4
9
  export type ReplyTarget = {
5
10
  kind: "dm";
@@ -12,6 +17,18 @@ export type ReplyTarget = {
12
17
  sessionScope: BotSessionScope;
13
18
  /** True when this bot was @mentioned: answer without claiming. */
14
19
  mentioned: boolean;
20
+ } | {
21
+ /**
22
+ * A scheduled job the cloud fired for this bot. Nobody sent it: the work
23
+ * is the job's own prompt, and the result is reported in the owner's DM,
24
+ * clearly marked as a scheduled run rather than a reply.
25
+ */
26
+ kind: "job";
27
+ jobId: string;
28
+ /** Where the report goes: the bot's DM with its owner. */
29
+ peerUid: string;
30
+ sessionScope: BotSessionScope;
31
+ job: JobRunFields;
15
32
  };
16
33
  export interface ReplyPolicyConfig {
17
34
  ownerUid: string;
@@ -1,5 +1,10 @@
1
- /** Reply to any correspondent or delivered room message, except our own posts. */
2
- import { roomSessionScope } from "./paths.js";
1
+ /** Reply to any correspondent or delivered room message, except our own posts.
2
+ * Jobs (channel "job") are scheduled runs the cloud fired: not addressed to
3
+ * anyone, they resolve to their own lane and model session, and report in the
4
+ * owner's DM.
5
+ */
6
+ import { readJobRun } from "./job-run.js";
7
+ import { jobSessionScope, roomSessionScope } from "./paths.js";
3
8
  function escapeRegExp(s) {
4
9
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
10
  }
@@ -13,6 +18,19 @@ export function isDmItem(item) {
13
18
  }
14
19
  export function resolveReplyTarget(item, config) {
15
20
  const sender = item.fromPersonUid ?? "";
21
+ if (item.channel === "job") {
22
+ const job = readJobRun(item);
23
+ // A job item with no jobId or no prompt is nothing the bot can run.
24
+ if (!job)
25
+ return null;
26
+ return {
27
+ kind: "job",
28
+ jobId: job.jobId,
29
+ peerUid: config.ownerUid,
30
+ sessionScope: jobSessionScope(job.jobId),
31
+ job,
32
+ };
33
+ }
16
34
  if (isDmItem(item)) {
17
35
  return sender && sender !== config.agentUid ? { kind: "dm", peerUid: sender } : null;
18
36
  }
@@ -13,6 +13,14 @@
13
13
  * - first start only: the intro DM, then (when bot.json has `kickoff`) ONE
14
14
  * model turn on that prompt as if the owner sent it, answered in the DM, so
15
15
  * a guided bot starts working without waiting for the owner to type
16
+ * - job items (channel "job"): a run the cloud fired on an EventBridge
17
+ * schedule. Nothing is scheduled on this computer. The run is one model turn
18
+ * in its own session (one per jobId), reported in the owner's DM with a
19
+ * "scheduled job" header through the same split/recovery path as every other
20
+ * reply, and reported back to the cloud EXACTLY ONCE — nothing else clears
21
+ * the job's in-progress flag. A run whose staleAfter has passed still runs,
22
+ * once, labelled a catch-up; older pending runs of the same job are reported
23
+ * skipped-precondition instead of piling up.
16
24
  * - delivered room items (channels / group chats) are eligible for replies; claimed
17
25
  * first (first-claim-wins), one session per room, reply posted in the room
18
26
  * - conversations run side by side: up to MAX_PARALLEL_TURNS turns at once,
@@ -82,6 +90,18 @@ export declare const MAX_PARALLEL_TURNS = 2;
82
90
  export declare const RECOVERY_ATTEMPT_LIMIT = 5;
83
91
  /** What the person sees when a reply could not be delivered at all. */
84
92
  export declare const UNDELIVERABLE_REPLY_TEXT = "I wrote a reply but couldn't deliver it. Ask me again and I'll keep it shorter.";
93
+ /**
94
+ * The same, for a scheduled run. Nobody asked, so "ask me again" is wrong: the
95
+ * run DID finish, and its outcome is still recorded with the job in HQ.
96
+ */
97
+ export declare const UNDELIVERABLE_JOB_REPORT_TEXT = "This scheduled run finished, but I couldn't deliver its report here. Its result is saved with the job's run history.";
98
+ /**
99
+ * Scheduled work must never crowd out the people the bot talks to. A job turn
100
+ * can run for a long time, so at most ONE runs at a time, and a job never takes
101
+ * the last free slot: a DM or a room message can always start immediately
102
+ * instead of waiting, with no explanation, behind two long jobs.
103
+ */
104
+ export declare const MAX_PARALLEL_JOB_TURNS = 1;
85
105
  export type SleepFn = (ms: number) => Promise<void>;
86
106
  export interface BotRunDeps {
87
107
  dir: string;
@@ -163,7 +183,9 @@ export interface BotHandle {
163
183
  /**
164
184
  * The lane an inbox item runs in: the model session its turn resumes. Owner
165
185
  * DMs retain the legacy DM session; other senders and rooms each have their
166
- * own. An unsupported item gets a lane of its own.
186
+ * own, and so does each scheduled job (two different jobs must never share a
187
+ * model session, and a job must never pollute the owner's DM conversation).
188
+ * An item that never reaches a model turn gets a lane of its own.
167
189
  */
168
190
  export declare function inboxLaneKey(item: InboxItem, config: Pick<BotConfig, "ownerUid" | "agentUid" | "name">): string;
169
191
  export declare function preflightCreds(dir: string, config: BotConfig): string | null;