@indigoai-us/hq-cli 5.345.87 → 5.345.89

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.
@@ -11,6 +11,7 @@ import { buildCmdExeSpawnPlan, isWindowsBatchFile } from "../utils/windows-spawn
11
11
  import { describeCoreCommand } from "./core-command-catalog.js";
12
12
  import { resolveLiveRoot } from "../utils/hq-roots.js";
13
13
  import { runBundledScript } from "../utils/run-bundled-script.js";
14
+ import { brainstormFrontmatterSet, brainstormSummary, brainstormTitle } from "./core-delegate-brainstorm.js";
14
15
  export const CORE_NATIVE_DELEGATE_FLAG = "core-native-delegate";
15
16
  const SCRIPT_ASSETS = {
16
17
  resolve: "core/scripts/hq-delegate-resolve.sh",
@@ -52,7 +53,7 @@ const USAGE_TEXT = {
52
53
  pickup: "# hq-core: public\n# hq-delegate-pickup.sh — close the delegation loop with a receipt or a FAILED\n# state (2026-09-07).\n#\n# Why: the delegate pipeline ended at \"sent\", which is a send receipt, not a\n# delivery receipt. Seven delegations sat at sent/building with no way to tell\n# whether the recipient could act. This script adds the two missing states:\n#\n# sent --(evidence)--> picked-up recipient demonstrably has the work\n# sent --(silence past window)--> failed nobody picked it up; sender is told why\n#\n# Evidence, checked in order (any one suffices):\n# 1. --ack \"<text>\" an explicit acknowledgement (DM reply, chat) —\n# recorded verbatim with a timestamp\n# 2. repo branch has a commit by the recipient after sentAt\n# (manifest.repo.path + repo.branch, author email == to.principal)\n# 3. --evidence \"<free text>\" any other proof the operator observed\n#\n# Usage:\n# hq-delegate-pickup.sh --manifest <path> --ack \"<text>\" # record receipt\n# hq-delegate-pickup.sh --manifest <path> --evidence \"<text>\" # record receipt\n# hq-delegate-pickup.sh --manifest <path> --check [--window-hours N] # probe repo; fail if silent past window (default 72)\n# hq-delegate-pickup.sh --manifest <path> --status # print state line\n#\n# Exit 0: picked-up (or already). Exit 5: failed (window elapsed, no evidence).\n# Exit 6: still waiting (inside window, no evidence). Exit 2: usage/precondition.\nset -uo pipefail\n",
53
54
  repo: "hq-delegate-repo.sh — code/branch handover for a delegation: make sure the\nproject's branch exists on the remote, record uncommitted work as NOT\ntransferred, and verify the recipient's repository access read-only.\n\nUsage:\n core/scripts/hq-delegate-repo.sh --manifest <path> [--yes] [--github-user <login>]\n\nBehavior (driven by the manifest's repo{} block; no-op when repo is null):\n - records remote URL and the branch's headSha into the manifest\n - a branch that exists only locally requires confirmation: without --yes\n the helper prints the plan and exits 2; with --yes it pushes via an\n explicitly anchored `git -C <abs path> push -u origin <branch>`\n - dirty/untracked files are captured into repo.dirtyFiles[] and appended\n to BRIEF.md as work NOT transferred — never silently dropped\n - recipient access is checked read-only via gh when --github-user is\n given; otherwise (or on a gh failure) repo.accessVerified stays false\n and the brief says so plainly — the helper never claims unverified access\n - never touches vaultPrefixes[] and never pushes repo content to the vault\n\nEnv: HQ_ROOT — HQ root override (default: resolved from script location)\n",
54
55
  secrets: "hq-delegate-secrets.sh — grant a delegation recipient access to the secrets\na project needs, BY NAME ONLY. No command in this helper ever reads,\nprints, logs, or interpolates a secret value.\n\nUsage:\n core/scripts/hq-delegate-secrets.sh --manifest <path> [--yes] [--no-secrets]\n [--env-schema <path>]\n\nBehavior:\n - derives required secret NAMES from the project's / repo's .env.schema\n (--env-schema overrides discovery); no schema -> records secrets: []\n and says so plainly rather than guessing at credentials\n - without --yes: prints a full-prose confirmation listing every name and\n the principal, then exits 2 granting nothing — this step is a privilege\n handover and gets its own confirmation, separate from the file grants\n - with --yes: hq secrets share <NAME> --with <principal> --permission read\n per name, then records the names (and only the names) in the manifest\n - --no-secrets: skips everything, records {secrets: [], secretsSkipped: true}\n\nConsumption on the recipient's side is via `hq run` / `hq secrets exec` —\ndocumented in the brief; values never appear in any delegation artifact\n(policy hq-delegate-never-inlines-secrets-or-share-urls).\n",
55
- transfer: "hq-delegate-transfer.sh — make a delegation real everywhere HQ tracks\nownership: board entry, PRD metadata, work mesh, and the project journal.\n\nUsage:\n core/scripts/hq-delegate-transfer.sh --manifest <path>\n\nBehavior (mode \"transfer\"; mode \"share\" skips every mutation):\n 1. companies/<co>/board.json — the project's entry gets owner=<recipient>\n and a bumped updated_at; created (once) if absent\n 2. prd.json metadata gains owner, delegatedFrom, delegatedAt\n 3. work-mesh: `done` event closes the delegator's in-progress thread and\n broadcasts the reassignment; silently skipped when the helper is\n unavailable (local/offline installs) — the transfer still succeeds\n 4. a dated \"Delegated\" stanza is appended to the project journal naming\n the recipient, delegation id, and transferred scope\n\nIdempotent: re-running updates owner/timestamps in place — never a\nduplicate board entry, never a duplicated journal stanza.\n\nThe delegator's own vault access is untouched (granting the recipient\nwrite does not revoke anything).\n\nEnv: HQ_ROOT — HQ root override (default: resolved from script location)\n",
56
+ transfer: "hq-delegate-transfer.sh — make a delegation real everywhere HQ tracks\nownership: board entry, PRD metadata, work mesh, and the project journal.\n\nUsage:\n core/scripts/hq-delegate-transfer.sh --manifest <path>\n\nBehavior (mode \"transfer\"; mode \"share\" skips every mutation):\n 1. companies/<co>/board.json — the project's entry gets owner=<recipient>\n and a bumped updated_at; created (once) if absent. A brainstorm-stage\n entry (status exploring, brainstorm_path set) is matched by\n brainstorm_path and keeps its status.\n 2. prd.json metadata gains owner, delegatedFrom, delegatedAt; for a\n brainstorm-stage project the same three keys land in the\n brainstorm.md frontmatter (owner, delegated_from, delegated_at)\n 3. work-mesh: `done` event closes the delegator's in-progress thread and\n broadcasts the reassignment; silently skipped when the helper is\n unavailable (local/offline installs) — the transfer still succeeds\n 4. a dated \"Delegated\" stanza is appended to the project journal naming\n the recipient, delegation id, and transferred scope\n\nIdempotent: re-running updates owner/timestamps in place — never a\nduplicate board entry, never a duplicated journal stanza.\n\n",
56
57
  };
57
58
  function usage(name) {
58
59
  return USAGE_TEXT[name];
@@ -189,7 +190,6 @@ export function moveFile(source, target, fileSystem = fs) {
189
190
  }
190
191
  export function writeJsonAtomically(target, value, siblingTemp = false, fileSystem = fs) {
191
192
  const serialized = serializeJqJson(value, "pretty");
192
- // Native delegate JSON writers use LF on every platform.
193
193
  if (siblingTemp) {
194
194
  const temporary = `${target}.tmp.${randomBytes(6).toString("hex")}`;
195
195
  let fd;
@@ -918,14 +918,25 @@ function runTransfer(args, context) {
918
918
  }
919
919
  const now = jqRawPath(document, "ownershipTransferredAt") || new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
920
920
  const today = (process.env.HQ_DELEGATE_NOW || new Date().toISOString()).slice(0, 10);
921
+ const stage = jqRawPath(document, "project.stage", "prd");
921
922
  const prdPath = `companies/${company}/projects/${project}/prd.json`;
922
923
  const prdAbs = path.join(root, prdPath);
924
+ const brainstormPath = `companies/${company}/projects/${project}/brainstorm.md`;
925
+ const brainstormAbs = path.join(root, brainstormPath);
923
926
  const projectDir = path.join(root, "companies", company, "projects", project);
924
927
  const board = path.join(root, "companies", company, "board.json");
925
- if (!fs.existsSync(prdAbs) || !fs.statSync(prdAbs).isFile()) {
928
+ if (stage !== "prd" && stage !== "brainstorm") {
929
+ writeErr(`${prefix} manifest project.stage is '${stage}' — expected prd or brainstorm\n`);
930
+ return 1;
931
+ }
932
+ if (stage === "prd" && (!fs.existsSync(prdAbs) || !fs.statSync(prdAbs).isFile())) {
926
933
  writeErr(`${prefix} prd not found: ${prdPath}\n`);
927
934
  return 1;
928
935
  }
936
+ if (stage === "brainstorm" && (!fs.existsSync(brainstormAbs) || !fs.statSync(brainstormAbs).isFile())) {
937
+ writeErr(`${prefix} brainstorm.md not found: ${brainstormPath}\n`);
938
+ return 1;
939
+ }
929
940
  if (fs.existsSync(board)) {
930
941
  if (!jqValid(board)) {
931
942
  writeErr(`${prefix} board.json is not valid JSON: ${board}\n`);
@@ -936,9 +947,9 @@ function runTransfer(args, context) {
936
947
  fs.mkdirSync(path.dirname(board), { recursive: true });
937
948
  fs.writeFileSync(board, '{"projects": []}\n');
938
949
  }
939
- const prd = readJsonFile(prdAbs);
940
- const title = jqRawPath(prd, "name");
941
- const descriptionValue = jqFallback(jsonAt(prd, "description"), "");
950
+ const prd = stage === "prd" ? readJsonFile(prdAbs) : undefined;
951
+ const title = stage === "prd" ? jqRawPath(prd, "name") : brainstormTitle(brainstormAbs);
952
+ const descriptionValue = stage === "prd" ? jqFallback(jsonAt(prd, "description"), "") : brainstormSummary(brainstormAbs);
942
953
  const description = Buffer.from(serializeJqJson(descriptionValue, "raw"), "utf8")
943
954
  .subarray(0, 300).toString("utf8").replace(/\n+$/, "");
944
955
  const boardDocument = readJsonFile(board);
@@ -948,35 +959,46 @@ function runTransfer(args, context) {
948
959
  const projects = Array.isArray(projectsValue)
949
960
  ? projectsValue
950
961
  : isRecord(projectsValue) ? Object.values(projectsValue) : [];
951
- const hasProject = projects.some((entry) => jsonAt(entry, "prd_path") === prdPath);
962
+ const matchPath = stage === "prd" ? prdPath : brainstormPath;
963
+ const hasProject = projects.some((entry) => jsonAt(entry, stage === "prd" ? "prd_path" : "brainstorm_path") === matchPath);
952
964
  if (hasProject) {
953
- boardDocument.projects = projects.map((entry) => jsonAt(entry, "prd_path") === prdPath && isRecord(entry)
965
+ boardDocument.projects = projects.map((entry) => jsonAt(entry, stage === "prd" ? "prd_path" : "brainstorm_path") === matchPath && isRecord(entry)
954
966
  ? { ...entry, owner: principal, updated_at: now }
955
967
  : entry);
956
968
  }
957
969
  else {
958
- boardDocument.projects = [...projects, {
959
- id: `proj-${project}`,
960
- title,
961
- description,
962
- status: "in_progress",
963
- owner: principal,
964
- prd_path: prdPath,
965
- created_at: now,
966
- updated_at: now,
967
- }];
970
+ const entry = {
971
+ id: `proj-${project}`,
972
+ title,
973
+ description,
974
+ status: stage === "prd" ? "in_progress" : "exploring",
975
+ owner: principal,
976
+ prd_path: stage === "prd" ? prdPath : null,
977
+ created_at: now,
978
+ updated_at: now,
979
+ };
980
+ if (stage === "brainstorm")
981
+ entry.brainstorm_path = brainstormPath;
982
+ boardDocument.projects = [...projects, entry];
968
983
  }
969
984
  boardDocument.updated_at = now;
970
985
  writeJsonAtomically(board, boardDocument);
971
- if (!isRecord(prd))
972
- throw new TypeError("Cannot update non-object prd.json");
973
- if (!isRecord(prd.metadata))
974
- prd.metadata = {};
975
- const metadata = prd.metadata;
976
- metadata.owner = principal;
977
- metadata.delegatedFrom = from;
978
- metadata.delegatedAt = now;
979
- writeJsonAtomically(prdAbs, prd);
986
+ if (stage === "prd") {
987
+ if (!isRecord(prd))
988
+ throw new TypeError("Cannot update non-object prd.json");
989
+ if (!isRecord(prd.metadata))
990
+ prd.metadata = {};
991
+ const metadata = prd.metadata;
992
+ metadata.owner = principal;
993
+ metadata.delegatedFrom = from;
994
+ metadata.delegatedAt = now;
995
+ writeJsonAtomically(prdAbs, prd);
996
+ }
997
+ else {
998
+ brainstormFrontmatterSet(brainstormAbs, "owner", principal);
999
+ brainstormFrontmatterSet(brainstormAbs, "delegated_from", from);
1000
+ brainstormFrontmatterSet(brainstormAbs, "delegated_at", now);
1001
+ }
980
1002
  if (commandExists("hq", context.cwd)) {
981
1003
  const sessionId = process.env.HQ_SESSION_ID || "delegate";
982
1004
  const mesh = subprocess("hq", ["mesh", "session", "note", "--enqueue", "--session-id", sessionId, "--seq", "1", "--harness", "claude-code", "--adapter-version", "1.0.0", "--company-slug", company, "--project", project, "--summary", `Delegated to ${display} (${principal}) — delegation ${delegationId}; ownership transferred`], context.cwd, { stdout: "inherit" });
@@ -993,12 +1015,17 @@ function runTransfer(args, context) {
993
1015
  const prefixesValue = jqFallback(document.vaultPrefixes, []);
994
1016
  const vaultPrefixes = Array.isArray(prefixesValue) ? prefixesValue : [];
995
1017
  const scope = vaultPrefixes.map((entry) => jqRawPath(entry, "prefix")).join(", ");
996
- fs.appendFileSync(journal, `\n## ${today} — Delegated to ${display}\n\n- Delegation: \`${delegationId}\`\n- From: ${from}\n- To: ${display} (${principal})\n- Transferred scope: ${scope}\n- Local board and PRD ownership updated; Work Mesh ownership is unconfirmed. Existing delegator access is unchanged.\n`);
1018
+ const ownershipLine = stage === "prd"
1019
+ ? "Local board and PRD ownership updated; Work Mesh ownership is unconfirmed. Existing delegator access is unchanged."
1020
+ : "Brainstorm-stage project (no PRD yet). Local board and brainstorm.md ownership updated; Work Mesh ownership is unconfirmed. Existing delegator access is unchanged.";
1021
+ fs.appendFileSync(journal, `\n## ${today} — Delegated to ${display}\n\n- Delegation: \`${delegationId}\`\n- From: ${from}\n- To: ${display} (${principal})\n- Transferred scope: ${scope}\n- ${ownershipLine}\n`);
997
1022
  }
998
1023
  document.ownershipTransferredAt = now;
999
1024
  document.workMeshOwnership = "unconfirmed";
1000
1025
  writeJsonAtomically(file, document);
1001
- writeOut(`${prefix} ownership of '${project}' transferred to ${display} (${principal}) — local board, PRD, and journal updated; Work Mesh ownership unconfirmed\n`);
1026
+ const sourceLabel = stage === "prd" ? "PRD" : "brainstorm.md";
1027
+ const stageLabel = stage === "prd" ? "" : " (brainstorm stage)";
1028
+ writeOut(`${prefix} ownership of '${project}'${stageLabel} transferred to ${display} (${principal}) — local board, ${sourceLabel}, and journal updated; Work Mesh ownership unconfirmed\n`);
1002
1029
  return 0;
1003
1030
  }
1004
1031
  function runNative(name, args, context) {
@@ -268,6 +268,7 @@ export const SCAFFOLD_ONLY_ASSETS = [
268
268
  { asset: "core/scripts/lib/session-scope-capability.sh", root: "live", summary: "Session scope-capability lib sourced by hq-session" },
269
269
  { asset: "core/scripts/lib/session-id.sh", root: "live", summary: "Session-id resolver lib sourced by hq-session" },
270
270
  { asset: "core/scripts/lib/hq-cli-floor.sh", root: "live", summary: "Shared CLI version-floor helper sourced by generated forwarders" },
271
+ { asset: "core/scripts/lib/delegate-brainstorm.sh", root: "live", summary: "Shared brainstorm-stage data helper sourced by delegate assets" },
271
272
  { asset: "core/scripts/lib/forwarder.sh", root: "live", summary: "Shared runtime sourced by generated scaffold forwarders" },
272
273
  { asset: "core/scripts/lib/work-mesh-enqueue.sh", root: "live", summary: "Work Mesh Live spool enqueue lib sourced by hooks" },
273
274
  { asset: "core/scripts/lib/work-mesh-live-hook.sh", root: "live", summary: "Work Mesh Live hook helpers sourced by rebind" },
@@ -101,6 +101,12 @@ export interface PresenceClientOptions {
101
101
  * backoff counters.
102
102
  */
103
103
  export declare function backoffDelayMs(attempt: number, baseMs: number, maxMs: number, random: () => number, previousMs?: number, minMs?: number): number;
104
+ /**
105
+ * Stable episode key for transport failures so a network drop logs once
106
+ * instead of once per reconnect/error event (those Error objects otherwise
107
+ * pin the heap while the daemon crash-loops).
108
+ */
109
+ export declare function networkErrorEpisodeKey(err: unknown): string | null;
104
110
  export declare function buildPresencePayload(input: {
105
111
  status: "online" | "offline";
106
112
  actorUid: string;
@@ -129,6 +135,10 @@ export declare class PresenceClient {
129
135
  private publishedTopics;
130
136
  /** Last refusal code we logged via onError (episode dedupe). */
131
137
  private loggedRefusalKey;
138
+ /** Last transport-failure episode logged via onError (network-drop dedupe). */
139
+ private loggedNetworkErrorKey;
140
+ /** Serialize connect/vend so a drop cannot stack MQTT clients or fetches. */
141
+ private connectPromise;
132
142
  private refusal;
133
143
  private preferredHandle;
134
144
  private preferredSnapshot;
@@ -163,6 +173,9 @@ export declare class PresenceClient {
163
173
  /** Force a reconnect cycle (network change / sleep-wake). */
164
174
  reconnectNow(): void;
165
175
  private teardownClient;
176
+ private emitError;
177
+ private clearNetworkErrorEpisode;
178
+ private enqueueConnect;
166
179
  private onCredentialsRenewed;
167
180
  private handleRenewalError;
168
181
  private refusalKey;
@@ -54,6 +54,27 @@ export function backoffDelayMs(attempt, baseMs, maxMs, random, previousMs = 0, m
54
54
  const raw = Math.max(0, random() * cap);
55
55
  return Math.min(maxMs, Math.max(previousMs, raw, minMs));
56
56
  }
57
+ const NETWORK_ERROR_RE = /fetch failed|ETIMEDOUT|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ENETUNREACH|ECONNRESET|ENETDOWN|EHOSTUNREACH/i;
58
+ /**
59
+ * Stable episode key for transport failures so a network drop logs once
60
+ * instead of once per reconnect/error event (those Error objects otherwise
61
+ * pin the heap while the daemon crash-loops).
62
+ */
63
+ export function networkErrorEpisodeKey(err) {
64
+ const code = err !== null && typeof err === "object" && "code" in err &&
65
+ typeof err.code === "string"
66
+ ? err.code
67
+ : "";
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ if (code && NETWORK_ERROR_RE.test(code))
70
+ return code;
71
+ if (!NETWORK_ERROR_RE.test(msg))
72
+ return null;
73
+ if (/fetch failed/i.test(msg))
74
+ return "fetch_failed";
75
+ const match = msg.match(/ETIMEDOUT|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ENETUNREACH|ECONNRESET|ENETDOWN|EHOSTUNREACH/i);
76
+ return match ? match[0].toUpperCase() : "network";
77
+ }
57
78
  export function buildPresencePayload(input) {
58
79
  return {
59
80
  v: 1,
@@ -92,6 +113,10 @@ export class PresenceClient {
92
113
  publishedTopics = [];
93
114
  /** Last refusal code we logged via onError (episode dedupe). */
94
115
  loggedRefusalKey = null;
116
+ /** Last transport-failure episode logged via onError (network-drop dedupe). */
117
+ loggedNetworkErrorKey = null;
118
+ /** Serialize connect/vend so a drop cannot stack MQTT clients or fetches. */
119
+ connectPromise = null;
95
120
  refusal = null;
96
121
  preferredHandle = null;
97
122
  preferredSnapshot = [];
@@ -202,7 +227,7 @@ export class PresenceClient {
202
227
  if (this.stopped)
203
228
  throw new Error("PresenceClient already stopped");
204
229
  this.setState("connecting");
205
- await this.connectOnce();
230
+ await this.enqueueConnect();
206
231
  }
207
232
  /**
208
233
  * Graceful stop: disconnect cleanly (MQTT DISCONNECT so the broker emits
@@ -234,13 +259,20 @@ export class PresenceClient {
234
259
  }
235
260
  // Backoff counters reset only after a stable connection — not here.
236
261
  this.clearReconnect();
237
- void this.connectOnce();
262
+ void this.enqueueConnect();
238
263
  }
239
264
  teardownClient(force) {
240
265
  const client = this.client;
241
266
  if (!client)
242
267
  return;
243
268
  this.client = null;
269
+ try {
270
+ const removable = client;
271
+ removable.removeAllListeners?.();
272
+ }
273
+ catch {
274
+ /* best-effort */
275
+ }
244
276
  try {
245
277
  if (force) {
246
278
  client.end(true);
@@ -253,22 +285,43 @@ export class PresenceClient {
253
285
  /* best-effort */
254
286
  }
255
287
  }
288
+ emitError(err) {
289
+ const key = networkErrorEpisodeKey(err);
290
+ if (key) {
291
+ if (this.loggedNetworkErrorKey === key)
292
+ return;
293
+ this.loggedNetworkErrorKey = key;
294
+ }
295
+ this.options.onError?.(err);
296
+ }
297
+ clearNetworkErrorEpisode() {
298
+ this.loggedNetworkErrorKey = null;
299
+ }
300
+ enqueueConnect() {
301
+ if (this.connectPromise)
302
+ return this.connectPromise;
303
+ this.connectPromise = this.connectOnce().finally(() => {
304
+ this.connectPromise = null;
305
+ });
306
+ return this.connectPromise;
307
+ }
256
308
  async onCredentialsRenewed(bundle) {
257
309
  this.bundle = bundle;
258
310
  this.notePreferredVend();
259
311
  // Successful vend ends a refusal episode (renewal skips connectOnce's vend path).
260
312
  this.clearRefusal(true);
313
+ this.clearNetworkErrorEpisode();
261
314
  if (this.stopped)
262
315
  return;
263
316
  // Reconnect with the new presigned URL; backoff resets only after stable connect.
264
- await this.connectOnce();
317
+ await this.enqueueConnect();
265
318
  }
266
319
  handleRenewalError(err, info) {
267
320
  if (err instanceof CredentialVendError && err.kind === "refused") {
268
321
  this.noteRefusal(err, info?.nextRetryAt);
269
322
  return;
270
323
  }
271
- this.options.onError?.(err);
324
+ this.emitError(err);
272
325
  }
273
326
  refusalKey(err) {
274
327
  return err.code ?? `HTTP_${err.status}`;
@@ -314,6 +367,7 @@ export class PresenceClient {
314
367
  this.bundle = await this.options.fetchCredentials();
315
368
  // Successful vend ends a refusal episode (even before MQTT connects).
316
369
  this.clearRefusal(true);
370
+ this.clearNetworkErrorEpisode();
317
371
  this.renewal.schedule(this.bundle);
318
372
  this.notePreferredVend();
319
373
  this.armPreferredWatch();
@@ -334,7 +388,7 @@ export class PresenceClient {
334
388
  });
335
389
  }
336
390
  catch (err) {
337
- this.options.onError?.(err);
391
+ this.emitError(err);
338
392
  this.scheduleReconnect();
339
393
  return;
340
394
  }
@@ -342,6 +396,7 @@ export class PresenceClient {
342
396
  client.on("connect", () => {
343
397
  if (this.stopped || generation !== this.generation)
344
398
  return;
399
+ this.clearNetworkErrorEpisode();
345
400
  // Do not reset attempt here — broker may accept then close immediately
346
401
  // (denied publish / policy). Reset only after CONNECT_STABLE_GRACE_MS.
347
402
  this.connectedAtMs = this.timers.now();
@@ -362,7 +417,7 @@ export class PresenceClient {
362
417
  client.on("error", (err) => {
363
418
  if (this.stopped || generation !== this.generation)
364
419
  return;
365
- this.options.onError?.(err);
420
+ this.emitError(err);
366
421
  });
367
422
  client.on("close", () => {
368
423
  if (this.stopped || generation !== this.generation)
@@ -381,7 +436,7 @@ export class PresenceClient {
381
436
  this.scheduleReconnect({ refused: err });
382
437
  return;
383
438
  }
384
- this.options.onError?.(err);
439
+ this.emitError(err);
385
440
  this.scheduleReconnect();
386
441
  }
387
442
  }
@@ -401,7 +456,7 @@ export class PresenceClient {
401
456
  for (const company of bundle.companies) {
402
457
  const topic = company.presenceTopic;
403
458
  if (!isOwnPresenceTopic(topic, bundle.actorUid, companyUids)) {
404
- this.options.onError?.(new Error(`refusing to publish to non-presence topic`));
459
+ this.emitError(new Error(`refusing to publish to non-presence topic`));
405
460
  continue;
406
461
  }
407
462
  await new Promise((resolve) => {
@@ -412,7 +467,7 @@ export class PresenceClient {
412
467
  }
413
468
  }
414
469
  catch (err) {
415
- this.options.onError?.(err);
470
+ this.emitError(err);
416
471
  resolve();
417
472
  }
418
473
  });
@@ -451,7 +506,7 @@ export class PresenceClient {
451
506
  if (opts?.refused) {
452
507
  this.bundle = null;
453
508
  }
454
- void this.connectOnce();
509
+ void this.enqueueConnect();
455
510
  }, delay);
456
511
  return delay;
457
512
  }
@@ -24,6 +24,9 @@ export declare const FLUSH_INTERVAL_MS = 10000;
24
24
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
25
25
  export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
26
26
  export declare const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2000;
27
+ /** Skip further membership probes after a transport failure so a network drop
28
+ * cannot fan out one hanging lookup per held session. */
29
+ export declare const DEVICE_DEFAULT_MEMBERSHIP_UNAVAILABLE_COOLDOWN_MS = 30000;
27
30
  /** Three sequential async shutdown steps must finish within six seconds. */
28
31
  export declare const SHUTDOWN_STEP_TIMEOUT_MS = 2000;
29
32
  /** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
@@ -45,6 +45,9 @@ export const FLUSH_INTERVAL_MS = 10_000;
45
45
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
46
46
  export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
47
47
  export const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2_000;
48
+ /** Skip further membership probes after a transport failure so a network drop
49
+ * cannot fan out one hanging lookup per held session. */
50
+ export const DEVICE_DEFAULT_MEMBERSHIP_UNAVAILABLE_COOLDOWN_MS = 30_000;
48
51
  /** Three sequential async shutdown steps must finish within six seconds. */
49
52
  export const SHUTDOWN_STEP_TIMEOUT_MS = 2_000;
50
53
  /** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
@@ -172,6 +175,9 @@ export async function runMeshDaemon(deps = {}) {
172
175
  const resolveMembership = deps.resolveMembership ?? resolveActiveMembershipCompany;
173
176
  const machineIdentity = deps.machineIdentity ?? isMachineIdentity();
174
177
  /** The sole authorization seam for daemon tenant I/O derived from a device default. */
178
+ let membershipUnavailableUntilMs = 0;
179
+ let membershipUnavailableError = "membership lookup unavailable";
180
+ let membershipUnavailableLogged = false;
175
181
  const tenantGate = {
176
182
  authorize: async (input) => {
177
183
  const state = input.state ?? (input.sessionId
@@ -190,6 +196,9 @@ export async function runMeshDaemon(deps = {}) {
190
196
  const lookupKey = slug || state?.companyUid || (usingDefaultFallback
191
197
  ? (defaultCompany?.slug || defaultCompany?.uid)
192
198
  : undefined);
199
+ if (lookupKey && now().getTime() < membershipUnavailableUntilMs) {
200
+ return { status: "unavailable", error: membershipUnavailableError };
201
+ }
193
202
  let authorization;
194
203
  if (!lookupKey) {
195
204
  authorization = { status: "denied" };
@@ -240,14 +249,21 @@ export async function runMeshDaemon(deps = {}) {
240
249
  }
241
250
  }
242
251
  if (authorization.status === "unavailable") {
243
- const prior = readDaemonState(dir)?.deviceDefaultMembershipLookupFailures ?? 0;
244
- patchDaemonState(dir, {
245
- lastErrorCode: "DEVICE_DEFAULT_MEMBERSHIP_LOOKUP_FAILED",
246
- deviceDefaultMembershipLookupFailures: prior + 1,
247
- }, now);
248
- log(dir, `device-default membership lookup unavailable${slug ? ` company=${slug}` : ""}: ${authorization.error.slice(0, 160)}`);
252
+ membershipUnavailableUntilMs = now().getTime() + DEVICE_DEFAULT_MEMBERSHIP_UNAVAILABLE_COOLDOWN_MS;
253
+ membershipUnavailableError = authorization.error;
254
+ if (!membershipUnavailableLogged) {
255
+ membershipUnavailableLogged = true;
256
+ const prior = readDaemonState(dir)?.deviceDefaultMembershipLookupFailures ?? 0;
257
+ patchDaemonState(dir, {
258
+ lastErrorCode: "DEVICE_DEFAULT_MEMBERSHIP_LOOKUP_FAILED",
259
+ deviceDefaultMembershipLookupFailures: prior + 1,
260
+ }, now);
261
+ log(dir, `device-default membership lookup unavailable${slug ? ` company=${slug}` : ""}: ${authorization.error.slice(0, 160)}`);
262
+ }
249
263
  return authorization;
250
264
  }
265
+ membershipUnavailableUntilMs = 0;
266
+ membershipUnavailableLogged = false;
251
267
  if (!input.deviceDefault)
252
268
  return authorization;
253
269
  if (authorization.status === "authorized" &&
@@ -351,12 +367,17 @@ export async function runMeshDaemon(deps = {}) {
351
367
  lastHeldRetryAtMs = atMs;
352
368
  },
353
369
  log: (message) => log(dir, message),
354
- authorizeDeviceDefaultTenant: async (state) => (await tenantGate.authorize({
355
- state,
356
- companySlug: state.companySlug,
357
- companyUid: state.companyUid,
358
- deviceDefault: true,
359
- })).status === "authorized",
370
+ authorizeDeviceDefaultTenant: async (state) => {
371
+ const authorization = await tenantGate.authorize({
372
+ state,
373
+ companySlug: state.companySlug,
374
+ companyUid: state.companyUid,
375
+ deviceDefault: true,
376
+ });
377
+ if (authorization.status === "unavailable")
378
+ return "unavailable";
379
+ return authorization.status === "authorized";
380
+ },
360
381
  });
361
382
  });
362
383
  const replayFn = deps.replayOutbox ??
@@ -45,8 +45,11 @@ export interface FlushDeps {
45
45
  heldMaxLines?: number;
46
46
  /** Optional logger (daemon wires this); used once per overflow trim. */
47
47
  log?: (message: string) => void;
48
- /** Authoritative gate for state derived from a device default. */
49
- authorizeDeviceDefaultTenant?: (state: SessionStateFile) => Promise<boolean>;
48
+ /** Authoritative gate for state derived from a device default.
49
+ * `true` allow, `false` deny (hold NEEDS_COMPANY), `"unavailable"` skip the
50
+ * network this cycle and keep the original held envelope.
51
+ */
52
+ authorizeDeviceDefaultTenant?: (state: SessionStateFile) => Promise<boolean | "unavailable">;
50
53
  /** Passed through to bind-best (catalog cache root and HQ_WORK_MESH_CACHE). */
51
54
  env?: NodeJS.ProcessEnv;
52
55
  home?: string;
@@ -142,6 +142,10 @@ async function postBatchWithRetries(poster, body, deps) {
142
142
  lastBody = res.body;
143
143
  if (res.ok)
144
144
  return { ok: true, body: res.body };
145
+ if (res.status === 0) {
146
+ // Transport down (fetch failed): do not hot-loop five more attempts.
147
+ return { ok: false, retryable: true, status: 0, body: res.body };
148
+ }
145
149
  if (res.status === 429 || res.status === 503) {
146
150
  const retryAfterMs = typeof res.retryAfterMs === "number" &&
147
151
  Number.isFinite(res.retryAfterMs) &&
@@ -279,12 +283,13 @@ export async function flushSessionEvents(inputDeps) {
279
283
  if (leaveResult.abort)
280
284
  continue;
281
285
  const remainingItems = leaveResult.items;
286
+ state = bindHeldCompanyIfResolved(sessionId, remainingItems, state, deps, now);
282
287
  if (state?.companyConfidence === "device_default" &&
283
288
  deps.authorizeDeviceDefaultTenant) {
284
289
  const allowed = await deps.authorizeDeviceDefaultTenant(state);
285
290
  // Authorization may restore a company the gate previously cleared.
286
291
  state = readSessionState(sessionId, deps.workContextRoot) ?? state;
287
- if (!allowed) {
292
+ if (allowed === false || allowed === "unavailable") {
288
293
  const heldAt = now().toISOString();
289
294
  summary.held += appendHeldLinesIfAbsent(remainingItems.map((item) => heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt)), deps.workMeshRoot);
290
295
  for (const item of remainingItems)
@@ -392,6 +397,8 @@ export async function flushSessionEvents(inputDeps) {
392
397
  summary.restored += 1;
393
398
  markDisposed(item.event);
394
399
  }
400
+ if (result.status === 0)
401
+ break sessionLoop;
395
402
  if (result.status === 429 || result.status === 503)
396
403
  break;
397
404
  if (deps.signal?.aborted)
@@ -604,6 +611,72 @@ const SESSION_EVENT_HARNESSES = new Set([
604
611
  "agent-box",
605
612
  "agents-v2",
606
613
  ]);
614
+ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
615
+ if (state?.companyUid)
616
+ return state;
617
+ const sample = items[0]?.event;
618
+ if (!sample)
619
+ return state;
620
+ const cwd = eventText(sample, "cwd");
621
+ const hqRoot = eventText(sample, "hqRoot");
622
+ const remoteOwnerSlug = cwd
623
+ ? deriveRemoteOwnerSlug({ cwd, hqRoot }) ?? undefined
624
+ : undefined;
625
+ const repoIdentityKey = cwd ? deriveRepoIdentityKey({ cwd }) : null;
626
+ const resolution = resolveCompany({
627
+ root: deps.workContextRoot,
628
+ sessionId,
629
+ existingState: state,
630
+ env: {
631
+ ...(deps.env ?? {}),
632
+ HQ_SPAWN_COMPANY: "",
633
+ HQ_AGENT_IDENTITY_FILE: "",
634
+ },
635
+ trusted: eventText(sample, "companySlug")
636
+ ? { companySlug: eventText(sample, "companySlug") }
637
+ : undefined,
638
+ cwd,
639
+ hqRoot,
640
+ remoteOwnerSlug,
641
+ repoIdentityKey,
642
+ });
643
+ if (resolution.status !== "resolved" || !resolution.company.uid)
644
+ return state;
645
+ if (resolution.company.source === "trusted_explicit" &&
646
+ !eventText(sample, "companySlug")) {
647
+ return state;
648
+ }
649
+ const uid = resolution.company.uid;
650
+ const slug = resolution.company.slug;
651
+ const nowIso = now().toISOString();
652
+ const next = updateSessionState(sessionId, deps.workContextRoot, (current) => {
653
+ const base = current ?? state;
654
+ if (!base)
655
+ return null;
656
+ if (base.companyUid && base.companyUid !== uid)
657
+ return null;
658
+ const contextStatus = base.contextStatus === "needs_company" ||
659
+ base.contextStatus === "unresolved" ||
660
+ !base.contextStatus
661
+ ? (base.projectId
662
+ ? (base.taskId ? "bound" : "needs_task")
663
+ : "needs_project")
664
+ : base.contextStatus;
665
+ return {
666
+ ...base,
667
+ sessionId,
668
+ contractVersion: base.contractVersion ?? 1,
669
+ companyUid: uid,
670
+ ...(slug ? { companySlug: slug } : {}),
671
+ ...(resolution.company.source === "device_default"
672
+ ? { companyConfidence: "device_default" }
673
+ : {}),
674
+ contextStatus,
675
+ updatedAt: nowIso,
676
+ };
677
+ });
678
+ return next ?? readSessionState(sessionId, deps.workContextRoot) ?? state;
679
+ }
607
680
  function applySessionMetaCompanyRebind(sessionId, items, state, deps, now) {
608
681
  if (!state)
609
682
  return state;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.87",
3
+ "version": "5.345.89",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {