@indigoai-us/hq-cli 5.126.0 → 5.128.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
  3. package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
  4. package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
  5. package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
  6. package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
  7. package/dist/command-catalog.generated.d.ts +245 -11
  8. package/dist/command-catalog.generated.js +323 -11
  9. package/dist/command-registration-plan.d.ts +1 -0
  10. package/dist/command-registration-plan.js +11 -2
  11. package/dist/commands/agents.d.ts +12 -0
  12. package/dist/commands/agents.js +46 -2
  13. package/dist/commands/lanes.d.ts +55 -16
  14. package/dist/commands/lanes.js +1187 -250
  15. package/dist/commands/mesh.js +254 -19
  16. package/dist/lib/core-utils/sentry-report.d.ts +2 -0
  17. package/dist/lib/core-utils/sentry-report.js +47 -7
  18. package/dist/lib/lanes/accounts.d.ts +67 -7
  19. package/dist/lib/lanes/accounts.js +233 -43
  20. package/dist/lib/lanes/compaction.d.ts +96 -0
  21. package/dist/lib/lanes/compaction.js +197 -0
  22. package/dist/lib/lanes/config.d.ts +15 -0
  23. package/dist/lib/lanes/config.js +65 -8
  24. package/dist/lib/lanes/delivery.d.ts +151 -0
  25. package/dist/lib/lanes/delivery.js +354 -0
  26. package/dist/lib/lanes/dropbox.d.ts +96 -0
  27. package/dist/lib/lanes/dropbox.js +408 -0
  28. package/dist/lib/lanes/effort.d.ts +50 -0
  29. package/dist/lib/lanes/effort.js +109 -0
  30. package/dist/lib/lanes/errors.d.ts +2 -2
  31. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  32. package/dist/lib/lanes/hooks-install.js +326 -0
  33. package/dist/lib/lanes/hq-root.d.ts +19 -0
  34. package/dist/lib/lanes/hq-root.js +28 -0
  35. package/dist/lib/lanes/index.d.ts +35 -5
  36. package/dist/lib/lanes/index.js +19 -4
  37. package/dist/lib/lanes/liveness.d.ts +7 -1
  38. package/dist/lib/lanes/liveness.js +64 -5
  39. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  40. package/dist/lib/lanes/machine-identity.js +92 -0
  41. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  42. package/dist/lib/lanes/mesh-events.js +275 -0
  43. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  44. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  45. package/dist/lib/lanes/questions.d.ts +75 -0
  46. package/dist/lib/lanes/questions.js +243 -0
  47. package/dist/lib/lanes/quota.d.ts +95 -0
  48. package/dist/lib/lanes/quota.js +320 -0
  49. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  50. package/dist/lib/lanes/reconcile-install.js +221 -0
  51. package/dist/lib/lanes/reconcile.d.ts +102 -0
  52. package/dist/lib/lanes/reconcile.js +985 -0
  53. package/dist/lib/lanes/registry.d.ts +18 -5
  54. package/dist/lib/lanes/registry.js +293 -19
  55. package/dist/lib/lanes/spawn.d.ts +274 -0
  56. package/dist/lib/lanes/spawn.js +1363 -0
  57. package/dist/lib/lanes/store.d.ts +7 -3
  58. package/dist/lib/lanes/store.js +150 -32
  59. package/dist/lib/lanes/types.d.ts +346 -4
  60. package/dist/lib/lanes/types.js +138 -5
  61. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  62. package/dist/lib/lanes/watcher-main.js +22 -0
  63. package/dist/lib/lanes/watcher.d.ts +216 -0
  64. package/dist/lib/lanes/watcher.js +859 -0
  65. package/dist/lib/mesh/api.d.ts +71 -7
  66. package/dist/lib/mesh/api.js +167 -28
  67. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  68. package/dist/lib/mesh/live/backfill-held.js +66 -21
  69. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  70. package/dist/lib/mesh/live/daemon/run.js +111 -54
  71. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  72. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  73. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  74. package/dist/lib/scan-packages/index.d.ts +2 -0
  75. package/dist/lib/scan-packages/index.js +1 -1
  76. package/dist/lib/work-context/state.d.ts +12 -0
  77. package/dist/lib/work-context/state.js +131 -3
  78. package/package.json +3 -1
@@ -19,7 +19,7 @@ import { replayOutbox } from "../../../work-context/outbox.js";
19
19
  import { workContextRoot } from "../../../work-context/paths.js";
20
20
  import { ensureDefaultCompany } from "../../../work-context/default-company.js";
21
21
  import { invalidateDefaultCompanyMemberships, readDeviceConfig, } from "../../../work-context/config.js";
22
- import { readSessionState, writeSessionState, } from "../../../work-context/state.js";
22
+ import { readSessionState, mergeLocalSessionFields, updateSessionState, } from "../../../work-context/state.js";
23
23
  import { createSessionEventsPoster, resolveVaultApiBase, } from "../session-events-client.js";
24
24
  import { flushSessionEvents, HELD_RETRY_INTERVAL_MS, } from "../flush.js";
25
25
  import { backfillHeldSessions, } from "../backfill-held.js";
@@ -58,6 +58,30 @@ export function shouldFlushOnSpoolDirEvent(filename) {
58
58
  return true;
59
59
  return String(filename) === "spool.jsonl";
60
60
  }
61
+ function sameCompanyValue(a, b) {
62
+ return Boolean(a && b && a.toLowerCase() === b.toLowerCase());
63
+ }
64
+ function stillMatchesDeniedCompany(current, input, slug) {
65
+ const deniedUid = input.companyUid ?? input.state?.companyUid;
66
+ const deniedSlug = input.companySlug ?? input.state?.companySlug ?? slug;
67
+ if (deniedUid && current.companyUid && deniedUid !== current.companyUid)
68
+ return false;
69
+ if (deniedSlug && current.companySlug && !sameCompanyValue(deniedSlug, current.companySlug))
70
+ return false;
71
+ return Boolean(current.companyUid || current.companySlug);
72
+ }
73
+ function stillMatchesDeniedDeviceDefaultBinding(current, original, input, slug) {
74
+ return stillMatchesDeniedCompany(current, input, slug) &&
75
+ current.companyUid === original.companyUid &&
76
+ current.companySlug === original.companySlug &&
77
+ current.contextStatus === original.contextStatus &&
78
+ current.projectId === original.projectId &&
79
+ current.taskId === original.taskId &&
80
+ current.bindingEpisodeId === original.bindingEpisodeId &&
81
+ current.source === original.source &&
82
+ current.version === original.version &&
83
+ current.updatedAt === original.updatedAt;
84
+ }
61
85
  function alive(pid) {
62
86
  try {
63
87
  process.kill(pid, 0);
@@ -123,33 +147,54 @@ export async function runMeshDaemon(deps = {}) {
123
147
  ? readSessionState(input.sessionId, ctxRoot)
124
148
  : null);
125
149
  const slug = input.companySlug ?? state?.companySlug;
126
- const membership = await (async () => {
150
+ let authorization;
151
+ if (!slug) {
152
+ authorization = { status: "denied" };
153
+ }
154
+ else {
155
+ const controller = new AbortController();
156
+ const timeout = setTimeout(() => controller.abort(), DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS);
127
157
  try {
128
- if (!slug)
129
- return null;
130
- const controller = new AbortController();
131
- const timeout = setTimeout(() => controller.abort(), DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS);
132
- try {
133
- return await Promise.race([
134
- resolveMembership(await getToken(), slug, controller.signal),
135
- new Promise((resolve) => controller.signal.addEventListener("abort", () => resolve(null), { once: true })),
136
- ]);
137
- }
138
- finally {
139
- clearTimeout(timeout);
140
- }
158
+ authorization = await Promise.race([
159
+ (async () => {
160
+ const membership = await resolveMembership(await getToken(), slug, controller.signal);
161
+ return membership
162
+ ? { status: "authorized", uid: membership.companyUid }
163
+ : { status: "denied" };
164
+ })(),
165
+ new Promise((resolve) => {
166
+ controller.signal.addEventListener("abort", () => resolve({
167
+ status: "unavailable",
168
+ error: `membership lookup timed out after ${DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS}ms`,
169
+ }), { once: true });
170
+ }),
171
+ ]);
141
172
  }
142
- catch {
143
- return null;
173
+ catch (err) {
174
+ authorization = {
175
+ status: "unavailable",
176
+ error: err instanceof Error ? err.message : String(err),
177
+ };
144
178
  }
145
- })();
146
- const uid = membership?.companyUid;
179
+ finally {
180
+ clearTimeout(timeout);
181
+ }
182
+ }
183
+ if (authorization.status === "unavailable") {
184
+ const prior = readDaemonState(dir)?.deviceDefaultMembershipLookupFailures ?? 0;
185
+ patchDaemonState(dir, {
186
+ lastErrorCode: "DEVICE_DEFAULT_MEMBERSHIP_LOOKUP_FAILED",
187
+ deviceDefaultMembershipLookupFailures: prior + 1,
188
+ }, now);
189
+ log(dir, `device-default membership lookup unavailable${slug ? ` company=${slug}` : ""}: ${authorization.error.slice(0, 160)}`);
190
+ return authorization;
191
+ }
147
192
  if (!input.deviceDefault)
148
- return uid ? { uid } : false;
149
- if (uid &&
150
- (!input.companyUid || input.companyUid === uid) &&
151
- (!state?.companyUid || state.companyUid === uid)) {
152
- return { uid };
193
+ return authorization;
194
+ if (authorization.status === "authorized" &&
195
+ (!input.companyUid || input.companyUid === authorization.uid) &&
196
+ (!state?.companyUid || state.companyUid === authorization.uid)) {
197
+ return authorization;
153
198
  }
154
199
  try {
155
200
  invalidateDefaultCompanyMemberships({ root: ctxRoot, now });
@@ -158,20 +203,39 @@ export async function runMeshDaemon(deps = {}) {
158
203
  log(dir, `default membership cache invalidation failed: ${String(err).slice(0, 160)}`);
159
204
  }
160
205
  if (state) {
161
- writeSessionState({
162
- ...state,
163
- companyUid: undefined,
164
- companySlug: undefined,
165
- contextStatus: "needs_company",
166
- updatedAt: now().toISOString(),
167
- }, ctxRoot);
206
+ // Commit only if the device-default binding that started this lookup is
207
+ // still current. The shared session-state lock also makes a hook writer
208
+ // that arrives at this boundary commit after us rather than get lost.
209
+ deps.beforeDeviceDefaultRevocationCommit?.();
210
+ const revoked = updateSessionState(state.sessionId, ctxRoot, (current) => {
211
+ if (!current || !stillMatchesDeniedDeviceDefaultBinding(current, state, input, slug)) {
212
+ return null;
213
+ }
214
+ return mergeLocalSessionFields({
215
+ ...current,
216
+ companyUid: undefined,
217
+ companySlug: undefined,
218
+ contextStatus: "needs_company",
219
+ updatedAt: now().toISOString(),
220
+ }, current, { preserveCompanyBinding: false });
221
+ });
222
+ if (!revoked) {
223
+ log(dir, `device-default membership denial left newer session binding intact session=${state.sessionId}`);
224
+ }
168
225
  }
169
226
  const prior = readDaemonState(dir)?.deviceDefaultAuthorizationDenials ?? 0;
170
227
  patchDaemonState(dir, { deviceDefaultAuthorizationDenials: prior + 1 }, now);
171
228
  log(dir, `device-default membership denied${slug ? ` company=${slug}` : ""}; work held`);
172
- return false;
229
+ return { status: "denied" };
173
230
  },
174
231
  };
232
+ const validateDaemonMembership = async (slug, deviceDefault) => {
233
+ const authorization = await tenantGate.authorize({ companySlug: slug, deviceDefault });
234
+ if (authorization.status === "unavailable") {
235
+ throw new Error(`device-default membership lookup unavailable: ${authorization.error}`);
236
+ }
237
+ return authorization.status === "authorized" ? { uid: authorization.uid } : false;
238
+ };
175
239
  /** In-memory held re-attempt throttle (survives watch-triggered flushes). */
176
240
  let lastHeldRetryAtMs = 0;
177
241
  const flushFn = deps.flush ??
@@ -191,12 +255,12 @@ export async function runMeshDaemon(deps = {}) {
191
255
  lastHeldRetryAtMs = atMs;
192
256
  },
193
257
  log: (message) => log(dir, message),
194
- authorizeDeviceDefaultTenant: async (state) => Boolean(await tenantGate.authorize({
258
+ authorizeDeviceDefaultTenant: async (state) => (await tenantGate.authorize({
195
259
  state,
196
260
  companySlug: state.companySlug,
197
261
  companyUid: state.companyUid,
198
262
  deviceDefault: true,
199
- })),
263
+ })).status === "authorized",
200
264
  });
201
265
  });
202
266
  const replayFn = deps.replayOutbox ??
@@ -215,13 +279,16 @@ export async function runMeshDaemon(deps = {}) {
215
279
  // only authority that may release them.
216
280
  !op.companyConfidence;
217
281
  if (requiresMembershipCheck) {
218
- const allowed = await tenantGate.authorize({
282
+ const authorization = await tenantGate.authorize({
219
283
  state: state ?? undefined,
220
284
  companySlug: op.companySlug ?? state?.companySlug,
221
285
  companyUid: op.companyUid ?? state?.companyUid,
222
286
  deviceDefault: true,
223
287
  });
224
- if (!allowed) {
288
+ if (authorization.status === "unavailable") {
289
+ return { ok: false, retryable: true, code: "DEVICE_DEFAULT_MEMBERSHIP_LOOKUP_FAILED" };
290
+ }
291
+ if (authorization.status !== "authorized") {
225
292
  return { ok: false, retryable: false, code: "DEVICE_DEFAULT_MEMBERSHIP_DENIED" };
226
293
  }
227
294
  }
@@ -260,13 +327,11 @@ export async function runMeshDaemon(deps = {}) {
260
327
  root: ctxRoot,
261
328
  env,
262
329
  deliver,
263
- validateMembership: async (slug) => tenantGate.authorize({
264
- companySlug: slug,
265
- deviceDefault: useDeviceDefault,
266
- }),
330
+ validateMembership: (slug) => validateDaemonMembership(slug, useDeviceDefault),
267
331
  allowDeviceDefault: useDeviceDefault,
268
332
  deviceDefaultConfidence: useDeviceDefault,
269
333
  }),
334
+ validateMembership: (slug) => validateDaemonMembership(slug, useDeviceDefault),
270
335
  });
271
336
  });
272
337
  const boardLast = new Map();
@@ -282,12 +347,12 @@ export async function runMeshDaemon(deps = {}) {
282
347
  lastRefresh: boardLast,
283
348
  intervalMs: BOARD_REFRESH_INTERVAL_MS,
284
349
  now: () => timers.now(),
285
- authorizeDeviceDefaultTenant: async (_sessionId, state) => Boolean(await tenantGate.authorize({
350
+ authorizeDeviceDefaultTenant: async (_sessionId, state) => (await tenantGate.authorize({
286
351
  state,
287
352
  companySlug: state.companySlug,
288
353
  companyUid: state.companyUid,
289
354
  deviceDefault: true,
290
- })),
355
+ })).status === "authorized",
291
356
  });
292
357
  });
293
358
  let presence = null;
@@ -387,18 +452,10 @@ export async function runMeshDaemon(deps = {}) {
387
452
  fs: deps.transcriptFs,
388
453
  now: () => now().getTime(),
389
454
  lastHookEventAt,
390
- validateMembership: async (slug) => {
391
- try {
392
- const token = await getToken();
393
- const membership = await resolveActiveMembershipCompany(token, slug);
394
- if (!membership)
395
- return false;
396
- return { uid: membership.companyUid };
397
- }
398
- catch {
399
- return false;
400
- }
401
- },
455
+ // Transcript watch has no direct state writer: reconcile owns its write
456
+ // and merges local fields. Surface transient lookup failures as throws so
457
+ // reconcile defers without turning them into an authoritative denial.
458
+ validateMembership: (slug) => validateDaemonMembership(slug, true),
402
459
  deliver: async (op) => {
403
460
  try {
404
461
  const token = await getToken();
@@ -29,6 +29,8 @@ export interface DaemonStateFile {
29
29
  emitMode?: "legacy" | "direct";
30
30
  /** Authoritative membership denials for device-default-derived tenant I/O. */
31
31
  deviceDefaultAuthorizationDenials?: number;
32
+ /** Transient device-default membership refresh failures; the next cycle retries. */
33
+ deviceDefaultMembershipLookupFailures?: number;
32
34
  updatedAt: string;
33
35
  }
34
36
  export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
@@ -49,6 +49,9 @@ export declare const TRANSCRIPT_WATCH_REVALIDATE_MS: number;
49
49
  export declare const TRANSCRIPT_TURN_QUIET_MS = 5000;
50
50
  export declare const TRANSCRIPT_SESSION_END_MS: number;
51
51
  export declare const TRANSCRIPT_HOOK_COVER_MS = 60000;
52
+ /** Retry unavailable transcript-start reconciliation without hot-looping. */
53
+ export declare const TRANSCRIPT_START_RETRY_BASE_MS = 30000;
54
+ export declare const TRANSCRIPT_START_RETRY_MAX_MS = 120000;
52
55
  /** Ignore cold transcripts whose mtime is older than this on first sight. */
53
56
  export declare const TRANSCRIPT_FRESH_MS: number;
54
57
  export declare const TRANSCRIPT_MARKER: "from transcript";
@@ -54,6 +54,9 @@ export const TRANSCRIPT_WATCH_REVALIDATE_MS = 5 * 60_000;
54
54
  export const TRANSCRIPT_TURN_QUIET_MS = 5_000;
55
55
  export const TRANSCRIPT_SESSION_END_MS = 10 * 60_000;
56
56
  export const TRANSCRIPT_HOOK_COVER_MS = 60_000;
57
+ /** Retry unavailable transcript-start reconciliation without hot-looping. */
58
+ export const TRANSCRIPT_START_RETRY_BASE_MS = 30_000;
59
+ export const TRANSCRIPT_START_RETRY_MAX_MS = 120_000;
57
60
  /** Ignore cold transcripts whose mtime is older than this on first sight. */
58
61
  export const TRANSCRIPT_FRESH_MS = TRANSCRIPT_SESSION_END_MS;
59
62
  export const TRANSCRIPT_MARKER = "from transcript";
@@ -652,6 +655,8 @@ export class TranscriptWatcher {
652
655
  ended: false,
653
656
  pendingTurn: false,
654
657
  seq: 0,
658
+ startRetryAttempts: 0,
659
+ nextStartRetryAtMs: 0,
655
660
  };
656
661
  this.tracked.set(d.path, track);
657
662
  const started = await this.emitStart(track, nowMs);
@@ -661,6 +666,14 @@ export class TranscriptWatcher {
661
666
  }
662
667
  if (track.ended)
663
668
  continue;
669
+ if (!track.started) {
670
+ if (nowMs < track.nextStartRetryAtMs)
671
+ continue;
672
+ const started = await this.emitStart(track, nowMs);
673
+ if (started)
674
+ result.started += 1;
675
+ continue;
676
+ }
664
677
  if (d.mtimeMs > track.lastMtimeMs) {
665
678
  track.lastMtimeMs = d.mtimeMs;
666
679
  track.lastChangeAtMs = nowMs;
@@ -737,14 +750,22 @@ export class TranscriptWatcher {
737
750
  hqRoot: this.deps.hqRoot,
738
751
  source: "transcript",
739
752
  };
740
- await this.reconcileFn(observation, {
741
- root: this.deps.workContextRoot,
742
- env: this.deps.env,
743
- now: () => new Date(nowMs),
744
- validateMembership: this.deps.validateMembership,
745
- deliver: this.deps.deliver,
746
- offline: !this.deps.deliver,
747
- });
753
+ try {
754
+ await this.reconcileFn(observation, {
755
+ root: this.deps.workContextRoot,
756
+ env: this.deps.env,
757
+ now: () => new Date(nowMs),
758
+ validateMembership: this.deps.validateMembership,
759
+ deliver: this.deps.deliver,
760
+ offline: !this.deps.deliver,
761
+ });
762
+ }
763
+ catch {
764
+ track.startRetryAttempts += 1;
765
+ const delayMs = Math.min(TRANSCRIPT_START_RETRY_BASE_MS * 2 ** (track.startRetryAttempts - 1), TRANSCRIPT_START_RETRY_MAX_MS);
766
+ track.nextStartRetryAtMs = nowMs + delayMs;
767
+ return false;
768
+ }
748
769
  // Re-check: a hook may have raced us during reconcile.
749
770
  const after = this.readState(track.sessionId, this.deps.workContextRoot);
750
771
  if (isHookWrittenSessionState(after)) {
@@ -753,6 +774,8 @@ export class TranscriptWatcher {
753
774
  }
754
775
  this.emitKind(track, "session_start", nowMs);
755
776
  track.started = true;
777
+ track.startRetryAttempts = 0;
778
+ track.nextStartRetryAtMs = 0;
756
779
  return true;
757
780
  }
758
781
  emitKind(track, kind, nowMs) {
@@ -1,3 +1,4 @@
1
+ import type { PackContributeKey } from '../../types.js';
1
2
  type Logger = (message: string) => void;
2
3
  export interface ScanPackagesOptions {
3
4
  quiet?: boolean;
@@ -7,6 +8,7 @@ export interface ScanPackagesOptions {
7
8
  export interface ScanPackagesResult {
8
9
  status: 0 | 1;
9
10
  }
11
+ export declare function readContributes(manifest: string): Partial<Record<PackContributeKey, string[]>>;
10
12
  /** Wire installed pack contributions into their table-declared host locations. */
11
13
  export declare function scanPackages(hqRoot: string, options?: ScanPackagesOptions): ScanPackagesResult;
12
14
  export {};
@@ -18,7 +18,7 @@ catch {
18
18
  return false;
19
19
  } }
20
20
  function normalized(relative) { return relative.replaceAll(path.sep, '/'); }
21
- function readContributes(manifest) {
21
+ export function readContributes(manifest) {
22
22
  // This deliberately mirrors the staging script's shallow awk grammar rather
23
23
  // than accepting YAML forms the legacy ABI never recognized (notably a
24
24
  // non-empty inline list). Mapping remains single-sourced in
@@ -109,8 +109,19 @@ export interface AcknowledgedRegisterBinding {
109
109
  }
110
110
  /** Allowlisted keys that may appear in the durable state projection. */
111
111
  export declare const SESSION_STATE_ALLOWLIST: Set<string>;
112
+ /**
113
+ * Serialize a session-state read/modify/write with every state writer. Atomic
114
+ * rename protects readers from torn files, but only this shared lock protects
115
+ * a writer that has to verify the current binding before changing it.
116
+ */
117
+ export declare function withSessionStateLock<T>(sessionId: string, root: string, fn: () => T): T;
112
118
  export declare function readSessionState(sessionId: string, root: string): SessionStateFile | null;
113
119
  export declare function writeSessionState(state: SessionStateFile, root: string): string;
120
+ /**
121
+ * Commit a state change only after inspecting the latest value under the same
122
+ * lock used by ordinary session writers. Returning null declines the write.
123
+ */
124
+ export declare function updateSessionState(sessionId: string, root: string, update: (current: SessionStateFile | null) => SessionStateFile | null): SessionStateFile | null;
114
125
  /** Strip any non-allowlisted fields before durable write. */
115
126
  export declare function projectSessionState(state: SessionStateFile): SessionStateFile;
116
127
  /**
@@ -119,6 +130,7 @@ export declare function projectSessionState(state: SessionStateFile): SessionSta
119
130
  */
120
131
  export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null, options?: {
121
132
  preserveBindingEpisode?: boolean;
133
+ preserveCompanyBinding?: boolean;
122
134
  }): SessionStateFile;
123
135
  /**
124
136
  * Upgrade the local projection after the server acknowledges a register.
@@ -3,9 +3,10 @@
3
3
  * Written only by hq mesh context. Owner 0600. Metadata-only — never prompts/tokens.
4
4
  */
5
5
  import * as fs from "node:fs";
6
+ import * as path from "node:path";
6
7
  import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
7
- import { atomicWriteJson, ensureOwnerDir } from "./atomic.js";
8
- import { NotTrackingError } from "./errors.js";
8
+ import { atomicWriteJson, ensureOwnerDir, resolveRealTarget } from "./atomic.js";
9
+ import { NotTrackingError, UnsafeConfigPathError } from "./errors.js";
9
10
  import { isSafeWorkContextSegment, workContextSessionPath, workContextSessionsDir, } from "./paths.js";
10
11
  /** Allowlisted keys that may appear in the durable state projection. */
11
12
  export const SESSION_STATE_ALLOWLIST = new Set([
@@ -30,6 +31,113 @@ export const SESSION_STATE_ALLOWLIST = new Set([
30
31
  "companyConfidence",
31
32
  "updatedAt",
32
33
  ]);
34
+ const SESSION_STATE_LOCK_TIMEOUT_MS = 5_000;
35
+ const SESSION_STATE_LOCK_RETRY_MS = 10;
36
+ function waitForSessionStateLock(ms) {
37
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
38
+ }
39
+ function asPersistenceFailure(err) {
40
+ if (err instanceof NotTrackingError || err instanceof UnsafeConfigPathError)
41
+ throw err;
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ throw new NotTrackingError(`Failed to persist session state: ${message}`, "NOT_TRACKING");
44
+ }
45
+ function reclaimStaleSessionStateLock(lockPath) {
46
+ const claim = `${lockPath}.reclaim.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
47
+ try {
48
+ fs.renameSync(lockPath, claim);
49
+ }
50
+ catch (err) {
51
+ if (err.code === "ENOENT")
52
+ return false;
53
+ throw err;
54
+ }
55
+ try {
56
+ return true;
57
+ }
58
+ finally {
59
+ try {
60
+ fs.unlinkSync(claim);
61
+ }
62
+ catch { /* private claim cannot block writers */ }
63
+ }
64
+ }
65
+ /**
66
+ * Serialize a session-state read/modify/write with every state writer. Atomic
67
+ * rename protects readers from torn files, but only this shared lock protects
68
+ * a writer that has to verify the current binding before changing it.
69
+ */
70
+ export function withSessionStateLock(sessionId, root, fn) {
71
+ if (!isSafeWorkContextSegment(sessionId)) {
72
+ throw new NotTrackingError(`Unsafe session id for state file: ${sessionId}`, "NOT_TRACKING");
73
+ }
74
+ try {
75
+ ensureOwnerDir(workContextSessionsDir(root));
76
+ }
77
+ catch (err) {
78
+ asPersistenceFailure(err);
79
+ }
80
+ const statePath = resolveRealTarget(workContextSessionPath(sessionId, root));
81
+ const lockPath = `${statePath}.lock`;
82
+ const token = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
83
+ const deadline = Date.now() + SESSION_STATE_LOCK_TIMEOUT_MS;
84
+ let fd;
85
+ while (fd === undefined) {
86
+ try {
87
+ fd = fs.openSync(lockPath, "wx", 0o600);
88
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, token }));
89
+ }
90
+ catch (err) {
91
+ if (err.code !== "EEXIST")
92
+ asPersistenceFailure(err);
93
+ let ownerPid = 0;
94
+ try {
95
+ const raw = JSON.parse(fs.readFileSync(lockPath, "utf8"));
96
+ ownerPid = typeof raw.pid === "number" && Number.isInteger(raw.pid) ? raw.pid : 0;
97
+ }
98
+ catch {
99
+ // A malformed lock cannot safely protect the state.
100
+ }
101
+ let ownerAlive = false;
102
+ if (ownerPid > 0) {
103
+ try {
104
+ process.kill(ownerPid, 0);
105
+ ownerAlive = true;
106
+ }
107
+ catch {
108
+ // Dead owners do not retain the lock.
109
+ }
110
+ }
111
+ if (!ownerAlive) {
112
+ try {
113
+ reclaimStaleSessionStateLock(lockPath);
114
+ }
115
+ catch (err) {
116
+ asPersistenceFailure(err);
117
+ }
118
+ continue;
119
+ }
120
+ if (Date.now() >= deadline) {
121
+ throw new NotTrackingError(`Timed out waiting for session state lock: ${path.basename(statePath)}`, "NOT_TRACKING");
122
+ }
123
+ waitForSessionStateLock(SESSION_STATE_LOCK_RETRY_MS);
124
+ }
125
+ }
126
+ try {
127
+ return fn();
128
+ }
129
+ finally {
130
+ fs.closeSync(fd);
131
+ try {
132
+ const current = JSON.parse(fs.readFileSync(lockPath, "utf8"));
133
+ if (current.token === token)
134
+ fs.unlinkSync(lockPath);
135
+ }
136
+ catch {
137
+ // A stale-lock reclaimer or replacement owner has already retired it.
138
+ }
139
+ }
140
+ }
33
141
  export function readSessionState(sessionId, root) {
34
142
  if (!isSafeWorkContextSegment(sessionId))
35
143
  return null;
@@ -50,6 +158,9 @@ export function writeSessionState(state, root) {
50
158
  if (!isSafeWorkContextSegment(state.sessionId)) {
51
159
  throw new NotTrackingError(`Unsafe session id for state file: ${state.sessionId}`, "NOT_TRACKING");
52
160
  }
161
+ return withSessionStateLock(state.sessionId, root, () => writeSessionStateUnlocked(state, root));
162
+ }
163
+ function writeSessionStateUnlocked(state, root) {
53
164
  const projected = projectSessionState(state);
54
165
  try {
55
166
  ensureOwnerDir(workContextSessionsDir(root));
@@ -61,6 +172,20 @@ export function writeSessionState(state, root) {
61
172
  throw new NotTrackingError(`Failed to persist session state: ${err instanceof Error ? err.message : String(err)}`, "NOT_TRACKING");
62
173
  }
63
174
  }
175
+ /**
176
+ * Commit a state change only after inspecting the latest value under the same
177
+ * lock used by ordinary session writers. Returning null declines the write.
178
+ */
179
+ export function updateSessionState(sessionId, root, update) {
180
+ return withSessionStateLock(sessionId, root, () => {
181
+ const current = readSessionState(sessionId, root);
182
+ const next = update(current);
183
+ if (!next)
184
+ return null;
185
+ writeSessionStateUnlocked(next, root);
186
+ return next;
187
+ });
188
+ }
64
189
  /** Strip any non-allowlisted fields before durable write. */
65
190
  export function projectSessionState(state) {
66
191
  const out = {
@@ -118,7 +243,10 @@ export function mergeLocalSessionFields(next, prior, options = {}) {
118
243
  // projected locally; losing it causes held events and repeat registrations.
119
244
  // A caller that carries either company field is intentionally changing the
120
245
  // binding and remains authoritative.
121
- if (!out.companyUid && !out.companySlug && (prior.companyUid || prior.companySlug)) {
246
+ if (options.preserveCompanyBinding !== false &&
247
+ !out.companyUid &&
248
+ !out.companySlug &&
249
+ (prior.companyUid || prior.companySlug)) {
122
250
  out.companyUid = prior.companyUid;
123
251
  out.companySlug = prior.companySlug;
124
252
  out.contextStatus = prior.contextStatus;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.126.0",
3
+ "version": "5.128.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -13,6 +13,8 @@
13
13
  "assets/scaffold",
14
14
  "assets/bot-workers",
15
15
  "assets/mesh-daemon",
16
+ "assets/lanes-reconcile",
17
+ "assets/lanes-hooks",
16
18
  "CHANGELOG.md",
17
19
  "README.md"
18
20
  ],