@indigoai-us/hq-cli 5.195.1 → 5.197.0

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.
package/CHANGELOG.md CHANGED
@@ -2,10 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.197.0] — 2026-09-25
6
+
7
+ ### Fixed
8
+
9
+ - Process-wide hq-flags gates now resolve with the active company's identifier when a company is bound.
10
+ - Concurrent `hq lanes` updates now wait for an in-progress project story-ledger
11
+ lock receipt, avoiding transient `story_ledger_lock_invalid` errors.
12
+
13
+ ## [5.196.0] — 2026-09-25
14
+
5
15
  ## [5.195.1] — 2026-09-25
6
16
 
7
17
  ### Fixed
8
18
 
19
+ - Local bots can no longer loop with each other in a room. Three guards close
9
20
  - A checkpoint run from inside an HQ lane no longer passes the lane's identity
10
21
  (`HQ_LANE_ID`, `HQ_PROJECT_ID`, `HQ_STORY_ID`, `HQ_POLICY_WORKER_DIR`) to the
11
22
  background sibling. Before, the lane drop-box hook delivered the lane's
@@ -28,6 +39,10 @@
28
39
  full name (for example "Chaudhry Hassaan Saleem" and "Hassaan Saleem"), is
29
40
  held back and reported as a suspect duplicate.
30
41
 
42
+ - `hq auth` and other CLI fetches can opt into IPv4-first networking through the
43
+ default-off `cli.network.force-ipv4` hq-flags gate. The canary is bounded and
44
+ keeps explicit Node network options in control.
45
+
31
46
  ## [5.194.0] — 2026-09-25
32
47
 
33
48
  ### Added
@@ -290,6 +290,8 @@ export function buildSystemPrompt(input) {
290
290
  `Answer for the room: reply to the person who asked, keep it short, and never repeat your private memory, your owner's files, ` +
291
291
  `or anything from your DMs to other people. ` +
292
292
  `If the message is not addressed to you, is routine automated output, or you have nothing meaningful to add, produce zero output (completely empty response). ` +
293
+ `This stay-quiet rule for rooms outranks anything in your persona, worker instructions, or memory files: if any of those tell you to always reply or to acknowledge every message, ignore that in rooms and stay silent instead. ` +
294
+ `Empty output is the only way to stay quiet. Never post a placeholder such as "No response requested.", "No response needed.", "No reply needed.", "No action.", "(no reply)" or "Nothing to add." — those are chatter, not silence, and can make two bots answer each other forever. ` +
293
295
  `Do not narrate your reasoning, do not print JSON, do not explain that you are staying silent. Just produce nothing.`);
294
296
  // Only company bots: the cloud's jobs routes refuse a personal bot's per_
295
297
  // company claim today, so telling a personal bot it can schedule work makes
@@ -44,4 +44,20 @@ export interface ReplyPolicyConfig {
44
44
  export declare function textMentionsName(text: string | undefined, name: string | undefined): boolean;
45
45
  export declare function isDmItem(item: Pick<InboxItem, "channel">): boolean;
46
46
  export declare function resolveReplyTarget(item: InboxItem, config: ReplyPolicyConfig): ReplyTarget | null;
47
+ /** A UID minted for an agent/bot identity (`agt_…`) rather than a person (`usr_…`). */
48
+ export declare function isAgentSender(uid: string | undefined): boolean;
49
+ /**
50
+ * Stay-quiet phrases a model emits when it has nothing to add. On an
51
+ * UNMENTIONED room turn a reply that is ONLY one of these is treated as silent
52
+ * (empty), so the bot does not post meta chatter and cannot loop with another
53
+ * bot answering "No response requested." to every message. DMs and @mentions
54
+ * are unaffected — there the phrase is posted as written.
55
+ */
56
+ export declare const META_NON_REPLY_PHRASES: readonly ["No response requested.", "No response needed.", "No reply needed.", "No action.", "(no reply)", "Nothing to add."];
57
+ /**
58
+ * True when `reply` (as a whole) is only a stay-quiet phrase: case-insensitive,
59
+ * trimmed, trailing punctuation ignored. Never a substring match — a real
60
+ * answer that merely contains such a phrase is not silenced.
61
+ */
62
+ export declare function isMetaNonReply(reply: string | undefined): boolean;
47
63
  //# sourceMappingURL=room-policy.d.ts.map
@@ -55,4 +55,37 @@ export function resolveReplyTarget(item, config) {
55
55
  mentioned,
56
56
  };
57
57
  }
58
+ /** A UID minted for an agent/bot identity (`agt_…`) rather than a person (`usr_…`). */
59
+ export function isAgentSender(uid) {
60
+ return typeof uid === "string" && uid.startsWith("agt_");
61
+ }
62
+ /**
63
+ * Stay-quiet phrases a model emits when it has nothing to add. On an
64
+ * UNMENTIONED room turn a reply that is ONLY one of these is treated as silent
65
+ * (empty), so the bot does not post meta chatter and cannot loop with another
66
+ * bot answering "No response requested." to every message. DMs and @mentions
67
+ * are unaffected — there the phrase is posted as written.
68
+ */
69
+ export const META_NON_REPLY_PHRASES = [
70
+ "No response requested.",
71
+ "No response needed.",
72
+ "No reply needed.",
73
+ "No action.",
74
+ "(no reply)",
75
+ "Nothing to add.",
76
+ ];
77
+ const META_NON_REPLY_NORMALIZED = new Set(META_NON_REPLY_PHRASES.map((p) => p.toLowerCase().replace(/[.!?)\s]+$/, "").trim()));
78
+ /**
79
+ * True when `reply` (as a whole) is only a stay-quiet phrase: case-insensitive,
80
+ * trimmed, trailing punctuation ignored. Never a substring match — a real
81
+ * answer that merely contains such a phrase is not silenced.
82
+ */
83
+ export function isMetaNonReply(reply) {
84
+ if (typeof reply !== "string")
85
+ return false;
86
+ const normalized = reply.trim().toLowerCase().replace(/[.!?)\s]+$/, "").trim();
87
+ if (!normalized)
88
+ return false;
89
+ return META_NON_REPLY_NORMALIZED.has(normalized);
90
+ }
58
91
  //# sourceMappingURL=room-policy.js.map
@@ -66,7 +66,7 @@ import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
66
66
  import { botAttachmentsDir, dmSessionScope } from "./paths.js";
67
67
  import { attachmentTurnBlock, downloadTurnAttachments } from "./attachments.js";
68
68
  import { inboxEntryAttachments } from "../agent-kit/inbox-state.js";
69
- import { isDmItem, resolveReplyTarget } from "./room-policy.js";
69
+ import { isAgentSender, isDmItem, isMetaNonReply, resolveReplyTarget } from "./room-policy.js";
70
70
  import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
71
71
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
72
72
  import { isRuntimeSignInFailure, SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
@@ -686,11 +686,16 @@ export async function runBot(deps) {
686
686
  ...(args.readDirs?.length ? { readDirs: args.readDirs } : {}),
687
687
  });
688
688
  running = false;
689
- const sentinelOrEmpty = containsNoReplySentinel(turn.text) || !turn.text.trim();
689
+ // Control tokens were already stripped from turn.text in runRuntimeTurn, so
690
+ // a `<|eos|>`-only reply arrives here as empty. On an unmentioned room turn
691
+ // a stay-quiet phrase ("No response requested." etc.) is also silence — the
692
+ // meta chatter that let two bots loop on 2026-09-25.
693
+ const stayQuiet = silenceable && isMetaNonReply(turn.text);
694
+ const sentinelOrEmpty = containsNoReplySentinel(turn.text) || !turn.text.trim() || stayQuiet;
690
695
  // Unmentioned room turn: a no_reply sentinel (bare, fenced, or with
691
- // preamble) or an empty/whitespace reply means stay quiet. Abandon the
692
- // poster (it posts nothing) and return a silent outcome; the caller acks
693
- // without posting anything.
696
+ // preamble), an empty/whitespace reply, or a stay-quiet phrase means stay
697
+ // quiet. Abandon the poster (it posts nothing) and return a silent outcome;
698
+ // the caller acks without posting anything.
694
699
  if (silenceable && sentinelOrEmpty) {
695
700
  await poster.abandon();
696
701
  return { outcome: { ok: true, reply: "", silent: true }, poster };
@@ -1247,6 +1252,15 @@ export async function runBot(deps) {
1247
1252
  return;
1248
1253
  }
1249
1254
  const target = resolveReplyTarget(item, config);
1255
+ // A bot/agent sender that did not @mention this bot must never start a model
1256
+ // turn: two bots each answering the other's plain room posts loop forever
1257
+ // (observed 2026-09-25). An explicit @mention still gets answered below.
1258
+ if (target && target.kind === "room" && !target.mentioned && isAgentSender(item.fromPersonUid)) {
1259
+ markProcessed(dir, id, now().toISOString());
1260
+ await api.ackInbox(config.agentUid, id);
1261
+ log("info", `ignored inbox item ${id} (room ${target.channelId}, unmentioned message from bot ${item.fromPersonUid ?? "unknown"}; not starting a turn)`);
1262
+ return;
1263
+ }
1250
1264
  if (!target) {
1251
1265
  // A job item the bot cannot run. When it names a job, the cloud is still
1252
1266
  // holding that job's in-progress slot: acking alone would jam every later
@@ -8,6 +8,7 @@ import { GrokAcpClient } from "./grok-acp.js";
8
8
  import { createClaudeRuntime } from "./claude.js";
9
9
  import { createCodexRuntime } from "./codex.js";
10
10
  import { createGrokRuntime, grokRetryArgsAfterInvalidOutputFormat, probeGrokOutputFormat } from "./grok.js";
11
+ import { stripControlTokens } from "./sanitize.js";
11
12
  import { parseJsonLine, RuntimeError, turnTimeoutMs, stderrExcerpt, } from "./types.js";
12
13
  export * from "./types.js";
13
14
  export { createClaudeRuntime, claudePermissionMode } from "./claude.js";
@@ -406,7 +407,11 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
406
407
  throw new RuntimeError(`${runtime.binary} exited with ${result.code ?? `signal ${result.signal}`}${reason ? `: ${reason.slice(0, 200)}` : ""}`, reason ? reason.slice(0, 400) : fallback, result.code);
407
408
  }
408
409
  const parsed = runtime.parseOutput(result.stdout, { outputFile, outputFileText });
410
+ // Strip any leaked model control token (`<|eos|>` etc.) from the final reply
411
+ // before it is posted; a reply that was only such tokens becomes empty and the
412
+ // caller applies its normal empty-reply handling.
413
+ const text = stripControlTokens(parsed.text);
409
414
  // A resumed session keeps its id even when the CLI omits it from the envelope.
410
- return { text: parsed.text, sessionId: parsed.sessionId ?? input.sessionId };
415
+ return { text, sessionId: parsed.sessionId ?? input.sessionId };
411
416
  }
412
417
  //# sourceMappingURL=index.js.map
@@ -22,6 +22,7 @@
22
22
  * fixtures in runtime/fixtures/. Claude sends one content block per assistant
23
23
  * event; older Grok sends a whole message (thinking + text + tool_use) per event.
24
24
  */
25
+ import { stripControlTokens } from "./sanitize.js";
25
26
  import { parseJsonLine, RuntimeError } from "./types.js";
26
27
  /** Progress events in one stream line: top-level assistant text and tool starts only. */
27
28
  export function messagesStreamEvents(line) {
@@ -44,8 +45,12 @@ export function messagesStreamEvents(line) {
44
45
  if (!block || typeof block !== "object")
45
46
  continue;
46
47
  const b = block;
47
- if (b.type === "text" && typeof b.text === "string" && b.text.trim())
48
- out.push({ kind: "message", text: b.text.trim() });
48
+ if (b.type === "text" && typeof b.text === "string") {
49
+ // Strip leaked control tokens from in-between progress posts too.
50
+ const clean = stripControlTokens(b.text).trim();
51
+ if (clean)
52
+ out.push({ kind: "message", text: clean });
53
+ }
49
54
  else if (b.type === "tool_use" && typeof b.name === "string")
50
55
  out.push({ kind: "tool", name: b.name });
51
56
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Model control-token stripping for bot replies.
3
+ *
4
+ * A model can leak a raw control token as visible text — grok posted a bare
5
+ * `<|eos|>` as a room reply on 2026-09-25. These tokens are never meant for a
6
+ * person, so they are removed from both the in-between progress messages and
7
+ * the final reply before anything is posted.
8
+ *
9
+ * The generic `<|...|>` form covers any single control token in that shape
10
+ * (`<|eos|>`, `<|endoftext|>`, `<|im_end|>`, `<|eot_id|>`, and any other).
11
+ * Whitespace-only reply after stripping is empty, and callers apply the same
12
+ * empty-reply handling they already have (silent on an unmentioned room turn).
13
+ */
14
+ /** Remove every `<|...|>` control token from `text`, leaving the rest intact. */
15
+ export declare function stripControlTokens(text: string): string;
16
+ /** True when `text` is nothing but control tokens and whitespace. */
17
+ export declare function isOnlyControlTokens(text: string): boolean;
18
+ //# sourceMappingURL=sanitize.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Model control-token stripping for bot replies.
3
+ *
4
+ * A model can leak a raw control token as visible text — grok posted a bare
5
+ * `<|eos|>` as a room reply on 2026-09-25. These tokens are never meant for a
6
+ * person, so they are removed from both the in-between progress messages and
7
+ * the final reply before anything is posted.
8
+ *
9
+ * The generic `<|...|>` form covers any single control token in that shape
10
+ * (`<|eos|>`, `<|endoftext|>`, `<|im_end|>`, `<|eot_id|>`, and any other).
11
+ * Whitespace-only reply after stripping is empty, and callers apply the same
12
+ * empty-reply handling they already have (silent on an unmentioned room turn).
13
+ */
14
+ /** Any single control token of the form `<|...|>` (no nested `<|`/`|>`). */
15
+ const CONTROL_TOKEN_RE = /<\|(?:(?!<\||\|>).)*\|>/g;
16
+ /** Remove every `<|...|>` control token from `text`, leaving the rest intact. */
17
+ export function stripControlTokens(text) {
18
+ if (!text)
19
+ return text;
20
+ return text.replace(CONTROL_TOKEN_RE, "");
21
+ }
22
+ /** True when `text` is nothing but control tokens and whitespace. */
23
+ export function isOnlyControlTokens(text) {
24
+ if (!text.trim())
25
+ return false;
26
+ return stripControlTokens(text).trim() === "";
27
+ }
28
+ //# sourceMappingURL=sanitize.js.map
@@ -2,13 +2,24 @@
2
2
  import { parentPort, workerData } from "node:worker_threads";
3
3
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
4
4
  import { createFlagClient } from "@indigoai-us/hq-flags-client";
5
- const endpoint = workerData.endpoint;
5
+ const data = workerData;
6
+ const endpoint = data.endpoint;
7
+ const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
8
+ const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
9
  async function refresh() {
7
10
  if (!parentPort || typeof endpoint !== "string" || endpoint.trim() === "")
8
11
  return;
9
12
  try {
13
+ const companyUid = typeof data.companyUid === "string" && COMPANY_UID_RE.test(data.companyUid)
14
+ ? data.companyUid
15
+ : undefined;
16
+ const companyIdentifiers = Array.isArray(data.companyIdentifiers)
17
+ ? data.companyIdentifiers.filter((value) => typeof value === "string" && COMPANY_SLUG_RE.test(value))
18
+ : [];
10
19
  const client = createFlagClient({
11
20
  endpoint,
21
+ ...(companyUid ? { companyUid } : {}),
22
+ ...(companyIdentifiers.length ? { companyIdentifiers } : {}),
12
23
  getToken: () => loadCachedTokens()?.idToken ?? "",
13
24
  refreshIntervalMs: 0,
14
25
  onError: () => { },
@@ -7,8 +7,15 @@
7
7
  */
8
8
  import { Worker } from "node:worker_threads";
9
9
  import { isFlagSnapshot, readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
10
+ import { readMachineCompanyBinding } from "./work-context/company.js";
11
+ import { readSessionState } from "./work-context/state.js";
12
+ import { workContextRoot } from "./work-context/paths.js";
13
+ import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
14
+ import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
10
15
  /** Dedicated endpoint configuration; this is intentionally not HQ_PRO_API_URL. */
11
16
  export const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
17
+ const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
18
+ const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
12
19
  let heldSnapshot = null;
13
20
  let processReader = null;
14
21
  function processLocalOverride(flagKey) {
@@ -17,14 +24,66 @@ function processLocalOverride(flagKey) {
17
24
  function localOverrideOrFallback(flagKey, legacyFallback) {
18
25
  return processLocalOverride(flagKey) ?? legacyFallback();
19
26
  }
20
- function processSnapshotReader() {
27
+ function normalizedCompanyUid(value) {
28
+ const candidate = value?.trim() ?? "";
29
+ return COMPANY_UID_RE.test(candidate) ? candidate : undefined;
30
+ }
31
+ function normalizedCompanySlug(value) {
32
+ const candidate = value?.trim() ?? "";
33
+ return COMPANY_SLUG_RE.test(candidate) ? candidate : undefined;
34
+ }
35
+ /**
36
+ * Resolve only an explicit machine/session company binding. Device defaults
37
+ * and cwd/repository heuristics do not scope process-wide feature flags.
38
+ */
39
+ function processCompanyContext(env = process.env) {
40
+ const explicitUid = normalizedCompanyUid(env.HQ_COMPANY_UID);
41
+ const explicitSlug = normalizedCompanySlug(env.HQ_COMPANY_SLUG);
42
+ if (explicitUid || explicitSlug) {
43
+ return { companyUid: explicitUid, companySlug: explicitSlug };
44
+ }
45
+ const machine = readMachineCompanyBinding(env);
46
+ const spawnValue = env.HQ_SPAWN_COMPANY?.trim();
47
+ const spawnSlug = normalizedCompanySlug(spawnValue);
48
+ const machineContext = spawnValue && COMPANY_UID_RE.test(spawnValue)
49
+ ? { companyUid: spawnValue }
50
+ : spawnSlug
51
+ ? { companySlug: spawnSlug }
52
+ : {
53
+ companyUid: normalizedCompanyUid(machine?.uid),
54
+ companySlug: normalizedCompanySlug(machine?.slug),
55
+ };
56
+ if (machineContext.companyUid || machineContext.companySlug)
57
+ return machineContext;
58
+ const sessionId = env.HQ_SESSION_ID?.trim();
59
+ if (!sessionId)
60
+ return {};
61
+ const session = readSessionState(sessionId, workContextRoot(undefined, env));
62
+ if (session?.contextStatus !== "bound" || session.companyConfidence === "device_default") {
63
+ return {};
64
+ }
65
+ return {
66
+ companyUid: normalizedCompanyUid(session.companyUid),
67
+ companySlug: normalizedCompanySlug(session.companySlug),
68
+ };
69
+ }
70
+ function companyUidForContext(context, env = process.env) {
71
+ if (context.companyUid)
72
+ return context.companyUid;
73
+ if (!context.companySlug)
74
+ return undefined;
75
+ const hqRoot = resolveDefaultHqRoot({ env });
76
+ const manifestUid = readCompaniesManifestMap(hqRoot)?.[context.companySlug]?.cloud_uid;
77
+ return typeof manifestUid === "string" ? normalizedCompanyUid(manifestUid) : undefined;
78
+ }
79
+ function processSnapshotReader(companyIdentifiers) {
21
80
  return {
22
81
  isEnabled(flagKey, lookup = {}) {
23
82
  return resolveFlag({
24
83
  flagKey,
25
84
  env: process.env,
26
85
  snapshot: heldSnapshot,
27
- companyIdentifiers: [],
86
+ companyIdentifiers,
28
87
  lookup,
29
88
  }).value;
30
89
  },
@@ -45,9 +104,18 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
45
104
  const endpoint = (dependencies.endpoint ?? process.env[FLAG_REGISTRY_ENDPOINT_ENV] ?? "").trim();
46
105
  if (!endpoint)
47
106
  return null;
48
- processReader = processSnapshotReader();
107
+ const company = processCompanyContext();
108
+ const companyUid = companyUidForContext(company);
109
+ const companyIdentifiers = [companyUid, company.companySlug].filter((value) => Boolean(value));
110
+ processReader = processSnapshotReader(companyIdentifiers);
49
111
  try {
50
- const worker = (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(new URL("./flag-registry-worker.js", import.meta.url), { workerData: { endpoint } });
112
+ const worker = (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(new URL("./flag-registry-worker.js", import.meta.url), {
113
+ workerData: {
114
+ endpoint,
115
+ ...(companyUid ? { companyUid } : {}),
116
+ ...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
117
+ },
118
+ });
51
119
  worker.on("message", (value) => {
52
120
  if (isFlagSnapshot(value))
53
121
  heldSnapshot = value;
@@ -122,6 +122,11 @@ function lockPathFor(filePath) {
122
122
  function lockOwnerPath(lockPath) {
123
123
  return path.join(lockPath, "owner.json");
124
124
  }
125
+ function isInvalidLockOwnerReceipt(err, lockPath) {
126
+ return err instanceof LanesError &&
127
+ err.error === "story_ledger_lock_invalid" &&
128
+ err.details.path === lockOwnerPath(lockPath);
129
+ }
125
130
  function readLockOwner(lockPath) {
126
131
  const ownerPath = lockOwnerPath(lockPath);
127
132
  let contents;
@@ -157,15 +162,17 @@ function lockSleep(deps, ms) {
157
162
  }
158
163
  function restoreMovedLockWithoutReplacement(lockPath, stalePath) {
159
164
  const entries = fs.readdirSync(stalePath);
160
- if (entries.some((entry) => entry !== "owner.json")) {
165
+ const isOwnerTemp = (entry) => /^\.owner-[a-f0-9]{24}\.tmp$/.test(entry);
166
+ if (entries.some((entry) => entry !== "owner.json" && !isOwnerTemp(entry))) {
161
167
  throw new Error("Unexpected entry in moved story ledger lock");
162
168
  }
163
- const ownerContents = entries.includes("owner.json")
164
- ? fs.readFileSync(lockOwnerPath(stalePath), "utf8")
165
- : undefined;
169
+ const files = entries.map((entry) => ({
170
+ name: entry,
171
+ contents: fs.readFileSync(path.join(stalePath, entry), "utf8"),
172
+ }));
166
173
  fs.mkdirSync(lockPath);
167
- if (ownerContents !== undefined) {
168
- fs.writeFileSync(lockOwnerPath(lockPath), ownerContents, {
174
+ for (const file of files) {
175
+ fs.writeFileSync(path.join(lockPath, file.name), file.contents, {
169
176
  encoding: "utf8",
170
177
  flag: "wx",
171
178
  mode: 0o600,
@@ -186,10 +193,18 @@ function clearStaleLock(lockPath, deps) {
186
193
  if (!stat.isDirectory() || stat.isSymbolicLink()) {
187
194
  throw new LanesError("story_ledger_lock_invalid", "Story ledger lock path is not a regular directory: " + lockPath, { path: lockPath });
188
195
  }
189
- const ownerRead = readLockOwner(lockPath);
190
- const hasOwnerToken = hasLockOwnerToken(ownerRead);
196
+ let ownerRead;
197
+ try {
198
+ ownerRead = readLockOwner(lockPath);
199
+ }
200
+ catch (err) {
201
+ if (!isInvalidLockOwnerReceipt(err, lockPath) || lockNow(deps) - stat.mtimeMs >= LOCK_STALE_MS) {
202
+ throw err;
203
+ }
204
+ return false;
205
+ }
191
206
  let lockAt = stat.mtimeMs;
192
- if (hasOwnerToken && typeof ownerRead.owner.at === "string") {
207
+ if (hasLockOwnerToken(ownerRead) && typeof ownerRead.owner.at === "string") {
193
208
  const parsedAt = Date.parse(ownerRead.owner.at);
194
209
  if (Number.isFinite(parsedAt))
195
210
  lockAt = parsedAt;
@@ -207,7 +222,26 @@ function clearStaleLock(lockPath, deps) {
207
222
  throw err;
208
223
  }
209
224
  const moved = fs.lstatSync(stalePath);
210
- const movedOwnerRead = readLockOwner(stalePath);
225
+ let movedOwnerRead;
226
+ try {
227
+ movedOwnerRead = readLockOwner(stalePath);
228
+ }
229
+ catch (err) {
230
+ if (!isInvalidLockOwnerReceipt(err, stalePath))
231
+ throw err;
232
+ const stillFresh = lockNow(deps) - moved.mtimeMs < LOCK_STALE_MS;
233
+ try {
234
+ // Put the moved directory back before retrying or reporting the invalid receipt.
235
+ restoreMovedLockWithoutReplacement(lockPath, stalePath);
236
+ }
237
+ catch {
238
+ throw new LanesError("story_ledger_lock_race", "A replacement story ledger lock was preserved at " + stalePath +
239
+ " because it could not safely be restored to " + lockPath, { path: lockPath, preserved_path: stalePath });
240
+ }
241
+ if (stillFresh)
242
+ return false;
243
+ throw err;
244
+ }
211
245
  let movedLockAt = moved.mtimeMs;
212
246
  if (hasLockOwnerToken(movedOwnerRead) && typeof movedOwnerRead.owner.at === "string") {
213
247
  const parsedAt = Date.parse(movedOwnerRead.owner.at);
@@ -239,7 +273,14 @@ function acquireLedgerLock(filePath, deps) {
239
273
  while (lockNow(deps) - started < timeoutMs) {
240
274
  try {
241
275
  fs.mkdirSync(lockPath);
242
- fs.writeFileSync(lockOwnerPath(lockPath), JSON.stringify({ pid: process.pid, token, at: new Date(lockNow(deps)).toISOString() }) + "\n", { mode: 0o600 });
276
+ const ownerTempPath = path.join(lockPath, ".owner-" + token + ".tmp");
277
+ try {
278
+ fs.writeFileSync(ownerTempPath, JSON.stringify({ pid: process.pid, token, at: new Date(lockNow(deps)).toISOString() }) + "\n", { encoding: "utf8", flag: "wx", mode: 0o600 });
279
+ fs.renameSync(ownerTempPath, lockOwnerPath(lockPath));
280
+ }
281
+ finally {
282
+ fs.rmSync(ownerTempPath, { force: true });
283
+ }
243
284
  return { lockPath, token };
244
285
  }
245
286
  catch (err) {
@@ -0,0 +1,19 @@
1
+ import type { FlagClient, FlagClientOptions } from "@indigoai-us/hq-flags-client";
2
+ export declare const NODE_NETWORK_FORCE_IPV4_FLAG = "cli.network.force-ipv4";
3
+ export declare const NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK = false;
4
+ export declare const NODE_NETWORK_FORCE_IPV4_FLAG_TIMEOUT_MS = 1500;
5
+ type NetworkFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
6
+ export interface NodeNetworkForceIpv4Dependencies {
7
+ env?: NodeJS.ProcessEnv;
8
+ createClient?: (options: FlagClientOptions) => NetworkFlagClient;
9
+ getToken?: () => string | Promise<string>;
10
+ reportFailure?: (error: unknown) => void;
11
+ }
12
+ /**
13
+ * Read the opt-in hq-flags gate before CLI dispatch. Local hq-flags overrides
14
+ * work without a token or endpoint; service decisions require a company
15
+ * identity and use a short request deadline so startup remains bounded.
16
+ */
17
+ export declare function nodeNetworkForceIpv4Enabled(dependencies?: NodeNetworkForceIpv4Dependencies): Promise<boolean>;
18
+ export {};
19
+ //# sourceMappingURL=node-network-compat-flag.d.ts.map
@@ -0,0 +1,87 @@
1
+ import { createFlagClient, readLocalOverride } from "@indigoai-us/hq-flags-client";
2
+ export const NODE_NETWORK_FORCE_IPV4_FLAG = "cli.network.force-ipv4";
3
+ export const NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK = false;
4
+ // Node waits up to 1s before trying the next address family. Keep this request
5
+ // deadline above that wait so a dead IPv6 path can reach the usable IPv4 host.
6
+ export const NODE_NETWORK_FORCE_IPV4_FLAG_TIMEOUT_MS = 1_500;
7
+ function safeErrorClass(error) {
8
+ const name = error instanceof Error ? error.name : "UnknownError";
9
+ return /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name)
10
+ ? name
11
+ : "UnknownError";
12
+ }
13
+ function reportFlagFailure(error) {
14
+ process.stderr.write("HQ network IPv4 flag lookup failed (" +
15
+ safeErrorClass(error) +
16
+ "); using default off.\n");
17
+ }
18
+ async function loadCachedIdToken() {
19
+ const { loadCachedTokens } = await import("@indigoai-us/hq-cloud");
20
+ return loadCachedTokens()?.idToken ?? "";
21
+ }
22
+ /**
23
+ * Read the opt-in hq-flags gate before CLI dispatch. Local hq-flags overrides
24
+ * work without a token or endpoint; service decisions require a company
25
+ * identity and use a short request deadline so startup remains bounded.
26
+ */
27
+ export async function nodeNetworkForceIpv4Enabled(dependencies = {}) {
28
+ const env = dependencies.env ?? process.env;
29
+ const localOverride = readLocalOverride(NODE_NETWORK_FORCE_IPV4_FLAG, env);
30
+ if (localOverride !== null)
31
+ return localOverride;
32
+ const endpoint = env.HQ_FLAGS_API_URL?.trim() || "";
33
+ const companyUid = env.HQ_COMPANY_UID?.trim() || "";
34
+ if (!endpoint ||
35
+ !/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
36
+ return NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK;
37
+ }
38
+ const companySlug = env.HQ_COMPANY_SLUG?.trim() || "";
39
+ let client;
40
+ let failed = false;
41
+ let failureReported = false;
42
+ let enabled = NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK;
43
+ const reportFailureOnce = (error) => {
44
+ failed = true;
45
+ if (failureReported)
46
+ return;
47
+ failureReported = true;
48
+ (dependencies.reportFailure ?? reportFlagFailure)(error);
49
+ };
50
+ try {
51
+ const token = await (dependencies.getToken ?? loadCachedIdToken)();
52
+ if (!token)
53
+ return NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK;
54
+ const createClient = dependencies.createClient ?? createFlagClient;
55
+ client = createClient({
56
+ endpoint,
57
+ companyUid,
58
+ ...(companySlug && /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/.test(companySlug)
59
+ ? { companyIdentifiers: [companySlug] }
60
+ : {}),
61
+ env: {},
62
+ getToken: () => token,
63
+ refreshIntervalMs: 0,
64
+ requestTimeoutMs: NODE_NETWORK_FORCE_IPV4_FLAG_TIMEOUT_MS,
65
+ onError: reportFailureOnce,
66
+ });
67
+ await client.ready();
68
+ if (!failed) {
69
+ enabled = client.isEnabled(NODE_NETWORK_FORCE_IPV4_FLAG, {
70
+ fallback: NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK,
71
+ });
72
+ }
73
+ }
74
+ catch (error) {
75
+ reportFailureOnce(error);
76
+ }
77
+ finally {
78
+ try {
79
+ client?.close();
80
+ }
81
+ catch (error) {
82
+ reportFailureOnce(error);
83
+ }
84
+ }
85
+ return failed ? NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK : enabled;
86
+ }
87
+ //# sourceMappingURL=node-network-compat-flag.js.map
@@ -1,31 +1,28 @@
1
1
  /**
2
2
  * Node network defaults for HQ CLI entry points.
3
3
  *
4
- * Node's default 250 ms address-family attempt window can fail on hosts where
5
- * IPv6 is advertised but black-holed. On affected WSL installations, undici's
6
- * fetch() reaches ETIMEDOUT before its usable IPv4 path completes. A 1 second
7
- * window fixes the connection while retaining Node's automatic family choice.
4
+ * The compatibility timeout remains the default. On a canary where IPv6 is
5
+ * advertised but unusable, the default-off hq-flags gate can also prefer IPv4
6
+ * and disable automatic family racing before the CLI issues its first request.
8
7
  *
9
- * Keep this module dependency-free and import it immediately after the Node
10
- * version preflight. Operators can retain a custom value through NODE_OPTIONS
11
- * or a direct Node CLI option; HQ only supplies the compatibility default when
12
- * neither is present.
13
- *
14
- * Use a namespace import of `node:net` (not a static named import of
15
- * `setDefaultAutoSelectFamilyAttemptTimeout`). Named ESM imports are linked
16
- * before any entry-module body runs — including `node-preflight` — so importing
17
- * a Node 18.13+/20.4+ API by name would turn unsupported runtimes into a
18
- * module-link SyntaxError instead of the preflight's upgrade message.
8
+ * Keep this module safe to import immediately after the Node version preflight.
9
+ * The hq-flags reader loads dynamically so an unsupported runtime exits through
10
+ * the preflight rather than failing during module linking.
19
11
  */
20
12
  export declare const HQ_NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS = 1000;
21
13
  export interface NodeNetworkCompatibilityOptions {
22
14
  nodeOptions?: string;
23
15
  execArgv?: readonly string[];
16
+ forceIpv4?: boolean;
24
17
  /**
25
- * Inject the net timeout setter (tests). Pass `null` to simulate a runtime
26
- * that lacks `setDefaultAutoSelectFamilyAttemptTimeout`.
18
+ * Inject the net timeout setter (tests). Pass null to simulate a runtime
19
+ * that lacks setDefaultAutoSelectFamilyAttemptTimeout.
27
20
  */
28
21
  setAttemptTimeout?: ((milliseconds: number) => void) | null;
22
+ /** Inject Node's automatic family selection setter (tests). */
23
+ setAutoSelectFamily?: ((enabled: boolean) => void) | null;
24
+ /** Inject Node's DNS result-order setter (tests). */
25
+ setDefaultResultOrder?: ((order: "ipv4first") => void) | null;
29
26
  }
30
27
  export declare function configureNodeNetworkCompatibility(options?: NodeNetworkCompatibilityOptions): boolean;
31
28
  //# sourceMappingURL=node-network-compat.d.ts.map
@@ -1,30 +1,35 @@
1
1
  /**
2
2
  * Node network defaults for HQ CLI entry points.
3
3
  *
4
- * Node's default 250 ms address-family attempt window can fail on hosts where
5
- * IPv6 is advertised but black-holed. On affected WSL installations, undici's
6
- * fetch() reaches ETIMEDOUT before its usable IPv4 path completes. A 1 second
7
- * window fixes the connection while retaining Node's automatic family choice.
4
+ * The compatibility timeout remains the default. On a canary where IPv6 is
5
+ * advertised but unusable, the default-off hq-flags gate can also prefer IPv4
6
+ * and disable automatic family racing before the CLI issues its first request.
8
7
  *
9
- * Keep this module dependency-free and import it immediately after the Node
10
- * version preflight. Operators can retain a custom value through NODE_OPTIONS
11
- * or a direct Node CLI option; HQ only supplies the compatibility default when
12
- * neither is present.
13
- *
14
- * Use a namespace import of `node:net` (not a static named import of
15
- * `setDefaultAutoSelectFamilyAttemptTimeout`). Named ESM imports are linked
16
- * before any entry-module body runs — including `node-preflight` — so importing
17
- * a Node 18.13+/20.4+ API by name would turn unsupported runtimes into a
18
- * module-link SyntaxError instead of the preflight's upgrade message.
8
+ * Keep this module safe to import immediately after the Node version preflight.
9
+ * The hq-flags reader loads dynamically so an unsupported runtime exits through
10
+ * the preflight rather than failing during module linking.
19
11
  */
12
+ import * as nodeDns from "node:dns";
20
13
  import * as nodeNet from "node:net";
21
14
  export const HQ_NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS = 1000;
22
15
  const ATTEMPT_TIMEOUT_OPTION = "--network-family-autoselection-attempt-timeout";
23
16
  function hasExplicitAttemptTimeout(nodeOptions, execArgv) {
24
- const optionPattern = new RegExp(`(?:^|\\s)${ATTEMPT_TIMEOUT_OPTION}(?:=|\\s|$)`);
17
+ const optionPattern = new RegExp("(?:^|\\s)" + ATTEMPT_TIMEOUT_OPTION + "(?:=|\\s|$)");
25
18
  return (optionPattern.test(nodeOptions) ||
26
19
  execArgv.some((argument) => argument === ATTEMPT_TIMEOUT_OPTION ||
27
- argument.startsWith(`${ATTEMPT_TIMEOUT_OPTION}=`)));
20
+ argument.startsWith(ATTEMPT_TIMEOUT_OPTION + "=")));
21
+ }
22
+ function hasExplicitFamilySelection(nodeOptions, execArgv) {
23
+ const optionPattern = /(?:^|\s)(?:--network-family-autoselection|--enable-network-family-autoselection|--no-network-family-autoselection|--dns-result-order)(?:=|\s|$)/;
24
+ return (optionPattern.test(nodeOptions) ||
25
+ execArgv.some((argument) => argument === "--network-family-autoselection" ||
26
+ argument === "--enable-network-family-autoselection" ||
27
+ argument === "--no-network-family-autoselection" ||
28
+ argument === "--dns-result-order" ||
29
+ argument.startsWith("--network-family-autoselection=") ||
30
+ argument.startsWith("--enable-network-family-autoselection=") ||
31
+ argument.startsWith("--no-network-family-autoselection=") ||
32
+ argument.startsWith("--dns-result-order=")));
28
33
  }
29
34
  function resolveSetAttemptTimeout(override) {
30
35
  if (override === null)
@@ -34,19 +39,69 @@ function resolveSetAttemptTimeout(override) {
34
39
  const fn = nodeNet.setDefaultAutoSelectFamilyAttemptTimeout;
35
40
  return typeof fn === "function" ? fn.bind(nodeNet) : undefined;
36
41
  }
42
+ function resolveSetAutoSelectFamily(override) {
43
+ if (override === null)
44
+ return undefined;
45
+ if (override)
46
+ return override;
47
+ const fn = nodeNet.setDefaultAutoSelectFamily;
48
+ return typeof fn === "function" ? fn.bind(nodeNet) : undefined;
49
+ }
50
+ function resolveSetDefaultResultOrder(override) {
51
+ if (override === null)
52
+ return undefined;
53
+ if (override)
54
+ return override;
55
+ const fn = nodeDns.setDefaultResultOrder;
56
+ return typeof fn === "function" ? fn.bind(nodeDns) : undefined;
57
+ }
58
+ function applyIpv4Preference(options, nodeOptions, execArgv) {
59
+ if (!options.forceIpv4 || hasExplicitFamilySelection(nodeOptions, execArgv)) {
60
+ return false;
61
+ }
62
+ const setAutoSelectFamily = resolveSetAutoSelectFamily(options.setAutoSelectFamily);
63
+ const setDefaultResultOrder = resolveSetDefaultResultOrder(options.setDefaultResultOrder);
64
+ if (!setAutoSelectFamily || !setDefaultResultOrder)
65
+ return false;
66
+ setDefaultResultOrder("ipv4first");
67
+ setAutoSelectFamily(false);
68
+ return true;
69
+ }
37
70
  export function configureNodeNetworkCompatibility(options = {}) {
38
71
  const nodeOptions = options.nodeOptions ?? process.env.NODE_OPTIONS ?? "";
39
72
  const execArgv = options.execArgv ?? process.execArgv;
73
+ const ipv4Applied = applyIpv4Preference(options, nodeOptions, execArgv);
40
74
  if (hasExplicitAttemptTimeout(nodeOptions, execArgv)) {
41
- return false;
75
+ return ipv4Applied;
42
76
  }
43
77
  const setAttemptTimeout = resolveSetAttemptTimeout(options.setAttemptTimeout);
44
78
  if (!setAttemptTimeout) {
45
- // Runtime lacks the API (pre-Node 18.13 / preflight should already have exited).
46
- return false;
79
+ // Runtime lacks the API; the Node preflight should already have exited.
80
+ return ipv4Applied;
47
81
  }
48
82
  setAttemptTimeout(HQ_NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS);
49
83
  return true;
50
84
  }
85
+ async function applyHqFlaggedIpv4Preference() {
86
+ try {
87
+ const { nodeNetworkForceIpv4Enabled } = await import("./node-network-compat-flag.js");
88
+ if (nodeNetworkForceIpv4Enabled &&
89
+ (await nodeNetworkForceIpv4Enabled())) {
90
+ configureNodeNetworkCompatibility({ forceIpv4: true });
91
+ }
92
+ }
93
+ catch (error) {
94
+ const rawName = error instanceof Error ? error.name : "UnknownError";
95
+ const errorName = /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(rawName)
96
+ ? rawName
97
+ : "UnknownError";
98
+ process.stderr.write("HQ network IPv4 flag lookup failed (" +
99
+ errorName +
100
+ "); using default off.\n");
101
+ }
102
+ }
103
+ // Preserve the historical timeout before any flag request. If the hq-flags
104
+ // lookup succeeds, the IPv4 preference is applied before command dispatch.
51
105
  configureNodeNetworkCompatibility();
106
+ await applyHqFlaggedIpv4Preference();
52
107
  //# sourceMappingURL=node-network-compat.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.195.1",
3
+ "version": "5.197.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {