@indigoai-us/hq-cli 5.345.198 → 5.345.200

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.
Files changed (129) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +6 -4
  3. package/dist/command-catalog.generated.d.ts +150 -6
  4. package/dist/command-catalog.generated.js +182 -6
  5. package/dist/commands/bot-events.d.ts +19 -0
  6. package/dist/commands/bot-events.js +46 -0
  7. package/dist/commands/bot-jobs.d.ts +2 -0
  8. package/dist/commands/bot-jobs.js +43 -2
  9. package/dist/commands/bot-runtime.d.ts +35 -0
  10. package/dist/commands/bot-runtime.js +115 -0
  11. package/dist/commands/bot.d.ts +11 -0
  12. package/dist/commands/bot.js +72 -11
  13. package/dist/commands/creators.js +2 -1
  14. package/dist/commands/lanes-report.d.ts +2 -1
  15. package/dist/commands/lanes-report.js +44 -1
  16. package/dist/commands/lanes.d.ts +9 -0
  17. package/dist/commands/lanes.js +95 -9
  18. package/dist/commands/library-remove.js +2 -1
  19. package/dist/commands/library.js +5 -4
  20. package/dist/commands/meetings.js +1 -21
  21. package/dist/commands/mesh.d.ts +0 -7
  22. package/dist/commands/mesh.js +5 -1
  23. package/dist/commands/signals-run.d.ts +2 -0
  24. package/dist/commands/signals-run.js +6 -1
  25. package/dist/commands/sync-held.js +2 -2
  26. package/dist/lanes-drain-hook.js +9 -0
  27. package/dist/lib/bot/api.d.ts +5 -0
  28. package/dist/lib/bot/claude-login.d.ts +31 -0
  29. package/dist/lib/bot/claude-login.js +160 -0
  30. package/dist/lib/bot/events.d.ts +18 -0
  31. package/dist/lib/bot/events.js +160 -0
  32. package/dist/lib/bot/executive-settings.d.ts +10 -0
  33. package/dist/lib/bot/executive-settings.js +43 -0
  34. package/dist/lib/bot/fleet-cron.js +4 -0
  35. package/dist/lib/bot/fleet-download.d.ts +27 -0
  36. package/dist/lib/bot/fleet-download.js +162 -0
  37. package/dist/lib/bot/fleet-local.d.ts +51 -2
  38. package/dist/lib/bot/fleet-local.js +179 -76
  39. package/dist/lib/bot/fleet-python.d.ts +2 -0
  40. package/dist/lib/bot/fleet-python.js +29 -0
  41. package/dist/lib/bot/job-run.js +1 -1
  42. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +8 -0
  43. package/dist/lib/bot/jobs/hot-signal-sync.js +2 -0
  44. package/dist/lib/bot/jobs/meeting-detect.d.ts +28 -0
  45. package/dist/lib/bot/jobs/meeting-detect.js +176 -0
  46. package/dist/lib/bot/jobs/meeting-rider.d.ts +53 -0
  47. package/dist/lib/bot/jobs/meeting-rider.js +284 -0
  48. package/dist/lib/bot/jobs/signal-to-lane.d.ts +32 -0
  49. package/dist/lib/bot/jobs/signal-to-lane.js +169 -0
  50. package/dist/lib/bot/local-jobs.d.ts +15 -2
  51. package/dist/lib/bot/local-jobs.js +24 -7
  52. package/dist/lib/bot/run.d.ts +7 -49
  53. package/dist/lib/bot/run.js +100 -4
  54. package/dist/lib/bot/status.d.ts +21 -0
  55. package/dist/lib/doctor/checks/bots.d.ts +6 -0
  56. package/dist/lib/doctor/checks/bots.js +47 -1
  57. package/dist/lib/hot-channels/adapters/meetings.d.ts +1 -0
  58. package/dist/lib/hot-channels/adapters/meetings.js +3 -1
  59. package/dist/lib/hot-channels/meeting-source-ref.d.ts +3 -0
  60. package/dist/lib/hot-channels/meeting-source-ref.js +7 -0
  61. package/dist/lib/hot-channels/schedule.js +2 -2
  62. package/dist/lib/hot-channels/signals-pass.d.ts +9 -32
  63. package/dist/lib/hot-channels/signals-pass.js +51 -17
  64. package/dist/lib/lanes/admission.d.ts +2 -0
  65. package/dist/lib/lanes/admission.js +4 -0
  66. package/dist/lib/lanes/approval-gate.js +4 -1
  67. package/dist/lib/lanes/links.d.ts +8 -1
  68. package/dist/lib/lanes/links.js +84 -3
  69. package/dist/lib/lanes/mesh-events.js +3 -1
  70. package/dist/lib/lanes/outcome.d.ts +154 -0
  71. package/dist/lib/lanes/outcome.js +151 -0
  72. package/dist/lib/lanes/reconcile.js +1 -0
  73. package/dist/lib/lanes/signal-brief-template.d.ts +7 -0
  74. package/dist/lib/lanes/signal-brief-template.js +40 -0
  75. package/dist/lib/lanes/signal-lane.d.ts +24 -0
  76. package/dist/lib/lanes/signal-lane.js +99 -0
  77. package/dist/lib/lanes/source-report.d.ts +37 -0
  78. package/dist/lib/lanes/source-report.js +66 -0
  79. package/dist/lib/lanes/spawn.d.ts +6 -0
  80. package/dist/lib/lanes/spawn.js +21 -0
  81. package/dist/lib/lanes/store.d.ts +3 -0
  82. package/dist/lib/lanes/store.js +64 -0
  83. package/dist/lib/lanes/story-ledger.d.ts +3 -0
  84. package/dist/lib/lanes/story-ledger.js +1 -0
  85. package/dist/lib/lanes/types.d.ts +137 -0
  86. package/dist/lib/lanes/types.js +17 -2
  87. package/dist/lib/lanes/watcher.js +4 -0
  88. package/dist/lib/live-meetings.d.ts +19 -0
  89. package/dist/lib/live-meetings.js +23 -0
  90. package/dist/lib/mesh/api.js +2 -1
  91. package/dist/lib/mesh/live/daemon/doctor.js +6 -7
  92. package/dist/lib/mesh/live/daemon/run.d.ts +0 -10
  93. package/dist/lib/mesh/live/daemon/run.js +3 -0
  94. package/dist/lib/mesh/live/emit.d.ts +2 -11
  95. package/dist/lib/mesh/live/emit.js +20 -2
  96. package/dist/lib/mesh/live/flush.d.ts +2 -0
  97. package/dist/lib/mesh/live/flush.js +34 -3
  98. package/dist/lib/mesh/live/reporting-privacy.d.ts +7 -0
  99. package/dist/lib/mesh/live/reporting-privacy.js +47 -0
  100. package/dist/lib/mesh/live/spool.d.ts +8 -0
  101. package/dist/lib/mesh/live/spool.js +59 -40
  102. package/dist/lib/plan-limit-nag.d.ts +30 -4
  103. package/dist/lib/plan-limit-nag.js +68 -17
  104. package/dist/lib/signals/engine.d.ts +4 -0
  105. package/dist/lib/signals/engine.js +25 -5
  106. package/dist/lib/signals/incremental.d.ts +42 -0
  107. package/dist/lib/signals/incremental.js +191 -0
  108. package/dist/lib/signals/prior-signals.d.ts +11 -0
  109. package/dist/lib/signals/prior-signals.js +44 -0
  110. package/dist/lib/signals/reply.js +6 -1
  111. package/dist/lib/signals/settings.d.ts +5 -0
  112. package/dist/lib/signals/settings.js +17 -0
  113. package/dist/lib/signals/steps/extract.d.ts +23 -2
  114. package/dist/lib/signals/steps/extract.js +142 -9
  115. package/dist/lib/signals/steps/types.d.ts +1 -0
  116. package/dist/lib/signals/write-guard.js +7 -0
  117. package/dist/lib/work-context/company.d.ts +2 -3
  118. package/dist/lib/work-context/company.js +4 -22
  119. package/dist/secrets-env-action.js +5 -2
  120. package/dist/utils/cognito-callback-port.d.ts +17 -0
  121. package/dist/utils/cognito-callback-port.js +56 -0
  122. package/dist/utils/cognito-config.js +7 -3
  123. package/dist/utils/cognito-session.d.ts +7 -6
  124. package/dist/utils/cognito-session.js +19 -14
  125. package/dist/utils/interactive-caller.d.ts +23 -0
  126. package/dist/utils/interactive-caller.js +80 -0
  127. package/dist/utils/login-browser.js +5 -2
  128. package/dist/utils/resident-package-preload.js +4 -0
  129. package/package.json +7 -7
@@ -0,0 +1,169 @@
1
+ /** Source-independent action-item consumer for the personal Executive. */
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { readBotConfig } from "../config.js";
6
+ import { botsRoot } from "../paths.js";
7
+ import { fleetProfileOwnerCompanies } from "../fleet-local.js";
8
+ import { fleetHotSignalSyncCompanies } from "../fleet-cron.js";
9
+ import { readActiveCompanySlug, readBuiltinJobsMarker, writeBuiltinJobsMarker } from "./hot-signal-sync.js";
10
+ import { isExecutiveBot } from "./meeting-rider.js";
11
+ import { readLocalJobsStrict, writeLocalJobs, withJobsLock } from "../local-jobs.js";
12
+ import { emitBotEvent } from "../events.js";
13
+ import { isLanesError } from "../../lanes/errors.js";
14
+ import { lookupSignal } from "../../lanes/signal-lane.js";
15
+ export const SIGNAL_TO_LANE_JOB_KIND = "signal-to-lane";
16
+ export function signalLaneCompanies(config) {
17
+ return fleetHotSignalSyncCompanies(config, readActiveCompanySlug(config.hqRoot) ?? null, fleetProfileOwnerCompanies(config.agentUid));
18
+ }
19
+ export function ensureSignalToLaneJobs(dir, config, companies = signalLaneCompanies(config), home) {
20
+ if (!isExecutiveBot(config, home))
21
+ return [];
22
+ return withJobsLock(dir, () => {
23
+ const jobs = readLocalJobsStrict(dir);
24
+ const marker = readBuiltinJobsMarker(dir);
25
+ for (const company of new Set(companies)) {
26
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(company) || company === "personal")
27
+ continue;
28
+ const key = `${SIGNAL_TO_LANE_JOB_KIND}:${company}`;
29
+ if (marker.registered[key])
30
+ continue;
31
+ if (!jobs.some(job => job.context?.jobKind === SIGNAL_TO_LANE_JOB_KIND && job.context.company === company)) {
32
+ jobs.push({ jobId: `job_${randomUUID().replaceAll("-", "").slice(0, 20)}`,
33
+ prompt: "Open a conductor lane from an action item in-process.", timezone: "UTC",
34
+ trigger: { kind: "event", event: "signal.written", filter: { type: "action_item", company } },
35
+ context: { jobKind: SIGNAL_TO_LANE_JOB_KIND, company }, createdAt: new Date().toISOString() });
36
+ }
37
+ marker.registered[key] = new Date().toISOString();
38
+ }
39
+ writeLocalJobs(dir, jobs);
40
+ writeBuiltinJobsMarker(dir, marker);
41
+ return jobs.filter(job => job.context?.jobKind === SIGNAL_TO_LANE_JOB_KIND);
42
+ });
43
+ }
44
+ /** Only new locally extracted signals reach here; cloud upload/garden is separate. */
45
+ export function emitExecutiveSignalWritten(hqRoot, company, payload, root = botsRoot(), home) {
46
+ if (!fs.existsSync(root))
47
+ return false;
48
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
49
+ if (!entry.isDirectory())
50
+ continue;
51
+ const dir = path.join(root, entry.name);
52
+ const config = readBotConfig(dir);
53
+ if (!config || typeof config.hqRoot !== "string" || path.resolve(config.hqRoot) !== path.resolve(hqRoot) || !isExecutiveBot(config, home))
54
+ continue;
55
+ if (!signalLaneCompanies(config).includes(company))
56
+ continue;
57
+ emitBotEvent(dir, "signal.written", { ...payload, company }, new Date(), prior => prior.event === "signal.written" && prior.payload.company === company && prior.payload.signal_id === payload.signal_id);
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+ function publicationDir(hqRoot, company) {
63
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(company))
64
+ throw new Error("Invalid signal publication company");
65
+ return path.join(hqRoot, "workspace", "signal-publications", company);
66
+ }
67
+ /** Persist intent before publishing a new signal file; ordinary redo creates none. */
68
+ export function prepareSignalPublication(hqRoot, company, payload) {
69
+ const dir = publicationDir(hqRoot, company);
70
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
71
+ const file = path.join(dir, createHash("sha256").update(payload.signal_id).digest("hex") + ".json");
72
+ const tmp = `${file}.${randomUUID()}.tmp`;
73
+ fs.writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
74
+ fs.renameSync(tmp, file);
75
+ }
76
+ /** A pre-append crash retries; a post-append crash uses the event journal's dedupe. */
77
+ export function flushSignalPublications(hqRoot, company, emit = emitExecutiveSignalWritten) {
78
+ const dir = publicationDir(hqRoot, company);
79
+ if (!fs.existsSync(dir))
80
+ return;
81
+ for (const name of fs.readdirSync(dir).filter(name => /^[a-f0-9]{64}\.json$/.test(name))) {
82
+ const file = path.join(dir, name);
83
+ let payload;
84
+ try {
85
+ payload = JSON.parse(fs.readFileSync(file, "utf8"));
86
+ }
87
+ catch (error) {
88
+ if (error.code === "ENOENT")
89
+ continue;
90
+ throw error;
91
+ }
92
+ if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
93
+ ["signal_id", "type", "source_ref", "audience_key", "origin"].some(key => typeof payload[key] !== "string")) {
94
+ throw new Error("Invalid signal publication intent");
95
+ }
96
+ try {
97
+ lookupSignal(hqRoot, company, payload.signal_id);
98
+ }
99
+ catch (error) {
100
+ if (isLanesError(error) && error.error === "signal_not_found")
101
+ continue;
102
+ throw error;
103
+ }
104
+ if (emit(hqRoot, company, payload)) {
105
+ try {
106
+ fs.unlinkSync(file);
107
+ }
108
+ catch (error) {
109
+ if (error.code !== "ENOENT")
110
+ throw error;
111
+ }
112
+ }
113
+ }
114
+ }
115
+ const createDefault = async (input) => {
116
+ // Load the CLI admission graph only when a signal actually needs a lane.
117
+ const { createLaneFromSignal } = await import("../../../commands/lanes.js");
118
+ return createLaneFromSignal(input);
119
+ };
120
+ export async function runSignalToLane(input) {
121
+ if (!isExecutiveBot(input.config, input.home))
122
+ return { status: "skipped-precondition", output: "Not the Executive" };
123
+ const payload = input.payload;
124
+ const company = input.company;
125
+ if (!payload || payload.type !== "action_item" || typeof payload.signal_id !== "string" || !payload.signal_id.trim() ||
126
+ typeof company !== "string" || payload.company !== company || !(input.companies ?? signalLaneCompanies(input.config)).includes(company)) {
127
+ return { status: "skipped-precondition", output: "Signal outside the served company or action-item scope" };
128
+ }
129
+ // Persist deferred IDs before acknowledging; the next matching event retries.
130
+ const file = path.join(input.dir, "signal-to-lane-pending.json");
131
+ const read = () => {
132
+ try {
133
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
134
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
135
+ Object.values(value).some(ids => !Array.isArray(ids) || ids.some(id => typeof id !== "string"))) {
136
+ throw new Error("Invalid pending signal lane state");
137
+ }
138
+ return Object.assign(Object.create(null), value);
139
+ }
140
+ catch (err) {
141
+ if (err.code === "ENOENT")
142
+ return Object.create(null);
143
+ throw err;
144
+ }
145
+ };
146
+ const update = (change) => withJobsLock(input.dir, () => {
147
+ const pending = read();
148
+ change(pending);
149
+ const tmp = `${file}.${process.pid}.tmp`;
150
+ fs.writeFileSync(tmp, JSON.stringify(pending), { mode: 0o600 });
151
+ fs.renameSync(tmp, file);
152
+ return pending;
153
+ });
154
+ const pending = update(p => { p[company] = [...new Set([...(p[company] ?? []), payload.signal_id])]; });
155
+ for (const signal_id of pending[company] ?? []) {
156
+ try {
157
+ const result = await (input.create ?? createDefault)({ hqRoot: input.config.hqRoot, company, signal_id });
158
+ input.log(JSON.stringify(result));
159
+ update(p => { p[company] = (p[company] ?? []).filter(id => id !== signal_id); });
160
+ }
161
+ catch (err) {
162
+ if (!isLanesError(err) || err.error !== "no_conductor_link")
163
+ throw err;
164
+ input.log(JSON.stringify({ error: "no_conductor_link", company, signal_id }));
165
+ }
166
+ }
167
+ return { status: "succeeded", output: "Signal lane processing finished; deferred signals retry on the next matching event" };
168
+ }
169
+ //# sourceMappingURL=signal-to-lane.js.map
@@ -23,7 +23,12 @@ export interface LocalJob {
23
23
  jobId: string;
24
24
  prompt: string;
25
25
  /** Standard 5-field cron, evaluated in `timezone`. */
26
- cron: string;
26
+ cron?: string;
27
+ trigger?: {
28
+ kind: "event";
29
+ event: string;
30
+ filter?: Record<string, string>;
31
+ };
27
32
  timezone: string;
28
33
  context?: Record<string, unknown>;
29
34
  createdAt: string;
@@ -45,6 +50,12 @@ export interface LocalJob {
45
50
  runCount?: number;
46
51
  }
47
52
  export declare function readLocalJobs(dir: string): LocalJob[];
53
+ /**
54
+ * `jobs.json` for a write that adds a job: a missing file is an empty list,
55
+ * but a file that exists and cannot be read or parsed throws, so adding a job
56
+ * never writes back a list that drops every other job.
57
+ */
58
+ export declare function readLocalJobsStrict(dir: string): LocalJob[];
48
59
  export declare function writeLocalJobs(dir: string, jobs: LocalJob[]): void;
49
60
  /**
50
61
  * Run a read-change-write of `jobs.json` under an exclusive lock. The running
@@ -74,9 +85,11 @@ export declare function parseCron(expr: string): CronMatcher | null;
74
85
  export declare function latestDueFire(job: LocalJob, now: Date): Date | null;
75
86
  /** The next time this job is due after `now`, within a week; null otherwise. */
76
87
  export declare function nextDueFire(job: LocalJob, now: Date): Date | null;
88
+ export declare function toJobRecord(job: LocalJob, now?: Date): BotJobRecord;
77
89
  export declare function addLocalJob(dir: string, input: {
78
90
  prompt: string;
79
- cron: string;
91
+ cron?: string;
92
+ trigger?: LocalJob["trigger"];
80
93
  timezone: string;
81
94
  context?: Record<string, unknown>;
82
95
  }, now?: Date): LocalJob;
@@ -22,6 +22,7 @@ import * as fs from "node:fs";
22
22
  import * as path from "node:path";
23
23
  import { randomUUID } from "node:crypto";
24
24
  import { BotApiError } from "./api.js";
25
+ import { ackBotEvent } from "./events.js";
25
26
  const LOCAL_JOBS_FILE = "jobs.json";
26
27
  const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
27
28
  /** How far back a missed fire is still looked for (one week). */
@@ -47,7 +48,7 @@ export function readLocalJobs(dir) {
47
48
  * but a file that exists and cannot be read or parsed throws, so adding a job
48
49
  * never writes back a list that drops every other job.
49
50
  */
50
- function readLocalJobsForAdd(dir) {
51
+ export function readLocalJobsStrict(dir) {
51
52
  let text;
52
53
  try {
53
54
  text = fs.readFileSync(localJobsPath(dir), "utf8");
@@ -270,6 +271,8 @@ function scheduleOrigin(job) {
270
271
  return Date.parse(job.scheduledFrom ?? job.createdAt);
271
272
  }
272
273
  export function latestDueFire(job, now) {
274
+ if (job.trigger || !job.cron)
275
+ return null;
273
276
  const interval = rateIntervalMs(job.cron);
274
277
  if (interval) {
275
278
  const origin = scheduleOrigin(job);
@@ -294,6 +297,8 @@ export function latestDueFire(job, now) {
294
297
  }
295
298
  /** The next time this job is due after `now`, within a week; null otherwise. */
296
299
  export function nextDueFire(job, now) {
300
+ if (job.trigger || !job.cron)
301
+ return null;
297
302
  const interval = rateIntervalMs(job.cron);
298
303
  if (interval) {
299
304
  const origin = scheduleOrigin(job);
@@ -313,13 +318,13 @@ export function nextDueFire(job, now) {
313
318
  return null;
314
319
  }
315
320
  // ─── The job list (what `hq bot jobs` reads and writes) ──────────────────────
316
- function toJobRecord(job, now = new Date()) {
321
+ export function toJobRecord(job, now = new Date()) {
317
322
  const next = nextDueFire(job, now);
318
323
  return {
319
324
  jobId: job.jobId,
320
325
  prompt: job.prompt,
321
326
  status: "active",
322
- schedule: { kind: "recurring", cron: job.cron, timezone: job.timezone },
327
+ ...(job.trigger ? { trigger: job.trigger } : { schedule: { kind: "recurring", cron: job.cron, timezone: job.timezone } }),
323
328
  ...(job.context ? { context: job.context } : {}),
324
329
  createdAt: job.createdAt,
325
330
  ...(next ? { nextRunAt: next.toISOString() } : {}),
@@ -329,17 +334,24 @@ function toJobRecord(job, now = new Date()) {
329
334
  };
330
335
  }
331
336
  export function addLocalJob(dir, input, now = new Date()) {
332
- if (!isRunnableSchedule(input.cron))
337
+ if (input.trigger && (!input.trigger.event.trim() || input.trigger.kind !== "event" || input.cron))
338
+ throw new Error("An event job needs an event and no cron schedule.");
339
+ if (!input.trigger && (!input.cron || !isRunnableSchedule(input.cron)))
333
340
  throw new Error(`"${input.cron}" is not a schedule this bot can run.`);
334
341
  const job = {
335
342
  jobId: `job_${randomUUID().replace(/-/g, "").slice(0, 20)}`,
336
343
  prompt: input.prompt,
337
- cron: input.cron,
344
+ ...(input.trigger ? { trigger: input.trigger } : { cron: input.cron }),
338
345
  timezone: input.timezone,
339
346
  ...(input.context ? { context: input.context } : {}),
340
347
  createdAt: now.toISOString(),
341
348
  };
342
- withJobsLock(dir, () => writeLocalJobs(dir, [...readLocalJobsForAdd(dir), job]));
349
+ withJobsLock(dir, () => {
350
+ const jobs = readLocalJobsStrict(dir);
351
+ if (jobs.length >= MAX_LOCAL_JOBS)
352
+ throw new BotApiError(409, "local jobs", "JOB_QUOTA_EXCEEDED", `at most ${MAX_LOCAL_JOBS} jobs`);
353
+ writeLocalJobs(dir, [...jobs, job]);
354
+ });
343
355
  return job;
344
356
  }
345
357
  /**
@@ -350,7 +362,7 @@ export function ensureLocalJob(dir, matches, input, now = new Date()) {
350
362
  if (!isRunnableSchedule(input.cron))
351
363
  throw new Error(`"${input.cron}" is not a schedule this bot can run.`);
352
364
  return withJobsLock(dir, () => {
353
- const jobs = readLocalJobsForAdd(dir);
365
+ const jobs = readLocalJobsStrict(dir);
354
366
  const existing = jobs.find(matches);
355
367
  if (existing)
356
368
  return { job: existing, created: false };
@@ -386,6 +398,7 @@ schedule, now = new Date()) {
386
398
  timezone: schedule.timezone ?? jobs[index].timezone,
387
399
  scheduledFrom: now.toISOString(),
388
400
  };
401
+ delete updated.trigger;
389
402
  jobs[index] = updated;
390
403
  writeLocalJobs(dir, jobs);
391
404
  return updated;
@@ -517,6 +530,10 @@ export function withLocalJobs(api, dir, clock = () => new Date()) {
517
530
  return [...cloud, ...takeDueLocalJobRuns(dir, clock())];
518
531
  };
519
532
  local.ackInbox = async (agentUid, messageId) => {
533
+ if (messageId.startsWith("localjob-event-")) {
534
+ ackBotEvent(dir, messageId);
535
+ return;
536
+ }
520
537
  if (isLocalJobMessage(messageId))
521
538
  return;
522
539
  await api.ackInbox(agentUid, messageId);
@@ -1,52 +1,3 @@
1
- /**
2
- * The resident bot process: `hq bot run <name>` (local-bots US-003/US-004).
3
- *
4
- * - pid lock in ~/.hq/bots/<name>/pid; status.json is the observable state
5
- * - refuses to start unless the creds file names this bot's agt_ AND the
6
- * server-side record is owned by the configured owner
7
- * - heartbeat every 30s (POST /v1/agents/{uid}/heartbeat, agent JWT)
8
- * - inbox poll every 2s: DMs go to ONE headless model turn (resumed by
9
- * session id); a new session (restart, or a resume id the runtime no longer
10
- * has) is given the recent DM thread as a "Recent messages:" block so a
11
- * one-word reply still has the question it answers; the reply is sent as
12
- * the bot; the message is acked only after the reply is sent; processed ids
13
- * persist so a crash between reply and ack never answers twice
14
- * - any correspondent can message the bot; each sender has a separate session
15
- * - first start only: the intro DM, then (when bot.json has `kickoff`) ONE
16
- * model turn on that prompt as if the owner sent it, answered in the DM, so
17
- * a guided bot starts working without waiting for the owner to type
18
- * - job items (channel "job"): a scheduled run, fired by the bot's own loop
19
- * from its jobs.json (lib/bot/local-jobs.ts) or, for a hosted agent, by
20
- * the cloud. No system scheduler is used on this computer. The run is one model turn
21
- * in its own session (one per jobId), reported in the owner's DM with a
22
- * "scheduled job" header through the same split/recovery path as every other
23
- * reply, and reported back to the cloud EXACTLY ONCE — nothing else clears
24
- * the job's in-progress flag. A run whose staleAfter has passed still runs,
25
- * once, labelled a catch-up; older pending runs of the same job are reported
26
- * skipped-precondition instead of piling up.
27
- * - delivered room items (channels / group chats) are eligible for replies; claimed
28
- * first (first-claim-wins), one session per room, reply posted in the room
29
- * - conversations run side by side: up to MAX_PARALLEL_TURNS turns at once,
30
- * one lane per model session (one DM correspondent, or one room). Messages
31
- * in the same lane run in order, so a session is never resumed by two turns
32
- * at once and a thread's answers never arrive out of sequence.
33
- * - while a turn runs, each finished assistant message is posted as its own
34
- * message (progress.ts); turns have no time limit
35
- * - every received message gets a reply: a turn that fails after the model
36
- * started (error, crash, owner stop, restart) posts one "did not finish"
37
- * message and is NOT run again — it may already have done outward things.
38
- * inflight.json marks the turn in progress so a restart can say so.
39
- * - a turn that fails because the coding tool's sign-in no longer works
40
- * (runtime-sign-in.ts) is not a failure of the message: the bot says once
41
- * per conversation that it needs a sign-in, keeps the message un-acked,
42
- * holds new turns, and tries again every SIGN_IN_RETRY_MS (and at once
43
- * after a restart). A turn that works clears it.
44
- * - failures before the model starts (missing binary) retry with backoff;
45
- * 5 model failures in 10 minutes → state `failed` and a clean exit (launchd
46
- * does not respawn a clean exit). Owner stops never count.
47
- *
48
- * Everything with a side effect is injectable so the loop is unit-testable.
49
- */
50
1
  import { type OwnerContext } from "./owner-context.js";
51
2
  import { type BotApi, type InboxItem } from "./api.js";
52
3
  import type { BotConfig, BotKind } from "./config.js";
@@ -57,6 +8,7 @@ import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "
57
8
  import { type BotSessionScope } from "./paths.js";
58
9
  import { type DecisionDmClient } from "./decisions.js";
59
10
  import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js";
11
+ import type { JobRunCompletion } from "./api.js";
60
12
  import { type RuntimeSignInCheck } from "./runtime-sign-in.js";
61
13
  import { type HeartbeatAgentUidSource, type HeartbeatCredsSource, type HeartbeatIdentityMismatchReporter } from "../heartbeat-identity.js";
62
14
  import { type BotState, type BotStatusFile } from "./status.js";
@@ -325,6 +277,12 @@ export declare function inboxLaneKey(item: InboxItem, config: Pick<BotConfig, "o
325
277
  */
326
278
  export declare function dmTurnSessionScope(item: Pick<InboxItem, "fromPersonUid" | "text">, ownerUid: string): BotSessionScope;
327
279
  export declare function preflightCreds(dir: string, config: BotConfig): string | null;
280
+ /** Register source-independent in-process jobs by their context.jobKind. */
281
+ export declare const builtinEventHandlers: Record<string, (input: {
282
+ dir: string;
283
+ config: BotConfig;
284
+ item: InboxItem;
285
+ }) => Promise<JobRunCompletion>>;
328
286
  export declare function runBot(deps: BotRunDeps): Promise<BotHandle>;
329
287
  /** True for a setup note the desktop app sent, not words the person typed. */
330
288
  export declare function isAppSetupNote(text: string): boolean;
@@ -1,3 +1,4 @@
1
+ import { ensureSignalToLaneJobs, runSignalToLane, SIGNAL_TO_LANE_JOB_KIND } from "./jobs/signal-to-lane.js";
1
2
  /**
2
3
  * The resident bot process: `hq bot run <name>` (local-bots US-003/US-004).
3
4
  *
@@ -47,6 +48,9 @@
47
48
  *
48
49
  * Everything with a side effect is injectable so the loop is unit-testable.
49
50
  */
51
+ import { createMeetingRider, defaultMeetingRiderDeps, ensureMeetingRiderJob, MEETING_RIDER_JOB_KIND } from "./jobs/meeting-rider.js";
52
+ import { executiveRiderEnabled, isExecutiveBot } from "./executive-settings.js";
53
+ import { createMeetingDetect, defaultMeetingDetectDeps, resolveDetectedMeeting } from "./jobs/meeting-detect.js";
50
54
  import { botAuthEnv, describeBotAuth, formatBotAuth } from "./runtime-auth.js";
51
55
  import { ownerContextBlock } from "./owner-context.js";
52
56
  import { randomUUID } from "node:crypto";
@@ -86,6 +90,7 @@ import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOut
86
90
  import { botJobQuietOptionEnabled } from "./job-quiet-flag.js";
87
91
  import { currentHotSignalSyncCompany, hotSignalSyncJobCompany, readActiveCompanySlug, removeStaleHotSignalSyncJobs } from "./jobs/hot-signal-sync.js";
88
92
  import { isLocalJobMessage, isLocalJobRetryMessage, localJobRetryMessageId, readLocalJobs } from "./local-jobs.js";
93
+ import { drainBotEvents } from "./events.js";
89
94
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
90
95
  import { SIGN_IN_CHECK_FRESH_MS, SIGN_IN_CHECK_MS, SIGN_IN_RETRY_MS, signInNeededText, signedOutExcerpt, } from "./runtime-sign-in.js";
91
96
  import { checkRuntimeSignIn } from "./runtime-sign-in-check.js";
@@ -407,6 +412,8 @@ export function preflightCreds(dir, config) {
407
412
  }
408
413
  return null;
409
414
  }
415
+ /** Register source-independent in-process jobs by their context.jobKind. */
416
+ export const builtinEventHandlers = Object.create(null);
410
417
  export async function runBot(deps) {
411
418
  const now = deps.now ?? (() => new Date());
412
419
  const sleep = deps.sleep ?? defaultSleep;
@@ -414,6 +421,23 @@ export async function runBot(deps) {
414
421
  const log = deps.log ?? createBotLogger(deps.dir, now);
415
422
  const pid = deps.pid ?? process.pid;
416
423
  const { dir, config, api, runtime } = deps;
424
+ const meetingRider = createMeetingRider(defaultMeetingRiderDeps(config, dir, (message) => log("info", message)));
425
+ const meetingDetectDeps = defaultMeetingDetectDeps(config, dir, (message) => log("info", message));
426
+ const meetingDetect = createMeetingDetect(meetingDetectDeps);
427
+ const eventHandlers = Object.create(builtinEventHandlers);
428
+ eventHandlers[MEETING_RIDER_JOB_KIND] = async ({ item }) => {
429
+ if (!isExecutiveBot(config))
430
+ return { status: "skipped-precondition", output: "Not the Executive" };
431
+ if (!executiveRiderEnabled())
432
+ return { status: "skipped-precondition", output: "rider disabled" };
433
+ meetingRider.start(await resolveDetectedMeeting(config.hqRoot, typeof item.context === "object" ? item.context?.payload : undefined, meetingDetectDeps.list));
434
+ return { status: "succeeded", output: "Meeting rider accepted" };
435
+ };
436
+ eventHandlers[SIGNAL_TO_LANE_JOB_KIND] = async ({ item }) => runSignalToLane({
437
+ dir, config, payload: typeof item.context === "object" ? item.context?.payload : undefined,
438
+ company: typeof item.context === "object" ? item.context?.company : undefined,
439
+ log: message => log("info", message),
440
+ });
417
441
  const runTurn = deps.runTurn ?? ((rt, input) => runRuntimeTurn(rt, input));
418
442
  const inboxIntervalMs = deps.inboxIntervalMs ?? INBOX_POLL_INTERVAL_MS;
419
443
  const inboxPollBackoffEnabled = deps.inboxPollBackoffEnabled ?? createInboxPollBackoffFlagReader(config);
@@ -554,9 +578,13 @@ export async function runBot(deps) {
554
578
  ...(deps.isProcessAlive ? { isProcessAlive: deps.isProcessAlive } : {}),
555
579
  });
556
580
  const finish = async (final, code, reason, beforeExit) => {
557
- if (state === "stopped" || state === "failed")
581
+ if (state === "stopped" || state === "failed") {
582
+ await done;
558
583
  return;
584
+ }
559
585
  state = final;
586
+ await meetingDetect.stop();
587
+ await meetingRider.stop();
560
588
  closeInboxWakeWatcher();
561
589
  setStatus({ state: final, ...(final === "failed" ? { lastError: reason.slice(0, 400) } : {}) });
562
590
  log(final === "failed" ? "error" : "info", `bot ${final}: ${reason}`);
@@ -1031,6 +1059,8 @@ export async function runBot(deps) {
1031
1059
  activeInboxWait?.abort();
1032
1060
  };
1033
1061
  const waitForInbox = async (delayMs, adaptive, useMqttWake = false) => {
1062
+ if (readLocalJobs(dir).some((job) => job.trigger))
1063
+ delayMs = Math.min(delayMs, inboxIntervalMs);
1034
1064
  if (!adaptive) {
1035
1065
  await sleep(delayMs);
1036
1066
  return;
@@ -1040,9 +1070,14 @@ export async function runBot(deps) {
1040
1070
  while (remainingMs > 0 && !stopping && state === "running") {
1041
1071
  const controller = new AbortController();
1042
1072
  activeInboxWait = controller;
1043
- const chunkMs = useMqttWake ? remainingMs : Math.min(inboxIntervalMs, remainingMs);
1073
+ const chunkMs = Math.min(inboxIntervalMs, remainingMs);
1044
1074
  if (useMqttWake) {
1045
- await getInboxWakeWatcher().wait(chunkMs, controller.signal);
1075
+ const wake = await getInboxWakeWatcher().wait(chunkMs, controller.signal);
1076
+ if (wake === "wake") {
1077
+ if (activeInboxWait === controller)
1078
+ activeInboxWait = null;
1079
+ return;
1080
+ }
1046
1081
  }
1047
1082
  else {
1048
1083
  await sleep(chunkMs, controller.signal);
@@ -1053,6 +1088,9 @@ export async function runBot(deps) {
1053
1088
  break;
1054
1089
  if (hasPromotionHold(dir))
1055
1090
  break;
1091
+ // The first event job can be added while an otherwise idle bot waits.
1092
+ if (readLocalJobs(dir).some((job) => job.trigger))
1093
+ break;
1056
1094
  if (observedGeneration !== inboxWaitGeneration || controller.signal.aborted) {
1057
1095
  observedGeneration = inboxWaitGeneration;
1058
1096
  remainingMs = inboxIntervalMs;
@@ -2017,6 +2055,31 @@ export async function runBot(deps) {
2017
2055
  const id = item.messageId;
2018
2056
  const { job, jobId } = target;
2019
2057
  const peerUid = target.peerUid;
2058
+ const eventHandler = item.messageId.startsWith("localjob-event-") && typeof job.context === "object" && typeof job.context?.jobKind === "string"
2059
+ ? eventHandlers[job.context.jobKind] : undefined;
2060
+ if (eventHandler) {
2061
+ // Recoverable built-ins persist intent before acknowledging the event.
2062
+ // Signal lane creation is idempotent, so a pre-ack crash can replay.
2063
+ const durableEvent = typeof job.context === "object" && (job.context?.jobKind === MEETING_RIDER_JOB_KIND || job.context?.jobKind === SIGNAL_TO_LANE_JOB_KIND);
2064
+ if (!durableEvent)
2065
+ markProcessed(dir, id, now().toISOString());
2066
+ let completion;
2067
+ try {
2068
+ completion = await eventHandler({ dir, config, item });
2069
+ // Durable intent is stored before acknowledging; a pre-effect crash replays.
2070
+ if (durableEvent)
2071
+ markProcessed(dir, id, now().toISOString());
2072
+ }
2073
+ catch (error) {
2074
+ if (durableEvent)
2075
+ throw error;
2076
+ completion = { status: "failed", output: error instanceof Error ? error.message : String(error) };
2077
+ }
2078
+ await completeJobRun(jobId, id, completion);
2079
+ await api.ackInbox(config.agentUid, id);
2080
+ log("info", `event job ${jobId} run ${id} ${completion.status}`);
2081
+ return;
2082
+ }
2020
2083
  // An older pending run of this job: a newer one runs instead. Reported so
2021
2084
  // the cloud releases the slot and the history says what happened.
2022
2085
  if (supersededJobRuns.has(id)) {
@@ -2821,6 +2884,21 @@ export async function runBot(deps) {
2821
2884
  // up below: end here, before the kickoff can send a "needs a sign-in" DM.
2822
2885
  if (stopping)
2823
2886
  return await stoppedDuringStartup();
2887
+ try {
2888
+ ensureSignalToLaneJobs(dir, config);
2889
+ }
2890
+ catch {
2891
+ log("warn", "Signal-to-lane registration failed");
2892
+ }
2893
+ if (isExecutiveBot(config)) {
2894
+ try {
2895
+ if (ensureMeetingRiderJob(dir) && executiveRiderEnabled())
2896
+ meetingRider.resume();
2897
+ }
2898
+ catch {
2899
+ log("warn", "Meeting rider registration or recovery failed");
2900
+ }
2901
+ }
2824
2902
  // The kickoff runs alongside the heartbeat, before the first inbox poll, so
2825
2903
  // an owner message sent meanwhile is answered after it in the same session.
2826
2904
  const kickoff = kickoffAfterIntro
@@ -2937,6 +3015,14 @@ export async function runBot(deps) {
2937
3015
  kickCompletionRetries();
2938
3016
  // A failed signals run's automatic retry, once due (no model turn).
2939
3017
  enqueueDueSignalsRetries();
3018
+ if (isExecutiveBot(config)) {
3019
+ try {
3020
+ meetingDetect.tick();
3021
+ }
3022
+ catch {
3023
+ log("warn", "meeting detection settings unreadable");
3024
+ }
3025
+ }
2940
3026
  // Holding after repeated model failures: no inbox pulls until the
2941
3027
  // cooldown is over (the update watcher above keeps running meanwhile).
2942
3028
  if (modelHoldUntil > now().getTime()) {
@@ -2945,6 +3031,15 @@ export async function runBot(deps) {
2945
3031
  }
2946
3032
  if (modelHoldUntil)
2947
3033
  liftModelHold();
3034
+ // Local events are available even when the cloud inbox is unavailable.
3035
+ try {
3036
+ for (const item of drainBotEvents(dir))
3037
+ enqueue(item);
3038
+ pump();
3039
+ }
3040
+ catch (error) {
3041
+ log("warn", `event drain failed: ${error instanceof Error ? error.message : String(error)}`);
3042
+ }
2948
3043
  // While the tool says it is signed out, ask it again on a timer (cheap,
2949
3044
  // no turn): the moment it is signed in, the held messages run.
2950
3045
  if (signInIssue && busyLanes.size === 0 && now().getTime() >= nextSignInCheckAt) {
@@ -3002,7 +3097,8 @@ export async function runBot(deps) {
3002
3097
  log("warn", `inbox poll failed: ${message}`);
3003
3098
  if (stopping || state !== "running")
3004
3099
  break;
3005
- await sleep(apiPollDelayMs({ err, attempt: inboxFails - 1, intervalMs: inboxIntervalMs, random }));
3100
+ const retryDelay = apiPollDelayMs({ err, attempt: inboxFails - 1, intervalMs: inboxIntervalMs, random });
3101
+ await sleep(readLocalJobs(dir).some((job) => job.trigger) ? Math.min(retryDelay, inboxIntervalMs) : retryDelay);
3006
3102
  continue;
3007
3103
  }
3008
3104
  if (stopping || state !== "running")
@@ -85,8 +85,29 @@ export interface BotStatusFile {
85
85
  } | null;
86
86
  /** hq-cli version this process loaded (a bot keeps it until it restarts). */
87
87
  cliVersion?: string;
88
+ /**
89
+ * HQ Fleet Runtime bots: the last turn that ended in the canned away line,
90
+ * as the bot reported it to HQ and to the owner (written by the HQ DM
91
+ * adapter in hq-fleet-local).
92
+ */
93
+ lastFailure?: BotLastFailure;
88
94
  updatedAt: string;
89
95
  }
96
+ export interface BotLastFailure {
97
+ at: string;
98
+ /** auth | billing | rate_limit | outage | runtime_error | generic */
99
+ class: string;
100
+ site?: string;
101
+ provider?: string | null;
102
+ /** Plain-language cause, as the owner was told. */
103
+ cause: string;
104
+ /** Plain-language fix, as the owner was told. */
105
+ fix: string;
106
+ /** The HQ failure report id, when the upload succeeded. */
107
+ reportId?: string | null;
108
+ uploaded?: boolean;
109
+ ownerNotified?: boolean;
110
+ }
90
111
  export declare function defaultBotStatus(pid: number, runtime: BotRuntimeId, now?: () => Date): BotStatusFile;
91
112
  export declare function readBotStatus(dir: string): BotStatusFile | null;
92
113
  export declare function writeBotStatus(dir: string, status: BotStatusFile): void;
@@ -1,3 +1,5 @@
1
+ import { type ClaudeLoginCheck } from "../../bot/claude-login.js";
2
+ import { type BotStatusFile } from "../../bot/status.js";
1
3
  import { type NodeBinaryIo } from "../../node-binary.js";
2
4
  import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
3
5
  export declare const BOTS_FAMILY_ID = "bots";
@@ -7,7 +9,11 @@ export interface BotsDoctorDeps {
7
9
  root: (home?: string) => string;
8
10
  /** Filesystem view used to judge the startup agent's node path (tests). */
9
11
  nodeIo?: NodeBinaryIo;
12
+ /** The Claude Code login read the way the HQ Fleet Runtime reads it (tests). */
13
+ claudeLogin?: () => ClaudeLoginCheck;
10
14
  }
15
+ /** The last reported failure, unless a later turn worked. */
16
+ export declare function unresolvedLastFailure(status: BotStatusFile | null): BotStatusFile["lastFailure"] | null;
11
17
  export declare const botsFamily: CheckFamily;
12
18
  export declare function checkBots(_context: CheckContext, deps?: BotsDoctorDeps): CheckResult[];
13
19
  //# sourceMappingURL=bots.d.ts.map