@indigoai-us/hq-cli 5.250.0 → 5.252.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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.252.0] — 2026-09-27
6
+ ### Added
7
+
8
+ - `hq report publish` takes a snapshot of the finished page and publishes it as the report's preview image, using a browser already installed on the computer. A page that names its own preview image keeps it. If no browser is found the report publishes without a preview.
9
+
10
+ ## [5.251.0] — 2026-09-27
11
+
5
12
  ## [5.250.0] — 2026-09-27
6
13
 
7
14
  ### Fixed
8
15
 
16
+ - A Work Mesh session bound to a project and task keeps the binding episode the
9
17
  - An enrolled agent that sets `HQ_AGENT_DIR` now acts as that agent in every
10
18
  command, not only under `hq agent`. `hq dm` and `hq secrets` used to look
11
19
  for a person's session instead, find none, and open a browser sign-in. On
@@ -37,7 +45,6 @@
37
45
  host key is missing, instead of passing on a cached token and leaving the
38
46
  agent to fail at its next refresh.
39
47
 
40
-
41
48
  ## [5.249.1] — 2026-09-27
42
49
 
43
50
  ## [5.249.0] — 2026-09-27
@@ -1,4 +1,5 @@
1
1
  /** `hq report` — safe channel-report lifecycle commands for bot runbooks. */
2
+ import { snapshotReport } from "../lib/report-snapshot.js";
2
3
  import * as fs from "node:fs";
3
4
  import * as os from "node:os";
4
5
  import * as path from "node:path";
@@ -339,6 +340,7 @@ async function publishWithOrg(token, report, directory, draft) {
339
340
  return deployReportStatic(token, { ...input, org });
340
341
  },
341
342
  now: () => new Date().toISOString(),
343
+ snapshot: (source) => snapshotReport(source),
342
344
  });
343
345
  rememberReportChannelAfterSuccess(published);
344
346
  return published;
@@ -91,7 +91,17 @@ export interface ReportPublishDependencies {
91
91
  to: string;
92
92
  body: string;
93
93
  }): Promise<void>;
94
+ /**
95
+ * PNG of the finished page for the gallery card, or null when none can be
96
+ * made. It is given a staged copy of the files as they will be published. Only asked for when the page does not name its own preview image.
97
+ */
98
+ snapshot?(directory: string): Promise<Buffer | null>;
94
99
  }
100
+ /**
101
+ * Add a snapshot of the page as its preview when it has none of its own.
102
+ * A snapshot that fails never fails the publish.
103
+ */
104
+ export declare function withPreviewSnapshot(source: ReportSource, snapshot: ReportPublishDependencies["snapshot"]): Promise<ReportSource>;
95
105
  /**
96
106
  * Plain owner DM after a failed run. Suppressed for the publish lock
97
107
  * ("already running") so a second run does not page the owner.
@@ -10,12 +10,55 @@ import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
  import { gzipSync } from "node:zlib";
13
+ import { REPORT_PREVIEW_FILE } from "./report-snapshot.js";
13
14
  import { loadUpdateReportRunbook } from "./report-templates.js";
14
15
  /** Mirrors the deploy skill's static-report guardrails. */
15
16
  export const REPORT_SOURCE_MAX_BYTES = 25 * 1024 * 1024;
16
17
  export const REPORT_BUNDLE_MAX_BYTES = 10 * 1024 * 1024;
17
18
  export const REPORT_SOURCE_MAX_FILES = 100;
18
19
  export const REPORT_CSP = "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; form-action 'none'";
20
+ /**
21
+ * Write the files exactly as they will be published, protected index.html
22
+ * included, so the snapshot renders under the same rules as the live page
23
+ * and never opens the bot's raw page.
24
+ */
25
+ function stageForSnapshot(files) {
26
+ const stage = fs.mkdtempSync(path.join(os.tmpdir(), "hq-report-stage-"));
27
+ for (const file of files) {
28
+ const target = path.resolve(stage, file.path);
29
+ if (target !== stage && !target.startsWith(stage + path.sep))
30
+ continue;
31
+ fs.mkdirSync(path.dirname(target), { recursive: true });
32
+ fs.writeFileSync(target, Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8"));
33
+ }
34
+ return stage;
35
+ }
36
+ /**
37
+ * Add a snapshot of the page as its preview when it has none of its own.
38
+ * A snapshot that fails never fails the publish.
39
+ */
40
+ export async function withPreviewSnapshot(source, snapshot) {
41
+ if (source.previewImage || !snapshot)
42
+ return source;
43
+ let image = null;
44
+ let stage = null;
45
+ try {
46
+ stage = stageForSnapshot(source.files);
47
+ image = await snapshot(stage);
48
+ }
49
+ catch {
50
+ image = null;
51
+ }
52
+ finally {
53
+ if (stage)
54
+ fs.rmSync(stage, { recursive: true, force: true });
55
+ }
56
+ if (!image || image.byteLength === 0)
57
+ return source;
58
+ const files = source.files.filter((file) => file.path !== REPORT_PREVIEW_FILE);
59
+ files.push({ path: REPORT_PREVIEW_FILE, content: image.toString("base64"), encoding: "base64" });
60
+ return { ...source, files, previewImage: REPORT_PREVIEW_FILE };
61
+ }
19
62
  /**
20
63
  * Plain owner DM after a failed run. Suppressed for the publish lock
21
64
  * ("already running") so a second run does not page the owner.
@@ -277,7 +320,7 @@ export async function publishReport(report, directory, draft, deps) {
277
320
  throw error;
278
321
  throw new ReportPublishError("Could not take the report run lock.");
279
322
  }
280
- const source = loadReportSource(directory);
323
+ const source = await withPreviewSnapshot(loadReportSource(directory), deps.snapshot);
281
324
  const deployment = await deps.deploy({
282
325
  slug: reportDeploySlug(current, draft),
283
326
  files: source.files,
@@ -0,0 +1,14 @@
1
+ /** File name of the snapshot inside the published report. */
2
+ export declare const REPORT_PREVIEW_FILE = "hq-preview.png";
3
+ export declare const REPORT_PREVIEW_WIDTH = 1280;
4
+ export declare const REPORT_PREVIEW_HEIGHT = 720;
5
+ /** First installed browser that can take a snapshot, or null. */
6
+ export declare function findSnapshotBrowser(env?: NodeJS.ProcessEnv, platform?: string, exists?: (file: string) => boolean): string | null;
7
+ export declare function snapshotArgs(indexFile: string, outFile: string, profileDir: string): string[];
8
+ export type SnapshotRunner = (browser: string, args: string[], outFile: string, timeoutMs: number) => Promise<void>;
9
+ /** PNG of the report's first screen, or null when one cannot be made. */
10
+ export declare function snapshotReport(directory: string, options?: {
11
+ browser?: string | null;
12
+ run?: SnapshotRunner;
13
+ }): Promise<Buffer | null>;
14
+ //# sourceMappingURL=report-snapshot.d.ts.map
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Report preview snapshot.
3
+ *
4
+ * Renders a report's index.html in a browser already installed on this
5
+ * computer and returns a PNG of the first screen. The snapshot is a nicety:
6
+ * every failure (no browser, timeout, empty file) returns null and the
7
+ * publish carries on without a preview.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ /** File name of the snapshot inside the published report. */
15
+ export const REPORT_PREVIEW_FILE = "hq-preview.png";
16
+ export const REPORT_PREVIEW_WIDTH = 1280;
17
+ export const REPORT_PREVIEW_HEIGHT = 720;
18
+ const SNAPSHOT_TIMEOUT_MS = 30_000;
19
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
20
+ const BROWSER_PATHS = {
21
+ darwin: [
22
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
23
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
24
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
25
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
26
+ ],
27
+ linux: [
28
+ "/usr/bin/google-chrome",
29
+ "/usr/bin/google-chrome-stable",
30
+ "/usr/bin/chromium",
31
+ "/usr/bin/chromium-browser",
32
+ "/snap/bin/chromium",
33
+ ],
34
+ win32: [
35
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
36
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
37
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
38
+ ],
39
+ };
40
+ /** First installed browser that can take a snapshot, or null. */
41
+ export function findSnapshotBrowser(env = process.env, platform = process.platform, exists = fs.existsSync) {
42
+ const override = (env.HQ_REPORT_BROWSER ?? "").trim();
43
+ if (override)
44
+ return exists(override) ? override : null;
45
+ return (BROWSER_PATHS[platform] ?? []).find((file) => exists(file)) ?? null;
46
+ }
47
+ export function snapshotArgs(indexFile, outFile, profileDir) {
48
+ return [
49
+ "--headless=new",
50
+ "--disable-gpu",
51
+ "--hide-scrollbars",
52
+ "--no-first-run",
53
+ "--no-default-browser-check",
54
+ "--disable-extensions",
55
+ `--user-data-dir=${profileDir}`,
56
+ `--window-size=${REPORT_PREVIEW_WIDTH},${REPORT_PREVIEW_HEIGHT}`,
57
+ "--virtual-time-budget=5000",
58
+ `--screenshot=${outFile}`,
59
+ pathToFileURL(indexFile).toString(),
60
+ ];
61
+ }
62
+ function stop(child) {
63
+ if (child.pid === undefined)
64
+ return;
65
+ try {
66
+ // The browser is its own process group, so its helpers stop with it.
67
+ if (process.platform === "win32")
68
+ child.kill("SIGKILL");
69
+ else
70
+ process.kill(-child.pid, "SIGKILL");
71
+ }
72
+ catch {
73
+ // Already gone.
74
+ }
75
+ }
76
+ /**
77
+ * Some browser versions write the snapshot and then never exit. The run is
78
+ * done when the file is complete, so the browser is stopped at that point
79
+ * instead of waited on.
80
+ */
81
+ const runBrowser = (browser, args, outFile, timeoutMs) => new Promise((resolve, reject) => {
82
+ const child = spawn(browser, args, { stdio: "ignore", detached: process.platform !== "win32" });
83
+ let lastSize = -1;
84
+ let settled = false;
85
+ const finish = (error) => {
86
+ if (settled)
87
+ return;
88
+ settled = true;
89
+ clearInterval(poll);
90
+ clearTimeout(limit);
91
+ stop(child);
92
+ if (error)
93
+ reject(error);
94
+ else
95
+ resolve();
96
+ };
97
+ const poll = setInterval(() => {
98
+ let size = 0;
99
+ try {
100
+ size = fs.statSync(outFile).size;
101
+ }
102
+ catch {
103
+ return;
104
+ }
105
+ if (size > 0 && size === lastSize)
106
+ finish();
107
+ lastSize = size;
108
+ }, 250);
109
+ const limit = setTimeout(() => finish(new Error("The snapshot timed out.")), timeoutMs);
110
+ child.once("error", (error) => finish(error));
111
+ child.once("exit", () => finish(fs.existsSync(outFile) ? undefined : new Error("The browser wrote no snapshot.")));
112
+ });
113
+ /** PNG of the report's first screen, or null when one cannot be made. */
114
+ export async function snapshotReport(directory, options = {}) {
115
+ const browser = options.browser === undefined ? findSnapshotBrowser() : options.browser;
116
+ if (!browser)
117
+ return null;
118
+ const indexFile = path.join(path.resolve(directory), "index.html");
119
+ if (!fs.existsSync(indexFile))
120
+ return null;
121
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "hq-report-preview-"));
122
+ try {
123
+ const outFile = path.join(work, REPORT_PREVIEW_FILE);
124
+ await (options.run ?? runBrowser)(browser, snapshotArgs(indexFile, outFile, path.join(work, "profile")), outFile, SNAPSHOT_TIMEOUT_MS);
125
+ const bytes = fs.readFileSync(outFile);
126
+ return bytes.subarray(0, 4).equals(PNG_MAGIC) ? bytes : null;
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ finally {
132
+ fs.rmSync(work, { recursive: true, force: true });
133
+ }
134
+ }
135
+ //# sourceMappingURL=report-snapshot.js.map
@@ -8,7 +8,7 @@ import * as path from "node:path";
8
8
  import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
9
9
  import { atomicWriteJson, ensureOwnerDir } from "./atomic.js";
10
10
  import { NotTrackingError } from "./errors.js";
11
- import { readSessionState, writeAcknowledgedRegisterBinding, } from "./state.js";
11
+ import { readSessionState, stripRegisterReceiptEpisodes, updateSessionState, writeAcknowledgedRegisterBinding, } from "./state.js";
12
12
  import { isSafeWorkContextSegment, workContextOutboxDir, workContextOutboxPath, } from "./paths.js";
13
13
  /** Fields allowed on a durable outbox operation (privacy allowlist). */
14
14
  export const OUTBOX_ALLOWLIST = [
@@ -285,7 +285,35 @@ export function writeAckedRegisterState(op, root, receiptId, now = () => new Dat
285
285
  /** Repair older unresolved session projections from acknowledged registrations. */
286
286
  export function repairAckedRegisterStates(root, sessionIds, now = () => new Date()) {
287
287
  let repaired = 0;
288
- for (const op of listOutboxOperations(root)) {
288
+ const operations = listOutboxOperations(root);
289
+ // A stored bindingEpisodeId that is a register receipt makes the server drop
290
+ // every batch. Clear it even when the register operation that produced it is
291
+ // no longer in the outbox: the id shape identifies it. This runs for every
292
+ // requested session, whatever its company or tracking status.
293
+ const receiptsBySession = new Map();
294
+ for (const op of operations) {
295
+ if (op.kind !== "register" || !op.receiptId)
296
+ continue;
297
+ const receipts = receiptsBySession.get(op.sessionId) ?? [];
298
+ receipts.push(op.receiptId);
299
+ receiptsBySession.set(op.sessionId, receipts);
300
+ }
301
+ for (const sessionId of sessionIds ?? receiptsBySession.keys()) {
302
+ if (!isSafeWorkContextSegment(sessionId))
303
+ continue;
304
+ const known = receiptsBySession.get(sessionId) ?? [];
305
+ // Take the lock only for a session that needs the repair.
306
+ const observed = readSessionState(sessionId, root);
307
+ if (!observed || stripRegisterReceiptEpisodes(observed, known) === observed)
308
+ continue;
309
+ updateSessionState(sessionId, root, (current) => {
310
+ if (!current)
311
+ return null;
312
+ const next = stripRegisterReceiptEpisodes(current, known);
313
+ return next === current ? null : next;
314
+ });
315
+ }
316
+ for (const op of operations) {
289
317
  if (op.delivery !== "acked" ||
290
318
  !op.receiptId ||
291
319
  op.kind !== "register" ||
@@ -15,7 +15,7 @@ import { rebindNeedsProjectOnPrompt } from "./prompt-rebind.js";
15
15
  import { resolveProjectTask, shouldAskAfter } from "./project.js";
16
16
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
17
17
  import { enqueueOutbox, listOutboxOperations, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
18
- import { mergeLocalSessionFields, readSessionState, writeSessionState, } from "./state.js";
18
+ import { mergeLocalSessionFields, isRegisterReceiptEpisodeId, readSessionState, stripRegisterReceiptEpisodes, writeSessionState, } from "./state.js";
19
19
  function resultOf(parts) {
20
20
  const out = {
21
21
  contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
@@ -174,7 +174,22 @@ export async function reconcileObservation(obs, deps) {
174
174
  }
175
175
  }
176
176
  // Already untracked locally — stay untracked until explicit opt-in (no backfill).
177
- const prior = readSessionState(sessionId, deps.root);
177
+ const observedPrior = readSessionState(sessionId, deps.root);
178
+ // Acked register operations for this session. A register response never
179
+ // carries a binding episode, so an already acknowledged binding is
180
+ // identified by its company / project / task, never by comparing the
181
+ // register receipt with the session's bindingEpisodeId.
182
+ const ackedRegisters = observedPrior
183
+ ? listOutboxOperations(deps.root).filter((operation) => operation.kind === "register" &&
184
+ operation.delivery === "acked" &&
185
+ operation.sessionId === sessionId &&
186
+ Boolean(operation.receiptId))
187
+ : [];
188
+ // Older clients stored a register receipt as the episode. Work from a
189
+ // projection with those cleared so no later write carries one forward.
190
+ const prior = observedPrior
191
+ ? stripRegisterReceiptEpisodes(observedPrior, ackedRegisters.map((operation) => operation.receiptId))
192
+ : observedPrior;
178
193
  if (prior?.contextStatus === "untracked" && prior.untrackedReceipt?.origin === "user") {
179
194
  return {
180
195
  result: resultOf({
@@ -690,15 +705,6 @@ export async function reconcileObservation(obs, deps) {
690
705
  registerClassification = "needs_project";
691
706
  diagnostics.push(`company rebind to ${resolvedCompany.slug ?? resolvedCompany.uid}; cleared project`);
692
707
  }
693
- // Acked register operations for this session. A register that did not come
694
- // back bound yields no binding episode, so its receipt (not the session's
695
- // bindingEpisodeId) is what identifies an already acknowledged binding.
696
- const ackedRegisters = prior
697
- ? listOutboxOperations(deps.root).filter((operation) => operation.kind === "register" &&
698
- operation.delivery === "acked" &&
699
- operation.sessionId === sessionId &&
700
- Boolean(operation.receiptId))
701
- : [];
702
708
  const stateClassification = registerClassification === "unresolved" &&
703
709
  prior?.contextStatus === "needs_project" &&
704
710
  prior.companyUid === resolvedCompany.uid &&
@@ -711,22 +717,26 @@ export async function reconcileObservation(obs, deps) {
711
717
  // into a second semantic register operation: the prior receipt is the stable
712
718
  // identity for this exact company/project/task binding.
713
719
  const priorBindingIsBound = Boolean(projectId && taskId);
720
+ // A state that is not an acknowledged binding (for example one quarantined
721
+ // as company_conflict) must register again even when its fields still match.
722
+ const priorIsAcknowledgedBinding = prior?.contextStatus === "bound" ||
723
+ prior?.contextStatus === "needs_task" ||
724
+ prior?.contextStatus === "needs_project" ||
725
+ prior?.contextStatus === "unresolved";
714
726
  const acknowledgedReceipt = prior &&
727
+ priorIsAcknowledgedBinding &&
715
728
  prior.companyUid === resolvedCompany.uid &&
716
729
  prior.companySlug === resolvedCompany.slug &&
717
730
  prior.projectId === projectId &&
718
731
  prior.taskId === taskId
719
- ? ackedRegisters.find((operation) => (priorBindingIsBound
720
- ? operation.receiptId === prior.bindingEpisodeId
721
- : !prior.bindingEpisodeId || operation.receiptId === prior.bindingEpisodeId) &&
722
- operation.companyUid === resolvedCompany.uid &&
732
+ ? ackedRegisters.find((operation) => operation.companyUid === resolvedCompany.uid &&
723
733
  operation.companySlug === resolvedCompany.slug &&
724
734
  operation.projectId === projectId &&
725
735
  operation.taskId === taskId)?.receiptId
726
736
  : undefined;
727
737
  const acknowledgedPrior = acknowledgedReceipt ? prior : null;
728
738
  if (acknowledgedPrior) {
729
- // A partial (not bound) acknowledged register carries no server episode.
739
+ // Only a bound session holds a server episode (issued by bind / organize).
730
740
  const retainedEpisodeId = priorBindingIsBound
731
741
  ? acknowledgedPrior.bindingEpisodeId
732
742
  : undefined;
@@ -895,10 +905,9 @@ export async function reconcileObservation(obs, deps) {
895
905
  // the true computed classification into durable session state; otherwise
896
906
  // the emitter and mesh presence lose the tenant the server already
897
907
  // acknowledged. projectId / taskId are written only when genuinely
898
- // resolved, and a fresh bindingEpisodeId only when the session is bound —
899
- // never fabricated on a partial path. (writeAckedRegisterState above may
900
- // have already recorded a receipt-derived episode for a resolved partial
901
- // binding; the default merge preserves that when the binding still matches.)
908
+ // resolved. A register ack never yields a bindingEpisodeId, bound or not:
909
+ // the server issues episodes only on bind / organize. The default merge
910
+ // keeps an episode the session already holds when the binding still matches.
902
911
  if (resolvedCompany.uid) {
903
912
  const bound = registerClassification === "bound";
904
913
  // A server ack of the company makes it authoritative: the durable status
@@ -919,7 +928,6 @@ export async function reconcileObservation(obs, deps) {
919
928
  contextStatus: bound ? "bound" : ackedClassification,
920
929
  ...(projectId ? { projectId } : {}),
921
930
  ...(taskId ? { taskId } : {}),
922
- ...(bound ? { bindingEpisodeId: delivered.receiptId } : {}),
923
931
  updatedAt: nowFn().toISOString(),
924
932
  startedAt,
925
933
  ...(typeof toolWrites === "number" ? { toolWrites } : {}),
@@ -939,6 +947,12 @@ export async function reconcileObservation(obs, deps) {
939
947
  : registerClassification === "needs_project"
940
948
  ? "needs_project"
941
949
  : "queued";
950
+ // Report the episode the session actually holds, never the receipt.
951
+ const ackedState = readSessionState(sessionId, deps.root);
952
+ const ackedEpisodeId = ackedState &&
953
+ !isRegisterReceiptEpisodeId(ackedState.bindingEpisodeId, [delivered.receiptId])
954
+ ? ackedState.bindingEpisodeId
955
+ : undefined;
942
956
  return {
943
957
  result: resultOf({
944
958
  kind: kind === "queued" ? "queued" : kind,
@@ -950,7 +964,7 @@ export async function reconcileObservation(obs, deps) {
950
964
  companySlug: resolvedCompany.slug,
951
965
  projectId,
952
966
  taskId,
953
- bindingEpisodeId: delivered.receiptId,
967
+ ...(ackedEpisodeId ? { bindingEpisodeId: ackedEpisodeId } : {}),
954
968
  ...bindResultFields,
955
969
  }),
956
970
  exitCode: EXIT_OK,
@@ -158,6 +158,22 @@ export declare function mergeLocalSessionFields(next: SessionStateFile, prior: S
158
158
  preserveBindingEpisode?: boolean;
159
159
  preserveCompanyBinding?: boolean;
160
160
  }): SessionStateFile;
161
+ /**
162
+ * True when a stored bindingEpisodeId is a register receipt, not an episode.
163
+ *
164
+ * The server mints binding episodes as UUIDs, and only on bind / organize. A
165
+ * register response carries no episode: its receipt is the operation id the
166
+ * client generated (`op_...`). An id is treated as a receipt when it has that
167
+ * shape or when it equals a receipt the caller knows about. A UUID is never
168
+ * treated as a receipt by shape.
169
+ */
170
+ export declare function isRegisterReceiptEpisodeId(id: string | undefined, knownReceiptIds?: Iterable<string | undefined>): boolean;
171
+ /**
172
+ * Clear register receipts stored as a binding episode, on the session itself
173
+ * and on its pending presenceLeave. Returns the same object when nothing
174
+ * changed, so callers can detect a repair by identity.
175
+ */
176
+ export declare function stripRegisterReceiptEpisodes(state: SessionStateFile, knownReceiptIds?: Iterable<string | undefined>): SessionStateFile;
161
177
  /**
162
178
  * Upgrade the local projection after the server acknowledges a register.
163
179
  *
@@ -165,6 +181,9 @@ export declare function mergeLocalSessionFields(next: SessionStateFile, prior: S
165
181
  * erase an explicit untracked choice, a conflicting existing company, or a
166
182
  * more specific binding for the same company. This is also used to repair
167
183
  * state files created by older clients before this write-back existed.
184
+ *
185
+ * The read and the write happen under the session state lock, so a concurrent
186
+ * writer (for example bind-project) is never overwritten from a stale snapshot.
168
187
  */
169
188
  export declare function writeAcknowledgedRegisterBinding(binding: AcknowledgedRegisterBinding, root: string, now?: () => Date): SessionStateFile | null;
170
189
  /** True when a durable state file was written by the hook / reconcile path. */
@@ -342,6 +342,47 @@ function bindingStrength(state) {
342
342
  return 1;
343
343
  return 0;
344
344
  }
345
+ const SERVER_EPISODE_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
346
+ /**
347
+ * True when a stored bindingEpisodeId is a register receipt, not an episode.
348
+ *
349
+ * The server mints binding episodes as UUIDs, and only on bind / organize. A
350
+ * register response carries no episode: its receipt is the operation id the
351
+ * client generated (`op_...`). An id is treated as a receipt when it has that
352
+ * shape or when it equals a receipt the caller knows about. A UUID is never
353
+ * treated as a receipt by shape.
354
+ */
355
+ export function isRegisterReceiptEpisodeId(id, knownReceiptIds = []) {
356
+ if (!id)
357
+ return false;
358
+ for (const receiptId of knownReceiptIds) {
359
+ if (receiptId && receiptId === id)
360
+ return true;
361
+ }
362
+ if (SERVER_EPISODE_SHAPE.test(id))
363
+ return false;
364
+ return id.startsWith("op_");
365
+ }
366
+ /**
367
+ * Clear register receipts stored as a binding episode, on the session itself
368
+ * and on its pending presenceLeave. Returns the same object when nothing
369
+ * changed, so callers can detect a repair by identity.
370
+ */
371
+ export function stripRegisterReceiptEpisodes(state, knownReceiptIds = []) {
372
+ const known = [...knownReceiptIds];
373
+ const ownIsReceipt = isRegisterReceiptEpisodeId(state.bindingEpisodeId, known);
374
+ const leaveIsReceipt = isRegisterReceiptEpisodeId(state.presenceLeave?.bindingEpisodeId, known);
375
+ if (!ownIsReceipt && !leaveIsReceipt)
376
+ return state;
377
+ const out = { ...state };
378
+ if (ownIsReceipt)
379
+ out.bindingEpisodeId = undefined;
380
+ if (leaveIsReceipt && state.presenceLeave) {
381
+ const { bindingEpisodeId: _receipt, ...leave } = state.presenceLeave;
382
+ out.presenceLeave = leave;
383
+ }
384
+ return out;
385
+ }
345
386
  /**
346
387
  * Upgrade the local projection after the server acknowledges a register.
347
388
  *
@@ -349,60 +390,68 @@ function bindingStrength(state) {
349
390
  * erase an explicit untracked choice, a conflicting existing company, or a
350
391
  * more specific binding for the same company. This is also used to repair
351
392
  * state files created by older clients before this write-back existed.
393
+ *
394
+ * The read and the write happen under the session state lock, so a concurrent
395
+ * writer (for example bind-project) is never overwritten from a stale snapshot.
352
396
  */
353
397
  export function writeAcknowledgedRegisterBinding(binding, root, now = () => new Date()) {
354
398
  const acknowledged = acknowledgedStatus(binding);
355
399
  if (!acknowledged || !isSafeWorkContextSegment(binding.sessionId))
356
400
  return null;
357
- const prior = readSessionState(binding.sessionId, root);
358
- // Reconcile always writes the local projection before network delivery. Do
359
- // not manufacture session files when replay sees an orphaned legacy outbox
360
- // record with no corresponding tracked session.
361
- if (!prior)
362
- return null;
363
- if (prior.contextStatus === "untracked" ||
364
- (prior.companyUid && prior.companyUid !== binding.companyUid)) {
365
- return prior;
366
- }
367
- const acknowledgementStrength = acknowledged === "bound" ? 3 : acknowledged === "needs_task" ? 2 : 1;
368
- const priorStrength = bindingStrength(prior);
369
- const preservePriorBinding = Boolean(prior.companyUid === binding.companyUid && priorStrength > acknowledgementStrength);
370
- // The server issues no binding episode for a register that did not come
371
- // back bound (unresolved / needs_project / needs_task). Its receipt must
372
- // never stand in for one: session-event batches carry bindingEpisodeId and
373
- // the server drops batches whose episode it does not recognise. Older
374
- // clients stored that receipt here, so also repair a prior value that is
375
- // exactly this partial register's receipt.
376
- const receiptEpisode = acknowledged === "bound" ? binding.receiptId : undefined;
377
- const priorEpisode = acknowledged !== "bound" && prior.bindingEpisodeId === binding.receiptId
378
- ? undefined
379
- : prior.bindingEpisodeId;
380
- const sameBindingAsPrior = prior.companyUid === binding.companyUid &&
381
- prior.projectId === binding.projectId &&
382
- prior.taskId === binding.taskId;
383
- const next = mergeLocalSessionFields({
384
- contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
385
- sessionId: binding.sessionId,
386
- companyUid: binding.companyUid,
387
- companySlug: binding.companySlug ?? prior.companySlug,
388
- contextStatus: preservePriorBinding ? prior.contextStatus : acknowledged,
389
- projectId: preservePriorBinding ? prior.projectId : binding.projectId,
390
- taskId: preservePriorBinding ? prior.taskId : binding.taskId,
391
- bindingEpisodeId: preservePriorBinding
392
- ? priorEpisode ?? receiptEpisode
393
- : receiptEpisode ?? (sameBindingAsPrior ? priorEpisode : undefined),
394
- updatedAt: now().toISOString(),
395
- }, prior, { preserveBindingEpisode: false });
396
- if (prior.companyUid === next.companyUid &&
397
- prior.companySlug === next.companySlug &&
398
- prior.contextStatus === next.contextStatus &&
399
- prior.projectId === next.projectId &&
400
- prior.taskId === next.taskId &&
401
- prior.bindingEpisodeId === next.bindingEpisodeId) {
402
- return prior;
403
- }
404
- writeSessionState(next, root);
405
- return next;
401
+ let result = null;
402
+ updateSessionState(binding.sessionId, root, (current) => {
403
+ // Reconcile always writes the local projection before network delivery. Do
404
+ // not manufacture session files when replay sees an orphaned legacy outbox
405
+ // record with no corresponding tracked session.
406
+ if (!current)
407
+ return null;
408
+ // A register response never carries a server episode, bound or not. The
409
+ // server mints episodes only on bind / organize, and drops session-event
410
+ // batches whose episode it does not recognise. Older clients stored the
411
+ // register receipt as the episode, so clear it wherever it is found. This
412
+ // repair is independent of the binding decision below and therefore also
413
+ // applies to untracked and different-company states.
414
+ const prior = stripRegisterReceiptEpisodes(current, [binding.receiptId]);
415
+ const repaired = prior !== current;
416
+ if (prior.contextStatus === "untracked" ||
417
+ (prior.companyUid && prior.companyUid !== binding.companyUid)) {
418
+ result = prior;
419
+ return repaired ? prior : null;
420
+ }
421
+ const acknowledgementStrength = acknowledged === "bound" ? 3 : acknowledged === "needs_task" ? 2 : 1;
422
+ const priorStrength = bindingStrength(prior);
423
+ const preservePriorBinding = Boolean(prior.companyUid === binding.companyUid && priorStrength > acknowledgementStrength);
424
+ const sameBindingAsPrior = prior.companyUid === binding.companyUid &&
425
+ prior.projectId === binding.projectId &&
426
+ prior.taskId === binding.taskId;
427
+ const next = mergeLocalSessionFields({
428
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
429
+ sessionId: binding.sessionId,
430
+ companyUid: binding.companyUid,
431
+ companySlug: binding.companySlug ?? prior.companySlug,
432
+ contextStatus: preservePriorBinding ? prior.contextStatus : acknowledged,
433
+ projectId: preservePriorBinding ? prior.projectId : binding.projectId,
434
+ taskId: preservePriorBinding ? prior.taskId : binding.taskId,
435
+ // Keep a server-issued episode only while the binding it was issued
436
+ // for is unchanged. Never derive one from the receipt.
437
+ bindingEpisodeId: preservePriorBinding || sameBindingAsPrior
438
+ ? prior.bindingEpisodeId
439
+ : undefined,
440
+ updatedAt: now().toISOString(),
441
+ }, prior, { preserveBindingEpisode: false });
442
+ if (prior.companyUid === next.companyUid &&
443
+ prior.companySlug === next.companySlug &&
444
+ prior.contextStatus === next.contextStatus &&
445
+ prior.projectId === next.projectId &&
446
+ prior.taskId === next.taskId &&
447
+ prior.bindingEpisodeId === next.bindingEpisodeId) {
448
+ result = prior;
449
+ return repaired ? prior : null;
450
+ }
451
+ result = next;
452
+ return next;
453
+ });
454
+ return result;
406
455
  }
407
456
  /** True when a durable state file was written by the hook / reconcile path. */
408
457
  export function isHookWrittenSessionState(state) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.250.0",
3
+ "version": "5.252.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {