@indigoai-us/hq-cli 5.345.148 → 5.345.149

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 (65) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -0
  3. package/dist/command-catalog.generated.d.ts +44 -8
  4. package/dist/command-catalog.generated.js +53 -8
  5. package/dist/command-help.js +2 -1
  6. package/dist/commands/access.d.ts +45 -2
  7. package/dist/commands/access.js +537 -223
  8. package/dist/commands/billing-usage.d.ts +33 -2
  9. package/dist/commands/billing-usage.js +75 -4
  10. package/dist/commands/bot.js +2 -2
  11. package/dist/commands/channels.d.ts +27 -0
  12. package/dist/commands/channels.js +108 -4
  13. package/dist/commands/core-refresh-vault-access-native.d.ts +16 -0
  14. package/dist/commands/core-refresh-vault-access-native.js +142 -9
  15. package/dist/commands/core.js +4 -1
  16. package/dist/commands/dm.js +9 -1
  17. package/dist/commands/install-global.d.ts +1 -8
  18. package/dist/commands/install-global.js +11 -27
  19. package/dist/commands/library-add.d.ts +31 -1
  20. package/dist/commands/library-add.js +69 -2
  21. package/dist/commands/library.js +37 -3
  22. package/dist/commands/reindex.js +19 -0
  23. package/dist/hook-engine-path.js +10 -3
  24. package/dist/index.d.ts +10 -0
  25. package/dist/index.js +70 -5
  26. package/dist/lazy-commands.js +2 -0
  27. package/dist/lib/billing/usage-cost.d.ts +31 -1
  28. package/dist/lib/billing/usage-cost.js +46 -0
  29. package/dist/lib/db/local.d.ts +2 -2
  30. package/dist/lib/db/local.js +2 -2
  31. package/dist/lib/db/sql.js +2 -2
  32. package/dist/lib/db/sqlite-binding.d.ts +22 -0
  33. package/dist/lib/db/sqlite-binding.js +168 -0
  34. package/dist/lib/doctor/checks/anywhere.d.ts +12 -0
  35. package/dist/lib/doctor/checks/anywhere.js +63 -12
  36. package/dist/lib/doctor/checks/cli-path.d.ts +2 -1
  37. package/dist/lib/doctor/checks/cli-path.js +2 -1
  38. package/dist/lib/doctor/checks/hook-engine.js +2 -1
  39. package/dist/lib/hook-engine/reconcile.d.ts +9 -0
  40. package/dist/lib/hook-engine/reconcile.js +134 -0
  41. package/dist/lib/hook-engine/registration.d.ts +17 -0
  42. package/dist/lib/hook-engine/registration.js +50 -0
  43. package/dist/lib/install/claude-targets.d.ts +2 -1
  44. package/dist/lib/install/claude-targets.js +8 -3
  45. package/dist/lib/install/codex-targets.d.ts +3 -0
  46. package/dist/lib/install/codex-targets.js +15 -4
  47. package/dist/lib/install/hook-target.d.ts +9 -0
  48. package/dist/lib/install/hook-target.js +39 -0
  49. package/dist/lib/windows/console-encoding.d.ts +24 -0
  50. package/dist/lib/windows/console-encoding.js +83 -0
  51. package/dist/lib/windows/install-followup.d.ts +35 -0
  52. package/dist/lib/windows/install-followup.js +142 -0
  53. package/dist/lib/windows/user-path.d.ts +80 -3
  54. package/dist/lib/windows/user-path.js +187 -26
  55. package/dist/main.js +2 -1
  56. package/dist/unknown-command.d.ts +18 -0
  57. package/dist/unknown-command.js +88 -0
  58. package/dist/utils/cognito-session.d.ts +8 -0
  59. package/dist/utils/cognito-session.js +14 -2
  60. package/dist/utils/hook-trust.d.ts +9 -0
  61. package/dist/utils/hook-trust.js +30 -2
  62. package/dist/utils/qmd-native-binding-error.js +3 -1
  63. package/dist/utils/resolve-executable.d.ts +2 -0
  64. package/dist/utils/resolve-executable.js +6 -2
  65. package/package.json +8 -8
@@ -17,9 +17,18 @@
17
17
  *
18
18
  * Usage numbers come through `readMonthUsage` (src/lib/billing/usage-cost.ts),
19
19
  * the single seam to switch to the US-005 daily rollup once it has a read route.
20
+ *
21
+ * Only an account on usage pricing gets costs. The plan comes from the
22
+ * server: `pricingPlan` / `usageOnly` on the usage-limits body (hq-pro #5042,
23
+ * computed with `isOnUsagePricing`). Only when that body does not say, the
24
+ * billing summary the command already reads for the trial. A flat-plan account (for example a Workforce
25
+ * company that subscribed before the 2026-10-16 cutover) and an account the
26
+ * server says nothing about get readings only, with no rate, cost or
27
+ * projected bill.
20
28
  */
21
29
  import type { Command } from "commander";
22
- import { type ReadMonthUsageDeps, type StorageSplit, type UsageReport, type UsageScope } from "../lib/billing/usage-cost.js";
30
+ import { type BillingUsageReport, type ReadMonthUsageDeps, type StorageSplit, type UsageScope } from "../lib/billing/usage-cost.js";
31
+ import { type PricingPlan } from "../lib/billing/usage-only.js";
23
32
  export interface MembershipRef {
24
33
  uid: string;
25
34
  slug: string;
@@ -34,6 +43,12 @@ export interface BillingUsageDeps {
34
43
  fetchPricing(): Promise<unknown | null>;
35
44
  /** Whether the usage trial is running for the scope. Null = unknown. */
36
45
  readTrialActive?: (scope: UsageScope) => Promise<boolean | null>;
46
+ /**
47
+ * The billing summary body for the scope (company: `/v1/billing/summary`,
48
+ * person: `/v1/billing/person/summary`). Null when it did not answer. Used
49
+ * for the plan when usage-limits does not carry it (older server).
50
+ */
51
+ fetchBillingSummary?: (scope: UsageScope) => Promise<unknown | null>;
37
52
  now(): Date;
38
53
  }
39
54
  export interface BillingUsageOptions {
@@ -46,7 +61,23 @@ export declare class UsageScopeError extends Error {
46
61
  }
47
62
  /** Pick the scope. Never reaches outside the caller's own memberships. */
48
63
  export declare function resolveUsageScope(memberships: MembershipRef[], opts: BillingUsageOptions): UsageScope;
49
- export declare function runBillingUsage(opts: BillingUsageOptions, deps: BillingUsageDeps): Promise<UsageReport>;
64
+ /** The plan on a usage-limits body: `pricingPlan`, else the `usageOnly` / `onUsagePricing` boolean. */
65
+ export declare function pricingPlanFromUsageLimits(body: unknown): PricingPlan | null;
66
+ /**
67
+ * The plan on a billing summary body. A summary that answered without saying
68
+ * usage-only is a flat plan: the person summary carries `personalUsage` only
69
+ * for usage pricing, and a company summary from a server older than usage
70
+ * pricing has no `pricingPlan` at all.
71
+ */
72
+ export declare function pricingPlanFromSummary(body: unknown): PricingPlan | null;
73
+ /**
74
+ * True: price the meters. False: flat plan. Null: unknown, show no cost.
75
+ * When usage-limits answers, only its answer counts: the server computes it
76
+ * with `isOnUsagePricing`, the source of truth. The summary's plan is used
77
+ * only when usage-limits does not say.
78
+ */
79
+ export declare function resolveOnUsagePricing(limitsPlan: PricingPlan | null, summaryPlan: PricingPlan | null): boolean | null;
80
+ export declare function runBillingUsage(opts: BillingUsageOptions, deps: BillingUsageDeps): Promise<BillingUsageReport>;
50
81
  /**
51
82
  * `hq billing usage --card`: the card from GET /v1/pricing, or the bundled
52
83
  * card when the feed has none. Needs no sign-in.
@@ -17,14 +17,22 @@
17
17
  *
18
18
  * Usage numbers come through `readMonthUsage` (src/lib/billing/usage-cost.ts),
19
19
  * the single seam to switch to the US-005 daily rollup once it has a read route.
20
+ *
21
+ * Only an account on usage pricing gets costs. The plan comes from the
22
+ * server: `pricingPlan` / `usageOnly` on the usage-limits body (hq-pro #5042,
23
+ * computed with `isOnUsagePricing`). Only when that body does not say, the
24
+ * billing summary the command already reads for the trial. A flat-plan account (for example a Workforce
25
+ * company that subscribed before the 2026-10-16 cutover) and an account the
26
+ * server says nothing about get readings only, with no rate, cost or
27
+ * projected bill.
20
28
  */
21
29
  import chalk from "chalk";
22
30
  import { ensureCognitoToken } from "../utils/cognito-session.js";
23
31
  import { vaultApiFetch, vaultApiFetchPublic } from "../utils/vault-api.js";
24
- import { buildReport, formatReport, readMonthUsage, } from "../lib/billing/usage-cost.js";
32
+ import { buildReadingsReport, buildReport, formatReport, readMonthUsage, } from "../lib/billing/usage-cost.js";
25
33
  import { ratesFromPricingStatement } from "../lib/billing/usage-rate-card.js";
26
34
  import { fetchUsageCard, formatUsageCardLines } from "../lib/billing/usage-card.js";
27
- import { fetchCompanyBillingSummary, usageTrialActiveFromBody } from "../lib/billing/usage-only.js";
35
+ import { fetchCompanyBillingSummary, pricingPlanFromBody, usageTrialActiveFromBody, } from "../lib/billing/usage-only.js";
28
36
  export class UsageScopeError extends Error {
29
37
  }
30
38
  /** Pick the scope. Never reaches outside the caller's own memberships. */
@@ -51,13 +59,56 @@ export function resolveUsageScope(memberships, opts) {
51
59
  memberships.map((m) => ` --company ${m.slug}`).join("\n") +
52
60
  "\n --personal");
53
61
  }
62
+ function asRecord(value) {
63
+ return value !== null && typeof value === "object" && !Array.isArray(value)
64
+ ? value
65
+ : null;
66
+ }
67
+ /** The plan on a usage-limits body: `pricingPlan`, else the `usageOnly` / `onUsagePricing` boolean. */
68
+ export function pricingPlanFromUsageLimits(body) {
69
+ const plan = pricingPlanFromBody(body);
70
+ if (plan)
71
+ return plan;
72
+ const rec = asRecord(body);
73
+ const flag = typeof rec?.onUsagePricing === "boolean" ? rec.onUsagePricing : rec?.usageOnly;
74
+ if (typeof flag === "boolean")
75
+ return flag ? "usage-only" : "current";
76
+ return null;
77
+ }
78
+ /**
79
+ * The plan on a billing summary body. A summary that answered without saying
80
+ * usage-only is a flat plan: the person summary carries `personalUsage` only
81
+ * for usage pricing, and a company summary from a server older than usage
82
+ * pricing has no `pricingPlan` at all.
83
+ */
84
+ export function pricingPlanFromSummary(body) {
85
+ const rec = asRecord(body);
86
+ if (!rec)
87
+ return null;
88
+ // `plan: "usage"` only means "no Workforce subscription", not usage pricing,
89
+ // so it never turns pricing on. Without pricingPlan, show no cost.
90
+ return pricingPlanFromBody(rec) ?? "current";
91
+ }
92
+ /**
93
+ * True: price the meters. False: flat plan. Null: unknown, show no cost.
94
+ * When usage-limits answers, only its answer counts: the server computes it
95
+ * with `isOnUsagePricing`, the source of truth. The summary's plan is used
96
+ * only when usage-limits does not say.
97
+ */
98
+ export function resolveOnUsagePricing(limitsPlan, summaryPlan) {
99
+ const plan = limitsPlan ?? summaryPlan;
100
+ if (plan === null)
101
+ return null;
102
+ return plan === "usage-only";
103
+ }
54
104
  export async function runBillingUsage(opts, deps) {
55
105
  const memberships = await deps.listCompanies();
56
106
  const scope = resolveUsageScope(memberships, opts);
57
107
  const membership = scope.kind === "company" ? memberships.find((m) => m.uid === scope.companyUid) : undefined;
108
+ let limitsBody = null;
58
109
  const [usage, pricing] = await Promise.all([
59
110
  readMonthUsage(scope, {
60
- fetchUsageLimits: deps.fetchUsageLimits,
111
+ fetchUsageLimits: async (s) => (limitsBody = await deps.fetchUsageLimits(s)),
61
112
  now: deps.now,
62
113
  ...(deps.readTrialActive ? { readTrialActive: deps.readTrialActive } : {}),
63
114
  ...(opts.storageScan !== false && deps.readStorageSplit && membership
@@ -66,7 +117,18 @@ export async function runBillingUsage(opts, deps) {
66
117
  }),
67
118
  deps.fetchPricing().catch(() => null),
68
119
  ]);
69
- return buildReport(usage, ratesFromPricingStatement(pricing));
120
+ const limitsPlan = pricingPlanFromUsageLimits(limitsBody);
121
+ const summary = limitsPlan === null && deps.fetchBillingSummary
122
+ ? await deps.fetchBillingSummary(scope).catch((err) => {
123
+ // stderr keeps --json output on stdout parseable.
124
+ console.error(`Could not read the billing summary: ${err instanceof Error ? err.message : String(err)}`);
125
+ return null;
126
+ })
127
+ : null;
128
+ const onUsagePricing = resolveOnUsagePricing(limitsPlan, pricingPlanFromSummary(summary));
129
+ if (onUsagePricing !== true)
130
+ return buildReadingsReport(usage, onUsagePricing);
131
+ return { ...buildReport(usage, ratesFromPricingStatement(pricing)), onUsagePricing: true };
70
132
  }
71
133
  // ── Production deps ─────────────────────────────────────────────────────────
72
134
  function productionDeps() {
@@ -117,6 +179,15 @@ function productionDeps() {
117
179
  const body = await fetchCompanyBillingSummary(await token(), scope.companyUid);
118
180
  return body === null ? null : usageTrialActiveFromBody(body);
119
181
  },
182
+ async fetchBillingSummary(scope) {
183
+ // Company: the same cached read as readTrialActive, so no second call.
184
+ if (scope.kind === "company")
185
+ return fetchCompanyBillingSummary(await token(), scope.companyUid);
186
+ const res = await vaultApiFetch({ token: await token(), path: "/v1/billing/person/summary" });
187
+ if (!res.ok)
188
+ return null;
189
+ return res.json().catch(() => null);
190
+ },
120
191
  now: () => new Date(),
121
192
  };
122
193
  }
@@ -475,7 +475,7 @@ export function resolveCreateKind(opts, worker) {
475
475
  return { kind, companies: [] };
476
476
  }
477
477
  if (isSetup) {
478
- throw Object.assign(new Error("HQ's setup bot is always a personal bot; drop --kind company."), { expected: true });
478
+ throw Object.assign(new Error("HQ's assistant is always a personal bot; drop --kind company."), { expected: true });
479
479
  }
480
480
  const companies = requested.length > 0 ? requested : normalizeBotCompanies(worker?.company ? [worker.company] : []);
481
481
  if (companies.length === 0) {
@@ -638,7 +638,7 @@ async function runBotCreateLocked(name, opts) {
638
638
  const { kind, companies } = resolveCreateKind(opts, worker);
639
639
  const isSetupBot = name === RESERVED_SETUP_BOT_NAME && kind === "personal" && worker?.id === SETUP_BOT_WORKER_ID;
640
640
  if (name === RESERVED_SETUP_BOT_NAME && kind === "personal" && !isSetupBot) {
641
- throw Object.assign(new Error(`The name ${RESERVED_SETUP_BOT_NAME} is reserved for HQ's setup bot; create it with --worker ${RESERVED_SETUP_BOT_NAME}.`), { expected: true });
641
+ throw Object.assign(new Error(`The name ${RESERVED_SETUP_BOT_NAME} is reserved for HQ's assistant; create it with --worker ${RESERVED_SETUP_BOT_NAME}.`), { expected: true });
642
642
  }
643
643
  const existing = readBotConfig(dir);
644
644
  if (existing) {
@@ -12,5 +12,32 @@ export declare function describeChannel(c: ChannelSummary): string;
12
12
  * fetched list) so the formatting is unit-testable without network/auth.
13
13
  */
14
14
  export declare function formatChannelsList(channels: ChannelSummary[]): string;
15
+ /** Activity fields the server returns on each channel row. */
16
+ type ChannelWithActivity = ChannelSummary & {
17
+ lastActivityAt?: string;
18
+ lastMessageAt?: string;
19
+ lastHumanMessageAt?: string;
20
+ unreadCount?: number;
21
+ };
22
+ /**
23
+ * Latest activity time on a channel row, as epoch ms, or 0 when the channel
24
+ * has never had activity. lastActivityAt moves on new messages (work-session
25
+ * status cards do not move it); lastMessageAt and lastHumanMessageAt are read
26
+ * as fallbacks for rows that carry only those.
27
+ * Pure → unit-testable.
28
+ */
29
+ export declare function channelActivityMs(c: ChannelWithActivity): number;
30
+ /**
31
+ * Parse `--active-since`: an ISO timestamp, or a duration back from now such
32
+ * as `30m`, `2h`, `1d`, `90s`. Returns epoch ms, or null when unreadable.
33
+ */
34
+ export declare function parseActiveSince(value: string, now?: number): number | null;
35
+ /**
36
+ * Channels with activity at or after `sinceMs`, newest first. With no bound,
37
+ * every channel sorted newest first (never-active channels last, in their
38
+ * original order). Pure → unit-testable.
39
+ */
40
+ export declare function channelsByActivity<T extends ChannelWithActivity>(channels: T[], sinceMs?: number): T[];
15
41
  export declare function registerChannelsCommand(program: Command): void;
42
+ export {};
16
43
  //# sourceMappingURL=channels.d.ts.map
@@ -67,7 +67,64 @@ function formatChannelMembers(members, json) {
67
67
  .map((member) => `${member.displayName ?? ""}\t${member.personUid}`)
68
68
  .join("\n");
69
69
  }
70
- async function runChannelsList() {
70
+ /**
71
+ * Latest activity time on a channel row, as epoch ms, or 0 when the channel
72
+ * has never had activity. lastActivityAt moves on new messages (work-session
73
+ * status cards do not move it); lastMessageAt and lastHumanMessageAt are read
74
+ * as fallbacks for rows that carry only those.
75
+ * Pure → unit-testable.
76
+ */
77
+ export function channelActivityMs(c) {
78
+ let best = 0;
79
+ for (const v of [c.lastActivityAt, c.lastMessageAt, c.lastHumanMessageAt]) {
80
+ const ms = typeof v === "string" ? Date.parse(v) : NaN;
81
+ if (Number.isFinite(ms) && ms > best)
82
+ best = ms;
83
+ }
84
+ return best;
85
+ }
86
+ /**
87
+ * Parse `--active-since`: an ISO timestamp, or a duration back from now such
88
+ * as `30m`, `2h`, `1d`, `90s`. Returns epoch ms, or null when unreadable.
89
+ */
90
+ export function parseActiveSince(value, now = Date.now()) {
91
+ const v = value.trim();
92
+ const rel = /^(\d+)\s*([smhd])$/i.exec(v);
93
+ if (rel) {
94
+ const unit = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[rel[2].toLowerCase()];
95
+ return now - Number(rel[1]) * unit;
96
+ }
97
+ if (!/^\d{4}-\d{2}-\d{2}/.test(v))
98
+ return null;
99
+ const ms = Date.parse(v);
100
+ return Number.isFinite(ms) ? ms : null;
101
+ }
102
+ /**
103
+ * Channels with activity at or after `sinceMs`, newest first. With no bound,
104
+ * every channel sorted newest first (never-active channels last, in their
105
+ * original order). Pure → unit-testable.
106
+ */
107
+ export function channelsByActivity(channels, sinceMs) {
108
+ return channels
109
+ .map((c, i) => ({ c, i, at: channelActivityMs(c) }))
110
+ .filter(({ at }) => sinceMs === undefined || (at > 0 && at >= sinceMs))
111
+ .sort((a, b) => b.at - a.at || a.i - b.i)
112
+ .map(({ c }) => c);
113
+ }
114
+ async function runChannelsList(opts = {}) {
115
+ let sinceMs;
116
+ if (opts.activeSince !== undefined) {
117
+ const parsed = parseActiveSince(opts.activeSince);
118
+ if (parsed === null) {
119
+ console.error(chalk.red(`--active-since must be an ISO time (2026-10-19T17:00:00Z) or a duration (30m, 2h, 1d); got '${opts.activeSince}'.`));
120
+ process.exit(2);
121
+ }
122
+ sinceMs = parsed;
123
+ }
124
+ if (opts.sort !== undefined && opts.sort !== "activity" && opts.sort !== "default") {
125
+ console.error(chalk.red(`--sort must be 'activity' or 'default'; got '${opts.sort}'.`));
126
+ process.exit(2);
127
+ }
71
128
  try {
72
129
  const token = await ensureCognitoToken();
73
130
  const res = await vaultApiFetch({ token, path: "/v1/notify/channels" });
@@ -82,7 +139,44 @@ async function runChannelsList() {
82
139
  process.exit(1);
83
140
  }
84
141
  const data = (await res.json());
85
- console.log(formatChannelsList(data.channels ?? []));
142
+ let list = data.channels ?? [];
143
+ if (sinceMs !== undefined || opts.sort === "activity") {
144
+ list = channelsByActivity(list, sinceMs);
145
+ }
146
+ console.log(opts.json ? JSON.stringify(list, null, 2) : formatChannelsList(list));
147
+ }
148
+ catch (err) {
149
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
150
+ process.exit(1);
151
+ }
152
+ }
153
+ /**
154
+ * `hq channels changes` — one call to the channel directory feed
155
+ * (GET /v1/notify/channels?cursor). No cursor, or an expired one, returns a
156
+ * full snapshot in `rows` with `snapshot: true` (or `reset: true`); a valid
157
+ * cursor returns only the channels that changed since it in `changed`, plus
158
+ * `removedChannelIds`. Every response carries the successor `cursor`
159
+ * (single-use, 15-minute TTL). Prints the response as JSON.
160
+ */
161
+ async function runChannelsChanges(opts) {
162
+ try {
163
+ const token = await ensureCognitoToken();
164
+ const res = await vaultApiFetch({
165
+ token,
166
+ path: "/v1/notify/channels",
167
+ query: { cursor: opts.cursor ?? "" },
168
+ });
169
+ if (!res.ok) {
170
+ if (res.status === 401) {
171
+ console.error(chalk.red("Not authenticated — run `hq login` and try again."));
172
+ }
173
+ else {
174
+ const err = (await res.json().catch(() => ({})));
175
+ console.error(chalk.red(`Could not read channel changes: ${err.error ?? err.message ?? res.statusText}`));
176
+ }
177
+ process.exit(1);
178
+ }
179
+ console.log(JSON.stringify(await res.json(), null, 2));
86
180
  }
87
181
  catch (err) {
88
182
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -119,8 +213,18 @@ export function registerChannelsCommand(program) {
119
213
  channels
120
214
  .command("list", { isDefault: true })
121
215
  .description("List your DM channels — named channels and group DMs.")
122
- .action(async () => {
123
- await runChannelsList();
216
+ .option("--json", "Output the channel array as JSON, including activity fields.")
217
+ .option("--active-since <when>", "Only channels with activity at or after an ISO time or a duration back from now (30m, 2h, 1d); newest first.")
218
+ .option("--sort <order>", "'activity' lists the most recently active channels first.")
219
+ .action(async (opts) => {
220
+ await runChannelsList(opts);
221
+ });
222
+ channels
223
+ .command("changes")
224
+ .description("Print the channels that changed since a feed cursor, as JSON (no cursor → full snapshot).")
225
+ .option("--cursor <token>", "Cursor from the previous `hq channels changes` response.")
226
+ .action(async (opts) => {
227
+ await runChannelsChanges(opts);
124
228
  });
125
229
  channels
126
230
  .command("members <channel>")
@@ -7,6 +7,7 @@ export type HqResult = {
7
7
  stdout: string;
8
8
  stderr: string;
9
9
  missing?: boolean;
10
+ timedOut?: boolean;
10
11
  };
11
12
  export type RefreshVaultAccessDependencies = {
12
13
  cwd: string;
@@ -17,6 +18,21 @@ export type RefreshVaultAccessDependencies = {
17
18
  /** How long to wait for another refresh to release the manifest lock. */
18
19
  lockWaitMs?: number;
19
20
  };
21
+ /**
22
+ * Per-call ceiling for each child `hq` call. SessionStart runs this helper, so
23
+ * a stalled vault request must not outlive the session that started it.
24
+ */
25
+ export declare const DEFAULT_HQ_CALL_TIMEOUT_MS = 30000;
26
+ /**
27
+ * Run one child `hq` call with a timeout and with browser sign-in disabled.
28
+ * Without `HQ_NO_BROWSER_LOGIN` (read by cognito-session's
29
+ * resolveInteractiveLogin) a signed-out child opens a browser login and
30
+ * waits on it indefinitely. On Windows the call runs under a supervisor so a
31
+ * timeout ends the cmd.exe shim and every process under it.
32
+ */
33
+ /** Windows npm shims run hq under cmd.exe, so only there does a plain kill leave the real hq running. */
34
+ export declare function usesTreeKillByDefault(platform?: NodeJS.Platform): boolean;
35
+ export declare function runHqChild(args: string[], cwd: string, env: NodeJS.ProcessEnv, killTree?: boolean): HqResult;
20
36
  export declare function createRefreshVaultAccessDependencies(cwd: string, env: NodeJS.ProcessEnv, now: () => Date): RefreshVaultAccessDependencies;
21
37
  export declare function runRefreshVaultAccess(args: string[], deps: RefreshVaultAccessDependencies): UtilityOutput;
22
38
  /** Test seam: runs after a waiter has judged the lock stale and before it takes the lock over. */
@@ -47,18 +47,143 @@ export const REFRESH_VAULT_ACCESS_HELP = `# hq-core: public
47
47
  # bash core/scripts/refresh-vault-access.sh [--company <slug>]... [--root <hqRoot>]
48
48
  #
49
49
  `.replace(/^# ?/gm, "");
50
- function defaultHq(args, cwd, env) {
51
- const spawnPlan = planHqSpawn(args, env, cwd);
50
+ /**
51
+ * Per-call ceiling for each child `hq` call. SessionStart runs this helper, so
52
+ * a stalled vault request must not outlive the session that started it.
53
+ */
54
+ export const DEFAULT_HQ_CALL_TIMEOUT_MS = 30_000;
55
+ function hqCallTimeoutMs(env) {
56
+ const parsed = Number(env.HQ_VAULT_ACCESS_TIMEOUT_MS);
57
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HQ_CALL_TIMEOUT_MS;
58
+ }
59
+ /**
60
+ * Supervisor for one child call when the whole process tree must die on
61
+ * timeout. On Windows `hq` is a cmd.exe shim around node, and spawnSync's
62
+ * timeout ends only cmd.exe: the node child keeps running and keeps the
63
+ * output pipes open. The supervisor runs the call asynchronously, kills the
64
+ * tree on timeout (`taskkill /T /F` on Windows, the process group elsewhere),
65
+ * and writes the result as JSON. It exits without waiting for pipes still
66
+ * held by a grandchild, so the parent's spawnSync returns.
67
+ */
68
+ const TREE_KILL_SUPERVISOR = String.raw `
69
+ const { spawn, spawnSync } = require("node:child_process");
70
+ const path = require("node:path");
71
+ const plan = JSON.parse(process.env.HQ_TREE_KILL_PLAN);
72
+ delete process.env.HQ_TREE_KILL_PLAN;
73
+ const win = process.platform === "win32";
74
+ let done = false;
75
+ const finish = (result) => {
76
+ if (done) return;
77
+ done = true;
78
+ process.stdout.write(JSON.stringify(result), () => process.exit(0));
79
+ };
80
+ const out = [];
81
+ const err = [];
82
+ let child;
83
+ try {
84
+ child = spawn(plan.cmd, plan.args, {
85
+ cwd: plan.cwd,
86
+ env: process.env,
87
+ stdio: ["ignore", "pipe", "pipe"],
88
+ shell: plan.shell,
89
+ windowsHide: true,
90
+ windowsVerbatimArguments: plan.verbatim,
91
+ detached: !win,
92
+ });
93
+ } catch (error) {
94
+ finish({ error: String(error && error.message), errorCode: error && error.code });
95
+ }
96
+ if (child) {
97
+ const killTree = () => {
98
+ if (child.pid === undefined) return;
99
+ if (win) {
100
+ const taskkill = process.env.SystemRoot ? path.join(process.env.SystemRoot, "System32", "taskkill.exe") : "taskkill";
101
+ spawnSync(taskkill, ["/T", "/F", "/PID", String(child.pid)], { stdio: "ignore", windowsHide: true, timeout: 10000 });
102
+ } else {
103
+ try { process.kill(-child.pid, "SIGKILL"); } catch {}
104
+ }
105
+ };
106
+ const timer = setTimeout(() => { killTree(); finish({ timedOut: true }); }, plan.timeoutMs);
107
+ child.stdout.on("data", (chunk) => out.push(chunk));
108
+ child.stderr.on("data", (chunk) => err.push(chunk));
109
+ const result = (code) => ({ code, stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8") });
110
+ child.on("error", (error) => { clearTimeout(timer); finish({ error: error.message, errorCode: error.code }); });
111
+ child.on("close", (code) => { clearTimeout(timer); finish(result(code ?? 128)); });
112
+ // A grandchild that outlives the call can hold the pipes open. Report once
113
+ // the direct child has exited and the pipes have had a moment to drain.
114
+ child.on("exit", (code) => { setTimeout(() => { clearTimeout(timer); finish(result(code ?? 128)); }, 500).unref(); });
115
+ }
116
+ `;
117
+ /** Extra time the parent allows the supervisor beyond the call timeout, for taskkill and startup. */
118
+ const SUPERVISOR_GRACE_MS = 15_000;
119
+ /**
120
+ * Run one child `hq` call with a timeout and with browser sign-in disabled.
121
+ * Without `HQ_NO_BROWSER_LOGIN` (read by cognito-session's
122
+ * resolveInteractiveLogin) a signed-out child opens a browser login and
123
+ * waits on it indefinitely. On Windows the call runs under a supervisor so a
124
+ * timeout ends the cmd.exe shim and every process under it.
125
+ */
126
+ /** Windows npm shims run hq under cmd.exe, so only there does a plain kill leave the real hq running. */
127
+ export function usesTreeKillByDefault(platform = process.platform) {
128
+ return platform === "win32";
129
+ }
130
+ export function runHqChild(args, cwd, env, killTree = usesTreeKillByDefault()) {
131
+ const childEnv = { ...env, HQ_NO_BROWSER_LOGIN: "1" };
132
+ const timeoutMs = hqCallTimeoutMs(env);
133
+ const timedOut = () => ({ code: 124, stdout: "", stderr: `hq ${args.join(" ")} timed out after ${timeoutMs}ms`, timedOut: true });
134
+ const failed = (message, code) => ({ code: 127, stdout: "", stderr: message, missing: code === "ENOENT" });
135
+ const spawnPlan = planHqSpawn(args, childEnv, cwd);
136
+ if (killTree) {
137
+ const plan = JSON.stringify({
138
+ cmd: spawnPlan.cmd,
139
+ args: spawnPlan.args,
140
+ cwd,
141
+ shell: spawnPlan.shell,
142
+ verbatim: Boolean(spawnPlan.windowsVerbatimArguments),
143
+ timeoutMs,
144
+ });
145
+ const supervised = spawnSync(process.execPath, ["-e", TREE_KILL_SUPERVISOR], {
146
+ cwd,
147
+ env: { ...childEnv, HQ_TREE_KILL_PLAN: plan },
148
+ encoding: "utf8",
149
+ stdio: ["ignore", "pipe", "pipe"],
150
+ timeout: timeoutMs + SUPERVISOR_GRACE_MS,
151
+ killSignal: "SIGKILL",
152
+ maxBuffer: 64 * 1024 * 1024,
153
+ windowsHide: true,
154
+ });
155
+ if (supervised.error) {
156
+ const code = supervised.error.code;
157
+ return code === "ETIMEDOUT" ? timedOut() : failed(supervised.error.message, code);
158
+ }
159
+ let parsed;
160
+ try {
161
+ parsed = JSON.parse(String(supervised.stdout ?? ""));
162
+ }
163
+ catch {
164
+ return { code: supervised.status ?? 128, stdout: "", stderr: String(supervised.stderr ?? "") };
165
+ }
166
+ if (parsed.timedOut)
167
+ return timedOut();
168
+ if (parsed.error !== undefined)
169
+ return failed(parsed.error, parsed.errorCode);
170
+ return { code: parsed.code ?? 128, stdout: parsed.stdout ?? "", stderr: parsed.stderr ?? "" };
171
+ }
52
172
  const result = spawnSync(spawnPlan.cmd, spawnPlan.args, {
53
173
  cwd,
54
- env,
174
+ env: childEnv,
55
175
  encoding: "utf8",
56
176
  stdio: ["ignore", "pipe", "pipe"],
57
177
  shell: spawnPlan.shell,
178
+ timeout: timeoutMs,
179
+ killSignal: "SIGKILL",
180
+ windowsHide: true,
58
181
  ...(spawnPlan.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
59
182
  });
60
- if (result.error)
61
- return { code: 127, stdout: "", stderr: result.error.message, missing: result.error.code === "ENOENT" };
183
+ if (result.error) {
184
+ const code = result.error.code;
185
+ return code === "ETIMEDOUT" ? timedOut() : failed(result.error.message, code);
186
+ }
62
187
  return { code: result.status ?? 128, stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? "") };
63
188
  }
64
189
  function executableOnPath(name, value) {
@@ -85,7 +210,7 @@ export function createRefreshVaultAccessDependencies(cwd, env, now) {
85
210
  env,
86
211
  now,
87
212
  hqAvailable: executableOnPath("hq", env.PATH),
88
- runHq: (args, directory) => defaultHq(args, directory, env),
213
+ runHq: (args, directory) => runHqChild(args, directory, env),
89
214
  };
90
215
  }
91
216
  function isHqRoot(root) {
@@ -174,13 +299,18 @@ function roleFromTable(output, email) {
174
299
  }
175
300
  return "unknown";
176
301
  }
302
+ /** `hq whoami` prints this when no person session is cached. */
303
+ const SIGNED_OUT_PATTERN = /^Not logged in\b/m;
177
304
  function resolveEmail(deps, runHq) {
178
305
  const override = deps.env.HQ_VAULT_ACCESS_EMAIL ?? "";
179
306
  if (override)
180
- return { email: override, stderr: "" };
307
+ return { email: override, stderr: "", signedOut: false };
181
308
  const result = runHq(["whoami"], deps.cwd);
182
309
  const email = getEmail(result.stdout);
183
- return { email, stderr: email ? "" : log("could not determine caller email — roles will be 'unknown' (hook will not enforce)") };
310
+ // An API key reaches the vault without a person session, so only a
311
+ // key-less caller whose whoami reports no session is treated as signed out.
312
+ const signedOut = !email && !deps.env.HQ_API_KEY?.trim() && SIGNED_OUT_PATTERN.test(cleanAnsi(result.stdout));
313
+ return { email, signedOut, stderr: email ? "" : log("could not determine caller email — roles will be 'unknown' (hook will not enforce)") };
184
314
  }
185
315
  export function runRefreshVaultAccess(args, deps) {
186
316
  const parsed = parseOptions(args);
@@ -193,8 +323,11 @@ export function runRefreshVaultAccess(args, deps) {
193
323
  return { code: 1, stdout: "", stderr: log("could not resolve the HQ root (pass --root <hqRoot>)") };
194
324
  if (!deps.hqAvailable)
195
325
  return { code: 0, stdout: "", stderr: log("hq CLI not found — nothing refreshed (the write-access hook stays fail-open without a manifest)") };
196
- const runHq = deps.runHq ?? ((hqArgs, cwd) => defaultHq(hqArgs, cwd, deps.env));
326
+ const runHq = deps.runHq ?? ((hqArgs, cwd) => runHqChild(hqArgs, cwd, deps.env));
197
327
  const identity = resolveEmail(deps, runHq);
328
+ if (identity.signedOut) {
329
+ return { code: 0, stdout: "", stderr: log("not signed in to HQ — nothing refreshed (run `hq login`; the write-access hook stays fail-open without a manifest)") };
330
+ }
198
331
  let stderr = identity.stderr;
199
332
  const explicit = [...new Set(parsed.slugs)];
200
333
  const allGrants = new Map();
@@ -443,7 +443,10 @@ export function registerCoreCommands(program) {
443
443
  const hookEnginePath = core.command('hook-engine-path')
444
444
  .description('Print the offline, version-pinned native hook engine path')
445
445
  .option('--repair', 'Repair the pinned copy after closing running hook processes')
446
- .action((options) => runHookEnginePath(options.repair ? ['--repair'] : []));
446
+ .option('--for-registration', 'Print a path only when the offline launcher flag is enabled')
447
+ .action((options) => runHookEnginePath([
448
+ ...(options.repair ? ['--repair'] : []), ...(options.forRegistration ? ['--for-registration'] : []),
449
+ ]));
447
450
  describeCoreCommand(hookEnginePath, { execution: 'native', root: 'cwd' });
448
451
  registerCoreOntologyCommand(core);
449
452
  registerCoreCheckpointGateSettingCommand(core);
@@ -1465,7 +1465,15 @@ async function resolveChannel(token, target) {
1465
1465
  async function runDmChannel(target, opts) {
1466
1466
  try {
1467
1467
  const token = await ensureCognitoToken();
1468
- const channel = await resolveChannel(token, target);
1468
+ // A channel id with --json needs no lookup: the id is exact and JSON output
1469
+ // never uses the channel's name. Skipping the list saves fetching every
1470
+ // channel the caller is in (~1000 for an owner), which is what made polled
1471
+ // reads (the super-conductor message wake) take seconds instead of
1472
+ // milliseconds.
1473
+ const rawTarget = target.trim().replace(/^#/, "");
1474
+ const channel = opts.json && /^chn_[A-Za-z0-9]+$/.test(rawTarget)
1475
+ ? { channelId: rawTarget }
1476
+ : await resolveChannel(token, target);
1469
1477
  const channelId = channel.channelId;
1470
1478
  const query = {};
1471
1479
  if (opts.limit)
@@ -2,7 +2,6 @@ import { Command } from 'commander';
2
2
  import { AnywhereRuntimeDisabledError } from './anywhere-runtime-flag.js';
3
3
  import { type RuntimeHookTrustResult } from '../utils/hook-trust.js';
4
4
  import { type ShimPathCheck } from '../lib/windows/user-path.js';
5
- import { type HookTarget } from '../lib/install/claude-targets.js';
6
5
  export interface GlobalInstallOptions {
7
6
  /** HQ checkout to install from (absolute). */
8
7
  hqRoot: string;
@@ -32,13 +31,7 @@ export interface GlobalInstallOptions {
32
31
  /** Platform override for link creation (default process.platform). */
33
32
  platform?: NodeJS.Platform;
34
33
  }
35
- /** True when an hq daemon unit (launchd plist or systemd unit) exists. */
36
- export declare function isDaemonInstalled(home: string, platform?: NodeJS.Platform, queryTask?: (task: string) => boolean): boolean;
37
- /**
38
- * US-010: with hqd installed (and the checkout shipping the shim), hooks go to
39
- * the hqd shim; otherwise to master-hook.sh / the Codex adapter.
40
- */
41
- export declare function hookTargetFor(hqRoot: string, home: string, daemonInstalled?: boolean, env?: NodeJS.ProcessEnv): HookTarget;
34
+ export { hookTargetFor, isDaemonInstalled } from '../lib/install/hook-target.js';
42
35
  export interface ClaudePluginSource {
43
36
  sourceDir: string;
44
37
  marketplaceName: string;