@indigoai-us/hq-cli 5.345.197 → 5.345.199

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 (118) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/command-catalog.generated.d.ts +393 -12
  3. package/dist/command-catalog.generated.js +482 -12
  4. package/dist/commands/bot-events.d.ts +19 -0
  5. package/dist/commands/bot-events.js +46 -0
  6. package/dist/commands/bot-jobs.d.ts +2 -0
  7. package/dist/commands/bot-jobs.js +43 -2
  8. package/dist/commands/bot.d.ts +5 -0
  9. package/dist/commands/bot.js +33 -1
  10. package/dist/commands/core-resolve-company-native.d.ts +2 -1
  11. package/dist/commands/core-resolve-company-native.js +2 -24
  12. package/dist/commands/deploy-domains.d.ts +14 -2
  13. package/dist/commands/deploy-domains.js +726 -42
  14. package/dist/commands/integrations-connect.js +41 -3
  15. package/dist/commands/lanes-report.d.ts +2 -1
  16. package/dist/commands/lanes-report.js +44 -1
  17. package/dist/commands/lanes.d.ts +9 -0
  18. package/dist/commands/lanes.js +95 -9
  19. package/dist/commands/meetings.js +1 -21
  20. package/dist/commands/secrets.js +27 -6
  21. package/dist/commands/signals-run.d.ts +2 -0
  22. package/dist/commands/signals-run.js +6 -1
  23. package/dist/lanes-drain-hook.js +9 -0
  24. package/dist/lib/bot/api.d.ts +5 -0
  25. package/dist/lib/bot/claude-login.d.ts +31 -0
  26. package/dist/lib/bot/claude-login.js +160 -0
  27. package/dist/lib/bot/company-bind.d.ts +34 -0
  28. package/dist/lib/bot/company-bind.js +112 -0
  29. package/dist/lib/bot/config.js +3 -1
  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-local.d.ts +12 -0
  36. package/dist/lib/bot/fleet-local.js +50 -20
  37. package/dist/lib/bot/job-run.js +1 -1
  38. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +8 -0
  39. package/dist/lib/bot/jobs/hot-signal-sync.js +2 -0
  40. package/dist/lib/bot/jobs/meeting-detect.d.ts +28 -0
  41. package/dist/lib/bot/jobs/meeting-detect.js +176 -0
  42. package/dist/lib/bot/jobs/meeting-rider.d.ts +53 -0
  43. package/dist/lib/bot/jobs/meeting-rider.js +284 -0
  44. package/dist/lib/bot/jobs/signal-to-lane.d.ts +32 -0
  45. package/dist/lib/bot/jobs/signal-to-lane.js +169 -0
  46. package/dist/lib/bot/local-jobs.d.ts +15 -2
  47. package/dist/lib/bot/local-jobs.js +24 -7
  48. package/dist/lib/bot/run.d.ts +7 -49
  49. package/dist/lib/bot/run.js +135 -12
  50. package/dist/lib/bot/session.d.ts +19 -1
  51. package/dist/lib/bot/session.js +75 -28
  52. package/dist/lib/bot/status.d.ts +21 -0
  53. package/dist/lib/core-utils/manifest-slugs.d.ts +2 -0
  54. package/dist/lib/core-utils/manifest-slugs.js +26 -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/outcome.d.ts +154 -0
  70. package/dist/lib/lanes/outcome.js +151 -0
  71. package/dist/lib/lanes/reconcile.js +1 -0
  72. package/dist/lib/lanes/signal-brief-template.d.ts +7 -0
  73. package/dist/lib/lanes/signal-brief-template.js +40 -0
  74. package/dist/lib/lanes/signal-lane.d.ts +24 -0
  75. package/dist/lib/lanes/signal-lane.js +99 -0
  76. package/dist/lib/lanes/source-report.d.ts +37 -0
  77. package/dist/lib/lanes/source-report.js +66 -0
  78. package/dist/lib/lanes/spawn.d.ts +6 -0
  79. package/dist/lib/lanes/spawn.js +21 -0
  80. package/dist/lib/lanes/store.d.ts +3 -0
  81. package/dist/lib/lanes/store.js +64 -0
  82. package/dist/lib/lanes/story-ledger.d.ts +3 -0
  83. package/dist/lib/lanes/story-ledger.js +1 -0
  84. package/dist/lib/lanes/types.d.ts +137 -0
  85. package/dist/lib/lanes/types.js +17 -2
  86. package/dist/lib/lanes/watcher.js +4 -0
  87. package/dist/lib/live-meetings.d.ts +19 -0
  88. package/dist/lib/live-meetings.js +23 -0
  89. package/dist/lib/mesh/live/daemon/doctor.js +6 -7
  90. package/dist/lib/mesh/live/spool.d.ts +8 -0
  91. package/dist/lib/mesh/live/spool.js +59 -40
  92. package/dist/lib/plan-limit-nag.d.ts +30 -4
  93. package/dist/lib/plan-limit-nag.js +68 -17
  94. package/dist/lib/signals/engine.d.ts +4 -0
  95. package/dist/lib/signals/engine.js +25 -5
  96. package/dist/lib/signals/incremental.d.ts +42 -0
  97. package/dist/lib/signals/incremental.js +191 -0
  98. package/dist/lib/signals/prior-signals.d.ts +11 -0
  99. package/dist/lib/signals/prior-signals.js +44 -0
  100. package/dist/lib/signals/reply.js +6 -1
  101. package/dist/lib/signals/settings.d.ts +5 -0
  102. package/dist/lib/signals/settings.js +17 -0
  103. package/dist/lib/signals/steps/extract.d.ts +30 -2
  104. package/dist/lib/signals/steps/extract.js +153 -11
  105. package/dist/lib/signals/steps/types.d.ts +1 -0
  106. package/dist/lib/signals/write-guard.js +7 -0
  107. package/dist/secrets-env-action.d.ts +70 -0
  108. package/dist/secrets-env-action.js +220 -2
  109. package/dist/utils/cognito-callback-port.d.ts +15 -0
  110. package/dist/utils/cognito-callback-port.js +51 -0
  111. package/dist/utils/cognito-config.js +2 -3
  112. package/dist/utils/cognito-session.d.ts +7 -6
  113. package/dist/utils/cognito-session.js +14 -14
  114. package/dist/utils/interactive-caller.d.ts +15 -0
  115. package/dist/utils/interactive-caller.js +70 -0
  116. package/dist/utils/login-browser.js +3 -1
  117. package/dist/utils/resident-package-preload.js +4 -0
  118. package/package.json +9 -7
@@ -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";
@@ -58,7 +62,7 @@ import { fullJitterDelayMs } from "../mesh/live/backoff.js";
58
62
  import { acquirePidLock, defaultPidLockDeps, releasePidLock } from "../mesh/live/daemon/pid-lock.js";
59
63
  import { BotApiError } from "./api.js";
60
64
  import { effectiveBotCompanies, effectiveBotKind, patchBotConfig, readBotConfig, resolveBotMemoryDir, SETUP_BOT_WORKER_ID } from "./config.js";
61
- import { ensureBotSessionMeta } from "./company-bind.js";
65
+ import { botCompanyScopeKey, ensureBotSessionMeta, legacyPersonalEntryScopeKey, readManifestCompanies, resolveBotCompanyBinding } from "./company-bind.js";
62
66
  import { readBotCredsIdentity } from "./creds.js";
63
67
  import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
64
68
  import { clearInflight, patchInflight, readInflight, readInflightTurns, writeInflight } from "./inflight.js";
@@ -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);
@@ -531,18 +555,36 @@ export async function runBot(deps) {
531
555
  const kind = effectiveBotKind(config);
532
556
  const companies = effectiveBotCompanies(config);
533
557
  // A single membership is unambiguous; personal and multi-company bots stay unbound.
534
- const companySlug = config.companySlug ?? (kind === "company" && companies.length === 1 ? companies[0] : undefined);
535
- const companyScopeKey = kind === "company" || companySlug !== undefined
536
- ? JSON.stringify({ kind, companies: [...companies].sort(), companySlug: companySlug ?? null })
558
+ const binding = resolveBotCompanyBinding(config);
559
+ const companySlug = binding.slug;
560
+ // What bot.json alone says; a lock inferred from the manifest is not part of it.
561
+ const startExplicitSlug = resolveBotCompanyBinding(config, () => []).slug;
562
+ // The explicit scope: bot.json's kind, memberships and company. A lock inferred
563
+ // from the HQ's company list is not part of it.
564
+ const companyScopeKey = kind === "company" || (companySlug !== undefined && !binding.inferred)
565
+ ? botCompanyScopeKey(kind, companies, companySlug)
566
+ : undefined;
567
+ // A personal bot with no explicit company records the company it inferred (or
568
+ // null when unbound) beside that key. When the HQ's company list changes, its
569
+ // conversations are archived instead of resumed under a lock they were not
570
+ // created under. The first inferred lock keeps existing history.
571
+ const inferredScopeSlug = companyScopeKey === undefined && kind === "personal" && config.workerId !== SETUP_BOT_WORKER_ID
572
+ ? (binding.inferred ? binding.slug ?? null : null)
537
573
  : undefined;
574
+ // The key an older CLI wrote when bot.json lists "personal": the same scope.
575
+ const legacyScopeKey = legacyPersonalEntryScopeKey(config);
538
576
  const lockDeps = defaultPidLockDeps({
539
577
  pid,
540
578
  ...(deps.isProcessAlive ? { isProcessAlive: deps.isProcessAlive } : {}),
541
579
  });
542
580
  const finish = async (final, code, reason, beforeExit) => {
543
- if (state === "stopped" || state === "failed")
581
+ if (state === "stopped" || state === "failed") {
582
+ await done;
544
583
  return;
584
+ }
545
585
  state = final;
586
+ await meetingDetect.stop();
587
+ await meetingRider.stop();
546
588
  closeInboxWakeWatcher();
547
589
  setStatus({ state: final, ...(final === "failed" ? { lastError: reason.slice(0, 400) } : {}) });
548
590
  log(final === "failed" ? "error" : "info", `bot ${final}: ${reason}`);
@@ -679,7 +721,10 @@ export async function runBot(deps) {
679
721
  return { stop: async () => { }, done, status: () => null, idle: () => true };
680
722
  }
681
723
  try {
682
- const scope = ensureBotCompanyScope(dir, companyScopeKey);
724
+ const scope = ensureBotCompanyScope(dir, companyScopeKey, {
725
+ ...(inferredScopeSlug !== undefined ? { inferredSlug: inferredScopeSlug } : {}),
726
+ ...(legacyScopeKey !== undefined && legacyScopeKey !== companyScopeKey ? { equivalentKeys: [legacyScopeKey] } : {}),
727
+ });
683
728
  if (scope.reset)
684
729
  log("info", "company scope changed or was unverified; archived prior conversations before starting fresh");
685
730
  }
@@ -1014,6 +1059,8 @@ export async function runBot(deps) {
1014
1059
  activeInboxWait?.abort();
1015
1060
  };
1016
1061
  const waitForInbox = async (delayMs, adaptive, useMqttWake = false) => {
1062
+ if (readLocalJobs(dir).some((job) => job.trigger))
1063
+ delayMs = Math.min(delayMs, inboxIntervalMs);
1017
1064
  if (!adaptive) {
1018
1065
  await sleep(delayMs);
1019
1066
  return;
@@ -1023,9 +1070,14 @@ export async function runBot(deps) {
1023
1070
  while (remainingMs > 0 && !stopping && state === "running") {
1024
1071
  const controller = new AbortController();
1025
1072
  activeInboxWait = controller;
1026
- const chunkMs = useMqttWake ? remainingMs : Math.min(inboxIntervalMs, remainingMs);
1073
+ const chunkMs = Math.min(inboxIntervalMs, remainingMs);
1027
1074
  if (useMqttWake) {
1028
- 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
+ }
1029
1081
  }
1030
1082
  else {
1031
1083
  await sleep(chunkMs, controller.signal);
@@ -1036,6 +1088,9 @@ export async function runBot(deps) {
1036
1088
  break;
1037
1089
  if (hasPromotionHold(dir))
1038
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;
1039
1094
  if (observedGeneration !== inboxWaitGeneration || controller.signal.aborted) {
1040
1095
  observedGeneration = inboxWaitGeneration;
1041
1096
  remainingMs = inboxIntervalMs;
@@ -1102,14 +1157,24 @@ export async function runBot(deps) {
1102
1157
  * prepended so a one-word reply still has the question it answers.
1103
1158
  */
1104
1159
  const bindingNotices = new Set();
1160
+ let bindingNoticeLogged = false;
1105
1161
  const executeTurn = async (prompt, sessionScope, tReceived, stream = {}, historyEventId) => {
1106
1162
  const fresh = readBotConfig(dir);
1107
1163
  if (!fresh)
1108
1164
  throw new CompanyBindingError(`I couldn't read my company settings. Please restart ${config.name} after restoring its configuration; this work is still pending.`);
1109
1165
  const live = fresh;
1110
1166
  const liveCompanies = effectiveBotCompanies(live);
1111
- const liveCompanySlug = live.companySlug ?? (effectiveBotKind(live) === "company" && liveCompanies.length === 1 ? liveCompanies[0] : undefined);
1112
- if (liveCompanySlug !== companySlug || effectiveBotKind(live) !== kind ||
1167
+ // Explicit settings only: a lock inferred from the HQ's company list is
1168
+ // fixed at start, so an owner who creates or joins a company never fails a turn.
1169
+ const liveCompanySlug = resolveBotCompanyBinding(live, () => []).slug;
1170
+ if (binding.inferred && !bindingNoticeLogged) {
1171
+ const hqNow = readManifestCompanies(config.hqRoot);
1172
+ if (!(hqNow.length === 1 && hqNow[0] === companySlug)) {
1173
+ bindingNoticeLogged = true;
1174
+ log("warn", `${config.name}: this HQ's company list changed since start; keeping the start-time lock to ${companySlug} until the next restart.`);
1175
+ }
1176
+ }
1177
+ if (liveCompanySlug !== startExplicitSlug || effectiveBotKind(live) !== kind ||
1113
1178
  JSON.stringify([...liveCompanies].sort()) !== JSON.stringify([...companies].sort())) {
1114
1179
  throw new CompanyBindingError(`My company settings changed. Please restart ${config.name} to load them before I continue; this work is still pending.`);
1115
1180
  }
@@ -1171,7 +1236,7 @@ export async function runBot(deps) {
1171
1236
  let bind = { env };
1172
1237
  if (companySlug) {
1173
1238
  try {
1174
- ensureBotSessionMeta(config.hqRoot, sid, liveCompanySlug, undefined, now);
1239
+ ensureBotSessionMeta(config.hqRoot, sid, companySlug, undefined, now);
1175
1240
  }
1176
1241
  catch {
1177
1242
  throw new CompanyBindingError(`I couldn't bind this session to ${companySlug}. Please check the bot's workspace permissions and retry; this work is still pending.`);
@@ -1990,6 +2055,31 @@ export async function runBot(deps) {
1990
2055
  const id = item.messageId;
1991
2056
  const { job, jobId } = target;
1992
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
+ }
1993
2083
  // An older pending run of this job: a newer one runs instead. Reported so
1994
2084
  // the cloud releases the slot and the history says what happened.
1995
2085
  if (supersededJobRuns.has(id)) {
@@ -2794,6 +2884,21 @@ export async function runBot(deps) {
2794
2884
  // up below: end here, before the kickoff can send a "needs a sign-in" DM.
2795
2885
  if (stopping)
2796
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
+ }
2797
2902
  // The kickoff runs alongside the heartbeat, before the first inbox poll, so
2798
2903
  // an owner message sent meanwhile is answered after it in the same session.
2799
2904
  const kickoff = kickoffAfterIntro
@@ -2910,6 +3015,14 @@ export async function runBot(deps) {
2910
3015
  kickCompletionRetries();
2911
3016
  // A failed signals run's automatic retry, once due (no model turn).
2912
3017
  enqueueDueSignalsRetries();
3018
+ if (isExecutiveBot(config)) {
3019
+ try {
3020
+ meetingDetect.tick();
3021
+ }
3022
+ catch {
3023
+ log("warn", "meeting detection settings unreadable");
3024
+ }
3025
+ }
2913
3026
  // Holding after repeated model failures: no inbox pulls until the
2914
3027
  // cooldown is over (the update watcher above keeps running meanwhile).
2915
3028
  if (modelHoldUntil > now().getTime()) {
@@ -2918,6 +3031,15 @@ export async function runBot(deps) {
2918
3031
  }
2919
3032
  if (modelHoldUntil)
2920
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
+ }
2921
3043
  // While the tool says it is signed out, ask it again on a timer (cheap,
2922
3044
  // no turn): the moment it is signed in, the held messages run.
2923
3045
  if (signInIssue && busyLanes.size === 0 && now().getTime() >= nextSignInCheckAt) {
@@ -2975,7 +3097,8 @@ export async function runBot(deps) {
2975
3097
  log("warn", `inbox poll failed: ${message}`);
2976
3098
  if (stopping || state !== "running")
2977
3099
  break;
2978
- 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);
2979
3102
  continue;
2980
3103
  }
2981
3104
  if (stopping || state !== "running")
@@ -18,6 +18,24 @@ export interface BotSessionFile {
18
18
  export declare function readBotSession(dir: string, runtime: BotRuntimeId, scope?: BotSessionScope): BotSessionFile | null;
19
19
  export declare function writeBotSession(dir: string, session: BotSessionFile, scope?: BotSessionScope): void;
20
20
  export declare function clearBotSession(dir: string, scope?: BotSessionScope): void;
21
+ export interface BotCompanyScopeOptions {
22
+ /**
23
+ * Keys an earlier CLI wrote for this same scope. A marker holding one is
24
+ * rewritten to the current key in place: nothing is archived and pending
25
+ * work is kept. Used for a company bot whose bot.json lists "personal",
26
+ * which no longer counts as a membership.
27
+ */
28
+ equivalentKeys?: readonly string[];
29
+ /**
30
+ * A personal bot with no explicit company (companyScopeKey undefined): the
31
+ * company its sessions are locked to because the HQ lists only that one, or
32
+ * null when it is unbound. A change from the recorded value archives the
33
+ * conversations, like an explicit scope change, so a session is never
34
+ * resumed under a lock it was not created under. Ignored when
35
+ * companyScopeKey is set.
36
+ */
37
+ inferredSlug?: string | null;
38
+ }
21
39
  /**
22
40
  * Run once at startup, before reading or appending conversation state. A scope
23
41
  * change must never resume a prior company's model session or replay its DM
@@ -26,7 +44,7 @@ export declare function clearBotSession(dir: string, scope?: BotSessionScope): v
26
44
  * Legacy personal bots retain their existing history; company bots require
27
45
  * positive scope evidence and archive legacy, unproven conversations once.
28
46
  */
29
- export declare function ensureBotCompanyScope(dir: string, companyScopeKey: string | undefined): {
47
+ export declare function ensureBotCompanyScope(dir: string, companyScopeKey: string | undefined, options?: BotCompanyScopeOptions): {
30
48
  reset: boolean;
31
49
  archiveDir?: string;
32
50
  };
@@ -38,6 +38,31 @@ export function clearBotSession(dir, scope = "dm") {
38
38
  /* already gone */
39
39
  }
40
40
  }
41
+ const isScopeValue = (value) => typeof value === "string" || value === null;
42
+ function writeCompanyScopeMarker(dir, marker) {
43
+ const tmp = path.join(dir, `.company-scope.${randomUUID()}.tmp`);
44
+ try {
45
+ fs.writeFileSync(tmp, `${JSON.stringify(marker)}\n`, { mode: 0o600 });
46
+ fs.renameSync(tmp, path.join(dir, "company-scope.json"));
47
+ }
48
+ finally {
49
+ fs.rmSync(tmp, { force: true });
50
+ }
51
+ }
52
+ /** Move every session and DM history file into a fresh private archive folder. */
53
+ function archiveConversations(dir) {
54
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
55
+ const names = fs.readdirSync(dir).filter((name) => /^(?:session|dm-history)(?:-[A-Za-z0-9_-]+)?\.json$/.test(name));
56
+ if (!names.length)
57
+ return { names };
58
+ const archiveRoot = path.join(dir, "company-scope-archives");
59
+ fs.mkdirSync(archiveRoot, { recursive: true, mode: 0o700 });
60
+ const archiveDir = path.join(archiveRoot, randomUUID());
61
+ fs.mkdirSync(archiveDir, { mode: 0o700 });
62
+ for (const name of names)
63
+ fs.renameSync(path.join(dir, name), path.join(archiveDir, name));
64
+ return { names, archiveDir };
65
+ }
41
66
  /**
42
67
  * Run once at startup, before reading or appending conversation state. A scope
43
68
  * change must never resume a prior company's model session or replay its DM
@@ -46,13 +71,14 @@ export function clearBotSession(dir, scope = "dm") {
46
71
  * Legacy personal bots retain their existing history; company bots require
47
72
  * positive scope evidence and archive legacy, unproven conversations once.
48
73
  */
49
- export function ensureBotCompanyScope(dir, companyScopeKey) {
74
+ export function ensureBotCompanyScope(dir, companyScopeKey, options = {}) {
50
75
  const marker = path.join(dir, "company-scope.json");
51
76
  const hasMarker = fs.existsSync(marker);
52
- if (!hasMarker && companyScopeKey === undefined)
77
+ const inferred = companyScopeKey === undefined ? options.inferredSlug : undefined;
78
+ if (!hasMarker && companyScopeKey === undefined && inferred === undefined)
53
79
  return { reset: false };
54
80
  const expected = companyScopeKey ?? null;
55
- let provenPreviousScope = false;
81
+ let previous = null;
56
82
  if (hasMarker) {
57
83
  // Read errors fail closed. Invalid JSON is unproven provenance and resets.
58
84
  const body = fs.readFileSync(marker, "utf8");
@@ -63,16 +89,22 @@ export function ensureBotCompanyScope(dir, companyScopeKey) {
63
89
  catch {
64
90
  parsed = null;
65
91
  }
66
- provenPreviousScope = Boolean(parsed && typeof parsed === "object" &&
67
- parsed.v === 1 &&
68
- (typeof parsed.companyScopeKey === "string" ||
69
- parsed.companyScopeKey === null));
70
- if (parsed && typeof parsed === "object" &&
71
- parsed.v === 1 &&
72
- parsed.companyScopeKey === expected) {
73
- return { reset: false };
92
+ previous = parsed && typeof parsed === "object" && parsed.v === 1
93
+ ? parsed
94
+ : null;
95
+ if (inferred === undefined && previous !== null) {
96
+ if (previous.companyScopeKey === expected)
97
+ return { reset: false };
98
+ if (typeof previous.companyScopeKey === "string" && options.equivalentKeys?.includes(previous.companyScopeKey)) {
99
+ // The same scope under an older key: record the current key and keep everything.
100
+ writeCompanyScopeMarker(dir, { v: 1, companyScopeKey: expected });
101
+ return { reset: false };
102
+ }
74
103
  }
75
104
  }
105
+ const provenPreviousScope = previous !== null && isScopeValue(previous.companyScopeKey);
106
+ if (inferred !== undefined)
107
+ return ensureInferredScope(dir, hasMarker, previous, inferred);
76
108
  // A known company switch must settle recovery under its previous scope.
77
109
  // Legacy bots have no scope evidence: isolate their model context below,
78
110
  // but leave delivery/job markers intact for the existing recovery path.
@@ -84,26 +116,41 @@ export function ensureBotCompanyScope(dir, companyScopeKey) {
84
116
  }
85
117
  if (!provenPreviousScope)
86
118
  omitInflightRecoveryHistory(dir, pending);
87
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
88
- const names = fs.readdirSync(dir).filter((name) => /^(?:session|dm-history)(?:-[A-Za-z0-9_-]+)?\.json$/.test(name));
89
- let archiveDir;
90
- if (names.length) {
91
- const archiveRoot = path.join(dir, "company-scope-archives");
92
- fs.mkdirSync(archiveRoot, { recursive: true, mode: 0o700 });
93
- archiveDir = path.join(archiveRoot, randomUUID());
94
- fs.mkdirSync(archiveDir, { mode: 0o700 });
95
- for (const name of names)
96
- fs.renameSync(path.join(dir, name), path.join(archiveDir, name));
97
- }
119
+ const { names, archiveDir } = archiveConversations(dir);
98
120
  // Commit the new provenance only after every old conversation was isolated.
99
- const tmp = path.join(dir, `.company-scope.${randomUUID()}.tmp`);
100
- try {
101
- fs.writeFileSync(tmp, `${JSON.stringify({ v: 1, companyScopeKey: expected })}\n`, { mode: 0o600 });
102
- fs.renameSync(tmp, marker);
121
+ writeCompanyScopeMarker(dir, { v: 1, companyScopeKey: expected });
122
+ return { reset: names.length > 0, ...(archiveDir ? { archiveDir } : {}) };
123
+ }
124
+ /**
125
+ * The personal-bot path of ensureBotCompanyScope: the recorded inferred slug
126
+ * must match, or the conversations are archived. The explicit companyScopeKey
127
+ * field is carried over exactly as the explicit rule would leave it (absent,
128
+ * or null once the bot has had a marker), so a later explicit bind behaves as
129
+ * it did before inferred locks existed.
130
+ */
131
+ function ensureInferredScope(dir, hasMarker, previous, inferred) {
132
+ const previousInferred = previous !== null && isScopeValue(previous.inferredCompanySlug) ? previous.inferredCompanySlug : undefined;
133
+ const previousExplicit = previous !== null && isScopeValue(previous.companyScopeKey) ? previous.companyScopeKey : undefined;
134
+ const recordsNull = hasMarker && !(previousExplicit === undefined && previousInferred !== undefined);
135
+ const next = { v: 1, ...(recordsNull ? { companyScopeKey: null } : {}), inferredCompanySlug: inferred };
136
+ // No inferred record yet: a legacy personal bot (no marker, or an
137
+ // explicit-unbound one) keeps its history, as it did before inferred locks.
138
+ if (!hasMarker || (previousExplicit === null && previousInferred === undefined)) {
139
+ writeCompanyScopeMarker(dir, next);
140
+ return { reset: false };
103
141
  }
104
- finally {
105
- fs.rmSync(tmp, { force: true });
142
+ if (previousInferred === inferred && typeof previousExplicit !== "string")
143
+ return { reset: false };
144
+ const pending = readInflightTurns(dir, { strictIo: true });
145
+ // Leaving an explicit company keeps the explicit rule: settle its work first.
146
+ // A change in the HQ's company list cannot be undone from bot.json, so it
147
+ // never blocks the start; saved work is settled without the old history.
148
+ if (typeof previousExplicit === "string" && pending.length > 0) {
149
+ throw new Error("Bot company settings changed with pending work. Restore the previous company settings and settle pending work before switching companies.");
106
150
  }
151
+ omitInflightRecoveryHistory(dir, pending);
152
+ const { names, archiveDir } = archiveConversations(dir);
153
+ writeCompanyScopeMarker(dir, next);
107
154
  return { reset: names.length > 0, ...(archiveDir ? { archiveDir } : {}) };
108
155
  }
109
156
  //# sourceMappingURL=session.js.map
@@ -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;
@@ -0,0 +1,2 @@
1
+ export declare function parseManifestSlugs(text: string): string[];
2
+ //# sourceMappingURL=manifest-slugs.d.ts.map
@@ -0,0 +1,26 @@
1
+ /** Leaf module: company slugs from companies/manifest.yaml, shared by the resolver and the bot runtime. */
2
+ const keepSlug = (slug) => slug !== "" && slug !== "_template" && slug !== "companies" && slug !== "unaffiliated_repos";
3
+ export function parseManifestSlugs(text) {
4
+ const slugs = new Set();
5
+ let wrapped = false;
6
+ for (const rawLine of text.split("\n")) {
7
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
8
+ if (/^companies:[ \t]*$/.test(line)) {
9
+ wrapped = true;
10
+ continue;
11
+ }
12
+ if (wrapped && /^[^ \t][^:]*:[ \t]*$/.test(line))
13
+ wrapped = false;
14
+ if (wrapped) {
15
+ const match = line.match(/^ {2}([A-Za-z][A-Za-z0-9_-]*):/);
16
+ if (match && keepSlug(match[1]))
17
+ slugs.add(match[1]);
18
+ continue;
19
+ }
20
+ const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):/);
21
+ if (match && keepSlug(match[1]))
22
+ slugs.add(match[1]);
23
+ }
24
+ return [...slugs];
25
+ }
26
+ //# sourceMappingURL=manifest-slugs.js.map
@@ -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