@indigoai-us/hq-cli 5.345.155 → 5.345.159

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 (169) hide show
  1. package/CHANGELOG.md +289 -0
  2. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +110 -2
  3. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +101 -5
  4. package/assets/scaffold/core/scripts/worktree.sh +4 -0
  5. package/dist/command-catalog.generated.d.ts +536 -7
  6. package/dist/command-catalog.generated.js +676 -24
  7. package/dist/command-registration-plan.d.ts +18 -0
  8. package/dist/command-registration-plan.js +4 -0
  9. package/dist/commands/_patterns.d.ts +17 -0
  10. package/dist/commands/_patterns.js +22 -0
  11. package/dist/commands/agent-inbox.js +2 -2
  12. package/dist/commands/agent-kit.js +8 -6
  13. package/dist/commands/bot.js +7 -4
  14. package/dist/commands/channel-suggest.d.ts +56 -0
  15. package/dist/commands/channel-suggest.js +170 -0
  16. package/dist/commands/channels.js +7 -1
  17. package/dist/commands/cloud.d.ts +45 -0
  18. package/dist/commands/cloud.js +150 -10
  19. package/dist/commands/company.js +4 -3
  20. package/dist/commands/core-checkpoint-native.d.ts +18 -0
  21. package/dist/commands/core-checkpoint-native.js +67 -2
  22. package/dist/commands/core-pipeline-driver-native.d.ts +12 -1
  23. package/dist/commands/core-pipeline-driver-native.js +131 -5
  24. package/dist/commands/core.js +13 -1
  25. package/dist/commands/crm.js +83 -6
  26. package/dist/commands/deploy-access.d.ts +33 -0
  27. package/dist/commands/deploy-access.js +322 -0
  28. package/dist/commands/dm-attach.d.ts +106 -0
  29. package/dist/commands/dm-attach.js +184 -0
  30. package/dist/commands/dm.d.ts +37 -0
  31. package/dist/commands/dm.js +172 -10
  32. package/dist/commands/feedback-show.d.ts +40 -0
  33. package/dist/commands/feedback-show.js +261 -0
  34. package/dist/commands/feedback.d.ts +21 -0
  35. package/dist/commands/feedback.js +152 -2
  36. package/dist/commands/files-browse.d.ts +16 -0
  37. package/dist/commands/files-browse.js +52 -1
  38. package/dist/commands/files.d.ts +24 -0
  39. package/dist/commands/files.js +52 -1
  40. package/dist/commands/integrations-connect.js +7 -3
  41. package/dist/commands/integrations-core.d.ts +13 -1
  42. package/dist/commands/integrations-core.js +49 -4
  43. package/dist/commands/integrations.js +4 -2
  44. package/dist/commands/lanes-accounts.js +16 -1
  45. package/dist/commands/lanes-loop.js +14 -4
  46. package/dist/commands/lanes-message.js +6 -4
  47. package/dist/commands/lanes-resume.js +36 -13
  48. package/dist/commands/lanes-shared.d.ts +5 -0
  49. package/dist/commands/lanes-shared.js +13 -3
  50. package/dist/commands/lanes-watch.js +1 -0
  51. package/dist/commands/lanes.d.ts +20 -1
  52. package/dist/commands/lanes.js +201 -51
  53. package/dist/commands/mesh.js +149 -1
  54. package/dist/commands/monitor.d.ts +1 -0
  55. package/dist/commands/monitor.js +4 -1
  56. package/dist/commands/repo.d.ts +41 -0
  57. package/dist/commands/repo.js +331 -0
  58. package/dist/commands/scan.d.ts +3 -1
  59. package/dist/commands/scan.js +7 -3
  60. package/dist/commands/secrets.d.ts +37 -0
  61. package/dist/commands/secrets.js +185 -16
  62. package/dist/commands/signals.js +17 -5
  63. package/dist/commands/staff/listings.d.ts +41 -0
  64. package/dist/commands/staff/listings.js +160 -0
  65. package/dist/commands/staff/reset-test-account.d.ts +3 -2
  66. package/dist/commands/staff/reset-test-account.js +4 -3
  67. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2629 -1036
  68. package/dist/hq-cloud-sync-runner/package.json +1 -1
  69. package/dist/lib/agent-kit/inbox-state.d.ts +7 -0
  70. package/dist/lib/agent-kit/inbox-state.js +5 -1
  71. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  72. package/dist/lib/agent-kit/run/inbox.js +2 -1
  73. package/dist/lib/agent-kit/wake-secrets.d.ts +7 -0
  74. package/dist/lib/agent-kit/wake-secrets.js +32 -0
  75. package/dist/lib/agent-kit/wake.d.ts +4 -0
  76. package/dist/lib/agent-kit/wake.js +49 -3
  77. package/dist/lib/bot/inflight.d.ts +7 -1
  78. package/dist/lib/bot/inflight.js +10 -2
  79. package/dist/lib/bot/prompt.js +3 -0
  80. package/dist/lib/bot/run.js +72 -20
  81. package/dist/lib/bot/session.d.ts +14 -0
  82. package/dist/lib/bot/session.js +70 -0
  83. package/dist/lib/capacity/collectors/claude-oauth.d.ts +6 -0
  84. package/dist/lib/capacity/collectors/claude-oauth.js +12 -0
  85. package/dist/lib/capacity/lane-login.d.ts +6 -0
  86. package/dist/lib/capacity/lane-login.js +5 -0
  87. package/dist/lib/core-utils/worktree.js +2 -0
  88. package/dist/lib/daemon/exit-cause.d.ts +43 -0
  89. package/dist/lib/daemon/exit-cause.js +88 -0
  90. package/dist/lib/daemon/handoff.d.ts +2 -0
  91. package/dist/lib/daemon/handoff.js +6 -1
  92. package/dist/lib/daemon/hq-child-bootstrap.mjs +20 -3
  93. package/dist/lib/daemon/paths.d.ts +2 -0
  94. package/dist/lib/daemon/paths.js +1 -0
  95. package/dist/lib/daemon/run.js +13 -0
  96. package/dist/lib/daemon/supervisor.d.ts +54 -1
  97. package/dist/lib/daemon/supervisor.js +297 -21
  98. package/dist/lib/daemon/unit-holds.d.ts +22 -0
  99. package/dist/lib/daemon/unit-holds.js +86 -0
  100. package/dist/lib/doctor/checks/sync-health.d.ts +9 -0
  101. package/dist/lib/doctor/checks/sync-health.js +66 -1
  102. package/dist/lib/doctor/company-resolution-state.d.ts +30 -0
  103. package/dist/lib/doctor/company-resolution-state.js +89 -0
  104. package/dist/lib/doctor/fix/apply.d.ts +36 -6
  105. package/dist/lib/doctor/fix/apply.js +128 -12
  106. package/dist/lib/doctor/types.d.ts +1 -1
  107. package/dist/lib/doctor/types.js +1 -0
  108. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  109. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  110. package/dist/lib/hook-engine/resolve.js +6 -2
  111. package/dist/lib/index-render/company-knowledge.d.ts +1 -1
  112. package/dist/lib/index-render/company-knowledge.js +4 -5
  113. package/dist/lib/index-render/index.js +44 -3
  114. package/dist/lib/index-render/projects.d.ts +1 -1
  115. package/dist/lib/index-render/projects.js +5 -5
  116. package/dist/lib/lanes/account-company.d.ts +28 -0
  117. package/dist/lib/lanes/account-company.js +53 -0
  118. package/dist/lib/lanes/accounts.d.ts +22 -3
  119. package/dist/lib/lanes/accounts.js +38 -2
  120. package/dist/lib/lanes/claude-balance.d.ts +6 -1
  121. package/dist/lib/lanes/claude-balance.js +6 -1
  122. package/dist/lib/lanes/claude-credential.d.ts +78 -0
  123. package/dist/lib/lanes/claude-credential.js +163 -0
  124. package/dist/lib/lanes/compaction.d.ts +2 -0
  125. package/dist/lib/lanes/compaction.js +9 -0
  126. package/dist/lib/lanes/config.js +6 -0
  127. package/dist/lib/lanes/envelope-repair-dispatch.js +2 -1
  128. package/dist/lib/lanes/hooks-install.js +2 -1
  129. package/dist/lib/lanes/host-capacity.d.ts +2 -0
  130. package/dist/lib/lanes/host-capacity.js +1 -0
  131. package/dist/lib/lanes/keep-alive.d.ts +1 -1
  132. package/dist/lib/lanes/keep-alive.js +3 -1
  133. package/dist/lib/lanes/links.js +10 -4
  134. package/dist/lib/lanes/loop.js +16 -1
  135. package/dist/lib/lanes/node-binary.d.ts +17 -0
  136. package/dist/lib/lanes/node-binary.js +30 -0
  137. package/dist/lib/lanes/outpost-dispatch.d.ts +2 -1
  138. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  139. package/dist/lib/lanes/reconcile.js +4 -0
  140. package/dist/lib/lanes/remote-control-lane.js +2 -0
  141. package/dist/lib/lanes/spawn.d.ts +3 -0
  142. package/dist/lib/lanes/spawn.js +119 -9
  143. package/dist/lib/lanes/store.d.ts +6 -0
  144. package/dist/lib/lanes/store.js +107 -18
  145. package/dist/lib/lanes/types.d.ts +2 -0
  146. package/dist/lib/lanes/watch.js +28 -5
  147. package/dist/lib/lanes/watcher-main.js +4 -3
  148. package/dist/lib/lanes/watcher.d.ts +4 -0
  149. package/dist/lib/lanes/watcher.js +110 -21
  150. package/dist/lib/mesh/api.d.ts +32 -0
  151. package/dist/lib/mesh/api.js +75 -0
  152. package/dist/lib/secret-scan/detect.js +9 -0
  153. package/dist/lib/secret-scan/repo-config.d.ts +41 -0
  154. package/dist/lib/secret-scan/repo-config.js +237 -0
  155. package/dist/lib/secret-scan/rules.d.ts +7 -1
  156. package/dist/lib/secret-scan/rules.js +3 -1
  157. package/dist/lib/secret-scan/staged.d.ts +5 -0
  158. package/dist/lib/secret-scan/staged.js +19 -2
  159. package/dist/lib/secret-scan/types.d.ts +2 -0
  160. package/dist/lib/sync/server-owned-local-only.d.ts +29 -0
  161. package/dist/lib/sync/server-owned-local-only.js +75 -0
  162. package/dist/sentry-before-send.js +1 -0
  163. package/dist/utils/access-denied-hint.js +6 -0
  164. package/dist/utils/client-health.d.ts +34 -0
  165. package/dist/utils/client-health.js +96 -3
  166. package/dist/utils/package-use-lease.js +27 -9
  167. package/dist/utils/version-gate.d.ts +7 -0
  168. package/dist/utils/version-gate.js +66 -0
  169. package/package.json +8 -8
@@ -287,6 +287,12 @@ export declare function tokenFingerprint(token: string): string;
287
287
  export declare function readClaudeRefreshLast(root: string, home: string): ClaudeRefreshLastEntry | null;
288
288
  /** @deprecated prefer {@link revokedMarkerForCurrentCredential}; this is the pre-fingerprint shim. */
289
289
  export declare function isAccountRevokedPersisted(root: string, accountId: string): boolean;
290
+ /**
291
+ * True when the capacity collector has recorded this refresh token as
292
+ * revoked (by token fingerprint, under any account). Lanes use it to tell a
293
+ * dead sign-in from an access token that Claude Code will renew at startup.
294
+ */
295
+ export declare function isRefreshTokenRevoked(root: string, refreshToken: string): boolean;
290
296
  /**
291
297
  * Compare the persisted revoked marker (if any) against the refresh token the
292
298
  * caller is about to use.
@@ -1556,6 +1556,18 @@ export function isAccountRevokedPersisted(root, accountId) {
1556
1556
  const file = readRefreshLast(root);
1557
1557
  return Object.prototype.hasOwnProperty.call(file.revokedAccounts, accountId);
1558
1558
  }
1559
+ /**
1560
+ * True when the capacity collector has recorded this refresh token as
1561
+ * revoked (by token fingerprint, under any account). Lanes use it to tell a
1562
+ * dead sign-in from an access token that Claude Code will renew at startup.
1563
+ */
1564
+ export function isRefreshTokenRevoked(root, refreshToken) {
1565
+ const file = readRefreshLast(root);
1566
+ const fp = tokenFingerprint(refreshToken);
1567
+ if (file.revokedTokens[fp] !== undefined)
1568
+ return true;
1569
+ return Object.values(file.revokedAccounts).some((entry) => entry.fingerprint === fp);
1570
+ }
1559
1571
  /**
1560
1572
  * Compare the persisted revoked marker (if any) against the refresh token the
1561
1573
  * caller is about to use.
@@ -20,6 +20,12 @@ export interface LaneLoginInput {
20
20
  * login confirms sign-in through this probe. Defaults to the lanes probe.
21
21
  */
22
22
  probeProvider?: Ctx["probeProvider"];
23
+ /**
24
+ * Claude access-token expiry check for the post-login status. Defaults to
25
+ * the real Keychain/file read when the default probe is used; a caller that
26
+ * injects a probe passes its own reader or gets no expiry check.
27
+ */
28
+ readClaudeCredential?: Ctx["readClaudeCredential"];
23
29
  }
24
30
  export interface LaneLoginResult {
25
31
  id: string;
@@ -13,6 +13,7 @@ import { createLaneAccountsHandlers } from "../../commands/lanes-accounts.js";
13
13
  import { effectiveAccountHome } from "../lanes/account-home.js";
14
14
  import { canonicalizeHome, DEFAULT_ACCOUNT_HOMES, loadLanesConfig } from "../lanes/config.js";
15
15
  import { resolveLanesHqRoot } from "../lanes/hq-root.js";
16
+ import { readLaneClaudeCredential } from "../lanes/claude-credential.js";
16
17
  import { defaultProbe } from "../lanes/models.js";
17
18
  import { parseLaneProvider } from "../lanes/types.js";
18
19
  const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -108,6 +109,10 @@ function loginCtx(input, hqRoot) {
108
109
  existsSync: (target) => fs.existsSync(target),
109
110
  spawnLogin: defaultHomeEnv(input.spawnLogin ?? defaultSpawnLogin, input.homedir ?? os.homedir),
110
111
  probeProvider: defaultHomeProbe(input.probeProvider ?? defaultProbe, input.homedir ?? os.homedir),
112
+ // Always a real field: the Proxy below turns a missing one into a throwing
113
+ // function, which accountStatus would call and report as a failed check.
114
+ readClaudeCredential: input.readClaudeCredential ??
115
+ (input.probeProvider === undefined ? readLaneClaudeCredential : undefined),
111
116
  };
112
117
  return new Proxy(fields, {
113
118
  get(target, prop) {
@@ -42,6 +42,8 @@ export function createWorktree(args = [], options = {}) {
42
42
  }
43
43
  }
44
44
  const die = (message) => { line(stderr, `ERROR: ${message}`); return 1; };
45
+ // Accept "--base origin/<branch>" as well as "--base <branch>"; fetch and ref lookups add origin/ themselves.
46
+ base = base.replace(/^origin\//, "");
45
47
  if (!name)
46
48
  return die("--name is required (kebab-case slug, e.g. 'fix-login-bug')");
47
49
  if (name.includes("/") || name.includes(" "))
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Classifies why a supervised long-running unit stopped, so the supervisor can
3
+ * tell a failure that a restart will not fix (permanent) from one that may
4
+ * clear on its own (transient).
5
+ *
6
+ * Permanent causes stop the restart loop and hold the unit until a manual
7
+ * `hq daemon restart <unit>`, a command change, or a CLI update. Anything not
8
+ * recognised is transient, so an unknown failure keeps the bounded retry.
9
+ *
10
+ * Output is never substring-matched. Only npm's own diagnostic line
11
+ * (`npm error code <CODE>` from npm 10+, `npm ERR! code <CODE>` from older
12
+ * npm) counts, and only for a code in PERMANENT_NPM_CODES. The Desktop app
13
+ * uses the same prefixes and list.
14
+ */
15
+ export type PermanentExitCause = "missing-binary" | "not-executable" | "package-resolution" | "engine-mismatch" | "permission-denied" | "bad-config" | "auth-refused";
16
+ export type ExitCause = {
17
+ kind: "transient";
18
+ } | {
19
+ kind: "permanent";
20
+ cause: PermanentExitCause;
21
+ detail: string;
22
+ };
23
+ export interface ExitEvidence {
24
+ code: number | null;
25
+ signal: string | null;
26
+ /** The launch error, when the process could not be started. */
27
+ spawnError?: unknown;
28
+ /** Output the process wrote during this run only (not earlier runs). */
29
+ outputTail?: string;
30
+ }
31
+ /**
32
+ * npm error codes that a retry cannot fix without a change by a person.
33
+ * Network and cache codes (ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN,
34
+ * ECONNREFUSED, EINTEGRITY, EPERM, EBUSY, ENOSPC) stay transient on purpose.
35
+ */
36
+ export declare const PERMANENT_NPM_CODES: Readonly<Record<string, PermanentExitCause>>;
37
+ /** The first npm error code line naming a permanent code, as `{ code, line }`. */
38
+ export declare function permanentNpmCode(output: string): {
39
+ code: string;
40
+ cause: PermanentExitCause;
41
+ } | undefined;
42
+ export declare function classifyExit(evidence: ExitEvidence): ExitCause;
43
+ //# sourceMappingURL=exit-cause.d.ts.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Classifies why a supervised long-running unit stopped, so the supervisor can
3
+ * tell a failure that a restart will not fix (permanent) from one that may
4
+ * clear on its own (transient).
5
+ *
6
+ * Permanent causes stop the restart loop and hold the unit until a manual
7
+ * `hq daemon restart <unit>`, a command change, or a CLI update. Anything not
8
+ * recognised is transient, so an unknown failure keeps the bounded retry.
9
+ *
10
+ * Output is never substring-matched. Only npm's own diagnostic line
11
+ * (`npm error code <CODE>` from npm 10+, `npm ERR! code <CODE>` from older
12
+ * npm) counts, and only for a code in PERMANENT_NPM_CODES. The Desktop app
13
+ * uses the same prefixes and list.
14
+ */
15
+ /**
16
+ * npm error codes that a retry cannot fix without a change by a person.
17
+ * Network and cache codes (ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN,
18
+ * ECONNREFUSED, EINTEGRITY, EPERM, EBUSY, ENOSPC) stay transient on purpose.
19
+ */
20
+ export const PERMANENT_NPM_CODES = {
21
+ ETARGET: "package-resolution", // no version matches the requested range or tag
22
+ E404: "package-resolution", // package or version not in the registry
23
+ EINVALIDPACKAGENAME: "package-resolution",
24
+ EUNSUPPORTEDPROTOCOL: "package-resolution", // unsupported spec, e.g. a bad URL scheme
25
+ EBADENGINE: "engine-mismatch", // engine-strict and node does not satisfy engines
26
+ EACCES: "permission-denied", // cache or prefix not writable by this user
27
+ E401: "auth-refused",
28
+ E403: "auth-refused",
29
+ ENEEDAUTH: "auth-refused",
30
+ };
31
+ const NPM_CODE_PREFIXES = ["npm error code ", "npm ERR! code "];
32
+ /** sysexits.h EX_NOPERM / EX_CONFIG, and the shell's not-executable / not-found codes. */
33
+ const EXIT_CODE_CAUSES = {
34
+ 77: { cause: "auth-refused", detail: "exit code 77 (permission or auth refused)" },
35
+ 78: { cause: "bad-config", detail: "exit code 78 (configuration error)" },
36
+ 126: { cause: "not-executable", detail: "exit code 126 (command not executable)" },
37
+ 127: { cause: "missing-binary", detail: "exit code 127 (command not found)" },
38
+ };
39
+ /** Launch errno codes, read from the error's `code` field only. */
40
+ const SPAWN_ERROR_CAUSES = {
41
+ ENOENT: "missing-binary",
42
+ EACCES: "not-executable",
43
+ ENOEXEC: "not-executable",
44
+ };
45
+ function errorCode(err) {
46
+ if (err && typeof err === "object" && "code" in err && typeof err.code === "string") {
47
+ return err.code;
48
+ }
49
+ return undefined;
50
+ }
51
+ /** The first npm error code line naming a permanent code, as `{ code, line }`. */
52
+ export function permanentNpmCode(output) {
53
+ for (const raw of output.split(/\r?\n/)) {
54
+ const line = raw.trim();
55
+ for (const prefix of NPM_CODE_PREFIXES) {
56
+ if (!line.startsWith(prefix))
57
+ continue;
58
+ const code = line.slice(prefix.length).split(/\s+/)[0] ?? "";
59
+ const cause = Object.prototype.hasOwnProperty.call(PERMANENT_NPM_CODES, code) ? PERMANENT_NPM_CODES[code] : undefined;
60
+ if (cause)
61
+ return { code, cause };
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+ export function classifyExit(evidence) {
67
+ if (evidence.spawnError !== undefined) {
68
+ const code = errorCode(evidence.spawnError);
69
+ const cause = code ? SPAWN_ERROR_CAUSES[code] : undefined;
70
+ if (cause)
71
+ return { kind: "permanent", cause, detail: `could not start (${code})` };
72
+ }
73
+ // A process killed by a signal says nothing about its own configuration.
74
+ if (evidence.signal)
75
+ return { kind: "transient" };
76
+ if (evidence.code === 0)
77
+ return { kind: "transient" };
78
+ if (evidence.code !== null) {
79
+ const byCode = EXIT_CODE_CAUSES[evidence.code];
80
+ if (byCode)
81
+ return { kind: "permanent", ...byCode };
82
+ }
83
+ const npm = evidence.outputTail ? permanentNpmCode(evidence.outputTail) : undefined;
84
+ if (npm)
85
+ return { kind: "permanent", cause: npm.cause, detail: `npm error code ${npm.code}` };
86
+ return { kind: "transient" };
87
+ }
88
+ //# sourceMappingURL=exit-cause.js.map
@@ -9,6 +9,8 @@ import { type MachineType } from "./machine-type.js";
9
9
  import { type DaemonPaths } from "./paths.js";
10
10
  /** A live daemon runs `unitId` (or held it after it was stopped from outside). */
11
11
  export declare function daemonSupervises(paths: DaemonPaths, unitId: string, alive?: (pid: number) => boolean): boolean;
12
+ /** Current supervisor hold, including its explicit recovery command; never infer one from stale state. */
13
+ export declare function daemonHoldReason(paths: DaemonPaths, unitId: string, alive?: (pid: number) => boolean): string | null;
12
14
  /**
13
15
  * A live daemon runs the service `unitId` belongs to (`bots` for
14
16
  * `bots:<name>`), so it will run this instance once its plan includes it. For
@@ -16,7 +16,7 @@ import { detectMachineType } from "./machine-type.js";
16
16
  import { daemonPaths } from "./paths.js";
17
17
  import { writeControlRequest } from "./requests.js";
18
18
  /** Statuses where the daemon itself owns the unit's process. */
19
- const OWNED = new Set(["running", "backoff", "exited", "idle", "stopped"]);
19
+ const OWNED = new Set(["running", "backoff", "exited", "idle", "stopped", "held"]);
20
20
  const RECENT_HOSTED_DAEMON_MS = 5 * 60_000;
21
21
  /** The updater's postinstall may run in the short gap before the desktop app relaunches its daemon. */
22
22
  function recentlyHostedDaemon(paths) {
@@ -44,6 +44,11 @@ export function daemonSupervises(paths, unitId, alive = (pid) => isDaemonProcess
44
44
  const unit = liveState(paths, alive)?.units?.find((u) => u.id === unitId);
45
45
  return unit !== undefined && OWNED.has(unit.status);
46
46
  }
47
+ /** Current supervisor hold, including its explicit recovery command; never infer one from stale state. */
48
+ export function daemonHoldReason(paths, unitId, alive = (pid) => isDaemonProcess(pid)) {
49
+ const unit = liveState(paths, alive)?.units?.find((u) => u.id === unitId);
50
+ return unit?.status === "held" ? unit.reason ?? `held; \`hq daemon restart ${unitId}\` retries` : null;
51
+ }
47
52
  /**
48
53
  * A live daemon runs the service `unitId` belongs to (`bots` for
49
54
  * `bots:<name>`), so it will run this instance once its plan includes it. For
@@ -43,14 +43,31 @@ function updateRequestIsActive(requestPath) {
43
43
  const pid = request.pid;
44
44
  if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
45
45
  throw new Error("The HQ CLI update request is invalid");
46
+ // Same staleness rule as src/utils/package-use-lease.ts: a request written
47
+ // before the current boot (pid may be reused), or whose pid is gone (ESRCH)
48
+ // or owned by another user (EPERM), is not a live updater.
49
+ let stale = false;
46
50
  try {
47
- process.kill(pid, 0);
51
+ stale = fs.statSync(requestPath).mtimeMs < Date.now() - os.uptime() * 1000 - 5_000;
48
52
  }
49
53
  catch (error) {
50
- if (error.code === "ESRCH") {
51
- fs.rmSync(requestPath, { force: true });
54
+ if (error.code === "ENOENT")
52
55
  return false;
56
+ throw new Error("Unable to read the HQ CLI update request");
57
+ }
58
+ if (!stale) {
59
+ try {
60
+ process.kill(pid, 0);
53
61
  }
62
+ catch (error) {
63
+ const code = error.code;
64
+ if (code === "ESRCH" || code === "EPERM")
65
+ stale = true;
66
+ }
67
+ }
68
+ if (stale) {
69
+ fs.rmSync(requestPath, { force: true });
70
+ return false;
54
71
  }
55
72
  return true;
56
73
  }
@@ -8,6 +8,8 @@ export interface DaemonPaths {
8
8
  configPath: string;
9
9
  remoteControlPoolsPath: string;
10
10
  statePath: string;
11
+ /** Long-running units the supervisor holds down, with why (see unit-holds.ts). */
12
+ holdsPath: string;
11
13
  lockPath: string;
12
14
  logsDir: string;
13
15
  requestsDir: string;
@@ -14,6 +14,7 @@ export function daemonPaths(home = os.homedir(), env = process.env) {
14
14
  configPath: path.join(root, "config.json"),
15
15
  remoteControlPoolsPath: path.join(root, "remote-control-pools.json"),
16
16
  statePath: path.join(root, "state.json"),
17
+ holdsPath: path.join(root, "holds.json"),
17
18
  lockPath: path.join(root, "daemon.pid"),
18
19
  logsDir,
19
20
  requestsDir: path.join(root, "requests"),
@@ -56,6 +56,7 @@ import { adoptChild, createChildTracker, finishSuccession, handoffEligible, read
56
56
  import { listSyncRunnerProcesses, readWatchRunnerOwnerStatus, syncHeartbeatTimeoutMs, syncRunnerNodeOptions, watchRunnerOwnerStatusPath, } from "./sync-runner.js";
57
57
  import { STOP_ESCALATION_MS, SYNC_RUNNER_PERSIST_GRACE_MS, createSupervisor, } from "./supervisor.js";
58
58
  import { userToolDirs } from "./tools.js";
59
+ import { logOffset, readLogSince, readUnitHolds, writeUnitHolds } from "./unit-holds.js";
59
60
  import { daemonRunGuard } from "./lanes-reconcile-guard.js";
60
61
  export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
61
62
  /** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
@@ -1002,10 +1003,13 @@ export async function runDaemon(opts) {
1002
1003
  }
1003
1004
  return plans;
1004
1005
  };
1006
+ /** Where each unit's log ended when its current run started, so its exit is classified on that run's output only. */
1007
+ const unitLogOffsets = new Map();
1005
1008
  const spawnUnit = (unit) => {
1006
1009
  const command = unit.command;
1007
1010
  if (!command || command.type === "internal")
1008
1011
  throw new Error(`${unit.id} has no process to start`);
1012
+ unitLogOffsets.set(unit.id, logOffset(paths.unitLogPath(unit.id)));
1009
1013
  let [file, args] = command.type === "hq"
1010
1014
  ? daemonHqChildCommand(opts.nodeBinary, opts.hqBinary, command.args, stagedHqChildBootstrap?.bootstrapPath ?? null, cliPackagePrefix, childBootstrapHandoffWatchFlag.status().enabled)
1011
1015
  : command.type === "node"
@@ -1136,6 +1140,15 @@ export async function runDaemon(opts) {
1136
1140
  restartDaemon: () => restartDaemon(),
1137
1141
  runGuard: daemonRunGuard,
1138
1142
  writeState: (state) => writeJsonAtomic(paths.statePath, state),
1143
+ holds: {
1144
+ read: () => readUnitHolds(paths.holdsPath),
1145
+ write: (holds) => writeUnitHolds(paths.holdsPath, holds),
1146
+ },
1147
+ exitOutputTail: (unitId) => {
1148
+ // An adopted unit was started by an earlier daemon; its output start is unknown.
1149
+ const offset = unitLogOffsets.get(unitId);
1150
+ return offset === undefined ? undefined : readLogSince(paths.unitLogPath(unitId), offset);
1151
+ },
1139
1152
  log,
1140
1153
  onSyncRunnerStopping: () => extendSyncShutdownCap?.(),
1141
1154
  syncOwnerBlockReason: () => {
@@ -3,6 +3,11 @@
3
3
  *
4
4
  * - long-running units are restarted after they exit, with per-unit backoff
5
5
  * (1 s doubling to 60 s, reset once a unit has stayed up for a minute);
6
+ * a unit that fails EPISODE_MAX_FAILURES times without a run of
7
+ * EPISODE_HEALTHY_RESET_MS, or fails for a permanent cause (see exit-cause.ts), is held
8
+ * instead. Permanent holds need a restart request, command change or CLI update.
9
+ * Exhausted transient sync/mesh units make one durable supervised probe after
10
+ * a 30-minute cooldown; a failed probe re-holds, and an explicit stop stays stopped;
6
11
  * `on-failure` units stay down after a clean exit or a SIGTERM/SIGINT from
7
12
  * outside, and any unit stays down after a stop request (`hq bot stop`),
8
13
  * until a restart request;
@@ -26,6 +31,14 @@ import type { UnitPlan } from "./catalog.js";
26
31
  import type { MachineType } from "./machine-type.js";
27
32
  import type { ControlRequest } from "./requests.js";
28
33
  import { syncPassResult } from "./sync-runner.js";
34
+ export declare const HEALTHY_RUN_MS = 60000;
35
+ /**
36
+ * Failures in one episode before the unit is held: the first start plus five
37
+ * automatic retries. Matches the Desktop app's MAX_FAILURES.
38
+ */
39
+ export declare const EPISODE_MAX_FAILURES = 6;
40
+ /** A run this long ends the failure episode. Matches the Desktop app's HEALTHY_RUN_WINDOW. */
41
+ export declare const EPISODE_HEALTHY_RESET_MS: number;
29
42
  export declare const STOP_ESCALATION_MS = 10000;
30
43
  export declare const SYNC_RUNNER_PERSIST_GRACE_MS = 60000;
31
44
  export declare const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60000;
@@ -42,7 +55,38 @@ export interface SupervisedChild {
42
55
  on(event: "error", handler: (err: Error) => void): unknown;
43
56
  kill(signal?: NodeJS.Signals | number): boolean;
44
57
  }
45
- type UnitStatus = "running" | "waiting" | "unavailable" | "backoff" | "exited" | "idle" | "stopped";
58
+ type UnitStatus = "running" | "waiting" | "unavailable" | "backoff" | "exited" | "idle" | "stopped" | "held";
59
+ /**
60
+ * Why a long-running unit is held down. `permanent`: the last exit had a cause
61
+ * a restart cannot fix. `exhausted`: EPISODE_MAX_FAILURES failures without a
62
+ * run of EPISODE_HEALTHY_RESET_MS. Persisted (see SupervisorDeps.holds)
63
+ * so a daemon restart keeps the unit held.
64
+ */
65
+ export interface UnitHold {
66
+ kind: "permanent" | "exhausted";
67
+ /** A PermanentExitCause, or "transient" for an exhausted episode. */
68
+ cause: string;
69
+ detail: string;
70
+ /** Epoch ms when the hold started. */
71
+ since: number;
72
+ /** Failures in the episode that ended in the hold, the last one included. */
73
+ failures: number;
74
+ lastExit?: {
75
+ code: number | null;
76
+ signal: string | null;
77
+ at: number;
78
+ };
79
+ /** sha256 of the unit's plan key; a hold only applies to the command it was recorded for. */
80
+ keyHash: string;
81
+ /** Exhausted sync/mesh only: next durable supervised-probe deadline. */
82
+ retryAt?: number;
83
+ /** A probe claim remains durable until that running generation is healthy. */
84
+ probing?: boolean;
85
+ /** Explicit stop of a recovering unit; only an explicit restart clears it. */
86
+ retryStopped?: boolean;
87
+ }
88
+ /** The holds file: unit id to hold. */
89
+ export type PersistedHolds = Record<string, UnitHold>;
46
90
  interface UnitState {
47
91
  id: string;
48
92
  service: string;
@@ -65,6 +109,8 @@ interface UnitState {
65
109
  lastPassResult?: ReturnType<typeof syncPassResult>;
66
110
  /** The pass-request folder the running child was started with; absent when it was not. */
67
111
  passRequestDir?: string;
112
+ /** Also retained during a supervised recovery probe or its explicit manual stop. */
113
+ hold?: UnitHold;
68
114
  }
69
115
  /** An app that runs the daemon as its own child process instead of an OS unit. */
70
116
  export type HostApp = "desktop";
@@ -119,6 +165,13 @@ export interface SupervisorDeps {
119
165
  adopt?: readonly AdoptedUnit[];
120
166
  /** Pre-run check for scheduled units that declare `runGuard`; a throw counts as `spawn`. */
121
167
  runGuard?: (unit: UnitPlan) => "spawn" | "idle" | "skip";
168
+ /** What a long-running unit wrote during the run that just ended, for exit classification. */
169
+ exitOutputTail?: (unitId: string) => string | undefined;
170
+ /** Durable storage for unit holds, so a held unit stays held across daemon restarts. */
171
+ holds?: {
172
+ read(): PersistedHolds;
173
+ write(holds: PersistedHolds): void;
174
+ };
122
175
  }
123
176
  export interface Supervisor {
124
177
  start(): void;