@indigoai-us/hq-cli 5.345.178 → 5.345.180

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 (117) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/command-catalog.generated.d.ts +197 -1
  3. package/dist/command-catalog.generated.js +241 -1
  4. package/dist/commands/bot-jobs.d.ts +11 -9
  5. package/dist/commands/bot-jobs.js +56 -21
  6. package/dist/commands/bot.d.ts +37 -1
  7. package/dist/commands/bot.js +289 -13
  8. package/dist/commands/channels.d.ts +18 -1
  9. package/dist/commands/channels.js +185 -1
  10. package/dist/commands/daemon.d.ts +2 -0
  11. package/dist/commands/daemon.js +15 -4
  12. package/dist/commands/integrations-core.d.ts +2 -0
  13. package/dist/commands/integrations-core.js +1 -0
  14. package/dist/commands/lanes.d.ts +1 -1
  15. package/dist/commands/lanes.js +6 -2
  16. package/dist/commands/mesh.js +19 -0
  17. package/dist/commands/sources.d.ts +89 -0
  18. package/dist/commands/sources.js +363 -1
  19. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2285 -1743
  20. package/dist/hq-cloud-sync-runner/package.json +1 -1
  21. package/dist/lib/bot/config.d.ts +8 -0
  22. package/dist/lib/bot/fleet-local.d.ts +1 -1
  23. package/dist/lib/bot/fleet-local.js +82 -30
  24. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +109 -0
  25. package/dist/lib/bot/jobs/hot-signal-sync.js +177 -0
  26. package/dist/lib/bot/local-jobs.d.ts +40 -4
  27. package/dist/lib/bot/local-jobs.js +72 -5
  28. package/dist/lib/bot/run.d.ts +15 -0
  29. package/dist/lib/bot/run.js +99 -1
  30. package/dist/lib/bot/runtime-auth.d.ts +119 -0
  31. package/dist/lib/bot/runtime-auth.js +251 -0
  32. package/dist/lib/daemon/catalog.d.ts +1 -0
  33. package/dist/lib/daemon/catalog.js +1 -1
  34. package/dist/lib/daemon/handoff.d.ts +4 -0
  35. package/dist/lib/daemon/handoff.js +6 -2
  36. package/dist/lib/daemon/install.d.ts +70 -2
  37. package/dist/lib/daemon/install.js +185 -27
  38. package/dist/lib/daemon/legacy.d.ts +9 -3
  39. package/dist/lib/daemon/legacy.js +11 -4
  40. package/dist/lib/daemon/migrate.js +1 -1
  41. package/dist/lib/daemon/run.d.ts +5 -2
  42. package/dist/lib/daemon/run.js +74 -22
  43. package/dist/lib/doctor/checks/anywhere.d.ts +6 -0
  44. package/dist/lib/doctor/checks/anywhere.js +35 -6
  45. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  46. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  47. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.d.ts +33 -0
  48. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.js +53 -0
  49. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.d.ts +38 -0
  50. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.js +64 -0
  51. package/dist/lib/hot-channels/adapters/attio.d.ts +67 -0
  52. package/dist/lib/hot-channels/adapters/attio.js +374 -0
  53. package/dist/lib/hot-channels/adapters/gmail.d.ts +111 -0
  54. package/dist/lib/hot-channels/adapters/gmail.js +413 -0
  55. package/dist/lib/hot-channels/adapters/index.d.ts +23 -0
  56. package/dist/lib/hot-channels/adapters/index.js +39 -0
  57. package/dist/lib/hot-channels/adapters/linear.d.ts +79 -0
  58. package/dist/lib/hot-channels/adapters/linear.js +321 -0
  59. package/dist/lib/hot-channels/adapters/meeting-providers.d.ts +86 -0
  60. package/dist/lib/hot-channels/adapters/meeting-providers.js +253 -0
  61. package/dist/lib/hot-channels/adapters/meetings.d.ts +81 -0
  62. package/dist/lib/hot-channels/adapters/meetings.js +525 -0
  63. package/dist/lib/hot-channels/adapters/slack.d.ts +70 -0
  64. package/dist/lib/hot-channels/adapters/slack.js +277 -0
  65. package/dist/lib/hot-channels/alert.d.ts +78 -0
  66. package/dist/lib/hot-channels/alert.js +179 -0
  67. package/dist/lib/hot-channels/audience.d.ts +15 -0
  68. package/dist/lib/hot-channels/audience.js +25 -0
  69. package/dist/lib/hot-channels/contract.d.ts +146 -0
  70. package/dist/lib/hot-channels/contract.js +20 -0
  71. package/dist/lib/hot-channels/cursors.d.ts +41 -0
  72. package/dist/lib/hot-channels/cursors.js +84 -0
  73. package/dist/lib/hot-channels/index.d.ts +10 -0
  74. package/dist/lib/hot-channels/index.js +10 -0
  75. package/dist/lib/hot-channels/landing.d.ts +83 -0
  76. package/dist/lib/hot-channels/landing.js +175 -0
  77. package/dist/lib/hot-channels/ledger.d.ts +101 -0
  78. package/dist/lib/hot-channels/ledger.js +203 -0
  79. package/dist/lib/hot-channels/loop.d.ts +74 -0
  80. package/dist/lib/hot-channels/loop.js +221 -0
  81. package/dist/lib/hot-channels/prompts/signals-pass.md +22 -0
  82. package/dist/lib/hot-channels/registry.d.ts +28 -0
  83. package/dist/lib/hot-channels/registry.js +121 -0
  84. package/dist/lib/hot-channels/run.d.ts +137 -0
  85. package/dist/lib/hot-channels/run.js +324 -0
  86. package/dist/lib/hot-channels/signals-pass.d.ts +150 -0
  87. package/dist/lib/hot-channels/signals-pass.js +581 -0
  88. package/dist/lib/hot-channels/sync-lock.d.ts +51 -0
  89. package/dist/lib/hot-channels/sync-lock.js +224 -0
  90. package/dist/lib/install/hook-target.d.ts +6 -2
  91. package/dist/lib/install/hook-target.js +9 -4
  92. package/dist/lib/lanes/accounts.d.ts +1 -17
  93. package/dist/lib/lanes/accounts.js +5 -45
  94. package/dist/lib/lanes/loop.d.ts +10 -0
  95. package/dist/lib/lanes/loop.js +18 -1
  96. package/dist/lib/lanes/provider-home.d.ts +24 -0
  97. package/dist/lib/lanes/provider-home.js +52 -0
  98. package/dist/lib/lanes/spawn.d.ts +12 -0
  99. package/dist/lib/lanes/spawn.js +27 -0
  100. package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
  101. package/dist/lib/mesh/live/daemon/doctor.js +13 -0
  102. package/dist/lib/mesh/live/daemon/owner-inbox-push.d.ts +94 -0
  103. package/dist/lib/mesh/live/daemon/owner-inbox-push.js +273 -0
  104. package/dist/lib/mesh/live/daemon/run.js +31 -0
  105. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +14 -0
  106. package/dist/lib/mesh/live/daemon/work-subscribe.js +210 -3
  107. package/dist/lib/mesh/live/paths.d.ts +2 -0
  108. package/dist/lib/mesh/live/paths.js +2 -0
  109. package/dist/lib/sync-core/flag.d.ts +1 -1
  110. package/dist/lib/sync-core/flag.js +1 -1
  111. package/dist/lib/sync-core/reconcile.d.ts +1 -1
  112. package/dist/lib/sync-core/reconcile.js +9 -7
  113. package/dist/lib/sync-core/release.d.ts +3 -3
  114. package/dist/lib/sync-core/release.js +4 -4
  115. package/dist/lib/sync-core/rollout-state.d.ts +1 -0
  116. package/dist/lib/sync-core/rollout-state.js +1 -0
  117. package/package.json +8 -8
@@ -237,10 +237,14 @@ function rateIntervalMs(expr) {
237
237
  function isRunnableSchedule(expr) {
238
238
  return rateIntervalMs(expr) !== null || parseCron(expr) !== null;
239
239
  }
240
+ /** Where the current schedule starts counting: its last change, else creation. */
241
+ function scheduleOrigin(job) {
242
+ return Date.parse(job.scheduledFrom ?? job.createdAt);
243
+ }
240
244
  export function latestDueFire(job, now) {
241
245
  const interval = rateIntervalMs(job.cron);
242
246
  if (interval) {
243
- const origin = Date.parse(job.createdAt);
247
+ const origin = scheduleOrigin(job);
244
248
  const steps = Math.floor((now.getTime() - origin) / interval);
245
249
  if (steps < 1)
246
250
  return null;
@@ -252,7 +256,7 @@ export function latestDueFire(job, now) {
252
256
  return null;
253
257
  const start = new Date(now);
254
258
  start.setUTCSeconds(0, 0);
255
- const floor = Math.max(job.lastFiredAt ? Date.parse(job.lastFiredAt) : Date.parse(job.createdAt), start.getTime() - LOOKBACK_MINUTES * 60_000);
259
+ const floor = Math.max(job.lastFiredAt ? Date.parse(job.lastFiredAt) : Date.parse(job.createdAt), job.scheduledFrom ? Date.parse(job.scheduledFrom) : 0, start.getTime() - LOOKBACK_MINUTES * 60_000);
256
260
  for (let t = start.getTime(); t > floor; t -= 60_000) {
257
261
  const at = new Date(t);
258
262
  if (cronMatches(cron, at, job.timezone))
@@ -264,7 +268,7 @@ export function latestDueFire(job, now) {
264
268
  export function nextDueFire(job, now) {
265
269
  const interval = rateIntervalMs(job.cron);
266
270
  if (interval) {
267
- const origin = Date.parse(job.createdAt);
271
+ const origin = scheduleOrigin(job);
268
272
  const steps = Math.max(0, Math.floor((now.getTime() - origin) / interval)) + 1;
269
273
  return new Date(origin + steps * interval);
270
274
  }
@@ -310,6 +314,53 @@ export function addLocalJob(dir, input, now = new Date()) {
310
314
  withJobsLock(dir, () => writeLocalJobs(dir, [...readLocalJobs(dir), job]));
311
315
  return job;
312
316
  }
317
+ /**
318
+ * Add a job unless one already matches, as one locked read-and-write, so two
319
+ * bot starts racing each other still leave a single job.
320
+ */
321
+ export function ensureLocalJob(dir, matches, input, now = new Date()) {
322
+ if (!isRunnableSchedule(input.cron))
323
+ throw new Error(`"${input.cron}" is not a schedule this bot can run.`);
324
+ return withJobsLock(dir, () => {
325
+ const jobs = readLocalJobs(dir);
326
+ const existing = jobs.find(matches);
327
+ if (existing)
328
+ return { job: existing, created: false };
329
+ const job = {
330
+ jobId: `job_${randomUUID().replace(/-/g, "").slice(0, 20)}`,
331
+ prompt: input.prompt,
332
+ cron: input.cron,
333
+ timezone: input.timezone,
334
+ ...(input.context ? { context: input.context } : {}),
335
+ createdAt: now.toISOString(),
336
+ };
337
+ writeLocalJobs(dir, [...jobs, job]);
338
+ return { job, created: true };
339
+ });
340
+ }
341
+ /**
342
+ * Change a job's schedule in place, keeping its id, instruction, context and
343
+ * run history. Returns null when the bot has no job with that id.
344
+ */
345
+ export function rescheduleLocalJob(dir, jobId, schedule, now = new Date()) {
346
+ if (!isRunnableSchedule(schedule.cron))
347
+ throw new Error(`"${schedule.cron}" is not a schedule this bot can run.`);
348
+ return withJobsLock(dir, () => {
349
+ const jobs = readLocalJobs(dir);
350
+ const index = jobs.findIndex((job) => job.jobId === jobId);
351
+ if (index < 0)
352
+ return null;
353
+ const updated = {
354
+ ...jobs[index],
355
+ cron: schedule.cron,
356
+ timezone: schedule.timezone,
357
+ scheduledFrom: now.toISOString(),
358
+ };
359
+ jobs[index] = updated;
360
+ writeLocalJobs(dir, jobs);
361
+ return updated;
362
+ });
363
+ }
313
364
  /** Remove a job. Returns false when the bot has no job with that id. */
314
365
  function removeLocalJob(dir, jobId) {
315
366
  return withJobsLock(dir, () => {
@@ -321,6 +372,16 @@ function removeLocalJob(dir, jobId) {
321
372
  return true;
322
373
  });
323
374
  }
375
+ /** Remove every job `matches` picks, as one locked read-and-write. Returns the removed jobs. */
376
+ export function removeLocalJobsWhere(dir, matches) {
377
+ return withJobsLock(dir, () => {
378
+ const jobs = readLocalJobs(dir);
379
+ const removed = jobs.filter(matches);
380
+ if (removed.length > 0)
381
+ writeLocalJobs(dir, jobs.filter((job) => !matches(job)));
382
+ return removed;
383
+ });
384
+ }
324
385
  // ─── Firing ──────────────────────────────────────────────────────────────────
325
386
  /**
326
387
  * Hand over every job that is due now. Each fire is recorded (lastFiredAt and
@@ -430,8 +491,8 @@ export function withLocalJobs(api, dir, clock = () => new Date()) {
430
491
  /** The most jobs one bot may hold (the same cap the cloud uses). */
431
492
  const MAX_LOCAL_JOBS = 25;
432
493
  /**
433
- * `hq bot jobs` against the bot's own folder: the same three calls the cloud
434
- * client offers, answered from `jobs.json`.
494
+ * `hq bot jobs` against the bot's own folder: the same calls the cloud client
495
+ * offers, plus reschedule, answered from `jobs.json`.
435
496
  */
436
497
  export function localJobsClient(dir, clock = () => new Date()) {
437
498
  return {
@@ -454,6 +515,12 @@ export function localJobsClient(dir, clock = () => new Date()) {
454
515
  throw new BotApiError(404, "local jobs", "JOB_NOT_FOUND", `Job '${jobId}' not found`);
455
516
  }
456
517
  },
518
+ async rescheduleJob(_agentUid, jobId, schedule) {
519
+ const job = rescheduleLocalJob(dir, jobId, schedule, clock());
520
+ if (!job)
521
+ throw new BotApiError(404, "local jobs", "JOB_NOT_FOUND", `Job '${jobId}' not found`);
522
+ return toJobRecord(job, clock());
523
+ },
457
524
  };
458
525
  }
459
526
  //# sourceMappingURL=local-jobs.js.map
@@ -134,6 +134,21 @@ export interface BotRunDeps {
134
134
  inboxPollBackoffEnabled?: () => Promise<boolean>;
135
135
  /** Default-off gate for the per-job quiet-success option. */
136
136
  jobQuietOptionEnabled?: () => boolean | Promise<boolean>;
137
+ /**
138
+ * The built-in hot-signal-sync job (jobs/hot-signal-sync.ts), run in-process
139
+ * with no model turn. Unset = a fired run of that job fails with a plain
140
+ * report instead of handing the instruction to the model.
141
+ */
142
+ hotSignalSync?: (input: {
143
+ company: string;
144
+ catchUp: boolean;
145
+ now: () => Date;
146
+ /** The signals-failure alert to the owner, sent even when the job is quiet (US-012). */
147
+ sendAlert: (text: string) => Promise<void>;
148
+ }) => Promise<{
149
+ ok: boolean;
150
+ text: string;
151
+ }>;
137
152
  heartbeatIntervalMs?: number;
138
153
  /** Override the rendered system prompt (tests). */
139
154
  systemPromptOverride?: string;
@@ -46,6 +46,7 @@
46
46
  *
47
47
  * Everything with a side effect is injectable so the loop is unit-testable.
48
48
  */
49
+ import { botAuthEnv, describeBotAuth, formatBotAuth } from "./runtime-auth.js";
49
50
  import { ownerContextBlock } from "./owner-context.js";
50
51
  import { randomUUID } from "node:crypto";
51
52
  import { hasPromotionHold } from "./promotion-hold.js";
@@ -82,6 +83,7 @@ import { isAgentSender, isDmItem, isMetaNonReply, resolveReplyTarget } from "./r
82
83
  import { resolveRoomMentionsForBot, roomPostHasMentionTokens, } from "./room-mentions.js";
83
84
  import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
84
85
  import { botJobQuietOptionEnabled } from "./job-quiet-flag.js";
86
+ import { currentHotSignalSyncCompany, hotSignalSyncJobCompany, removeStaleHotSignalSyncJobs } from "./jobs/hot-signal-sync.js";
85
87
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
86
88
  import { SIGN_IN_CHECK_FRESH_MS, SIGN_IN_CHECK_MS, SIGN_IN_RETRY_MS, signInNeededText, signedOutExcerpt, } from "./runtime-sign-in.js";
87
89
  import { checkRuntimeSignIn } from "./runtime-sign-in-check.js";
@@ -557,6 +559,12 @@ export async function runBot(deps) {
557
559
  setStatus({ ...fallback(), pid, state: "starting", canDrainForPromotion: false, runtimeSignIn: null, cliVersion });
558
560
  log("info", `starting ${config.name} on hq-cli ${cliVersion} (${config.runtime}, ${kind} bot${companies.length ? ` of ${companies.join(", ")}` : ""}) as ${config.agentUid} for ${config.ownerUid}`);
559
561
  log("info", `${runtime.id} binary: ${describeRuntimeBinary(runtime, turnEnv(process.env, kind, config.agentUid, companySlug), turnCwd)}`);
562
+ {
563
+ const login = describeBotAuth(dir, config);
564
+ log("info", `${runtime.id} login: ${formatBotAuth(login, config.name)}`);
565
+ if (login.login === "own" && !login.present)
566
+ setStatus({ lastError: `own ${runtime.id} login missing: run hq bot login ${config.name} --runtime ${runtime.id}` });
567
+ }
560
568
  deps.cliUpdate?.startup(log);
561
569
  try {
562
570
  const record = await api.getAgent(config.agentUid);
@@ -1047,7 +1055,15 @@ export async function runBot(deps) {
1047
1055
  // resets). Claude gets the full hook set via its payload session id;
1048
1056
  // Codex/Grok only see HQ_SESSION_ID.
1049
1057
  const sid = session?.sessionId ?? randomUUID();
1050
- const env = withOsUser(turnEnv(process.env, kind, config.agentUid, companySlug));
1058
+ let env;
1059
+ try {
1060
+ // The bot's own login or account, when set. A missing one fails the
1061
+ // turn loudly (status + reply); it never falls back to the owner's login.
1062
+ env = withOsUser(botAuthEnv(dir, { ...live, runtime: config.runtime }, turnEnv(process.env, kind, config.agentUid, companySlug)));
1063
+ }
1064
+ catch (err) {
1065
+ throw new CompanyBindingError(`I can't answer yet: ${err instanceof Error ? err.message : String(err)} I did not use anyone else's login; this work is still pending.`);
1066
+ }
1051
1067
  let bind = { env };
1052
1068
  if (companySlug) {
1053
1069
  try {
@@ -1932,6 +1948,88 @@ export async function runBot(deps) {
1932
1948
  const markReported = (status) => {
1933
1949
  patchInflight(dir, id, { jobCompleted: status });
1934
1950
  };
1951
+ // The built-in hot-signal-sync job runs here, in the bot, with no model
1952
+ // turn. It is marked processed as soon as the sync finishes, so a report
1953
+ // that fails to post (or a redelivered item) never syncs a second time.
1954
+ const syncCompany = hotSignalSyncJobCompany(job.context);
1955
+ if (syncCompany) {
1956
+ // The job's stored company is checked against the bot's current config
1957
+ // (read fresh from disk) every time it fires. A company the bot no longer
1958
+ // serves is never synced: the run is skipped and the job removed.
1959
+ const freshConfig = readBotConfig(dir);
1960
+ if (!freshConfig) {
1961
+ // bot.json is missing or unreadable, so which company the bot serves is
1962
+ // unknown. Skip this run without writing anything; the job stays for the
1963
+ // next fire.
1964
+ const text = `Skipped the hot-channel sync for ${syncCompany}: the bot's config (bot.json) could not be read, so nothing was written. The job will try again at its next run.`;
1965
+ markProcessed(dir, id, now().toISOString());
1966
+ try {
1967
+ await sendToPerson(text);
1968
+ }
1969
+ catch (err) {
1970
+ log("warn", `report for run ${id} of job ${jobId} was not delivered: ${err instanceof Error ? err.message : String(err)}`);
1971
+ }
1972
+ await completeJobRun(jobId, id, { status: "skipped-precondition", output: jobRunOutput(text) });
1973
+ markReported("skipped-precondition");
1974
+ await api.ackInbox(config.agentUid, id);
1975
+ clearInflight(dir, id);
1976
+ attempts.delete(id);
1977
+ setStatus({ lastActivityAt: now().toISOString() });
1978
+ log("warn", `hot-signal-sync job ${jobId} run ${id} skipped: bot.json missing or unreadable`);
1979
+ return;
1980
+ }
1981
+ const currentCompany = currentHotSignalSyncCompany(freshConfig);
1982
+ if (currentCompany !== syncCompany) {
1983
+ let removedNote = "";
1984
+ try {
1985
+ removeStaleHotSignalSyncJobs(dir, currentCompany);
1986
+ removedNote = ` Removed job ${jobId}.`;
1987
+ }
1988
+ catch (err) {
1989
+ removedNote = ` Could not remove job ${jobId}: ${err instanceof Error ? err.message : String(err)}`;
1990
+ }
1991
+ const text = `Skipped the hot-channel sync for ${syncCompany}: this bot no longer syncs that company (current: ${currentCompany ?? "none"}). Nothing was written.${removedNote}`;
1992
+ markProcessed(dir, id, now().toISOString());
1993
+ try {
1994
+ await sendToPerson(text);
1995
+ }
1996
+ catch (err) {
1997
+ log("warn", `report for run ${id} of job ${jobId} was not delivered: ${err instanceof Error ? err.message : String(err)}`);
1998
+ }
1999
+ await completeJobRun(jobId, id, { status: "skipped-precondition", output: jobRunOutput(text) });
2000
+ markReported("skipped-precondition");
2001
+ await api.ackInbox(config.agentUid, id);
2002
+ clearInflight(dir, id);
2003
+ attempts.delete(id);
2004
+ setStatus({ lastActivityAt: now().toISOString() });
2005
+ log("info", `hot-signal-sync job ${jobId} run ${id} skipped: company ${syncCompany} no longer served (current: ${currentCompany ?? "none"})`);
2006
+ return;
2007
+ }
2008
+ const result = deps.hotSignalSync
2009
+ ? await deps.hotSignalSync({ company: syncCompany, catchUp, now, sendAlert: (text) => sendToPerson(text) })
2010
+ : { ok: false, text: `This bot cannot run the hot-channel sync for ${syncCompany}.` };
2011
+ markProcessed(dir, id, now().toISOString());
2012
+ const status = result.ok ? "succeeded" : "failed";
2013
+ if (!(quietJob && result.ok)) {
2014
+ try {
2015
+ await sendToPerson(result.text);
2016
+ }
2017
+ catch (err) {
2018
+ log("warn", `report for run ${id} of job ${jobId} was not delivered: ${err instanceof Error ? err.message : String(err)}`);
2019
+ }
2020
+ }
2021
+ await completeJobRun(jobId, id, { status, output: jobRunOutput(result.text) });
2022
+ markReported(status);
2023
+ await api.ackInbox(config.agentUid, id);
2024
+ clearInflight(dir, id);
2025
+ attempts.delete(id);
2026
+ setStatus({
2027
+ ...(!(quietJob && result.ok) ? { repliesSent: (readStatus()?.repliesSent ?? 0) + 1 } : {}),
2028
+ lastActivityAt: now().toISOString(),
2029
+ });
2030
+ log("info", `hot-signal-sync job ${jobId} run ${id} ${status} for ${syncCompany}${catchUp ? " (catch-up)" : ""}`);
2031
+ return;
2032
+ }
1935
2033
  const { outcome, poster } = await runTurnWithProgress({
1936
2034
  marker: { messageId: id, target: markerTarget, jobId, jobHeader: header, agentUid: config.agentUid },
1937
2035
  prompt: buildJobTurnPrompt(job, { now: now(), timeZone }),
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Which model login a local bot uses, per runtime.
3
+ *
4
+ * - default: the owner's own sign-in for that runtime (no config dir set).
5
+ * - own: a sign-in that belongs to the bot alone, kept in
6
+ * ~/.hq/bots/<name>/auth/<runtime>/ (CLAUDE_CONFIG_DIR / CODEX_HOME).
7
+ * - account: an existing `hq capacity` / lanes account home, by id.
8
+ *
9
+ * Also the session set-aside a runtime switch needs: session files belong to
10
+ * one runtime, so they move to a backup folder and the new runtime starts
11
+ * fresh. DM history, memory and the cloud identity are never touched.
12
+ *
13
+ * This module never reads, prints, copies or hashes a credential. It only
14
+ * names directories and checks whether they exist.
15
+ */
16
+ import type { BotConfig, BotRuntimeId } from "./config.js";
17
+ import type { LanesConfig } from "../lanes/types.js";
18
+ /** Runtimes `hq bot login` can sign in for (each has a config-dir variable and a login command). */
19
+ export declare const BOT_LOGIN_RUNTIMES: readonly ["claude", "codex"];
20
+ export type BotLoginRuntime = (typeof BOT_LOGIN_RUNTIMES)[number];
21
+ export declare function isBotLoginRuntime(value: unknown): value is BotLoginRuntime;
22
+ export type BotRuntimeAuth = {
23
+ mode: "own";
24
+ } | {
25
+ mode: "account";
26
+ account: string;
27
+ };
28
+ export type BotRuntimeAuthMap = Partial<Record<BotRuntimeId, BotRuntimeAuth>>;
29
+ export declare const BOT_AUTH_DIRNAME = "auth";
30
+ export declare const BOT_SESSION_ARCHIVE_DIRNAME = "session-archive";
31
+ export declare const VENDOR_LOGIN: Record<BotLoginRuntime, {
32
+ command: string;
33
+ args: string[];
34
+ }>;
35
+ export declare const VENDOR_LOGOUT: Record<BotLoginRuntime, {
36
+ command: string;
37
+ args: string[];
38
+ }>;
39
+ /** The bot's own config home for a runtime: <botDir>/auth/<runtime>. */
40
+ export declare function botOwnAuthHome(dir: string, runtime: BotRuntimeId): string;
41
+ /** The saved login choice for a runtime, or null for the owner's default. Tolerant of junk on read. */
42
+ export declare function botRuntimeAuth(config: Pick<BotConfig, "runtime" | "runtimeAuth">, runtime?: BotRuntimeId): BotRuntimeAuth | null;
43
+ /** A copy of the map with `runtime` set to `auth` (null = back to the owner's default). */
44
+ export declare function withRuntimeAuth(map: BotRuntimeAuthMap | undefined, runtime: BotRuntimeId, auth: BotRuntimeAuth | null): BotRuntimeAuthMap | undefined;
45
+ export interface AccountLookupDeps {
46
+ loadConfig?: (hqRoot: string) => LanesConfig;
47
+ existsSync?: (p: string) => boolean;
48
+ }
49
+ /** The home directory of a capacity account for a runtime. Throws an expected error when it is not usable. */
50
+ export declare function capacityAccountHome(hqRoot: string, runtime: BotRuntimeId, accountId: string, deps?: AccountLookupDeps): string;
51
+ /**
52
+ * The environment a bot's model turn runs with: `base` with the runtime's
53
+ * config-dir variable pointed at the bot's chosen login. A missing own login
54
+ * or a gone account is an error: falling back to the owner's login is the
55
+ * thing this setting exists to prevent.
56
+ */
57
+ export declare function botAuthEnv(dir: string, config: Pick<BotConfig, "name" | "runtime" | "runtimeAuth" | "hqRoot">, base: NodeJS.ProcessEnv, deps?: AccountLookupDeps): NodeJS.ProcessEnv;
58
+ export interface BotAuthDescription {
59
+ runtime: BotRuntimeId;
60
+ login: "default" | "own" | "account" | "invalid";
61
+ account?: string;
62
+ /** The config-dir variable the bot's turns set (never its value's contents). */
63
+ envVar?: string;
64
+ /** Own login: whether its folder exists. Never a token check. */
65
+ present?: boolean;
66
+ }
67
+ export declare function describeBotAuth(dir: string, config: Pick<BotConfig, "runtime" | "runtimeAuth">, existsSync?: (p: string) => boolean): BotAuthDescription;
68
+ export declare function formatBotAuth(d: BotAuthDescription, name: string): string;
69
+ export type SpawnInteractive = (command: string, args: string[], env: NodeJS.ProcessEnv) => {
70
+ status: number | null;
71
+ error?: Error;
72
+ };
73
+ /** Runs the vendor's own login/logout with the terminal attached. Its output goes to the terminal only, never to HQ's log. */
74
+ export declare const defaultSpawnInteractive: SpawnInteractive;
75
+ /** Logout runs quietly: nothing it prints is shown or logged. */
76
+ export declare const defaultSpawnQuiet: SpawnInteractive;
77
+ /**
78
+ * Sign a bot in with its own login for `runtime`. The vendor CLI writes the
79
+ * credential into the bot's home (or, for Claude on macOS, a keychain item
80
+ * keyed by that home). HQ never sees it.
81
+ */
82
+ export declare function signInBotRuntime(dir: string, runtime: BotLoginRuntime, deps?: {
83
+ spawn?: SpawnInteractive;
84
+ quietSpawn?: SpawnInteractive;
85
+ env?: NodeJS.ProcessEnv;
86
+ }): {
87
+ home: string;
88
+ created: boolean;
89
+ };
90
+ /**
91
+ * Sign the bot's own login out and delete its home. The vendor logout runs
92
+ * first so a Claude keychain item keyed by the home is removed too.
93
+ */
94
+ export declare function signOutBotRuntime(dir: string, runtime: BotLoginRuntime, deps?: {
95
+ spawn?: SpawnInteractive;
96
+ env?: NodeJS.ProcessEnv;
97
+ }): {
98
+ removed: boolean;
99
+ vendorLogout: "ok" | "failed" | "skipped";
100
+ };
101
+ /** True for the model-session files of a bot dir (owner DM, other DMs, rooms, jobs). */
102
+ export declare function isBotSessionFile(name: string): boolean;
103
+ export interface SessionSetAside {
104
+ archiveDir: string;
105
+ moved: string[];
106
+ }
107
+ /**
108
+ * Before a runtime switch: back up bot.json and move every session file into
109
+ * <dir>/session-archive/<stamp>-<oldRuntime>/, so the new runtime starts
110
+ * fresh and the old sessions can be put back.
111
+ */
112
+ export declare function setAsideBotSessions(dir: string, oldRuntime: BotRuntimeId, now?: Date): SessionSetAside;
113
+ /**
114
+ * Undo setAsideBotSessions: the moved session files go back. bot.json is not
115
+ * overwritten (the bot may have written fields since); the caller patches the
116
+ * settings it changed back. The backup folder stays for manual recovery.
117
+ */
118
+ export declare function restoreBotSessions(dir: string, aside: SessionSetAside): void;
119
+ //# sourceMappingURL=runtime-auth.d.ts.map
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Which model login a local bot uses, per runtime.
3
+ *
4
+ * - default: the owner's own sign-in for that runtime (no config dir set).
5
+ * - own: a sign-in that belongs to the bot alone, kept in
6
+ * ~/.hq/bots/<name>/auth/<runtime>/ (CLAUDE_CONFIG_DIR / CODEX_HOME).
7
+ * - account: an existing `hq capacity` / lanes account home, by id.
8
+ *
9
+ * Also the session set-aside a runtime switch needs: session files belong to
10
+ * one runtime, so they move to a backup folder and the new runtime starts
11
+ * fresh. DM history, memory and the cloud identity are never touched.
12
+ *
13
+ * This module never reads, prints, copies or hashes a credential. It only
14
+ * names directories and checks whether they exist.
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { spawnSync } from "node:child_process";
19
+ import { botConfigPath } from "./paths.js";
20
+ import { PROVIDER_HOME_ENV, isDefaultProviderHome, withProviderHomeEnv } from "../lanes/provider-home.js";
21
+ import { effectiveAccountHome } from "../lanes/account-home.js";
22
+ import { loadLanesConfig } from "../lanes/config.js";
23
+ /** Runtimes `hq bot login` can sign in for (each has a config-dir variable and a login command). */
24
+ export const BOT_LOGIN_RUNTIMES = ["claude", "codex"];
25
+ export function isBotLoginRuntime(value) {
26
+ return typeof value === "string" && BOT_LOGIN_RUNTIMES.includes(value);
27
+ }
28
+ export const BOT_AUTH_DIRNAME = "auth";
29
+ export const BOT_SESSION_ARCHIVE_DIRNAME = "session-archive";
30
+ const ACCOUNT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
31
+ export const VENDOR_LOGIN = {
32
+ claude: { command: "claude", args: ["auth", "login"] },
33
+ codex: { command: "codex", args: ["login"] },
34
+ };
35
+ export const VENDOR_LOGOUT = {
36
+ claude: { command: "claude", args: ["auth", "logout"] },
37
+ codex: { command: "codex", args: ["logout"] },
38
+ };
39
+ function expected(message) {
40
+ return Object.assign(new Error(message), { expected: true });
41
+ }
42
+ /** The bot's own config home for a runtime: <botDir>/auth/<runtime>. */
43
+ export function botOwnAuthHome(dir, runtime) {
44
+ return path.join(dir, BOT_AUTH_DIRNAME, runtime);
45
+ }
46
+ /** The saved login choice for a runtime, or null for the owner's default. Tolerant of junk on read. */
47
+ export function botRuntimeAuth(config, runtime = config.runtime) {
48
+ const raw = config.runtimeAuth?.[runtime];
49
+ if (!raw || typeof raw !== "object")
50
+ return null;
51
+ if (raw.mode === "own")
52
+ return { mode: "own" };
53
+ if (raw.mode === "account" && typeof raw.account === "string" && ACCOUNT_ID_RE.test(raw.account)) {
54
+ return { mode: "account", account: raw.account };
55
+ }
56
+ return null;
57
+ }
58
+ /** A copy of the map with `runtime` set to `auth` (null = back to the owner's default). */
59
+ export function withRuntimeAuth(map, runtime, auth) {
60
+ const next = { ...(map ?? {}) };
61
+ if (auth)
62
+ next[runtime] = auth;
63
+ else
64
+ delete next[runtime];
65
+ return Object.keys(next).length ? next : undefined;
66
+ }
67
+ /** The home directory of a capacity account for a runtime. Throws an expected error when it is not usable. */
68
+ export function capacityAccountHome(hqRoot, runtime, accountId, deps = {}) {
69
+ if (!ACCOUNT_ID_RE.test(accountId))
70
+ throw expected(`"${accountId.slice(0, 40)}" is not an account id.`);
71
+ const cfg = (deps.loadConfig ?? loadLanesConfig)(hqRoot);
72
+ const provider = cfg.providers[runtime];
73
+ const account = provider?.accounts[accountId];
74
+ if (!account) {
75
+ const known = Object.keys(provider?.accounts ?? {});
76
+ throw expected(`No ${runtime} account named "${accountId}"${known.length ? ` (known: ${known.join(", ")})` : ""}. Add one with: hq capacity login ${runtime}`);
77
+ }
78
+ if (!account.enabled)
79
+ throw expected(`The ${runtime} account "${accountId}" is disabled.`);
80
+ const home = effectiveAccountHome(account).home;
81
+ if (isDefaultProviderHome(runtime, home)) {
82
+ throw expected(`The ${runtime} account "${accountId}" is your default ${runtime} login, so it would not give the bot a login of its own. Pick another account, or use: hq bot login`);
83
+ }
84
+ if (!(deps.existsSync ?? fs.existsSync)(home))
85
+ throw expected(`The ${runtime} account "${accountId}" has no home folder on this computer. Sign it in with: hq capacity login ${runtime} --account ${accountId}`);
86
+ return home;
87
+ }
88
+ /**
89
+ * The environment a bot's model turn runs with: `base` with the runtime's
90
+ * config-dir variable pointed at the bot's chosen login. A missing own login
91
+ * or a gone account is an error: falling back to the owner's login is the
92
+ * thing this setting exists to prevent.
93
+ */
94
+ export function botAuthEnv(dir, config, base, deps = {}) {
95
+ const auth = botRuntimeAuth(config);
96
+ const runtime = config.runtime;
97
+ if (!auth) {
98
+ // A saved choice HQ cannot read is not the owner's default: refuse it.
99
+ if (config.runtimeAuth?.[runtime] !== undefined) {
100
+ throw expected(`${config.name}'s saved ${runtime} login setting is not readable. Set it again with: hq bot login ${config.name} --runtime ${runtime}, or hq bot set ${config.name} --account <id|default>`);
101
+ }
102
+ return { ...base };
103
+ }
104
+ if (auth.mode === "own") {
105
+ const home = botOwnAuthHome(dir, runtime);
106
+ if (!(deps.existsSync ?? fs.existsSync)(home)) {
107
+ throw expected(`${config.name} is set to its own ${runtime} login but has none on this computer. Sign it in with: hq bot login ${config.name} --runtime ${runtime}`);
108
+ }
109
+ return withProviderHomeEnv(base, runtime, home);
110
+ }
111
+ return withProviderHomeEnv(base, runtime, capacityAccountHome(config.hqRoot, runtime, auth.account, deps));
112
+ }
113
+ export function describeBotAuth(dir, config, existsSync = fs.existsSync) {
114
+ const auth = botRuntimeAuth(config);
115
+ const runtime = config.runtime;
116
+ if (!auth)
117
+ return config.runtimeAuth?.[runtime] !== undefined ? { runtime, login: "invalid" } : { runtime, login: "default" };
118
+ const envVar = PROVIDER_HOME_ENV[runtime];
119
+ if (auth.mode === "own")
120
+ return { runtime, login: "own", ...(envVar ? { envVar } : {}), present: existsSync(botOwnAuthHome(dir, runtime)) };
121
+ return { runtime, login: "account", account: auth.account, ...(envVar ? { envVar } : {}) };
122
+ }
123
+ export function formatBotAuth(d, name) {
124
+ if (d.login === "default")
125
+ return `default (your own ${d.runtime} sign-in)`;
126
+ if (d.login === "invalid")
127
+ return `unreadable setting; turns are refused until it is set again (hq bot login ${name} --runtime ${d.runtime})`;
128
+ if (d.login === "account")
129
+ return `account ${d.account}`;
130
+ return d.present ? `own (signed in for ${name} only)` : `own, but not signed in (run: hq bot login ${name} --runtime ${d.runtime})`;
131
+ }
132
+ /** Runs the vendor's own login/logout with the terminal attached. Its output goes to the terminal only, never to HQ's log. */
133
+ export const defaultSpawnInteractive = (command, args, env) => {
134
+ const r = spawnSync(command, args, { stdio: "inherit", env });
135
+ return { status: r.status, ...(r.error ? { error: r.error } : {}) };
136
+ };
137
+ /** Logout runs quietly: nothing it prints is shown or logged. */
138
+ export const defaultSpawnQuiet = (command, args, env) => {
139
+ const r = spawnSync(command, args, { stdio: ["ignore", "ignore", "ignore"], env, timeout: 30_000 });
140
+ return { status: r.status, ...(r.error ? { error: r.error } : {}) };
141
+ };
142
+ /** Refuse to touch anything that is not exactly <dir>/auth/<runtime>. */
143
+ function assertOwnAuthHome(dir, home) {
144
+ const root = path.resolve(dir, BOT_AUTH_DIRNAME);
145
+ const rel = path.relative(root, path.resolve(home));
146
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel) || rel.includes(path.sep)) {
147
+ throw new Error(`refusing to touch ${home}: not a bot login folder`);
148
+ }
149
+ for (const p of [root, home]) {
150
+ let link = false;
151
+ try {
152
+ link = fs.lstatSync(p).isSymbolicLink();
153
+ }
154
+ catch (err) {
155
+ if (err.code !== "ENOENT")
156
+ throw err;
157
+ }
158
+ if (link)
159
+ throw new Error(`refusing to touch ${p}: it is a symbolic link`);
160
+ }
161
+ }
162
+ /**
163
+ * Sign a bot in with its own login for `runtime`. The vendor CLI writes the
164
+ * credential into the bot's home (or, for Claude on macOS, a keychain item
165
+ * keyed by that home). HQ never sees it.
166
+ */
167
+ export function signInBotRuntime(dir, runtime, deps = {}) {
168
+ const home = botOwnAuthHome(dir, runtime);
169
+ assertOwnAuthHome(dir, home);
170
+ const created = !fs.existsSync(home);
171
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
172
+ fs.chmodSync(path.join(dir, BOT_AUTH_DIRNAME), 0o700);
173
+ fs.chmodSync(home, 0o700);
174
+ const { command, args } = VENDOR_LOGIN[runtime];
175
+ const r = (deps.spawn ?? defaultSpawnInteractive)(command, args, withProviderHomeEnv(deps.env ?? process.env, runtime, home));
176
+ if (r.error || r.status !== 0) {
177
+ // A first sign-in that failed leaves nothing behind.
178
+ if (created) {
179
+ // A keychain item keyed by this folder may already exist: sign it out first.
180
+ const { command: out, args: outArgs } = VENDOR_LOGOUT[runtime];
181
+ (deps.quietSpawn ?? defaultSpawnQuiet)(out, outArgs, withProviderHomeEnv(deps.env ?? process.env, runtime, home));
182
+ fs.rmSync(home, { recursive: true, force: true });
183
+ }
184
+ const why = r.error?.code === "ENOENT" ? `${command} is not installed` : `${command} ${args.join(" ")} did not finish (exit ${r.status ?? "signal"})`;
185
+ throw expected(`Sign-in failed: ${why}. Nothing was changed.`);
186
+ }
187
+ return { home, created };
188
+ }
189
+ /**
190
+ * Sign the bot's own login out and delete its home. The vendor logout runs
191
+ * first so a Claude keychain item keyed by the home is removed too.
192
+ */
193
+ export function signOutBotRuntime(dir, runtime, deps = {}) {
194
+ const home = botOwnAuthHome(dir, runtime);
195
+ assertOwnAuthHome(dir, home);
196
+ if (!fs.existsSync(home))
197
+ return { removed: false, vendorLogout: "skipped" };
198
+ const { command, args } = VENDOR_LOGOUT[runtime];
199
+ const r = (deps.spawn ?? defaultSpawnQuiet)(command, args, withProviderHomeEnv(deps.env ?? process.env, runtime, home));
200
+ if (r.error || r.status !== 0) {
201
+ // Keep the folder: it is the only handle on a keychain item keyed by it.
202
+ return { removed: false, vendorLogout: "failed" };
203
+ }
204
+ fs.rmSync(home, { recursive: true, force: true });
205
+ return { removed: true, vendorLogout: "ok" };
206
+ }
207
+ /** True for the model-session files of a bot dir (owner DM, other DMs, rooms, jobs). */
208
+ export function isBotSessionFile(name) {
209
+ return name === "session.json" || /^session-(dm|room|job)-[A-Za-z0-9._-]+\.json$/.test(name);
210
+ }
211
+ /**
212
+ * Before a runtime switch: back up bot.json and move every session file into
213
+ * <dir>/session-archive/<stamp>-<oldRuntime>/, so the new runtime starts
214
+ * fresh and the old sessions can be put back.
215
+ */
216
+ export function setAsideBotSessions(dir, oldRuntime, now = new Date()) {
217
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
218
+ const archiveDir = path.join(dir, BOT_SESSION_ARCHIVE_DIRNAME, `${stamp}-${oldRuntime}`);
219
+ fs.mkdirSync(archiveDir, { recursive: true, mode: 0o700 });
220
+ fs.copyFileSync(botConfigPath(dir), path.join(archiveDir, "bot.json"));
221
+ const moved = [];
222
+ try {
223
+ for (const name of fs.readdirSync(dir)) {
224
+ if (!isBotSessionFile(name))
225
+ continue;
226
+ fs.renameSync(path.join(dir, name), path.join(archiveDir, name));
227
+ moved.push(name);
228
+ }
229
+ }
230
+ catch (err) {
231
+ restoreBotSessions(dir, { archiveDir, moved });
232
+ throw err;
233
+ }
234
+ return { archiveDir, moved };
235
+ }
236
+ /**
237
+ * Undo setAsideBotSessions: the moved session files go back. bot.json is not
238
+ * overwritten (the bot may have written fields since); the caller patches the
239
+ * settings it changed back. The backup folder stays for manual recovery.
240
+ */
241
+ export function restoreBotSessions(dir, aside) {
242
+ for (const name of fs.readdirSync(dir)) {
243
+ // Files the new runtime wrote in the meantime are not the old runtime's.
244
+ if (isBotSessionFile(name) && !aside.moved.includes(name))
245
+ fs.rmSync(path.join(dir, name), { force: true });
246
+ }
247
+ for (const name of aside.moved) {
248
+ fs.copyFileSync(path.join(aside.archiveDir, name), path.join(dir, name));
249
+ }
250
+ }
251
+ //# sourceMappingURL=runtime-auth.js.map
@@ -189,6 +189,7 @@ export declare function hqCloudUnits(machineType: MachineType, enabled: readonly
189
189
  export declare const CODEX_RUNNER_SCRIPT = "/usr/local/bin/outpost-codex-runner.sh";
190
190
  /** The outpost's Claude runner script, read for the session name it uses. */
191
191
  export declare const CLAUDE_RUNNER_SCRIPT = "/usr/local/bin/outpost-runner.sh";
192
+ export declare function daemonAloneRunsLegacySync(ctx: PlanContext): boolean;
192
193
  /** Turn enabled services into concrete units for this host. */
193
194
  export declare function planUnits(services: readonly ServiceName[], ctx: PlanContext): UnitPlan[];
194
195
  export {};