@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
@@ -0,0 +1,224 @@
1
+ /**
2
+ * One hot-channel sync at a time per person per company. The bot's scheduled
3
+ * run and a manual `hq sources sync` can fire together; the index files are
4
+ * read-modify-write, so two runs at once can lose rows and process the same
5
+ * event twice. The lock is `sources/_runs/@{personKey}/.lock`, created with
6
+ * O_EXCL and holding the pid, host, start time and last heartbeat.
7
+ *
8
+ * A lock is stale when its holder on this host is no longer alive, or when its
9
+ * last heartbeat is older than `SYNC_LOCK_STALE_MS`. The holder heartbeats the
10
+ * lock while it runs, so a long signals pass is never taken over mid-write.
11
+ * Takeover runs under an O_EXCL `.takeover` guard: the guard holder renames the
12
+ * stale file to a unique name, checks it moved the file it judged stale, and
13
+ * every contender then competes on the O_EXCL create of a fresh lock.
14
+ * Release removes the file only while it still names this holder.
15
+ */
16
+ import { randomUUID } from "node:crypto";
17
+ import * as fs from "node:fs/promises";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+ import { assertNotIntegrationSecretsPath, personDirName } from "./cursors.js";
21
+ /** A lock whose last heartbeat is older than this is stale. */
22
+ export const SYNC_LOCK_STALE_MS = 30 * 60 * 1000;
23
+ /** How often a held lock is refreshed; well under the stale limit. */
24
+ export const SYNC_LOCK_HEARTBEAT_MS = 60 * 1000;
25
+ export const SYNC_LOCK_BUSY_REASON = "another sync is running";
26
+ /**
27
+ * The run no longer holds its sync lock (another run took it over, for example
28
+ * after this machine slept past the stale limit). The run stops before its
29
+ * next write.
30
+ */
31
+ export class SyncLockLostError extends Error {
32
+ constructor() {
33
+ super("sync lock lost to another run; stopped before the next write");
34
+ this.name = "SyncLockLostError";
35
+ }
36
+ }
37
+ export function syncLockPath(vaultRoot, personKey) {
38
+ const file = path.join(vaultRoot, "sources", "_runs", personDirName(personKey), ".lock");
39
+ assertNotIntegrationSecretsPath(file);
40
+ return file;
41
+ }
42
+ function serialize(body) {
43
+ return `${JSON.stringify(body)}\n`;
44
+ }
45
+ async function tryCreate(file, body) {
46
+ try {
47
+ await fs.writeFile(file, serialize(body), { encoding: "utf8", flag: "wx" });
48
+ return true;
49
+ }
50
+ catch (err) {
51
+ if (err.code === "EEXIST")
52
+ return false;
53
+ throw err;
54
+ }
55
+ }
56
+ async function readBody(file) {
57
+ try {
58
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
59
+ return parsed && typeof parsed === "object" ? parsed : null;
60
+ }
61
+ catch (err) {
62
+ if (err.code === "ENOENT")
63
+ return "missing";
64
+ return null;
65
+ }
66
+ }
67
+ /** True when `pid` names a live process on this host. EPERM means it exists. */
68
+ export function pidAlive(pid) {
69
+ try {
70
+ process.kill(pid, 0);
71
+ return true;
72
+ }
73
+ catch (err) {
74
+ return err.code === "EPERM";
75
+ }
76
+ }
77
+ async function isStale(file, nowMs, staleMs, alive) {
78
+ const body = await readBody(file);
79
+ if (body === "missing")
80
+ return "missing";
81
+ if (body && typeof body.pid === "number" && body.host === os.hostname() && !alive(body.pid))
82
+ return true;
83
+ const stamp = body?.heartbeatAt ?? body?.startedAt;
84
+ let at = typeof stamp === "string" ? Date.parse(stamp) : Number.NaN;
85
+ if (Number.isNaN(at)) {
86
+ try {
87
+ at = (await fs.stat(file)).mtimeMs;
88
+ }
89
+ catch {
90
+ return "missing";
91
+ }
92
+ }
93
+ return nowMs - at >= staleMs;
94
+ }
95
+ /**
96
+ * Take the sync lock, or return null when a live run holds it. A stale lock is
97
+ * moved aside and taken over once; a race lost during takeover returns null.
98
+ */
99
+ export async function acquireSyncLock(vaultRoot, personKey, options = {}) {
100
+ const now = options.now ?? (() => new Date());
101
+ const staleMs = options.staleMs ?? SYNC_LOCK_STALE_MS;
102
+ const heartbeatMs = options.heartbeatMs ?? SYNC_LOCK_HEARTBEAT_MS;
103
+ const alive = options.pidAlive ?? pidAlive;
104
+ const file = syncLockPath(vaultRoot, personKey);
105
+ await fs.mkdir(path.dirname(file), { recursive: true });
106
+ const startedAt = now().toISOString();
107
+ const body = { pid: process.pid, host: os.hostname(), startedAt, heartbeatAt: startedAt };
108
+ const ownsFile = async () => {
109
+ const current = await readBody(file);
110
+ return (current !== null &&
111
+ current !== "missing" &&
112
+ current.pid === body.pid &&
113
+ current.host === body.host &&
114
+ current.startedAt === body.startedAt);
115
+ };
116
+ const held = () => {
117
+ let released = false;
118
+ let timer;
119
+ const heartbeat = async () => {
120
+ if (released || !(await ownsFile()))
121
+ return;
122
+ const tmp = `${file}.${randomUUID()}.tmp`;
123
+ await fs.writeFile(tmp, serialize({ ...body, heartbeatAt: now().toISOString() }), "utf8");
124
+ await fs.rename(tmp, file);
125
+ };
126
+ if (heartbeatMs > 0) {
127
+ timer = setInterval(() => {
128
+ heartbeat().catch(() => undefined);
129
+ }, heartbeatMs);
130
+ timer.unref();
131
+ }
132
+ return {
133
+ file,
134
+ assertHeld: async () => {
135
+ if (released || !(await ownsFile()))
136
+ throw new SyncLockLostError();
137
+ },
138
+ heartbeat,
139
+ release: async () => {
140
+ released = true;
141
+ if (timer)
142
+ clearInterval(timer);
143
+ if (await ownsFile())
144
+ await fs.rm(file, { force: true });
145
+ },
146
+ };
147
+ };
148
+ if (await tryCreate(file, body))
149
+ return held();
150
+ const stale = await isStale(file, now().getTime(), staleMs, alive);
151
+ if (stale === false)
152
+ return null;
153
+ if (stale === true && !(await takeOverStale(file, now, staleMs, alive)))
154
+ return null;
155
+ return (await tryCreate(file, body)) ? held() : null;
156
+ }
157
+ /** A takeover guard older than this belongs to a contender that died mid-takeover. */
158
+ const TAKEOVER_GUARD_STALE_MS = 60 * 1000;
159
+ /**
160
+ * Move a stale lock out of the way. Only the contender that O_EXCL-creates the
161
+ * `.takeover` guard may touch the lock file, so two contenders can never both
162
+ * remove it. Under the guard the lock is re-read, renamed to a unique name,
163
+ * and the moved file is compared with what was read: if it changed in between
164
+ * (its holder released and a new run took the lock), it is put back. Returns
165
+ * false when this contender should give up.
166
+ */
167
+ async function takeOverStale(file, now, staleMs, alive) {
168
+ const guard = `${file}.takeover`;
169
+ const takeGuard = async () => {
170
+ try {
171
+ await fs.writeFile(guard, `${process.pid}\n`, { encoding: "utf8", flag: "wx" });
172
+ return true;
173
+ }
174
+ catch (err) {
175
+ if (err.code !== "EEXIST")
176
+ throw err;
177
+ return false;
178
+ }
179
+ };
180
+ if (!(await takeGuard())) {
181
+ let age = 0;
182
+ try {
183
+ age = now().getTime() - (await fs.stat(guard)).mtimeMs;
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ if (age < TAKEOVER_GUARD_STALE_MS)
189
+ return false;
190
+ const staleGuard = `${guard}.stale-${randomUUID()}`;
191
+ await fs.rename(guard, staleGuard).catch(() => undefined);
192
+ await fs.rm(staleGuard, { force: true });
193
+ if (!(await takeGuard()))
194
+ return false;
195
+ }
196
+ try {
197
+ const seen = await fs.readFile(file, "utf8").catch(() => null);
198
+ if (seen === null)
199
+ return true;
200
+ if ((await isStale(file, now().getTime(), staleMs, alive)) !== true)
201
+ return false;
202
+ const aside = `${file}.stale-${process.pid}-${randomUUID()}`;
203
+ try {
204
+ await fs.rename(file, aside);
205
+ }
206
+ catch (err) {
207
+ if (err.code === "ENOENT")
208
+ return true;
209
+ throw err;
210
+ }
211
+ const moved = await fs.readFile(aside, "utf8").catch(() => null);
212
+ if (moved !== seen) {
213
+ await fs.link(aside, file).catch(() => undefined);
214
+ await fs.rm(aside, { force: true });
215
+ return false;
216
+ }
217
+ await fs.rm(aside, { force: true });
218
+ return true;
219
+ }
220
+ finally {
221
+ await fs.rm(guard, { force: true });
222
+ }
223
+ }
224
+ //# sourceMappingURL=sync-lock.js.map
@@ -1,6 +1,10 @@
1
1
  import { type HookTarget } from './claude-targets.js';
2
- /** True when an hq daemon unit (launchd plist or systemd unit) exists. */
3
- export declare function isDaemonInstalled(home: string, platform?: NodeJS.Platform, queryTask?: (task: string) => boolean): boolean;
2
+ /**
3
+ * True when this user's hq daemon unit (launchd plist, systemd unit, or the
4
+ * per-user Windows Scheduled Task) exists. On Windows the shared task an older
5
+ * install registered counts only when it runs as this user.
6
+ */
7
+ export declare function isDaemonInstalled(home: string, platform?: NodeJS.Platform, queryTask?: (task: string) => boolean, taskName?: () => string, legacyTaskIsMine?: () => boolean): boolean;
4
8
  /**
5
9
  * US-010: with hqd installed (and the checkout shipping the shim), hooks go to
6
10
  * the hqd shim; otherwise to master-hook.sh / the Codex adapter.
@@ -7,15 +7,20 @@ import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import { realpathOrSelf } from '../../commands/mcp-registration.js';
10
- import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK, SYSTEM_UNIT_DIR } from '../daemon/install.js';
10
+ import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, SYSTEM_UNIT_DIR, currentWindowsIdentity, legacyWindowsTask, windowsTaskName } from '../daemon/install.js';
11
+ import { defaultRunCommand } from '../service-manager/launchd.js';
11
12
  import { readDaemonConfig } from '../daemon/config.js';
12
13
  import { daemonPaths } from '../daemon/paths.js';
13
14
  import { registrationEngine } from '../hook-engine/registration.js';
14
15
  import { shimPath } from './claude-targets.js';
15
- /** True when an hq daemon unit (launchd plist or systemd unit) exists. */
16
- export function isDaemonInstalled(home, platform = process.platform, queryTask = (task) => spawnSync('schtasks.exe', ['/Query', '/TN', task], { encoding: 'utf8', windowsHide: true }).status === 0) {
16
+ /**
17
+ * True when this user's hq daemon unit (launchd plist, systemd unit, or the
18
+ * per-user Windows Scheduled Task) exists. On Windows the shared task an older
19
+ * install registered counts only when it runs as this user.
20
+ */
21
+ export function isDaemonInstalled(home, platform = process.platform, queryTask = (task) => spawnSync('schtasks.exe', ['/Query', '/TN', task], { encoding: 'utf8', windowsHide: true }).status === 0, taskName = () => windowsTaskName(currentWindowsIdentity()), legacyTaskIsMine = () => legacyWindowsTask(defaultRunCommand, currentWindowsIdentity()).state === 'mine') {
17
22
  if (platform === 'win32')
18
- return queryTask(DAEMON_WINDOWS_TASK);
23
+ return queryTask(taskName()) || legacyTaskIsMine();
19
24
  const candidates = platform === 'darwin'
20
25
  ? [path.join(home, 'Library', 'LaunchAgents', `${DAEMON_LAUNCHD_LABEL}.plist`)]
21
26
  : platform === 'linux'
@@ -13,23 +13,7 @@
13
13
  import { type LaneRecord, type LaneProvider } from "./types.js";
14
14
  import type { LanesConfig } from "./types.js";
15
15
  import type { ClaudeCredentialReader, ClaudeTokenState } from "./claude-credential.js";
16
- export declare const PROVIDER_HOME_ENV: Record<LaneProvider, string>;
17
- /**
18
- * Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
19
- * the variable unset it reads the plain `Claude Code-credentials` item, and
20
- * with it set, even to the default `~/.claude`, it reads a hash-suffixed item
21
- * for that path. Setting the variable for the default home therefore makes a
22
- * lane use a different login than the interactive session (incident
23
- * 2026-10-13). A Claude account whose home is the default is launched with
24
- * the variable unset instead.
25
- */
26
- export declare function isDefaultProviderHome(provider: LaneProvider, home: string, homedir?: () => string): boolean;
27
- /**
28
- * Return a copy of `base` with the provider home variable set to `home`,
29
- * or, for a Claude account on the default home, with the variable removed
30
- * so the worker and the interactive session share one credential.
31
- */
32
- export declare function withProviderHomeEnv(base: NodeJS.ProcessEnv, provider: LaneProvider, home: string, homedir?: () => string): NodeJS.ProcessEnv;
16
+ export { PROVIDER_HOME_ENV, isDefaultProviderHome, withProviderHomeEnv } from "./provider-home.js";
33
17
  /** Who `claude auth status` says a home is logged in as. Never a token. */
34
18
  export interface ClaudeIdentity {
35
19
  email: string | null;
@@ -11,57 +11,17 @@
11
11
  * This module never prints, copies, or hashes credentials.
12
12
  */
13
13
  import * as fs from "node:fs";
14
- import * as os from "node:os";
15
14
  import { effectiveAccountHome } from "./account-home.js";
16
15
  import * as path from "node:path";
17
16
  import { AccountResolutionError } from "./errors.js";
18
17
  import { isTerminalLaneState } from "./types.js";
19
- import { DEFAULT_ACCOUNT_HOMES, accountMaxRunning, canonicalizeHome } from "./config.js";
18
+ import { accountMaxRunning } from "./config.js";
20
19
  import { listLaneIds, readLane } from "./store.js";
21
20
  import { loadQuotaState, quotaBlockFor } from "./quota.js";
22
- export const PROVIDER_HOME_ENV = {
23
- claude: "CLAUDE_CONFIG_DIR",
24
- codex: "CODEX_HOME",
25
- grok: "GROK_HOME",
26
- };
27
- /**
28
- * Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
29
- * the variable unset it reads the plain `Claude Code-credentials` item, and
30
- * with it set, even to the default `~/.claude`, it reads a hash-suffixed item
31
- * for that path. Setting the variable for the default home therefore makes a
32
- * lane use a different login than the interactive session (incident
33
- * 2026-10-13). A Claude account whose home is the default is launched with
34
- * the variable unset instead.
35
- */
36
- export function isDefaultProviderHome(provider, home, homedir = () => os.homedir()) {
37
- const deps = {
38
- homedir,
39
- realpathSync: (p) => fs.realpathSync(p),
40
- existsSync: (p) => fs.existsSync(p),
41
- };
42
- try {
43
- return canonicalizeHome(home, deps) === canonicalizeHome(DEFAULT_ACCOUNT_HOMES[provider], deps);
44
- }
45
- catch {
46
- return false;
47
- }
48
- }
49
- /**
50
- * Return a copy of `base` with the provider home variable set to `home`,
51
- * or, for a Claude account on the default home, with the variable removed
52
- * so the worker and the interactive session share one credential.
53
- */
54
- export function withProviderHomeEnv(base, provider, home, homedir) {
55
- const next = { ...base };
56
- const envVar = PROVIDER_HOME_ENV[provider];
57
- if (provider === "claude" && isDefaultProviderHome(provider, home, homedir)) {
58
- delete next[envVar];
59
- }
60
- else {
61
- next[envVar] = home;
62
- }
63
- return next;
64
- }
21
+ // Provider home helpers live in a leaf module so bot code can use them
22
+ // without importing the lane store; re-exported for existing callers.
23
+ export { PROVIDER_HOME_ENV, isDefaultProviderHome, withProviderHomeEnv } from "./provider-home.js";
24
+ import { PROVIDER_HOME_ENV, isDefaultProviderHome, withProviderHomeEnv } from "./provider-home.js";
65
25
  function optionalString(value) {
66
26
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
67
27
  }
@@ -222,6 +222,16 @@ export declare function inspectLoopRecovery(runDir: string): {
222
222
  kind: "pending" | "hold" | "protected" | "empty";
223
223
  reason?: string;
224
224
  };
225
+ /**
226
+ * Budget for an envelope that carries an absolute deadline. The deadline is
227
+ * stamped when the phase is routed, so the budget is the time between enqueue
228
+ * and that deadline, counted from when the lane starts the envelope. Time the
229
+ * envelope spent waiting in the queue does not count against it. Without a
230
+ * usable queued_at the remaining time from start is used, as before.
231
+ * A deadline at or before its enqueue still gets a 1 s execution window.
232
+ * An unparsable deadline returns undefined so the caller uses the phase budget.
233
+ */
234
+ export declare function absoluteDeadlineBudget(deadline: string, queuedAt: string | undefined, startedAtMs: number): number | undefined;
225
235
  /** Reset only after an explicit, durable disposition; this never starts work. */
226
236
  export declare function resetLoopAfterDisposition(runDir: string, laneId: string, decision: string, hasPending: boolean): void;
227
237
  /** Run queued phases serially. Sessions are isolated by story and provider. */
@@ -985,6 +985,23 @@ function phaseDeadlineBudget(envelope, defaultSeconds) {
985
985
  const configured = Number(process.env.HQ_LANE_LOOP_PHASE_DEADLINE_SECS);
986
986
  return Number.isFinite(configured) && configured > 0 ? configured : defaultSeconds;
987
987
  }
988
+ /**
989
+ * Budget for an envelope that carries an absolute deadline. The deadline is
990
+ * stamped when the phase is routed, so the budget is the time between enqueue
991
+ * and that deadline, counted from when the lane starts the envelope. Time the
992
+ * envelope spent waiting in the queue does not count against it. Without a
993
+ * usable queued_at the remaining time from start is used, as before.
994
+ * A deadline at or before its enqueue still gets a 1 s execution window.
995
+ * An unparsable deadline returns undefined so the caller uses the phase budget.
996
+ */
997
+ export function absoluteDeadlineBudget(deadline, queuedAt, startedAtMs) {
998
+ const deadlineMs = Date.parse(deadline);
999
+ if (!Number.isFinite(deadlineMs))
1000
+ return undefined;
1001
+ const queuedMs = typeof queuedAt === "string" ? Date.parse(queuedAt) : Number.NaN;
1002
+ const fromMs = Number.isFinite(queuedMs) && queuedMs <= startedAtMs ? queuedMs : startedAtMs;
1003
+ return Math.max(1, Math.round((deadlineMs - fromMs) / 1000));
1004
+ }
988
1005
  function persistStallCount(runDir, laneId, envelopeId, count) {
989
1006
  const status = loopStatus(runDir, laneId);
990
1007
  persistStatus(runDir, laneId, { stall_counts: { ...status.stall_counts, [envelopeId]: count } });
@@ -1121,7 +1138,7 @@ export async function runLoopLane(options) {
1121
1138
  const absoluteDeadline = envelope.deadline;
1122
1139
  const budget = record.deadline_budget_seconds ?? firstDeadline.get(record.envelope_id) ??
1123
1140
  (typeof absoluteDeadline === "string"
1124
- ? Math.max(1, Math.round((Date.parse(absoluteDeadline) - startedAtMs) / 1000)) // An absolute deadline already in the past still gets a 1 s execution window.
1141
+ ? absoluteDeadlineBudget(absoluteDeadline, record.queued_at, startedAtMs) ?? phaseDeadlineBudget(envelope, defaultDeadlineSeconds)
1125
1142
  : phaseDeadlineBudget(envelope, defaultDeadlineSeconds));
1126
1143
  firstDeadline.set(record.envelope_id, budget);
1127
1144
  record.deadline_budget_seconds = budget;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Provider home environment helpers, split from accounts.ts so callers that
3
+ * only need to point a provider at a home (bots) do not pull in the lane
4
+ * store and its daemon imports. accounts.ts re-exports these.
5
+ */
6
+ import type { LaneProvider } from "./types.js";
7
+ export declare const PROVIDER_HOME_ENV: Record<LaneProvider, string>;
8
+ /**
9
+ * Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
10
+ * the variable unset it reads the plain `Claude Code-credentials` item, and
11
+ * with it set, even to the default `~/.claude`, it reads a hash-suffixed item
12
+ * for that path. Setting the variable for the default home therefore makes a
13
+ * lane use a different login than the interactive session (incident
14
+ * 2026-10-13). A Claude account whose home is the default is launched with
15
+ * the variable unset instead.
16
+ */
17
+ export declare function isDefaultProviderHome(provider: LaneProvider, home: string, homedir?: () => string): boolean;
18
+ /**
19
+ * Return a copy of `base` with the provider home variable set to `home`,
20
+ * or, for a Claude account on the default home, with the variable removed
21
+ * so the worker and the interactive session share one credential.
22
+ */
23
+ export declare function withProviderHomeEnv(base: NodeJS.ProcessEnv, provider: LaneProvider, home: string, homedir?: () => string): NodeJS.ProcessEnv;
24
+ //# sourceMappingURL=provider-home.d.ts.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Provider home environment helpers, split from accounts.ts so callers that
3
+ * only need to point a provider at a home (bots) do not pull in the lane
4
+ * store and its daemon imports. accounts.ts re-exports these.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import { DEFAULT_ACCOUNT_HOMES, canonicalizeHome } from "./config.js";
9
+ export const PROVIDER_HOME_ENV = {
10
+ claude: "CLAUDE_CONFIG_DIR",
11
+ codex: "CODEX_HOME",
12
+ grok: "GROK_HOME",
13
+ };
14
+ /**
15
+ * Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
16
+ * the variable unset it reads the plain `Claude Code-credentials` item, and
17
+ * with it set, even to the default `~/.claude`, it reads a hash-suffixed item
18
+ * for that path. Setting the variable for the default home therefore makes a
19
+ * lane use a different login than the interactive session (incident
20
+ * 2026-10-13). A Claude account whose home is the default is launched with
21
+ * the variable unset instead.
22
+ */
23
+ export function isDefaultProviderHome(provider, home, homedir = () => os.homedir()) {
24
+ const deps = {
25
+ homedir,
26
+ realpathSync: (p) => fs.realpathSync(p),
27
+ existsSync: (p) => fs.existsSync(p),
28
+ };
29
+ try {
30
+ return canonicalizeHome(home, deps) === canonicalizeHome(DEFAULT_ACCOUNT_HOMES[provider], deps);
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ /**
37
+ * Return a copy of `base` with the provider home variable set to `home`,
38
+ * or, for a Claude account on the default home, with the variable removed
39
+ * so the worker and the interactive session share one credential.
40
+ */
41
+ export function withProviderHomeEnv(base, provider, home, homedir) {
42
+ const next = { ...base };
43
+ const envVar = PROVIDER_HOME_ENV[provider];
44
+ if (provider === "claude" && isDefaultProviderHome(provider, home, homedir)) {
45
+ delete next[envVar];
46
+ }
47
+ else {
48
+ next[envVar] = home;
49
+ }
50
+ return next;
51
+ }
52
+ //# sourceMappingURL=provider-home.js.map
@@ -204,6 +204,18 @@ export declare const FOREGROUND_ONLY_RULE: string;
204
204
  * install has it, the command fails with "No such file".
205
205
  */
206
206
  export declare function tempHomeRule(hqRoot: string): string;
207
+ /**
208
+ * Self-review rule carried next to the temp-home rule in every composed
209
+ * brief, for every provider. A PR URL reaches the core-pr-reviews channel only
210
+ * after two zero-critical reviews of its head: the lane's own review and an
211
+ * independent senior review. The lane supplies the first one and never posts.
212
+ *
213
+ * The report file under workspace/reviews/ uses the format the hq-core
214
+ * channel guard (block-core-pr-reviews-dm.sh) reads before a URL is posted.
215
+ * The skill path is absolute so codex and grok lanes, which have no /review
216
+ * slash command, can read and follow it.
217
+ */
218
+ export declare function selfReviewRule(hqRoot: string): string;
207
219
  /**
208
220
  * The answers as written by the reconcile pass (`answered-questions.json`),
209
221
  * reduced to id + choice. A malformed file reads as no inline answers; the
@@ -1378,6 +1378,33 @@ export function tempHomeRule(hqRoot) {
1378
1378
  "each fresh home re-downloads the Rust toolchain and pnpm store and is never deleted. " +
1379
1379
  "The wrapper keeps the real caches and removes the temp home when the command exits.");
1380
1380
  }
1381
+ /**
1382
+ * Self-review rule carried next to the temp-home rule in every composed
1383
+ * brief, for every provider. A PR URL reaches the core-pr-reviews channel only
1384
+ * after two zero-critical reviews of its head: the lane's own review and an
1385
+ * independent senior review. The lane supplies the first one and never posts.
1386
+ *
1387
+ * The report file under workspace/reviews/ uses the format the hq-core
1388
+ * channel guard (block-core-pr-reviews-dm.sh) reads before a URL is posted.
1389
+ * The skill path is absolute so codex and grok lanes, which have no /review
1390
+ * slash command, can read and follow it.
1391
+ */
1392
+ export function selfReviewRule(hqRoot) {
1393
+ const root = path.resolve(hqRoot);
1394
+ const skill = path.join(root, ".claude", "skills", "review", "SKILL.md");
1395
+ const reviews = path.join(root, "workspace", "reviews");
1396
+ return ("SELF-REVIEW. Before reporting done on work that opens or updates a PR, " +
1397
+ `run the paranoid /review (${skill}) on your own final diff, fix every critical, ` +
1398
+ "and re-run until there are 0 criticals. " +
1399
+ "Put the report in the PR body under a 'Self-review' heading with the head SHA. " +
1400
+ `Also save it as a file under ${reviews}/ whose first lines are ` +
1401
+ "`Review-Report: hq-pr-review.v1`, `PR: <url>`, `Head: <40-char head SHA>`, " +
1402
+ "`Reviewer-Role: self-review` and `Criticals: 0`. " +
1403
+ "Save only the final zero-critical run in that file: no other line may start with " +
1404
+ "`PR:`, `Head:`, `Reviewer-Role:` or `Criticals:`. " +
1405
+ "A new commit needs a new review of the new head. " +
1406
+ "Never post to core-pr-reviews. If you cannot produce the report, mark the PR as draft.");
1407
+ }
1381
1408
  /**
1382
1409
  * The answers as written by the reconcile pass (`answered-questions.json`),
1383
1410
  * reduced to id + choice. A malformed file reads as no inline answers; the
@@ -3,6 +3,7 @@
3
3
  * outbox/last-flush, and unhealthy when unacked spool age > 60s while online.
4
4
  */
5
5
  import { type CognitoActorKind, type CognitoTokenSource } from "../../../../utils/cognito-session.js";
6
+ import { type OwnerPushStatus } from "./owner-inbox-push.js";
6
7
  import { type UnattributedEventCounts } from "../spool.js";
7
8
  import { type DaemonStateFile } from "./state.js";
8
9
  import { type LegacyListenPlatform } from "./legacy-listen.js";
@@ -70,6 +71,8 @@ export interface DaemonDoctorReport {
70
71
  }>;
71
72
  /** True when PATH lists more than one hq. */
72
73
  multipleHqOnPath: boolean;
74
+ /** Owner DM push signal (person accounts). Content-free counter only. */
75
+ ownerPush?: OwnerPushStatus;
73
76
  }
74
77
  export interface DaemonDoctorDeps {
75
78
  home?: string;
@@ -11,6 +11,7 @@ import { readSessionState } from "../../../work-context/state.js";
11
11
  import { workContextRoot } from "../../../work-context/paths.js";
12
12
  import { outboxQuarantineStatusEnabled } from "../../../work-context/outbox-quarantine-status-flag.js";
13
13
  import { formatDroppedPresenceCompany } from "./credentials.js";
14
+ import { formatOwnerPushStatus, ownerPushStatusForAccount, readOwnerInboxPushState, } from "./owner-inbox-push.js";
14
15
  import { countDistinctHeldEvents, countJsonlLines, countUnattributedEvents, } from "../spool.js";
15
16
  import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
16
17
  import { daemonDir, daemonPidPath } from "./paths.js";
@@ -248,6 +249,14 @@ export function collectDaemonDoctor(deps = {}) {
248
249
  if (emitRetry > 0) {
249
250
  unhealthyReasons.push(`direct-emit retry depth=${emitRetry}`);
250
251
  }
252
+ // Only a person account has an owner DM topic; any other account reports off.
253
+ const ownerPush = ownerPushStatusForAccount(readOwnerInboxPushState(meshRoot), now(), auth.actorKind);
254
+ // Only a subscribed push whose heartbeat stopped counts. A rejected or
255
+ // retrying best-effort subscribe writes connected:false, and an offline
256
+ // daemon stops the heartbeat; neither makes the daemon unhealthy.
257
+ if (running && online && ownerPush.connected && ownerPush.status === "stale") {
258
+ unhealthyReasons.push(`owner push stale (last event ${ownerPush.lastAt ?? "never"})`);
259
+ }
251
260
  return {
252
261
  running,
253
262
  pid: running ? record?.pid : undefined,
@@ -282,6 +291,7 @@ export function collectDaemonDoctor(deps = {}) {
282
291
  emitMode,
283
292
  subscribed,
284
293
  emitRetryDepth: emitRetry,
294
+ ownerPush,
285
295
  lastPostAt: emitState?.lastPostAt,
286
296
  lastEmit: emitState
287
297
  ? {
@@ -341,6 +351,9 @@ export function formatDaemonDoctor(report) {
341
351
  ` unassigned=${report.lastEmit.unassigned ?? 0}` +
342
352
  ` rejected=${report.lastEmit.rejected ?? 0}`
343
353
  : ""}`, `emit retry depth: ${report.emitRetryDepth}`);
354
+ if (report.ownerPush) {
355
+ lines.push(formatOwnerPushStatus(report.ownerPush));
356
+ }
344
357
  if (report.presenceRefusal) {
345
358
  lines.push(`presence refusal: ${report.presenceRefusal.code} (HTTP ${report.presenceRefusal.status}); next retry at ${report.presenceRefusal.nextRetryAt}`);
346
359
  }