@indigoai-us/hq-cli 5.119.8 → 5.119.10
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 +43 -0
- package/dist/command-catalog.generated.d.ts +54 -1
- package/dist/command-catalog.generated.js +71 -1
- package/dist/commands/agent-inbox.js +6 -0
- package/dist/commands/dm.d.ts +31 -0
- package/dist/commands/dm.js +24 -3
- package/dist/commands/skill.d.ts +20 -1
- package/dist/commands/skill.js +250 -15
- package/dist/commands/whoami.d.ts +3 -3
- package/dist/commands/whoami.js +5 -5
- package/dist/lib/agent-kit/inbox-state.d.ts +22 -2
- package/dist/lib/agent-kit/inbox-state.js +40 -1
- package/dist/lib/billing/plan-lock.d.ts +33 -18
- package/dist/lib/billing/plan-lock.js +46 -26
- package/dist/lib/plan-limit-nag.d.ts +71 -5
- package/dist/lib/plan-limit-nag.js +224 -37
- package/package.json +1 -1
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `plan-lock` (starter-plan-hard-limits / US-011
|
|
3
|
-
* of
|
|
2
|
+
* `plan-lock` (starter-plan-hard-limits / US-011, rewritten for US-036) — the
|
|
3
|
+
* CLI-side read + render of a workspace's Starter plan-limit state.
|
|
4
4
|
*
|
|
5
|
-
* Starter (free) workspaces are capped on
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* Starter (free) workspaces are capped on members, integrations, secrets and
|
|
6
|
+
* agents (deployments and storage nag but never arm a stop). Going over does
|
|
7
|
+
* NOT stop the workspace working (US-033 owner decision 12): everything that
|
|
8
|
+
* exists keeps working, and the only two things that pause are adding new
|
|
9
|
+
* files to the vault and adding new secrets (owner decision 13). Every string
|
|
10
|
+
* this module renders is a nag, never a claim that HQ has stopped. The state
|
|
11
|
+
* is NOT decided here — hq-pro is the single source of truth and ships the
|
|
12
|
+
* answer on `GET /membership/me` as a per-company `planLock` object. This
|
|
13
|
+
* module only reads that field and renders it.
|
|
14
|
+
*
|
|
15
|
+
* Copy rule (US-036): no string a customer reads may contain "locked" or
|
|
16
|
+
* "read-only". `plan-lock.test.ts` scans every rendered string for both.
|
|
12
17
|
*
|
|
13
18
|
* Member counts are decoration, never a second opinion: the count comes from
|
|
14
19
|
* `GET /v1/billing/usage-limits` on a best-effort basis and its absence only
|
|
@@ -20,7 +25,7 @@ import chalk from "chalk";
|
|
|
20
25
|
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
21
26
|
/**
|
|
22
27
|
* Mirror of hq-pro's `PlanLockReason`. Owner decision 7 (2026-09-17) fixes the
|
|
23
|
-
*
|
|
28
|
+
* nagging set at these four: `deployments` and `storageBytes` are nag-only and
|
|
24
29
|
* never appear here. An unrecognised reason is dropped by `parsePlanLock`, so a
|
|
25
30
|
* server that adds a fifth dimension renders as the generic line rather than as
|
|
26
31
|
* a false claim about members.
|
|
@@ -204,7 +209,7 @@ export async function fetchPlanLockStatus(token, companyRef, opts = {}) {
|
|
|
204
209
|
checkedAt: new Date().toISOString(),
|
|
205
210
|
};
|
|
206
211
|
}
|
|
207
|
-
/** Why the workspace
|
|
212
|
+
/** Why the workspace is over, one clause per reason. */
|
|
208
213
|
export function reasonLabel(reason) {
|
|
209
214
|
switch (reason) {
|
|
210
215
|
case "users":
|
|
@@ -261,23 +266,34 @@ function reasonDetail(reason, status) {
|
|
|
261
266
|
return "agents are not included on Starter";
|
|
262
267
|
}
|
|
263
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* The one sentence every over-limit surface says about the two hard stops
|
|
271
|
+
* (US-033 §3.3). Written once so the CLI, the console and the emails cannot
|
|
272
|
+
* drift into three different promises.
|
|
273
|
+
*/
|
|
274
|
+
export const HARD_STOP_SENTENCE = "Nothing has been deleted and everything keeps working, except that new " +
|
|
275
|
+
"files and new secrets are paused until this is sorted.";
|
|
276
|
+
const HARD_STOP_SENTENCE_LINES = [
|
|
277
|
+
" Nothing has been deleted and everything keeps working, except that new",
|
|
278
|
+
" files and new secrets are paused until this is sorted.",
|
|
279
|
+
];
|
|
264
280
|
function capitalize(text) {
|
|
265
281
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
266
282
|
}
|
|
267
283
|
/**
|
|
268
|
-
* The full
|
|
284
|
+
* The full over-limit block: why the workspace is over, where it stands
|
|
269
285
|
* against the cap, and the ways out. Plain text — colour is applied by the
|
|
270
286
|
* caller so scripts capturing stdout get a clean block.
|
|
271
287
|
*
|
|
272
|
-
* Every line is derived from `lock.reasons`. A workspace
|
|
288
|
+
* Every line is derived from `lock.reasons`. A workspace over on secrets is
|
|
273
289
|
* never told it has too many members, and an empty reason list (a server
|
|
274
290
|
* dimension this CLI does not know) renders a generic line rather than a claim
|
|
275
291
|
* about a dimension nobody measured.
|
|
276
292
|
*/
|
|
277
|
-
export function
|
|
293
|
+
export function renderPlanLimitNotice(status) {
|
|
278
294
|
const { lock, members, companySlug } = status;
|
|
279
295
|
const lines = [];
|
|
280
|
-
lines.push(`
|
|
296
|
+
lines.push(`HQ Starter — ${companySlug} is over its plan limits.`);
|
|
281
297
|
const reasons = lock.reasons.length
|
|
282
298
|
? lock.reasons.map(reasonLabel).join("; ")
|
|
283
299
|
: "over the Starter plan limits";
|
|
@@ -297,9 +313,9 @@ export function renderPlanLockNotice(status) {
|
|
|
297
313
|
if (lock.reasons.includes("agents")) {
|
|
298
314
|
lines.push(` Agents allowed on Starter: ${agentTarget(lock)}.`);
|
|
299
315
|
}
|
|
300
|
-
lines.push(
|
|
301
|
-
lines.push(
|
|
302
|
-
lines.push(" Two ways to
|
|
316
|
+
lines.push(HARD_STOP_SENTENCE_LINES[0]);
|
|
317
|
+
lines.push(HARD_STOP_SENTENCE_LINES[1]);
|
|
318
|
+
lines.push(" Two ways to sort it:");
|
|
303
319
|
const remedy = lock.reasons.length
|
|
304
320
|
? lock.reasons.map((reason) => reasonRemedy(reason, lock)).join(", and ")
|
|
305
321
|
: "come back under the Starter plan limits";
|
|
@@ -309,17 +325,17 @@ export function renderPlanLockNotice(status) {
|
|
|
309
325
|
return lines.join("\n");
|
|
310
326
|
}
|
|
311
327
|
/**
|
|
312
|
-
* The one-line form injected on every turn while the workspace stays
|
|
328
|
+
* The one-line form injected on every turn while the workspace stays over.
|
|
313
329
|
* Kept to a single line on purpose — it repeats each turn.
|
|
314
330
|
*/
|
|
315
|
-
export function
|
|
331
|
+
export function renderPlanLimitLine(status) {
|
|
316
332
|
const detail = status.lock.reasons.length
|
|
317
333
|
? status.lock.reasons
|
|
318
334
|
.map((reason) => reasonDetail(reason, status))
|
|
319
335
|
.join("; ")
|
|
320
336
|
: "over the Starter plan limits";
|
|
321
|
-
return (`Company ${status.companySlug} is
|
|
322
|
-
`
|
|
337
|
+
return (`Company ${status.companySlug} is over its Starter limits (${detail}). ` +
|
|
338
|
+
`New files and new secrets are paused until this is sorted: ${status.lock.upgradeUrl}`);
|
|
323
339
|
}
|
|
324
340
|
/**
|
|
325
341
|
* The `Plan: …` orientation line. Starter only: a paid or enterprise
|
|
@@ -327,7 +343,7 @@ export function renderPlanLockLine(status) {
|
|
|
327
343
|
*/
|
|
328
344
|
export function renderPlanLine(status) {
|
|
329
345
|
if (status.lock.locked)
|
|
330
|
-
return "Plan: Starter —
|
|
346
|
+
return "Plan: Starter — over its limits";
|
|
331
347
|
if (status.plan !== "free")
|
|
332
348
|
return null;
|
|
333
349
|
const target = status.lock.fixOptions.removeMembersTo;
|
|
@@ -336,8 +352,12 @@ export function renderPlanLine(status) {
|
|
|
336
352
|
}
|
|
337
353
|
return `Plan: Starter — ${target} members included`;
|
|
338
354
|
}
|
|
339
|
-
/**
|
|
340
|
-
|
|
341
|
-
|
|
355
|
+
/**
|
|
356
|
+
* Colourised block for interactive output. Yellow, not red: this is a nag, and
|
|
357
|
+
* an error colour would read as "HQ has stopped working", which is the exact
|
|
358
|
+
* impression US-033 removes.
|
|
359
|
+
*/
|
|
360
|
+
export function colorizePlanLimitNotice(notice) {
|
|
361
|
+
return chalk.yellow(notice);
|
|
342
362
|
}
|
|
343
363
|
//# sourceMappingURL=plan-lock.js.map
|
|
@@ -5,17 +5,41 @@
|
|
|
5
5
|
* some 2xx JSON response bodies (US-012). This module:
|
|
6
6
|
*
|
|
7
7
|
* 1. Best-effort records the last-seen status from decoded JSON bodies
|
|
8
|
-
* (`recordPlanLimitStatus`)
|
|
8
|
+
* (`recordPlanLimitStatus`), and prints the one-time threshold notice
|
|
9
|
+
* right there — see below.
|
|
9
10
|
* 2. Emits a stderr nag at command completion (`emitPlanLimitNag`):
|
|
10
11
|
* - entries present, none over (≥80% warning): one-line yellow warning,
|
|
11
12
|
* once per process session
|
|
12
|
-
* - any resource over:
|
|
13
|
-
* `~/.hq/plan-limit-nag.json`) and once per session
|
|
13
|
+
* - any resource over: one-line notice, at most once every 6 hours
|
|
14
|
+
* (persisted in `~/.hq/plan-limit-nag.json`) and once per session
|
|
15
|
+
*
|
|
16
|
+
* The one-time threshold notice (US-035) is the third surface and the only one
|
|
17
|
+
* that does NOT wait for command completion. hq-pro attaches
|
|
18
|
+
* `thresholdCrossings: [{ resource, band, firstSeenAt, used, limit }]` beside
|
|
19
|
+
* `planLimits`, populated from the server's own threshold state, and this
|
|
20
|
+
* module prints each `{resource}:{band}` exactly once per EPISODE — persisted
|
|
21
|
+
* in `~/.hq/plan-limit-nag.json` under `notifiedThresholds`. It prints from the
|
|
22
|
+
* record path so a session that is already running surfaces the notice on its
|
|
23
|
+
* next API call rather than on its next launch, which is the whole point of
|
|
24
|
+
* carrying the crossing on the wire instead of recomputing it locally.
|
|
25
|
+
*
|
|
26
|
+
* "Once per episode", not once per lifetime: the stored value is the server's
|
|
27
|
+
* `firstSeenAt`, so a dimension that drops back under the threshold and climbs
|
|
28
|
+
* again arrives with a new timestamp and notifies again. The server decides
|
|
29
|
+
* when an episode ends; this module only remembers what it has printed.
|
|
14
30
|
*
|
|
15
31
|
* Additive only — never throws, never touches `process.exitCode`, never
|
|
16
|
-
* writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1
|
|
32
|
+
* writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1` silences all
|
|
33
|
+
* three surfaces, the threshold notice included.
|
|
17
34
|
*/
|
|
18
35
|
export declare const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
|
|
36
|
+
/**
|
|
37
|
+
* Cadence of the over-limit per-turn line (US-036). Was once a day; the nag
|
|
38
|
+
* model wants it more often than that and still not on every turn, so the
|
|
39
|
+
* window is six hours — up to four lines in a day for someone working all day,
|
|
40
|
+
* and one for someone who runs a single command.
|
|
41
|
+
*/
|
|
42
|
+
export declare const OVER_NAG_INTERVAL_MS: number;
|
|
19
43
|
/**
|
|
20
44
|
* Plain-English name for each plan-limit resource key, so a nag line says which
|
|
21
45
|
* dimension is tight rather than only the wire key. The key itself stays in the
|
|
@@ -31,12 +55,54 @@ export interface PlanLimitEntry {
|
|
|
31
55
|
}
|
|
32
56
|
/** Validated map of resource key → entry. */
|
|
33
57
|
export type PlanLimitsMap = Record<string, PlanLimitEntry>;
|
|
58
|
+
/** The two bands hq-pro reports. Anything else on the wire is ignored. */
|
|
59
|
+
export declare const PLAN_LIMIT_BANDS: readonly ["warn", "over"];
|
|
60
|
+
export type PlanLimitBand = (typeof PLAN_LIMIT_BANDS)[number];
|
|
61
|
+
/** One server-announced threshold crossing, as it arrives on the wire. */
|
|
62
|
+
export interface PlanLimitThresholdCrossing {
|
|
63
|
+
resource: string;
|
|
64
|
+
band: PlanLimitBand;
|
|
65
|
+
/** ISO time the server first recorded this band — the episode identity. */
|
|
66
|
+
firstSeenAt: string;
|
|
67
|
+
used: number;
|
|
68
|
+
limit: number;
|
|
69
|
+
}
|
|
34
70
|
/**
|
|
35
71
|
* Record plan-limit status from a decoded JSON response body.
|
|
36
72
|
* Never throws. Overwrites the last-seen cell when a well-formed
|
|
37
73
|
* `planLimits` object is present; ignores malformed / absent payloads.
|
|
38
74
|
*/
|
|
39
|
-
export declare function recordPlanLimitStatus(body: unknown
|
|
75
|
+
export declare function recordPlanLimitStatus(body: unknown, opts?: {
|
|
76
|
+
write?: (s: string) => void;
|
|
77
|
+
statePath?: string;
|
|
78
|
+
}): void;
|
|
79
|
+
/**
|
|
80
|
+
* The over-limit per-turn line (US-036 §2.2). One line, not the old box: it
|
|
81
|
+
* now repeats up to four times a day, and a multi-line box at that cadence
|
|
82
|
+
* reads as breakage rather than as a nag. It states the two paused things and
|
|
83
|
+
* never claims the workspace has stopped working.
|
|
84
|
+
*/
|
|
85
|
+
export declare function buildOverLine(overEntries: Array<[string, PlanLimitEntry]>, upgradeUrl: string): string;
|
|
86
|
+
/**
|
|
87
|
+
* One line per crossing.
|
|
88
|
+
*
|
|
89
|
+
* The copy states the fact and the exit, nothing else. It deliberately does
|
|
90
|
+
* not describe consequences that have not shipped: the hard stops arrive with
|
|
91
|
+
* their own story, and a notice that announces a pause nobody is experiencing
|
|
92
|
+
* reads as a bug.
|
|
93
|
+
*/
|
|
94
|
+
export declare function renderThresholdNotice(crossing: PlanLimitThresholdCrossing, upgradeUrl: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* Print every crossing this machine has not already printed for this episode.
|
|
97
|
+
*
|
|
98
|
+
* Called from the record path, so a session already running prints on its next
|
|
99
|
+
* API call. Never throws; a state file it cannot read or write degrades to
|
|
100
|
+
* per-process dedupe rather than to silence or to repetition.
|
|
101
|
+
*/
|
|
102
|
+
export declare function emitPlanLimitThresholdNotice(crossings: readonly PlanLimitThresholdCrossing[], upgradeUrl: string | null, opts?: {
|
|
103
|
+
write?: (s: string) => void;
|
|
104
|
+
statePath?: string;
|
|
105
|
+
}): void;
|
|
40
106
|
/**
|
|
41
107
|
* Emit a plan-limit nag to stderr at command completion.
|
|
42
108
|
*
|
|
@@ -5,22 +5,45 @@
|
|
|
5
5
|
* some 2xx JSON response bodies (US-012). This module:
|
|
6
6
|
*
|
|
7
7
|
* 1. Best-effort records the last-seen status from decoded JSON bodies
|
|
8
|
-
* (`recordPlanLimitStatus`)
|
|
8
|
+
* (`recordPlanLimitStatus`), and prints the one-time threshold notice
|
|
9
|
+
* right there — see below.
|
|
9
10
|
* 2. Emits a stderr nag at command completion (`emitPlanLimitNag`):
|
|
10
11
|
* - entries present, none over (≥80% warning): one-line yellow warning,
|
|
11
12
|
* once per process session
|
|
12
|
-
* - any resource over:
|
|
13
|
-
* `~/.hq/plan-limit-nag.json`) and once per session
|
|
13
|
+
* - any resource over: one-line notice, at most once every 6 hours
|
|
14
|
+
* (persisted in `~/.hq/plan-limit-nag.json`) and once per session
|
|
15
|
+
*
|
|
16
|
+
* The one-time threshold notice (US-035) is the third surface and the only one
|
|
17
|
+
* that does NOT wait for command completion. hq-pro attaches
|
|
18
|
+
* `thresholdCrossings: [{ resource, band, firstSeenAt, used, limit }]` beside
|
|
19
|
+
* `planLimits`, populated from the server's own threshold state, and this
|
|
20
|
+
* module prints each `{resource}:{band}` exactly once per EPISODE — persisted
|
|
21
|
+
* in `~/.hq/plan-limit-nag.json` under `notifiedThresholds`. It prints from the
|
|
22
|
+
* record path so a session that is already running surfaces the notice on its
|
|
23
|
+
* next API call rather than on its next launch, which is the whole point of
|
|
24
|
+
* carrying the crossing on the wire instead of recomputing it locally.
|
|
25
|
+
*
|
|
26
|
+
* "Once per episode", not once per lifetime: the stored value is the server's
|
|
27
|
+
* `firstSeenAt`, so a dimension that drops back under the threshold and climbs
|
|
28
|
+
* again arrives with a new timestamp and notifies again. The server decides
|
|
29
|
+
* when an episode ends; this module only remembers what it has printed.
|
|
14
30
|
*
|
|
15
31
|
* Additive only — never throws, never touches `process.exitCode`, never
|
|
16
|
-
* writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1
|
|
32
|
+
* writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1` silences all
|
|
33
|
+
* three surfaces, the threshold notice included.
|
|
17
34
|
*/
|
|
18
35
|
import chalk from "chalk";
|
|
19
36
|
import * as fs from "node:fs";
|
|
20
37
|
import * as os from "node:os";
|
|
21
38
|
import * as path from "node:path";
|
|
22
39
|
export const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
|
|
23
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Cadence of the over-limit per-turn line (US-036). Was once a day; the nag
|
|
42
|
+
* model wants it more often than that and still not on every turn, so the
|
|
43
|
+
* window is six hours — up to four lines in a day for someone working all day,
|
|
44
|
+
* and one for someone who runs a single command.
|
|
45
|
+
*/
|
|
46
|
+
export const OVER_NAG_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
24
47
|
/**
|
|
25
48
|
* Plain-English name for each plan-limit resource key, so a nag line says which
|
|
26
49
|
* dimension is tight rather than only the wire key. The key itself stays in the
|
|
@@ -40,12 +63,22 @@ function dimensionSuffix(key) {
|
|
|
40
63
|
const label = PLAN_LIMIT_DIMENSION_LABELS[key];
|
|
41
64
|
return label && label !== key ? ` (${label})` : "";
|
|
42
65
|
}
|
|
66
|
+
/** The two bands hq-pro reports. Anything else on the wire is ignored. */
|
|
67
|
+
export const PLAN_LIMIT_BANDS = ["warn", "over"];
|
|
43
68
|
/** Module-level last-seen cell — overwritten by each successful parse. */
|
|
44
69
|
let lastSeen = null;
|
|
45
70
|
/** Session dedupe for the ≥80% one-line warning. */
|
|
46
71
|
let warningShownThisSession = false;
|
|
47
|
-
/** Session dedupe for the over-limit
|
|
72
|
+
/** Session dedupe for the over-limit line. */
|
|
48
73
|
let overShownThisSession = false;
|
|
74
|
+
/**
|
|
75
|
+
* `"{resource}:{band}"` → `firstSeenAt` already printed in THIS process.
|
|
76
|
+
*
|
|
77
|
+
* The persisted file is the durable dedupe; this map exists so a process that
|
|
78
|
+
* cannot write `~/.hq` (read-only home, sandboxed agent) still prints each
|
|
79
|
+
* crossing once rather than on every single API call.
|
|
80
|
+
*/
|
|
81
|
+
const thresholdsShownThisSession = new Map();
|
|
49
82
|
function defaultStatePath() {
|
|
50
83
|
return path.join(os.homedir(), ".hq", "plan-limit-nag.json");
|
|
51
84
|
}
|
|
@@ -122,18 +155,67 @@ function parsePlanLimits(body) {
|
|
|
122
155
|
}
|
|
123
156
|
: null;
|
|
124
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Defensively parse the top-level `thresholdCrossings` array.
|
|
160
|
+
*
|
|
161
|
+
* Every field is validated and a malformed ROW is dropped rather than failing
|
|
162
|
+
* the whole array — an older CLI must stay useful against a newer server that
|
|
163
|
+
* has added a band or a field. An unrecognised band is dropped for the same
|
|
164
|
+
* reason it is not guessed at: printing "you have reached critical of your
|
|
165
|
+
* secrets" is worse than printing nothing.
|
|
166
|
+
*/
|
|
167
|
+
function parseThresholdCrossings(body) {
|
|
168
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
const raw = body.thresholdCrossings;
|
|
172
|
+
if (!Array.isArray(raw))
|
|
173
|
+
return [];
|
|
174
|
+
const out = [];
|
|
175
|
+
for (const value of raw) {
|
|
176
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const rec = value;
|
|
180
|
+
if (typeof rec.resource !== "string" || rec.resource.length === 0)
|
|
181
|
+
continue;
|
|
182
|
+
if (typeof rec.band !== "string" ||
|
|
183
|
+
!PLAN_LIMIT_BANDS.includes(rec.band)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (typeof rec.firstSeenAt !== "string" || rec.firstSeenAt.length === 0) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (typeof rec.used !== "number" || !Number.isFinite(rec.used))
|
|
190
|
+
continue;
|
|
191
|
+
if (typeof rec.limit !== "number" || !Number.isFinite(rec.limit))
|
|
192
|
+
continue;
|
|
193
|
+
out.push({
|
|
194
|
+
resource: rec.resource,
|
|
195
|
+
band: rec.band,
|
|
196
|
+
firstSeenAt: rec.firstSeenAt,
|
|
197
|
+
used: rec.used,
|
|
198
|
+
limit: rec.limit,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
125
203
|
/**
|
|
126
204
|
* Record plan-limit status from a decoded JSON response body.
|
|
127
205
|
* Never throws. Overwrites the last-seen cell when a well-formed
|
|
128
206
|
* `planLimits` object is present; ignores malformed / absent payloads.
|
|
129
207
|
*/
|
|
130
|
-
export function recordPlanLimitStatus(body) {
|
|
208
|
+
export function recordPlanLimitStatus(body, opts = {}) {
|
|
131
209
|
try {
|
|
132
210
|
const status = parsePlanLimits(body);
|
|
133
|
-
if (status
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
211
|
+
if (status !== null) {
|
|
212
|
+
const anyOver = Object.values(status.limits).some((e) => e.over);
|
|
213
|
+
lastSeen = { ...status, anyOver };
|
|
214
|
+
}
|
|
215
|
+
// Independent of `planLimits`: the server can announce a crossing on a
|
|
216
|
+
// response whose nag map this CLI could not parse, and the notice is the
|
|
217
|
+
// one surface that must land inside a session that is already running.
|
|
218
|
+
emitPlanLimitThresholdNotice(parseThresholdCrossings(body), status?.upgradeUrl ?? null, opts);
|
|
137
219
|
}
|
|
138
220
|
catch {
|
|
139
221
|
// Never throw from record path.
|
|
@@ -163,44 +245,148 @@ function formatPct(entry) {
|
|
|
163
245
|
function formatEntryLine(key, entry) {
|
|
164
246
|
return `${key} at ${entry.used}/${entry.limit} (${formatPct(entry)})${dimensionSuffix(key)}`;
|
|
165
247
|
}
|
|
166
|
-
|
|
248
|
+
/**
|
|
249
|
+
* Read the whole state file. A missing, unreadable or malformed file is an
|
|
250
|
+
* empty state — the nag re-arms rather than being silenced by a bad write.
|
|
251
|
+
*/
|
|
252
|
+
function readNagState(statePath) {
|
|
167
253
|
try {
|
|
168
254
|
const raw = fs.readFileSync(statePath, "utf-8");
|
|
169
255
|
const parsed = JSON.parse(raw);
|
|
170
|
-
if (typeof parsed
|
|
171
|
-
return
|
|
256
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
257
|
+
return {};
|
|
172
258
|
}
|
|
173
|
-
|
|
259
|
+
const rec = parsed;
|
|
260
|
+
const state = {};
|
|
261
|
+
if (typeof rec.shownAt === "number" && Number.isFinite(rec.shownAt)) {
|
|
262
|
+
state.shownAt = rec.shownAt;
|
|
263
|
+
}
|
|
264
|
+
if (rec.notifiedThresholds !== null &&
|
|
265
|
+
typeof rec.notifiedThresholds === "object" &&
|
|
266
|
+
!Array.isArray(rec.notifiedThresholds)) {
|
|
267
|
+
const entries = Object.entries(rec.notifiedThresholds).filter((pair) => typeof pair[1] === "string");
|
|
268
|
+
if (entries.length > 0) {
|
|
269
|
+
state.notifiedThresholds = Object.fromEntries(entries);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return state;
|
|
174
273
|
}
|
|
175
274
|
catch {
|
|
176
|
-
return
|
|
275
|
+
return {};
|
|
177
276
|
}
|
|
178
277
|
}
|
|
179
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Merge `patch` into the state file and write it back.
|
|
280
|
+
*
|
|
281
|
+
* Read-modify-write rather than overwrite, so the over-limit box's `shownAt`
|
|
282
|
+
* and the threshold notice's `notifiedThresholds` — written by different
|
|
283
|
+
* surfaces at different moments — cannot erase each other.
|
|
284
|
+
*/
|
|
285
|
+
function updateNagState(statePath, patch) {
|
|
180
286
|
try {
|
|
287
|
+
const current = readNagState(statePath);
|
|
288
|
+
const next = {
|
|
289
|
+
...current,
|
|
290
|
+
...patch,
|
|
291
|
+
...(patch.notifiedThresholds
|
|
292
|
+
? {
|
|
293
|
+
notifiedThresholds: {
|
|
294
|
+
...current.notifiedThresholds,
|
|
295
|
+
...patch.notifiedThresholds,
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
: {}),
|
|
299
|
+
};
|
|
181
300
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
182
|
-
|
|
183
|
-
fs.writeFileSync(statePath, JSON.stringify(payload));
|
|
301
|
+
fs.writeFileSync(statePath, JSON.stringify(next));
|
|
184
302
|
}
|
|
185
303
|
catch {
|
|
186
304
|
// best-effort; never break the CLI on cache write failure
|
|
187
305
|
}
|
|
188
306
|
}
|
|
189
|
-
function
|
|
190
|
-
return
|
|
307
|
+
function readShownAt(statePath) {
|
|
308
|
+
return readNagState(statePath).shownAt ?? null;
|
|
309
|
+
}
|
|
310
|
+
function writeShownAt(statePath, shownAt) {
|
|
311
|
+
updateNagState(statePath, { shownAt });
|
|
312
|
+
}
|
|
313
|
+
function withinNagWindow(shownAt, nowMs) {
|
|
314
|
+
return nowMs - shownAt < OVER_NAG_INTERVAL_MS;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The over-limit per-turn line (US-036 §2.2). One line, not the old box: it
|
|
318
|
+
* now repeats up to four times a day, and a multi-line box at that cadence
|
|
319
|
+
* reads as breakage rather than as a nag. It states the two paused things and
|
|
320
|
+
* never claims the workspace has stopped working.
|
|
321
|
+
*/
|
|
322
|
+
export function buildOverLine(overEntries, upgradeUrl) {
|
|
323
|
+
const facts = overEntries
|
|
324
|
+
.map(([key, entry]) => `${PLAN_LIMIT_DIMENSION_LABELS[key] ?? key} ${entry.used} of ${entry.limit}`)
|
|
325
|
+
.join(", ");
|
|
326
|
+
return (`⚠ HQ Starter: ${facts}. New files and new secrets are paused. ` +
|
|
327
|
+
`Upgrade: ${upgradeUrl}`);
|
|
328
|
+
}
|
|
329
|
+
/** `"{resource}:{band}"` — the persisted key, matching the server's own. */
|
|
330
|
+
function crossingKey(crossing) {
|
|
331
|
+
return `${crossing.resource}:${crossing.band}`;
|
|
332
|
+
}
|
|
333
|
+
/** Human dimension name, falling back to the wire key for an unknown resource. */
|
|
334
|
+
function dimensionName(resource) {
|
|
335
|
+
return PLAN_LIMIT_DIMENSION_LABELS[resource] ?? resource;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* One line per crossing.
|
|
339
|
+
*
|
|
340
|
+
* The copy states the fact and the exit, nothing else. It deliberately does
|
|
341
|
+
* not describe consequences that have not shipped: the hard stops arrive with
|
|
342
|
+
* their own story, and a notice that announces a pause nobody is experiencing
|
|
343
|
+
* reads as a bug.
|
|
344
|
+
*/
|
|
345
|
+
export function renderThresholdNotice(crossing, upgradeUrl) {
|
|
346
|
+
const name = dimensionName(crossing.resource);
|
|
347
|
+
const counts = `${crossing.used} of ${crossing.limit}`;
|
|
348
|
+
const headline = crossing.band === "over"
|
|
349
|
+
? `HQ Starter: you are past your ${name} limit (${counts}).`
|
|
350
|
+
: `HQ Starter: you have reached 80% of your ${name} (${counts}).`;
|
|
351
|
+
return `${headline} Upgrade: ${upgradeUrl}`;
|
|
191
352
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
353
|
+
/**
|
|
354
|
+
* Print every crossing this machine has not already printed for this episode.
|
|
355
|
+
*
|
|
356
|
+
* Called from the record path, so a session already running prints on its next
|
|
357
|
+
* API call. Never throws; a state file it cannot read or write degrades to
|
|
358
|
+
* per-process dedupe rather than to silence or to repetition.
|
|
359
|
+
*/
|
|
360
|
+
export function emitPlanLimitThresholdNotice(crossings, upgradeUrl, opts = {}) {
|
|
361
|
+
try {
|
|
362
|
+
if (!isPlanLimitNagEnabled())
|
|
363
|
+
return;
|
|
364
|
+
if (crossings.length === 0)
|
|
365
|
+
return;
|
|
366
|
+
const write = opts.write ?? ((s) => process.stderr.write(s));
|
|
367
|
+
const statePath = opts.statePath ?? defaultStatePath();
|
|
368
|
+
const stored = readNagState(statePath).notifiedThresholds ?? {};
|
|
369
|
+
const resolvedUpgradeUrl = upgradeUrl ?? PLAN_LIMIT_UPGRADE_URL;
|
|
370
|
+
const printed = {};
|
|
371
|
+
for (const crossing of crossings) {
|
|
372
|
+
const key = crossingKey(crossing);
|
|
373
|
+
// A DIFFERENT firstSeenAt is a different episode and prints again; the
|
|
374
|
+
// same one has already been said.
|
|
375
|
+
if (stored[key] === crossing.firstSeenAt)
|
|
376
|
+
continue;
|
|
377
|
+
if (thresholdsShownThisSession.get(key) === crossing.firstSeenAt)
|
|
378
|
+
continue;
|
|
379
|
+
thresholdsShownThisSession.set(key, crossing.firstSeenAt);
|
|
380
|
+
printed[key] = crossing.firstSeenAt;
|
|
381
|
+
write(chalk.yellow(renderThresholdNotice(crossing, resolvedUpgradeUrl)) + "\n");
|
|
382
|
+
}
|
|
383
|
+
if (Object.keys(printed).length > 0) {
|
|
384
|
+
updateNagState(statePath, { notifiedThresholds: printed });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
// Never throw from the notice path.
|
|
389
|
+
}
|
|
204
390
|
}
|
|
205
391
|
/**
|
|
206
392
|
* Emit a plan-limit nag to stderr at command completion.
|
|
@@ -227,12 +413,12 @@ export function emitPlanLimitNag(opts = {}) {
|
|
|
227
413
|
return;
|
|
228
414
|
const nowMs = now().getTime();
|
|
229
415
|
const prev = readShownAt(statePath);
|
|
230
|
-
if (prev !== null &&
|
|
416
|
+
if (prev !== null && withinNagWindow(prev, nowMs))
|
|
231
417
|
return;
|
|
232
418
|
overShownThisSession = true;
|
|
233
419
|
const overEntries = entries.filter(([, e]) => e.over);
|
|
234
|
-
const
|
|
235
|
-
write(chalk.yellow(
|
|
420
|
+
const line = buildOverLine(overEntries, resolvedUpgradeUrl);
|
|
421
|
+
write(chalk.yellow(line) + "\n");
|
|
236
422
|
writeShownAt(statePath, nowMs);
|
|
237
423
|
return;
|
|
238
424
|
}
|
|
@@ -243,7 +429,7 @@ export function emitPlanLimitNag(opts = {}) {
|
|
|
243
429
|
if (worst === null)
|
|
244
430
|
return;
|
|
245
431
|
warningShownThisSession = true;
|
|
246
|
-
const line = `⚠ HQ Starter
|
|
432
|
+
const line = `⚠ HQ Starter: ${formatEntryLine(worst.key, worst.entry)}. Add headroom before you hit the cap: ${resolvedUpgradeUrl}`;
|
|
247
433
|
write(chalk.yellow(line) + "\n");
|
|
248
434
|
}
|
|
249
435
|
catch {
|
|
@@ -255,5 +441,6 @@ export function _resetForTests() {
|
|
|
255
441
|
lastSeen = null;
|
|
256
442
|
warningShownThisSession = false;
|
|
257
443
|
overShownThisSession = false;
|
|
444
|
+
thresholdsShownThisSession.clear();
|
|
258
445
|
}
|
|
259
446
|
//# sourceMappingURL=plan-limit-nag.js.map
|