@indigoai-us/hq-cli 5.304.0 → 5.305.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 (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/command-catalog.generated.d.ts +1 -1
  3. package/dist/command-catalog.generated.js +3 -1
  4. package/dist/command-registration-plan.js +8 -0
  5. package/dist/commands/lanes.js +13 -0
  6. package/dist/commands/mcp-status.js +1 -0
  7. package/dist/commands/members.js +0 -1
  8. package/dist/commands/people.d.ts +3 -0
  9. package/dist/commands/people.js +27 -3
  10. package/dist/index.js +6 -1
  11. package/dist/lazy-commands.js +5 -2
  12. package/dist/lib/aws-sdk-node-warning-output.d.ts +12 -0
  13. package/dist/lib/aws-sdk-node-warning-output.js +22 -0
  14. package/dist/lib/company-errors-sdk-warning-flag.d.ts +5 -0
  15. package/dist/lib/company-errors-sdk-warning-flag.js +8 -0
  16. package/dist/lib/doctor/checks/work-context.js +2 -2
  17. package/dist/lib/lanes/keep-alive.js +13 -2
  18. package/dist/lib/mesh/live/daemon/doctor.js +2 -2
  19. package/dist/lib/mesh/live/flush.d.ts +2 -2
  20. package/dist/lib/mesh/live/flush.js +49 -29
  21. package/dist/lib/mesh/live/spool.d.ts +6 -0
  22. package/dist/lib/mesh/live/spool.js +203 -4
  23. package/dist/lib/work-context/bind-best.js +13 -1
  24. package/dist/main.js +16 -3
  25. package/dist/utils/self-update.d.ts +4 -0
  26. package/dist/utils/self-update.js +46 -9
  27. package/dist/utils/version-check.d.ts +7 -0
  28. package/dist/utils/version-check.js +30 -1
  29. package/dist/utils/version-gate.d.ts +28 -10
  30. package/dist/utils/version-gate.js +159 -29
  31. package/dist/utils/version-report.d.ts +2 -1
  32. package/dist/utils/version-report.js +8 -1
  33. package/package.json +1 -1
  34. package/dist/lib/work-context/index.d.ts +0 -26
  35. package/dist/lib/work-context/index.js +0 -22
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.305.0] — 2026-10-01
6
+
7
+ - Work Mesh project discovery skips projects whose matching local lifecycle state is archived or retired.
8
+
9
+ - `hq lanes show` now treats a newer terminal primary mirror as authoritative when an Outpost owner snapshot is behind, avoiding a misleading refresh warning while preserving warnings for nonterminal conflicts.
10
+ - Self-updates detect an unwritable npm global prefix before install, show the command to repair that install, and report the handled permission state as a rate-limited warning.
11
+ - Keep Work Mesh held events unique by event ID across retries, including interrupted overflow recovery.
12
+ - Added `hq mcp list` as an alias for the existing MCP status command. `hq version` can show cached CLI update guidance on stderr when the default-off `cli.version-update-nudge` flag is enabled. Bare `hq --version` prints only the installed CLI semver and starts no flag or network work.
13
+ - Keep-alive no longer lets a newer terminal lane or a same-profile lane under a distinct lane senior supersede a running lane.
14
+ - With `cli.company-errors-sdk-warning` enabled, `hq people` uses shared active-company guidance when local metadata is missing, and files commands show the AWS SDK Node-version notice only with verbose diagnostics.
15
+ - Remove the unrelated secret-sharing hint from active member listings.
16
+
5
17
  ## [5.304.0] — 2026-10-01
6
18
  - `hq lanes show` skips owner refresh only when a failed Outpost dispatch explicitly recorded `pickup_persisted: false` and no owner heartbeat; it reports `remote_refresh: "skipped_undispatched"` in that case.
7
19
 
@@ -3622,7 +3622,7 @@ export declare const COMMAND_CATALOG: readonly [{
3622
3622
  readonly subcommands: readonly [{
3623
3623
  readonly name: "status";
3624
3624
  readonly description: "Show installed MCP packs/servers and their health in BOTH Claude and Codex (PARTIAL/DRIFT flagged; secrets redacted)";
3625
- readonly aliases: readonly [];
3625
+ readonly aliases: readonly ["list"];
3626
3626
  readonly hidden: false;
3627
3627
  readonly usage: "[options]";
3628
3628
  readonly arguments: readonly [];
@@ -4674,7 +4674,9 @@ export const COMMAND_CATALOG = [
4674
4674
  {
4675
4675
  "name": "status",
4676
4676
  "description": "Show installed MCP packs/servers and their health in BOTH Claude and Codex (PARTIAL/DRIFT flagged; secrets redacted)",
4677
- "aliases": [],
4677
+ "aliases": [
4678
+ "list"
4679
+ ],
4678
4680
  "hidden": false,
4679
4681
  "usage": "[options]",
4680
4682
  "arguments": [],
@@ -85,6 +85,7 @@ export const REGISTRATION_PLAN = [
85
85
  ];
86
86
  async function executePlan(program, steps) {
87
87
  const parents = new Map([["program", program]]);
88
+ let filesRuntimeConfigured = false;
88
89
  for (const step of steps) {
89
90
  if (step.type === "group") {
90
91
  const group = program.command(step.name, step.hidden ? { hidden: true } : {});
@@ -94,6 +95,13 @@ async function executePlan(program, steps) {
94
95
  const parent = parents.get(step.parent);
95
96
  if (!parent)
96
97
  throw new Error(`Missing command parent '${step.parent}' for '${step.exportName}'`);
98
+ if (step.root === "files" && !filesRuntimeConfigured) {
99
+ // The SDK emits its Node support notice during module evaluation. Set
100
+ // its narrow suppression switch before loading any files command module.
101
+ const { configureAwsSdkNodeVersionWarningOutput } = await import("./lib/aws-sdk-node-warning-output.js");
102
+ configureAwsSdkNodeVersionWarningOutput();
103
+ filesRuntimeConfigured = true;
104
+ }
97
105
  const imported = await import(step.module);
98
106
  const register = imported[step.exportName];
99
107
  if (!register)
@@ -5065,6 +5065,19 @@ async function refreshOutpostLaneCopiesForShow(ctx, lane, localLanes) {
5065
5065
  return syncLaneRecordFromOwner(ctx.hqRoot(), laneId, outpostId, ownerLane, undefined, { allowNonTerminal: true });
5066
5066
  }
5067
5067
  catch (err) {
5068
+ if (isLanesError(err) && err.error === "outpost_lane_sync_conflict") {
5069
+ const ownerVersion = err.details.owner_version;
5070
+ const primaryVersion = err.details.primary_version;
5071
+ if (typeof ownerVersion === "number" &&
5072
+ typeof primaryVersion === "number" &&
5073
+ ownerVersion < primaryVersion) {
5074
+ const currentPrimary = readLane(ctx.hqRoot(), laneId);
5075
+ if (currentPrimary.version === primaryVersion &&
5076
+ isTerminalLaneState(currentPrimary.state)) {
5077
+ return currentPrimary;
5078
+ }
5079
+ }
5080
+ }
5068
5081
  const detail = isLanesError(err)
5069
5082
  ? `${err.error}: ${err.message}`
5070
5083
  : `outpost_lane_sync_unavailable: ${dispatchErrorMessage(err)}`;
@@ -374,6 +374,7 @@ export function registerMcpCommand(program) {
374
374
  .description('MCP pack observability (read-only) across Claude + Codex');
375
375
  mcp
376
376
  .command('status')
377
+ .alias('list')
377
378
  .description('Show installed MCP packs/servers and their health in BOTH Claude and Codex (PARTIAL/DRIFT flagged; secrets redacted)')
378
379
  .option('--json', 'Emit the structured report as JSON (for CI / scripting)')
379
380
  .action((opts) => {
@@ -672,7 +672,6 @@ export function registerMembersCommand(program) {
672
672
  const name = m.personName ?? m.personSlug ?? "";
673
673
  console.log([email.padEnd(emailW), m.role.padEnd(roleW), name].join(" "));
674
674
  }
675
- console.log(chalk.gray("Share a secret with a member: hq secrets share <path> --with <email>"));
676
675
  }
677
676
  catch (err) {
678
677
  if (err instanceof InviteHttpError) {
@@ -17,6 +17,9 @@ import { resolveNameToEmail, type PersonRecord } from "../utils/people.js";
17
17
  export type FetchPeopleRoster = (hqRoot: string, companySlug: string) => Promise<PersonRecord[]>;
18
18
  interface PeopleCommandDeps {
19
19
  fetchRoster?: FetchPeopleRoster;
20
+ /** Test seam for the shared active-membership ambiguity resolver. */
21
+ resolveMissingManifestMemberships?: () => Promise<void>;
22
+ companySelectionGuidanceEnabled?: () => boolean;
20
23
  }
21
24
  interface PeopleLookupOpts {
22
25
  localOnly?: boolean;
@@ -19,6 +19,7 @@ import { findHqRoot } from "../utils/manifest.js";
19
19
  import { manifestPath } from "./cloud-provision.js";
20
20
  import { ensureCognitoToken } from "../utils/cognito-session.js";
21
21
  import { getCompanyUid } from "../utils/vault-api.js";
22
+ import { companyErrorsSdkWarningEnabled } from "../lib/company-errors-sdk-warning-flag.js";
22
23
  import { listActiveMembers } from "./members.js";
23
24
  import { assertSafeCompanySlug, listCompanyPeople, searchPeople, resolveNameToEmail, companyPeopleDir, } from "../utils/people.js";
24
25
  function slugify(value) {
@@ -108,6 +109,29 @@ export function resolveCompanySlug(hqRoot, explicit) {
108
109
  throw new Error("Multiple companies found — re-run with --company <slug> to pick one:\n" +
109
110
  slugs.map((s) => ` --company ${s}`).join("\n"));
110
111
  }
112
+ async function resolvePeopleCompanySlug(hqRoot, explicit, localOnly, deps) {
113
+ try {
114
+ return resolveCompanySlug(hqRoot, explicit);
115
+ }
116
+ catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ const ambiguous = message.includes("companies/manifest.yaml not found") ||
119
+ message.startsWith("Multiple companies found");
120
+ const enabled = (deps.companySelectionGuidanceEnabled ?? companyErrorsSdkWarningEnabled)();
121
+ if (!explicit && !localOnly && enabled && ambiguous) {
122
+ const resolveMemberships = deps.resolveMissingManifestMemberships ??
123
+ (async () => {
124
+ const token = await ensureCognitoToken();
125
+ await getCompanyUid(token, undefined);
126
+ });
127
+ // The shared resolver throws members-style guidance for multiple active
128
+ // companies. A single membership cannot provide a local slug without a
129
+ // manifest, so preserve the existing missing-manifest error in that case.
130
+ await resolveMemberships();
131
+ }
132
+ throw error;
133
+ }
134
+ }
111
135
  /** Resolve the HQ root: explicit override (tests) → cwd walk-up. */
112
136
  function resolveHqRoot(opts) {
113
137
  return opts.hqRoot ?? findHqRoot();
@@ -192,7 +216,7 @@ export function registerPeopleCommand(program, deps = {}) {
192
216
  try {
193
217
  const scope = people.opts();
194
218
  const hqRoot = resolveHqRoot(scope);
195
- const slug = resolveCompanySlug(hqRoot, scope.company);
219
+ const slug = await resolvePeopleCompanySlug(hqRoot, scope.company, opts.localOnly, deps);
196
220
  let records = listCompanyPeople(hqRoot, slug);
197
221
  if (!opts.localOnly) {
198
222
  const roster = await tryFetchRoster(deps.fetchRoster ?? fetchMembershipRoster, hqRoot, slug);
@@ -223,7 +247,7 @@ export function registerPeopleCommand(program, deps = {}) {
223
247
  try {
224
248
  const scope = people.opts();
225
249
  const hqRoot = resolveHqRoot(scope);
226
- const slug = resolveCompanySlug(hqRoot, scope.company);
250
+ const slug = await resolvePeopleCompanySlug(hqRoot, scope.company, opts.localOnly, deps);
227
251
  const matches = await searchPeopleWithRosterFallback({
228
252
  hqRoot,
229
253
  slug,
@@ -255,7 +279,7 @@ export function registerPeopleCommand(program, deps = {}) {
255
279
  try {
256
280
  const scope = people.opts();
257
281
  const hqRoot = resolveHqRoot(scope);
258
- const slug = resolveCompanySlug(hqRoot, scope.company);
282
+ const slug = await resolvePeopleCompanySlug(hqRoot, scope.company, opts.localOnly, deps);
259
283
  const result = await resolvePersonWithRosterFallback({
260
284
  hqRoot,
261
285
  slug,
package/dist/index.js CHANGED
@@ -22,9 +22,14 @@ if (isVersionRequest(process.argv)) {
22
22
  // compare). `hq version` and `--json` print the labelled HQ/CLI report.
23
23
  // Lazy so this path still skips the command graph.
24
24
  if (wantsLabelledVersionReport(process.argv)) {
25
- void import("./utils/version-report.js").then(({ runVersionCommand }) => runVersionCommand(process.argv));
25
+ void import("./lib/flag-registry.js").then(({ kickFlagRegistryReadiness }) => {
26
+ kickFlagRegistryReadiness();
27
+ return import("./utils/version-report.js").then(({ runVersionCommand }) => runVersionCommand(process.argv));
28
+ });
26
29
  }
27
30
  else {
31
+ // Keep the benchmarked updater fast route limited to the installed semver.
32
+ // Registry checks and flag resolution belong to the regular command path.
28
33
  void import("./cli-version.js").then(({ CLI_VERSION }) => {
29
34
  process.stdout.write(`${CLI_VERSION}\n`);
30
35
  });
@@ -55,8 +55,11 @@ function commandForToken(token) {
55
55
  * preserve the complete help and unknown-command recovery surfaces.
56
56
  */
57
57
  export function findLazyCommand(argv) {
58
- const first = argv[2];
59
- const token = first === "help" ? argv[3] : first;
58
+ let commandIndex = 2;
59
+ while (argv[commandIndex] === "--verbose")
60
+ commandIndex += 1;
61
+ const first = argv[commandIndex];
62
+ const token = first === "help" ? argv[commandIndex + 1] : first;
60
63
  if (typeof token !== "string" || token.startsWith("-"))
61
64
  return null;
62
65
  const command = commandForToken(token);
@@ -0,0 +1,12 @@
1
+ export declare const AWS_SDK_NODE_VERSION_WARNING_ENV = "AWS_SDK_JS_NODE_VERSION_SUPPORT_WARNING_DISABLED";
2
+ export interface AwsSdkWarningOutputOptions {
3
+ argv?: readonly string[];
4
+ env?: NodeJS.ProcessEnv;
5
+ enabled?: boolean;
6
+ }
7
+ /**
8
+ * Configure the AWS SDK's documented, warning-specific switch before loading
9
+ * the files command and its SDK clients. No other SDK warnings are affected.
10
+ */
11
+ export declare function configureAwsSdkNodeVersionWarningOutput(options?: AwsSdkWarningOutputOptions): void;
12
+ //# sourceMappingURL=aws-sdk-node-warning-output.d.ts.map
@@ -0,0 +1,22 @@
1
+ import { companyErrorsSdkWarningEnabled } from "./company-errors-sdk-warning-flag.js";
2
+ export const AWS_SDK_NODE_VERSION_WARNING_ENV = "AWS_SDK_JS_NODE_VERSION_SUPPORT_WARNING_DISABLED";
3
+ /**
4
+ * Configure the AWS SDK's documented, warning-specific switch before loading
5
+ * the files command and its SDK clients. No other SDK warnings are affected.
6
+ */
7
+ export function configureAwsSdkNodeVersionWarningOutput(options = {}) {
8
+ const env = options.env ?? process.env;
9
+ const argv = options.argv ?? process.argv;
10
+ const enabled = options.enabled ?? companyErrorsSdkWarningEnabled();
11
+ if (!enabled)
12
+ return;
13
+ const verbose = argv.includes("--verbose");
14
+ const debug = /^(1|true|yes)$/i.test(env.HQ_DEBUG?.trim() ?? "");
15
+ if (verbose || debug) {
16
+ delete env[AWS_SDK_NODE_VERSION_WARNING_ENV];
17
+ }
18
+ else {
19
+ env[AWS_SDK_NODE_VERSION_WARNING_ENV] = "true";
20
+ }
21
+ }
22
+ //# sourceMappingURL=aws-sdk-node-warning-output.js.map
@@ -0,0 +1,5 @@
1
+ export declare const COMPANY_ERRORS_SDK_WARNING_FLAG = "cli.company-errors-sdk-warning";
2
+ export declare const COMPANY_ERRORS_SDK_WARNING_FALLBACK = false;
3
+ /** One default-off rollout gate for the two US-163 CLI compatibility fixes. */
4
+ export declare function companyErrorsSdkWarningEnabled(): boolean;
5
+ //# sourceMappingURL=company-errors-sdk-warning-flag.d.ts.map
@@ -0,0 +1,8 @@
1
+ import { resolveProcessFlagGate } from "./flag-registry.js";
2
+ export const COMPANY_ERRORS_SDK_WARNING_FLAG = "cli.company-errors-sdk-warning";
3
+ export const COMPANY_ERRORS_SDK_WARNING_FALLBACK = false;
4
+ /** One default-off rollout gate for the two US-163 CLI compatibility fixes. */
5
+ export function companyErrorsSdkWarningEnabled() {
6
+ return resolveProcessFlagGate(COMPANY_ERRORS_SDK_WARNING_FLAG, {}, () => COMPANY_ERRORS_SDK_WARNING_FALLBACK);
7
+ }
8
+ //# sourceMappingURL=company-errors-sdk-warning-flag.js.map
@@ -10,7 +10,7 @@ import { readDeviceConfig, } from "../../work-context/config.js";
10
10
  import { outboxStats, listOutboxOperations } from "../../work-context/outbox.js";
11
11
  import { outboxQuarantineStatusEnabled } from "../../work-context/outbox-quarantine-status-flag.js";
12
12
  import { workContextRoot, workContextSessionsDir } from "../../work-context/paths.js";
13
- import { countJsonlLines, deadLetterNonEmpty, } from "../../mesh/live/spool.js";
13
+ import { countDistinctHeldEvents, countJsonlLines, deadLetterNonEmpty, } from "../../mesh/live/spool.js";
14
14
  import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../../mesh/live/paths.js";
15
15
  import { collectDaemonDoctor, formatDaemonDoctor, } from "../../mesh/live/daemon/doctor.js";
16
16
  export const WORK_CONTEXT_FAMILY_ID = "work-context";
@@ -139,7 +139,7 @@ function runChecks(deps) {
139
139
  const heldPath = workMeshHeldPath(meshRoot);
140
140
  const deadLetterPath = workMeshDeadLetterPath(meshRoot);
141
141
  const spoolDepth = countJsonlLines(spoolPath);
142
- const heldDepth = countJsonlLines(heldPath);
142
+ const heldDepth = countDistinctHeldEvents(heldPath);
143
143
  results.push({
144
144
  status: "PASS",
145
145
  checkId: "work-context.spool-depth",
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { LanesError } from "./errors.js";
4
4
  import { dropboxInboxHasMessages } from "./dropbox.js";
5
5
  import { appendCappedHistory, listQuestionIds, readQuestion, retryUpdateLane } from "./store.js";
6
- import { isIsoDateTime } from "./types.js";
6
+ import { isTerminalLaneState, isIsoDateTime, } from "./types.js";
7
7
  export const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
8
8
  export const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
9
9
  export const LEAD_LANE_KEEP_ALIVE_NEXT_CHECK_FLAG = "lanes.keep-alive-next-check-at";
@@ -254,7 +254,10 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
254
254
  return lanes.find((candidate) => {
255
255
  if (candidate.lane_id === lane.lane_id || Date.parse(candidate.created_at) <= createdAt)
256
256
  return false;
257
- if (candidate.state === "cancelled" || candidate.state === "killed" || candidate.state === "failed")
257
+ if (candidate.state === "cancelled" ||
258
+ candidate.state === "killed" ||
259
+ candidate.state === "failed" ||
260
+ (lane.state === "running" && isTerminalLaneState(candidate.state)))
258
261
  return false;
259
262
  const candidateCompany = companyOf(candidate) ?? null;
260
263
  const sameCompany = laneCompany === null
@@ -270,8 +273,16 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
270
273
  const sameLaneSenior = (lane.senior.kind === "lane" || lane.senior.kind === "session") &&
271
274
  candidate.senior.kind === lane.senior.kind &&
272
275
  candidate.senior.id === lane.senior.id;
276
+ const differentKnownSenior = lane.senior.kind === "lane" &&
277
+ candidate.senior.kind === "lane" &&
278
+ typeof lane.senior.id === "string" &&
279
+ lane.senior.id.trim().length > 0 &&
280
+ typeof candidate.senior.id === "string" &&
281
+ candidate.senior.id.trim().length > 0 &&
282
+ candidate.senior.id !== lane.senior.id;
273
283
  const sameProfile = lane.worker_profile !== undefined &&
274
284
  candidate.worker_profile === lane.worker_profile &&
285
+ !(lane.state === "running" && differentKnownSenior) &&
275
286
  !(lineageAware && sameLaneSenior);
276
287
  const projectLead = lane.project_id !== undefined &&
277
288
  candidate.project_id === lane.project_id &&
@@ -11,7 +11,7 @@ import { readSessionState } from "../../../work-context/state.js";
11
11
  import { workContextRoot } from "../../../work-context/paths.js";
12
12
  import { outboxQuarantineStatusEnabled } from "../../../work-context/outbox-quarantine-status-flag.js";
13
13
  import { formatDroppedPresenceCompany } from "./credentials.js";
14
- import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
14
+ import { countDistinctHeldEvents, countJsonlLines, countUnattributedEvents, } from "../spool.js";
15
15
  import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
16
16
  import { daemonDir, daemonPidPath } from "./paths.js";
17
17
  import { readEmitState } from "../emit.js";
@@ -178,7 +178,7 @@ export function collectDaemonDoctor(deps = {}) {
178
178
  const heldPath = workMeshHeldPath(meshRoot);
179
179
  const deadPath = workMeshDeadLetterPath(meshRoot);
180
180
  const spoolDepth = countJsonlLines(spoolPath);
181
- const heldCount = countJsonlLines(heldPath);
181
+ const heldCount = countDistinctHeldEvents(heldPath);
182
182
  const trustedRoots = readDaemonConfig(dir).trustedHqRoots;
183
183
  const outboxOperations = listOutboxOperations(ctxRoot);
184
184
  const heldWithNoLocalEvidence = readHeldSessions(meshRoot).filter((ref) => {
@@ -14,7 +14,7 @@ import { type SessionEventsPoster } from "./session-events-client.js";
14
14
  export declare const HELD_TTL_MS: number;
15
15
  /** Re-attempt held events at most this often (spool still flushes every cycle). */
16
16
  export declare const HELD_RETRY_INTERVAL_MS: number;
17
- /** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
17
+ /** Cap distinct held events; oldest overflow is dead-lettered with HELD_OVERFLOW. */
18
18
  export declare const HELD_MAX_LINES = 20000;
19
19
  /**
20
20
  * Re-check an unbound session against the project catalog at most this often.
@@ -46,7 +46,7 @@ export interface FlushDeps {
46
46
  lastHeldRetryAtMs?: number;
47
47
  /** Invoked when this flush claims held, so the daemon can advance its timestamp. */
48
48
  onHeldClaimed?: (atMs: number) => void;
49
- /** Max lines retained in held.jsonl after a flush. Default HELD_MAX_LINES. */
49
+ /** Max distinct eventIds retained in held.jsonl after a flush. Default HELD_MAX_LINES. */
50
50
  heldMaxLines?: number;
51
51
  /** Optional logger (daemon wires this); used once per overflow trim. */
52
52
  log?: (message: string) => void;
@@ -22,7 +22,7 @@ import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.
22
22
  import { LOCAL_ONLY_FIELDS, stripLocalOnlyFields } from "./format-spool-line.js";
23
23
  import { enrichSessionEndEvent } from "./report-summary.js";
24
24
  import { SESSION_EVENTS_BATCH_MAX, WRITABLE_CONTEXT_STATUSES, parseSessionEventsBatchAck, } from "./session-events-client.js";
25
- import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLine, appendSpoolLine, claimHeld, claimSpool, countJsonlLines, ensureSpoolFile, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
25
+ import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLinesIfAbsent, appendSpoolLine, claimHeld, claimSpool, countDistinctHeldEvents, countJsonlLines, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
26
26
  import { workMeshHeldPath, workMeshRoot } from "./paths.js";
27
27
  /** Bound a replayed session backlog so one flush cannot burst hundreds of events. */
28
28
  const SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH = 25;
@@ -31,7 +31,7 @@ const MAX_RETRY_AFTER_MS = 5 * 60 * 1000;
31
31
  export const HELD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
32
32
  /** Re-attempt held events at most this often (spool still flushes every cycle). */
33
33
  export const HELD_RETRY_INTERVAL_MS = 5 * 60 * 1000;
34
- /** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
34
+ /** Cap distinct held events; oldest overflow is dead-lettered with HELD_OVERFLOW. */
35
35
  export const HELD_MAX_LINES = 20_000;
36
36
  /**
37
37
  * Re-check an unbound session against the project catalog at most this often.
@@ -279,11 +279,9 @@ export async function flushSessionEvents(deps) {
279
279
  state = readSessionState(sessionId, deps.workContextRoot) ?? state;
280
280
  if (!allowed) {
281
281
  const heldAt = now().toISOString();
282
- for (const item of remainingItems) {
283
- appendHeldLine(heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt), deps.workMeshRoot);
284
- summary.held += 1;
282
+ summary.held += appendHeldLinesIfAbsent(remainingItems.map((item) => heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt)), deps.workMeshRoot);
283
+ for (const item of remainingItems)
285
284
  markDisposed(item.event);
286
- }
287
285
  continue;
288
286
  }
289
287
  }
@@ -306,11 +304,9 @@ export async function flushSessionEvents(deps) {
306
304
  }
307
305
  if (decision.action === "hold") {
308
306
  const heldAt = now().toISOString();
309
- for (const item of remainingItems) {
310
- appendHeldLine(heldEnvelope(item.event, decision.heldReason, item.heldAt ?? heldAt), deps.workMeshRoot);
311
- summary.held += 1;
307
+ summary.held += appendHeldLinesIfAbsent(remainingItems.map((item) => heldEnvelope(item.event, decision.heldReason, item.heldAt ?? heldAt)), deps.workMeshRoot);
308
+ for (const item of remainingItems)
312
309
  markDisposed(item.event);
313
- }
314
310
  continue;
315
311
  }
316
312
  // Post a bounded portion of this session's remaining backlog (after any
@@ -368,11 +364,9 @@ export async function flushSessionEvents(deps) {
368
364
  deps.log?.(`work-mesh: session ${sessionId} project missing (HTTP 404); downgraded to needs_project and held events`);
369
365
  }
370
366
  const heldAt = now().toISOString();
371
- for (const item of originalChunk) {
372
- appendHeldLine(heldEnvelope(item.event, "NEEDS_PROJECT", item.heldAt ?? heldAt), deps.workMeshRoot);
373
- summary.held += 1;
367
+ summary.held += appendHeldLinesIfAbsent(originalChunk.map((item) => heldEnvelope(item.event, "NEEDS_PROJECT", item.heldAt ?? heldAt)), deps.workMeshRoot);
368
+ for (const item of originalChunk)
374
369
  markDisposed(item.event);
375
- }
376
370
  continue;
377
371
  }
378
372
  if (!result.retryable) {
@@ -429,15 +423,17 @@ export async function flushSessionEvents(deps) {
429
423
  * flusher (`hq mesh flush` beside the daemon) may have appended to a fresh
430
424
  * held.jsonl after our claim renamed the old one away.
431
425
  * - The overflow claim file is removed only after every dead letter and every
432
- * retained line is on disk. If anything throws, the claim stays behind and
433
- * recoverOrphanClaims re-reads it on the next flush (duplicates are possible,
434
- * loss is not).
426
+ * retained line is on disk. Retained rows are idempotent by eventId, so a
427
+ * retry after an interrupted append is safe; the claim stays until recovery
428
+ * can finish, preserving the append-before-remove ordering.
435
429
  */
436
430
  function trimHeldOverflow(meshRoot, maxLines, now, summary) {
437
431
  if (maxLines <= 0)
438
432
  return 0;
439
433
  const heldPath = workMeshHeldPath(meshRoot);
440
- if (countJsonlLines(heldPath) <= maxLines)
434
+ const lineCount = countJsonlLines(heldPath);
435
+ const distinctCount = countDistinctHeldEvents(heldPath);
436
+ if (lineCount <= maxLines && lineCount === distinctCount)
441
437
  return 0;
442
438
  const claim = claimHeld(meshRoot, `${now().getTime()}.overflow`);
443
439
  if (!claim.claimPath)
@@ -447,15 +443,26 @@ function trimHeldOverflow(meshRoot, maxLines, now, summary) {
447
443
  return 0;
448
444
  }
449
445
  try {
450
- if (claim.lines.length <= maxLines) {
451
- appendHeldLines(meshRoot, claim.lines);
446
+ const alreadyHeldIds = readHeldEventIds(heldPath);
447
+ const seenClaimIds = new Set();
448
+ const uniqueLines = claim.lines.filter((line) => {
449
+ const eventId = heldEventIdOfLine(line);
450
+ if (!eventId)
451
+ return true;
452
+ if (alreadyHeldIds.has(eventId) || seenClaimIds.has(eventId))
453
+ return false;
454
+ seenClaimIds.add(eventId);
455
+ return true;
456
+ });
457
+ if (uniqueLines.length <= maxLines) {
458
+ appendHeldLinesOnce(meshRoot, uniqueLines);
452
459
  removeClaimFile(claim.claimPath);
453
460
  return 0;
454
461
  }
455
- const overflow = claim.lines.length - maxLines;
462
+ const overflow = uniqueLines.length - maxLines;
456
463
  // Oldest by heldAt: retries regroup held by session, so file order is not
457
464
  // chronological. Unparseable lines sort first (dead-lettered first).
458
- const ordered = claim.lines
465
+ const ordered = uniqueLines
459
466
  .map((line, idx) => ({ line, idx, atMs: heldAtMs(line) }))
460
467
  .sort((a, b) => a.atMs - b.atMs || a.idx - b.idx);
461
468
  const toDead = ordered.slice(0, overflow);
@@ -475,7 +482,7 @@ function trimHeldOverflow(meshRoot, maxLines, now, summary) {
475
482
  }
476
483
  summary.deadLettered += 1;
477
484
  }
478
- appendHeldLines(meshRoot, toKeep);
485
+ appendHeldLinesOnce(meshRoot, toKeep);
479
486
  summary.heldOverflow = overflow;
480
487
  removeClaimFile(claim.claimPath);
481
488
  return overflow;
@@ -496,12 +503,25 @@ function heldAtMs(line) {
496
503
  const ms = Date.parse(heldAt);
497
504
  return Number.isFinite(ms) ? ms : 0;
498
505
  }
499
- function appendHeldLines(meshRoot, lines) {
500
- if (lines.length === 0)
501
- return;
502
- const heldPath = workMeshHeldPath(meshRoot);
503
- ensureSpoolFile(heldPath);
504
- fs.appendFileSync(heldPath, `${lines.join("\n")}\n`, { mode: 0o600 });
506
+ function appendHeldLinesOnce(meshRoot, lines) {
507
+ appendHeldLinesIfAbsent(lines, meshRoot);
508
+ }
509
+ function heldEventIdOfLine(line) {
510
+ const parsed = parseLine(line);
511
+ return parsed ? eventIdOf(unwrapHeld(parsed).event) : null;
512
+ }
513
+ function readHeldEventIds(filePath) {
514
+ if (!fs.existsSync(filePath))
515
+ return new Set();
516
+ const ids = new Set();
517
+ for (const line of fs.readFileSync(filePath, "utf8").split("\n")) {
518
+ if (!line.trim())
519
+ continue;
520
+ const id = heldEventIdOfLine(line);
521
+ if (id)
522
+ ids.add(id);
523
+ }
524
+ return ids;
505
525
  }
506
526
  /**
507
527
  * Account a 2xx batch: dead-letter rejected eventIds with their codes;
@@ -19,6 +19,10 @@ export declare function ensureSpoolFile(filePath: string): void;
19
19
  export declare function appendJsonlLine(filePath: string, line: string): void;
20
20
  export declare function appendSpoolLine(line: string, root?: string): string;
21
21
  export declare function appendHeldLine(line: string, root?: string): string;
22
+ /** Append a held envelope only when its eventId is not already held. */
23
+ export declare function appendHeldLineIfAbsent(line: string, root?: string): boolean;
24
+ /** Append a deduplicated held batch under one file lock and one write. */
25
+ export declare function appendHeldLinesIfAbsent(lines: string[], root?: string): number;
22
26
  export declare function appendDeadLetterLine(line: string, root?: string): string;
23
27
  export declare function appendDroppedReceipt(receipt: Record<string, unknown>, root?: string): string;
24
28
  export interface ClaimResult {
@@ -49,6 +53,8 @@ export declare function recoverOrphanClaims(root: string): {
49
53
  export declare function removeClaimFile(claimPath: string | null): void;
50
54
  export declare function deadLetterNonEmpty(root?: string): boolean;
51
55
  export declare function countJsonlLines(filePath: string): number;
56
+ /** Count held eventIds once; legacy rows without an eventId remain line-counted. */
57
+ export declare function countDistinctHeldEvents(filePath: string): number;
52
58
  /** Local queues that still lack a usable company attribution. */
53
59
  export interface UnattributedEventCounts {
54
60
  spool: number;