@indigoai-us/hq-cli 5.119.7 → 5.119.9
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 +39 -0
- package/dist/commands/agent-inbox.js +6 -0
- package/dist/commands/dm.d.ts +38 -0
- package/dist/commands/dm.js +37 -4
- 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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.119.9] — 2026-09-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- The CLI now prints a one-time notice when your workspace crosses a plan
|
|
10
|
+
threshold — reaching 80% of a Starter limit, or going past one. The server
|
|
11
|
+
decides when a threshold has been crossed and sends it with the response, so
|
|
12
|
+
a session you already have open surfaces the notice on its next API call
|
|
13
|
+
rather than on its next launch. Each `{resource}:{band}` is printed exactly
|
|
14
|
+
once per episode, remembered in `~/.hq/plan-limit-nag.json`; a dimension that
|
|
15
|
+
drops back under the threshold and climbs again is announced again.
|
|
16
|
+
`HQ_NO_PLAN_LIMIT_NAG=1` silences it along with the other plan-limit lines.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- A file attached to a DM is now visible to a bot reading its mail. `hq dm
|
|
21
|
+
inbox`, `hq dm thread`, `hq dm channel` and `hq agent inbox` print each
|
|
22
|
+
attached file's name, type and vault path under the message body; the JSON
|
|
23
|
+
forms carry an `attachments[]` field. Previously only the body text was
|
|
24
|
+
rendered, so an agent sent a screenshot answered "no image came through".
|
|
25
|
+
|
|
26
|
+
## [5.119.8] — 2026-09-17
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Channel posts that mention people by full name no longer show the parsing
|
|
31
|
+
quotes in the room. `@"Jacob Posel"` is still how a multi-word name is
|
|
32
|
+
typed, but the message is posted as `@Jacob Posel`, the way a person would
|
|
33
|
+
have written it. The structured mention itself is unchanged.
|
|
34
|
+
|
|
5
35
|
## [5.119.7] — 2026-09-17
|
|
6
36
|
|
|
7
37
|
### Fixed
|
|
@@ -35,6 +65,15 @@
|
|
|
35
65
|
on Starter: 1", and tells you to "disconnect integrations until you are at 1
|
|
36
66
|
or fewer"; the per-turn line reads "over its 1-integration limit". Nothing in
|
|
37
67
|
the CLI says Starter has no integrations any more.
|
|
68
|
+
- Plan-limit messages now say what is actually happening. Going over a Starter
|
|
69
|
+
limit never stopped the workspace, and the copy no longer implies it did:
|
|
70
|
+
the words "locked" and "read-only" are gone from every line `hq` prints, and
|
|
71
|
+
the notice states the two things that do pause — adding new files and adding
|
|
72
|
+
new secrets — while everything else keeps working. A test scans every
|
|
73
|
+
rendered string for both words so the copy cannot drift back.
|
|
74
|
+
- The over-limit per-turn nag is one line instead of a box, and it repeats at
|
|
75
|
+
most once every 6 hours (was once a day). `HQ_NO_PLAN_LIMIT_NAG=1` still
|
|
76
|
+
silences it.
|
|
38
77
|
|
|
39
78
|
## [5.119.4] — 2026-09-17
|
|
40
79
|
|
|
@@ -52,6 +52,12 @@ export function registerAgentInboxCommand(agent) {
|
|
|
52
52
|
console.log(`${chalk.bold(r.id)}${state} ${r.channel ?? "?"} from ${r.from}${r.fromUid ? ` <${r.fromUid}>` : ""} ${r.at ?? ""}`);
|
|
53
53
|
if (r.text)
|
|
54
54
|
console.log(` ${r.text.replace(/\n/g, "\n ")}`);
|
|
55
|
+
for (const f of r.attachments ?? []) {
|
|
56
|
+
const label = f.name ?? f.vaultPath.split("/").pop() ?? "file";
|
|
57
|
+
const type = f.contentType ? ` (${f.contentType})` : "";
|
|
58
|
+
console.log(` ${chalk.dim("[file]")} ${label}${type}`);
|
|
59
|
+
console.log(` ${chalk.dim(`vault: ${f.vaultPath}`)}`);
|
|
60
|
+
}
|
|
55
61
|
}
|
|
56
62
|
if (!opts.all)
|
|
57
63
|
console.log(chalk.dim(`\nReply with hq dm <uid> "…", then: hq agent inbox done <id>`));
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -97,6 +97,13 @@ export declare function mentionParticipantType(uid: string): "agent" | "human";
|
|
|
97
97
|
* Trailing punctuation is trimmed ("@Izzy," → "Izzy"), so a name is never
|
|
98
98
|
* polluted by the sentence around it.
|
|
99
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* Once a quoted token has resolved to a real mention, the quotes have done
|
|
102
|
+
* their job (they only exist so a multi-word name parses as one token). Post
|
|
103
|
+
* the body with `@"Jacob Posel"` rewritten to `@Jacob Posel`, which is how a
|
|
104
|
+
* person would have typed it and how the room should read it. Pure.
|
|
105
|
+
*/
|
|
106
|
+
export declare function stripMentionQuotes(body: string): string;
|
|
100
107
|
export declare function parseMentionTokens(body: string): string[];
|
|
101
108
|
/**
|
|
102
109
|
* The names one roster row answers to, lowercased: its display name, the slug of
|
|
@@ -194,6 +201,9 @@ export interface DmInboxEvent {
|
|
|
194
201
|
details?: string;
|
|
195
202
|
prompt?: string;
|
|
196
203
|
acknowledgedAt?: string;
|
|
204
|
+
/** US-007 file references. Singular `attachment` is the legacy form. */
|
|
205
|
+
attachments?: DmAttachment[];
|
|
206
|
+
attachment?: DmAttachment;
|
|
197
207
|
}
|
|
198
208
|
/** One message in a 1:1 thread as returned by GET /v1/notify/thread. */
|
|
199
209
|
export interface DmThreadMessage {
|
|
@@ -206,6 +216,8 @@ export interface DmThreadMessage {
|
|
|
206
216
|
direction: "in" | "out";
|
|
207
217
|
details?: string;
|
|
208
218
|
prompt?: string;
|
|
219
|
+
attachments?: DmAttachment[];
|
|
220
|
+
attachment?: DmAttachment;
|
|
209
221
|
}
|
|
210
222
|
/** One channel/group message from GET /v1/notify/channels/{id}/messages. */
|
|
211
223
|
export interface ChannelMessageItem {
|
|
@@ -216,7 +228,33 @@ export interface ChannelMessageItem {
|
|
|
216
228
|
fromDisplayName?: string;
|
|
217
229
|
body: string;
|
|
218
230
|
createdAt: string;
|
|
231
|
+
attachments?: DmAttachment[];
|
|
232
|
+
attachment?: DmAttachment;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* One file attached to a DM (US-007 `attachments[]` on the read endpoints). The
|
|
236
|
+
* server returns a vault path, not bytes: a reader fetches it with
|
|
237
|
+
* a presigned read of that key.
|
|
238
|
+
*/
|
|
239
|
+
export interface DmAttachment {
|
|
240
|
+
vaultPath: string;
|
|
241
|
+
name?: string;
|
|
242
|
+
sizeBytes?: number;
|
|
243
|
+
contentType?: string;
|
|
244
|
+
kind?: string;
|
|
245
|
+
companyUid?: string;
|
|
219
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Render the attachment lines for one message. An agent reading its DMs through
|
|
249
|
+
* the CLI only ever sees this text, so the vault path AND the command that
|
|
250
|
+
* fetches it both have to be on screen — a bare "[image]" marker is why a bot
|
|
251
|
+
* answers "no image came through". Returns "" when there is nothing attached.
|
|
252
|
+
* Pure → unit-testable.
|
|
253
|
+
*/
|
|
254
|
+
export declare function formatAttachments(m: {
|
|
255
|
+
attachments?: DmAttachment[];
|
|
256
|
+
attachment?: DmAttachment;
|
|
257
|
+
}, indent?: string): string;
|
|
220
258
|
/**
|
|
221
259
|
* Human label for a message sender — display name, else email, else uid. Pure →
|
|
222
260
|
* unit-testable.
|
package/dist/commands/dm.js
CHANGED
|
@@ -138,6 +138,15 @@ export function mentionParticipantType(uid) {
|
|
|
138
138
|
* Trailing punctuation is trimmed ("@Izzy," → "Izzy"), so a name is never
|
|
139
139
|
* polluted by the sentence around it.
|
|
140
140
|
*/
|
|
141
|
+
/**
|
|
142
|
+
* Once a quoted token has resolved to a real mention, the quotes have done
|
|
143
|
+
* their job (they only exist so a multi-word name parses as one token). Post
|
|
144
|
+
* the body with `@"Jacob Posel"` rewritten to `@Jacob Posel`, which is how a
|
|
145
|
+
* person would have typed it and how the room should read it. Pure.
|
|
146
|
+
*/
|
|
147
|
+
export function stripMentionQuotes(body) {
|
|
148
|
+
return body.replace(/(^|[\s([{<])@"([^"\n]{1,80})"/g, "$1@$2");
|
|
149
|
+
}
|
|
141
150
|
export function parseMentionTokens(body) {
|
|
142
151
|
const pattern = /(^|[\s([{<])@(?:"([^"\n]{1,80})"|([A-Za-z0-9][A-Za-z0-9._-]*))/g;
|
|
143
152
|
const names = [];
|
|
@@ -515,7 +524,7 @@ async function fetchChannelMembers(token, channelId) {
|
|
|
515
524
|
*/
|
|
516
525
|
async function runChannelSend(channelName, message, opts) {
|
|
517
526
|
try {
|
|
518
|
-
|
|
527
|
+
let body = (message ?? "").trim();
|
|
519
528
|
if (!body) {
|
|
520
529
|
console.error(chalk.red(`A message body is required: hq dm ${channelName} "<message>" (or hq dm --channel ${channelName} "<message>").`));
|
|
521
530
|
process.exit(1);
|
|
@@ -560,6 +569,9 @@ async function runChannelSend(channelName, message, opts) {
|
|
|
560
569
|
mentions = resolved.mentions;
|
|
561
570
|
}
|
|
562
571
|
}
|
|
572
|
+
// Send the human-readable form: the quotes only existed for parsing.
|
|
573
|
+
if (mentions.length > 0)
|
|
574
|
+
body = stripMentionQuotes(body);
|
|
563
575
|
const sendRes = await vaultApiFetch({
|
|
564
576
|
token,
|
|
565
577
|
path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`,
|
|
@@ -681,6 +693,27 @@ async function runDmRequests() {
|
|
|
681
693
|
process.exit(1);
|
|
682
694
|
}
|
|
683
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* Render the attachment lines for one message. An agent reading its DMs through
|
|
698
|
+
* the CLI only ever sees this text, so the vault path AND the command that
|
|
699
|
+
* fetches it both have to be on screen — a bare "[image]" marker is why a bot
|
|
700
|
+
* answers "no image came through". Returns "" when there is nothing attached.
|
|
701
|
+
* Pure → unit-testable.
|
|
702
|
+
*/
|
|
703
|
+
export function formatAttachments(m, indent = " ") {
|
|
704
|
+
const files = (m.attachments?.length ? m.attachments : m.attachment ? [m.attachment] : [])
|
|
705
|
+
.filter((f) => Boolean(f?.vaultPath));
|
|
706
|
+
if (files.length === 0)
|
|
707
|
+
return "";
|
|
708
|
+
return files
|
|
709
|
+
.map((f) => {
|
|
710
|
+
const label = f.name?.trim() || f.vaultPath.split("/").pop() || "file";
|
|
711
|
+
const type = f.contentType ? ` ${chalk.dim(f.contentType)}` : "";
|
|
712
|
+
const company = f.companyUid ? chalk.dim(` company=${f.companyUid}`) : "";
|
|
713
|
+
return `\n${indent}${chalk.dim("[file]")} ${label}${type}\n${indent} ${chalk.dim(`vault: ${f.vaultPath}`)}${company}`;
|
|
714
|
+
})
|
|
715
|
+
.join("");
|
|
716
|
+
}
|
|
684
717
|
/**
|
|
685
718
|
* Human label for a message sender — display name, else email, else uid. Pure →
|
|
686
719
|
* unit-testable.
|
|
@@ -761,20 +794,20 @@ export function formatInboxEvent(e, nowMs) {
|
|
|
761
794
|
const when = chalk.dim(formatRelativeTime(e.createdAt, nowMs));
|
|
762
795
|
const who = chalk.bold(senderLabel(e));
|
|
763
796
|
const email = e.fromEmail && e.fromDisplayName ? chalk.dim(` <${e.fromEmail}>`) : "";
|
|
764
|
-
return `${marker} ${when} ${who}${email}\n ${firstLine(e.body)}`;
|
|
797
|
+
return `${marker} ${when} ${who}${email}\n ${firstLine(e.body)}${formatAttachments(e)}`;
|
|
765
798
|
}
|
|
766
799
|
/** Render one 1:1 thread line, tagged by direction. */
|
|
767
800
|
export function formatThreadMessage(m, nowMs) {
|
|
768
801
|
const arrow = m.direction === "out" ? chalk.dim("→") : chalk.cyan("←");
|
|
769
802
|
const who = m.direction === "out" ? "you" : senderLabel(m);
|
|
770
803
|
const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
|
|
771
|
-
return `${arrow} ${chalk.bold(who)} ${when}\n ${firstLine(m.body)}`;
|
|
804
|
+
return `${arrow} ${chalk.bold(who)} ${when}\n ${firstLine(m.body)}${formatAttachments(m)}`;
|
|
772
805
|
}
|
|
773
806
|
/** Render one channel/group message line. */
|
|
774
807
|
export function formatChannelMessage(m, nowMs) {
|
|
775
808
|
const who = chalk.bold(senderLabel(m));
|
|
776
809
|
const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
|
|
777
|
-
return `${who} ${when}\n ${firstLine(m.body)}`;
|
|
810
|
+
return `${who} ${when}\n ${firstLine(m.body)}${formatAttachments(m)}`;
|
|
778
811
|
}
|
|
779
812
|
/** POST /v1/notify/inbox/ack — idempotently mark messages read. */
|
|
780
813
|
async function ackEvents(token, eventIds) {
|
|
@@ -21,9 +21,9 @@ export interface WhoamiDisplayIdentity {
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function resolveWhoamiIdentity(identity: WhoamiTokenIdentity): WhoamiDisplayIdentity;
|
|
23
23
|
/**
|
|
24
|
-
* Human rendering of the plan state: the full
|
|
25
|
-
* workspace is
|
|
26
|
-
* Starter and healthy, and nothing at all otherwise.
|
|
24
|
+
* Human rendering of the plan state: the full over-limit block when the
|
|
25
|
+
* workspace is over its Starter limits, the one-line `Plan: Starter — …`
|
|
26
|
+
* orientation when it is Starter and healthy, and nothing at all otherwise.
|
|
27
27
|
*/
|
|
28
28
|
export declare function renderWhoamiPlanBlock(status: PlanLockStatus): string | null;
|
|
29
29
|
export declare function registerWhoamiCommand(program: Command): void;
|
package/dist/commands/whoami.js
CHANGED
|
@@ -5,7 +5,7 @@ import chalk from 'chalk';
|
|
|
5
5
|
import { loadCachedTokens, isExpiring, loadMachineCreds, } from '@indigoai-us/hq-cloud';
|
|
6
6
|
import { peekIdToken as decodeIdToken } from "../utils/id-token.js";
|
|
7
7
|
import { ensureCognitoToken, loadMachineCachedTokens, resolveCognitoTokenSource, } from "../utils/cognito-session.js";
|
|
8
|
-
import {
|
|
8
|
+
import { colorizePlanLimitNotice, fetchPlanLockStatus, renderPlanLine, renderPlanLimitNotice, } from "../lib/billing/plan-lock.js";
|
|
9
9
|
/**
|
|
10
10
|
* Resolves the person represented by an ID token without pairing a delegated
|
|
11
11
|
* email with the token subject of the delegating machine.
|
|
@@ -69,13 +69,13 @@ async function readPlanLock(company) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
|
-
* Human rendering of the plan state: the full
|
|
73
|
-
* workspace is
|
|
74
|
-
* Starter and healthy, and nothing at all otherwise.
|
|
72
|
+
* Human rendering of the plan state: the full over-limit block when the
|
|
73
|
+
* workspace is over its Starter limits, the one-line `Plan: Starter — …`
|
|
74
|
+
* orientation when it is Starter and healthy, and nothing at all otherwise.
|
|
75
75
|
*/
|
|
76
76
|
export function renderWhoamiPlanBlock(status) {
|
|
77
77
|
if (status.lock.locked) {
|
|
78
|
-
return
|
|
78
|
+
return colorizePlanLimitNotice(renderPlanLimitNotice(status));
|
|
79
79
|
}
|
|
80
80
|
return renderPlanLine(status);
|
|
81
81
|
}
|
|
@@ -21,15 +21,35 @@ export declare function markInboxDone(paths: Pick<AgentKitPaths, "inboxDir">, id
|
|
|
21
21
|
/** Every mirrored entry, oldest first, de-duplicated by id (last write wins). */
|
|
22
22
|
export declare function readMirroredInbox(paths: Pick<AgentKitPaths, "inboxDir">): MirroredInboxEntry[];
|
|
23
23
|
export declare function pendingInbox(paths: Pick<AgentKitPaths, "inboxDir">): MirroredInboxEntry[];
|
|
24
|
-
/**
|
|
24
|
+
/** One file reference carried on an inbox item (US-007 chat attachments). */
|
|
25
|
+
export interface InboxAttachment {
|
|
26
|
+
vaultPath: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
contentType?: string;
|
|
29
|
+
sizeBytes?: number;
|
|
30
|
+
companyUid?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The file references on an inbox entry, plural `attachments[]` first and the
|
|
34
|
+
* legacy singular `attachment` as a fallback. Entries without a `vaultPath` are
|
|
35
|
+
* dropped: a reference the bot cannot fetch is worse than none. Pure.
|
|
36
|
+
*/
|
|
37
|
+
export declare function inboxEntryAttachments(e: MirroredInboxEntry): InboxAttachment[];
|
|
38
|
+
/**
|
|
39
|
+
* Compact view for bots: who, when, which channel, the text, and any attached
|
|
40
|
+
* files. The attachments belong here because this summary IS the message as far
|
|
41
|
+
* as a bot is concerned — omitting them made an agent answer "no image came
|
|
42
|
+
* through" for a DM that carried a screenshot.
|
|
43
|
+
*/
|
|
25
44
|
export declare function summarizeInboxEntry(e: MirroredInboxEntry, done: boolean): {
|
|
45
|
+
done: boolean;
|
|
46
|
+
attachments?: InboxAttachment[] | undefined;
|
|
26
47
|
id: string;
|
|
27
48
|
channel: string | undefined;
|
|
28
49
|
from: string;
|
|
29
50
|
fromUid: string | undefined;
|
|
30
51
|
at: string | undefined;
|
|
31
52
|
text: string | undefined;
|
|
32
|
-
done: boolean;
|
|
33
53
|
};
|
|
34
54
|
/**
|
|
35
55
|
* The agent's inbox as the bot should see it: the kit's local mirror merged
|
|
@@ -65,7 +65,44 @@ export function pendingInbox(paths) {
|
|
|
65
65
|
const done = readDoneIds(paths);
|
|
66
66
|
return readMirroredInbox(paths).filter((e) => !done.has(e.id));
|
|
67
67
|
}
|
|
68
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* The file references on an inbox entry, plural `attachments[]` first and the
|
|
70
|
+
* legacy singular `attachment` as a fallback. Entries without a `vaultPath` are
|
|
71
|
+
* dropped: a reference the bot cannot fetch is worse than none. Pure.
|
|
72
|
+
*/
|
|
73
|
+
export function inboxEntryAttachments(e) {
|
|
74
|
+
const raw = Array.isArray(e.attachments)
|
|
75
|
+
? e.attachments
|
|
76
|
+
: e.attachment
|
|
77
|
+
? [e.attachment]
|
|
78
|
+
: [];
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const entry of raw) {
|
|
81
|
+
if (!entry || typeof entry !== "object")
|
|
82
|
+
continue;
|
|
83
|
+
const rec = entry;
|
|
84
|
+
const vaultPath = typeof rec.vaultPath === "string" ? rec.vaultPath.trim() : "";
|
|
85
|
+
if (!vaultPath)
|
|
86
|
+
continue;
|
|
87
|
+
const str = (k) => typeof rec[k] === "string" && rec[k].trim()
|
|
88
|
+
? rec[k].trim()
|
|
89
|
+
: undefined;
|
|
90
|
+
out.push({
|
|
91
|
+
vaultPath,
|
|
92
|
+
...(str("name") ? { name: str("name") } : {}),
|
|
93
|
+
...(str("contentType") ? { contentType: str("contentType") } : {}),
|
|
94
|
+
...(typeof rec.sizeBytes === "number" ? { sizeBytes: rec.sizeBytes } : {}),
|
|
95
|
+
...(str("companyUid") ? { companyUid: str("companyUid") } : {}),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Compact view for bots: who, when, which channel, the text, and any attached
|
|
102
|
+
* files. The attachments belong here because this summary IS the message as far
|
|
103
|
+
* as a bot is concerned — omitting them made an agent answer "no image came
|
|
104
|
+
* through" for a DM that carried a screenshot.
|
|
105
|
+
*/
|
|
69
106
|
export function summarizeInboxEntry(e, done) {
|
|
70
107
|
const pick = (...keys) => {
|
|
71
108
|
for (const k of keys)
|
|
@@ -73,6 +110,7 @@ export function summarizeInboxEntry(e, done) {
|
|
|
73
110
|
return e[k];
|
|
74
111
|
return undefined;
|
|
75
112
|
};
|
|
113
|
+
const attachments = inboxEntryAttachments(e);
|
|
76
114
|
return {
|
|
77
115
|
id: e.id,
|
|
78
116
|
channel: pick("channel"),
|
|
@@ -80,6 +118,7 @@ export function summarizeInboxEntry(e, done) {
|
|
|
80
118
|
fromUid: pick("fromPersonUid"),
|
|
81
119
|
at: pick("receivedAt", "createdAt", "mirroredAt"),
|
|
82
120
|
text: pick("text", "body"),
|
|
121
|
+
...(attachments.length ? { attachments } : {}),
|
|
83
122
|
done,
|
|
84
123
|
};
|
|
85
124
|
}
|
|
@@ -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
|
|
@@ -18,7 +23,7 @@
|
|
|
18
23
|
*/
|
|
19
24
|
/**
|
|
20
25
|
* Mirror of hq-pro's `PlanLockReason`. Owner decision 7 (2026-09-17) fixes the
|
|
21
|
-
*
|
|
26
|
+
* nagging set at these four: `deployments` and `storageBytes` are nag-only and
|
|
22
27
|
* never appear here. An unrecognised reason is dropped by `parsePlanLock`, so a
|
|
23
28
|
* server that adds a fifth dimension renders as the generic line rather than as
|
|
24
29
|
* a false claim about members.
|
|
@@ -106,29 +111,39 @@ export declare function selectMemberUsage(body: unknown): PlanLockMembers | null
|
|
|
106
111
|
export declare function fetchPlanLockStatus(token: string, companyRef: string, opts?: {
|
|
107
112
|
timeoutMs?: number;
|
|
108
113
|
}): Promise<PlanLockStatus | null>;
|
|
109
|
-
/** Why the workspace
|
|
114
|
+
/** Why the workspace is over, one clause per reason. */
|
|
110
115
|
export declare function reasonLabel(reason: PlanLockReason): string;
|
|
111
116
|
/**
|
|
112
|
-
* The
|
|
117
|
+
* The one sentence every over-limit surface says about the two hard stops
|
|
118
|
+
* (US-033 §3.3). Written once so the CLI, the console and the emails cannot
|
|
119
|
+
* drift into three different promises.
|
|
120
|
+
*/
|
|
121
|
+
export declare const HARD_STOP_SENTENCE: string;
|
|
122
|
+
/**
|
|
123
|
+
* The full over-limit block: why the workspace is over, where it stands
|
|
113
124
|
* against the cap, and the ways out. Plain text — colour is applied by the
|
|
114
125
|
* caller so scripts capturing stdout get a clean block.
|
|
115
126
|
*
|
|
116
|
-
* Every line is derived from `lock.reasons`. A workspace
|
|
127
|
+
* Every line is derived from `lock.reasons`. A workspace over on secrets is
|
|
117
128
|
* never told it has too many members, and an empty reason list (a server
|
|
118
129
|
* dimension this CLI does not know) renders a generic line rather than a claim
|
|
119
130
|
* about a dimension nobody measured.
|
|
120
131
|
*/
|
|
121
|
-
export declare function
|
|
132
|
+
export declare function renderPlanLimitNotice(status: PlanLockStatus): string;
|
|
122
133
|
/**
|
|
123
|
-
* The one-line form injected on every turn while the workspace stays
|
|
134
|
+
* The one-line form injected on every turn while the workspace stays over.
|
|
124
135
|
* Kept to a single line on purpose — it repeats each turn.
|
|
125
136
|
*/
|
|
126
|
-
export declare function
|
|
137
|
+
export declare function renderPlanLimitLine(status: PlanLockStatus): string;
|
|
127
138
|
/**
|
|
128
139
|
* The `Plan: …` orientation line. Starter only: a paid or enterprise
|
|
129
140
|
* workspace — and an UNKNOWN plan — gets no line at all.
|
|
130
141
|
*/
|
|
131
142
|
export declare function renderPlanLine(status: PlanLockStatus): string | null;
|
|
132
|
-
/**
|
|
133
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Colourised block for interactive output. Yellow, not red: this is a nag, and
|
|
145
|
+
* an error colour would read as "HQ has stopped working", which is the exact
|
|
146
|
+
* impression US-033 removes.
|
|
147
|
+
*/
|
|
148
|
+
export declare function colorizePlanLimitNotice(notice: string): string;
|
|
134
149
|
//# sourceMappingURL=plan-lock.d.ts.map
|
|
@@ -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
|