@indigoai-us/hq-cli 5.119.8 → 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 CHANGED
@@ -2,6 +2,27 @@
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
+
5
26
  ## [5.119.8] — 2026-09-17
6
27
 
7
28
  ### Fixed
@@ -44,6 +65,15 @@
44
65
  on Starter: 1", and tells you to "disconnect integrations until you are at 1
45
66
  or fewer"; the per-turn line reads "over its 1-integration limit". Nothing in
46
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.
47
77
 
48
78
  ## [5.119.4] — 2026-09-17
49
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>`));
@@ -201,6 +201,9 @@ export interface DmInboxEvent {
201
201
  details?: string;
202
202
  prompt?: string;
203
203
  acknowledgedAt?: string;
204
+ /** US-007 file references. Singular `attachment` is the legacy form. */
205
+ attachments?: DmAttachment[];
206
+ attachment?: DmAttachment;
204
207
  }
205
208
  /** One message in a 1:1 thread as returned by GET /v1/notify/thread. */
206
209
  export interface DmThreadMessage {
@@ -213,6 +216,8 @@ export interface DmThreadMessage {
213
216
  direction: "in" | "out";
214
217
  details?: string;
215
218
  prompt?: string;
219
+ attachments?: DmAttachment[];
220
+ attachment?: DmAttachment;
216
221
  }
217
222
  /** One channel/group message from GET /v1/notify/channels/{id}/messages. */
218
223
  export interface ChannelMessageItem {
@@ -223,7 +228,33 @@ export interface ChannelMessageItem {
223
228
  fromDisplayName?: string;
224
229
  body: string;
225
230
  createdAt: string;
231
+ attachments?: DmAttachment[];
232
+ attachment?: DmAttachment;
226
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;
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;
227
258
  /**
228
259
  * Human label for a message sender — display name, else email, else uid. Pure →
229
260
  * unit-testable.
@@ -693,6 +693,27 @@ async function runDmRequests() {
693
693
  process.exit(1);
694
694
  }
695
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
+ }
696
717
  /**
697
718
  * Human label for a message sender — display name, else email, else uid. Pure →
698
719
  * unit-testable.
@@ -773,20 +794,20 @@ export function formatInboxEvent(e, nowMs) {
773
794
  const when = chalk.dim(formatRelativeTime(e.createdAt, nowMs));
774
795
  const who = chalk.bold(senderLabel(e));
775
796
  const email = e.fromEmail && e.fromDisplayName ? chalk.dim(` <${e.fromEmail}>`) : "";
776
- return `${marker} ${when} ${who}${email}\n ${firstLine(e.body)}`;
797
+ return `${marker} ${when} ${who}${email}\n ${firstLine(e.body)}${formatAttachments(e)}`;
777
798
  }
778
799
  /** Render one 1:1 thread line, tagged by direction. */
779
800
  export function formatThreadMessage(m, nowMs) {
780
801
  const arrow = m.direction === "out" ? chalk.dim("→") : chalk.cyan("←");
781
802
  const who = m.direction === "out" ? "you" : senderLabel(m);
782
803
  const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
783
- return `${arrow} ${chalk.bold(who)} ${when}\n ${firstLine(m.body)}`;
804
+ return `${arrow} ${chalk.bold(who)} ${when}\n ${firstLine(m.body)}${formatAttachments(m)}`;
784
805
  }
785
806
  /** Render one channel/group message line. */
786
807
  export function formatChannelMessage(m, nowMs) {
787
808
  const who = chalk.bold(senderLabel(m));
788
809
  const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
789
- return `${who} ${when}\n ${firstLine(m.body)}`;
810
+ return `${who} ${when}\n ${firstLine(m.body)}${formatAttachments(m)}`;
790
811
  }
791
812
  /** POST /v1/notify/inbox/ack — idempotently mark messages read. */
792
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 WORKSPACE LOCKED block when the
25
- * workspace is locked, the one-line `Plan: Starter — …` orientation when it 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;
@@ -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 { colorizePlanLockNotice, fetchPlanLockStatus, renderPlanLine, renderPlanLockNotice, } from "../lib/billing/plan-lock.js";
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 WORKSPACE LOCKED block when the
73
- * workspace is locked, the one-line `Plan: Starter — …` orientation when it 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 colorizePlanLockNotice(renderPlanLockNotice(status));
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
- /** Compact view for bots: who, when, which channel, and the text. */
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
- /** Compact view for bots: who, when, which channel, and the text. */
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) the CLI-side read + render
3
- * of the workspace plan lock.
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 four locking dimensions — members,
6
- * integrations, secrets and agents (owner decision 7, 2026-09-17; deployments
7
- * and storage nag but never lock). Going over locks the workspace immediately: it becomes read-only until the owner
8
- * trims back under the caps or upgrades to HQ Workforce. The lock decision is
9
- * NOT made here hq-pro's `src/billing/plan-lock.ts` is the single source of
10
- * truth and ships the answer on `GET /membership/me` as a per-company
11
- * `planLock` object. This module only reads that field and renders it.
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
- * locking set at these four: `deployments` and `storageBytes` are nag-only and
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 locked, one clause per reason. */
114
+ /** Why the workspace is over, one clause per reason. */
110
115
  export declare function reasonLabel(reason: PlanLockReason): string;
111
116
  /**
112
- * The full WORKSPACE LOCKED block: why it locked, where the workspace stands
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 locked on secrets is
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 renderPlanLockNotice(status: PlanLockStatus): string;
132
+ export declare function renderPlanLimitNotice(status: PlanLockStatus): string;
122
133
  /**
123
- * The one-line form injected on every turn while the workspace stays locked.
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 renderPlanLockLine(status: PlanLockStatus): string;
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
- /** Colourised block for interactive output. */
133
- export declare function colorizePlanLockNotice(notice: string): string;
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) the CLI-side read + render
3
- * of the workspace plan lock.
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 four locking dimensions — members,
6
- * integrations, secrets and agents (owner decision 7, 2026-09-17; deployments
7
- * and storage nag but never lock). Going over locks the workspace immediately: it becomes read-only until the owner
8
- * trims back under the caps or upgrades to HQ Workforce. The lock decision is
9
- * NOT made here hq-pro's `src/billing/plan-lock.ts` is the single source of
10
- * truth and ships the answer on `GET /membership/me` as a per-company
11
- * `planLock` object. This module only reads that field and renders it.
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
- * locking set at these four: `deployments` and `storageBytes` are nag-only and
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 locked, one clause per reason. */
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 WORKSPACE LOCKED block: why it locked, where the workspace stands
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 locked on secrets is
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 renderPlanLockNotice(status) {
293
+ export function renderPlanLimitNotice(status) {
278
294
  const { lock, members, companySlug } = status;
279
295
  const lines = [];
280
- lines.push(`WORKSPACE LOCKED — ${companySlug} is over its Starter plan.`);
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(" Nothing has been deleted. This workspace is read-only until it is fixed:");
301
- lines.push(" you can still delete things, but you cannot create, invite or upload.");
302
- lines.push(" Two ways to fix it:");
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 locked.
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 renderPlanLockLine(status) {
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 locked on Starter (${detail}). ` +
322
- `Writes to HQ cloud will fail until fixed: ${status.lock.upgradeUrl}`);
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 — LOCKED";
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
- /** Colourised block for interactive output. */
340
- export function colorizePlanLockNotice(notice) {
341
- return chalk.red(notice);
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: boxed notice, at most once per day (persisted in
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): void;
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: boxed notice, at most once per day (persisted in
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
- const DAY_MS = 24 * 60 * 60 * 1000;
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 boxed notice. */
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 === null)
134
- return;
135
- const anyOver = Object.values(status.limits).some((e) => e.over);
136
- lastSeen = { ...status, anyOver };
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
- function readShownAt(statePath) {
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.shownAt !== "number" || !Number.isFinite(parsed.shownAt)) {
171
- return null;
256
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
257
+ return {};
172
258
  }
173
- return parsed.shownAt;
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 null;
275
+ return {};
177
276
  }
178
277
  }
179
- function writeShownAt(statePath, shownAt) {
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
- const payload = { shownAt };
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 withinDayWindow(shownAt, nowMs) {
190
- return nowMs - shownAt < DAY_MS;
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
- function buildOverBox(overEntries, upgradeUrl) {
193
- const title = "⚠ HQ Starter plan limit exceeded";
194
- const upgrade = `Upgrade: ${upgradeUrl}`;
195
- const resourceLines = overEntries.map(([key, entry]) => ` ${key}: ${entry.used}/${entry.limit}${dimensionSuffix(key)}`);
196
- const contentLines = [title, "", ...resourceLines, "", upgrade];
197
- const innerWidth = Math.max(...contentLines.map((l) => l.length), 40);
198
- const top = `┌${"─".repeat(innerWidth + 2)}┐`;
199
- const bot = `└${"─".repeat(innerWidth + 2)}┘`;
200
- const mid = contentLines
201
- .map((l) => `│ ${l.padEnd(innerWidth)} │`)
202
- .join("\n");
203
- return `${top}\n${mid}\n${bot}`;
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 && withinDayWindow(prev, nowMs))
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 box = buildOverBox(overEntries, resolvedUpgradeUrl);
235
- write(chalk.yellow(box) + "\n");
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 plan: ${formatEntryLine(worst.key, worst.entry)}. Upgrade: ${resolvedUpgradeUrl}`;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.119.8",
3
+ "version": "5.119.9",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {