@indigoai-us/hq-cli 5.345.123 → 5.345.124

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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tell a lane's senior when a provider 429 put an account on a quota hold.
3
+ *
4
+ * An account-wide hold stops every lane on that account, so the senior needs
5
+ * to see it with the provider's own words and the command that lifts it. One
6
+ * message per (provider, account, hold), so several lanes failing on the same
7
+ * hold produce one notice.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { monitorSessionDir } from "../monitor.js";
12
+ import { appendDropboxMessageOnce, appendDropboxMessageOnceAtPath } from "./dropbox.js";
13
+ import { LANE_PROVIDERS } from "./types.js";
14
+ /** Build the notice for the account's current hold, or null when there is none. */
15
+ export function quotaHoldNotice(state, provider, accountId, laneId) {
16
+ const account = state.providers[provider]?.[accountId];
17
+ const holdUntil = account?.all_models_quota_reset_at;
18
+ if (!account || !holdUntil)
19
+ return null;
20
+ const said = account.provider_text ? `Provider said: "${account.provider_text}".` : "Provider gave no message text.";
21
+ const clear = `If the account is not actually exhausted (check /usage), clear the hold with: hq lanes accounts clear-quota ${provider} ${accountId}`;
22
+ const lines = [
23
+ `Quota hold: ${provider} account ${accountId} is held until ${holdUntil}; no lane will run on it until then. Set by lane ${laneId}.`,
24
+ said,
25
+ ];
26
+ if (account.reset_source === "unconfirmed" && account.unconfirmed_reset_at) {
27
+ lines.push(`The provider named a reset at ${account.unconfirmed_reset_at}. That is more than a day away, so this is a short hold; the account gets the full hold only if the next lane after it also gets a 429.`);
28
+ }
29
+ else if (account.reset_source === "derived") {
30
+ lines.push("The provider named no reset time, so this is the default hold.");
31
+ }
32
+ lines.push(clear);
33
+ return {
34
+ message_id: `quota-hold:${provider}:${accountId}:${holdUntil}`,
35
+ text: lines.join("\n"),
36
+ };
37
+ }
38
+ /**
39
+ * Deliver the notice to the lane's senior inbox. A session senior gets it in
40
+ * its monitor-session inbox, a lane senior in that lane's dropbox. The notice
41
+ * is always written to stderr as well, so a senior with no inbox still has it
42
+ * in the watcher log. Returns where it was delivered.
43
+ */
44
+ export function deliverQuotaHoldNotice(hqRoot, lane, notice, at) {
45
+ process.stderr.write(`hq lanes: ${notice.text.replace(/\n/g, " ")}\n`);
46
+ const message = { at, message_id: notice.message_id, kind: "quota_hold", text: notice.text };
47
+ if (lane.senior.kind === "lane" && lane.senior.id) {
48
+ appendDropboxMessageOnce(hqRoot, lane.senior.id, message);
49
+ return "lane_inbox";
50
+ }
51
+ if (lane.senior.kind === "session" && lane.senior.id) {
52
+ const sessionId = lane.senior.id;
53
+ const dirs = LANE_PROVIDERS.map((p) => monitorSessionDir(hqRoot, p, sessionId)).filter((dir) => fs.existsSync(dir));
54
+ if (dirs.length === 1) {
55
+ appendDropboxMessageOnceAtPath(path.join(dirs[0], "dropbox"), message);
56
+ return "session_inbox";
57
+ }
58
+ }
59
+ return "stderr_only";
60
+ }
61
+ //# sourceMappingURL=quota-notice.js.map
@@ -16,6 +16,14 @@
16
16
  * claude-fable-5-1 and still runs claude-opus-5). The record is therefore
17
17
  * per (account, model), plus an account-wide reset when the text is a
18
18
  * usage limit rather than a switch-model refusal.
19
+ *
20
+ * A single 429 is not trusted with a multi-day hold. When the provider names
21
+ * a reset more than a day away, the first 429 holds the account only for the
22
+ * short default hold and keeps the named reset as `unconfirmed_reset_at`. A
23
+ * second 429 from a lane that ran after that short hold expired confirms the
24
+ * reset; a successful turn drops it. (Measured 2026-10-13: account a returned
25
+ * "You've hit your weekly limit · resets Oct 16 at 1am (America/Denver)"
26
+ * while the owner's /usage showed the week at 43%.)
19
27
  */
20
28
  import type { LaneProvider } from "./types.js";
21
29
  export declare const QUOTA_STATE_REL: string;
@@ -26,7 +34,14 @@ export interface ModelQuota {
26
34
  /** Operator-facing marker, e.g. "needs credits for claude-fable-5-1". */
27
35
  marker?: string;
28
36
  }
29
- export type QuotaResetSource = "parsed" | "derived";
37
+ /**
38
+ * `parsed`: the provider's own reset. `derived`: the default hold for a 429
39
+ * with no parseable reset. `unconfirmed`: a short hold standing in for a
40
+ * provider reset more than a day away that a second 429 has not confirmed.
41
+ */
42
+ export type QuotaResetSource = "parsed" | "derived" | "unconfirmed";
43
+ /** A provider reset further away than this needs a second 429 before it holds. */
44
+ export declare const QUOTA_CONFIRM_HORIZON_MS: number;
30
45
  export interface AccountQuota {
31
46
  /** Set when the 429 is an account-wide usage limit with a reset time. */
32
47
  all_models_quota_reset_at?: string;
@@ -35,6 +50,13 @@ export interface AccountQuota {
35
50
  * the configured default hold applied to a 429 with no parseable reset.
36
51
  */
37
52
  reset_source?: QuotaResetSource;
53
+ /**
54
+ * The provider's named reset, held back until a second 429 on a fresh
55
+ * probe confirms it. Set only while `reset_source` is `unconfirmed`.
56
+ */
57
+ unconfirmed_reset_at?: string;
58
+ /** Raw provider text from the 429 that set the current hold. */
59
+ provider_text?: string;
38
60
  models: Record<string, ModelQuota>;
39
61
  }
40
62
  export interface QuotaState {
@@ -51,6 +73,8 @@ export interface QuotaSignal {
51
73
  account_wide?: boolean;
52
74
  /** Set by applyDefaultQuotaHold: whether the reset is the provider's or the default hold. */
53
75
  reset_source?: QuotaResetSource;
76
+ /** The provider's raw result text, kept so the hold can be shown to the senior. */
77
+ provider_text?: string;
54
78
  }
55
79
  /**
56
80
  * A 429 whose message names no reset time and no credits wording would
@@ -64,12 +88,18 @@ export declare function applyDefaultQuotaHold(signal: QuotaSignal, defaultHoldMs
64
88
  export declare function quotaStatePath(hqRoot: string): string;
65
89
  export declare function loadQuotaState(hqRoot: string): QuotaState;
66
90
  /**
67
- * Parse provider reset times such as "resets Sep 24, 2am (UTC)" and Codex's
68
- * "try again at Sep 14th, 2026 9:24 PM" into an ISO timestamp. Codex's
69
- * unzoned form is displayed in the provider CLI's local timezone, while the
70
- * provider `resets` form is UTC when it names `(UTC)`. The year is the current
71
- * UTC year for an unyear-tagged reset, rolled forward when it is already past.
72
- * An explicit provider year is kept as stated.
91
+ * Parse provider reset times into an ISO timestamp. Handled forms:
92
+ *
93
+ * - "resets 2am (UTC)" and "resets 1am (America/Denver)": the next such time.
94
+ * - "resets Sep 24, 2am (UTC)", "resets Oct 16 at 1am (America/Denver)",
95
+ * "resets Sep 24, 2026 2am (UTC)": a dated reset.
96
+ * - Codex's "try again at Sep 14th, 2026 9:24 PM", shown in the provider
97
+ * CLI's local timezone unless it names `(UTC)`.
98
+ *
99
+ * The zone is `UTC` or an IANA name; an unknown zone name fails the parse. A
100
+ * dated provider reset with no zone is read as UTC. The year is the zone's
101
+ * current year for an unyear-tagged reset, rolled forward when it is already
102
+ * past. An explicit provider year is kept as stated.
73
103
  */
74
104
  export declare function parseProviderResetTime(text: string, now: Date): string | null;
75
105
  /**
@@ -88,7 +118,13 @@ export declare function quotaSignalFromFields(fields: {
88
118
  result: unknown;
89
119
  }, model: string | undefined, now: Date): QuotaSignal | null;
90
120
  export declare function readQuotaSignal(runDir: string, model: string | undefined, now: Date, workerOutOffset?: number): QuotaSignal | null;
91
- export declare function recordAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, signal: QuotaSignal): QuotaState;
121
+ export interface RecordAccountQuotaOptions {
122
+ /** When the 429 was observed. Enables the far-reset confirmation gate. */
123
+ now: Date;
124
+ /** Hold applied while a far provider reset is unconfirmed (the default hold). */
125
+ unconfirmedHoldMs: number;
126
+ }
127
+ export declare function recordAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, incoming: QuotaSignal, options?: RecordAccountQuotaOptions): QuotaState;
92
128
  export interface ClearedModelQuotaReset {
93
129
  model: string;
94
130
  quota_reset_at: string;
@@ -16,12 +16,22 @@
16
16
  * claude-fable-5-1 and still runs claude-opus-5). The record is therefore
17
17
  * per (account, model), plus an account-wide reset when the text is a
18
18
  * usage limit rather than a switch-model refusal.
19
+ *
20
+ * A single 429 is not trusted with a multi-day hold. When the provider names
21
+ * a reset more than a day away, the first 429 holds the account only for the
22
+ * short default hold and keeps the named reset as `unconfirmed_reset_at`. A
23
+ * second 429 from a lane that ran after that short hold expired confirms the
24
+ * reset; a successful turn drops it. (Measured 2026-10-13: account a returned
25
+ * "You've hit your weekly limit · resets Oct 16 at 1am (America/Denver)"
26
+ * while the owner's /usage showed the week at 43%.)
19
27
  */
20
28
  import * as fs from "node:fs";
21
29
  import * as path from "node:path";
22
30
  import { withLaneRecordFileLock } from "./store.js";
23
31
  export const QUOTA_STATE_REL = path.join("personal", "lanes", "quota.json");
24
32
  export const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
33
+ /** A provider reset further away than this needs a second 429 before it holds. */
34
+ export const QUOTA_CONFIRM_HORIZON_MS = 24 * 60 * 60 * 1000;
25
35
  /**
26
36
  * A 429 whose message names no reset time and no credits wording would
27
37
  * otherwise record no hold at all, and the next lane on the account would
@@ -127,6 +137,15 @@ export function loadQuotaState(hqRoot) {
127
137
  else if (raw.reset_source === "default_hold" || raw.reset_source === "derived") {
128
138
  account.reset_source = "derived";
129
139
  }
140
+ else if (raw.reset_source === "unconfirmed") {
141
+ account.reset_source = "unconfirmed";
142
+ }
143
+ }
144
+ if (typeof raw.unconfirmed_reset_at === "string" && raw.unconfirmed_reset_at.length > 0) {
145
+ account.unconfirmed_reset_at = raw.unconfirmed_reset_at;
146
+ }
147
+ if (typeof raw.provider_text === "string" && raw.provider_text.length > 0) {
148
+ account.provider_text = raw.provider_text;
130
149
  }
131
150
  accounts[id] = account;
132
151
  }
@@ -141,37 +160,113 @@ function writeQuotaState(hqRoot, state) {
141
160
  fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
142
161
  fs.renameSync(tmp, filePath);
143
162
  }
163
+ /** True when `zone` is a timezone name the runtime's Intl data knows. */
164
+ function isKnownTimeZone(zone) {
165
+ try {
166
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
167
+ return true;
168
+ }
169
+ catch (err) {
170
+ if (err instanceof RangeError)
171
+ return false;
172
+ throw err;
173
+ }
174
+ }
175
+ /** Offset of `zone` from UTC at instant `ms`, in milliseconds. */
176
+ function zoneOffsetMs(zone, ms) {
177
+ const parts = new Intl.DateTimeFormat("en-US", {
178
+ timeZone: zone,
179
+ hourCycle: "h23",
180
+ year: "numeric",
181
+ month: "numeric",
182
+ day: "numeric",
183
+ hour: "numeric",
184
+ minute: "numeric",
185
+ second: "numeric",
186
+ }).formatToParts(new Date(ms));
187
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? 0);
188
+ const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second"));
189
+ return asUtc - Math.floor(ms / 1000) * 1000;
190
+ }
144
191
  /**
145
- * Parse provider reset times such as "resets Sep 24, 2am (UTC)" and Codex's
146
- * "try again at Sep 14th, 2026 9:24 PM" into an ISO timestamp. Codex's
147
- * unzoned form is displayed in the provider CLI's local timezone, while the
148
- * provider `resets` form is UTC when it names `(UTC)`. The year is the current
149
- * UTC year for an unyear-tagged reset, rolled forward when it is already past.
150
- * An explicit provider year is kept as stated.
192
+ * The UTC instant of a wall-clock time in `zone`, or null when that wall time
193
+ * does not exist there (a spring-forward gap) or a field is out of range.
194
+ */
195
+ function zonedWallTimeToUtc(zone, year, month, day, hour, minute) {
196
+ const wall = Date.UTC(year, month, day, hour, minute, 0, 0);
197
+ const check = new Date(wall);
198
+ if (check.getUTCFullYear() !== year ||
199
+ check.getUTCMonth() !== month ||
200
+ check.getUTCDate() !== day ||
201
+ check.getUTCHours() !== hour ||
202
+ check.getUTCMinutes() !== minute) {
203
+ return null;
204
+ }
205
+ if (zone.toUpperCase() === "UTC")
206
+ return wall;
207
+ let guess = wall - zoneOffsetMs(zone, wall);
208
+ guess = wall - zoneOffsetMs(zone, guess);
209
+ return guess + zoneOffsetMs(zone, guess) === wall ? guess : null;
210
+ }
211
+ function zonedDateParts(zone, ms) {
212
+ const offset = zone.toUpperCase() === "UTC" ? 0 : zoneOffsetMs(zone, ms);
213
+ const local = new Date(ms + offset);
214
+ return { year: local.getUTCFullYear(), month: local.getUTCMonth(), day: local.getUTCDate() };
215
+ }
216
+ function to24Hour(hourText, minuteText, meridiem) {
217
+ let hour = Number(hourText);
218
+ const minute = Number(minuteText ?? 0);
219
+ if (!Number.isInteger(hour) || hour < 1 || hour > 12 || !Number.isInteger(minute) || minute > 59)
220
+ return null;
221
+ const ap = meridiem.toLowerCase();
222
+ if (ap === "pm" && hour < 12)
223
+ hour += 12;
224
+ if (ap === "am" && hour === 12)
225
+ hour = 0;
226
+ return { hour, minute };
227
+ }
228
+ const ZONE_PATTERN = String.raw `\(\s*(UTC|[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+)+)\s*\)`;
229
+ /**
230
+ * Parse provider reset times into an ISO timestamp. Handled forms:
231
+ *
232
+ * - "resets 2am (UTC)" and "resets 1am (America/Denver)": the next such time.
233
+ * - "resets Sep 24, 2am (UTC)", "resets Oct 16 at 1am (America/Denver)",
234
+ * "resets Sep 24, 2026 2am (UTC)": a dated reset.
235
+ * - Codex's "try again at Sep 14th, 2026 9:24 PM", shown in the provider
236
+ * CLI's local timezone unless it names `(UTC)`.
237
+ *
238
+ * The zone is `UTC` or an IANA name; an unknown zone name fails the parse. A
239
+ * dated provider reset with no zone is read as UTC. The year is the zone's
240
+ * current year for an unyear-tagged reset, rolled forward when it is already
241
+ * past. An explicit provider year is kept as stated.
151
242
  */
152
243
  export function parseProviderResetTime(text, now) {
153
- const timeOnlyMatch = /\bresets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(UTC\)/i.exec(text);
244
+ const timeOnlyMatch = new RegExp(String.raw `\bresets\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*${ZONE_PATTERN}`, "i").exec(text);
154
245
  if (timeOnlyMatch) {
155
- let hour = Number(timeOnlyMatch[1]);
156
- const minute = Number(timeOnlyMatch[2] ?? 0);
157
- const meridiem = (timeOnlyMatch[3] ?? "").toLowerCase();
158
- if (!Number.isInteger(hour) || hour < 1 || hour > 12 || minute < 0 || minute > 59)
246
+ const zone = timeOnlyMatch[4] ?? "UTC";
247
+ if (!isKnownTimeZone(zone))
159
248
  return null;
160
- if (meridiem === "pm" && hour < 12)
161
- hour += 12;
162
- if (meridiem === "am" && hour === 12)
163
- hour = 0;
164
- const candidate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hour, minute, 0, 0));
165
- if (candidate.getTime() <= now.getTime())
166
- candidate.setUTCDate(candidate.getUTCDate() + 1);
167
- return candidate.toISOString();
249
+ const time = to24Hour(timeOnlyMatch[1] ?? "", timeOnlyMatch[2], timeOnlyMatch[3] ?? "");
250
+ if (!time)
251
+ return null;
252
+ const today = zonedDateParts(zone, now.getTime());
253
+ for (let offsetDays = 0; offsetDays <= 2; offsetDays += 1) {
254
+ const date = new Date(Date.UTC(today.year, today.month, today.day + offsetDays));
255
+ const ts = zonedWallTimeToUtc(zone, date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), time.hour, time.minute);
256
+ if (ts !== null && ts > now.getTime())
257
+ return new Date(ts).toISOString();
258
+ }
259
+ return null;
168
260
  }
169
- const resetMatch = /\bresets\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?(?:,\s*(?:(\d{4})\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm))?(?:\s*\((UTC)\))?/i.exec(text);
261
+ const resetMatch = new RegExp(String.raw `\bresets\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?(?:(?:,\s*(?:(\d{4})\s+)?|\s+at\s+)(\d{1,2})(?::(\d{2}))?\s*(am|pm))?(?:\s*${ZONE_PATTERN})?`, "i").exec(text);
170
262
  const codexMatch = /\btry\s+again\s+at\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?,\s*(\d{4})\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s*\((UTC)\))?/i.exec(text);
171
263
  const match = resetMatch ?? codexMatch;
172
264
  if (!match)
173
265
  return null;
174
- const codexLocalTime = codexMatch !== null && (match[7] ?? "").toLowerCase() !== "utc";
266
+ const codexLocalTime = resetMatch === null && (match[7] ?? "").toLowerCase() !== "utc";
267
+ const zone = match[7] ?? "UTC";
268
+ if (!codexLocalTime && !isKnownTimeZone(zone))
269
+ return null;
175
270
  const month = MONTHS[(match[1] ?? "").slice(0, 3).toLowerCase()];
176
271
  if (month === undefined)
177
272
  return null;
@@ -179,37 +274,31 @@ export function parseProviderResetTime(text, now) {
179
274
  if (!Number.isInteger(day) || day < 1 || day > 31)
180
275
  return null;
181
276
  const explicitYear = match[3] !== undefined;
182
- const parsedYear = explicitYear ? Number(match[3]) : now.getUTCFullYear();
277
+ const parsedYear = explicitYear
278
+ ? Number(match[3])
279
+ : codexLocalTime
280
+ ? now.getFullYear()
281
+ : zonedDateParts(zone, now.getTime()).year;
183
282
  if (!Number.isInteger(parsedYear) || parsedYear < 1970)
184
283
  return null;
185
284
  let hour = 0;
186
285
  let minute = 0;
187
286
  if (match[4] !== undefined) {
188
- hour = Number(match[4]);
189
- minute = match[5] !== undefined ? Number(match[5]) : 0;
190
- const ap = (match[6] ?? "").toLowerCase();
191
- if (ap === "pm" && hour < 12)
192
- hour += 12;
193
- if (ap === "am" && hour === 12)
194
- hour = 0;
195
- if (hour > 23 || minute > 59)
287
+ const time = to24Hour(match[4], match[5], match[6] ?? "");
288
+ if (!time)
196
289
  return null;
290
+ hour = time.hour;
291
+ minute = time.minute;
197
292
  }
198
293
  const timestampForYear = (targetYear) => {
199
- const candidate = codexLocalTime
200
- ? new Date(targetYear, month, day, hour, minute, 0, 0)
201
- : new Date(Date.UTC(targetYear, month, day, hour, minute, 0, 0));
202
- const valid = codexLocalTime
203
- ? candidate.getFullYear() === targetYear &&
204
- candidate.getMonth() === month &&
205
- candidate.getDate() === day &&
206
- candidate.getHours() === hour &&
207
- candidate.getMinutes() === minute
208
- : candidate.getUTCFullYear() === targetYear &&
209
- candidate.getUTCMonth() === month &&
210
- candidate.getUTCDate() === day &&
211
- candidate.getUTCHours() === hour &&
212
- candidate.getUTCMinutes() === minute;
294
+ if (!codexLocalTime)
295
+ return zonedWallTimeToUtc(zone, targetYear, month, day, hour, minute);
296
+ const candidate = new Date(targetYear, month, day, hour, minute, 0, 0);
297
+ const valid = candidate.getFullYear() === targetYear &&
298
+ candidate.getMonth() === month &&
299
+ candidate.getDate() === day &&
300
+ candidate.getHours() === hour &&
301
+ candidate.getMinutes() === minute;
213
302
  return valid ? candidate.getTime() : null;
214
303
  };
215
304
  let year = parsedYear;
@@ -279,6 +368,9 @@ export function quotaSignalFromFields(fields, model, now) {
279
368
  quota_reset_at,
280
369
  model,
281
370
  account_wide: true,
371
+ // Kept only when it reads as a provider quota message, so the senior can
372
+ // see the provider's own words next to the hold.
373
+ ...(isUsageLimitShape(text) && text.length <= 300 ? { provider_text: text } : {}),
282
374
  };
283
375
  }
284
376
  export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
@@ -314,11 +406,46 @@ export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
314
406
  const observedAt = Number.isFinite(outputAt.getTime()) ? outputAt : now;
315
407
  return quotaSignalFromFields(fields, model, observedAt);
316
408
  }
317
- export function recordAccountQuota(hqRoot, provider, accountId, signal) {
409
+ /**
410
+ * Hold a provider reset more than a day away to a short hold until a second
411
+ * 429 confirms it. The second 429 counts only when its lane ran after the
412
+ * short hold expired, so lanes that failed together on one 429 burst do not
413
+ * confirm each other.
414
+ */
415
+ function gateFarReset(current, signal, options) {
416
+ if (!signal.account_wide ||
417
+ !signal.quota_reset_at ||
418
+ signal.needs_credits ||
419
+ signal.reset_source === "derived") {
420
+ return { signal };
421
+ }
422
+ const resetMs = Date.parse(signal.quota_reset_at);
423
+ const nowMs = options.now.getTime();
424
+ if (!Number.isFinite(resetMs) || resetMs - nowMs <= QUOTA_CONFIRM_HORIZON_MS)
425
+ return { signal };
426
+ const pending = current.unconfirmed_reset_at;
427
+ const shortHoldActive = resetStillAhead(current.all_models_quota_reset_at, options.now);
428
+ if (pending !== undefined && !shortHoldActive) {
429
+ return { signal: { ...signal, reset_source: "parsed" } };
430
+ }
431
+ return {
432
+ signal: {
433
+ ...signal,
434
+ quota_reset_at: new Date(nowMs + options.unconfirmedHoldMs).toISOString(),
435
+ reset_source: "unconfirmed",
436
+ },
437
+ unconfirmed_reset_at: pending !== undefined && !shouldReplaceReset(pending, signal.quota_reset_at)
438
+ ? pending
439
+ : signal.quota_reset_at,
440
+ };
441
+ }
442
+ export function recordAccountQuota(hqRoot, provider, accountId, incoming, options) {
318
443
  return withLaneRecordFileLock(quotaStatePath(hqRoot), () => {
319
444
  const state = loadQuotaState(hqRoot);
320
445
  const accounts = state.providers[provider] ?? {};
321
446
  const current = accounts[accountId] ?? { models: {} };
447
+ const gated = options ? gateFarReset(current, incoming, options) : { signal: incoming };
448
+ const signal = gated.signal;
322
449
  const models = { ...current.models };
323
450
  if (signal.model) {
324
451
  const prior = models[signal.model] ?? {};
@@ -340,6 +467,14 @@ export function recordAccountQuota(hqRoot, provider, accountId, signal) {
340
467
  if (shouldReplaceReset(current.all_models_quota_reset_at, signal.quota_reset_at)) {
341
468
  account.all_models_quota_reset_at = signal.quota_reset_at;
342
469
  account.reset_source = signal.reset_source ?? "parsed";
470
+ if (signal.provider_text)
471
+ account.provider_text = signal.provider_text;
472
+ if (gated.unconfirmed_reset_at) {
473
+ account.unconfirmed_reset_at = gated.unconfirmed_reset_at;
474
+ }
475
+ else {
476
+ delete account.unconfirmed_reset_at;
477
+ }
343
478
  }
344
479
  else if (current.all_models_quota_reset_at) {
345
480
  account.all_models_quota_reset_at = current.all_models_quota_reset_at;
@@ -367,11 +502,17 @@ export function clearAccountQuota(hqRoot, provider, accountId, options = {}) {
367
502
  const shouldClear = (reset) => reset !== undefined &&
368
503
  (!options.futureOnly || resetStillAhead(reset, now));
369
504
  let changed = false;
505
+ // A successful turn or an operator clear disproves a pending far reset.
506
+ if (account.unconfirmed_reset_at !== undefined) {
507
+ delete account.unconfirmed_reset_at;
508
+ changed = true;
509
+ }
370
510
  const accountWideReset = account.all_models_quota_reset_at;
371
511
  if (shouldClear(accountWideReset)) {
372
512
  cleared.all_models_quota_reset_at = accountWideReset;
373
513
  delete account.all_models_quota_reset_at;
374
514
  delete account.reset_source;
515
+ delete account.provider_text;
375
516
  changed = true;
376
517
  }
377
518
  const modelNames = options.model === undefined
@@ -395,6 +536,7 @@ export function clearAccountQuota(hqRoot, provider, accountId, options = {}) {
395
536
  return cleared;
396
537
  if (account.all_models_quota_reset_at === undefined) {
397
538
  delete account.reset_source;
539
+ delete account.provider_text;
398
540
  }
399
541
  const accounts = state.providers[provider];
400
542
  if (account.all_models_quota_reset_at === undefined &&
@@ -34,6 +34,7 @@ import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_FLAG, LE
34
34
  import { isFailedQuotaKeepAliveLane, providerSupportsSessionResume } from "./delivery.js";
35
35
  import { LANE_PROVIDERS, isIsoDateTime, isTerminalLaneState } from "./types.js";
36
36
  import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, loadQuotaState, readQuotaSignal, recordAccountQuota, } from "./quota.js";
37
+ import { deliverQuotaHoldNotice, quotaHoldNotice } from "./quota-notice.js";
37
38
  import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
38
39
  import { SILENCE_MS, appendWatcherEvent, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
39
40
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
@@ -1733,7 +1734,22 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1733
1734
  classified.account &&
1734
1735
  (classification.reason === "account_quota_exhausted" ||
1735
1736
  classification.reason === "account_model_unavailable")) {
1736
- recordAccountQuota(hqRoot, classified.provider, classified.account, quota);
1737
+ const quotaState = recordAccountQuota(hqRoot, classified.provider, classified.account, quota, {
1738
+ now,
1739
+ unconfirmedHoldMs: providerQuotaDefaultHoldMs(loadLanesConfig(hqRoot).providers[classified.provider]),
1740
+ });
1741
+ if (classification.reason === "account_quota_exhausted") {
1742
+ const notice = quotaHoldNotice(quotaState, classified.provider, classified.account, lane.lane_id);
1743
+ if (notice) {
1744
+ try {
1745
+ deliverQuotaHoldNotice(hqRoot, lane, notice, now.toISOString());
1746
+ }
1747
+ catch (err) {
1748
+ const message = err instanceof Error ? err.message : String(err);
1749
+ process.stderr.write(`hq lanes reconcile: quota hold notice failed for ${lane.lane_id}: ${message}\n`);
1750
+ }
1751
+ }
1752
+ }
1737
1753
  }
1738
1754
  if (!deps.dryRun &&
1739
1755
  accountRetryReason &&
@@ -43,6 +43,7 @@ import { appendCappedHistory, listQuestionIds, readLane, readQuestion, retryUpda
43
43
  import { enforceRegistryApprovalGate } from "./approval-gate.js";
44
44
  import { readWorkerRunResult } from "./delivery.js";
45
45
  import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
46
+ import { deliverQuotaHoldNotice, quotaHoldNotice } from "./quota-notice.js";
46
47
  import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
47
48
  import { appendDropboxMessageAtPath, dropboxInboxHasMessages, readToolActivity, toolActivityPath } from "./dropbox.js";
48
49
  import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
@@ -1764,7 +1765,18 @@ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
1764
1765
  (classification.reason === "account_quota_exhausted" ||
1765
1766
  classification.reason === "account_model_unavailable")) {
1766
1767
  try {
1767
- recordAccountQuota(hqRoot, quotaLane.provider, quotaLane.account, quota);
1768
+ const observedAt = new Date(now());
1769
+ const quotaState = recordAccountQuota(hqRoot, quotaLane.provider, quotaLane.account, quota, {
1770
+ now: observedAt,
1771
+ unconfirmedHoldMs: providerQuotaDefaultHoldMs(loadLanesConfig(hqRoot).providers[quotaLane.provider]),
1772
+ });
1773
+ if (classification.reason === "account_quota_exhausted") {
1774
+ const notice = quotaHoldNotice(quotaState, quotaLane.provider, quotaLane.account, quotaLane.lane_id);
1775
+ if (notice) {
1776
+ const deliveredTo = deliverQuotaHoldNotice(hqRoot, quotaLane, notice, observedAt.toISOString());
1777
+ logDecision({ event: "quota_hold_notice", at: tick.emit.at, account: quotaLane.account, delivered_to: deliveredTo });
1778
+ }
1779
+ }
1768
1780
  }
1769
1781
  catch (err) {
1770
1782
  process.stderr.write(`hq lanes watcher: quota record failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
@@ -137,6 +137,8 @@ export interface StoryPatch {
137
137
  status?: string;
138
138
  assigneeUid?: string;
139
139
  clearAssignee?: boolean;
140
+ /** Override another holder's assignment (server answers 409 otherwise). */
141
+ force?: boolean;
140
142
  }
141
143
  export interface StoryPatchResult {
142
144
  projectId: string;
@@ -56,6 +56,7 @@ export async function meshJson(token, path, init = {}) {
56
56
  ].filter(Boolean);
57
57
  const failure = new Error(`${res.status} ${code}${detail}${context.length ? ` (${context.join("; ")})` : ""}`);
58
58
  failure.status = res.status;
59
+ failure.body = data;
59
60
  if (typeof err.code === "string" && err.code)
60
61
  failure.code = err.code;
61
62
  throw failure;
@@ -398,6 +399,8 @@ export async function patchStory(token, companyUid, projectId, storyId, patch) {
398
399
  body.assigneeUid = patch.assigneeUid;
399
400
  if (patch.clearAssignee)
400
401
  body.clearAssignee = true;
402
+ if (patch.force)
403
+ body.force = true;
401
404
  const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/stories/${encodeURIComponent(storyId)}`, {
402
405
  method: "PATCH",
403
406
  body,
@@ -114,6 +114,11 @@ export declare function fetchCompanyLive(token: string, companyUid: string, opts
114
114
  cursor?: string;
115
115
  get?: (path: string, query: Record<string, string>) => Promise<unknown>;
116
116
  }): Promise<CompanyLiveReadResponse>;
117
- export declare function formatCompanyLiveTable(live: CompanyLiveReadResponse, companyLabel: string): string[];
117
+ /**
118
+ * Fill participants[].displayName from the local directory cache when the
119
+ * server omitted it (older hq-pro builds do not send it).
120
+ */
121
+ export declare function withParticipantDisplayNames(live: CompanyLiveReadResponse, names: ReadonlyMap<string, string>): CompanyLiveReadResponse;
122
+ export declare function formatCompanyLiveTable(live: CompanyLiveReadResponse, companyLabel: string, names?: ReadonlyMap<string, string>): string[];
118
123
  export type { OrganizeSubmitDecision, OrganizeReceipt, CandidatesResponse, MigrateReceipt, PostMigrateFn, };
119
124
  //# sourceMappingURL=client.d.ts.map
@@ -290,22 +290,35 @@ export async function fetchCompanyLive(token, companyUid, opts) {
290
290
  : await meshJson(token, "/v1/work-mesh/live", { query });
291
291
  return data;
292
292
  }
293
- export function formatCompanyLiveTable(live, companyLabel) {
293
+ /**
294
+ * Fill participants[].displayName from the local directory cache when the
295
+ * server omitted it (older hq-pro builds do not send it).
296
+ */
297
+ export function withParticipantDisplayNames(live, names) {
298
+ return {
299
+ ...live,
300
+ participants: (live.participants ?? []).map((p) => {
301
+ const name = p.displayName?.trim() || names.get(p.actorUid);
302
+ return name ? { ...p, displayName: name } : p;
303
+ }),
304
+ };
305
+ }
306
+ export function formatCompanyLiveTable(live, companyLabel, names = new Map()) {
294
307
  const lines = [
295
308
  `Work mesh live: ${companyLabel} generatedAt=${live.generatedAt}`,
296
- "actorUid\tpresence\tdisplayName\tsessionId\tstatus\tcontext\tproject\ttask\tturns",
309
+ "name presence sessionId status context project task turns",
297
310
  ];
298
311
  for (const p of live.participants ?? []) {
312
+ const who = p.displayName?.trim() || names.get(p.actorUid) || p.actorUid;
299
313
  const sessions = p.sessions ?? [];
300
314
  if (sessions.length === 0) {
301
- lines.push(`${p.actorUid}\t${p.presence ?? "?"}\t${p.displayName ?? ""}\t-\t-\t-\t-\t-\t-`);
315
+ lines.push(`${who}\t${p.presence ?? "?"}\t-\t-\t-\t-\t-\t-`);
302
316
  continue;
303
317
  }
304
318
  for (const s of sessions) {
305
319
  lines.push([
306
- p.actorUid,
320
+ who,
307
321
  p.presence ?? "?",
308
- p.displayName ?? "",
309
322
  s.sessionId,
310
323
  s.status ?? "?",
311
324
  s.contextStatus ?? "?",