@indigoai-us/hq-cli 5.345.175 → 5.345.177

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 (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/command-catalog.generated.d.ts +65 -0
  3. package/dist/command-catalog.generated.js +80 -0
  4. package/dist/commands/bot.d.ts +28 -2
  5. package/dist/commands/bot.js +29 -6
  6. package/dist/commands/daemon.js +16 -0
  7. package/dist/commands/lanes-loop.d.ts +4 -1
  8. package/dist/commands/lanes-loop.js +23 -4
  9. package/dist/commands/lanes-shared.d.ts +5 -0
  10. package/dist/commands/lanes.d.ts +5 -7
  11. package/dist/commands/lanes.js +65 -5
  12. package/dist/commands/sync-core.d.ts +17 -0
  13. package/dist/commands/sync-core.js +46 -5
  14. package/dist/commands/sync-manifest.d.ts +6 -0
  15. package/dist/commands/sync-manifest.js +13 -0
  16. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +448 -226
  17. package/dist/hq-cloud-sync-runner/package.json +1 -1
  18. package/dist/lib/bot/cli-update.d.ts +36 -0
  19. package/dist/lib/bot/cli-update.js +160 -42
  20. package/dist/lib/bot/run.d.ts +21 -0
  21. package/dist/lib/bot/run.js +127 -10
  22. package/dist/lib/bot/runtime/claude.d.ts +7 -1
  23. package/dist/lib/bot/runtime/claude.js +29 -0
  24. package/dist/lib/bot/runtime/codex.d.ts +5 -0
  25. package/dist/lib/bot/runtime/codex.js +13 -0
  26. package/dist/lib/bot/runtime/types.d.ts +10 -0
  27. package/dist/lib/bot/runtime-sign-in-check.d.ts +32 -0
  28. package/dist/lib/bot/runtime-sign-in-check.js +53 -0
  29. package/dist/lib/bot/runtime-sign-in.d.ts +32 -3
  30. package/dist/lib/bot/runtime-sign-in.js +17 -2
  31. package/dist/lib/bot/status.d.ts +25 -1
  32. package/dist/lib/bot/status.js +28 -0
  33. package/dist/lib/daemon/catalog.d.ts +4 -0
  34. package/dist/lib/daemon/catalog.js +47 -19
  35. package/dist/lib/daemon/run.js +1 -0
  36. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  37. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  38. package/dist/lib/lanes/loop-disposition.d.ts +44 -0
  39. package/dist/lib/lanes/loop-disposition.js +302 -0
  40. package/dist/lib/lanes/loop.d.ts +16 -3
  41. package/dist/lib/lanes/loop.js +167 -60
  42. package/dist/lib/lanes/reconcile.d.ts +5 -0
  43. package/dist/lib/lanes/reconcile.js +79 -4
  44. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +3 -0
  45. package/dist/lib/mesh/live/daemon/work-subscribe.js +41 -12
  46. package/dist/lib/sync-core/engine-home.d.ts +11 -4
  47. package/dist/lib/sync-core/engine-home.js +22 -21
  48. package/dist/lib/sync-core/manifest-scopes.d.ts +15 -0
  49. package/dist/lib/sync-core/manifest-scopes.js +26 -0
  50. package/dist/lib/sync-core/reconcile.d.ts +2 -2
  51. package/dist/lib/sync-core/reconcile.js +70 -46
  52. package/dist/lib/sync-core/release.d.ts +2 -2
  53. package/dist/lib/sync-core/release.js +31 -6
  54. package/dist/lib/sync-core/rollout-state.d.ts +6 -3
  55. package/dist/lib/sync-core/rollout-state.js +26 -22
  56. package/package.json +8 -8
@@ -1 +1 @@
1
- {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.103","bundleSha256":"3ed362bac2c75d32a61b0e26b4253813af96458579920a798fd38a9583e855f9","formatVersion":1}
1
+ {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.104","bundleSha256":"40c72e049db21ee4551fa737676d8022cd090149ede9e3c9e53a9fae3f28dadc","formatVersion":1}
@@ -23,6 +23,15 @@
23
23
  * that target for CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS instead of restarting
24
24
  * again. Restarts are also capped per window, so an install that keeps
25
25
  * flipping cannot turn into a restart loop.
26
+ *
27
+ * The same watcher covers the macOS HQ.app launcher. A bot's LaunchAgent runs
28
+ * `HQ.app/Contents/MacOS/hq-sync-menubar --hq-launchd-task node hq bot run`, and
29
+ * that launcher is what macOS privacy (TCC) attributes the bot's file access
30
+ * to. When an HQ.app update replaces the launcher binary under a running bot,
31
+ * every coding-tool turn after that fails within milliseconds ("An unknown
32
+ * error occurred (Unexpected)") because ~/Documents access no longer resolves,
33
+ * until the bot is restarted under the new launcher. So a changed launcher
34
+ * binary is handled like an updated CLI: confirm, drain, exit for launchd.
26
35
  */
27
36
  import type { BotLogger } from "./log.js";
28
37
  /**
@@ -62,7 +71,18 @@ export interface CliUpdate {
62
71
  from: string;
63
72
  to: string;
64
73
  packageRoot: string;
74
+ /** "launcher": the HQ.app launcher binary was replaced; the CLI version is unchanged. */
75
+ reason?: "launcher";
76
+ }
77
+ /** One line for the log and the exit reason. */
78
+ export declare function describeCliUpdate(update: CliUpdate): string;
79
+ /** Identity of a file on disk: an app update replaces the inode or changes size/mtime. */
80
+ export interface LauncherStamp {
81
+ ino: number;
82
+ size: number;
83
+ mtimeMs: number;
65
84
  }
85
+ export declare function statLauncher(p: string): LauncherStamp | null;
66
86
  interface CliUpdateLedger {
67
87
  v: 1;
68
88
  /** Written just before an update restart; settled by the next process. */
@@ -70,6 +90,7 @@ interface CliUpdateLedger {
70
90
  from: string;
71
91
  to: string;
72
92
  at: string;
93
+ reason?: "launcher";
73
94
  };
74
95
  /** ISO times of recent update restarts (for the per-window cap). */
75
96
  restarts: string[];
@@ -95,6 +116,9 @@ export interface CliUpdateWatcherOptions {
95
116
  dir: string;
96
117
  /** The script this process was launched with (process.argv[1]); null = do not watch. */
97
118
  entry: string | null;
119
+ /** The HQ.app launcher binary that started this process (macOS LaunchAgent); null = do not watch. */
120
+ launcher?: string | null;
121
+ statLauncher?: (p: string) => LauncherStamp | null;
98
122
  runningVersion?: string;
99
123
  now?: () => number;
100
124
  read?: (entry: string) => InstalledCliRead;
@@ -107,6 +131,18 @@ export interface CliUpdateWatcherOptions {
107
131
  cooldownMs?: number;
108
132
  }
109
133
  export declare function createCliUpdateWatcher(opts: CliUpdateWatcherOptions): CliUpdateWatch;
134
+ /**
135
+ * The HQ.app launcher binary this process runs under, or null. A LaunchAgent
136
+ * written for HQ.app runs `<HQ.app>/Contents/MacOS/hq-sync-menubar
137
+ * --hq-launchd-task node hq ...`; the launcher spawns node and waits, so it is
138
+ * the direct parent. Only a parent whose command line carries the launcher
139
+ * flag counts.
140
+ */
141
+ export declare function detectHqAppLauncher(opts?: {
142
+ platform?: NodeJS.Platform;
143
+ ppid?: number;
144
+ parentCommand?: (pid: number) => string | null;
145
+ }): string | null;
110
146
  /**
111
147
  * Who starts this bot again after it exits.
112
148
  *
@@ -23,7 +23,17 @@
23
23
  * that target for CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS instead of restarting
24
24
  * again. Restarts are also capped per window, so an install that keeps
25
25
  * flipping cannot turn into a restart loop.
26
+ *
27
+ * The same watcher covers the macOS HQ.app launcher. A bot's LaunchAgent runs
28
+ * `HQ.app/Contents/MacOS/hq-sync-menubar --hq-launchd-task node hq bot run`, and
29
+ * that launcher is what macOS privacy (TCC) attributes the bot's file access
30
+ * to. When an HQ.app update replaces the launcher binary under a running bot,
31
+ * every coding-tool turn after that fails within milliseconds ("An unknown
32
+ * error occurred (Unexpected)") because ~/Documents access no longer resolves,
33
+ * until the bot is restarted under the new launcher. So a changed launcher
34
+ * binary is handled like an updated CLI: confirm, drain, exit for launchd.
26
35
  */
36
+ import { spawnSync } from "node:child_process";
27
37
  import * as fs from "node:fs";
28
38
  import * as path from "node:path";
29
39
  import semver from "semver";
@@ -98,6 +108,22 @@ export function readInstalledCli(entry, io = fs) {
98
108
  }
99
109
  return { ok: false, reason: `no ${CLI_NAME} package.json above ${real}` };
100
110
  }
111
+ /** One line for the log and the exit reason. */
112
+ export function describeCliUpdate(update) {
113
+ return update.reason === "launcher"
114
+ ? `HQ.app was updated (${update.packageRoot}); restarting under the new launcher`
115
+ : `hq-cli updated ${update.from} -> ${update.to}; restarting to load it`;
116
+ }
117
+ export function statLauncher(p) {
118
+ try {
119
+ const st = fs.statSync(p);
120
+ return { ino: st.ino, size: st.size, mtimeMs: st.mtimeMs };
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ const sameStamp = (a, b) => a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs;
101
127
  function ledgerPath(dir) {
102
128
  return path.join(dir, CLI_UPDATE_LEDGER_FILE);
103
129
  }
@@ -133,6 +159,19 @@ export function createCliUpdateWatcher(opts) {
133
159
  const restartWindowMs = opts.restartWindowMs ?? CLI_UPDATE_RESTART_WINDOW_MS;
134
160
  const cooldownMs = opts.cooldownMs ?? CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS;
135
161
  const { dir, entry } = opts;
162
+ const launcher = opts.launcher ?? null;
163
+ const stat = opts.statLauncher ?? statLauncher;
164
+ /** The launcher as it was when this process started; null = not watched. */
165
+ const launcherBaseline = launcher ? stat(launcher) : null;
166
+ let launcherCandidate = null;
167
+ let launcherSeen = 0;
168
+ let lastLauncherNote = null;
169
+ const launcherNote = (log, level, message) => {
170
+ if (message === lastLauncherNote)
171
+ return;
172
+ lastLauncherNote = message;
173
+ log(level, message);
174
+ };
136
175
  let nextCheckAt = now() + checkIntervalMs;
137
176
  let candidate = null;
138
177
  let seen = 0;
@@ -151,6 +190,10 @@ export function createCliUpdateWatcher(opts) {
151
190
  return {
152
191
  runningVersion,
153
192
  startup(log) {
193
+ if (launcher && launcherBaseline)
194
+ log("info", `watching ${launcher} for HQ.app updates`);
195
+ else if (launcher)
196
+ log("warn", `not watching for HQ.app updates: ${launcher} is not readable`);
154
197
  if (!entry) {
155
198
  log("info", "not watching for hq-cli updates (no entry script)");
156
199
  return;
@@ -159,7 +202,10 @@ export function createCliUpdateWatcher(opts) {
159
202
  const pending = ledger.pending;
160
203
  if (pending) {
161
204
  delete ledger.pending;
162
- if (runningVersion === pending.to) {
205
+ if (pending.reason === "launcher") {
206
+ log("info", `restarted after an HQ.app update; now running hq-cli ${runningVersion}`);
207
+ }
208
+ else if (runningVersion === pending.to) {
163
209
  log("info", `hq-cli update converged: now running ${runningVersion} (was ${pending.from})`);
164
210
  if (ledger.blocked?.version === runningVersion)
165
211
  delete ledger.blocked;
@@ -183,60 +229,132 @@ export function createCliUpdateWatcher(opts) {
183
229
  log("info", `watching ${entry} for hq-cli updates (running ${runningVersion})`);
184
230
  },
185
231
  poll(log) {
186
- if (!entry)
232
+ if (!entry && !launcherBaseline)
187
233
  return null;
188
234
  const at = now();
189
235
  if (at < nextCheckAt)
190
236
  return null;
191
237
  nextCheckAt = at + checkIntervalMs;
192
- const installed = read(entry);
193
- if (!installed.ok) {
194
- candidate = null;
195
- seen = 0;
196
- note(log, "warn", `hq-cli install not readable, not restarting: ${installed.reason}`);
197
- return null;
198
- }
199
- if (installed.version === runningVersion) {
200
- candidate = null;
201
- seen = 0;
202
- lastNote = null;
203
- return null;
204
- }
205
- if (rewriteInProgress(installed.packageRoot)) {
206
- candidate = null;
207
- seen = 0;
208
- note(log, "info", `hq-cli ${installed.version} is still being installed; waiting for it to finish`);
209
- return null;
210
- }
211
- if (candidate === installed.version)
212
- seen += 1;
213
- else {
214
- candidate = installed.version;
215
- seen = 1;
216
- }
217
- if (seen < confirmations) {
218
- note(log, "info", `hq-cli ${installed.version} is installed (running ${runningVersion}); confirming before restarting`);
219
- return null;
220
- }
221
- const ledger = readCliUpdateLedger(dir);
222
- if (ledger.blocked?.version === installed.version && at < Date.parse(ledger.blocked.until)) {
223
- note(log, "warn", `not restarting for hq-cli ${installed.version}: an earlier restart for it did not load it (retry after ${ledger.blocked.until})`);
224
- return null;
225
- }
226
- if (recentRestarts(ledger, at).length >= restartLimit) {
227
- note(log, "warn", `not restarting for hq-cli ${installed.version}: already restarted ${restartLimit} times for updates in the last ${Math.round(restartWindowMs / 60_000)} minutes`);
228
- return null;
229
- }
230
- return { from: runningVersion, to: installed.version, packageRoot: installed.packageRoot };
238
+ return pollCli(log, at) ?? pollLauncher(log, at);
231
239
  },
232
240
  recordRestart(update) {
233
241
  const at = now();
234
242
  const ledger = readCliUpdateLedger(dir);
235
243
  ledger.restarts = [...recentRestarts(ledger, at), new Date(at).toISOString()];
236
- ledger.pending = { from: update.from, to: update.to, at: new Date(at).toISOString() };
244
+ ledger.pending = {
245
+ from: update.from,
246
+ to: update.to,
247
+ at: new Date(at).toISOString(),
248
+ ...(update.reason ? { reason: update.reason } : {}),
249
+ };
237
250
  writeCliUpdateLedger(dir, ledger);
238
251
  },
239
252
  };
253
+ function underRestartCap(log, at, what, say = note) {
254
+ if (recentRestarts(readCliUpdateLedger(dir), at).length < restartLimit)
255
+ return true;
256
+ say(log, "warn", `not restarting for ${what}: already restarted ${restartLimit} times for updates in the last ${Math.round(restartWindowMs / 60_000)} minutes`);
257
+ return false;
258
+ }
259
+ function pollLauncher(log, at) {
260
+ if (!launcher || !launcherBaseline)
261
+ return null;
262
+ const current = stat(launcher);
263
+ if (!current) {
264
+ // Mid-replace: the old bundle is gone and the new one is not there yet.
265
+ launcherCandidate = null;
266
+ launcherSeen = 0;
267
+ launcherNote(log, "info", `${launcher} is missing (HQ.app being replaced?); waiting`);
268
+ return null;
269
+ }
270
+ if (sameStamp(current, launcherBaseline)) {
271
+ launcherCandidate = null;
272
+ launcherSeen = 0;
273
+ lastLauncherNote = null;
274
+ return null;
275
+ }
276
+ if (launcherCandidate && sameStamp(current, launcherCandidate))
277
+ launcherSeen += 1;
278
+ else {
279
+ launcherCandidate = current;
280
+ launcherSeen = 1;
281
+ }
282
+ if (launcherSeen < confirmations) {
283
+ launcherNote(log, "info", `${launcher} changed (HQ.app update); confirming before restarting`);
284
+ return null;
285
+ }
286
+ if (!underRestartCap(log, at, "the HQ.app update", launcherNote))
287
+ return null;
288
+ return { from: runningVersion, to: runningVersion, packageRoot: launcher, reason: "launcher" };
289
+ }
290
+ function pollCli(log, at) {
291
+ if (!entry)
292
+ return null;
293
+ const installed = read(entry);
294
+ if (!installed.ok) {
295
+ candidate = null;
296
+ seen = 0;
297
+ note(log, "warn", `hq-cli install not readable, not restarting: ${installed.reason}`);
298
+ return null;
299
+ }
300
+ if (installed.version === runningVersion) {
301
+ candidate = null;
302
+ seen = 0;
303
+ lastNote = null;
304
+ return null;
305
+ }
306
+ if (rewriteInProgress(installed.packageRoot)) {
307
+ candidate = null;
308
+ seen = 0;
309
+ note(log, "info", `hq-cli ${installed.version} is still being installed; waiting for it to finish`);
310
+ return null;
311
+ }
312
+ if (candidate === installed.version)
313
+ seen += 1;
314
+ else {
315
+ candidate = installed.version;
316
+ seen = 1;
317
+ }
318
+ if (seen < confirmations) {
319
+ note(log, "info", `hq-cli ${installed.version} is installed (running ${runningVersion}); confirming before restarting`);
320
+ return null;
321
+ }
322
+ const ledger = readCliUpdateLedger(dir);
323
+ if (ledger.blocked?.version === installed.version && at < Date.parse(ledger.blocked.until)) {
324
+ note(log, "warn", `not restarting for hq-cli ${installed.version}: an earlier restart for it did not load it (retry after ${ledger.blocked.until})`);
325
+ return null;
326
+ }
327
+ if (!underRestartCap(log, at, `hq-cli ${installed.version}`))
328
+ return null;
329
+ return { from: runningVersion, to: installed.version, packageRoot: installed.packageRoot };
330
+ }
331
+ }
332
+ /**
333
+ * The HQ.app launcher binary this process runs under, or null. A LaunchAgent
334
+ * written for HQ.app runs `<HQ.app>/Contents/MacOS/hq-sync-menubar
335
+ * --hq-launchd-task node hq ...`; the launcher spawns node and waits, so it is
336
+ * the direct parent. Only a parent whose command line carries the launcher
337
+ * flag counts.
338
+ */
339
+ export function detectHqAppLauncher(opts = {}) {
340
+ if ((opts.platform ?? process.platform) !== "darwin")
341
+ return null;
342
+ const ppid = opts.ppid ?? process.ppid;
343
+ if (!ppid || ppid <= 1)
344
+ return null;
345
+ const parentCommand = opts.parentCommand ??
346
+ ((pid) => {
347
+ const r = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 });
348
+ return r.status === 0 ? r.stdout.trim() : null;
349
+ });
350
+ const command = parentCommand(ppid);
351
+ if (!command)
352
+ return null;
353
+ const flag = command.indexOf(" --hq-launchd-task ");
354
+ if (flag <= 0)
355
+ return null;
356
+ const binary = command.slice(0, flag);
357
+ return binary.endsWith("/Contents/MacOS/hq-sync-menubar") ? binary : null;
240
358
  }
241
359
  export function detectBotSupervisor(name, opts = {}) {
242
360
  const env = opts.env ?? process.env;
@@ -56,6 +56,7 @@ import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "
56
56
  import { type BotSessionScope } from "./paths.js";
57
57
  import { type DecisionDmClient } from "./decisions.js";
58
58
  import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js";
59
+ import { type RuntimeSignInCheck } from "./runtime-sign-in.js";
59
60
  import { type HeartbeatAgentUidSource, type HeartbeatCredsSource, type HeartbeatIdentityMismatchReporter } from "../heartbeat-identity.js";
60
61
  import { type BotState, type BotStatusFile } from "./status.js";
61
62
  import { type CliUpdate, type CliUpdateWatch } from "./cli-update.js";
@@ -197,6 +198,15 @@ export interface BotRunDeps {
197
198
  * thread are recorded through it. Unset leaves every owner DM on the model path.
198
199
  */
199
200
  decisions?: DecisionDmClient;
201
+ /**
202
+ * The bot's own sign-in check (runtime-sign-in-check.ts), run with the
203
+ * turn's environment at start, before each turn, and while signed out.
204
+ * Injected in tests; unset runs the runtime's status command. A runtime
205
+ * with no status command answers `signedIn: null` and turns run as before.
206
+ */
207
+ checkSignIn?: (env: NodeJS.ProcessEnv) => Promise<RuntimeSignInCheck>;
208
+ /** How often to ask the tool again while it is signed out (default SIGN_IN_CHECK_MS). */
209
+ signInCheckMs?: number;
200
210
  }
201
211
  export declare const KICKOFF_MESSAGE_ID = "kickoff";
202
212
  /**
@@ -219,6 +229,17 @@ export declare const KICKOFF_MESSAGE_ID = "kickoff";
219
229
  * Pure so the rule is unit-testable.
220
230
  */
221
231
  export declare function turnEnv(base: NodeJS.ProcessEnv, kind: BotKind, agentUid?: string, companySlug?: string): NodeJS.ProcessEnv;
232
+ /** The account name this process runs as, or null when the OS cannot say. */
233
+ export declare function osUserName(): string | null;
234
+ /**
235
+ * Claude Code keys its macOS Keychain login on `$USER` (falling back to the
236
+ * literal "unknown"), so a tool spawned from an environment without USER
237
+ * reads a different Keychain item than the owner's terminal and is signed
238
+ * out. launchd and the HQ app's launcher may leave it unset; fill it (and
239
+ * LOGNAME) from the account this process runs as. Never overrides a USER
240
+ * already set. Pure.
241
+ */
242
+ export declare function withOsUser(env: NodeJS.ProcessEnv, name?: string | null): NodeJS.ProcessEnv;
222
243
  /** The core worker HQ's setup bot runs. */
223
244
  export declare const SETUP_WORKER_ID = "setup";
224
245
  /**
@@ -50,6 +50,7 @@ import { ownerContextBlock } from "./owner-context.js";
50
50
  import { randomUUID } from "node:crypto";
51
51
  import { hasPromotionHold } from "./promotion-hold.js";
52
52
  import * as fs from "node:fs";
53
+ import * as os from "node:os";
53
54
  import * as path from "node:path";
54
55
  import { fullJitterDelayMs } from "../mesh/live/backoff.js";
55
56
  import { acquirePidLock, defaultPidLockDeps, releasePidLock } from "../mesh/live/daemon/pid-lock.js";
@@ -82,14 +83,15 @@ import { resolveRoomMentionsForBot, roomPostHasMentionTokens, } from "./room-men
82
83
  import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
83
84
  import { botJobQuietOptionEnabled } from "./job-quiet-flag.js";
84
85
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
85
- import { SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
86
+ import { SIGN_IN_CHECK_FRESH_MS, SIGN_IN_CHECK_MS, SIGN_IN_RETRY_MS, signInNeededText, signedOutExcerpt, } from "./runtime-sign-in.js";
87
+ import { checkRuntimeSignIn } from "./runtime-sign-in-check.js";
86
88
  import { classifyRuntimeError, classifyRuntimeFailure, runtimeDidNotFinishText, runtimeFailureReason, runtimeFailureText, runtimeRepairNotice, withRetrySuggestion, } from "./runtime-failure.js";
87
89
  import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
88
90
  import { captureHeartbeatIdentityMismatch, identityMismatchMessage, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
89
91
  import { clearBotSession, ensureBotCompanyScope, readBotSession, writeBotSession } from "./session.js";
90
92
  import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
91
93
  import { CLI_VERSION } from "../../cli-version.js";
92
- import { CLI_UPDATE_RESTART_EXIT_CODE } from "./cli-update.js";
94
+ import { CLI_UPDATE_RESTART_EXIT_CODE, describeCliUpdate } from "./cli-update.js";
93
95
  import { createInboxMqttWakeFlagReader, createInboxPollBackoffFlagReader, safeErrorClass } from "./inbox-poll-backoff-flag.js";
94
96
  import { createInboxWakeWatcher } from "./inbox-wake.js";
95
97
  import { workMeshRoot } from "../mesh/live/paths.js";
@@ -195,6 +197,29 @@ export function turnEnv(base, kind, agentUid, companySlug) {
195
197
  }
196
198
  return env;
197
199
  }
200
+ /** The account name this process runs as, or null when the OS cannot say. */
201
+ export function osUserName() {
202
+ try {
203
+ const name = os.userInfo().username?.trim();
204
+ return name || null;
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ }
210
+ /**
211
+ * Claude Code keys its macOS Keychain login on `$USER` (falling back to the
212
+ * literal "unknown"), so a tool spawned from an environment without USER
213
+ * reads a different Keychain item than the owner's terminal and is signed
214
+ * out. launchd and the HQ app's launcher may leave it unset; fill it (and
215
+ * LOGNAME) from the account this process runs as. Never overrides a USER
216
+ * already set. Pure.
217
+ */
218
+ export function withOsUser(env, name = osUserName()) {
219
+ if (env.USER?.trim() || !name)
220
+ return env;
221
+ return { ...env, USER: name, ...(env.LOGNAME?.trim() ? {} : { LOGNAME: name }) };
222
+ }
198
223
  /** The core worker HQ's setup bot runs. */
199
224
  export const SETUP_WORKER_ID = SETUP_BOT_WORKER_ID;
200
225
  /**
@@ -495,7 +520,7 @@ export async function runBot(deps) {
495
520
  cliUpdatePending = update;
496
521
  stopping = true;
497
522
  const busy = running.size;
498
- log("info", `hq-cli updated ${update.from} -> ${update.to}; restarting to load it${busy ? ` after ${busy} running turn${busy === 1 ? "" : "s"} finish${busy === 1 ? "es" : ""}` : ""}`);
523
+ log("info", `${describeCliUpdate(update)}${busy ? ` after ${busy} running turn${busy === 1 ? "" : "s"} finish${busy === 1 ? "es" : ""}` : ""}`);
499
524
  return true;
500
525
  };
501
526
  // ── Preflight: creds, pid lock, owner verification ─────────────────────────
@@ -714,14 +739,17 @@ export async function runBot(deps) {
714
739
  /** Conversations already told this time that the bot needs a sign-in. */
715
740
  const toldAboutSignIn = new Set();
716
741
  const isSignInFailure = (excerpt) => classifyRuntimeFailure(excerpt) === "signed-out";
717
- const markSignInBroken = (excerpt) => {
718
- nextSignInRetryAt = now().getTime() + signInRetryMs;
742
+ const markSignInBroken = (excerpt, state = "expired", bump = true) => {
743
+ if (bump)
744
+ nextSignInRetryAt = now().getTime() + signInRetryMs;
719
745
  modelHealth = "degraded";
720
746
  if (signInIssue)
721
747
  return;
722
- signInIssue = { state: "expired", runtime: config.runtime, since: now().toISOString() };
748
+ signInIssue = { state, runtime: config.runtime, since: now().toISOString() };
723
749
  setStatus({ runtimeSignIn: signInIssue, lastError: excerpt.slice(0, 400) });
724
- log("warn", `${runtime.id} needs to sign in again on this computer; holding turns and retrying every ${Math.round(signInRetryMs / 1000)}s`);
750
+ log("warn", state === "signed-out"
751
+ ? `${runtime.id} is not signed in on this computer (its own check); holding turns and checking every ${Math.round(signInCheckMs / 1000)}s`
752
+ : `${runtime.id} needs to sign in again on this computer; holding turns and retrying every ${Math.round(signInRetryMs / 1000)}s`);
725
753
  };
726
754
  const markSignInWorks = () => {
727
755
  if (!signInIssue)
@@ -731,6 +759,70 @@ export async function runBot(deps) {
731
759
  setStatus({ runtimeSignIn: null });
732
760
  log("info", `${runtime.id} sign-in works again; resuming`);
733
761
  };
762
+ // ── The bot's own sign-in check ────────────────────────────────────────────
763
+ // The tool is asked in the turn's environment (not the owner's shell): at
764
+ // start, before a turn, and on a timer while it says signed out, so a
765
+ // message is never spent on a turn that cannot start and the app can read
766
+ // the answer before it sends a job.
767
+ const signInCheckMs = Math.max(0, deps.signInCheckMs ?? SIGN_IN_CHECK_MS);
768
+ const checkSignIn = deps.checkSignIn ?? ((env) => checkRuntimeSignIn({ runtime, env, cwd: turnCwd, now }));
769
+ let lastSignInCheck = null;
770
+ let nextSignInCheckAt = 0;
771
+ /** Ask the tool; record the answer in status.json; return it. */
772
+ const runSignInCheck = async (reason) => {
773
+ const env = withOsUser(turnEnv(process.env, kind, config.agentUid, companySlug));
774
+ let check;
775
+ try {
776
+ check = await checkSignIn(env);
777
+ }
778
+ catch (err) {
779
+ check = { signedIn: null, checkedAt: now().toISOString(), detail: err instanceof Error ? err.message : String(err) };
780
+ }
781
+ lastSignInCheck = check;
782
+ nextSignInCheckAt = now().getTime() + signInCheckMs;
783
+ setStatus({ runtimeSignInCheck: check });
784
+ if (check.signedIn === false) {
785
+ // A repeat "no" while already held does not push the retry out again:
786
+ // the hold's own timer decides when a real turn gets to disagree.
787
+ markSignInBroken(signedOutExcerpt(runtime.id), "signed-out", signInIssue == null);
788
+ }
789
+ else if (check.signedIn === true) {
790
+ if (signInIssue) {
791
+ // The tool answers for itself now: the held messages run at once.
792
+ nextSignInRetryAt = 0;
793
+ markSignInWorks();
794
+ }
795
+ }
796
+ else if (check.detail) {
797
+ log("debug", `sign-in check (${reason}) could not tell: ${check.detail}`);
798
+ }
799
+ return check;
800
+ };
801
+ /** When a real turn last ran despite the tool saying "signed out". */
802
+ let lastTurnDespiteSignedOutAt = now().getTime();
803
+ /**
804
+ * Before a turn: a fresh "signed in" stands; anything else is asked again.
805
+ * The tool's status command only knows about its own account sign-in, so a
806
+ * tool that works some other way (an API key, Bedrock, Vertex) says "no"
807
+ * and would be held forever. Once per retry window a real turn runs anyway
808
+ * and its outcome decides: success clears the hold, a sign-in failure
809
+ * keeps it.
810
+ */
811
+ const signedOutBeforeTurn = async () => {
812
+ const fresh = lastSignInCheck?.signedIn === true && now().getTime() - new Date(lastSignInCheck.checkedAt).getTime() < SIGN_IN_CHECK_FRESH_MS;
813
+ if (fresh)
814
+ return false;
815
+ const check = await runSignInCheck("turn");
816
+ if (check.signedIn !== false)
817
+ return false;
818
+ const t = now().getTime();
819
+ if (signInRetryMs > 0 && t - lastTurnDespiteSignedOutAt >= signInRetryMs) {
820
+ lastTurnDespiteSignedOutAt = t;
821
+ log("info", `${runtime.id} still says it is not signed in; letting one turn try so a tool signed in another way is not held forever`);
822
+ return false;
823
+ }
824
+ return true;
825
+ };
734
826
  /** Say once per conversation that the bot cannot work until its tool signs in. */
735
827
  const tellSignInNeeded = async (conversation, post, kickoff = false, retryOf) => {
736
828
  if (toldAboutSignIn.has(conversation))
@@ -741,7 +833,7 @@ export async function runBot(deps) {
741
833
  runtime: config.runtime,
742
834
  botName: config.name,
743
835
  context: kickoff ? "kickoff" : "message",
744
- text: signInNeededText(config.runtime, kickoff),
836
+ text: signInNeededText(config.runtime, kickoff, signInIssue?.state ?? "expired"),
745
837
  ...(retryOf ? { retryOf } : {}),
746
838
  }));
747
839
  toldAboutSignIn.add(conversation);
@@ -955,7 +1047,7 @@ export async function runBot(deps) {
955
1047
  // resets). Claude gets the full hook set via its payload session id;
956
1048
  // Codex/Grok only see HQ_SESSION_ID.
957
1049
  const sid = session?.sessionId ?? randomUUID();
958
- const env = turnEnv(process.env, kind, config.agentUid, companySlug);
1050
+ const env = withOsUser(turnEnv(process.env, kind, config.agentUid, companySlug));
959
1051
  let bind = { env };
960
1052
  if (companySlug) {
961
1053
  try {
@@ -966,6 +1058,12 @@ export async function runBot(deps) {
966
1058
  }
967
1059
  bind = { newSessionId: sid, env: { ...env, HQ_SESSION_ID: sid } };
968
1060
  }
1061
+ // The tool's own word first: a turn on a signed-out tool cannot start,
1062
+ // and its failure text varies by version (on 2026-10-28 it was a bare
1063
+ // "unknown error" that read as a crash). Held like any sign-in failure.
1064
+ if (await signedOutBeforeTurn()) {
1065
+ throw new RuntimeError(`${runtime.id} is not signed in on this computer`, signedOutExcerpt(runtime.id), 1, { started: false, durationMs: 0 });
1066
+ }
969
1067
  const tTurnStart = now().getTime();
970
1068
  // Model and thinking level are read fresh each turn, so a change from the
971
1069
  // bot's profile (`hq bot set`) applies to the next message without a restart.
@@ -1020,6 +1118,14 @@ export async function runBot(deps) {
1020
1118
  setStatus({ runtimeModelFallback: null });
1021
1119
  }
1022
1120
  log("info", `model turn done in ${Math.round((now().getTime() - tTurnStart) / 1000)}s (${Math.round((tTurnStart - tReceived) / 1000)}s before it)`);
1121
+ // Clears a reported runtime crash (runtimeCrashSinceStart): the tool works again.
1122
+ // Stamped with when the turn STARTED, so a long turn begun before a crash
1123
+ // that finishes after it does not clear the crash for turns that follow.
1124
+ // Never moves backwards: two turns run side by side, and an older one
1125
+ // finishing last must not bring back a crash a newer turn already cleared.
1126
+ const prevOk = Date.parse(readStatus()?.lastTurnOkAt ?? "");
1127
+ if (!Number.isFinite(prevOk) || tTurnStart > prevOk)
1128
+ setStatus({ lastTurnOkAt: new Date(tTurnStart).toISOString() });
1023
1129
  // A brief job's session is one run long; nothing resumes it.
1024
1130
  if (turn.sessionId && !isBriefJobSessionScope(sessionScope)) {
1025
1131
  writeBotSession(dir, {
@@ -2431,6 +2537,10 @@ export async function runBot(deps) {
2431
2537
  attempts.delete(id);
2432
2538
  log("error", `turn on ${id} ${outcome.aborted ? "stopped by the owner" : "failed"} after it started; told the owner, not re-run`);
2433
2539
  };
2540
+ // The bot's first look at its tool's sign-in, so `hq bot status` and the
2541
+ // app know before any message arrives. A tool that cannot be asked changes
2542
+ // nothing; the first turn then reports for itself.
2543
+ await runSignInCheck("start");
2434
2544
  // The kickoff runs alongside the heartbeat, before the first inbox poll, so
2435
2545
  // an owner message sent meanwhile is answered after it in the same session.
2436
2546
  const kickoff = kickoffAfterIntro
@@ -2532,6 +2642,13 @@ export async function runBot(deps) {
2532
2642
  }
2533
2643
  if (modelHoldUntil)
2534
2644
  liftModelHold();
2645
+ // While the tool says it is signed out, ask it again on a timer (cheap,
2646
+ // no turn): the moment it is signed in, the held messages run.
2647
+ if (signInIssue && busyLanes.size === 0 && now().getTime() >= nextSignInCheckAt) {
2648
+ await runSignInCheck("watch");
2649
+ if (!signInIssue)
2650
+ pump();
2651
+ }
2535
2652
  // A kickoff held for a sign-in retries on the same timer as messages.
2536
2653
  if (busyLanes.size === 0 &&
2537
2654
  (!signInIssue || now().getTime() >= nextSignInRetryAt) &&
@@ -2662,7 +2779,7 @@ export async function runBot(deps) {
2662
2779
  catch (err) {
2663
2780
  log("warn", `could not record the update restart: ${err instanceof Error ? err.message : String(err)}`);
2664
2781
  }
2665
- void finish("stopped", CLI_UPDATE_RESTART_EXIT_CODE, `hq-cli updated ${update.from} -> ${update.to}; restarting to load it`, () => deps.onCliUpdateRestart?.(update, log));
2782
+ void finish("stopped", CLI_UPDATE_RESTART_EXIT_CODE, describeCliUpdate(update), () => deps.onCliUpdateRestart?.(update, log));
2666
2783
  });
2667
2784
  return { stop, done, status: readStatus, idle: () => pending.size === 0 };
2668
2785
  }
@@ -15,7 +15,7 @@
15
15
  * carries the final text and session_id.
16
16
  * Verified against Claude Code 2.1.258 on 2026-09-12 (fixtures/claude-stream.ndjson).
17
17
  */
18
- import type { BotRuntime } from "./types.js";
18
+ import type { BotRuntime, SpawnResult } from "./types.js";
19
19
  export declare const CLAUDE_PERMISSION_MODE_ENV = "HQ_BOT_CLAUDE_PERMISSION_MODE";
20
20
  export declare const DEFAULT_CLAUDE_PERMISSION_MODE = "bypassPermissions";
21
21
  export declare const GATED_CLAUDE_PERMISSION_MODE = "acceptEdits";
@@ -84,4 +84,10 @@ export interface ClaudeRuntimeOptions {
84
84
  systemBinDirs?: readonly string[];
85
85
  }
86
86
  export declare function createClaudeRuntime(env?: NodeJS.ProcessEnv, opts?: ClaudeRuntimeOptions): BotRuntime;
87
+ /**
88
+ * `claude auth status --json` prints `{"loggedIn": true|false, ...}` and
89
+ * exits 0 either way. Null when the output is not that (an older CLI, or a
90
+ * tool that could not run at all).
91
+ */
92
+ export declare function parseClaudeAuthStatus(result: SpawnResult): boolean | null;
87
93
  //# sourceMappingURL=claude.d.ts.map
@@ -230,6 +230,35 @@ export function createClaudeRuntime(env = process.env, opts = {}) {
230
230
  failureReason(stdout) {
231
231
  return messagesStreamFailure(stdout);
232
232
  },
233
+ signInStatus: { args: ["auth", "status", "--json"], parse: parseClaudeAuthStatus },
233
234
  };
234
235
  }
236
+ /**
237
+ * `claude auth status --json` prints `{"loggedIn": true|false, ...}` and
238
+ * exits 0 either way. Null when the output is not that (an older CLI, or a
239
+ * tool that could not run at all).
240
+ */
241
+ export function parseClaudeAuthStatus(result) {
242
+ const text = result.stdout ?? "";
243
+ const start = text.indexOf("{");
244
+ if (start >= 0) {
245
+ try {
246
+ const parsed = JSON.parse(text.slice(start));
247
+ if (typeof parsed.loggedIn === "boolean")
248
+ return parsed.loggedIn;
249
+ }
250
+ catch {
251
+ /* fall through to the loose match */
252
+ }
253
+ }
254
+ const m = /"loggedIn"\s*:\s*(true|false)/.exec(text);
255
+ if (m)
256
+ return m[1] === "true";
257
+ const plain = `${text}\n${result.stderr ?? ""}`;
258
+ if (/\bnot logged in\b/i.test(plain))
259
+ return false;
260
+ if (/\blogged in\b/i.test(plain))
261
+ return true;
262
+ return null;
263
+ }
235
264
  //# sourceMappingURL=claude.js.map