@indigoai-us/hq-cloud 6.11.14 → 6.11.16

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 (64) hide show
  1. package/.github/workflows/ci.yml +1 -0
  2. package/.github/workflows/publish.yml +20 -64
  3. package/dist/agent-codex-instructions.d.ts +84 -0
  4. package/dist/agent-codex-instructions.d.ts.map +1 -0
  5. package/dist/agent-codex-instructions.js +275 -0
  6. package/dist/agent-codex-instructions.js.map +1 -0
  7. package/dist/agent-codex-instructions.test.d.ts +2 -0
  8. package/dist/agent-codex-instructions.test.d.ts.map +1 -0
  9. package/dist/agent-codex-instructions.test.js +271 -0
  10. package/dist/agent-codex-instructions.test.js.map +1 -0
  11. package/dist/bin/sync-runner-planning.d.ts +11 -0
  12. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  13. package/dist/bin/sync-runner-planning.js +19 -2
  14. package/dist/bin/sync-runner-planning.js.map +1 -1
  15. package/dist/bin/sync-runner-telemetry.d.ts +1 -0
  16. package/dist/bin/sync-runner-telemetry.d.ts.map +1 -1
  17. package/dist/bin/sync-runner-telemetry.js +8 -1
  18. package/dist/bin/sync-runner-telemetry.js.map +1 -1
  19. package/dist/bin/sync-runner.d.ts +20 -1
  20. package/dist/bin/sync-runner.d.ts.map +1 -1
  21. package/dist/bin/sync-runner.js +154 -9
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +328 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/share.js +13 -9
  26. package/dist/cli/share.js.map +1 -1
  27. package/dist/cli/sync.d.ts.map +1 -1
  28. package/dist/cli/sync.js +24 -12
  29. package/dist/cli/sync.js.map +1 -1
  30. package/dist/cli/sync.test.js +16 -0
  31. package/dist/cli/sync.test.js.map +1 -1
  32. package/dist/context.d.ts +8 -2
  33. package/dist/context.d.ts.map +1 -1
  34. package/dist/context.js +16 -6
  35. package/dist/context.js.map +1 -1
  36. package/dist/qmd-reindex.d.ts +7 -0
  37. package/dist/qmd-reindex.d.ts.map +1 -1
  38. package/dist/qmd-reindex.js +12 -1
  39. package/dist/qmd-reindex.js.map +1 -1
  40. package/dist/skill-telemetry.d.ts +15 -0
  41. package/dist/skill-telemetry.d.ts.map +1 -1
  42. package/dist/skill-telemetry.js +34 -3
  43. package/dist/skill-telemetry.js.map +1 -1
  44. package/dist/skill-telemetry.test.js +75 -1
  45. package/dist/skill-telemetry.test.js.map +1 -1
  46. package/dist/vault-client.d.ts +16 -0
  47. package/dist/vault-client.d.ts.map +1 -1
  48. package/dist/vault-client.js +21 -0
  49. package/dist/vault-client.js.map +1 -1
  50. package/package.json +3 -1
  51. package/src/agent-codex-instructions.test.ts +332 -0
  52. package/src/agent-codex-instructions.ts +309 -0
  53. package/src/bin/sync-runner-planning.ts +34 -2
  54. package/src/bin/sync-runner-telemetry.ts +8 -1
  55. package/src/bin/sync-runner.test.ts +413 -0
  56. package/src/bin/sync-runner.ts +184 -7
  57. package/src/cli/share.ts +15 -9
  58. package/src/cli/sync.test.ts +17 -0
  59. package/src/cli/sync.ts +33 -12
  60. package/src/context.ts +17 -6
  61. package/src/qmd-reindex.ts +18 -1
  62. package/src/skill-telemetry.test.ts +94 -0
  63. package/src/skill-telemetry.ts +51 -3
  64. package/src/vault-client.ts +24 -0
@@ -106,7 +106,9 @@ import { collectAndSendTelemetry } from "../telemetry.js";
106
106
  import { collectAndSendSkillTelemetry } from "../skill-telemetry.js";
107
107
  import { reindexAfterSync } from "../qmd-reindex.js";
108
108
  import { pruneConflictIndex } from "../lib/conflict-index.js";
109
+ import { materializeCodexAgents } from "../agent-codex-instructions.js";
109
110
  import { getOrCreateMachineId } from "../lib/machine-id.js";
111
+ import { describeError } from "../lib/describe-error.js";
110
112
  import type { Clock, TreeChangeBatch } from "../watcher.js";
111
113
  import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
112
114
  import {
@@ -404,6 +406,53 @@ export interface VaultClientSurface {
404
406
  listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
405
407
  }
406
408
 
409
+ interface RunnerDiagnostic {
410
+ component: string;
411
+ event: string;
412
+ message: string;
413
+ err?: unknown;
414
+ context?: Record<string, unknown>;
415
+ }
416
+
417
+ type RunnerDiagnosticReporter = (diagnostic: RunnerDiagnostic) => void;
418
+
419
+ function errorDetails(err: unknown): Record<string, unknown> {
420
+ const details: Record<string, unknown> = { message: describeError(err) };
421
+ if (err instanceof Error) {
422
+ if (err.name) details.name = err.name;
423
+ const code = (err as NodeJS.ErrnoException).code;
424
+ if (code) details.code = code;
425
+ }
426
+ return details;
427
+ }
428
+
429
+ function createRunnerDiagnosticReporter(
430
+ stderr: { write: (chunk: string) => boolean | void },
431
+ ): RunnerDiagnosticReporter {
432
+ return (diagnostic) => {
433
+ try {
434
+ const payload: Record<string, unknown> = {
435
+ type: "error",
436
+ diagnostic: true,
437
+ component: diagnostic.component,
438
+ event: diagnostic.event,
439
+ path: `(${diagnostic.component})`,
440
+ message:
441
+ diagnostic.err === undefined
442
+ ? diagnostic.message
443
+ : `${diagnostic.message}: ${describeError(diagnostic.err)}`,
444
+ ...(diagnostic.context ?? {}),
445
+ };
446
+ if (diagnostic.err !== undefined) {
447
+ payload.err = errorDetails(diagnostic.err);
448
+ }
449
+ stderr.write(`${JSON.stringify(payload)}\n`);
450
+ } catch {
451
+ /* diagnostics must never affect runner control flow */
452
+ }
453
+ };
454
+ }
455
+
407
456
  // `resolvePullScope`, `readPinnedPrefixes`, and the `PullScope` type now live
408
457
  // in `../sync/pull-scope.ts` so the menubar runner and `hq sync pull|now`
409
458
  // (hq-cli) share ONE scope resolver — the drift between them was the root
@@ -459,6 +508,17 @@ interface IdTokenClaims {
459
508
  name?: string;
460
509
  given_name?: string;
461
510
  family_name?: string;
511
+ /**
512
+ * Entity-bound machine-identity claims. A headless agent box authenticates
513
+ * with machine creds (username `machine-agt_*`); its idToken carries the
514
+ * agent's own entity binding here. The vault authorizer reads these to
515
+ * resolve the caller AS the agent. The runner reads them to drive
516
+ * agent-aware `--personal` target resolution: when `custom:entityType ===
517
+ * "agent"`, the personal slot resolves to the agent's OWN entity
518
+ * (`custom:entityUid`, `agt_*`) instead of the person-only canonical pick.
519
+ */
520
+ "custom:entityType"?: string;
521
+ "custom:entityUid"?: string;
462
522
  }
463
523
 
464
524
  export interface RunnerDeps {
@@ -769,6 +829,7 @@ export async function defaultCollectTelemetry(
769
829
  client: VaultClientSurface,
770
830
  clientIsStub: boolean,
771
831
  hqRoot: string,
832
+ reportDiagnostic: RunnerDiagnosticReporter = () => undefined,
772
833
  ): Promise<void> {
773
834
  if (clientIsStub) return;
774
835
 
@@ -797,7 +858,14 @@ export async function defaultCollectTelemetry(
797
858
  try {
798
859
  const persons = await client.entity.listByType("person");
799
860
  if (pickCanonicalPersonEntity(persons) === null) return;
800
- } catch {
861
+ } catch (err) {
862
+ reportDiagnostic({
863
+ component: "telemetry",
864
+ event: "runner.telemetry.person_probe_failed",
865
+ message: "telemetry person probe failed; continuing with telemetry",
866
+ err,
867
+ context: { hqRoot },
868
+ });
801
869
  // Probe failed — preserve prior behavior and let telemetry run.
802
870
  }
803
871
 
@@ -820,7 +888,14 @@ export async function defaultCollectTelemetry(
820
888
  try {
821
889
  machineId = getOrCreateMachineId(hqRoot);
822
890
  installerVersion = process.env.HQ_INSTALLER_VERSION ?? "hq-cloud";
823
- } catch {
891
+ } catch (err) {
892
+ reportDiagnostic({
893
+ component: "telemetry",
894
+ event: "runner.telemetry.identity_failed",
895
+ message: "telemetry identity resolution failed; skipping telemetry",
896
+ err,
897
+ context: { hqRoot },
898
+ });
824
899
  return;
825
900
  }
826
901
 
@@ -836,8 +911,22 @@ export async function defaultCollectTelemetry(
836
911
  // manifest.yaml and stamp companyUid (US-002). Unmatched cwds stay
837
912
  // unattributed (field omitted).
838
913
  hqRoot,
914
+ log: (message) =>
915
+ reportDiagnostic({
916
+ component: "telemetry",
917
+ event: "runner.telemetry.usage_nonfatal",
918
+ message,
919
+ context: { hqRoot },
920
+ }),
921
+ });
922
+ } catch (err) {
923
+ reportDiagnostic({
924
+ component: "telemetry",
925
+ event: "runner.telemetry.usage_failed",
926
+ message: "usage telemetry collector threw",
927
+ err,
928
+ context: { hqRoot },
839
929
  });
840
- } catch {
841
930
  // Fire-and-forget; nothing escapes the boundary.
842
931
  }
843
932
 
@@ -851,8 +940,22 @@ export async function defaultCollectTelemetry(
851
940
  // Scope skill capture to the HQ project — only invocations whose cwd is
852
941
  // the HQ root are emitted; skill usage in unrelated repos is excluded.
853
942
  hqRoot,
943
+ log: (message) =>
944
+ reportDiagnostic({
945
+ component: "telemetry",
946
+ event: "runner.telemetry.skill_nonfatal",
947
+ message,
948
+ context: { hqRoot },
949
+ }),
950
+ });
951
+ } catch (err) {
952
+ reportDiagnostic({
953
+ component: "telemetry",
954
+ event: "runner.telemetry.skill_failed",
955
+ message: "skill telemetry collector threw",
956
+ err,
957
+ context: { hqRoot },
854
958
  });
855
- } catch {
856
959
  // Fire-and-forget; nothing escapes the boundary.
857
960
  }
858
961
  }
@@ -867,6 +970,7 @@ export async function runRunner(
867
970
  ): Promise<number> {
868
971
  const stdout = deps.stdout ?? process.stdout;
869
972
  const stderr = deps.stderr ?? process.stderr;
973
+ const reportDiagnostic = createRunnerDiagnosticReporter(stderr);
870
974
 
871
975
  // ---- emit ---------------------------------------------------------------
872
976
  // Error-class events go to stderr; everything else to stdout.
@@ -1022,6 +1126,7 @@ export async function runRunner(
1022
1126
  personal: parsed.personal,
1023
1127
  skipPersonal: parsed.skipPersonal,
1024
1128
  client,
1129
+ claims,
1025
1130
  resolveSkipPersonal,
1026
1131
  });
1027
1132
  if (targetPlan.status === "setup-needed") {
@@ -1068,7 +1173,16 @@ export async function runRunner(
1068
1173
  client,
1069
1174
  deps.createVaultClient !== undefined,
1070
1175
  parsed.hqRoot,
1176
+ reportDiagnostic,
1071
1177
  ),
1178
+ onError: (err) =>
1179
+ reportDiagnostic({
1180
+ component: "telemetry",
1181
+ event: "runner.telemetry.collector_failed",
1182
+ message: "telemetry collector failed",
1183
+ err,
1184
+ context: { hqRoot: parsed.hqRoot },
1185
+ }),
1072
1186
  });
1073
1187
 
1074
1188
  emit({
@@ -1095,8 +1209,24 @@ export async function runRunner(
1095
1209
  process.env.HQ_QMD_REINDEX_ON_SYNC !== "0"
1096
1210
  ) {
1097
1211
  try {
1098
- reindexAfterSync(parsed.hqRoot);
1099
- } catch {
1212
+ reindexAfterSync(parsed.hqRoot, {
1213
+ log: ({ event, message, err, context }) =>
1214
+ reportDiagnostic({
1215
+ component: "qmd-reindex",
1216
+ event,
1217
+ message,
1218
+ err,
1219
+ context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
1220
+ }),
1221
+ });
1222
+ } catch (err) {
1223
+ reportDiagnostic({
1224
+ component: "qmd-reindex",
1225
+ event: "runner.qmd_reindex.failed",
1226
+ message: "post-sync qmd reindex threw",
1227
+ err,
1228
+ context: { hqRoot: parsed.hqRoot },
1229
+ });
1100
1230
  // Defensive: reindexAfterSync already swallows internally.
1101
1231
  }
1102
1232
  }
@@ -1113,11 +1243,58 @@ export async function runRunner(
1113
1243
  if (process.env.HQ_CONFLICT_PRUNE_ON_SYNC !== "0") {
1114
1244
  try {
1115
1245
  pruneConflictIndex(parsed.hqRoot);
1116
- } catch {
1246
+ } catch (err) {
1247
+ reportDiagnostic({
1248
+ component: "conflict-index",
1249
+ event: "runner.conflict_index.prune_failed",
1250
+ message: "post-sync conflict index prune failed",
1251
+ err,
1252
+ context: { hqRoot: parsed.hqRoot },
1253
+ });
1117
1254
  // Defensive: a prune failure must never affect the sync outcome.
1118
1255
  }
1119
1256
  }
1120
1257
 
1258
+ // Post-sync agent codex-instructions materialization — best-effort tail step
1259
+ // (runs AFTER `all-complete`, never affects the exit code). For an AGENT box
1260
+ // ONLY (`custom:entityType === "agent"`), project the synced personal/ agent
1261
+ // overlay (agents-profile.md + agent-capabilities/*.md) into the codex GLOBAL
1262
+ // instruction file ~/.codex/AGENTS.md, which `codex exec` natively merges on
1263
+ // every run. This is the codex mirror of Claude's ~/.claude/CLAUDE.md:
1264
+ // DURABLE (outside the hq-root, so `hq rescue` never touches it) and
1265
+ // fleet-SCALABLE (every box runs @latest each cycle). A human running
1266
+ // `hq sync` (person identity) never triggers it. Opt-out: set
1267
+ // HQ_AGENT_CODEX_MATERIALIZE=0. The write is idempotent and never destructive
1268
+ // (skips when the overlay is empty), and never throws (mirrors the qmd /
1269
+ // conflict-prune tail steps above).
1270
+ if (
1271
+ claims?.["custom:entityType"] === "agent" &&
1272
+ process.env.HQ_AGENT_CODEX_MATERIALIZE !== "0"
1273
+ ) {
1274
+ try {
1275
+ materializeCodexAgents({
1276
+ hqRoot: parsed.hqRoot,
1277
+ log: ({ event, message, err, context }) =>
1278
+ reportDiagnostic({
1279
+ component: "agent-codex-instructions",
1280
+ event,
1281
+ message,
1282
+ err,
1283
+ context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
1284
+ }),
1285
+ });
1286
+ } catch (err) {
1287
+ reportDiagnostic({
1288
+ component: "agent-codex-instructions",
1289
+ event: "runner.agent_codex_instructions.failed",
1290
+ message: "post-sync agent codex-instructions materialization threw",
1291
+ err,
1292
+ context: { hqRoot: parsed.hqRoot },
1293
+ });
1294
+ // Defensive: materializeCodexAgents already swallows internally.
1295
+ }
1296
+ }
1297
+
1121
1298
  // Exit 2 only when something actually threw (`errors.length > 0`). A clean
1122
1299
  // conflict-abort sets `partial: true` in the JSON but exits 0 — the Tauri
1123
1300
  // menubar's non-zero-exit Sentry capture would otherwise fire for normal
package/src/cli/share.ts CHANGED
@@ -888,15 +888,6 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
888
888
  let propagateDeletePolicy: DeletePolicy =
889
889
  options.propagateDeletePolicy ?? "owned-only";
890
890
  const baseEmit = options.onEvent ?? defaultConsoleLogger;
891
- // Mirror push progress into the shared cross-process snapshot (see sync.ts).
892
- const recordProgress = createSyncProgressRecorder({
893
- company: company ?? null,
894
- phase: "push",
895
- });
896
- const emit: ShareEmit = (event) => {
897
- baseEmit(event);
898
- recordProgress(event);
899
- };
900
891
 
901
892
  if (vaultConfig && entityContext) {
902
893
  throw new Error(
@@ -928,6 +919,21 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
928
919
  propagateDeletePolicy = "currency-gated";
929
920
  }
930
921
 
922
+ // Mirror push progress into the shared cross-process snapshot (see sync.ts).
923
+ // Record the friendly company slug, or null for the personal vault so the
924
+ // menubar shows "Personal" — never the raw prs_/cmp_ UID.
925
+ const recordProgress = createSyncProgressRecorder({
926
+ company:
927
+ ctx.uid.startsWith("prs_") || options.personalMode === true
928
+ ? null
929
+ : ctx.slug,
930
+ phase: "push",
931
+ });
932
+ const emit: ShareEmit = (event) => {
933
+ baseEmit(event);
934
+ recordProgress(event);
935
+ };
936
+
931
937
  const syncRoot = options.personalMode === true
932
938
  ? hqRoot
933
939
  : path.join(hqRoot, "companies", ctx.slug);
@@ -46,6 +46,7 @@ vi.mock("./reindex.js", () => ({
46
46
  import { sync, reportNewFilesToNotify } from "./sync.js";
47
47
  import * as s3Module from "../s3.js";
48
48
  import { reindex } from "./reindex.js";
49
+ import { readSyncProgress } from "../sync-progress.js";
49
50
 
50
51
  const mockConfig: VaultServiceConfig = {
51
52
  apiUrl: "https://vault-api.test",
@@ -144,6 +145,22 @@ describe("sync", () => {
144
145
  expect(reindex).toHaveBeenCalledWith({ repoRoot: tmpDir, skipLock: true });
145
146
  });
146
147
 
148
+ it("records the company slug, not the input UID, in the shared progress snapshot", async () => {
149
+ // sync() is called with the company UID, but the menubar popover must show
150
+ // the friendly slug — so the producer records ctx.slug, never the raw UID.
151
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
152
+ { key: "docs/x.md", size: 10, lastModified: new Date(), etag: '"x"' },
153
+ ]);
154
+ await sync({
155
+ company: "cmp_01ABCDEF",
156
+ vaultConfig: mockConfig,
157
+ hqRoot: tmpDir,
158
+ });
159
+ const snap = readSyncProgress();
160
+ expect(snap).not.toBeNull();
161
+ expect(snap!.company).toBe("acme"); // ctx.slug, not "cmp_01ABCDEF"
162
+ });
163
+
147
164
  it("F15: public sync entrypoint refuses an already-held operation lock", async () => {
148
165
  process.env.HQ_OP_LOCK_TIMEOUT = "0";
149
166
  const lockPath = lockPathFor(tmpDir);
package/src/cli/sync.ts CHANGED
@@ -658,6 +658,23 @@ function logNotifyFailure(err: unknown): void {
658
658
  }
659
659
  }
660
660
 
661
+ /** Log a non-fatal post-sync maintenance failure without changing sync state. */
662
+ function logPostSyncMaintenanceFailure(
663
+ task: string,
664
+ hqRoot: string,
665
+ err: unknown,
666
+ ): void {
667
+ try {
668
+ console.error(
669
+ `[hq-sync] ${task} failed (non-fatal; hqRoot=${hqRoot}): ${
670
+ err instanceof Error ? err.message : String(err)
671
+ }`,
672
+ );
673
+ } catch {
674
+ // swallow — logging must never break sync
675
+ }
676
+ }
677
+
661
678
  /**
662
679
  * Sync (pull) all allowed files from the entity vault.
663
680
  */
@@ -710,17 +727,6 @@ async function syncWithOperationLockHeld(
710
727
  async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
711
728
  const { company, vaultConfig, hqRoot } = options;
712
729
  const baseEmit = options.onEvent ?? defaultConsoleLogger;
713
- // Mirror every progress event into the shared cross-process snapshot so the
714
- // menubar shows live progress for THIS sync regardless of who launched it
715
- // (auto-sync / Sync Now / CLI). Best-effort; never affects the sync.
716
- const recordProgress = createSyncProgressRecorder({
717
- company: company ?? null,
718
- phase: "pull",
719
- });
720
- const emit: SyncEventEmitter = (event) => {
721
- baseEmit(event);
722
- recordProgress(event);
723
- };
724
730
 
725
731
  const companyRef = company ?? resolveActiveCompany(hqRoot);
726
732
  if (!companyRef) {
@@ -738,6 +744,20 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
738
744
  const journalSlug = options.journalSlug ?? ctx.slug;
739
745
  const startedAt = new Date().toISOString();
740
746
 
747
+ // Mirror every progress event into the shared cross-process snapshot so the
748
+ // menubar shows live progress for THIS sync regardless of who launched it
749
+ // (auto-sync / Sync Now / CLI). Record the friendly company slug (e.g.
750
+ // "indigo") — or null for the personal vault so the menubar shows "Personal"
751
+ // — never the raw prs_/cmp_ UID. Best-effort; never affects the sync.
752
+ const recordProgress = createSyncProgressRecorder({
753
+ company: options.personalMode === true ? null : ctx.slug,
754
+ phase: "pull",
755
+ });
756
+ const emit: SyncEventEmitter = (event) => {
757
+ baseEmit(event);
758
+ recordProgress(event);
759
+ };
760
+
741
761
  if (journalSlug === PERSONAL_VAULT_JOURNAL_SLUG) migratePersonalVaultJournal();
742
762
  const journal = migrateToV2(readJournal(journalSlug));
743
763
  gcTombstones(journal, Date.now());
@@ -1466,7 +1486,8 @@ function finalizePullRun(
1466
1486
  if (!run.options.skipReindex && changedOnDisk) {
1467
1487
  try {
1468
1488
  reindex({ repoRoot: run.hqRoot, skipLock: true });
1469
- } catch {
1489
+ } catch (err) {
1490
+ logPostSyncMaintenanceFailure("post-sync reindex", run.hqRoot, err);
1470
1491
  // best-effort: a post-sync refresh failure never fails the sync
1471
1492
  }
1472
1493
  }
package/src/context.ts CHANGED
@@ -76,9 +76,15 @@ function slugCacheKey(callerKey: string, slug: string): string {
76
76
  /**
77
77
  * Closed-set of recognised entity-UID prefixes. Adding a new entity type
78
78
  * means appending one entry here AND extending the dispatch in
79
- * `resolveEntityContext` (cmp_ → /sts/vend, prs_ → /sts/vend-self).
79
+ * `resolveEntityContext` (cmp_ → /sts/vend, prs_/agt_ → /sts/vend-self).
80
+ *
81
+ * `agt_` (US-013, unblocks US-004): a headless agent box syncs its OWN
82
+ * personal overlay via `hq-sync-runner --personal`, which resolves the
83
+ * agent's own entity (`agt_*`, bucket `hq-vault-agt-<uid>`). Without `agt_`
84
+ * here an agt_ uid is mistaken for a SLUG and routed through the company
85
+ * by-slug lookup; with it, the uid is recognised and self-vended below.
80
86
  */
81
- export const KNOWN_UID_PREFIXES = ["cmp_", "prs_"] as const;
87
+ export const KNOWN_UID_PREFIXES = ["cmp_", "prs_", "agt_"] as const;
82
88
 
83
89
  /**
84
90
  * Look up an entity by slug or UID via vault-service, then vend STS-scoped
@@ -103,7 +109,12 @@ export async function resolveEntityContext(
103
109
  const looksLikeUid = KNOWN_UID_PREFIXES.some((p) =>
104
110
  companyUidOrSlug.startsWith(p),
105
111
  );
106
- const looksLikePerson = companyUidOrSlug.startsWith("prs_");
112
+ // Self-vend covers BOTH person (`prs_`) and agent (`agt_`) entities — each
113
+ // owns a personal vault gated by self-ownership at `POST /sts/vend-self`,
114
+ // not the membership-gated company `POST /sts/vend`. (US-013, unblocks
115
+ // US-004: an agent box self-vends its own `hq-vault-agt-<uid>` vault.)
116
+ const looksLikePerson =
117
+ companyUidOrSlug.startsWith("prs_") || companyUidOrSlug.startsWith("agt_");
107
118
 
108
119
  // For slug lookups, scope the cache key by the caller. Resolving the
109
120
  // access token here (once) doubles as a way to extract the sub for
@@ -137,9 +148,9 @@ export async function resolveEntityContext(
137
148
  }
138
149
 
139
150
  // Step 2: Dispatch credential vending by RESOLVED-uid prefix.
140
- // cmp_* → POST /sts/vend (company path; membership-gated)
141
- // prs_* → POST /sts/vend-self (person path; self-ownership-gated)
142
- // slug → resolves to a cmp_* (the slug path is company-only)
151
+ // cmp_* → POST /sts/vend (company path; membership-gated)
152
+ // prs_* / agt_* → POST /sts/vend-self (self path; self-ownership-gated)
153
+ // slug → resolves to a cmp_* (the slug path is company-only)
143
154
  //
144
155
  // HQ-59 vend-skip: when the run uses the presigned-URL transport for company
145
156
  // vaults, a `cmp_*` context needs no STS creds (the presign transport
@@ -56,6 +56,13 @@ export interface ReindexOptions {
56
56
  readCompanies?: (companiesDir: string) => string[];
57
57
  /** Returns true if the knowledge dir has at least one indexable .md file. */
58
58
  hasIndexableMarkdown?: (knowledgeDir: string) => boolean;
59
+ /** Optional diagnostic sink for unexpected swallowed failures. */
60
+ log?: (diagnostic: {
61
+ event: string;
62
+ message: string;
63
+ err: unknown;
64
+ context?: Record<string, unknown>;
65
+ }) => void;
59
66
  }
60
67
 
61
68
  /**
@@ -110,7 +117,17 @@ export function reindexAfterSync(
110
117
  const embed = exec(["embed"]);
111
118
  result.embedded = embed.status === 0;
112
119
  }
113
- } catch {
120
+ } catch (err) {
121
+ try {
122
+ opts.log?.({
123
+ event: "runner.qmd_reindex.internal_failed",
124
+ message: "post-sync qmd reindex failed",
125
+ err,
126
+ context: { hqRoot },
127
+ });
128
+ } catch {
129
+ /* diagnostics must never affect sync */
130
+ }
114
131
  // Reindex is best-effort; the sync result is authoritative. Swallow.
115
132
  }
116
133
 
@@ -9,6 +9,7 @@ import {
9
9
  codexRowTurnId,
10
10
  parseCodexSessionMeta,
11
11
  collectAndSendSkillTelemetry,
12
+ readFileRegion,
12
13
  } from "./skill-telemetry.js";
13
14
  import type { SkillInvocationBatch } from "./vault-client.js";
14
15
 
@@ -1157,3 +1158,96 @@ describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
1157
1158
  await fs.rm(tmp, { recursive: true, force: true });
1158
1159
  });
1159
1160
  });
1161
+
1162
+ describe("readFileRegion — HQ-SYNC-WEB-15 (SIGABRT on >2GiB single read)", () => {
1163
+ const INT32_MAX = 2 ** 31 - 1;
1164
+
1165
+ it("never issues a single read whose length exceeds Int32, even for a multi-GiB region", async () => {
1166
+ // The original code did `fh.read(buf, 0, currentSize - offset, offset)` in
1167
+ // one shot. Once a session log's unread tail passed ~2 GiB, the length
1168
+ // argument was no longer an Int32, so Node's native fs.read asserted
1169
+ // (`args[3]->IsInt32()`) and SIGABRT'd the whole runner. Simulate a 3 GiB
1170
+ // region with a recording fake handle (no real allocation) and prove every
1171
+ // native read length stays within Int32.
1172
+ const THREE_GIB = 3 * 1024 * 1024 * 1024;
1173
+ const lengths: number[] = [];
1174
+ let cursor = 0;
1175
+ const fakeFh = {
1176
+ read: vi.fn(
1177
+ async (_buf: Buffer, _off: number, length: number, position: number) => {
1178
+ lengths.push(length);
1179
+ expect(position).toBe(cursor); // contiguous, in order
1180
+ cursor += length;
1181
+ return { bytesRead: length };
1182
+ },
1183
+ ),
1184
+ };
1185
+
1186
+ const total = await readFileRegion(
1187
+ fakeFh as unknown as Parameters<typeof readFileRegion>[0],
1188
+ Buffer.alloc(0), // recording fake never touches the buffer
1189
+ 0,
1190
+ THREE_GIB,
1191
+ );
1192
+
1193
+ expect(total).toBe(THREE_GIB);
1194
+ expect(lengths.length).toBeGreaterThan(1); // it actually chunked
1195
+ for (const len of lengths) {
1196
+ expect(len).toBeGreaterThan(0);
1197
+ expect(len).toBeLessThanOrEqual(INT32_MAX);
1198
+ }
1199
+ expect(lengths.reduce((a, b) => a + b, 0)).toBe(THREE_GIB);
1200
+ });
1201
+
1202
+ it("reads an entire real region correctly across multiple chunks", async () => {
1203
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "readregion-"));
1204
+ const filePath = path.join(tmp, "data.bin");
1205
+ const payload = Buffer.from("abcdefghijklmnopqrstuvwxyz0123456789", "utf-8");
1206
+ await fs.writeFile(filePath, payload);
1207
+
1208
+ const fh = await fs.open(filePath, "r");
1209
+ try {
1210
+ const buf = Buffer.alloc(payload.length);
1211
+ // Tiny chunk size forces the loop to iterate many times.
1212
+ const read = await readFileRegion(fh, buf, 0, payload.length, 7);
1213
+ expect(read).toBe(payload.length);
1214
+ expect(buf.equals(payload)).toBe(true);
1215
+ } finally {
1216
+ await fh.close();
1217
+ }
1218
+
1219
+ // Reading a sub-region from an offset also lands correctly.
1220
+ const fh2 = await fs.open(filePath, "r");
1221
+ try {
1222
+ const buf = Buffer.alloc(10);
1223
+ const read = await readFileRegion(fh2, buf, 5, 10, 4);
1224
+ expect(read).toBe(10);
1225
+ expect(buf.toString("utf-8")).toBe(payload.toString("utf-8", 5, 15));
1226
+ } finally {
1227
+ await fh2.close();
1228
+ }
1229
+
1230
+ await fs.rm(tmp, { recursive: true, force: true });
1231
+ });
1232
+
1233
+ it("stops cleanly on a short/zero read (file truncated since stat)", async () => {
1234
+ // Ask for 100 bytes but the file only holds 30: the handle yields bytes up
1235
+ // to what's left, then signals EOF with a 0-byte read.
1236
+ let remaining = 30;
1237
+ const fakeFh = {
1238
+ read: vi.fn(async (_b: Buffer, _o: number, length: number) => {
1239
+ const n = Math.min(length, remaining);
1240
+ remaining -= n;
1241
+ return { bytesRead: n };
1242
+ }),
1243
+ };
1244
+ const got = await readFileRegion(
1245
+ fakeFh as unknown as Parameters<typeof readFileRegion>[0],
1246
+ Buffer.alloc(0),
1247
+ 0,
1248
+ 100,
1249
+ 8,
1250
+ );
1251
+ expect(got).toBe(30);
1252
+ });
1253
+ });
@@ -594,6 +594,51 @@ async function listJsonlFiles(root: string): Promise<string[]> {
594
594
  // session context without slurping a multi-MiB transcript.
595
595
  const CODEX_META_PREFIX_BYTES = 64 * 1024;
596
596
 
597
+ /**
598
+ * Largest length passed to a single `FileHandle.read()`. Node's native fs.read
599
+ * asserts `args[3]->IsInt32()` on the length argument, so a single read whose
600
+ * length exceeds 2**31-1 (≈2 GiB) does NOT throw a catchable error — it aborts
601
+ * the whole process with SIGABRT. A skill/session log whose unread tail had
602
+ * grown past ~2 GiB crashed the entire `hq sync` runner this way
603
+ * (Sentry HQ-SYNC-WEB-15). 256 MiB stays comfortably within Int32 while keeping
604
+ * the read-loop iteration count small.
605
+ */
606
+ const MAX_FS_READ_CHUNK_BYTES = 256 * 1024 * 1024;
607
+
608
+ /** Minimal structural view of the `FileHandle.read` we depend on. */
609
+ type ReadableFileHandle = {
610
+ read(
611
+ buffer: Buffer,
612
+ offset: number,
613
+ length: number,
614
+ position: number,
615
+ ): Promise<{ bytesRead: number }>;
616
+ };
617
+
618
+ /**
619
+ * Read `length` bytes from `fh` starting at byte `position` into `buf`, issuing
620
+ * native reads no larger than `chunkBytes` so the length argument never exceeds
621
+ * Int32 and trips the SIGABRT-on-assert path (HQ-SYNC-WEB-15). Returns the total
622
+ * bytes actually read — which may be < `length` if the file was truncated
623
+ * between `stat` and this read (a 0-byte read means EOF, so we stop).
624
+ */
625
+ export async function readFileRegion(
626
+ fh: ReadableFileHandle,
627
+ buf: Buffer,
628
+ position: number,
629
+ length: number,
630
+ chunkBytes: number = MAX_FS_READ_CHUNK_BYTES,
631
+ ): Promise<number> {
632
+ let filled = 0;
633
+ while (filled < length) {
634
+ const chunk = Math.min(chunkBytes, length - filled);
635
+ const { bytesRead } = await fh.read(buf, filled, chunk, position + filled);
636
+ if (bytesRead === 0) break; // EOF / truncated since stat — stop cleanly.
637
+ filled += bytesRead;
638
+ }
639
+ return filled;
640
+ }
641
+
597
642
  /** Read cwd + sessionId from a Codex rollout's leading `session_meta` line.
598
643
  * Always reads from offset 0 (independent of the byte cursor). Best-effort:
599
644
  * any error → empty context (events then fail the hqRoot scope filter, which
@@ -605,7 +650,7 @@ async function readCodexSessionContext(
605
650
  const fh = await fs.open(filePath, "r");
606
651
  try {
607
652
  const buf = Buffer.alloc(CODEX_META_PREFIX_BYTES);
608
- const { bytesRead } = await fh.read(buf, 0, CODEX_META_PREFIX_BYTES, 0);
653
+ const bytesRead = await readFileRegion(fh, buf, 0, CODEX_META_PREFIX_BYTES);
609
654
  const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n", 1)[0]?.trim();
610
655
  if (!firstLine) return {};
611
656
  return parseCodexSessionMeta(JSON.parse(firstLine)) ?? {};
@@ -791,8 +836,11 @@ export async function collectAndSendSkillTelemetry(
791
836
  try {
792
837
  const length = Math.max(0, currentSize - offset);
793
838
  const buf = Buffer.alloc(length);
794
- await fh.read(buf, 0, length, offset);
795
- content = buf.toString("utf-8");
839
+ // Chunked: a single fh.read() with length > Int32 aborts the process
840
+ // with SIGABRT (HQ-SYNC-WEB-15). `bytesRead` may be < length if the
841
+ // file was truncated since stat — bound the decode to it.
842
+ const bytesRead = await readFileRegion(fh, buf, offset, length);
843
+ content = buf.toString("utf-8", 0, bytesRead);
796
844
  } finally {
797
845
  await fh.close();
798
846
  }