@indigoai-us/hq-cli 5.145.0 → 5.146.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.146.0] — 2026-09-23
6
+
7
+ ### Fixed
8
+
9
+ - The mesh daemon asks for presence in the device-default company and the companies with a session updated in the last day (at most five), and asks again within a minute when that set changes. `hq mesh doctor` says when a company was left out because presence is off or because one credential cannot cover every company.
10
+ - `hq mesh project ensure` closes a genesis thread only when that same call created it and register returns a different server `proj-` thread. An already-open project thread stays open on a re-run. The register route does not accept a client thread id.
11
+ - `hq mesh project ensure` on a new project closes the client genesis thread when register returns the server `proj-` thread, so the project has one open thread. The register route does not accept a client thread id.
12
+ - A session that registers or creates a project is bound to it. `hq mesh project ensure` and `hq mesh project set --create` bind the calling session when it is the same company and still `needs_project` or unresolved, and they leave a session that is already on a different project alone. The next prompt re-checks `needs_project` against the current catalog. `hq mesh context prd-sync --session <sid> --file <prd.json>` emits `task_status` when a bound session's story `passes` or `status` changes (`passes: true` is done, `in_progress` is in progress) and does not call the network when nothing changed.
13
+ - `hq report publish` now fills a report's missing company from its channel and keeps it across updates. Before, a report saved without a company was published under a personal access policy and the deploy refused it.
14
+
5
15
  ## [5.145.0] — 2026-09-23
6
16
 
7
17
  - `hq files` now preserves a trailing-slash private-folder pattern such as
@@ -58,6 +68,7 @@
58
68
  ### Fixed
59
69
 
60
70
  - `hq lanes monitor-check` now recognizes a live Claude Monitor task launched through the CLI's shell/node process wrapper, so active lanes are not reported as uncovered.
71
+
61
72
  - A session hint now binds to the best existing project. An exact Board is used as-is. An exact local PRD gets a Board through the same genesis-thread, view, then register path as `project ensure`, and is bound. A single clear match is bound with a recorded reason. When nothing matches, or two candidates are close, the session stays `needs_project` and the start hook lists the candidates. `hq mesh context bind-project` applies the answer. A flush HTTP 404 downgrades that session, holds its events, and logs one line instead of dead-lettering them. `hq mesh doctor` lists `needs_project` sessions.
62
73
 
63
74
  ## [5.139.0] — 2026-09-23
@@ -5882,6 +5882,24 @@ export declare const COMMAND_CATALOG: readonly [{
5882
5882
  readonly description: "Print machine-readable JSON";
5883
5883
  }];
5884
5884
  readonly subcommands: readonly [];
5885
+ }, {
5886
+ readonly name: "prd-sync";
5887
+ readonly description: "Emit task_status for PRD story passes/status changes on a bound session";
5888
+ readonly aliases: readonly [];
5889
+ readonly hidden: false;
5890
+ readonly usage: "[options]";
5891
+ readonly arguments: readonly [];
5892
+ readonly options: readonly [{
5893
+ readonly flags: "--session <sid>";
5894
+ readonly description: "Canonical session id";
5895
+ }, {
5896
+ readonly flags: "--file <path>";
5897
+ readonly description: "prd.json written by the bound session";
5898
+ }, {
5899
+ readonly flags: "--json";
5900
+ readonly description: "Print machine-readable JSON";
5901
+ }];
5902
+ readonly subcommands: readonly [];
5885
5903
  }, {
5886
5904
  readonly name: "organize";
5887
5905
  readonly description: "List or submit one-time project/task decisions (US-007B / US-005B)";
@@ -7593,6 +7593,29 @@ export const COMMAND_CATALOG = [
7593
7593
  ],
7594
7594
  "subcommands": []
7595
7595
  },
7596
+ {
7597
+ "name": "prd-sync",
7598
+ "description": "Emit task_status for PRD story passes/status changes on a bound session",
7599
+ "aliases": [],
7600
+ "hidden": false,
7601
+ "usage": "[options]",
7602
+ "arguments": [],
7603
+ "options": [
7604
+ {
7605
+ "flags": "--session <sid>",
7606
+ "description": "Canonical session id"
7607
+ },
7608
+ {
7609
+ "flags": "--file <path>",
7610
+ "description": "prd.json written by the bound session"
7611
+ },
7612
+ {
7613
+ "flags": "--json",
7614
+ "description": "Print machine-readable JSON"
7615
+ }
7616
+ ],
7617
+ "subcommands": []
7618
+ },
7596
7619
  {
7597
7620
  "name": "organize",
7598
7621
  "description": "List or submit one-time project/task decisions (US-007B / US-005B)",
@@ -12,7 +12,7 @@ import chalk from "chalk";
12
12
  import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
13
13
  import * as readline from "node:readline/promises";
14
14
  import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
15
- import { ensureProjectBoard } from "../lib/mesh/project-board.js";
15
+ import { closeSupersededProjectThread, ensureProjectBoard } from "../lib/mesh/project-board.js";
16
16
  import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
17
17
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
18
18
  import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
@@ -39,6 +39,9 @@ import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-
39
39
  import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
40
40
  import { readSessionState, updateSessionState } from "../lib/work-context/state.js";
41
41
  import { findLocalPrd, listNeedsProjectSessions } from "../lib/work-context/bind-best.js";
42
+ import { bindCallingSession, formatSessionBoundLine } from "../lib/work-context/bind-calling-session.js";
43
+ import { rebindNeedsProjectOnPrompt } from "../lib/work-context/prompt-rebind.js";
44
+ import { planPrdSync } from "../lib/work-context/prd-sync.js";
42
45
  import { WORK_CONTEXT_CONTRACT_VERSION } from "../lib/work-context/contract.js";
43
46
  import { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "../lib/work-context/reconcile.js";
44
47
  import { resolveCompany } from "../lib/work-context/company.js";
@@ -388,6 +391,9 @@ async function runProjectSet(projectId, opts) {
388
391
  throw err;
389
392
  }
390
393
  }
394
+ if (!current && opts.create) {
395
+ noteCallingSessionBound(view.projectId, company);
396
+ }
391
397
  if (opts.json) {
392
398
  console.log(JSON.stringify({ ok: true, action: "project.set", principalUid, ...view }, null, 2));
393
399
  return;
@@ -488,6 +494,13 @@ async function runProjectEnsure(projectId, opts) {
488
494
  }
489
495
  }
490
496
  registration = await registerMeshProject(token, company.companyUid, projectId);
497
+ await closeSupersededProjectThread({
498
+ token,
499
+ companyUid: company.companyUid,
500
+ openedThreadId: genesis.threadId,
501
+ canonicalThreadId: registration.threadId,
502
+ created: genesis.created,
503
+ });
491
504
  if (!registration.threadId || !registration.channelId) {
492
505
  fail("Work mesh registration did not return a threadId and channelId.");
493
506
  }
@@ -519,12 +532,91 @@ async function runProjectEnsure(projectId, opts) {
519
532
  version: verified.version,
520
533
  stories: verified.stories,
521
534
  };
535
+ noteCallingSessionBound(payload.projectId, company);
522
536
  if (opts.json) {
523
537
  console.log(JSON.stringify(payload));
524
538
  return;
525
539
  }
526
540
  console.log(`Work mesh: project ${payload.projectId} ensured (thread ${payload.threadId}, channel ${payload.channelId}, ${payload.stories.length} stories)`);
527
541
  }
542
+ async function runPrdSync(opts) {
543
+ const sessionId = opts.session.trim();
544
+ const file = opts.file.trim();
545
+ if (!sessionId || !file)
546
+ fail("Usage: hq mesh context prd-sync --session <sid> --file <prd.json>");
547
+ const root = workContextHomeRoot();
548
+ const plan = planPrdSync({ sessionId, file, root });
549
+ if (!plan.changed) {
550
+ const payload = { ok: true, action: "context.prd-sync", changed: 0, ...(plan.skip ? { skip: plan.skip } : {}) };
551
+ if (opts.json)
552
+ console.log(JSON.stringify(payload));
553
+ else
554
+ console.log(plan.skip ? `Work mesh: prd-sync skipped (${plan.skip})` : "Work mesh: prd-sync unchanged");
555
+ return;
556
+ }
557
+ let token;
558
+ try {
559
+ token = await requireToken();
560
+ }
561
+ catch {
562
+ token = null;
563
+ }
564
+ if (!token) {
565
+ console.error("work-mesh: prd-sync has changes but no token; snapshot not advanced");
566
+ return;
567
+ }
568
+ const events = plan.events.map((event, index) => buildMeshEmitEvent({
569
+ eventId: generateUlid(Date.now() + index),
570
+ kind: "task_status",
571
+ sessionId,
572
+ harness: "hq-sessions",
573
+ adapterVersion: "1.0.0",
574
+ at: new Date().toISOString(),
575
+ seq: plan.seqStart + index + 1,
576
+ source: "hooks",
577
+ taskId: event.taskId,
578
+ status: event.status,
579
+ summary: event.status === "done" ? `${event.taskId} passes` : `${event.taskId} in progress`,
580
+ }));
581
+ const poster = createEmitPoster({ token, baseUrl: resolveVaultApiBase(process.env) });
582
+ await emitEvents({
583
+ workMeshRoot: workMeshHomeRoot(),
584
+ poster,
585
+ newEvents: events,
586
+ });
587
+ plan.commit();
588
+ if (plan.taskId) {
589
+ updateSessionState(sessionId, root, (current) => {
590
+ if (!current)
591
+ return null;
592
+ return {
593
+ ...current,
594
+ taskId: plan.taskId,
595
+ contextStatus: current.projectId ? "bound" : current.contextStatus,
596
+ updatedAt: new Date().toISOString(),
597
+ };
598
+ });
599
+ }
600
+ if (opts.json) {
601
+ console.log(JSON.stringify({ ok: true, action: "context.prd-sync", changed: plan.events.length, taskId: plan.taskId }));
602
+ return;
603
+ }
604
+ console.log(`Work mesh: prd-sync emitted ${plan.events.length} task_status`);
605
+ }
606
+ function noteCallingSessionBound(projectId, company) {
607
+ try {
608
+ const bound = bindCallingSession({
609
+ root: workContextHomeRoot(),
610
+ projectId,
611
+ company,
612
+ });
613
+ if (bound.bound)
614
+ console.error(formatSessionBoundLine(bound.sessionId, bound.projectId));
615
+ }
616
+ catch {
617
+ /* session bind is local and must not fail project ensure */
618
+ }
619
+ }
528
620
  async function runDoctor(opts) {
529
621
  if (opts.apply) {
530
622
  fail("`hq mesh doctor --apply` (paced Board PUTs from local prd.json) is not in the CLI yet.\n" +
@@ -1327,6 +1419,21 @@ async function runSessionEnqueue(cliKind, opts) {
1327
1419
  const sessionId = resolveEnqueueSessionId(opts.sessionId, process.env);
1328
1420
  if (!sessionId)
1329
1421
  fail("sessionId required (--session-id or HQ_SESSION_ID)");
1422
+ if (kind === "turn_start") {
1423
+ try {
1424
+ rebindNeedsProjectOnPrompt({
1425
+ sessionId,
1426
+ root: workContextHomeRoot(),
1427
+ hqRoot: opts.hqRoot?.trim() || process.env.HQ_ROOT,
1428
+ cwd: opts.cwd,
1429
+ companySlug: opts.companySlug,
1430
+ env: process.env,
1431
+ });
1432
+ }
1433
+ catch {
1434
+ /* prompt rebind is fail-soft */
1435
+ }
1436
+ }
1330
1437
  const evidence = buildEmitEvidence({
1331
1438
  sessionId: sessionId,
1332
1439
  hqRoot: opts.hqRoot,
@@ -1674,6 +1781,13 @@ export function registerMeshCommand(program) {
1674
1781
  .option("--company <slug|uid>", "Company override when creating a Board")
1675
1782
  .option("--json", "Print machine-readable JSON")
1676
1783
  .action((target, slug, opts) => wrap(() => runBindProject(target, { ...opts, slug }))());
1784
+ context
1785
+ .command("prd-sync")
1786
+ .description("Emit task_status for PRD story passes/status changes on a bound session")
1787
+ .requiredOption("--session <sid>", "Canonical session id")
1788
+ .requiredOption("--file <path>", "prd.json written by the bound session")
1789
+ .option("--json", "Print machine-readable JSON")
1790
+ .action((opts) => wrap(() => runPrdSync(opts))());
1677
1791
  context
1678
1792
  .command("organize")
1679
1793
  .description("List or submit one-time project/task decisions (US-007B / US-005B)")
@@ -8,6 +8,12 @@ type ChannelRow = {
8
8
  type ReportPatchInput = ReportPatch | ((current: ChannelReport) => ReportPatch);
9
9
  /** Any company-owned channel (company or project scope) can carry reports; personal channels cannot. */
10
10
  export declare function isCompanyReportChannel(channel: ChannelRow): boolean;
11
+ /**
12
+ * Records created before hq-pro stamped companyUid carry null. The channel the
13
+ * caller can see knows its company, and publish needs it to pick the deploy
14
+ * org, so fill it from there rather than failing every publish.
15
+ */
16
+ export declare function withChannelCompany(report: ChannelReport, channels: ChannelRow[]): ChannelReport;
11
17
  /** Explicit context has priority; discovery is the authenticated fallback for normal CLI use. */
12
18
  export declare function resolveReport(token: string, reportId: string, requestedChannelId?: string): Promise<ChannelReport>;
13
19
  export declare function patchReport(token: string, report: ChannelReport, patch: ReportPatchInput): Promise<ChannelReport>;
@@ -117,6 +117,17 @@ async function readReportInChannel(token, channelId, reportId) {
117
117
  throw new ReportPublishError("Report response was incomplete.");
118
118
  return body.report;
119
119
  }
120
+ /**
121
+ * Records created before hq-pro stamped companyUid carry null. The channel the
122
+ * caller can see knows its company, and publish needs it to pick the deploy
123
+ * org, so fill it from there rather than failing every publish.
124
+ */
125
+ export function withChannelCompany(report, channels) {
126
+ if (report.companyUid)
127
+ return report;
128
+ const companyUid = channels.find((row) => row.channelId === report.channelId)?.companyUid;
129
+ return companyUid ? { ...report, companyUid } : report;
130
+ }
120
131
  /** Explicit context has priority; discovery is the authenticated fallback for normal CLI use. */
121
132
  export async function resolveReport(token, reportId, requestedChannelId) {
122
133
  const explicit = requestedChannelId ?? process.env.HQ_REPORT_CHANNEL_ID;
@@ -129,14 +140,14 @@ export async function resolveReport(token, reportId, requestedChannelId) {
129
140
  const body = await res.json();
130
141
  if (!body.report)
131
142
  throw new ReportPublishError("Report response was incomplete.");
132
- return body.report;
143
+ return withChannelCompany(body.report, channels);
133
144
  }
134
145
  const recordedChannel = locallyRecordedChannel(reportId);
135
146
  if (recordedChannel) {
136
147
  requireCompanyReportChannel(channels, recordedChannel);
137
148
  const recorded = await readReportInChannel(token, recordedChannel, reportId);
138
149
  if (recorded)
139
- return recorded;
150
+ return withChannelCompany(recorded, channels);
140
151
  }
141
152
  // The report API is intentionally channel-scoped. Discover only among the
142
153
  // caller's existing channels, using list endpoints so missing reports never
@@ -157,13 +168,13 @@ export async function resolveReport(token, reportId, requestedChannelId) {
157
168
  const reports = Array.isArray(body.reports) ? body.reports : [];
158
169
  const match = reports.find((report) => report.reportId === reportId);
159
170
  if (match)
160
- return match;
171
+ return withChannelCompany(match, channels);
161
172
  }
162
173
  throw new ReportPublishError(notFoundMessage(scannedChannels));
163
174
  }
164
175
  export async function patchReport(token, report, patch) {
165
176
  if (!report.companyUid)
166
- throw new ReportPublishError("Channel reports require a company channel.");
177
+ throw new ReportPublishError("Channel reports require a company channel.", "source-invalid");
167
178
  const patchOnce = (current) => vaultApiFetch({
168
179
  token,
169
180
  path: reportPath(current.channelId, current.reportId),
@@ -279,7 +290,14 @@ export async function deployReportStatic(token, input) {
279
290
  }
280
291
  async function publishWithOrg(token, report, directory, draft) {
281
292
  const published = await publishReport(report, directory, draft, {
282
- patch: (current, patch) => patchReport(token, current, patch),
293
+ // The server echoes the stored record, whose companyUid may still be null
294
+ // on reports created before hq-pro stamped it. Keep the company resolved
295
+ // from the channel so the access policy and deploy org stay correct.
296
+ patch: async (current, patch) => {
297
+ const next = await patchReport(token, current, patch);
298
+ const companyUid = next.companyUid ?? current.companyUid ?? report.companyUid;
299
+ return companyUid ? { ...next, companyUid } : next;
300
+ },
283
301
  deploy: async (input) => {
284
302
  const org = await orgFor(token, report);
285
303
  return deployReportStatic(token, { ...input, org });
@@ -18,6 +18,12 @@ export declare const REFUSED_RETRY_ENV = "HQ_MESH_DAEMON_REFUSED_RETRY_MS";
18
18
  export declare const MAX_TIMER_DELAY_MS = 2147483647;
19
19
  /** Cap any single retry so a bogus Retry-After cannot park the daemon. */
20
20
  export declare const MAX_RETRY_DELAY_MS: number;
21
+ /** Contract-3 vend accepts at most this many preferred company uids. */
22
+ export declare const PREFERRED_COMPANY_LIMIT = 5;
23
+ /** Session files older than this do not count as companies in use. */
24
+ export declare const PREFERRED_SESSION_WINDOW_MS: number;
25
+ /** Preferred-set changes re-vend at most this often. */
26
+ export declare const PREFERRED_REVEND_MIN_MS = 60000;
21
27
  export type CredentialVendFailureKind = "refused" | "transient";
22
28
  /**
23
29
  * Typed vend failure. `refused` = server will not vend right now
@@ -51,6 +57,8 @@ export interface PresenceCompany {
51
57
  }
52
58
  export interface DroppedPresenceCompany {
53
59
  companyUid: string;
60
+ /** Short name for doctor text when the server sent one. */
61
+ label?: string;
54
62
  /** Server-supplied cause when available; otherwise a deliberately neutral label. */
55
63
  reason: string;
56
64
  }
@@ -98,6 +106,30 @@ export declare function normalizeContract3Bundle(raw: unknown): Contract3Bundle;
98
106
  * No identity file → return the bundle unchanged (person laptop).
99
107
  */
100
108
  export declare function scopeBundleToAgentIdentity(bundle: Contract3Bundle, env?: NodeJS.ProcessEnv): Contract3Bundle;
109
+ /**
110
+ * Device default first, then distinct company uids from session files updated
111
+ * in the last 24 hours, newest first, max 5. Any read error returns undefined
112
+ * so the vend body omits the field.
113
+ */
114
+ export declare function readPreferredCompanyUids(input: {
115
+ workContextRoot: string;
116
+ nowMs?: number;
117
+ }): string[] | undefined;
118
+ export declare function preferredCompanyKey(uids: readonly string[] | undefined): string;
119
+ /** True when a new preferred set should force a vend, at most once per minute. */
120
+ export declare function shouldRevendPreferredCompanies(input: {
121
+ previous: readonly string[] | undefined;
122
+ next: readonly string[] | undefined;
123
+ lastVendMs: number;
124
+ nowMs: number;
125
+ minIntervalMs?: number;
126
+ }): boolean;
127
+ export declare function contract3VendBody(preferredCompanyUids: readonly string[] | undefined): {
128
+ contractVersion: 3;
129
+ preferredCompanyUids?: string[];
130
+ };
131
+ /** Plain doctor line for one company the vend left out. */
132
+ export declare function formatDroppedPresenceCompany(company: DroppedPresenceCompany): string;
101
133
  export type CredentialVendPostResult = {
102
134
  status: number;
103
135
  body: unknown;
@@ -110,6 +142,8 @@ export declare function createContract3Fetcher(opts: {
110
142
  post?: (path: string, body: unknown) => Promise<CredentialVendPostResult>;
111
143
  /** Process env for identity-file scoping (tests). */
112
144
  env?: NodeJS.ProcessEnv;
145
+ /** Companies to ask for. Undefined (read error) omits the field. */
146
+ readPreferredCompanyUids?: () => string[] | undefined;
113
147
  }): CredentialsFetcher;
114
148
  export interface CredentialRenewalErrorInfo {
115
149
  /** Absolute ISO timestamp when the renewal retry is scheduled to fire. */
@@ -130,8 +164,11 @@ export declare class CredentialRenewalManager {
130
164
  private readonly random;
131
165
  private handle;
132
166
  private stopped;
167
+ private inflight;
133
168
  constructor(fetchCredentials: CredentialsFetcher, onRenewed: (bundle: Contract3Bundle) => void, onError?: (err: unknown, info?: CredentialRenewalErrorInfo) => void, timers?: TimerHost, retryDelayMs?: number, refusedRetryMs?: number, random?: () => number);
134
169
  schedule(bundle: Contract3Bundle): void;
170
+ /** Vend now (preferred companies changed). No-op while a vend is in flight. */
171
+ renewNow(): void;
135
172
  private renew;
136
173
  stop(): void;
137
174
  private clear;
@@ -2,9 +2,13 @@
2
2
  * Contract-3 realtime credential vend + 80%-of-lifetime renewal.
3
3
  * Never logs or persists credential material.
4
4
  */
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
5
7
  import { DEFAULT_VAULT_API_URL } from "../../../../utils/cognito-session.js";
6
8
  import { vaultApiFetch } from "../../../../utils/vault-api.js";
7
9
  import { readAgentIdentityCompanyUid } from "../../../work-context/company.js";
10
+ import { readDeviceConfig } from "../../../work-context/config.js";
11
+ import { workContextSessionsDir } from "../../../work-context/paths.js";
8
12
  export const REALTIME_CREDENTIALS_PATH = "/v1/realtime/credentials";
9
13
  export const MQTT_KEEPALIVE_SECONDS = 15;
10
14
  /** Renew at 80% of credential lifetime. */
@@ -20,6 +24,12 @@ export const REFUSED_RETRY_ENV = "HQ_MESH_DAEMON_REFUSED_RETRY_MS";
20
24
  export const MAX_TIMER_DELAY_MS = 2_147_483_647;
21
25
  /** Cap any single retry so a bogus Retry-After cannot park the daemon. */
22
26
  export const MAX_RETRY_DELAY_MS = 24 * 60 * 60 * 1000;
27
+ /** Contract-3 vend accepts at most this many preferred company uids. */
28
+ export const PREFERRED_COMPANY_LIMIT = 5;
29
+ /** Session files older than this do not count as companies in use. */
30
+ export const PREFERRED_SESSION_WINDOW_MS = 24 * 60 * 60 * 1000;
31
+ /** Preferred-set changes re-vend at most this often. */
32
+ export const PREFERRED_REVEND_MIN_MS = 60_000;
23
33
  /**
24
34
  * Typed vend failure. `refused` = server will not vend right now
25
35
  * (FEATURE_DISABLED / REALTIME_CONTRACT_UNSUPPORTED / 403); daemon should
@@ -241,8 +251,14 @@ export function normalizeContract3Bundle(raw) {
241
251
  const companyUid = typeof row.companyUid === "string" ? row.companyUid.trim() : "";
242
252
  if (!companyUid)
243
253
  continue;
254
+ const slug = typeof row.companySlug === "string"
255
+ ? row.companySlug.trim()
256
+ : typeof row.slug === "string"
257
+ ? row.slug.trim()
258
+ : "";
244
259
  droppedCompanies.push({
245
260
  companyUid,
261
+ ...(slug ? { label: slug } : {}),
246
262
  reason: typeof row.reason === "string" && row.reason.trim()
247
263
  ? row.reason.trim()
248
264
  : "not included",
@@ -282,6 +298,101 @@ export function scopeBundleToAgentIdentity(bundle, env = process.env) {
282
298
  }
283
299
  return { ...bundle, companies: matched };
284
300
  }
301
+ function pushDistinct(out, seen, uid) {
302
+ const trimmed = uid?.trim();
303
+ if (!trimmed || seen.has(trimmed) || out.length >= PREFERRED_COMPANY_LIMIT)
304
+ return;
305
+ seen.add(trimmed);
306
+ out.push(trimmed);
307
+ }
308
+ /**
309
+ * Device default first, then distinct company uids from session files updated
310
+ * in the last 24 hours, newest first, max 5. Any read error returns undefined
311
+ * so the vend body omits the field.
312
+ */
313
+ export function readPreferredCompanyUids(input) {
314
+ try {
315
+ const nowMs = input.nowMs ?? Date.now();
316
+ const config = readDeviceConfig({ root: input.workContextRoot });
317
+ const out = [];
318
+ const seen = new Set();
319
+ if (config.defaultCompany?.enabled && config.defaultCompany.uid) {
320
+ pushDistinct(out, seen, config.defaultCompany.uid);
321
+ }
322
+ const dir = workContextSessionsDir(input.workContextRoot);
323
+ let names = [];
324
+ try {
325
+ names = fs.readdirSync(dir);
326
+ }
327
+ catch (err) {
328
+ const code = err.code;
329
+ if (code !== "ENOENT")
330
+ throw err;
331
+ }
332
+ const recent = [];
333
+ for (const name of names) {
334
+ if (!name.endsWith(".json"))
335
+ continue;
336
+ const full = path.join(dir, name);
337
+ const stat = fs.statSync(full);
338
+ if (!stat.isFile())
339
+ continue;
340
+ const raw = JSON.parse(fs.readFileSync(full, "utf8"));
341
+ const parsedAt = typeof raw.updatedAt === "string" ? Date.parse(raw.updatedAt) : Number.NaN;
342
+ const at = Number.isFinite(parsedAt) ? parsedAt : stat.mtimeMs;
343
+ if (!Number.isFinite(at) || nowMs - at > PREFERRED_SESSION_WINDOW_MS)
344
+ continue;
345
+ if (nowMs < at)
346
+ continue;
347
+ if (typeof raw.companyUid !== "string")
348
+ continue;
349
+ recent.push({ uid: raw.companyUid, at });
350
+ }
351
+ recent.sort((a, b) => b.at - a.at || a.uid.localeCompare(b.uid));
352
+ for (const row of recent)
353
+ pushDistinct(out, seen, row.uid);
354
+ return out;
355
+ }
356
+ catch {
357
+ return undefined;
358
+ }
359
+ }
360
+ export function preferredCompanyKey(uids) {
361
+ return (uids ?? []).join("\n");
362
+ }
363
+ /** True when a new preferred set should force a vend, at most once per minute. */
364
+ export function shouldRevendPreferredCompanies(input) {
365
+ if (!input.next)
366
+ return false;
367
+ if (preferredCompanyKey(input.previous) === preferredCompanyKey(input.next)) {
368
+ return false;
369
+ }
370
+ const min = input.minIntervalMs ?? PREFERRED_REVEND_MIN_MS;
371
+ return input.nowMs - input.lastVendMs >= min;
372
+ }
373
+ export function contract3VendBody(preferredCompanyUids) {
374
+ const body = {
375
+ contractVersion: 3,
376
+ };
377
+ if (preferredCompanyUids && preferredCompanyUids.length > 0) {
378
+ body.preferredCompanyUids = preferredCompanyUids.slice(0, PREFERRED_COMPANY_LIMIT);
379
+ }
380
+ return body;
381
+ }
382
+ /** Plain doctor line for one company the vend left out. */
383
+ export function formatDroppedPresenceCompany(company) {
384
+ const name = company.label?.trim() || company.companyUid;
385
+ if (company.reason === "gate_disabled") {
386
+ return `${name}: not included (presence is turned off for this company)`;
387
+ }
388
+ if (company.reason === "policy_budget") {
389
+ return `${name}: not included (too many companies for one credential)`;
390
+ }
391
+ if (!company.reason || company.reason === "unknown" || company.reason === "not included") {
392
+ return `${name}: not included`;
393
+ }
394
+ return `${name}: not included (${company.reason})`;
395
+ }
285
396
  function headerGet(headers, name) {
286
397
  if (!headers)
287
398
  return null;
@@ -294,11 +405,19 @@ function headerGet(headers, name) {
294
405
  }
295
406
  export function createContract3Fetcher(opts) {
296
407
  return async () => {
408
+ let preferred;
409
+ if (opts.readPreferredCompanyUids) {
410
+ try {
411
+ preferred = opts.readPreferredCompanyUids();
412
+ }
413
+ catch {
414
+ preferred = undefined;
415
+ }
416
+ }
417
+ const body = contract3VendBody(preferred);
297
418
  let bundle;
298
419
  if (opts.post) {
299
- const res = await opts.post(REALTIME_CREDENTIALS_PATH, {
300
- contractVersion: 3,
301
- });
420
+ const res = await opts.post(REALTIME_CREDENTIALS_PATH, body);
302
421
  if (res.status < 200 || res.status >= 300) {
303
422
  throw classifyCredentialVendFailure(res.status, res.body, headerGet(res.headers, "Retry-After"));
304
423
  }
@@ -309,23 +428,23 @@ export function createContract3Fetcher(opts) {
309
428
  token: opts.token,
310
429
  path: REALTIME_CREDENTIALS_PATH,
311
430
  method: "POST",
312
- body: { contractVersion: 3 },
431
+ body,
313
432
  baseUrl: opts.baseUrl ?? DEFAULT_VAULT_API_URL,
314
433
  });
315
434
  const text = await res.text();
316
- let body = {};
435
+ let parsed = {};
317
436
  if (text) {
318
437
  try {
319
- body = JSON.parse(text);
438
+ parsed = JSON.parse(text);
320
439
  }
321
440
  catch {
322
- body = { raw: text.slice(0, 200) };
441
+ parsed = { raw: text.slice(0, 200) };
323
442
  }
324
443
  }
325
444
  if (!res.ok) {
326
- throw classifyCredentialVendFailure(res.status, body, res.headers.get("Retry-After"));
445
+ throw classifyCredentialVendFailure(res.status, parsed, res.headers.get("Retry-After"));
327
446
  }
328
- bundle = normalizeContract3Bundle(body);
447
+ bundle = normalizeContract3Bundle(parsed);
329
448
  }
330
449
  return scopeBundleToAgentIdentity(bundle, opts.env ?? process.env);
331
450
  };
@@ -344,6 +463,7 @@ export class CredentialRenewalManager {
344
463
  random;
345
464
  handle = null;
346
465
  stopped = false;
466
+ inflight = false;
347
467
  constructor(fetchCredentials, onRenewed, onError = () => { }, timers = realTimerHost, retryDelayMs = 30_000, refusedRetryMs = DEFAULT_REFUSED_RETRY_MS, random = Math.random) {
348
468
  this.fetchCredentials = fetchCredentials;
349
469
  this.onRenewed = onRenewed;
@@ -360,9 +480,17 @@ export class CredentialRenewalManager {
360
480
  const delay = renewalDelayMs(this.timers.now(), bundle.expiresAt);
361
481
  this.handle = scheduleBoundedTimeout(this.timers, () => void this.renew(), delay);
362
482
  }
483
+ /** Vend now (preferred companies changed). No-op while a vend is in flight. */
484
+ renewNow() {
485
+ if (this.stopped || this.inflight)
486
+ return;
487
+ this.clear();
488
+ void this.renew();
489
+ }
363
490
  async renew() {
364
- if (this.stopped)
491
+ if (this.stopped || this.inflight)
365
492
  return;
493
+ this.inflight = true;
366
494
  try {
367
495
  const bundle = await this.fetchCredentials();
368
496
  if (this.stopped)
@@ -389,6 +517,9 @@ export class CredentialRenewalManager {
389
517
  return;
390
518
  this.handle = scheduleBoundedTimeout(this.timers, () => void this.renew(), delay);
391
519
  }
520
+ finally {
521
+ this.inflight = false;
522
+ }
392
523
  }
393
524
  stop() {
394
525
  this.stopped = true;
@@ -9,6 +9,7 @@ import { describeCognitoTokenSource, formatCognitoSubject, loadMachineCachedToke
9
9
  import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
10
10
  import { readSessionState } from "../../../work-context/state.js";
11
11
  import { workContextRoot } from "../../../work-context/paths.js";
12
+ import { formatDroppedPresenceCompany } from "./credentials.js";
12
13
  import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
13
14
  import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
14
15
  import { daemonDir, daemonPidPath } from "./paths.js";
@@ -239,9 +240,9 @@ export function formatDaemonDoctor(report) {
239
240
  if (report.presenceDroppedCompanies.length > 0) {
240
241
  lines.push(`presence not published for ${report.presenceDroppedCompanies.length} companies: ` +
241
242
  report.presenceDroppedCompanies
242
- .map((company) => `${company.companyUid} (${company.reason})`)
243
- .join(", ") +
244
- `; presence credential holds ${report.companiesOnline.length} companies; these were not included`);
243
+ .map((company) => formatDroppedPresenceCompany(company))
244
+ .join("; ") +
245
+ `; presence credential holds ${report.companiesOnline.length} companies`);
245
246
  }
246
247
  if (report.unhealthy) {
247
248
  lines.push(`health: UNHEALTHY`);