@indigoai-us/hq-cli 5.343.0 → 5.344.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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/command-catalog.generated.d.ts +15 -4
  3. package/dist/command-catalog.generated.js +19 -4
  4. package/dist/commands/cloud.js +2 -2
  5. package/dist/commands/daemon.js +2 -11
  6. package/dist/commands/dm.js +10 -1
  7. package/dist/commands/lanes-shared.d.ts +1 -0
  8. package/dist/commands/lanes.d.ts +1 -0
  9. package/dist/commands/lanes.js +11 -0
  10. package/dist/commands/login.js +12 -1
  11. package/dist/commands/logout.js +10 -0
  12. package/dist/commands/mesh.d.ts +27 -1
  13. package/dist/commands/mesh.js +123 -12
  14. package/dist/lib/agent-kit/mcp/tools.js +1 -1
  15. package/dist/lib/bot/api.d.ts +1 -0
  16. package/dist/lib/bot/api.js +2 -0
  17. package/dist/lib/bot/progress.d.ts +12 -14
  18. package/dist/lib/bot/progress.js +29 -40
  19. package/dist/lib/bot/run.js +24 -24
  20. package/dist/lib/core-utils/sentry-report.d.ts +1 -1
  21. package/dist/lib/core-utils/sentry-report.js +18 -1
  22. package/dist/lib/daemon/command-call.d.ts +9 -0
  23. package/dist/lib/daemon/command-call.js +18 -0
  24. package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
  25. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  26. package/dist/lib/work-context/config.d.ts +66 -0
  27. package/dist/lib/work-context/config.js +142 -0
  28. package/dist/lib/work-context/person-identity.d.ts +16 -0
  29. package/dist/lib/work-context/person-identity.js +39 -0
  30. package/dist/utils/cli-telemetry.js +5 -1
  31. package/dist/utils/client-health.d.ts +7 -3
  32. package/dist/utils/client-health.js +30 -6
  33. package/dist/utils/install-tree-torn.d.ts +16 -13
  34. package/dist/utils/install-tree-torn.js +58 -26
  35. package/dist/utils/resident-package-preload.d.ts +1 -1
  36. package/dist/utils/resident-package-preload.js +18 -0
  37. package/package.json +1 -1
@@ -9,6 +9,10 @@ import { DefaultCompanyUnavailableError, UnsafeConfigPathError, WorkContextError
9
9
  import { workContextConfigPath } from "./paths.js";
10
10
  import { assertWindowsOwnerOnlyAcl } from "./windows-acl.js";
11
11
  const DEVICE_CONFIG_SCHEMA_VERSION = 1;
12
+ /** A person principal uid, as carried in the `custom:entityUid` token claim. */
13
+ export function isPersonUid(value) {
14
+ return typeof value === "string" && /^prs_[A-Za-z0-9]+$/.test(value);
15
+ }
12
16
  const CONFIG_LOCK_TIMEOUT_MS = 5_000;
13
17
  const CONFIG_LOCK_RETRY_MS = 10;
14
18
  function waitForConfigLock(ms) {
@@ -146,6 +150,44 @@ function sanitizeRepoCompanyMap(raw) {
146
150
  }
147
151
  return Object.keys(out).length > 0 ? out : null;
148
152
  }
153
+ /** Parse a raw usageAttribution value; anything malformed is dropped (fail closed). */
154
+ export function sanitizeUsageAttribution(raw) {
155
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
156
+ return undefined;
157
+ const v = raw;
158
+ if (v.mode !== "default-company")
159
+ return undefined;
160
+ const companyUid = typeof v.companyUid === "string" ? v.companyUid.trim() : "";
161
+ const personUid = typeof v.personUid === "string" ? v.personUid.trim() : "";
162
+ const optedInAt = typeof v.optedInAt === "string" ? v.optedInAt.trim() : "";
163
+ if (!companyUid.startsWith("cmp_") ||
164
+ !isPersonUid(personUid) ||
165
+ !optedInAt ||
166
+ !Number.isFinite(Date.parse(optedInAt))) {
167
+ return undefined;
168
+ }
169
+ return { mode: "default-company", companyUid, personUid, optedInAt };
170
+ }
171
+ /**
172
+ * The opt-in that is in effect: present, for the uid of the current enabled
173
+ * default company, and given by the signed-in person. Mirrors the hq-cloud
174
+ * reader. No signed-in person means no opt-in.
175
+ */
176
+ export function effectiveUsageAttribution(cfg, personUid) {
177
+ const opt = cfg.usageAttribution;
178
+ if (!opt || !isPersonUid(personUid) || opt.personUid !== personUid)
179
+ return null;
180
+ const def = cfg.defaultCompany;
181
+ if (!opt || !def || def.enabled !== true || def.source === "disabled")
182
+ return null;
183
+ if (!def.uid || def.uid !== opt.companyUid)
184
+ return null;
185
+ return opt;
186
+ }
187
+ /** Keep `usageAttribution` next to `defaultCompany`, omitting it when absent. */
188
+ function usageAttributionField(value) {
189
+ return value ? { usageAttribution: value } : {};
190
+ }
149
191
  /**
150
192
  * Refuse a config path that is a symlink (every platform) or writable by
151
193
  * anyone other than the owner.
@@ -216,6 +258,7 @@ export function readDeviceConfig(deps) {
216
258
  defaultCompany: legacyNullDefault
217
259
  ? { slug: "", enabled: false, source: "disabled" }
218
260
  : raw.defaultCompany ?? null,
261
+ ...usageAttributionField(sanitizeUsageAttribution(raw.usageAttribution)),
219
262
  migrationCapability: raw.migrationCapability ?? null,
220
263
  repoCompanyMap: sanitizeRepoCompanyMap(raw.repoCompanyMap),
221
264
  defaultCompanyMemberships: sanitizeMembershipSnapshot(raw.defaultCompanyMemberships),
@@ -287,9 +330,36 @@ export async function setDefaultCompany(slug, deps) {
287
330
  };
288
331
  if (resolvedUid)
289
332
  defaultCompany.uid = resolvedUid;
333
+ const choice = deps.usageAttribution ?? "keep-if-same";
334
+ let usageAttribution;
335
+ const personUid = isPersonUid(deps.personUid) ? deps.personUid : undefined;
336
+ if (choice === "opt-in") {
337
+ if (!resolvedUid?.startsWith("cmp_")) {
338
+ throw new WorkContextError("UsageAttributionUnavailable", `Cannot count usage toward "${trimmed}": its company uid is unknown`);
339
+ }
340
+ if (!personUid) {
341
+ throw new WorkContextError("UsageAttributionUnavailable", `Cannot count usage toward "${trimmed}": the signed-in person could not be determined`);
342
+ }
343
+ usageAttribution = {
344
+ mode: "default-company",
345
+ companyUid: resolvedUid,
346
+ personUid,
347
+ optedInAt: now().toISOString(),
348
+ };
349
+ }
350
+ else if (choice === "keep-if-same") {
351
+ usageAttribution =
352
+ resolvedUid &&
353
+ personUid &&
354
+ prior.usageAttribution?.companyUid === resolvedUid &&
355
+ prior.usageAttribution.personUid === personUid
356
+ ? prior.usageAttribution
357
+ : undefined;
358
+ }
290
359
  const next = {
291
360
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
292
361
  defaultCompany,
362
+ ...usageAttributionField(usageAttribution),
293
363
  migrationCapability: prior.migrationCapability ?? null,
294
364
  repoCompanyMap: prior.repoCompanyMap ?? null,
295
365
  defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
@@ -300,6 +370,69 @@ export async function setDefaultCompany(slug, deps) {
300
370
  return next;
301
371
  });
302
372
  }
373
+ /**
374
+ * Record or remove the usage-attribution opt-in for the current default
375
+ * company. Opting in requires an enabled, explicitly chosen default with a
376
+ * cmp_ uid that equals `companyUid` (the company the person was asked about);
377
+ * an automatic single-company default is never opted in.
378
+ */
379
+ export function setUsageAttribution(optIn, deps) {
380
+ const now = deps.now ?? (() => new Date());
381
+ return withDeviceConfigLock(deps.root, () => {
382
+ const prior = readDeviceConfig(deps);
383
+ let usageAttribution;
384
+ if (optIn) {
385
+ const def = prior.defaultCompany;
386
+ if (!def ||
387
+ def.enabled !== true ||
388
+ def.source !== "chosen" ||
389
+ !def.uid?.startsWith("cmp_") ||
390
+ def.uid !== optIn.companyUid ||
391
+ !isPersonUid(optIn.personUid)) {
392
+ throw new WorkContextError("UsageAttributionUnavailable", "Usage can only be counted toward a default company you chose on this device");
393
+ }
394
+ usageAttribution = {
395
+ mode: "default-company",
396
+ companyUid: def.uid,
397
+ personUid: optIn.personUid,
398
+ optedInAt: now().toISOString(),
399
+ };
400
+ }
401
+ const next = {
402
+ schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
403
+ defaultCompany: prior.defaultCompany ?? null,
404
+ ...usageAttributionField(usageAttribution),
405
+ migrationCapability: prior.migrationCapability ?? null,
406
+ repoCompanyMap: prior.repoCompanyMap ?? null,
407
+ defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
408
+ repairHeldWithDefault: prior.repairHeldWithDefault === true,
409
+ updatedAt: now().toISOString(),
410
+ };
411
+ writeDeviceConfig({ root: deps.root }, next);
412
+ return next;
413
+ });
414
+ }
415
+ /**
416
+ * Drop the usage-attribution opt-in unless it was given by `personUid`. Called
417
+ * on login (identity may have changed) and logout (null: always drop). A
418
+ * config with no opt-in, or one already matching, is left untouched.
419
+ */
420
+ export function retainUsageAttributionForPerson(personUid, deps) {
421
+ if (!fs.existsSync(workContextConfigPath(deps.root)))
422
+ return false;
423
+ return withDeviceConfigLock(deps.root, () => {
424
+ const prior = readDeviceConfig(deps);
425
+ const opt = prior.usageAttribution;
426
+ if (!opt)
427
+ return false;
428
+ if (isPersonUid(personUid) && opt.personUid === personUid)
429
+ return false;
430
+ const now = deps.now ?? (() => new Date());
431
+ const { usageAttribution: _dropped, ...rest } = prior;
432
+ writeDeviceConfig({ root: deps.root }, { ...rest, updatedAt: now().toISOString() });
433
+ return true;
434
+ });
435
+ }
303
436
  export function clearDefaultCompany(deps) {
304
437
  const now = deps.now ?? (() => new Date());
305
438
  return withDeviceConfigLock(deps.root, () => {
@@ -307,6 +440,8 @@ export function clearDefaultCompany(deps) {
307
440
  const next = {
308
441
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
309
442
  defaultCompany: { slug: "", enabled: false, source: "disabled" },
443
+ // usageAttribution is intentionally omitted: clearing the default also
444
+ // withdraws the opt-in to count usage toward it.
310
445
  migrationCapability: prior.migrationCapability ?? null,
311
446
  repoCompanyMap: prior.repoCompanyMap ?? null,
312
447
  defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
@@ -355,6 +490,7 @@ export function setRepoCompany(key, mapping, deps) {
355
490
  const next = {
356
491
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
357
492
  defaultCompany: prior.defaultCompany ?? null,
493
+ ...usageAttributionField(prior.usageAttribution),
358
494
  migrationCapability: prior.migrationCapability ?? null,
359
495
  repoCompanyMap: nextMap,
360
496
  defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
@@ -373,6 +509,7 @@ export function recordMigrationCapabilitySnapshot(snapshot, deps) {
373
509
  const next = {
374
510
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
375
511
  defaultCompany: prior.defaultCompany ?? null,
512
+ ...usageAttributionField(prior.usageAttribution),
376
513
  migrationCapability: {
377
514
  unlocked: snapshot.unlocked,
378
515
  offline: snapshot.offline,
@@ -414,6 +551,7 @@ export function recordDefaultCompanyMemberships(snapshot, deps) {
414
551
  const next = {
415
552
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
416
553
  defaultCompany: prior.defaultCompany ?? null,
554
+ ...usageAttributionField(prior.usageAttribution),
417
555
  migrationCapability: prior.migrationCapability ?? null,
418
556
  repoCompanyMap: prior.repoCompanyMap ?? null,
419
557
  defaultCompanyMemberships: {
@@ -435,6 +573,7 @@ export function invalidateDefaultCompanyMemberships(deps) {
435
573
  const next = {
436
574
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
437
575
  defaultCompany: prior.defaultCompany ?? null,
576
+ ...usageAttributionField(prior.usageAttribution),
438
577
  migrationCapability: prior.migrationCapability ?? null,
439
578
  repoCompanyMap: prior.repoCompanyMap ?? null,
440
579
  defaultCompanyMemberships: null,
@@ -468,6 +607,8 @@ export function setAutoDefaultCompany(membership, deps) {
468
607
  enabled: true,
469
608
  source: "auto_single",
470
609
  },
610
+ // usageAttribution is intentionally omitted: an automatic default is
611
+ // never opted in to usage attribution.
471
612
  migrationCapability: prior.migrationCapability ?? null,
472
613
  repoCompanyMap: prior.repoCompanyMap ?? null,
473
614
  defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
@@ -485,6 +626,7 @@ export function setRepairHeldWithDefault(enabled, deps) {
485
626
  const next = {
486
627
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
487
628
  defaultCompany: prior.defaultCompany ?? null,
629
+ ...usageAttributionField(prior.usageAttribution),
488
630
  migrationCapability: prior.migrationCapability ?? null,
489
631
  repoCompanyMap: prior.repoCompanyMap ?? null,
490
632
  defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Signed-in person identity for the usage-attribution opt-in.
3
+ *
4
+ * Source: the cached Cognito person session (`~/.hq/cognito-tokens.json`,
5
+ * read through `loadCachedTokens`). The person uid is the `custom:entityUid`
6
+ * claim of the ID token (falling back to the access token), and only a
7
+ * `prs_` value counts. Agent (`agt_`) or unknown identities return null, so
8
+ * no opt-in is written or kept for them. Claims are read without
9
+ * verification; this decides only whether local consent belongs to the
10
+ * account that is signed in, never authorization.
11
+ */
12
+ export declare function personUidFromTokens(tokens: {
13
+ idToken?: string;
14
+ accessToken?: string;
15
+ } | null | undefined): string | null;
16
+ //# sourceMappingURL=person-identity.d.ts.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Signed-in person identity for the usage-attribution opt-in.
3
+ *
4
+ * Source: the cached Cognito person session (`~/.hq/cognito-tokens.json`,
5
+ * read through `loadCachedTokens`). The person uid is the `custom:entityUid`
6
+ * claim of the ID token (falling back to the access token), and only a
7
+ * `prs_` value counts. Agent (`agt_`) or unknown identities return null, so
8
+ * no opt-in is written or kept for them. Claims are read without
9
+ * verification; this decides only whether local consent belongs to the
10
+ * account that is signed in, never authorization.
11
+ */
12
+ import { isPersonUid } from "./config.js";
13
+ function claimsOf(token) {
14
+ if (typeof token !== "string")
15
+ return null;
16
+ const part = token.split(".")[1];
17
+ if (!part)
18
+ return null;
19
+ try {
20
+ const parsed = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
21
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
22
+ ? parsed
23
+ : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function personUidFromTokens(tokens) {
30
+ if (!tokens)
31
+ return null;
32
+ for (const token of [tokens.idToken, tokens.accessToken]) {
33
+ const uid = claimsOf(token)?.["custom:entityUid"];
34
+ if (isPersonUid(uid))
35
+ return uid;
36
+ }
37
+ return null;
38
+ }
39
+ //# sourceMappingURL=person-identity.js.map
@@ -186,7 +186,11 @@ export async function emitCliFeatureUsed(usage, timeoutMs = TELEMETRY_TIMEOUT_MS
186
186
  consentBasis,
187
187
  schemaVersion: 1,
188
188
  ...(companyUid ? { companyUid } : {}),
189
- properties: { kind: usage.kind, outcome: usage.outcome },
189
+ properties: {
190
+ kind: usage.kind,
191
+ outcome: usage.outcome,
192
+ version: CLI_VERSION.slice(0, 40),
193
+ },
190
194
  },
191
195
  ],
192
196
  },
@@ -32,7 +32,7 @@
32
32
  */
33
33
  import { isExpiring, loadCachedTokens } from "./cognito-session.js";
34
34
  import { type VersionInfo } from "./feedback-versions.js";
35
- import { type ClientHealthArch, type ClientHealthHeartbeat, type ClientHealthLocalFilesOverview, type ClientHealthPlatform } from "./client-health-contract.js";
35
+ import { type ClientHealthArch, type ClientHealthFailureReason, type ClientHealthHeartbeat, type ClientHealthLocalFilesOverview, type ClientHealthPlatform } from "./client-health-contract.js";
36
36
  export declare const CLIENT_HEALTH_STATE_FILE = "cli-client-health.json";
37
37
  /**
38
38
  * Single-flight marker for the journal observation below. Held only for the
@@ -141,6 +141,8 @@ export interface BuildCliHeartbeatInput {
141
141
  now: Date;
142
142
  platform?: ClientHealthPlatform | null;
143
143
  arch?: ClientHealthArch | null;
144
+ /** Closed, typed reason from the terminal sync runner; never free-form text. */
145
+ failureReason?: ClientHealthFailureReason;
144
146
  /**
145
147
  * Bounded local log/journal file facts (US-016). Only ever attached for
146
148
  * `sync_success` / `sync_failure` kinds — support asked for this on the
@@ -148,6 +150,8 @@ export interface BuildCliHeartbeatInput {
148
150
  */
149
151
  localFilesOverview?: ClientHealthLocalFilesOverview | null;
150
152
  }
153
+ /** Classify sync failures using typed error identity/code fields only. */
154
+ export declare function classifySyncFailure(err: unknown): ClientHealthFailureReason;
151
155
  /**
152
156
  * Build one contract-valid CLI heartbeat, or null when this environment
153
157
  * cannot be represented in the closed contract enums (unknown platform/arch)
@@ -184,7 +188,7 @@ export interface ClientHealthDeps {
184
188
  * ignores its abort signal) must never delay a command past the bound. On
185
189
  * timeout the pending work is abandoned silently.
186
190
  */
187
- export declare function contributeClientHealth(kind: ClientHealthEventKind, deps?: ClientHealthDeps): Promise<void>;
191
+ export declare function contributeClientHealth(kind: ClientHealthEventKind, deps?: ClientHealthDeps, failureReason?: ClientHealthFailureReason): Promise<void>;
188
192
  /**
189
193
  * Fire-once-per-process invocation heartbeat (commander preAction seam, next
190
194
  * to `emitCliSessionStarted`). Reports the real CLI + Core versions, platform,
@@ -194,7 +198,7 @@ export declare function reportCliClientHealthInvocation(deps?: ClientHealthDeps)
194
198
  export interface SyncHealthReport {
195
199
  /** Report the terminal outcome. Bounded, swallowed, safe to await. */
196
200
  succeeded(): Promise<void>;
197
- failed(): Promise<void>;
201
+ failed(reason?: ClientHealthFailureReason): Promise<void>;
198
202
  }
199
203
  /**
200
204
  * Report a CLI sync command run: an attempt heartbeat now (fire-and-forget)
@@ -37,6 +37,7 @@ import { getStateDir, listJournals } from "@indigoai-us/hq-cloud";
37
37
  import { isExpiring, isMachineIdentity, loadCachedTokens, } from "./cognito-session.js";
38
38
  import { vaultApiFetch } from "./vault-api.js";
39
39
  import { collectVersions } from "./feedback-versions.js";
40
+ import { isAuthError } from "./auth-error.js";
40
41
  import { CLIENT_HEALTH_CONTRACT_VERSION, parseClientHealthHeartbeat, } from "./client-health-contract.js";
41
42
  import { collectLocalFilesOverview } from "./local-files-overview.js";
42
43
  /** Same bound as CLI telemetry: a heartbeat may never stall a command. */
@@ -314,6 +315,25 @@ function acquireJournalObservationLock(stateDir, nowMs, staleMs) {
314
315
  }
315
316
  };
316
317
  }
318
+ /** Classify sync failures using typed error identity/code fields only. */
319
+ export function classifySyncFailure(err) {
320
+ if (isAuthError(err))
321
+ return "AUTH_EXPIRED";
322
+ let current = err;
323
+ for (let depth = 0; depth < 3; depth += 1) {
324
+ if (current === null || typeof current !== "object")
325
+ break;
326
+ const value = current;
327
+ if (value.code === "ENOSPC" || value.code === "EDQUOT") {
328
+ return "DISK_FULL";
329
+ }
330
+ if (value.code === "EACCES" || value.code === "EPERM") {
331
+ return "PERMISSION_DENIED";
332
+ }
333
+ current = value.cause;
334
+ }
335
+ return "RUNNER_FAILED";
336
+ }
317
337
  /**
318
338
  * Build one contract-valid CLI heartbeat, or null when this environment
319
339
  * cannot be represented in the closed contract enums (unknown platform/arch)
@@ -388,6 +408,9 @@ export function buildCliHeartbeat(input) {
388
408
  if (input.state.lastSyncAttemptAt !== undefined) {
389
409
  heartbeat.lastSyncAttemptAt = input.state.lastSyncAttemptAt;
390
410
  }
411
+ if (input.kind === "sync_failure" && input.failureReason !== undefined) {
412
+ heartbeat.failureReason = input.failureReason;
413
+ }
391
414
  if (lastSyncSuccessAt !== null) {
392
415
  heartbeat.lastSyncSuccessAt = lastSyncSuccessAt;
393
416
  }
@@ -435,10 +458,10 @@ function settleWithin(work, ms) {
435
458
  * ignores its abort signal) must never delay a command past the bound. On
436
459
  * timeout the pending work is abandoned silently.
437
460
  */
438
- export async function contributeClientHealth(kind, deps = {}) {
439
- await settleWithin(contributeClientHealthUnbounded(kind, deps), deps.timeoutMs ?? CLIENT_HEALTH_TIMEOUT_MS);
461
+ export async function contributeClientHealth(kind, deps = {}, failureReason) {
462
+ await settleWithin(contributeClientHealthUnbounded(kind, deps, failureReason), deps.timeoutMs ?? CLIENT_HEALTH_TIMEOUT_MS);
440
463
  }
441
- async function contributeClientHealthUnbounded(kind, deps) {
464
+ async function contributeClientHealthUnbounded(kind, deps, failureReason) {
442
465
  try {
443
466
  const now = (deps.now ?? (() => new Date()))();
444
467
  const stateDir = (deps.stateDir ?? getStateDir)();
@@ -546,6 +569,7 @@ async function contributeClientHealthUnbounded(kind, deps) {
546
569
  journalLastSyncAt,
547
570
  now,
548
571
  localFilesOverview,
572
+ failureReason,
549
573
  });
550
574
  if (!heartbeat)
551
575
  return;
@@ -587,12 +611,12 @@ export function reportCliClientHealthInvocation(deps) {
587
611
  */
588
612
  export function beginSyncHealthReport(deps = {}) {
589
613
  const attempt = contributeClientHealth("sync_attempt", deps);
590
- const finish = (kind) => attempt
591
- .then(() => contributeClientHealth(kind, deps))
614
+ const finish = (kind, failureReason) => attempt
615
+ .then(() => contributeClientHealth(kind, deps, failureReason))
592
616
  .catch(() => undefined);
593
617
  return {
594
618
  succeeded: () => finish("sync_success"),
595
- failed: () => finish("sync_failure"),
619
+ failed: (reason) => finish("sync_failure", reason),
596
620
  };
597
621
  }
598
622
  //# sourceMappingURL=client-health.js.map
@@ -278,7 +278,8 @@ type VersionGatePhase = "preflight" | "lock_acquired" | "installer_running" | "v
278
278
  /**
279
279
  * Poll until the install tree has been continuously READY for `quietMs`, or the
280
280
  * wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
281
- * pid, (ii) the missing target is present, and — when `packageRoot` is known —
281
+ * pid and no live recorded updater while the tree is in flight, (ii) the missing
282
+ * target is present, and — when `packageRoot` is known —
282
283
  * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
283
284
  * `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
284
285
  * its condition "not ready" rather than throwing.
@@ -295,20 +296,22 @@ type VersionGatePhase = "preflight" | "lock_acquired" | "installer_running" | "v
295
296
  * any installer before attempting a repair. Any observed signature change
296
297
  * keeps the ordinary settle deadline in force. `deadlineMs === 0` evaluates
297
298
  * readiness once.
298
- * - LOCKED time — wall time observed under a fresh foreign lock held by a live
299
- * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
300
- * 10-minute staleness window) and does NOT consume the settle budget. When
301
- * that ceiling is reached with the lock still held, the result is unsettled
302
- * with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
303
- * outcome and remedy rather than telling the user to reinstall over a live
304
- * writer.
305
- * Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
306
- * unlocked bucket by whether a fresh foreign lock was held when the interval
307
- * began, so a starved event loop only makes the wait coarser, never unbounded. A
299
+ * - WRITER time — wall time observed under a fresh foreign lock, or while a
300
+ * recorded foreign updater pid is alive and the tree has an in-flight signal
301
+ * — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
302
+ * 10-minute staleness window) and does NOT consume the settle budget. This
303
+ * also covers a still-running updater whose lock became stale by age. When
304
+ * that ceiling is reached, the result is unsettled; `lockHeldAtEnd` remains
305
+ * reserved for a fresh lock held at the end.
306
+ * Each poll's REAL elapsed interval (now() deltas) is attributed to the writer or
307
+ * unlocked bucket by whether a fresh lock or qualifying live updater was observed
308
+ * when the interval began, so a starved event loop only makes the wait coarser,
309
+ * never unbounded. A
308
310
  * stale lock (dead pid, older than the staleness window, unparseable) or our own
309
- * pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
311
+ * pid never extends anything by itself. The whole wait is bounded (≤ `deadlineMs +
310
312
  * lockWaitMs`), and the unchanged-tree path normally returns after the much
311
- * shorter `staleProbeMs` observation window.
313
+ * shorter `staleProbeMs` observation window unless a live updater pid is still
314
+ * present with an in-flight tree signal.
312
315
  */
313
316
  export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
314
317
  export interface InstallRewriteSignalArgs {
@@ -714,6 +714,28 @@ function updaterPidAlive(lockPath, fs, isPidAlive) {
714
714
  return "unknown";
715
715
  }
716
716
  }
717
+ /** Whether the lock names a live process other than this CLI invocation. */
718
+ function liveForeignUpdaterPid(lockPath, fs, isPidAlive) {
719
+ try {
720
+ const lock = JSON.parse(fs.readFileSync(lockPath, "utf-8"));
721
+ if (typeof lock.startedAt !== "string" ||
722
+ Number.isNaN(Date.parse(lock.startedAt)) ||
723
+ typeof lock.tool !== "string" ||
724
+ lock.tool.length === 0 ||
725
+ typeof lock.version !== "string" ||
726
+ lock.version.length === 0) {
727
+ return false;
728
+ }
729
+ return typeof lock.pid === "number" &&
730
+ Number.isSafeInteger(lock.pid) &&
731
+ lock.pid > 0 &&
732
+ lock.pid !== process.pid &&
733
+ isPidAlive(lock.pid);
734
+ }
735
+ catch {
736
+ return false;
737
+ }
738
+ }
717
739
  /** Snapshot retired sibling trees so changes below their root stay observable. */
718
740
  function retiredSiblingActivitySignature(packageRoot, fs) {
719
741
  const { leaf } = cliNameParts();
@@ -917,7 +939,8 @@ const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
917
939
  /**
918
940
  * Poll until the install tree has been continuously READY for `quietMs`, or the
919
941
  * wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
920
- * pid, (ii) the missing target is present, and — when `packageRoot` is known —
942
+ * pid and no live recorded updater while the tree is in flight, (ii) the missing
943
+ * target is present, and — when `packageRoot` is known —
921
944
  * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
922
945
  * `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
923
946
  * its condition "not ready" rather than throwing.
@@ -934,20 +957,22 @@ const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
934
957
  * any installer before attempting a repair. Any observed signature change
935
958
  * keeps the ordinary settle deadline in force. `deadlineMs === 0` evaluates
936
959
  * readiness once.
937
- * - LOCKED time — wall time observed under a fresh foreign lock held by a live
938
- * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
939
- * 10-minute staleness window) and does NOT consume the settle budget. When
940
- * that ceiling is reached with the lock still held, the result is unsettled
941
- * with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
942
- * outcome and remedy rather than telling the user to reinstall over a live
943
- * writer.
944
- * Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
945
- * unlocked bucket by whether a fresh foreign lock was held when the interval
946
- * began, so a starved event loop only makes the wait coarser, never unbounded. A
960
+ * - WRITER time — wall time observed under a fresh foreign lock, or while a
961
+ * recorded foreign updater pid is alive and the tree has an in-flight signal
962
+ * — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
963
+ * 10-minute staleness window) and does NOT consume the settle budget. This
964
+ * also covers a still-running updater whose lock became stale by age. When
965
+ * that ceiling is reached, the result is unsettled; `lockHeldAtEnd` remains
966
+ * reserved for a fresh lock held at the end.
967
+ * Each poll's REAL elapsed interval (now() deltas) is attributed to the writer or
968
+ * unlocked bucket by whether a fresh lock or qualifying live updater was observed
969
+ * when the interval began, so a starved event loop only makes the wait coarser,
970
+ * never unbounded. A
947
971
  * stale lock (dead pid, older than the staleness window, unparseable) or our own
948
- * pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
972
+ * pid never extends anything by itself. The whole wait is bounded (≤ `deadlineMs +
949
973
  * lockWaitMs`), and the unchanged-tree path normally returns after the much
950
- * shorter `staleProbeMs` observation window.
974
+ * shorter `staleProbeMs` observation window unless a live updater pid is still
975
+ * present with an in-flight tree signal.
951
976
  */
952
977
  export async function waitForInstallTreeSettled(args) {
953
978
  const now = args.now ?? Date.now;
@@ -967,17 +992,21 @@ export async function waitForInstallTreeSettled(args) {
967
992
  let sawLock = false;
968
993
  let sawRetired = false;
969
994
  let lockedMs = 0;
995
+ let writerMs = 0;
970
996
  let unlockedMs = 0;
971
997
  let lockTool = "";
972
998
  let prevT = start;
973
999
  let prevForeignLockHeld = false;
1000
+ let prevWriterHeld = false;
974
1001
  let lockExtendedFired = false;
975
1002
  for (;;) {
976
1003
  const t = now();
977
- // Attribute the interval that just elapsed [prevT, t) to the bucket for the
978
- // lock state that held when it BEGAN (real now() deltas, never nominal pollMs).
1004
+ // Attribute the interval that just elapsed [prevT, t) using the state that
1005
+ // held when it BEGAN (real now() deltas, never nominal pollMs).
979
1006
  if (prevForeignLockHeld)
980
1007
  lockedMs += t - prevT;
1008
+ if (prevWriterHeld)
1009
+ writerMs += t - prevT;
981
1010
  else
982
1011
  unlockedMs += t - prevT;
983
1012
  const lock = freshForeignLock(args.lockPath, t, isPidAlive, fs);
@@ -1015,7 +1044,10 @@ export async function waitForInstallTreeSettled(args) {
1015
1044
  retiredCheckFailed = true;
1016
1045
  }
1017
1046
  }
1018
- let ready = !foreignLockHeld && installTargetPresent(args.target, fs);
1047
+ const inFlightTree = sawLock || sawRetired || sawTreeChange;
1048
+ const updaterPidHeld = inFlightTree && liveForeignUpdaterPid(args.lockPath, fs, isPidAlive);
1049
+ const writerHeld = foreignLockHeld || updaterPidHeld;
1050
+ let ready = !writerHeld && installTargetPresent(args.target, fs);
1019
1051
  if (ready && args.packageRoot) {
1020
1052
  try {
1021
1053
  if (!packageRootHealthy(args.packageRoot, fs))
@@ -1055,6 +1087,7 @@ export async function waitForInstallTreeSettled(args) {
1055
1087
  }
1056
1088
  if (!ready &&
1057
1089
  !foreignLockHeld &&
1090
+ !updaterPidHeld &&
1058
1091
  !retiredCheckFailed &&
1059
1092
  !sawLock &&
1060
1093
  !sawTreeChange &&
@@ -1078,14 +1111,13 @@ export async function waitForInstallTreeSettled(args) {
1078
1111
  };
1079
1112
  }
1080
1113
  // Give up when the TREE has had a full unlocked budget with no writer visible,
1081
- // OR a writer is STILL holding the lock and has held it past its own staleness
1082
- // ceiling. The lock ceiling is gated on a lock being held RIGHT NOW so that a
1083
- // ceiling of 0 (HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS=0, "do not extend under a
1084
- // lock") disables only the extension and does not collapse the ordinary
1085
- // deadlineMs settle when no lock exists — `lockedMs` is 0 ≥ 0 on every poll.
1114
+ // OR a qualifying writer has exceeded the shared writer ceiling. The writer
1115
+ // ceiling only extends while a fresh lock or in-flight live PID is present, so
1116
+ // a ceiling of 0 disables only that extension and does not collapse the normal
1117
+ // deadlineMs settle when no writer exists.
1086
1118
  // `deadlineMs` 0 makes the unlocked test fire on the first poll (evaluate-once),
1087
1119
  // matching the pre-lock-aware contract; `lockHeldAtEnd` distinguishes the causes.
1088
- if (unlockedMs >= deadlineMs || (foreignLockHeld && lockedMs >= lockWaitMs)) {
1120
+ if (unlockedMs >= deadlineMs || (writerHeld && writerMs >= lockWaitMs)) {
1089
1121
  return {
1090
1122
  settled: false,
1091
1123
  waitedMs,
@@ -1102,15 +1134,15 @@ export async function waitForInstallTreeSettled(args) {
1102
1134
  updaterPidAlive: updaterPidAlive(args.lockPath, fs, isPidAlive),
1103
1135
  };
1104
1136
  }
1105
- // The first poll at which the wait has reached `deadlineMs` while a lock is
1106
- // still held is exactly where the pre-lock-aware wait gave up — announce the
1107
- // extension once, and only when we are genuinely going to keep waiting.
1108
- if (!lockExtendedFired && foreignLockHeld && waitedMs >= deadlineMs) {
1137
+ // Announce once when the deadline has passed and a qualifying writer is why
1138
+ // the bounded wait continues.
1139
+ if (!lockExtendedFired && writerHeld && waitedMs >= deadlineMs) {
1109
1140
  lockExtendedFired = true;
1110
1141
  args.onLockExtended?.();
1111
1142
  }
1112
1143
  prevT = t;
1113
1144
  prevForeignLockHeld = foreignLockHeld;
1145
+ prevWriterHeld = writerHeld;
1114
1146
  await sleep(pollMs);
1115
1147
  }
1116
1148
  }
@@ -1,4 +1,4 @@
1
- export type ResidentPackagePath = "agent-kit" | "agent-mcp" | "auth-login" | "agents-terminal" | "bot" | "bot-logs-follow" | "childWrapper" | "coreJobRun" | "login" | "mcp" | "daemon" | "daemon-logs-follow" | "mesh-daemon" | "lanes-watch" | "monitor-wait" | "monitor-run" | "agents-message-wait" | "integrations-oauth-wait" | "secrets-sandbox" | "pkg-update" | "pack-install";
1
+ export type ResidentPackagePath = "agent-kit" | "agent-mcp" | "auth-login" | "agents-terminal" | "bot" | "bot-logs-follow" | "childWrapper" | "coreJobRun" | "daemon-call" | "login" | "mcp" | "daemon" | "daemon-logs-follow" | "mesh-daemon" | "lanes-watch" | "monitor-wait" | "monitor-run" | "agents-message-wait" | "integrations-oauth-wait" | "outpost-wait" | "secrets-sandbox" | "pkg-update" | "pack-install";
2
2
  /** Runtime modules that resident commands may need after a wait starts. */
3
3
  export declare const RESIDENT_PACKAGE_MODULES: Record<ResidentPackagePath, readonly string[]>;
4
4
  export declare class ResidentPackagePreloadError extends Error {