@indigoai-us/hq-cli 5.345.160 → 5.345.161

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 (60) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/assets/scaffold/core/scripts/session-journal.sh +116 -24
  3. package/dist/command-catalog.generated.d.ts +218 -5
  4. package/dist/command-catalog.generated.js +269 -5
  5. package/dist/commands/auth.js +6 -5
  6. package/dist/commands/core-session-helper-runtime.d.ts +1 -0
  7. package/dist/commands/core-session-journal-native.d.ts +1 -0
  8. package/dist/commands/core-session-journal-native.js +158 -34
  9. package/dist/commands/feedback.d.ts +12 -0
  10. package/dist/commands/feedback.js +52 -0
  11. package/dist/commands/files-browse.d.ts +8 -0
  12. package/dist/commands/files-browse.js +83 -1
  13. package/dist/commands/install-global.d.ts +2 -0
  14. package/dist/commands/install-global.js +24 -1
  15. package/dist/commands/integrations-connect.js +1 -0
  16. package/dist/commands/integrations-manage.d.ts +2 -0
  17. package/dist/commands/integrations-manage.js +6 -4
  18. package/dist/commands/lanes-link.js +5 -3
  19. package/dist/commands/lanes-session-start.d.ts +12 -0
  20. package/dist/commands/lanes-session-start.js +93 -12
  21. package/dist/commands/meetings.d.ts +11 -0
  22. package/dist/commands/meetings.js +41 -11
  23. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2486 -1794
  24. package/dist/hq-cloud-sync-runner/package.json +1 -1
  25. package/dist/index.js +21 -0
  26. package/dist/lanes-session-start-argv.d.ts +7 -0
  27. package/dist/lanes-session-start-argv.js +9 -0
  28. package/dist/lib/bot/log.d.ts +3 -2
  29. package/dist/lib/bot/progress.d.ts +23 -1
  30. package/dist/lib/bot/progress.js +43 -5
  31. package/dist/lib/bot/run.js +2 -0
  32. package/dist/lib/bot/runtime/claude.d.ts +12 -0
  33. package/dist/lib/bot/runtime/claude.js +20 -0
  34. package/dist/lib/bot/runtime/index.d.ts +10 -1
  35. package/dist/lib/bot/runtime/index.js +14 -2
  36. package/dist/lib/bot/runtime/types.d.ts +8 -0
  37. package/dist/lib/doctor/checks/ps1-shims.d.ts +21 -0
  38. package/dist/lib/doctor/checks/ps1-shims.js +82 -0
  39. package/dist/lib/doctor/fix/apply.d.ts +13 -1
  40. package/dist/lib/doctor/fix/apply.js +38 -3
  41. package/dist/lib/doctor/fix/remediation.d.ts +9 -1
  42. package/dist/lib/doctor/fix/remediation.js +22 -0
  43. package/dist/lib/doctor/registry.js +2 -0
  44. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  45. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  46. package/dist/lib/lanes/links.d.ts +2 -1
  47. package/dist/lib/lanes/links.js +235 -17
  48. package/dist/lib/lanes/registry-flag.d.ts +24 -0
  49. package/dist/lib/lanes/registry-flag.js +72 -6
  50. package/dist/lib/lanes/store.d.ts +4 -2
  51. package/dist/lib/lanes/store.js +8 -8
  52. package/dist/lib/windows/ps1-shims.d.ts +60 -0
  53. package/dist/lib/windows/ps1-shims.js +152 -0
  54. package/dist/utils/self-update.d.ts +2 -0
  55. package/dist/utils/self-update.js +8 -0
  56. package/dist/utils/staged-npm-install.js +13 -2
  57. package/dist/utils/token-keychain.d.ts +11 -8
  58. package/dist/utils/token-keychain.js +36 -15
  59. package/package.json +8 -8
  60. package/scripts/install-hook-engine.mjs +12 -0
@@ -20,6 +20,8 @@ export interface LinkOptions {
20
20
  timeout?: number;
21
21
  event?: string;
22
22
  input?: string;
23
+ ackMode?: string;
24
+ claims?: string;
23
25
  now?: () => Date;
24
26
  sleep?: (milliseconds: number) => Promise<void>;
25
27
  }
@@ -31,7 +33,6 @@ export declare function resolveLinkSessionId(root: string, explicit: string | un
31
33
  /** Cheap hook guard used before remote flag lookup or queue access. */
32
34
  export declare function linkedSessionIdFromHookInput(input: string): string;
33
35
  export declare function isLinkedSession(root: string, sid: string): boolean;
34
- /** Claims pending files by renaming the whole queue, matching conduct-inbox's no-loss drain. */
35
36
  export declare function drainLinkQueue(inbox: string): string;
36
37
  export declare function runLaneLink(subcommand: string, options: LinkOptions): Promise<LinkResult>;
37
38
  /** Apply the default-on feature gate contract at the command boundary. */
@@ -2,6 +2,8 @@ import { randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { appendDropboxMessageAtPath } from "./dropbox.js";
5
+ import { withLaneRecordFileLock } from "./store.js";
6
+ import { LanesError } from "./errors.js";
5
7
  export const LANE_SESSION_LINKS_FLAG = "lanes.session-links";
6
8
  const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
7
9
  export class LaneLinkError extends Error {
@@ -132,6 +134,17 @@ function resolvePointer(root, sid, role, options) {
132
134
  function queueDir(root, link, child) {
133
135
  return path.join(rootDir(root), link, child ? "children" : "up", ...(child ? [child] : []), "inbox");
134
136
  }
137
+ function withLinkQueueLock(inbox, fn) {
138
+ try {
139
+ return withLaneRecordFileLock(path.join(inbox, "queue"), fn, { reclaimByAge: false });
140
+ }
141
+ catch (error) {
142
+ if (error instanceof LanesError && error.error === "store_lock_timeout") {
143
+ throw new LaneLinkError("queue_busy", "Conductor queue busy; instructions retained");
144
+ }
145
+ throw error;
146
+ }
147
+ }
135
148
  function sendQueue(root, link, child, text) {
136
149
  const inbox = queueDir(root, link, child);
137
150
  const pending = path.join(inbox, "pending");
@@ -139,10 +152,12 @@ function sendQueue(root, link, child, text) {
139
152
  // Reuse hq monitor's session-inbox append primitive; the pending directory is
140
153
  // still claimed as a whole below, keeping conduct-inbox's no-loss handoff.
141
154
  const cleanText = text.replaceAll("\0", "").replaceAll("\u000b", "").replaceAll("\u000c", "").replaceAll("\r", "");
142
- appendDropboxMessageAtPath(pending, { at: new Date().toISOString(), text: cleanText });
155
+ withLinkQueueLock(inbox, () => {
156
+ appendDropboxMessageAtPath(pending, { at: new Date().toISOString(), text: cleanText });
157
+ });
143
158
  }
144
159
  /** Claims pending files by renaming the whole queue, matching conduct-inbox's no-loss drain. */
145
- export function drainLinkQueue(inbox) {
160
+ function drainLinkQueueUnlocked(inbox) {
146
161
  const pending = path.join(inbox, "pending");
147
162
  const claimedRoot = path.join(inbox, "claimed");
148
163
  // Recover claims interrupted before the delivery result was committed. This is
@@ -190,16 +205,197 @@ export function drainLinkQueue(inbox) {
190
205
  fs.writeFileSync(path.join(claim, ".consumed"), "delivered\n", { mode: 0o600 });
191
206
  return messages;
192
207
  }
208
+ export function drainLinkQueue(inbox) {
209
+ return withLinkQueueLock(inbox, () => drainLinkQueueUnlocked(inbox));
210
+ }
211
+ /** Claims remain replayable until the recipient explicitly accepts instructions.
212
+ * This is at-least-once transport, not proof of a native turn or execution.
213
+ */
214
+ function claimLinkQueueUnlocked(inbox) {
215
+ const pending = path.join(inbox, "pending");
216
+ const claimed = path.join(inbox, "unaccepted");
217
+ fs.mkdirSync(claimed, { recursive: true });
218
+ if (fs.existsSync(path.join(pending, "inbox.jsonl"))) {
219
+ // Allocate while holding the queue lock. Wall time alone can repeat or
220
+ // move backward, and pid/random suffixes do not preserve claim order.
221
+ let serial = 0n;
222
+ for (const previous of fs.readdirSync(claimed)) {
223
+ const prefix = /^(\d{1,16})-/.exec(previous)?.[1];
224
+ if (prefix && BigInt(prefix) > serial)
225
+ serial = BigInt(prefix);
226
+ }
227
+ const wall = Date.now();
228
+ if (!Number.isSafeInteger(wall) || wall < 0)
229
+ throw new LaneLinkError("claim_clock_invalid", "Cannot allocate a safe receipt serial");
230
+ serial = BigInt(wall) > serial ? BigInt(wall) : serial + 1n;
231
+ if (serial > 9999999999999999n)
232
+ throw new LaneLinkError("claim_serial_exhausted", "Receipt serial exhausted; instructions retained");
233
+ const id = `${serial.toString().padStart(16, "0")}-${process.pid}-${randomBytes(8).toString("hex")}`;
234
+ const dir = path.join(claimed, id);
235
+ fs.mkdirSync(dir);
236
+ // The shared queue lock joins the producer's open/write/close before this
237
+ // immutable snapshot is read. Keep the producer directory stable too.
238
+ try {
239
+ fs.renameSync(path.join(pending, "inbox.jsonl"), path.join(dir, "inbox.jsonl"));
240
+ }
241
+ catch (error) {
242
+ if (error.code !== "ENOENT")
243
+ throw error;
244
+ }
245
+ }
246
+ const claims = [];
247
+ const messages = [];
248
+ const ordered = fs.readdirSync(claimed).sort((a, b) => {
249
+ const left = /^(\d{1,16})-/.exec(a)?.[1];
250
+ const right = /^(\d{1,16})-/.exec(b)?.[1];
251
+ if (left && right && BigInt(left) !== BigInt(right))
252
+ return BigInt(left) < BigInt(right) ? -1 : 1;
253
+ return a.localeCompare(b);
254
+ });
255
+ for (const id of ordered) {
256
+ if (!safeId(id))
257
+ continue;
258
+ const dir = path.join(claimed, id);
259
+ if (fs.existsSync(path.join(dir, ".consumed")))
260
+ continue;
261
+ let text;
262
+ try {
263
+ text = fs.readFileSync(path.join(dir, "inbox.jsonl"), "utf8");
264
+ }
265
+ catch (error) {
266
+ if (error.code === "ENOENT")
267
+ continue;
268
+ throw error;
269
+ }
270
+ const rows = [];
271
+ const invalidRows = [];
272
+ text.split(/\r?\n/).forEach((line, index) => {
273
+ if (!line)
274
+ return;
275
+ try {
276
+ const row = JSON.parse(line);
277
+ if (!row || typeof row !== "object" || typeof row.text !== "string")
278
+ throw new Error("invalid row");
279
+ rows.push(row.text);
280
+ }
281
+ catch {
282
+ invalidRows.push(index + 1);
283
+ }
284
+ });
285
+ if (invalidRows.length) {
286
+ // The immutable original is the quarantine evidence. Record positions,
287
+ // never malformed contents, and retain both even after valid rows' ACK.
288
+ const audit = path.join(dir, "invalid-rows.json");
289
+ const expectedAudit = JSON.stringify({ version: 1, rows: invalidRows }) + "\n";
290
+ let previousAudit;
291
+ try {
292
+ previousAudit = fs.readFileSync(audit, "utf8");
293
+ }
294
+ catch (error) {
295
+ if (error.code !== "ENOENT")
296
+ throw error;
297
+ }
298
+ if (previousAudit !== expectedAudit) {
299
+ const temporary = `${audit}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
300
+ try {
301
+ const fd = fs.openSync(temporary, "wx", 0o600);
302
+ try {
303
+ fs.writeFileSync(fd, expectedAudit);
304
+ fs.fsyncSync(fd);
305
+ }
306
+ finally {
307
+ fs.closeSync(fd);
308
+ }
309
+ fs.renameSync(temporary, audit);
310
+ }
311
+ finally {
312
+ fs.rmSync(temporary, { force: true });
313
+ }
314
+ }
315
+ // Retry all durability barriers even if a previous attempt wrote the
316
+ // audit but failed before publishing its directory entry durably.
317
+ for (const file of [path.join(dir, "inbox.jsonl"), audit]) {
318
+ const fd = fs.openSync(file, "r");
319
+ try {
320
+ fs.fsyncSync(fd);
321
+ }
322
+ finally {
323
+ fs.closeSync(fd);
324
+ }
325
+ }
326
+ for (const directory of [dir, claimed]) {
327
+ try {
328
+ const fd = fs.openSync(directory, "r");
329
+ try {
330
+ fs.fsyncSync(fd);
331
+ }
332
+ finally {
333
+ fs.closeSync(fd);
334
+ }
335
+ }
336
+ catch (error) {
337
+ // Same known unsupported directory-flush codes as lanes store.
338
+ // File flush errors and unknown directory errors remain fatal.
339
+ if (!["EISDIR", "EINVAL", "ENOTSUP"].includes(error.code ?? ""))
340
+ throw error;
341
+ }
342
+ }
343
+ }
344
+ if (!rows.length)
345
+ continue;
346
+ claims.push(id);
347
+ messages.push(...rows);
348
+ }
349
+ return { messages: messages.join("\n"), claims };
350
+ }
351
+ function claimLinkQueue(inbox) {
352
+ return withLinkQueueLock(inbox, () => claimLinkQueueUnlocked(inbox));
353
+ }
354
+ function acknowledgeLinkClaims(inbox, claims) {
355
+ return withLinkQueueLock(inbox, () => acknowledgeLinkClaimsUnlocked(inbox, claims));
356
+ }
357
+ function acknowledgeLinkClaimsUnlocked(inbox, claims) {
358
+ if (!Array.isArray(claims) || !claims.length || claims.some(id => typeof id !== "string" || !safeId(id))) {
359
+ throw new LaneLinkError("claim_invalid", "An exact nonempty claim receipt is required");
360
+ }
361
+ // Validate the entire receipt before changing any claim. Never resolve paths
362
+ // outside the current session's authoritative queue.
363
+ for (const id of claims) {
364
+ if (!fs.existsSync(path.join(inbox, "unaccepted", id, "inbox.jsonl"))) {
365
+ throw new LaneLinkError("claim_not_found", "Claim does not belong to this queue");
366
+ }
367
+ }
368
+ for (const id of claims) {
369
+ fs.writeFileSync(path.join(inbox, "unaccepted", id, ".consumed"), "recipient-accepted\n", { mode: 0o600 });
370
+ }
371
+ }
372
+ function countQuarantined(inbox) {
373
+ const claims = path.join(inbox, "unaccepted");
374
+ if (!fs.existsSync(claims))
375
+ return 0;
376
+ return fs.readdirSync(claims).filter(id => safeId(id) && fs.existsSync(path.join(claims, id, "invalid-rows.json"))).length;
377
+ }
193
378
  function countPending(inbox) {
194
- const payload = path.join(inbox, "pending", "inbox.jsonl");
195
- try {
196
- return fs.readFileSync(payload, "utf8").split(/\r?\n/).filter(Boolean).length;
379
+ const paths = [path.join(inbox, "pending", "inbox.jsonl")];
380
+ for (const name of ["claimed", "unaccepted"]) {
381
+ const claimed = path.join(inbox, name);
382
+ if (fs.existsSync(claimed))
383
+ for (const id of fs.readdirSync(claimed)) {
384
+ if (!fs.existsSync(path.join(claimed, id, ".consumed")))
385
+ paths.push(path.join(claimed, id, "inbox.jsonl"));
386
+ }
197
387
  }
198
- catch (error) {
199
- if (error.code === "ENOENT")
200
- return 0;
201
- throw error;
388
+ let count = 0;
389
+ for (const file of paths) {
390
+ try {
391
+ count += fs.readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean).length;
392
+ }
393
+ catch (error) {
394
+ if (error.code !== "ENOENT")
395
+ throw error;
396
+ }
202
397
  }
398
+ return count;
203
399
  }
204
400
  function readText(options) {
205
401
  if (options.textFile) {
@@ -244,7 +440,7 @@ function listChildren(root, dir) {
244
440
  if (!fs.existsSync(metaPath))
245
441
  return [];
246
442
  const unread = countPending(path.join(cdir, "inbox"));
247
- return [{ ...readJson(metaPath), undelivered: unread }];
443
+ return [{ ...readJson(metaPath), undelivered: unread, quarantined_claims: countQuarantined(path.join(cdir, "inbox")) }];
248
444
  });
249
445
  }
250
446
  async function waitForReport(dir, timeoutSeconds, sleep) {
@@ -288,6 +484,7 @@ export async function runLaneLink(subcommand, options) {
288
484
  const root = options.hqRoot;
289
485
  let sid = "";
290
486
  let childContext = false;
487
+ let stopHookActive = false;
291
488
  if (subcommand === "_deliver") {
292
489
  let payload = {};
293
490
  try {
@@ -299,6 +496,7 @@ export async function runLaneLink(subcommand, options) {
299
496
  const value = payload.session_id ?? payload.sessionId;
300
497
  sid = typeof value === "string" && safeId(value) ? value : "";
301
498
  childContext = isChildHookPayload(payload);
499
+ stopHookActive = payload.stop_hook_active === true || payload.stopHookActive === true;
302
500
  }
303
501
  const requireSid = () => sid || (sid = resolveLinkSessionId(root, options.sessionId, env));
304
502
  const links = rootDir(root);
@@ -307,21 +505,41 @@ export async function runLaneLink(subcommand, options) {
307
505
  // drain the root's queue into the subagent, so leave the message queued.
308
506
  if (!sid || childContext || !["PostToolUse", "Stop"].includes(options.event ?? ""))
309
507
  return {};
310
- if (options.event === "Stop" && env.HQ_STOP_DECISION_DELIVERABLE === "0")
508
+ if (options.event === "Stop" && (stopHookActive || env.HQ_STOP_DECISION_DELIVERABLE === "0"))
311
509
  return {};
312
510
  const pointer = readPointer(root, sid);
313
511
  if (!pointer)
314
512
  return {};
315
513
  const target = hookMessage(root, pointer);
316
- const messages = drainLinkQueue(target.inbox);
514
+ if (options.ackMode !== undefined && options.ackMode !== "explicit")
515
+ throw new LaneLinkError("ack_mode_invalid", "Unsupported acknowledgement mode");
516
+ const claim = options.ackMode === "explicit" ? claimLinkQueue(target.inbox) : undefined;
517
+ const messages = claim ? claim.messages : drainLinkQueue(target.inbox);
317
518
  if (!messages)
318
519
  return {};
319
- const body = target.role === "conductor"
520
+ const receipt = claim ? { _hqLinkDelivery: { version: 1, sessionId: sid, link: pointer.link, child: pointer.name ?? null, claims: claim.claims } } : {};
521
+ const acceptance = claim ? `\n\n[conduct receipt] Stable instruction receipt IDs: ${claim.claims.join(",")}. Treat replayed IDs as the same instruction, not new execution authority. After accepting these instructions, acknowledge receipt once with: hq lanes link ack --session-id ${sid} --link ${pointer.link}${pointer.name ? ` --child ${pointer.name}` : ""} --claims ${claim.claims.join(",")}. Receipt acceptance does not approve actions, answer owner pickers, or mean work is completed.` : "";
522
+ const body = (target.role === "conductor"
320
523
  ? `[conduct] Sessions you are conducting reported while you were working.\nEach report names the session it came from. Relay what the operator needs to know, and answer a session with: hq lanes link send --child <name> --text '...'\n\n${messages}`
321
- : `[conduct] The session conducting you sent this while you were working.\nTreat it as a new instruction from the operator, taking precedence over your current task where the two conflict. When you have something to tell the conductor - a result, a blocker, a question - send it with: hq lanes link report --state <working|blocked|idle|done> --text '...'\n\n${messages}`;
524
+ : `[conduct] The session conducting you sent this while you were working.\nTreat it as a new instruction from the operator, taking precedence over your current task where the two conflict. When you have something to tell the conductor - a result, a blocker, a question - send it with: hq lanes link report --state <working|blocked|idle|done> --text '...'\n\n${messages}`) + acceptance;
322
525
  return options.event === "PostToolUse"
323
- ? { output: { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: body } } }
324
- : { output: { decision: "block", reason: body } };
526
+ ? { output: { ...receipt, hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: body } } }
527
+ : { output: { ...receipt, decision: "block", reason: body } };
528
+ }
529
+ if (subcommand === "capabilities") {
530
+ return { output: { protocol: "conduct-recipient-acceptance/1", explicitClaim: true, recipientAck: true, idleWake: false } };
531
+ }
532
+ if (subcommand === "ack") {
533
+ const currentSid = requireSid();
534
+ const pointer = readPointer(root, currentSid);
535
+ if (!pointer)
536
+ throw new LaneLinkError("session_unlinked", "Session is not linked");
537
+ if (!options.link || options.link !== pointer.link || (options.child ?? null) !== (pointer.name ?? null)) {
538
+ throw new LaneLinkError("claim_owner_invalid", "Receipt does not match the current link/child binding");
539
+ }
540
+ const claims = options.claims?.split(",");
541
+ acknowledgeLinkClaims(hookMessage(root, pointer).inbox, claims);
542
+ return { output: { acknowledged: true, acceptance: "recipient_instruction_only" } };
325
543
  }
326
544
  if (subcommand === "open") {
327
545
  const sessionId = requireSid();
@@ -438,7 +656,7 @@ export async function runLaneLink(subcommand, options) {
438
656
  const dir = linkPath(root, link);
439
657
  const meta = readJson(path.join(dir, "meta.json"));
440
658
  const unread = countPending(path.join(dir, "up", "inbox"));
441
- return { output: { ...meta, unread_reports: unread, children: listChildren(root, dir) } };
659
+ return { output: { ...meta, unread_reports: unread, quarantined_reports: countQuarantined(path.join(dir, "up", "inbox")), children: listChildren(root, dir) } };
442
660
  }
443
661
  if (subcommand === "leave") {
444
662
  const sessionId = requireSid();
@@ -1,5 +1,6 @@
1
1
  import { type FlagClientOptions, type FlagResolution } from "@indigoai-us/hq-flags-client";
2
2
  import type { LaneCompanyRef } from "./types.js";
3
+ import { type PersistedPersonFlagSnapshot } from "../flag-snapshot-file.js";
3
4
  export declare const LANES_FLAG_API_DEFAULT_ENDPOINT = "https://hqapi.hq.computer";
4
5
  export declare const LANES_FLAG_READ_TIMEOUT_MS = 5000;
5
6
  export interface LanesFlagClient {
@@ -64,5 +65,28 @@ export declare function readLanesRegistryFlagFailOpen(flagKey: string, options?:
64
65
  */
65
66
  export declare function readLanesRegistryFlagFailOff(flagKey: string, options?: ReadLanesFlagOptions, reader?: typeof readLanesRegistryFlag): Promise<boolean>;
66
67
  export declare function _resetThrownFlagReadWarningsForTests(): void;
68
+ /** Offline value for a company-less lanes flag, with how long ago the registry confirmed it. */
69
+ export interface OfflineLanesFlagRead {
70
+ value: boolean;
71
+ ageMs: number;
72
+ source: "local-override" | "lanes-cache" | "person-snapshot";
73
+ }
74
+ export interface ReadLanesFlagOfflineOptions {
75
+ defaultValue?: boolean;
76
+ env?: NodeJS.ProcessEnv;
77
+ /** Last-known-good cache directory; null disables it. Defaults to the HQ state dir. */
78
+ cacheDir?: string | null;
79
+ now?: number;
80
+ /** Person-scope flag snapshot reader. Defaults to the snapshot under the env's cache home. */
81
+ readPersonSnapshot?: () => PersistedPersonFlagSnapshot | null;
82
+ }
83
+ /**
84
+ * Read a company-less lanes flag without any network request. Order: local
85
+ * override, then the lanes last-known-good cache, then the person flag
86
+ * snapshot other hq commands keep (default registry endpoint only). Returns
87
+ * null when no value was confirmed within LANES_FLAG_CACHE_MAX_AGE_MS; the
88
+ * caller decides the fallback and whether to refresh in the background.
89
+ */
90
+ export declare function readLanesRegistryFlagOffline(flagKey: string, options?: ReadLanesFlagOfflineOptions): OfflineLanesFlagRead | null;
67
91
  export declare function _resetLanesRegistryFlagCacheForTests(): void;
68
92
  //# sourceMappingURL=registry-flag.d.ts.map
@@ -1,8 +1,8 @@
1
1
  import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { ensureCognitoIdToken, loadCachedTokens, refreshActivePersonTokens, wantsMachineCognitoTokens, } from "../../utils/cognito-session.js";
5
4
  import { FLAG_REGISTRY_DEFAULT_ENDPOINT, resolveFlagRegistryEndpoint, } from "../flag-registry-endpoint.js";
5
+ import { readPersonFlagSnapshotFile } from "../flag-snapshot-file.js";
6
6
  export const LANES_FLAG_API_DEFAULT_ENDPOINT = FLAG_REGISTRY_DEFAULT_ENDPOINT;
7
7
  export const LANES_FLAG_READ_TIMEOUT_MS = 5_000;
8
8
  export function readLanesFlagLocalOverride(flagKey, env = process.env) {
@@ -55,7 +55,7 @@ function readFlagCacheFile(dir) {
55
55
  return {};
56
56
  }
57
57
  }
58
- function readCachedFlagValue(dir, key, now = Date.now()) {
58
+ function readCachedFlagEntry(dir, key, now = Date.now()) {
59
59
  const entry = readFlagCacheFile(dir)[key];
60
60
  if (!entry || typeof entry.value !== "boolean" || typeof entry.resolvedAt !== "string")
61
61
  return null;
@@ -66,9 +66,15 @@ function readCachedFlagValue(dir, key, now = Date.now()) {
66
66
  // A future timestamp (clock skew or a hand-edited file) is not trusted either.
67
67
  if (age < 0 || age > LANES_FLAG_CACHE_MAX_AGE_MS)
68
68
  return null;
69
- return entry.value;
69
+ return { value: entry.value, missing: entry.missing === true, ageMs: age };
70
70
  }
71
- function writeCachedFlagValue(dir, key, value) {
71
+ function readCachedFlagValue(dir, key, defaultValue) {
72
+ const entry = readCachedFlagEntry(dir, key);
73
+ if (!entry)
74
+ return null;
75
+ return entry.missing ? defaultValue : entry.value;
76
+ }
77
+ function writeCachedFlagValue(dir, key, value, missing = false) {
72
78
  let tmp;
73
79
  try {
74
80
  const entries = readFlagCacheFile(dir);
@@ -76,7 +82,7 @@ function writeCachedFlagValue(dir, key, value) {
76
82
  // measures time since the registry last confirmed it. Delete first so the
77
83
  // key moves to the end and eviction drops the least recently confirmed.
78
84
  delete entries[key];
79
- entries[key] = { value, resolvedAt: new Date().toISOString() };
85
+ entries[key] = { value, resolvedAt: new Date().toISOString(), ...(missing ? { missing: true } : {}) };
80
86
  const keys = Object.keys(entries);
81
87
  for (const stale of keys.slice(0, Math.max(0, keys.length - FLAG_CACHE_MAX_ENTRIES)))
82
88
  delete entries[stale];
@@ -237,7 +243,13 @@ function classifyAttempt(attempt) {
237
243
  return "transient";
238
244
  return "permanent";
239
245
  }
246
+ // Loaded on first token use: the module costs ~350 ms to import, and offline
247
+ // readers such as the SessionStart hook never need a token.
248
+ function loadCognitoSession() {
249
+ return import("../../utils/cognito-session.js");
250
+ }
240
251
  async function defaultRefreshToken() {
252
+ const { ensureCognitoIdToken, loadCachedTokens, refreshActivePersonTokens, wantsMachineCognitoTokens } = await loadCognitoSession();
241
253
  if (wantsMachineCognitoTokens()) {
242
254
  return ensureCognitoIdToken({ interactive: false });
243
255
  }
@@ -250,6 +262,7 @@ async function defaultRefreshToken() {
250
262
  function defaultAcquireToken() {
251
263
  return async () => {
252
264
  try {
265
+ const { ensureCognitoIdToken } = await loadCognitoSession();
253
266
  return await ensureCognitoIdToken({ interactive: false });
254
267
  }
255
268
  catch (error) {
@@ -388,7 +401,7 @@ async function readRegistryValue(flagKey, endpoint, key, options) {
388
401
  // 4xx, and a missing token mean the registry (or the session) refused the
389
402
  // read, so it fails closed to the caller's default instead.
390
403
  const transient = classifyAttempt(attempt) === "transient";
391
- const cachedValue = transient && cacheDir ? readCachedFlagValue(cacheDir, key) : null;
404
+ const cachedValue = transient && cacheDir ? readCachedFlagValue(cacheDir, key, options.defaultValue ?? false) : null;
392
405
  if (cachedValue !== null) {
393
406
  return {
394
407
  result: { state: "resolved", value: cachedValue },
@@ -397,8 +410,61 @@ async function readRegistryValue(flagKey, endpoint, key, options) {
397
410
  }
398
411
  return { result: { state: "unknown" }, failure: { ...attempt.failure, diagnostics } };
399
412
  }
413
+ // A clean read that fell back means the registry has no row for this flag.
414
+ // Remember that, so offline readers can apply their default without a request.
415
+ if (attempt.resolution?.source === "fallback" && cacheDir)
416
+ writeCachedFlagValue(cacheDir, key, attempt.resolution.value, true);
400
417
  return { result: { state: "resolved", value: attempt.resolution?.value ?? false } };
401
418
  }
419
+ function defaultPersonSnapshotReader(env, now) {
420
+ return () => {
421
+ // Use only a cache home the env names, so a hermetic env never reads the real home.
422
+ const home = env.HOME?.trim() || env.USERPROFILE?.trim();
423
+ const cacheHome = env.XDG_CACHE_HOME?.trim() || (home ? path.join(home, ".cache") : "");
424
+ if (!cacheHome)
425
+ return null;
426
+ return readPersonFlagSnapshotFile({ env: { ...env, XDG_CACHE_HOME: cacheHome }, now, maxAgeMs: LANES_FLAG_CACHE_MAX_AGE_MS });
427
+ };
428
+ }
429
+ /**
430
+ * Read a company-less lanes flag without any network request. Order: local
431
+ * override, then the lanes last-known-good cache, then the person flag
432
+ * snapshot other hq commands keep (default registry endpoint only). Returns
433
+ * null when no value was confirmed within LANES_FLAG_CACHE_MAX_AGE_MS; the
434
+ * caller decides the fallback and whether to refresh in the background.
435
+ */
436
+ export function readLanesRegistryFlagOffline(flagKey, options = {}) {
437
+ const env = options.env ?? process.env;
438
+ const defaultValue = options.defaultValue ?? false;
439
+ const now = options.now ?? Date.now();
440
+ const localOverride = readLanesFlagLocalOverride(flagKey, env);
441
+ if (localOverride !== null)
442
+ return { value: localOverride, ageMs: 0, source: "local-override" };
443
+ const endpoint = resolveFlagRegistryEndpoint(undefined, env);
444
+ const cacheDir = flagCacheDir({ env, ...(options.cacheDir !== undefined ? { cacheDir: options.cacheDir } : {}) });
445
+ const cached = cacheDir ? readCachedFlagEntry(cacheDir, cacheKey(flagKey, null, endpoint), now) : null;
446
+ let snapshot = null;
447
+ if (endpoint === FLAG_REGISTRY_DEFAULT_ENDPOINT) {
448
+ try {
449
+ snapshot = (options.readPersonSnapshot ?? defaultPersonSnapshotReader(env, now))();
450
+ }
451
+ catch {
452
+ snapshot = null;
453
+ }
454
+ }
455
+ const snapshotAge = snapshot ? now - Date.parse(snapshot.fetched_at) : Number.NaN;
456
+ const snapshotUsable = snapshot !== null && Number.isFinite(snapshotAge) && snapshotAge >= 0 &&
457
+ snapshotAge <= LANES_FLAG_CACHE_MAX_AGE_MS;
458
+ // Prefer whichever source the registry confirmed most recently.
459
+ if (cached && (!snapshotUsable || cached.ageMs <= snapshotAge)) {
460
+ return { value: cached.missing ? defaultValue : cached.value, ageMs: cached.ageMs, source: "lanes-cache" };
461
+ }
462
+ if (snapshotUsable) {
463
+ const value = snapshot.flags[flagKey];
464
+ return { value: typeof value === "boolean" ? value : defaultValue, ageMs: snapshotAge, source: "person-snapshot" };
465
+ }
466
+ return null;
467
+ }
402
468
  function reportRegistryFailure(flagKey, key, outcome, options, failOpen) {
403
469
  if (!outcome.failure)
404
470
  return;
@@ -40,9 +40,11 @@ export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname"> &
40
40
  export declare function mintQuestionId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
41
41
  export declare function appendCappedHistory<T>(history: readonly T[], entry: T, cap?: number): T[];
42
42
  /** Test seam: run withRecordLock with overridden deps and a shorter timeout. */
43
- export declare function withRecordLockForTests<T>(recordPath: string, overrides: Partial<StoreDeps>, fn: () => T, timeoutMs?: number): T;
43
+ export declare function withRecordLockForTests<T>(recordPath: string, overrides: Partial<StoreDeps>, fn: () => T, timeoutMs?: number, reclaimByAge?: boolean): T;
44
44
  /** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
45
- export declare function withLaneRecordFileLock<T>(recordPath: string, fn: () => T): T;
45
+ export declare function withLaneRecordFileLock<T>(recordPath: string, fn: () => T, options?: {
46
+ reclaimByAge?: boolean;
47
+ }): T;
46
48
  export declare function parseLaneRecord(raw: unknown, filePath?: string): LaneRecord;
47
49
  export declare function parseQuestionRecord(raw: unknown, filePath?: string): QuestionRecord;
48
50
  /**
@@ -183,7 +183,7 @@ function sameFile(a, b) {
183
183
  * (another process reclaimed it and took a fresh lock in between), the fresh
184
184
  * lock is linked back into place and nothing is reclaimed.
185
185
  */
186
- function recordLockIsStale(lockPath, deps) {
186
+ function recordLockIsStale(lockPath, deps, reclaimByAge = true) {
187
187
  const statSync = deps.statSync ?? ((p) => fs.statSync(p));
188
188
  const linkSync = deps.linkSync ?? fs.linkSync.bind(fs);
189
189
  const before = statSync(lockPath);
@@ -199,7 +199,7 @@ function recordLockIsStale(lockPath, deps) {
199
199
  const mtimeAge = now - before.mtimeMs;
200
200
  const age = Number.isFinite(ts) ? Math.max(now - ts, mtimeAge) : mtimeAge;
201
201
  const stale = (pidKnown && sameHost && pid !== process.pid && !lockOwnerAlive(pid)) ||
202
- age > lockStaleMs();
202
+ (reclaimByAge && age > lockStaleMs());
203
203
  if (!stale)
204
204
  return false;
205
205
  const parked = `${lockPath}.stale.${process.pid}.${randomBytes(6).toString("hex")}`;
@@ -243,7 +243,7 @@ function recordLockIsStale(lockPath, deps) {
243
243
  removeParked();
244
244
  return reclaimed;
245
245
  }
246
- function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS) {
246
+ function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS, reclaimByAge = true) {
247
247
  const lockPath = lockPathFor(recordPath);
248
248
  const statSync = deps.statSync ?? ((p) => fs.statSync(p));
249
249
  const fstatSync = deps.fstatSync ?? ((fd) => fs.fstatSync(fd));
@@ -295,7 +295,7 @@ function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS) {
295
295
  throw new LanesError("store_lock_timeout", `Timed out waiting for lock ${lockPath}`, { path: lockPath });
296
296
  }
297
297
  try {
298
- if (recordLockIsStale(lockPath, deps))
298
+ if (recordLockIsStale(lockPath, deps, reclaimByAge))
299
299
  continue;
300
300
  }
301
301
  catch (statErr) {
@@ -309,12 +309,12 @@ function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS) {
309
309
  }
310
310
  }
311
311
  /** Test seam: run withRecordLock with overridden deps and a shorter timeout. */
312
- export function withRecordLockForTests(recordPath, overrides, fn, timeoutMs) {
313
- return withRecordLock(recordPath, { ...defaultStoreDeps(), ...overrides }, fn, timeoutMs);
312
+ export function withRecordLockForTests(recordPath, overrides, fn, timeoutMs, reclaimByAge = true) {
313
+ return withRecordLock(recordPath, { ...defaultStoreDeps(), ...overrides }, fn, timeoutMs, reclaimByAge);
314
314
  }
315
315
  /** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
316
- export function withLaneRecordFileLock(recordPath, fn) {
317
- return withRecordLock(recordPath, defaultStoreDeps(), fn);
316
+ export function withLaneRecordFileLock(recordPath, fn, options = {}) {
317
+ return withRecordLock(recordPath, defaultStoreDeps(), fn, LOCK_TIMEOUT_MS, options.reclaimByAge ?? true);
318
318
  }
319
319
  function readJsonFile(filePath, deps) {
320
320
  let text;
@@ -0,0 +1,60 @@
1
+ /** hq-cli's own bins and the package file each npm launcher runs. */
2
+ export declare const HQ_CLI_BINS: Readonly<Record<string, string>>;
3
+ /**
4
+ * True when `content` is npm's (cmd-shim) PowerShell launcher for the hq-cli
5
+ * bin `bin`. The launcher resolves its own folder, runs node on
6
+ * `$basedir/node_modules/@indigoai-us/hq-cli/<entry>` and exits with node's
7
+ * code. Either slash direction is accepted.
8
+ */
9
+ export declare function isNpmGeneratedHqCliPs1Shim(content: string, bin: string): boolean;
10
+ export interface Ps1ShimFs {
11
+ readFile: (file: string) => string;
12
+ remove: (file: string) => void;
13
+ }
14
+ /** The npm-generated hq-cli `.ps1` launchers present in `binDir`. */
15
+ export declare function findHqCliPs1Shims(binDir: string, io?: Ps1ShimFs): string[];
16
+ export interface RemovePs1ShimsResult {
17
+ /** Launchers removed by this call. */
18
+ removed: string[];
19
+ /** `.ps1` files with an hq-cli bin name that are not npm's launcher. */
20
+ kept: string[];
21
+ /** Launchers that could not be removed, with the reason. */
22
+ failed: Array<{
23
+ file: string;
24
+ reason: string;
25
+ }>;
26
+ }
27
+ /**
28
+ * Remove the npm-generated `.ps1` launchers for hq-cli's bins from `binDir`
29
+ * (the npm global prefix on Windows). Safe to run again: a missing file is
30
+ * not an error. Never touches another package's `.ps1` or a hand-written one.
31
+ */
32
+ export declare function removeHqCliPs1Shims(binDir: string, io?: Ps1ShimFs): RemovePs1ShimsResult;
33
+ /**
34
+ * True when the effective execution policy blocks npm's unsigned launcher.
35
+ * `Undefined` as the effective policy means no scope set one, which Windows
36
+ * client editions treat as `Restricted`.
37
+ */
38
+ export declare function policyBlocksUnsignedScripts(policy: string): boolean;
39
+ export type ExecutionPolicyRun = () => {
40
+ status: number | null;
41
+ stdout: string;
42
+ };
43
+ /**
44
+ * Read the current user's effective PowerShell execution policy. Read-only:
45
+ * it runs `Get-ExecutionPolicy` and never sets anything. Null when
46
+ * PowerShell could not be run or printed nothing usable.
47
+ */
48
+ export declare function readExecutionPolicy(run?: ExecutionPolicyRun): string | null;
49
+ /**
50
+ * Postinstall entry: after a global npm install on Windows, remove hq-cli's
51
+ * `.ps1` launchers from the prefix holding `packageDir`. Acts only when the
52
+ * package sits at `<prefix>/node_modules/@indigoai-us/hq-cli`, npm's Windows
53
+ * global layout. Null when it did nothing. npm 11.16+ may skip lifecycle
54
+ * scripts, so `hq doctor` reports any launcher left behind.
55
+ */
56
+ export declare function removePs1ShimsAfterNpmInstall(packageDir: string, opts: {
57
+ platform: NodeJS.Platform;
58
+ global: boolean;
59
+ }, io?: Ps1ShimFs): RemovePs1ShimsResult | null;
60
+ //# sourceMappingURL=ps1-shims.d.ts.map