@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.
- package/CHANGELOG.md +35 -0
- package/README.md +2 -0
- package/dist/command-catalog.generated.d.ts +44 -8
- package/dist/command-catalog.generated.js +53 -8
- package/dist/command-help.js +2 -1
- package/dist/commands/access.d.ts +45 -2
- package/dist/commands/access.js +537 -223
- package/dist/commands/billing-usage.d.ts +33 -2
- package/dist/commands/billing-usage.js +75 -4
- package/dist/commands/bot.js +2 -2
- package/dist/commands/channels.d.ts +27 -0
- package/dist/commands/channels.js +108 -4
- package/dist/commands/core-refresh-vault-access-native.d.ts +16 -0
- package/dist/commands/core-refresh-vault-access-native.js +142 -9
- package/dist/commands/core.js +4 -1
- package/dist/commands/dm.js +9 -1
- package/dist/commands/install-global.d.ts +1 -8
- package/dist/commands/install-global.js +11 -27
- package/dist/commands/library-add.d.ts +31 -1
- package/dist/commands/library-add.js +69 -2
- package/dist/commands/library.js +37 -3
- package/dist/commands/reindex.js +19 -0
- package/dist/hook-engine-path.js +10 -3
- package/dist/index.d.ts +10 -0
- package/dist/index.js +70 -5
- package/dist/lazy-commands.js +2 -0
- package/dist/lib/billing/usage-cost.d.ts +31 -1
- package/dist/lib/billing/usage-cost.js +46 -0
- package/dist/lib/db/local.d.ts +2 -2
- package/dist/lib/db/local.js +2 -2
- package/dist/lib/db/sql.js +2 -2
- package/dist/lib/db/sqlite-binding.d.ts +22 -0
- package/dist/lib/db/sqlite-binding.js +168 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +12 -0
- package/dist/lib/doctor/checks/anywhere.js +63 -12
- package/dist/lib/doctor/checks/cli-path.d.ts +2 -1
- package/dist/lib/doctor/checks/cli-path.js +2 -1
- package/dist/lib/doctor/checks/hook-engine.js +2 -1
- package/dist/lib/hook-engine/reconcile.d.ts +9 -0
- package/dist/lib/hook-engine/reconcile.js +134 -0
- package/dist/lib/hook-engine/registration.d.ts +17 -0
- package/dist/lib/hook-engine/registration.js +50 -0
- package/dist/lib/install/claude-targets.d.ts +2 -1
- package/dist/lib/install/claude-targets.js +8 -3
- package/dist/lib/install/codex-targets.d.ts +3 -0
- package/dist/lib/install/codex-targets.js +15 -4
- package/dist/lib/install/hook-target.d.ts +9 -0
- package/dist/lib/install/hook-target.js +39 -0
- package/dist/lib/windows/console-encoding.d.ts +24 -0
- package/dist/lib/windows/console-encoding.js +83 -0
- package/dist/lib/windows/install-followup.d.ts +35 -0
- package/dist/lib/windows/install-followup.js +142 -0
- package/dist/lib/windows/user-path.d.ts +80 -3
- package/dist/lib/windows/user-path.js +187 -26
- package/dist/main.js +2 -1
- package/dist/unknown-command.d.ts +18 -0
- package/dist/unknown-command.js +88 -0
- package/dist/utils/cognito-session.d.ts +8 -0
- package/dist/utils/cognito-session.js +14 -2
- package/dist/utils/hook-trust.d.ts +9 -0
- package/dist/utils/hook-trust.js +30 -2
- package/dist/utils/qmd-native-binding-error.js +3 -1
- package/dist/utils/resolve-executable.d.ts +2 -0
- package/dist/utils/resolve-executable.js +6 -2
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/bot.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
123
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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) =>
|
|
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();
|
package/dist/commands/core.js
CHANGED
|
@@ -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
|
-
.
|
|
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);
|
package/dist/commands/dm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|