@indigoai-us/hq-cli 5.345.177 → 5.345.179

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 (100) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/command-catalog.generated.d.ts +79 -1
  3. package/dist/command-catalog.generated.js +96 -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 +13 -0
  7. package/dist/commands/bot.js +230 -21
  8. package/dist/commands/cloud.d.ts +49 -0
  9. package/dist/commands/cloud.js +155 -15
  10. package/dist/commands/daemon.d.ts +2 -0
  11. package/dist/commands/daemon.js +15 -4
  12. package/dist/commands/dm-attach.js +15 -3
  13. package/dist/commands/integrations-core.d.ts +2 -0
  14. package/dist/commands/integrations-core.js +1 -0
  15. package/dist/commands/library-add.d.ts +9 -0
  16. package/dist/commands/library-add.js +19 -2
  17. package/dist/commands/run.js +9 -1
  18. package/dist/commands/sources.d.ts +89 -0
  19. package/dist/commands/sources.js +363 -1
  20. package/dist/lib/bot/config.d.ts +2 -0
  21. package/dist/lib/bot/fleet-default-flag.d.ts +31 -0
  22. package/dist/lib/bot/fleet-default-flag.js +127 -0
  23. package/dist/lib/bot/fleet-local.d.ts +30 -1
  24. package/dist/lib/bot/fleet-local.js +86 -11
  25. package/dist/lib/bot/index.d.ts +1 -0
  26. package/dist/lib/bot/index.js +1 -0
  27. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +109 -0
  28. package/dist/lib/bot/jobs/hot-signal-sync.js +177 -0
  29. package/dist/lib/bot/local-config.js +1 -1
  30. package/dist/lib/bot/local-jobs.d.ts +40 -4
  31. package/dist/lib/bot/local-jobs.js +72 -5
  32. package/dist/lib/bot/run.d.ts +15 -0
  33. package/dist/lib/bot/run.js +61 -0
  34. package/dist/lib/daemon/catalog.d.ts +2 -0
  35. package/dist/lib/daemon/catalog.js +7 -2
  36. package/dist/lib/daemon/config.d.ts +2 -1
  37. package/dist/lib/daemon/config.js +4 -3
  38. package/dist/lib/daemon/handoff.d.ts +4 -0
  39. package/dist/lib/daemon/handoff.js +6 -2
  40. package/dist/lib/daemon/install.d.ts +70 -2
  41. package/dist/lib/daemon/install.js +185 -27
  42. package/dist/lib/daemon/legacy.d.ts +9 -3
  43. package/dist/lib/daemon/legacy.js +11 -4
  44. package/dist/lib/daemon/migrate.js +1 -1
  45. package/dist/lib/daemon/run.d.ts +3 -2
  46. package/dist/lib/daemon/run.js +10 -6
  47. package/dist/lib/doctor/checks/anywhere.d.ts +6 -0
  48. package/dist/lib/doctor/checks/anywhere.js +35 -6
  49. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.d.ts +33 -0
  50. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.js +53 -0
  51. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.d.ts +38 -0
  52. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.js +64 -0
  53. package/dist/lib/hot-channels/adapters/attio.d.ts +67 -0
  54. package/dist/lib/hot-channels/adapters/attio.js +374 -0
  55. package/dist/lib/hot-channels/adapters/gmail.d.ts +111 -0
  56. package/dist/lib/hot-channels/adapters/gmail.js +413 -0
  57. package/dist/lib/hot-channels/adapters/index.d.ts +23 -0
  58. package/dist/lib/hot-channels/adapters/index.js +39 -0
  59. package/dist/lib/hot-channels/adapters/linear.d.ts +79 -0
  60. package/dist/lib/hot-channels/adapters/linear.js +321 -0
  61. package/dist/lib/hot-channels/adapters/meeting-providers.d.ts +86 -0
  62. package/dist/lib/hot-channels/adapters/meeting-providers.js +253 -0
  63. package/dist/lib/hot-channels/adapters/meetings.d.ts +81 -0
  64. package/dist/lib/hot-channels/adapters/meetings.js +523 -0
  65. package/dist/lib/hot-channels/adapters/slack.d.ts +70 -0
  66. package/dist/lib/hot-channels/adapters/slack.js +277 -0
  67. package/dist/lib/hot-channels/alert.d.ts +78 -0
  68. package/dist/lib/hot-channels/alert.js +179 -0
  69. package/dist/lib/hot-channels/audience.d.ts +15 -0
  70. package/dist/lib/hot-channels/audience.js +25 -0
  71. package/dist/lib/hot-channels/contract.d.ts +140 -0
  72. package/dist/lib/hot-channels/contract.js +20 -0
  73. package/dist/lib/hot-channels/cursors.d.ts +41 -0
  74. package/dist/lib/hot-channels/cursors.js +84 -0
  75. package/dist/lib/hot-channels/index.d.ts +10 -0
  76. package/dist/lib/hot-channels/index.js +10 -0
  77. package/dist/lib/hot-channels/landing.d.ts +83 -0
  78. package/dist/lib/hot-channels/landing.js +175 -0
  79. package/dist/lib/hot-channels/ledger.d.ts +101 -0
  80. package/dist/lib/hot-channels/ledger.js +186 -0
  81. package/dist/lib/hot-channels/loop.d.ts +74 -0
  82. package/dist/lib/hot-channels/loop.js +219 -0
  83. package/dist/lib/hot-channels/prompts/signals-pass.md +22 -0
  84. package/dist/lib/hot-channels/registry.d.ts +28 -0
  85. package/dist/lib/hot-channels/registry.js +121 -0
  86. package/dist/lib/hot-channels/run.d.ts +137 -0
  87. package/dist/lib/hot-channels/run.js +324 -0
  88. package/dist/lib/hot-channels/signals-pass.d.ts +150 -0
  89. package/dist/lib/hot-channels/signals-pass.js +581 -0
  90. package/dist/lib/hot-channels/sync-lock.d.ts +51 -0
  91. package/dist/lib/hot-channels/sync-lock.js +224 -0
  92. package/dist/lib/install/hook-target.d.ts +6 -2
  93. package/dist/lib/install/hook-target.js +9 -4
  94. package/dist/lib/lanes/loop.d.ts +10 -0
  95. package/dist/lib/lanes/loop.js +18 -1
  96. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -0
  97. package/dist/lib/mesh/live/daemon/work-subscribe.js +26 -6
  98. package/dist/run/hq-plugin.d.ts +3 -0
  99. package/dist/run/hq-plugin.js +18 -0
  100. package/package.json +7 -7
@@ -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'
@@ -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;
@@ -17,6 +17,7 @@ export interface WorkCacheSubscriber {
17
17
  export { AGENT_INBOX_PUSH_STATE_BASENAME, SYNC_WAKE_STATE_BASENAME } from "../paths.js";
18
18
  export type SyncWakeVaults = Record<string, {
19
19
  wakeSequence: number;
20
+ head?: number;
20
21
  }>;
21
22
  export declare function startWorkCacheSubscriber(opts: {
22
23
  meshRoot: string;
@@ -15,6 +15,7 @@ const INBOX_PUSH_HEARTBEAT_MS = 5_000;
15
15
  const SYNC_WAKE_KEYS = new Set(["v", "type", "vault", "head", "rootId"]);
16
16
  const SYNC_WAKE_VAULT = /^(?:prs|cmp|agt)_[0-9A-Za-z]+$/;
17
17
  const SYNC_WAKE_ROOT = /^[0-9a-f]{64}$/i;
18
+ const SYNC_WAKE_MAX_BYTES = 4_096;
18
19
  function parseSyncWakeEnvelope(payload) {
19
20
  let parsed;
20
21
  try {
@@ -38,7 +39,7 @@ function parseSyncWakeEnvelope(payload) {
38
39
  Number(message.head) > 0 &&
39
40
  (message.rootId === null ||
40
41
  (typeof message.rootId === "string" && SYNC_WAKE_ROOT.test(message.rootId)));
41
- return valid ? { vault: message.vault } : null;
42
+ return valid ? { vault: message.vault, head: Number(message.head) } : null;
42
43
  }
43
44
  function readVaultSequences(raw) {
44
45
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -47,9 +48,11 @@ function readVaultSequences(raw) {
47
48
  for (const [vault, entry] of Object.entries(raw)) {
48
49
  if (!SYNC_WAKE_VAULT.test(vault) || !entry || typeof entry !== "object")
49
50
  continue;
50
- const sequence = entry.wakeSequence;
51
+ const { wakeSequence: sequence, head } = entry;
51
52
  if (Number.isSafeInteger(sequence) && Number(sequence) >= 0) {
52
- vaults[vault] = { wakeSequence: Number(sequence) };
53
+ vaults[vault] = Number.isSafeInteger(head) && Number(head) >= 0
54
+ ? { wakeSequence: Number(sequence), head: Number(head) }
55
+ : { wakeSequence: Number(sequence) };
53
56
  }
54
57
  }
55
58
  return vaults;
@@ -85,6 +88,9 @@ function readWakeSequence(meshRoot, basename, label, onInfo) {
85
88
  };
86
89
  }
87
90
  }
91
+ function withoutHeads(vaults) {
92
+ return Object.fromEntries(Object.entries(vaults).map(([vault, entry]) => [vault, { wakeSequence: entry.wakeSequence }]));
93
+ }
88
94
  function writeWakeState(meshRoot, basename, state, durable) {
89
95
  fs.mkdirSync(meshRoot, { recursive: true, mode: 0o700 });
90
96
  const rootInfo = fs.lstatSync(meshRoot);
@@ -162,15 +168,27 @@ export async function startWorkCacheSubscriber(opts) {
162
168
  timers.clearTimeout(heartbeatTimer);
163
169
  heartbeatTimer = null;
164
170
  };
171
+ let syncWakeHeadsOmitted = false;
165
172
  const persistState = (basename, label, connected, wakeSequence, identity, durable = true, vaults) => {
166
173
  try {
167
- writeWakeState(opts.meshRoot, basename, {
174
+ const state = {
168
175
  connected,
169
176
  heartbeatAtMs: now().getTime(),
170
177
  wakeSequence,
171
178
  ...(connected && actorUid && identity ? { [identity]: actorUid } : {}),
172
179
  ...(vaults ? { vaults } : {}),
173
- }, durable);
180
+ };
181
+ if (vaults) {
182
+ const oversized = Buffer.byteLength(`${JSON.stringify(state)}\n`) > SYNC_WAKE_MAX_BYTES;
183
+ if (oversized) {
184
+ state.vaults = withoutHeads(vaults);
185
+ if (!syncWakeHeadsOmitted) {
186
+ opts.onInfo?.(`${label} state over ${SYNC_WAKE_MAX_BYTES} bytes; vault heads omitted`);
187
+ }
188
+ }
189
+ syncWakeHeadsOmitted = oversized;
190
+ }
191
+ writeWakeState(opts.meshRoot, basename, state, durable);
174
192
  }
175
193
  catch (error) {
176
194
  const name = error instanceof Error ? error.name : "UnknownError";
@@ -318,8 +336,10 @@ export async function startWorkCacheSubscriber(opts) {
318
336
  return;
319
337
  }
320
338
  syncWakeSequence += 1;
339
+ const previous = syncWakeVaults[envelope.vault];
321
340
  syncWakeVaults[envelope.vault] = {
322
- wakeSequence: (syncWakeVaults[envelope.vault]?.wakeSequence ?? 0) + 1,
341
+ wakeSequence: (previous?.wakeSequence ?? 0) + 1,
342
+ head: Math.max(previous?.head ?? 0, envelope.head),
323
343
  };
324
344
  persistSyncState(true);
325
345
  return;
@@ -18,6 +18,9 @@ export interface PluginState {
18
18
  }>;
19
19
  loadedSecretsByName: Map<string, string>;
20
20
  }
21
+ /** True when the varlock item has an `# @optional` decorator. */
22
+ export declare function itemIsOptional(item: unknown): boolean;
23
+ export declare function isSkippableOptionalSecretError(code: string | undefined): boolean;
21
24
  export declare function installHqPlugin(graph: any, opts: InstallHqPluginOpts): PluginState;
22
25
  export declare function prewarmHqSecrets(graph: any, opts: InstallHqPluginOpts, state: PluginState): Promise<void>;
23
26
  //# sourceMappingURL=hq-plugin.d.ts.map
@@ -16,6 +16,15 @@ function normalizeCacheTtlMs(secret) {
16
16
  ? secret.cacheTtlMs
17
17
  : 0;
18
18
  }
19
+ const SKIPPABLE_OPTIONAL_CODES = new Set(['forbidden', 'not_found', 'not_returned']);
20
+ /** True when the varlock item has an `# @optional` decorator. */
21
+ export function itemIsOptional(item) {
22
+ const decorators = item?.effectiveDecorators;
23
+ return Boolean(decorators && Object.prototype.hasOwnProperty.call(decorators, 'optional'));
24
+ }
25
+ export function isSkippableOptionalSecretError(code) {
26
+ return Boolean(code && SKIPPABLE_OPTIONAL_CODES.has(code));
27
+ }
19
28
  export function installHqPlugin(graph /* EnvGraph */, opts) {
20
29
  const pluginState = {
21
30
  schemaCompanySlug: null,
@@ -63,6 +72,10 @@ export function installHqPlugin(graph /* EnvGraph */, opts) {
63
72
  }
64
73
  const err = pluginState.errorsByName.get(secretName);
65
74
  if (err) {
75
+ // `# @optional`: missing ACL or a missing secret leaves the var unset.
76
+ if (itemIsOptional(this._ownerItem) && isSkippableOptionalSecretError(err.code)) {
77
+ return undefined;
78
+ }
66
79
  if (err.code === 'forbidden') {
67
80
  throw new ResolutionError(`No read permission for secret "${secretName}" — ask an admin to share it via \`hq secrets share ${secretName} --with <you> --permission read\``);
68
81
  }
@@ -85,13 +98,18 @@ export function installHqPlugin(graph /* EnvGraph */, opts) {
85
98
  if (inMemory != null) {
86
99
  return inMemory;
87
100
  }
101
+ if (itemIsOptional(this._ownerItem)) {
102
+ return undefined;
103
+ }
88
104
  throw new ResolutionError(`Secret "${secretName}" was not returned by vault after server authorization`);
89
105
  },
90
106
  };
91
107
  // Captured during process(parent); used by resolve() to fall back to the var key.
92
108
  _ownerKey;
109
+ _ownerItem;
93
110
  process(parent) {
94
111
  super.process(parent);
112
+ this._ownerItem = parent;
95
113
  if (parent != null && typeof parent.key === 'string') {
96
114
  this._ownerKey = parent.key;
97
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.177",
3
+ "version": "5.345.179",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -114,11 +114,11 @@
114
114
  "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
115
115
  },
116
116
  "optionalDependencies": {
117
- "@indigoai-us/hq-hook-win32-x64": "5.345.177",
118
- "@indigoai-us/hq-hook-win32-arm64": "5.345.177",
119
- "@indigoai-us/hq-hook-darwin-x64": "5.345.177",
120
- "@indigoai-us/hq-hook-darwin-arm64": "5.345.177",
121
- "@indigoai-us/hq-hook-linux-x64": "5.345.177",
122
- "@indigoai-us/hq-hook-linux-arm64": "5.345.177"
117
+ "@indigoai-us/hq-hook-win32-x64": "5.345.179",
118
+ "@indigoai-us/hq-hook-win32-arm64": "5.345.179",
119
+ "@indigoai-us/hq-hook-darwin-x64": "5.345.179",
120
+ "@indigoai-us/hq-hook-darwin-arm64": "5.345.179",
121
+ "@indigoai-us/hq-hook-linux-x64": "5.345.179",
122
+ "@indigoai-us/hq-hook-linux-arm64": "5.345.179"
123
123
  }
124
124
  }