@indigoai-us/hq-cli 5.131.0 → 5.133.0

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/dist/command-catalog.generated.d.ts +12 -0
  3. package/dist/command-catalog.generated.js +16 -0
  4. package/dist/commands/lanes.d.ts +15 -4
  5. package/dist/commands/lanes.js +478 -48
  6. package/dist/commands/mesh.js +24 -2
  7. package/dist/commands/update.js +1 -1
  8. package/dist/lib/bot/paths.d.ts +7 -5
  9. package/dist/lib/bot/paths.js +8 -1
  10. package/dist/lib/bot/prompt.d.ts +0 -2
  11. package/dist/lib/bot/prompt.js +5 -11
  12. package/dist/lib/bot/room-policy.d.ts +1 -24
  13. package/dist/lib/bot/room-policy.js +2 -35
  14. package/dist/lib/bot/run.d.ts +7 -8
  15. package/dist/lib/bot/run.js +21 -33
  16. package/dist/lib/bot/session.d.ts +1 -1
  17. package/dist/lib/bot/session.js +1 -1
  18. package/dist/lib/lanes/accounts.d.ts +6 -0
  19. package/dist/lib/lanes/accounts.js +2 -1
  20. package/dist/lib/lanes/config.d.ts +4 -0
  21. package/dist/lib/lanes/config.js +9 -0
  22. package/dist/lib/lanes/constants.d.ts +3 -0
  23. package/dist/lib/lanes/constants.js +3 -0
  24. package/dist/lib/lanes/index.d.ts +11 -3
  25. package/dist/lib/lanes/index.js +6 -1
  26. package/dist/lib/lanes/mesh-events.js +4 -0
  27. package/dist/lib/lanes/models.d.ts +78 -0
  28. package/dist/lib/lanes/models.js +205 -0
  29. package/dist/lib/lanes/move-account.d.ts +56 -0
  30. package/dist/lib/lanes/move-account.js +193 -0
  31. package/dist/lib/lanes/provider-rejection.d.ts +86 -0
  32. package/dist/lib/lanes/provider-rejection.js +258 -0
  33. package/dist/lib/lanes/quota.d.ts +23 -3
  34. package/dist/lib/lanes/quota.js +88 -14
  35. package/dist/lib/lanes/reconcile-install.d.ts +24 -3
  36. package/dist/lib/lanes/reconcile-install.js +48 -12
  37. package/dist/lib/lanes/reconcile.d.ts +5 -2
  38. package/dist/lib/lanes/reconcile.js +135 -13
  39. package/dist/lib/lanes/spawn.js +26 -5
  40. package/dist/lib/lanes/store.js +10 -0
  41. package/dist/lib/lanes/types.d.ts +51 -2
  42. package/dist/lib/lanes/types.js +5 -3
  43. package/dist/lib/lanes/watcher.d.ts +27 -0
  44. package/dist/lib/lanes/watcher.js +68 -5
  45. package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
  46. package/dist/lib/mesh/live/backfill-held.js +37 -14
  47. package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
  48. package/dist/lib/mesh/live/daemon/doctor.js +16 -2
  49. package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
  50. package/dist/lib/mesh/live/daemon/install.js +100 -7
  51. package/dist/lib/mesh/live/daemon/legacy-listen.d.ts +43 -0
  52. package/dist/lib/mesh/live/daemon/legacy-listen.js +89 -0
  53. package/dist/lib/mesh/live/daemon/live-cache.d.ts +26 -0
  54. package/dist/lib/mesh/live/daemon/live-cache.js +116 -0
  55. package/dist/lib/mesh/live/daemon/presence.d.ts +7 -3
  56. package/dist/lib/mesh/live/daemon/presence.js +10 -9
  57. package/dist/lib/mesh/live/daemon/run.d.ts +18 -0
  58. package/dist/lib/mesh/live/daemon/run.js +65 -6
  59. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +31 -0
  60. package/dist/lib/mesh/live/daemon/work-subscribe.js +124 -0
  61. package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
  62. package/dist/lib/mesh/live/emit-evidence.js +20 -2
  63. package/dist/lib/work-context/company.d.ts +16 -0
  64. package/dist/lib/work-context/company.js +46 -14
  65. package/dist/utils/self-update.js +4 -1
  66. package/package.json +2 -2
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Provider-level turn failures read off the worker's own stdout.
3
+ *
4
+ * A worker whose turn the provider refused (a 400 for a model the account
5
+ * cannot use, a 402 for an exhausted balance, a 5xx) exits with partial
6
+ * output and no envelope. Without this module the watcher classifies that
7
+ * `interrupted` with `exited_with_partial_output`, and the operator has to
8
+ * read the raw log to learn why. Here the failure is named: the lane is
9
+ * `failed` with `terminal_reason: provider_rejected` and the provider's own
10
+ * message on `terminal_details`.
11
+ *
12
+ * A 429 is parsed here like any other status so that a codex or grok 429,
13
+ * which the claude-shaped quota reader cannot see, still reaches the quota
14
+ * path: the watcher routes a `status === QUOTA_STATUS` failure to quota.ts
15
+ * (reset time, account hold) and everything else to `provider_rejected`.
16
+ *
17
+ * Only the fields named below are read out of the payload. The prompt, the
18
+ * response body and everything else stay on disk. Nothing here logs.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ export const PROVIDER_REJECTED_REASON = "provider_rejected";
23
+ /** Bytes of the provider message kept on the record. The log keeps the rest. */
24
+ export const PROVIDER_REJECTION_MESSAGE_CAP = 2000;
25
+ /** Status the watcher routes to the quota path instead of `provider_rejected`. */
26
+ export const QUOTA_STATUS = 429;
27
+ function isPlainObject(value) {
28
+ return value !== null && typeof value === "object" && !Array.isArray(value);
29
+ }
30
+ /**
31
+ * Walk the JSON lines of `text` from the last line backward, parsing one
32
+ * line at a time, and return the first value `pick` accepts. A long codex
33
+ * run writes thousands of events to worker.out; the failure, when there is
34
+ * one, is at the tail, so the scan stops there and never holds every parsed
35
+ * event at once. Lines that are not objects are skipped without parsing.
36
+ */
37
+ function scanJsonLinesBackward(text, pick) {
38
+ let end = text.length;
39
+ while (end > 0) {
40
+ const start = text.lastIndexOf("\n", end - 1);
41
+ const line = text.slice(start + 1, end).trim();
42
+ end = start === -1 ? 0 : start;
43
+ if (!line.startsWith("{"))
44
+ continue;
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(line);
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ if (!isPlainObject(parsed))
53
+ continue;
54
+ const picked = pick(parsed);
55
+ if (picked !== null)
56
+ return picked;
57
+ }
58
+ return null;
59
+ }
60
+ /** Parse the whole text as one JSON object (grok prints a single pretty-printed object). */
61
+ function wholeObject(text) {
62
+ const trimmed = text.trim();
63
+ if (!trimmed.startsWith("{"))
64
+ return null;
65
+ try {
66
+ const parsed = JSON.parse(trimmed);
67
+ return isPlainObject(parsed) ? parsed : null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ const NOT_REJECTED = Symbol("not_rejected");
74
+ function statusOf(value) {
75
+ return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599
76
+ ? value
77
+ : null;
78
+ }
79
+ export function capMessage(message) {
80
+ return message.length > PROVIDER_REJECTION_MESSAGE_CAP
81
+ ? `${message.slice(0, PROVIDER_REJECTION_MESSAGE_CAP)}…`
82
+ : message;
83
+ }
84
+ /**
85
+ * Whether a later run could succeed without the operator changing anything.
86
+ * A 400 model rejection cannot; a request timeout, a too-early, or a server
87
+ * error can. An unknown status is not retryable: guessing "try again" on a
88
+ * failure we cannot classify would loop the lane.
89
+ */
90
+ export function retryableFromStatus(status) {
91
+ if (status === null)
92
+ return false;
93
+ if (status === 408 || status === 425)
94
+ return true;
95
+ return status >= 500 && status <= 599;
96
+ }
97
+ /**
98
+ * Codex `exec --json`: `{"type":"turn.failed","error":{"message":"<text>"}}`.
99
+ * Measured 2026-09-22 (lane 01M34HCCP1ZDN3H68ESCRZE7RZ): the message is
100
+ * itself a JSON string, `{"type":"error","status":400,"error":{"type":
101
+ * "invalid_request_error","message":"The 'terra' model is not supported when
102
+ * using Codex with a ChatGPT account."}}`. The inner message and status are
103
+ * read when it parses; otherwise the outer text is the message.
104
+ */
105
+ export function parseCodexTurnFailed(text) {
106
+ return scanJsonLinesBackward(text, (rec) => {
107
+ if (rec.type !== "turn.failed")
108
+ return null;
109
+ const error = isPlainObject(rec.error) ? rec.error : {};
110
+ const outer = typeof error.message === "string" ? error.message : "";
111
+ let message = outer;
112
+ let status = null;
113
+ let errorType = null;
114
+ const inner = wholeObject(outer);
115
+ if (inner) {
116
+ status = statusOf(inner.status);
117
+ const innerError = isPlainObject(inner.error) ? inner.error : {};
118
+ if (typeof innerError.message === "string" && innerError.message.length > 0) {
119
+ message = innerError.message;
120
+ }
121
+ if (typeof innerError.type === "string")
122
+ errorType = innerError.type;
123
+ }
124
+ return {
125
+ reason: PROVIDER_REJECTED_REASON,
126
+ provider: "codex",
127
+ message: capMessage(message),
128
+ status,
129
+ error_type: errorType,
130
+ retryable: retryableFromStatus(status),
131
+ event: "codex.turn.failed",
132
+ };
133
+ });
134
+ }
135
+ /**
136
+ * Claude `-p --output-format json`: the last `type: "result"` object with
137
+ * `is_error: true` and a numeric `api_error_status`. A result that is an
138
+ * error with no status is not a provider rejection: it is either the quota
139
+ * text shape quota.ts reads, or the worker's own failure, and keeps the
140
+ * existing classification.
141
+ */
142
+ export function parseClaudeResultRejection(text) {
143
+ // The last `result` decides: a success or a status-less error there is
144
+ // final (NOT_REJECTED), so the scan does not continue to earlier results.
145
+ const found = scanJsonLinesBackward(text, (rec) => {
146
+ if (rec.type !== "result")
147
+ return null;
148
+ if (rec.is_error !== true)
149
+ return NOT_REJECTED;
150
+ const status = statusOf(rec.api_error_status);
151
+ if (status === null)
152
+ return NOT_REJECTED;
153
+ const message = typeof rec.result === "string" ? rec.result : "";
154
+ return {
155
+ reason: PROVIDER_REJECTED_REASON,
156
+ provider: "claude",
157
+ message: capMessage(message),
158
+ status,
159
+ error_type: typeof rec.subtype === "string" ? rec.subtype : null,
160
+ retryable: retryableFromStatus(status),
161
+ event: "claude.result.is_error",
162
+ };
163
+ });
164
+ return found === NOT_REJECTED ? null : found;
165
+ }
166
+ /**
167
+ * Grok `--output-format json` prints one object. Measured 2026-09-22 (lane
168
+ * 01M33X586J18J1B6GK0E6M4Y07): `{"type":"error","message":"Internal error:
169
+ * {\n \"message\": \"API error (status 402 Payment Required): Grok Build
170
+ * usage balance exhausted\",\n \"http_status\": 402, ...}", ...}`. The
171
+ * inner object carries `message` and `http_status`; when it does not parse,
172
+ * the status is read from `(status NNN` in the text.
173
+ */
174
+ export function parseGrokErrorOutput(text) {
175
+ // One pretty-printed object is the measured shape; a line stream is the
176
+ // fallback, read from the tail like the other providers.
177
+ const whole = wholeObject(text);
178
+ if (whole !== null)
179
+ return grokRejectionFrom(whole);
180
+ return scanJsonLinesBackward(text, grokRejectionFrom);
181
+ }
182
+ function grokRejectionFrom(rec) {
183
+ if (rec.type !== "error")
184
+ return null;
185
+ const outer = typeof rec.message === "string" ? rec.message : "";
186
+ let message = outer;
187
+ let status = null;
188
+ const braceAt = outer.indexOf("{");
189
+ const inner = braceAt >= 0 ? wholeObject(outer.slice(braceAt)) : null;
190
+ if (inner) {
191
+ if (typeof inner.message === "string" && inner.message.length > 0) {
192
+ message = inner.message;
193
+ }
194
+ status = statusOf(inner.http_status);
195
+ }
196
+ if (status === null) {
197
+ const match = /\(status\s+(\d{3})\b/.exec(outer);
198
+ status = match ? statusOf(Number(match[1])) : null;
199
+ }
200
+ return {
201
+ reason: PROVIDER_REJECTED_REASON,
202
+ provider: "grok",
203
+ message: capMessage(message),
204
+ status,
205
+ error_type: null,
206
+ retryable: retryableFromStatus(status),
207
+ event: "grok.error",
208
+ };
209
+ }
210
+ /** The `terminal_details` record both the watcher and the reconcile pass write for a rejection. */
211
+ export function providerRejectionDetails(rejection, lane) {
212
+ return {
213
+ reason: rejection.reason,
214
+ provider: rejection.provider,
215
+ message: rejection.message,
216
+ status: rejection.status,
217
+ error_type: rejection.error_type,
218
+ retryable: rejection.retryable,
219
+ event: rejection.event,
220
+ model: lane.model ?? null,
221
+ account: lane.account ?? null,
222
+ };
223
+ }
224
+ export function parseProviderRejection(provider, text) {
225
+ switch (provider) {
226
+ case "codex":
227
+ return parseCodexTurnFailed(text);
228
+ case "claude":
229
+ return parseClaudeResultRejection(text);
230
+ case "grok":
231
+ return parseGrokErrorOutput(text);
232
+ default: {
233
+ const _exhaustive = provider;
234
+ return _exhaustive;
235
+ }
236
+ }
237
+ }
238
+ /**
239
+ * Read this run's bytes of `logs/worker.out` (past `workerOutOffset`, the
240
+ * same cut delivery.ts uses) and return the provider rejection they carry,
241
+ * or null. A missing file is null; any other read error propagates.
242
+ */
243
+ export function readProviderRejection(runDir, provider, workerOutOffset = 0) {
244
+ const outPath = path.join(runDir, "logs", "worker.out");
245
+ let text;
246
+ try {
247
+ const bytes = fs.readFileSync(outPath);
248
+ text = bytes.subarray(Math.min(workerOutOffset, bytes.length)).toString("utf8");
249
+ }
250
+ catch (err) {
251
+ const code = err.code;
252
+ if (code === "ENOENT")
253
+ return null;
254
+ throw err;
255
+ }
256
+ return parseProviderRejection(provider, text);
257
+ }
258
+ //# sourceMappingURL=provider-rejection.js.map
@@ -26,9 +26,15 @@ export interface ModelQuota {
26
26
  /** Operator-facing marker, e.g. "needs credits for claude-fable-5-1". */
27
27
  marker?: string;
28
28
  }
29
+ export type QuotaResetSource = "provider" | "default_hold";
29
30
  export interface AccountQuota {
30
31
  /** Set when the 429 is an account-wide usage limit with a reset time. */
31
32
  all_models_quota_reset_at?: string;
33
+ /**
34
+ * Where `all_models_quota_reset_at` came from: the provider's own message,
35
+ * or the configured default hold applied to a 429 that named no reset.
36
+ */
37
+ reset_source?: QuotaResetSource;
32
38
  models: Record<string, ModelQuota>;
33
39
  }
34
40
  export interface QuotaState {
@@ -43,13 +49,27 @@ export interface QuotaSignal {
43
49
  marker?: string;
44
50
  /** True when the text is a usage limit with a reset, not a per-model credits refusal. */
45
51
  account_wide?: boolean;
52
+ /** Set by applyDefaultQuotaHold: whether the reset is the provider's or the default hold. */
53
+ reset_source?: QuotaResetSource;
46
54
  }
55
+ /**
56
+ * A 429 whose message names no reset time and no credits wording would
57
+ * otherwise record no hold at all, and the next lane on the account would
58
+ * hit the same 429. Give such a signal the provider's configured default
59
+ * hold (`quota_default_hold_ms`, fifteen minutes unless set) as its reset,
60
+ * marked `default_hold`; a signal that already carries a reset keeps it,
61
+ * marked `provider`. A per-model credits refusal is untouched.
62
+ */
63
+ export declare function applyDefaultQuotaHold(signal: QuotaSignal, defaultHoldMs: number, now: Date): QuotaSignal;
47
64
  export declare function quotaStatePath(hqRoot: string): string;
48
65
  export declare function loadQuotaState(hqRoot: string): QuotaState;
49
66
  /**
50
- * Parse "resets Sep 24, 2am (UTC)" into an ISO timestamp. The year is the
51
- * current UTC year, rolled forward when that instant is already past.
52
- * Returns null when the text names no reset.
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.
53
73
  */
54
74
  export declare function parseProviderResetTime(text: string, now: Date): string | null;
55
75
  /**
@@ -21,6 +21,26 @@ import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
22
  export const QUOTA_STATE_REL = path.join("personal", "lanes", "quota.json");
23
23
  export const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
24
+ /**
25
+ * A 429 whose message names no reset time and no credits wording would
26
+ * otherwise record no hold at all, and the next lane on the account would
27
+ * hit the same 429. Give such a signal the provider's configured default
28
+ * hold (`quota_default_hold_ms`, fifteen minutes unless set) as its reset,
29
+ * marked `default_hold`; a signal that already carries a reset keeps it,
30
+ * marked `provider`. A per-model credits refusal is untouched.
31
+ */
32
+ export function applyDefaultQuotaHold(signal, defaultHoldMs, now) {
33
+ if (signal.reason !== "account_quota_exhausted" || signal.needs_credits)
34
+ return signal;
35
+ if (signal.quota_reset_at)
36
+ return { ...signal, reset_source: "provider" };
37
+ return {
38
+ ...signal,
39
+ quota_reset_at: new Date(now.getTime() + defaultHoldMs).toISOString(),
40
+ account_wide: true,
41
+ reset_source: "default_hold",
42
+ };
43
+ }
24
44
  const MONTHS = {
25
45
  jan: 0,
26
46
  feb: 1,
@@ -98,6 +118,9 @@ export function loadQuotaState(hqRoot) {
98
118
  if (typeof raw.all_models_quota_reset_at === "string" &&
99
119
  raw.all_models_quota_reset_at.length > 0) {
100
120
  account.all_models_quota_reset_at = raw.all_models_quota_reset_at;
121
+ if (raw.reset_source === "provider" || raw.reset_source === "default_hold") {
122
+ account.reset_source = raw.reset_source;
123
+ }
101
124
  }
102
125
  accounts[id] = account;
103
126
  }
@@ -113,26 +136,36 @@ function writeQuotaState(hqRoot, state) {
113
136
  fs.renameSync(tmp, filePath);
114
137
  }
115
138
  /**
116
- * Parse "resets Sep 24, 2am (UTC)" into an ISO timestamp. The year is the
117
- * current UTC year, rolled forward when that instant is already past.
118
- * Returns null when the text names no reset.
139
+ * Parse provider reset times such as "resets Sep 24, 2am (UTC)" and Codex's
140
+ * "try again at Sep 14th, 2026 9:24 PM" into an ISO timestamp. Codex's
141
+ * unzoned form is displayed in the provider CLI's local timezone, while the
142
+ * provider `resets` form is UTC when it names `(UTC)`. The year is the current
143
+ * UTC year for an unyear-tagged reset, rolled forward when it is already past.
144
+ * An explicit provider year is kept as stated.
119
145
  */
120
146
  export function parseProviderResetTime(text, now) {
121
- const match = /resets\s+([A-Za-z]+)\s+(\d{1,2})(?:,\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm))?(?:\s*\(UTC\))?/i.exec(text);
147
+ 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);
148
+ 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);
149
+ const match = resetMatch ?? codexMatch;
122
150
  if (!match)
123
151
  return null;
152
+ const codexLocalTime = codexMatch !== null && (match[7] ?? "").toLowerCase() !== "utc";
124
153
  const month = MONTHS[(match[1] ?? "").slice(0, 3).toLowerCase()];
125
154
  if (month === undefined)
126
155
  return null;
127
156
  const day = Number(match[2]);
128
157
  if (!Number.isInteger(day) || day < 1 || day > 31)
129
158
  return null;
159
+ const explicitYear = match[3] !== undefined;
160
+ const parsedYear = explicitYear ? Number(match[3]) : now.getUTCFullYear();
161
+ if (!Number.isInteger(parsedYear) || parsedYear < 1970)
162
+ return null;
130
163
  let hour = 0;
131
164
  let minute = 0;
132
- if (match[3] !== undefined) {
133
- hour = Number(match[3]);
134
- minute = match[4] !== undefined ? Number(match[4]) : 0;
135
- const ap = (match[5] ?? "").toLowerCase();
165
+ if (match[4] !== undefined) {
166
+ hour = Number(match[4]);
167
+ minute = match[5] !== undefined ? Number(match[5]) : 0;
168
+ const ap = (match[6] ?? "").toLowerCase();
136
169
  if (ap === "pm" && hour < 12)
137
170
  hour += 12;
138
171
  if (ap === "am" && hour === 12)
@@ -140,11 +173,32 @@ export function parseProviderResetTime(text, now) {
140
173
  if (hour > 23 || minute > 59)
141
174
  return null;
142
175
  }
143
- let year = now.getUTCFullYear();
144
- let ts = Date.UTC(year, month, day, hour, minute, 0, 0);
145
- if (ts < now.getTime() - 36 * 60 * 60 * 1000) {
176
+ const timestampForYear = (targetYear) => {
177
+ const candidate = codexLocalTime
178
+ ? new Date(targetYear, month, day, hour, minute, 0, 0)
179
+ : new Date(Date.UTC(targetYear, month, day, hour, minute, 0, 0));
180
+ const valid = codexLocalTime
181
+ ? candidate.getFullYear() === targetYear &&
182
+ candidate.getMonth() === month &&
183
+ candidate.getDate() === day &&
184
+ candidate.getHours() === hour &&
185
+ candidate.getMinutes() === minute
186
+ : candidate.getUTCFullYear() === targetYear &&
187
+ candidate.getUTCMonth() === month &&
188
+ candidate.getUTCDate() === day &&
189
+ candidate.getUTCHours() === hour &&
190
+ candidate.getUTCMinutes() === minute;
191
+ return valid ? candidate.getTime() : null;
192
+ };
193
+ let year = parsedYear;
194
+ let ts = timestampForYear(year);
195
+ if (ts === null)
196
+ return null;
197
+ if (!explicitYear && ts < now.getTime() - 36 * 60 * 60 * 1000) {
146
198
  year += 1;
147
- ts = Date.UTC(year, month, day, hour, minute, 0, 0);
199
+ ts = timestampForYear(year);
200
+ if (ts === null)
201
+ return null;
148
202
  }
149
203
  return new Date(ts).toISOString();
150
204
  }
@@ -236,20 +290,40 @@ export function recordAccountQuota(hqRoot, provider, accountId, signal) {
236
290
  next.marker = signal.marker ?? `needs credits for ${signal.model}`;
237
291
  delete next.quota_reset_at;
238
292
  }
239
- if (signal.quota_reset_at && !signal.needs_credits) {
293
+ if (signal.quota_reset_at &&
294
+ !signal.needs_credits &&
295
+ shouldReplaceReset(next.quota_reset_at, signal.quota_reset_at)) {
240
296
  next.quota_reset_at = signal.quota_reset_at;
241
297
  }
242
298
  models[signal.model] = next;
243
299
  }
244
300
  const account = { ...current, models };
245
301
  if (signal.account_wide && signal.quota_reset_at) {
246
- account.all_models_quota_reset_at = signal.quota_reset_at;
302
+ if (shouldReplaceReset(current.all_models_quota_reset_at, signal.quota_reset_at)) {
303
+ account.all_models_quota_reset_at = signal.quota_reset_at;
304
+ account.reset_source = signal.reset_source ?? "provider";
305
+ }
306
+ else if (current.all_models_quota_reset_at) {
307
+ account.all_models_quota_reset_at = current.all_models_quota_reset_at;
308
+ account.reset_source = current.reset_source ?? "provider";
309
+ }
247
310
  }
248
311
  accounts[accountId] = account;
249
312
  state.providers[provider] = accounts;
250
313
  writeQuotaState(hqRoot, state);
251
314
  return state;
252
315
  }
316
+ function shouldReplaceReset(current, incoming) {
317
+ if (!current)
318
+ return true;
319
+ const currentMs = Date.parse(current);
320
+ const incomingMs = Date.parse(incoming);
321
+ if (!Number.isFinite(currentMs))
322
+ return Number.isFinite(incomingMs);
323
+ if (!Number.isFinite(incomingMs))
324
+ return false;
325
+ return incomingMs > currentMs;
326
+ }
253
327
  function resetStillAhead(iso, now) {
254
328
  if (!iso)
255
329
  return false;
@@ -8,8 +8,12 @@
8
8
  */
9
9
  export declare const RECONCILE_TIMER_INTERVAL = "1min";
10
10
  export interface ProviderPathEnv {
11
- /** PATH for the systemd unit: resolved provider dirs, systemd defaults, then the shell PATH. */
12
- path: string;
11
+ /**
12
+ * For each resolved provider: the directory where the command name was
13
+ * found on the installing shell's PATH, then the realpath target's
14
+ * directory when it differs. Provider order, deduplicated.
15
+ */
16
+ resolved_dirs: string[];
13
17
  /** Providers whose binary resolved on the installing shell's PATH. */
14
18
  resolved: string[];
15
19
  /** Providers whose binary did not resolve. Reported, never fatal on their own. */
@@ -27,6 +31,22 @@ export interface ProviderPathEnv {
27
31
  * run.
28
32
  */
29
33
  export declare function providerPathEnv(providers: readonly string[], pathEnv?: string | undefined): ProviderPathEnv;
34
+ /**
35
+ * The unit's PATH: each resolved provider binary directory, the directory of
36
+ * the hq binary itself, the directory of the node binary that runs it (a
37
+ * provider installed as a `#!/usr/bin/env node` script needs `node` on the
38
+ * unit PATH, and nvm/asdf installs keep it outside the baseline), then the
39
+ * systemd baseline, deduplicated. Nothing
40
+ * from the installing shell's PATH is carried over: on a box whose profile
41
+ * is synced from another machine that PATH holds directories from the other
42
+ * machine (measured 2026-09-22: `/Users/…` and `/opt/homebrew/…` entries on
43
+ * a Linux outpost), and none of them belong in a systemd unit.
44
+ */
45
+ export declare function unitPathEnv(input: {
46
+ providerDirs: readonly string[];
47
+ hqBinary: string;
48
+ nodeBinary: string;
49
+ }): string;
30
50
  export interface RunCommandResult {
31
51
  status: number;
32
52
  stdout?: string;
@@ -38,7 +58,8 @@ export interface ReconcileInstallOpts {
38
58
  hqRoot: string;
39
59
  nodeBinary?: string;
40
60
  hqBinary?: string;
41
- pathEnv?: string;
61
+ /** Resolved provider binary directories (providerPathEnv().resolved_dirs). */
62
+ providerDirs?: readonly string[];
42
63
  home?: string;
43
64
  enable?: boolean;
44
65
  runCommand?: RunCommand;
@@ -46,21 +46,57 @@ export function providerPathEnv(providers, pathEnv = process.env.PATH) {
46
46
  continue;
47
47
  }
48
48
  resolved.push(provider);
49
- // Keep the real binary's directory, not only a wrapper directory that
50
- // happened to be on the installing shell's PATH. Systemd must be able to
51
- // invoke the resolved provider after it starts with its own environment.
52
- resolvedDirs.push(path.dirname(fs.realpathSync(path.join(found, provider))));
49
+ // Both directories: the one holding the literal command name (an npm
50
+ // `codex` link may target `codex.js`, a versioned claude launcher has
51
+ // another basename; spawn.ts launches the literal name, so its directory
52
+ // must be on the unit PATH) and the real binary's directory (a wrapper
53
+ // that only resolves with the installing shell's environment is not
54
+ // enough for systemd). Unrelated shell PATH entries stay out.
55
+ const linkDir = path.resolve(found);
56
+ const realDir = path.dirname(fs.realpathSync(path.join(found, provider)));
57
+ for (const dir of [linkDir, realDir]) {
58
+ if (!resolvedDirs.includes(dir))
59
+ resolvedDirs.push(dir);
60
+ }
53
61
  }
54
62
  if (providers.length > 0 && resolved.length === 0) {
55
63
  throw new LanesError("provider_binary_unresolved", `Cannot resolve any provider binary (${providers.join(", ")}) from PATH`, { providers: [...providers], searched_path: searched });
56
64
  }
57
- return {
58
- path: [...new Set([...resolvedDirs, ...SYSTEMD_PATH_ENV.split(":"), ...directories])]
59
- .filter(Boolean)
60
- .join(":"),
61
- resolved,
62
- unresolved,
63
- };
65
+ return { resolved_dirs: resolvedDirs, resolved, unresolved };
66
+ }
67
+ /**
68
+ * Directory of the hq entry point as invoked, not its realpath: the `hq`
69
+ * name on PATH is a symlink in a bin directory, and that bin directory is
70
+ * what the reconcile pass needs on PATH to run `hq` subcommands.
71
+ */
72
+ function hqBinaryDir(hqBinary) {
73
+ return path.dirname(path.resolve(hqBinary));
74
+ }
75
+ /**
76
+ * The unit's PATH: each resolved provider binary directory, the directory of
77
+ * the hq binary itself, the directory of the node binary that runs it (a
78
+ * provider installed as a `#!/usr/bin/env node` script needs `node` on the
79
+ * unit PATH, and nvm/asdf installs keep it outside the baseline), then the
80
+ * systemd baseline, deduplicated. Nothing
81
+ * from the installing shell's PATH is carried over: on a box whose profile
82
+ * is synced from another machine that PATH holds directories from the other
83
+ * machine (measured 2026-09-22: `/Users/…` and `/opt/homebrew/…` entries on
84
+ * a Linux outpost), and none of them belong in a systemd unit.
85
+ */
86
+ export function unitPathEnv(input) {
87
+ const baseline = SYSTEMD_PATH_ENV.split(":");
88
+ const nodeDir = path.dirname(path.resolve(input.nodeBinary));
89
+ return [
90
+ ...new Set([
91
+ ...input.providerDirs,
92
+ hqBinaryDir(input.hqBinary),
93
+ // A baseline node (/usr/bin/node) keeps its baseline position.
94
+ ...(baseline.includes(nodeDir) ? [] : [nodeDir]),
95
+ ...baseline,
96
+ ]),
97
+ ]
98
+ .filter(Boolean)
99
+ .join(":");
64
100
  }
65
101
  function shellQuote(v) {
66
102
  return /^[A-Za-z0-9_./:-]+$/.test(v) ? v : `'${v.replace(/'/g, `'\\''`)}'`;
@@ -167,7 +203,7 @@ export function installReconcileTimer(opts) {
167
203
  hqBinary,
168
204
  hqRoot: opts.hqRoot,
169
205
  home: opts.home,
170
- pathEnv: opts.pathEnv,
206
+ pathEnv: unitPathEnv({ providerDirs: opts.providerDirs ?? [], hqBinary, nodeBinary }),
171
207
  }));
172
208
  const timerChanged = writeIfChanged(timerPath, renderReconcileTimer());
173
209
  let enabled = false;
@@ -12,12 +12,15 @@ import type { LaneRecord, LivenessState, QuestionRecord, SeniorRef } from "./typ
12
12
  import { type LaneMesh } from "./mesh-events.js";
13
13
  /** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
14
14
  export declare const QUESTION_AGE_MS: number;
15
- /** Spec §6.6 + §9: v1 permits exactly one aged advance. */
16
- export declare const MAX_AGED_ADVANCES = 1;
15
+ /** Spec §6.6 + §9: a depth-3 worker has two hops to age toward the user. */
16
+ export declare const MAX_AGED_ADVANCES: number;
17
17
  /** Aligns with watcher silence: a host that has not reported for this long is dead. */
18
18
  export declare const DEAD_HOST_MS: number;
19
19
  export declare const RECONCILE_LOCK_STALE_MS = 30000;
20
20
  export declare const RECONCILE_LOCK_TIMEOUT_MS = 5000;
21
+ /** A pickup probe that has not cleared its marker within this window is abandoned. */
22
+ export declare const PICKUP_VALIDATION_MAX_AGE_MS: number;
23
+ export declare const PICKUP_VALIDATION_TIMEOUT_REASON: "pickup_validation_timeout";
21
24
  export declare const RECONCILE_UNIT_NAME = "hq-lanes-reconcile.service";
22
25
  export declare const RECONCILE_TIMER_NAME = "hq-lanes-reconcile.timer";
23
26
  export interface ReconcileSkip {