@indigoai-us/hq-cli 5.123.2 → 5.125.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 +47 -0
  2. package/dist/command-catalog.generated.d.ts +111 -1
  3. package/dist/command-catalog.generated.js +144 -1
  4. package/dist/commands/billing-sponsor.d.ts +83 -0
  5. package/dist/commands/billing-sponsor.js +344 -0
  6. package/dist/commands/billing.d.ts +3 -0
  7. package/dist/commands/billing.js +5 -0
  8. package/dist/commands/login.js +49 -6
  9. package/dist/commands/mesh.js +96 -19
  10. package/dist/lib/doctor/checks/work-context.js +4 -2
  11. package/dist/lib/mesh/live/backfill-held.d.ts +2 -0
  12. package/dist/lib/mesh/live/backfill-held.js +8 -4
  13. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +3 -0
  14. package/dist/lib/mesh/live/daemon/board-refresh.js +6 -0
  15. package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
  16. package/dist/lib/mesh/live/daemon/doctor.js +21 -1
  17. package/dist/lib/mesh/live/daemon/run.d.ts +11 -0
  18. package/dist/lib/mesh/live/daemon/run.js +129 -10
  19. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  20. package/dist/lib/mesh/live/flush.d.ts +3 -0
  21. package/dist/lib/mesh/live/flush.js +11 -0
  22. package/dist/lib/work-context/company.d.ts +8 -0
  23. package/dist/lib/work-context/company.js +62 -18
  24. package/dist/lib/work-context/config.d.ts +35 -0
  25. package/dist/lib/work-context/config.js +307 -62
  26. package/dist/lib/work-context/contract.d.ts +2 -0
  27. package/dist/lib/work-context/default-company.d.ts +43 -0
  28. package/dist/lib/work-context/default-company.js +83 -0
  29. package/dist/lib/work-context/outbox.d.ts +5 -1
  30. package/dist/lib/work-context/outbox.js +5 -0
  31. package/dist/lib/work-context/reconcile.d.ts +4 -0
  32. package/dist/lib/work-context/reconcile.js +59 -3
  33. package/dist/lib/work-context/state.d.ts +2 -0
  34. package/dist/lib/work-context/state.js +14 -0
  35. package/package.json +1 -1
@@ -0,0 +1,344 @@
1
+ /**
2
+ * `hq billing sponsor` — the individual-pull half of plan sponsorship, from the
3
+ * terminal ("ask a company to sponsor me", hq-pro #3609).
4
+ *
5
+ * A person who is over a personal limit has exactly three ways out: pay $50
6
+ * themselves, reduce what they store, or ask a company to cover the $50 plan
7
+ * line. This command is the third one, plus the admin side that answers it.
8
+ *
9
+ * hq billing sponsor request <company> — ask that company to cover my plan
10
+ * hq billing sponsor list — the queue (admins), or my own
11
+ * single status (everyone else)
12
+ * hq billing sponsor approve <id> — admin: say yes
13
+ * hq billing sponsor decline <id> — admin: say no
14
+ * hq billing sponsor revoke <id> — admin: end an active sponsorship
15
+ * hq billing sponsor withdraw <id> — me: take back my own request
16
+ *
17
+ * Every subcommand talks to the SAME hq-pro routes the console uses
18
+ * (`/v1/billing/sponsorship/...`). hq-pro is the authoritative gate on all of
19
+ * them: it resolves the caller's person id and the company's admin set
20
+ * server-side, so nothing here is trusted from the client.
21
+ *
22
+ * FEATURE SWITCH. The `plan` request kind only exists while hq-pro's
23
+ * `PLAN_SPONSORSHIP_REQUESTS_ENABLED` switch is on. With the switch off the
24
+ * backend does not recognise `plan` and answers 400 `MISSING_FIELDS` — which
25
+ * would read as "you typed something wrong". We translate that one case into a
26
+ * plain "not available yet" line so a person never sees a validation error for
27
+ * a feature that simply is not turned on.
28
+ */
29
+ import chalk from "chalk";
30
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
31
+ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
32
+ /** The request kind this command files. Matches hq-pro's contract. */
33
+ export const PLAN_RESOURCE = "plan";
34
+ /** What a person is told when hq-pro's plan-sponsorship switch is off. */
35
+ export const FEATURE_OFF_MESSAGE = "Asking a company to sponsor your plan isn't available yet.";
36
+ /** What v1 sponsorship covers, stated wherever a person confirms or reads it. */
37
+ export const COVERAGE_SENTENCE = "Your company pays your $50 plan. Storage above your plan's allowance is still yours.";
38
+ /** What a person is told while an approval waits on the sponsor's card. */
39
+ export const HELD_FOR_CARD_SENTENCE = "Your company said yes and is adding a card. Your limits still apply until then.";
40
+ /**
41
+ * An error we already have plain wording for. `expected` marks it as a normal
42
+ * outcome (a decline, a rate limit) rather than a fault, matching the flag the
43
+ * rest of the CLI uses to keep Sentry quiet.
44
+ */
45
+ class SponsorError extends Error {
46
+ expected = true;
47
+ }
48
+ function fail(err) {
49
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
50
+ process.exit(1);
51
+ }
52
+ /**
53
+ * Turn an hq-pro non-2xx into one plain sentence.
54
+ *
55
+ * The switch-off 400 is the important translation: with the feature off,
56
+ * `plan` is an unrecognised resource kind and the generic body reads
57
+ * "companyUid and resource ('outpost'|'agent') are required", which tells a
58
+ * person nothing true about what happened.
59
+ */
60
+ export function messageForError(status, body) {
61
+ const code = body.code;
62
+ if (status === 400 && code === "MISSING_FIELDS")
63
+ return FEATURE_OFF_MESSAGE;
64
+ if (status === 401)
65
+ return "Your sign-in has expired. Run `hq login` and try again.";
66
+ if (status === 403 && code === "SPONSOR_PLAN_REQUIRED") {
67
+ return "That company's plan can't cover an individual plan. Only HQ Workforce and Enterprise companies can.";
68
+ }
69
+ if (status === 403) {
70
+ return "You're not allowed to do that. Only an owner or admin of the company can answer a sponsorship request.";
71
+ }
72
+ if (status === 404)
73
+ return "No sponsorship request with that id.";
74
+ if (status === 409 && code === "DUPLICATE_PENDING_REQUEST") {
75
+ return "You've already asked that company. They just need to answer.";
76
+ }
77
+ if (status === 429 && code === "PLAN_REQUEST_RATE_LIMITED") {
78
+ return "You asked that company in the last 24 hours. Give them a day before asking again.";
79
+ }
80
+ return body.error ?? body.message ?? `Request failed (${status}).`;
81
+ }
82
+ /** Call an hq-pro sponsorship route, or throw a sentence a person can read. */
83
+ async function callApi(token, path, init = {}) {
84
+ const res = await vaultApiFetch({ token, path, ...init });
85
+ if (!res.ok) {
86
+ const body = (await res.json().catch(() => ({})));
87
+ throw new SponsorError(messageForError(res.status, body));
88
+ }
89
+ return (await res.json());
90
+ }
91
+ /**
92
+ * Read the caller's OWN plan-sponsorship state for one company.
93
+ *
94
+ * Two non-2xx cases mean the same thing to a person and must NOT fall through
95
+ * to {@link messageForError}: a 400 `MISSING_FIELDS` (hq-pro does not accept the
96
+ * `plan` resource kind yet) and a 404 (an hq-pro that predates this route at
97
+ * all). Both are "the feature is not on", never "no request with that id" —
98
+ * there is no id in a list command for that sentence to be about.
99
+ */
100
+ async function readSelfState(token, companyUid) {
101
+ const res = await vaultApiFetch({
102
+ token,
103
+ path: "/v1/billing/sponsorship/mine",
104
+ query: { companyUid, resource: PLAN_RESOURCE },
105
+ });
106
+ if (!res.ok) {
107
+ const body = (await res.json().catch(() => ({})));
108
+ if (res.status === 404 || (res.status === 400 && body.code === "MISSING_FIELDS")) {
109
+ throw new SponsorError(FEATURE_OFF_MESSAGE);
110
+ }
111
+ throw new SponsorError(messageForError(res.status, body));
112
+ }
113
+ return (await res.json());
114
+ }
115
+ /** Plain label for a request status. Never shows the raw enum. */
116
+ export function statusLabel(status) {
117
+ switch (status) {
118
+ case "pending":
119
+ return "Waiting for an answer";
120
+ case "pending-sponsor-billing":
121
+ return "Said yes — waiting on their card";
122
+ case "active":
123
+ return "Covered by the company";
124
+ case "declined":
125
+ return "Not covered";
126
+ case "expired":
127
+ return "Expired";
128
+ default:
129
+ return status;
130
+ }
131
+ }
132
+ /** One line per request, in the same column order everywhere. */
133
+ function renderRow(r) {
134
+ const when = r.requestedAt.slice(0, 10);
135
+ return ` ${chalk.bold(r.requestId)} ${statusLabel(r.status).padEnd(32)} asked ${when}`;
136
+ }
137
+ /** Only plan rows belong on this surface; outpost/agent have their own. */
138
+ function planRowsOf(rows) {
139
+ return rows.filter((r) => r.resource === PLAN_RESOURCE);
140
+ }
141
+ /** Shared body of approve / decline / revoke / withdraw. */
142
+ async function transition(requestId, op) {
143
+ const token = await ensureCognitoToken();
144
+ const data = await callApi(token, `/v1/billing/sponsorship/request/${op}`, {
145
+ method: "POST",
146
+ body: { requestId },
147
+ });
148
+ return { ...data.request, code: data.code, addCardUrl: data.addCardUrl };
149
+ }
150
+ export function registerBillingSponsorCommands(billing) {
151
+ const sponsor = billing
152
+ .command("sponsor")
153
+ .description("Ask a company to cover your $50 plan, or answer a request");
154
+ sponsor
155
+ .command("request")
156
+ .argument("<company>", "Company slug or uid to ask")
157
+ .description("Ask a company to cover your $50 HQ plan")
158
+ .option("--json", "Emit raw JSON")
159
+ .action(async function (company, opts) {
160
+ try {
161
+ const token = await ensureCognitoToken();
162
+ const companyUid = await getCompanyUid(token, company);
163
+ const data = await callApi(token, "/v1/billing/sponsorship/request", { method: "POST", body: { companyUid, resource: PLAN_RESOURCE } });
164
+ if (opts.json) {
165
+ process.stdout.write(JSON.stringify(data.request, null, 2) + "\n");
166
+ return;
167
+ }
168
+ console.log(`Asked ${company} to cover your $50 HQ plan.`);
169
+ console.log(COVERAGE_SENTENCE);
170
+ console.log(`An owner or admin there answers next. Check with \`hq billing sponsor list\`.`);
171
+ }
172
+ catch (err) {
173
+ fail(err);
174
+ }
175
+ });
176
+ sponsor
177
+ .command("list")
178
+ .description("Show sponsorship requests — the company's queue, or your own")
179
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
180
+ .option("--json", "Emit raw JSON")
181
+ .action(async function (opts) {
182
+ try {
183
+ const token = await ensureCognitoToken();
184
+ // A company the caller cannot resolve is not fatal here. The person
185
+ // this feature is for often belongs to no company at all, and they
186
+ // still need to be told what to do next.
187
+ const named = opts.company ?? billing.opts().company;
188
+ const companyUid = await getCompanyUid(token, named).catch(() => null);
189
+ // The admin queue is the richer view. A non-admin is refused it, so we
190
+ // fall back to the member SELF-READ — a different route with a
191
+ // different answer: hq-pro returns ONE status for that person and that
192
+ // company, not a list. Rendering it as a list (or reusing the list's
193
+ // "not found" wording) is what made this read wrong.
194
+ let rows = null;
195
+ if (companyUid) {
196
+ try {
197
+ const data = await callApi(token, "/v1/billing/sponsorship/requests", { query: { companyUid } });
198
+ rows = planRowsOf(data.requests);
199
+ }
200
+ catch (err) {
201
+ if (!(err instanceof SponsorError))
202
+ throw err;
203
+ }
204
+ }
205
+ if (rows === null) {
206
+ // The self-read is company-scoped by contract, so without a company
207
+ // there is nothing to ask for. Say which flag fixes it.
208
+ if (!companyUid) {
209
+ const line = named
210
+ ? `Couldn't find a company called ${named}.`
211
+ : "Name the company you asked: `hq billing sponsor list --company <slug>`.";
212
+ if (opts.json) {
213
+ process.stdout.write(JSON.stringify({ error: line }, null, 2) + "\n");
214
+ return;
215
+ }
216
+ console.log(line);
217
+ return;
218
+ }
219
+ const state = await readSelfState(token, companyUid);
220
+ if (opts.json) {
221
+ process.stdout.write(JSON.stringify(state, null, 2) + "\n");
222
+ return;
223
+ }
224
+ const where = named ?? "that company";
225
+ if (!state.active) {
226
+ console.log(`No company is covering your $50 HQ plan right now.`);
227
+ console.log(`If you asked ${where}, they haven't answered yet — an owner or admin there answers next.`);
228
+ return;
229
+ }
230
+ console.log(`Your $50 HQ plan: ${statusLabel(state.status ?? "active")}`);
231
+ if (state.requestId)
232
+ console.log(` ${chalk.bold(state.requestId)}`);
233
+ console.log(COVERAGE_SENTENCE);
234
+ if (state.status === "pending-sponsor-billing") {
235
+ console.log(`\n${HELD_FOR_CARD_SENTENCE}`);
236
+ }
237
+ return;
238
+ }
239
+ if (opts.json) {
240
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
241
+ return;
242
+ }
243
+ if (rows.length === 0) {
244
+ console.log("No one has asked this company to cover their plan.");
245
+ return;
246
+ }
247
+ console.log("Plan sponsorship requests:");
248
+ for (const r of rows)
249
+ console.log(renderRow(r));
250
+ if (rows.some((r) => r.status === "pending-sponsor-billing")) {
251
+ console.log(`\n${chalk.yellow("Add a card")} to start the requests you said yes to. Nothing is covered until then.`);
252
+ }
253
+ }
254
+ catch (err) {
255
+ fail(err);
256
+ }
257
+ });
258
+ sponsor
259
+ .command("approve")
260
+ .argument("<id>", "Request id")
261
+ .description("Say yes — put someone's $50 plan on the company bill")
262
+ .option("--json", "Emit raw JSON")
263
+ .action(async function (id, opts) {
264
+ try {
265
+ const result = await transition(id, "approve");
266
+ if (opts.json) {
267
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
268
+ return;
269
+ }
270
+ if (result.status === "pending-sponsor-billing") {
271
+ console.log("Approved. Add a card to start this sponsorship — nothing is covered until then.");
272
+ if (result.addCardUrl)
273
+ console.log(` ${chalk.cyan(result.addCardUrl)}`);
274
+ return;
275
+ }
276
+ console.log("Approved. Their $50 plan is now on the company bill.");
277
+ }
278
+ catch (err) {
279
+ fail(err);
280
+ }
281
+ });
282
+ sponsor
283
+ .command("decline")
284
+ .argument("<id>", "Request id")
285
+ .description("Say no to a plan sponsorship request")
286
+ .option("--json", "Emit raw JSON")
287
+ .action(async function (id, opts) {
288
+ try {
289
+ const result = await transition(id, "decline");
290
+ if (opts.json) {
291
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
292
+ return;
293
+ }
294
+ console.log("Declined. Their plan stays theirs to pay or reduce.");
295
+ }
296
+ catch (err) {
297
+ fail(err);
298
+ }
299
+ });
300
+ sponsor
301
+ .command("revoke")
302
+ .argument("<id>", "Request id")
303
+ .description("End a sponsorship the company is currently paying for")
304
+ .option("--json", "Emit raw JSON")
305
+ .action(async function (id, opts) {
306
+ try {
307
+ // Revoke and decline are the SAME hq-pro route: an active row flips to
308
+ // declined and the plan goes back to the person. Two names here because
309
+ // "revoke a sponsorship you're paying for" and "decline a request you
310
+ // haven't answered" are different things to the admin doing them.
311
+ const result = await transition(id, "decline");
312
+ if (opts.json) {
313
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
314
+ return;
315
+ }
316
+ console.log("Ended. Their $50 plan goes back to them from the next bill.");
317
+ }
318
+ catch (err) {
319
+ fail(err);
320
+ }
321
+ });
322
+ sponsor
323
+ .command("withdraw")
324
+ .argument("<id>", "Request id")
325
+ .description("Take back a sponsorship request you made")
326
+ .option("--json", "Emit raw JSON")
327
+ .action(async function (id, opts) {
328
+ try {
329
+ // hq-pro lets a person decline THEIR OWN plan row without being an admin
330
+ // (it resolves the caller's person id server-side and compares it to the
331
+ // row's requester), so withdraw is the same route scoped to yourself.
332
+ const result = await transition(id, "decline");
333
+ if (opts.json) {
334
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
335
+ return;
336
+ }
337
+ console.log("Withdrawn. You can pay the $50 yourself, reduce what you store, or ask another company.");
338
+ }
339
+ catch (err) {
340
+ fail(err);
341
+ }
342
+ });
343
+ }
344
+ //# sourceMappingURL=billing-sponsor.js.map
@@ -11,6 +11,9 @@
11
11
  * Subcommands:
12
12
  * hq billing status [--company <slug>] — subscription + card on file
13
13
  * hq billing checkout [--company <slug>] [--personal] — mint a card-capture link
14
+ * hq billing sponsor … — ask a company to cover
15
+ * your $50 plan, or
16
+ * answer such a request
14
17
  */
15
18
  import { Command } from "commander";
16
19
  export declare function registerBillingCommand(program: Command): void;
@@ -11,12 +11,16 @@
11
11
  * Subcommands:
12
12
  * hq billing status [--company <slug>] — subscription + card on file
13
13
  * hq billing checkout [--company <slug>] [--personal] — mint a card-capture link
14
+ * hq billing sponsor … — ask a company to cover
15
+ * your $50 plan, or
16
+ * answer such a request
14
17
  */
15
18
  import chalk from "chalk";
16
19
  import { ensureCognitoToken } from "../utils/cognito-session.js";
17
20
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
18
21
  import { mintPaymentLink } from "../utils/billing-gate.js";
19
22
  import { upgradeToTeam } from "../utils/team-upgrade.js";
23
+ import { registerBillingSponsorCommands } from "./billing-sponsor.js";
20
24
  function fail(err) {
21
25
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
22
26
  process.exit(1);
@@ -126,5 +130,6 @@ export function registerBillingCommand(program) {
126
130
  fail(err);
127
131
  }
128
132
  });
133
+ registerBillingSponsorCommands(billing);
129
134
  }
130
135
  //# sourceMappingURL=billing.js.map
@@ -4,6 +4,11 @@
4
4
  import chalk from 'chalk';
5
5
  import { browserLogin, loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
6
6
  import { cognitoConfigForLoginProvider } from '../utils/login-provider.js';
7
+ import * as readline from "node:readline/promises";
8
+ import { listActiveMembershipCompanies } from "../lib/mesh/api.js";
9
+ import { setDefaultCompany } from "../lib/work-context/config.js";
10
+ import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
11
+ import { workContextRoot } from "../lib/work-context/paths.js";
7
12
  function peekIdToken(idToken) {
8
13
  try {
9
14
  const payload = idToken.split('.')[1];
@@ -24,21 +29,59 @@ export function registerLoginCommand(program) {
24
29
  .description('Authenticate with HQ via Cognito')
25
30
  .option('--provider <provider>', 'OAuth provider to use: google, microsoft, or picker')
26
31
  .action(async (options) => {
32
+ let accessToken;
27
33
  try {
28
34
  const existing = loadCachedTokens();
29
35
  if (existing && !isExpiring(existing, 120)) {
30
36
  const who = peekIdToken(existing.idToken).email ?? 'cached session';
31
37
  console.log(chalk.green(`Already logged in as ${who}. Run \`hq logout\` to switch accounts.`));
32
- return;
38
+ accessToken = existing.accessToken;
39
+ }
40
+ else {
41
+ console.log('Opening browser for authentication...');
42
+ const tokens = await browserLogin(cognitoConfigForLoginProvider(options.provider));
43
+ const who = peekIdToken(tokens.idToken).email ?? 'HQ';
44
+ console.log(chalk.green(`Logged in as ${who}`));
45
+ accessToken = tokens.accessToken;
33
46
  }
34
- console.log('Opening browser for authentication...');
35
- const tokens = await browserLogin(cognitoConfigForLoginProvider(options.provider));
36
- const who = peekIdToken(tokens.idToken).email ?? 'HQ';
37
- console.log(chalk.green(`Logged in as ${who}`));
38
47
  }
39
48
  catch (error) {
40
49
  console.error(chalk.red('Login failed:'), error instanceof Error ? error.message : 'Unknown error');
41
- process.exit(1);
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ try {
54
+ const root = workContextRoot();
55
+ const activation = await ensureDefaultCompany({
56
+ root,
57
+ listMemberships: () => listActiveMembershipCompanies(accessToken),
58
+ log: (message) => console.error(chalk.dim(message)),
59
+ });
60
+ if (activation.status === "needs_choice" && process.stdin.isTTY && process.stdout.isTTY) {
61
+ const choices = activation.memberships
62
+ .map((membership, index) => `${index + 1}. ${membership.slug}`)
63
+ .join("\n");
64
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
65
+ try {
66
+ const answer = await rl.question(`Choose this device's default company:\n${choices}\nSelection: `);
67
+ const selected = activation.memberships[Number.parseInt(answer, 10) - 1];
68
+ if (selected) {
69
+ await setDefaultCompany(selected.slug, {
70
+ root,
71
+ validateMembership: (candidate) => candidate === selected.slug ? { uid: selected.uid } : false,
72
+ });
73
+ }
74
+ }
75
+ finally {
76
+ rl.close();
77
+ }
78
+ }
79
+ else if (activation.status === "needs_choice") {
80
+ console.error(chalk.yellow("Multiple active memberships. Run `hq mesh context default set <slug>` to choose this device's default company."));
81
+ }
82
+ }
83
+ catch (error) {
84
+ console.error(chalk.yellow('Default-company setup failed:'), error instanceof Error ? error.message : 'Unknown error');
42
85
  }
43
86
  });
44
87
  }
@@ -12,13 +12,15 @@ import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js
12
12
  import * as readline from "node:readline/promises";
13
13
  import { STORY_STATUSES, appendThreadEvent, callerLabelFromToken, ensureProjectThread, eventPayload, listActiveMembershipCompanies, listActiveThreads, patchStoryStatus, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
14
14
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
15
- import { clearDefaultCompany, getDefaultCompany, readDeviceConfig, recordMigrationCapabilitySnapshot, setDefaultCompany, } from "../lib/work-context/config.js";
15
+ import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
16
+ import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
16
17
  import { DefaultCompanyUnavailableError, } from "../lib/work-context/errors.js";
17
18
  import { isValidSessionId } from "../lib/mesh/live/session-identity.js";
18
19
  import { CLI_KIND_TO_SCHEMA, resolveEnqueueSessionId, } from "../lib/mesh/live/index.js";
19
20
  import { flushSessionEvents } from "../lib/mesh/live/flush.js";
20
21
  import { backfillHeldSessions } from "../lib/mesh/live/backfill-held.js";
21
22
  import { createSessionEventsPoster, resolveVaultApiBase, } from "../lib/mesh/live/session-events-client.js";
23
+ const MEMBERSHIP_VALIDATION_TIMEOUT_MS = 5_000;
22
24
  import { buildEmitEvidence } from "../lib/mesh/live/emit-evidence.js";
23
25
  import { createEmitPoster, buildMeshEmitEvent } from "../lib/mesh/live/emit-client.js";
24
26
  import { emitEvents } from "../lib/mesh/live/emit.js";
@@ -153,25 +155,73 @@ async function runContextReconcile(opts) {
153
155
  let fetchCandidates;
154
156
  let validateMembership;
155
157
  let offline = Boolean(opts.offline);
156
- if (!offline) {
157
- try {
158
- const token = await requireToken();
159
- deliver = createWorkSessionDeliverer({ token });
160
- fetchCandidates = createCandidatesFetcher({ token });
161
- validateMembership = async (candidate) => {
162
- const membership = await resolveActiveMembershipCompany(token, candidate);
163
- if (!membership)
164
- return false;
165
- return { uid: membership.companyUid };
166
- };
158
+ let token;
159
+ try {
160
+ // Offline is outbox-only delivery. It still refreshes a device default's
161
+ // token-backed membership so a revoked default cannot be preflighted.
162
+ token = await requireToken();
163
+ const sessionToken = token;
164
+ validateMembership = async (candidate) => {
165
+ const membership = await new Promise((resolve, reject) => {
166
+ const timer = setTimeout(() => reject(new Error("membership validation timed out")), MEMBERSHIP_VALIDATION_TIMEOUT_MS);
167
+ void resolveActiveMembershipCompany(sessionToken, candidate).then((value) => {
168
+ clearTimeout(timer);
169
+ resolve(value);
170
+ }, (error) => {
171
+ clearTimeout(timer);
172
+ reject(error);
173
+ });
174
+ });
175
+ if (!membership || typeof membership.companyUid !== "string" || !membership.companyUid) {
176
+ return false;
177
+ }
178
+ return { uid: membership.companyUid };
179
+ };
180
+ if (!offline) {
181
+ deliver = createWorkSessionDeliverer({ token: sessionToken });
182
+ fetchCandidates = createCandidatesFetcher({ token: sessionToken });
167
183
  }
168
- catch {
184
+ }
185
+ catch {
186
+ if (!offline) {
169
187
  offline = true;
170
188
  if (!opts.machine) {
171
189
  console.error(chalk.yellow("No Cognito session; reconciling offline (outbox only)."));
172
190
  }
173
191
  }
174
192
  }
193
+ if (token) {
194
+ const activation = await ensureDefaultCompany({
195
+ root,
196
+ listMemberships: () => listActiveMembershipCompanies(token),
197
+ log: (message) => console.error(chalk.dim(message)),
198
+ });
199
+ if (activation.status === "needs_choice") {
200
+ const interactive = !opts.machine && Boolean(process.stdin.isTTY && process.stdout.isTTY) && !isMachineIdentity();
201
+ if (interactive) {
202
+ const choices = activation.memberships
203
+ .map((membership, index) => `${index + 1}. ${membership.slug}`)
204
+ .join("\n");
205
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
206
+ try {
207
+ const answer = await rl.question(`Choose this device's default company:\n${choices}\nSelection: `);
208
+ const selected = activation.memberships[Number.parseInt(answer, 10) - 1];
209
+ if (selected) {
210
+ await setDefaultCompany(selected.slug, {
211
+ root,
212
+ validateMembership: (candidate) => candidate === selected.slug ? { uid: selected.uid } : false,
213
+ });
214
+ }
215
+ }
216
+ finally {
217
+ rl.close();
218
+ }
219
+ }
220
+ else if (!opts.machine) {
221
+ console.error(chalk.yellow("Multiple active memberships. Run `hq mesh context default set <slug>` to choose this device's default company."));
222
+ }
223
+ }
224
+ }
175
225
  const outcome = await reconcileObservation(obs, {
176
226
  root,
177
227
  env: process.env,
@@ -295,6 +345,7 @@ async function runContextBackfillHeld(opts) {
295
345
  const parsedLimit = opts.limit ? Number.parseInt(opts.limit, 10) : 0;
296
346
  const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 0;
297
347
  const companyHint = opts.company?.trim() || undefined;
348
+ const useDeviceDefault = opts.useDeviceDefault === true || readDeviceConfig({ root }).repairHeldWithDefault === true;
298
349
  // Build the reconcile network seam exactly like `hq mesh context reconcile`.
299
350
  // Not needed in --dry-run (reconcile is never invoked then). Explicit types
300
351
  // because these are captured in the reconcile closure below (no control-flow
@@ -331,6 +382,7 @@ async function runContextBackfillHeld(opts) {
331
382
  // Mirror the reconcile closure's non-forcing hint in the dry-run predictor
332
383
  // so `--dry-run` and the real run report the same counts.
333
384
  remoteOwnerHint: companyHint || undefined,
385
+ useDeviceDefault,
334
386
  reconcile: (obs) => reconcileObservation(
335
387
  // Pass --company as a non-forcing hint (remoteOwnerSlug feeds
336
388
  // deterministic resolution BELOW the identity-file / device default,
@@ -342,6 +394,8 @@ async function runContextBackfillHeld(opts) {
342
394
  fetchCandidates,
343
395
  offline,
344
396
  validateMembership,
397
+ allowDeviceDefault: useDeviceDefault,
398
+ deviceDefaultConfidence: useDeviceDefault,
345
399
  }),
346
400
  });
347
401
  if (opts.json) {
@@ -364,16 +418,34 @@ async function runContextBackfillHeld(opts) {
364
418
  async function runContextDefaultGet(opts) {
365
419
  const root = workContextHomeRoot();
366
420
  const cfg = readDeviceConfig({ root });
367
- const current = getDefaultCompany({ root });
421
+ const current = cfg.defaultCompany?.enabled && cfg.defaultCompany.slug
422
+ ? cfg.defaultCompany
423
+ : null;
424
+ const memberships = cfg.defaultCompanyMemberships?.memberships ?? [];
425
+ const disabled = isDefaultCompanyDisabled({ root });
426
+ const source = disabled ? "disabled" : current?.source ?? (current ? "chosen" : "unset");
368
427
  if (opts.json) {
369
- console.log(JSON.stringify({ ok: true, config: cfg, defaultCompany: current }, null, 2));
428
+ console.log(JSON.stringify({
429
+ ok: true,
430
+ slug: current?.slug ?? null,
431
+ uid: current?.uid ?? null,
432
+ enabled: current?.enabled ?? false,
433
+ source,
434
+ needsChoice: !current && !disabled && memberships.length > 1,
435
+ memberships,
436
+ repairHeldWithDefault: cfg.repairHeldWithDefault === true,
437
+ }, null, 2));
438
+ return;
439
+ }
440
+ if (disabled) {
441
+ console.log("Default company disabled on this device.");
370
442
  return;
371
443
  }
372
444
  if (!current) {
373
445
  console.log("No device default company (default-company mode may be dark).");
374
446
  return;
375
447
  }
376
- console.log(`Default company: ${current.slug}${current.uid ? ` (${current.uid})` : ""} enabled=${current.enabled}`);
448
+ console.log(`Default company: ${current.slug}${current.uid ? ` (${current.uid})` : ""} enabled=${current.enabled} source=${source}`);
377
449
  }
378
450
  async function runContextDefaultSet(slug, opts) {
379
451
  const root = workContextHomeRoot();
@@ -434,11 +506,14 @@ async function runContextDefaultSet(slug, opts) {
434
506
  return { uid: membership.companyUid };
435
507
  },
436
508
  });
509
+ const configured = opts.repairHeldWithDefault
510
+ ? setRepairHeldWithDefault(true, { root })
511
+ : cfg;
437
512
  if (opts.json) {
438
- console.log(JSON.stringify({ ok: true, config: cfg, migrationWarning }, null, 2));
513
+ console.log(JSON.stringify({ ok: true, config: configured, migrationWarning }, null, 2));
439
514
  return;
440
515
  }
441
- const stored = cfg.defaultCompany;
516
+ const stored = configured.defaultCompany;
442
517
  console.log(`Default company set to ${stored?.slug ?? slug}${stored?.uid ? ` (${stored.uid})` : ""}`);
443
518
  if (migrationWarning)
444
519
  console.error(chalk.yellow(migrationWarning));
@@ -1035,7 +1110,8 @@ export function registerMeshCommand(program) {
1035
1110
  .option("--dry-run", "Report what would be reconciled; write nothing, no network")
1036
1111
  .option("--offline", "Reconcile outbox-only (no candidates/register network)")
1037
1112
  .option("--limit <n>", "Cap sessions processed (default 0 = no cap)")
1038
- .option("--company <slug>", "Optional non-forcing company hint (resolver default still wins)")
1113
+ .option("--company <slug>", "Optional non-forcing company hint")
1114
+ .option("--use-device-default", "Opt in to historical repair using the device default (lower-confidence attribution)")
1039
1115
  .option("--json", "Print machine-readable JSON")
1040
1116
  .action((opts) => wrap(() => runContextBackfillHeld(opts))());
1041
1117
  context
@@ -1090,6 +1166,7 @@ export function registerMeshCommand(program) {
1090
1166
  .description("Set the device default company slug after verifying membership")
1091
1167
  .argument("<slug>", "Company slug")
1092
1168
  .option("--allow-without-migration", "Deprecated compatibility no-op; selected-company membership is always verified")
1169
+ .option("--repair-held-with-default", "Persist opt-in for lower-confidence historical held-session repair")
1093
1170
  .option("--company <slug|uid>", "Deprecated no-op; the selected company is verified directly")
1094
1171
  .option("--json", "Print machine-readable JSON")
1095
1172
  .action((slug, opts) => wrap(() => runContextDefaultSet(slug, opts))());