@indigoai-us/hq-cli 5.345.144 → 5.345.146

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,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.146] — 2026-10-17
6
+ - `hq capacity route` and `hq lanes create --account auto` can now use a Claude account whose weekly limit is used up but whose paid extra usage is on. Pass `--allow-extra-usage` to allow it; the account is picked last. Without the flag, the refusal now says extra usage is on and names the flag.
7
+
8
+ ## [5.345.145] — 2026-10-17
9
+ - `hq core checkpoint`: the background checkpoint helper now fills in automatic
10
+ saves that were queued before it started. It writes its own report first,
11
+ then works through at most 5 queued saves per run; any extra queued saves go
12
+ back on the queue for the next run instead of being dropped. If a run is
13
+ stopped partway through its queued saves, the next run puts the ones it did
14
+ not finish back on the queue.
15
+ - Outpost scheduled-job alerts now reach owners recorded by their Cognito user
16
+ id. `hq core job notify` used to pass the id to `hq dm`, which read it as a
17
+ channel name and failed with "No channel named …". It now sends to the email
18
+ `hq whoami --json` reports for that id (the signed-in person, or the person
19
+ an Outpost acts for), and logs a clear reason when it cannot match one.
20
+ - `hq whoami --json` adds `sub`, `delegatedEmail` and `delegatedSub`.
21
+ - `hq core jobs reconcile` matches company jobs owned by the Outpost owner's
22
+ email or id, and reaches the user systemd manager when it runs from a system
23
+ service with no login session (it used to fail with "Failed to connect to
24
+ bus: No medium found").
25
+ - `hq members list --json` prints each member's person id (`person_id`, the `prs_` id that file and secret grants use), email, name, slug, role and membership key. `--pending --json` does the same for pending invites. `hq members list --ids` adds a PERSON_ID column to the table. The default table is unchanged.
26
+
5
27
  ## [5.345.144] — 2026-10-16
6
28
  - New `hq library add <file|url>...` uploads files or http(s) URLs straight to
7
29
  the company vault and writes only a `.hqcloud` placeholder into
@@ -3586,6 +3586,14 @@ export declare const COMMAND_CATALOG: readonly [{
3586
3586
  readonly flags: "--pending";
3587
3587
  readonly description: "List pending invites instead of active members";
3588
3588
  readonly hidden: false;
3589
+ }, {
3590
+ readonly flags: "--ids";
3591
+ readonly description: "Add a PERSON_ID column (the prs_ id used in grants and membership keys)";
3592
+ readonly hidden: false;
3593
+ }, {
3594
+ readonly flags: "--json";
3595
+ readonly description: "Print machine-readable JSON, including each person id";
3596
+ readonly hidden: false;
3589
3597
  }];
3590
3598
  readonly subcommands: readonly [];
3591
3599
  }, {
@@ -10367,6 +10375,10 @@ export declare const COMMAND_CATALOG: readonly [{
10367
10375
  readonly description: "Minimum headroom percent the pick must clear (0-100)";
10368
10376
  readonly hidden: false;
10369
10377
  readonly defaultValue: "15";
10378
+ }, {
10379
+ readonly flags: "--allow-extra-usage";
10380
+ readonly description: "Rank a Claude account whose plan limit is used up but whose paid extra usage is on (ranked last)";
10381
+ readonly hidden: false;
10370
10382
  }, {
10371
10383
  readonly flags: "--json";
10372
10384
  readonly description: "Print the route result as JSON (default)";
@@ -14044,6 +14056,10 @@ export declare const COMMAND_CATALOG: readonly [{
14044
14056
  readonly flags: "--account <id>";
14045
14057
  readonly description: "Provider account id, or 'auto' to pick the account with the most room (all accounts are one pool across companies)";
14046
14058
  readonly hidden: false;
14059
+ }, {
14060
+ readonly flags: "--allow-extra-usage";
14061
+ readonly description: "With --account auto, allow a Claude account whose plan limit is used up but whose paid extra usage is on; ranked last";
14062
+ readonly hidden: false;
14047
14063
  }, {
14048
14064
  readonly flags: "--model <id>";
14049
14065
  readonly description: "Model id for the provider CLI";
@@ -4333,6 +4333,16 @@ export const COMMAND_CATALOG = [
4333
4333
  "flags": "--pending",
4334
4334
  "description": "List pending invites instead of active members",
4335
4335
  "hidden": false
4336
+ },
4337
+ {
4338
+ "flags": "--ids",
4339
+ "description": "Add a PERSON_ID column (the prs_ id used in grants and membership keys)",
4340
+ "hidden": false
4341
+ },
4342
+ {
4343
+ "flags": "--json",
4344
+ "description": "Print machine-readable JSON, including each person id",
4345
+ "hidden": false
4336
4346
  }
4337
4347
  ],
4338
4348
  "subcommands": []
@@ -12563,6 +12573,11 @@ export const COMMAND_CATALOG = [
12563
12573
  "hidden": false,
12564
12574
  "defaultValue": "15"
12565
12575
  },
12576
+ {
12577
+ "flags": "--allow-extra-usage",
12578
+ "description": "Rank a Claude account whose plan limit is used up but whose paid extra usage is on (ranked last)",
12579
+ "hidden": false
12580
+ },
12566
12581
  {
12567
12582
  "flags": "--json",
12568
12583
  "description": "Print the route result as JSON (default)",
@@ -17072,6 +17087,11 @@ export const COMMAND_CATALOG = [
17072
17087
  "description": "Provider account id, or 'auto' to pick the account with the most room (all accounts are one pool across companies)",
17073
17088
  "hidden": false
17074
17089
  },
17090
+ {
17091
+ "flags": "--allow-extra-usage",
17092
+ "description": "With --account auto, allow a Claude account whose plan limit is used up but whose paid extra usage is on; ranked last",
17093
+ "hidden": false
17094
+ },
17075
17095
  {
17076
17096
  "flags": "--model <id>",
17077
17097
  "description": "Model id for the provider CLI",
@@ -358,6 +358,7 @@ export function registerCapacityCommand(program) {
358
358
  .option("--model <id>", "Model id. Narrows --provider any to the mapped provider")
359
359
  .option("--prefer <list>", "Comma-separated preferred provider order", "claude,codex,grok")
360
360
  .option("--floor <percent>", "Minimum headroom percent the pick must clear (0-100)", "15")
361
+ .option("--allow-extra-usage", "Rank a Claude account whose plan limit is used up but whose paid extra usage is on (ranked last)")
361
362
  .option("--json", "Print the route result as JSON (default)")
362
363
  .option("--text", "Print a single-line text summary instead of JSON")
363
364
  .action((_opts, command) => {
@@ -394,6 +395,7 @@ export function registerCapacityCommand(program) {
394
395
  collectorEnabled: mark ? mark.enabled : null,
395
396
  capacityStale: freshness.stale,
396
397
  lanesError,
398
+ allowExtraUsage: opts.allowExtraUsage === true,
397
399
  });
398
400
  }
399
401
  catch (err) {
@@ -27,6 +27,14 @@ export declare const NO_BACKEND_RETRY_MS: number;
27
27
  * always releases the queue before the lock would be presumed abandoned.
28
28
  */
29
29
  export declare const SIBLING_MAX_RUNTIME_MS: number;
30
+ /**
31
+ * Queued payloads one sibling run may process, across its launch claim and its
32
+ * own queue drain. Each one runs the full upgrade flow, so a 50-deep queue in
33
+ * one run outlasts SIBLING_MAX_RUNTIME_MS and the grok turn cap; the run is
34
+ * then killed with work half done. The launcher re-queues the overflow for the
35
+ * next sibling instead.
36
+ */
37
+ export declare const MAX_QUEUED_PAYLOADS_PER_RUN = 5;
30
38
  type Backend = "claude" | "codex" | "grok" | "none";
31
39
  type SpawnableBackend = Exclude<Backend, "none">;
32
40
  /**
@@ -121,6 +129,28 @@ export declare function siblingLaunch(backend: SpawnableBackend, args: string[],
121
129
  command: string;
122
130
  args: string[];
123
131
  };
132
+ /**
133
+ * Keep the oldest {@link MAX_QUEUED_PAYLOADS_PER_RUN} claimed payloads for this
134
+ * run and append the rest back to the queue, so a deep queue is worked off
135
+ * across several runs instead of being lost when one run hits its cap. The
136
+ * claim file is rewritten to hold only what this run owns.
137
+ */
138
+ export declare function limitClaimedPayloads(entries: unknown[], claimPath: string, pendingPath: string, limit?: number): unknown[];
139
+ /** Times one queued record may be recovered from a finished run before it is dropped. */
140
+ export declare const MAX_CLAIM_RECOVERIES = 3;
141
+ /**
142
+ * Put back the queued records a finished sibling claimed but never processed.
143
+ *
144
+ * A sibling stopped at its wall-clock or turn cap leaves its claim files
145
+ * (pending-claimed*.jsonl) in its run directory. A record whose thread file
146
+ * still exists at its thread_path was not upgraded (the sibling renames the
147
+ * -auto- file when it is done), so it is appended back to pending.jsonl for
148
+ * this launch to claim. Only runs whose process is gone are read, records
149
+ * already queued are not duplicated, and each record is recovered at most
150
+ * {@link MAX_CLAIM_RECOVERIES} times so one the sibling keeps skipping cannot
151
+ * cycle forever. Each claim file read is renamed to *.recovered.
152
+ */
153
+ export declare function recoverOrphanedClaims(siblingRoot: string, pendingPath: string, liveRoot: string): number;
124
154
  /** Attach the native checkpoint command to the hidden `hq core` group. */
125
155
  export declare function registerCoreCheckpointCommand(core: Command, preResolvedHqRoot?: string): void;
126
156
  export {};
@@ -92,6 +92,14 @@ const SIBLING_LOCK_TTL_MS = 60 * 60 * 1000;
92
92
  export const SIBLING_MAX_RUNTIME_MS = 30 * 60 * 1000;
93
93
  /** Newest-wins cap so a wedged sibling cannot grow an unbounded queue. */
94
94
  const MAX_PENDING_PAYLOADS = 50;
95
+ /**
96
+ * Queued payloads one sibling run may process, across its launch claim and its
97
+ * own queue drain. Each one runs the full upgrade flow, so a 50-deep queue in
98
+ * one run outlasts SIBLING_MAX_RUNTIME_MS and the grok turn cap; the run is
99
+ * then killed with work half done. The launcher re-queues the overflow for the
100
+ * next sibling instead.
101
+ */
102
+ export const MAX_QUEUED_PAYLOADS_PER_RUN = 5;
95
103
  class CheckpointUsageError extends Error {
96
104
  }
97
105
  /**
@@ -202,16 +210,37 @@ report instead of guessing.
202
210
  skip silently on any failure. Hook or automation improvements go ONLY under
203
211
  personal/hooks/ as proposals.
204
212
  7. WRITE BOUNDS: you may write only under personal/, workspace/, and companies/<company>/ as constrained above. You must NEVER write into .claude/, core/, .agents/, .codex/, repos/, or anywhere outside the HQ root.
205
- 8. Write <runDir>/report.md — full prose: what you read, what you changed
206
- (paths), and what you skipped and why. List every policy or knowledge file
207
- you amended, superseded, merged or deleted with the reason, so a human can
213
+ 8. Write <runDir>/report.md NOW, before you touch any queued payload — full
214
+ prose: what you read, what you changed (paths), and what you skipped and
215
+ why. List every policy or knowledge file you
216
+ amended, superseded, merged or deleted with the reason, so a human can
208
217
  audit and reverse it; a deletion you do not name in the report is a defect.
209
- Then drain the queue: while
210
- workspace/checkpoints/sibling/pending.jsonl exists and is non-empty, claim
218
+ The run can be stopped at its time or turn cap at any point after this, so
219
+ this run's own report must already exist.
220
+ 9. QUEUED PAYLOADS CLAIMED AT LAUNCH: the payload's pending_payloads array
221
+ holds checkpoints that were queued while an earlier sibling was busy; the
222
+ same records are in <runDir>/pending-claimed.jsonl. The launcher hands one
223
+ run at most <maxQueued> of them and has already put any others back in
224
+ workspace/checkpoints/sibling/pending.jsonl. For EACH record in
225
+ pending_payloads, in order, run steps 1-5 with that record as the payload:
226
+ upgrade the thread file named in its thread_path IN PLACE and drop -auto-
227
+ from its filename. If that -auto- file no longer exists, the record was
228
+ already processed; note it and move on. For a queued record, skip the
229
+ journal close in step 4 (it belongs to the parent session) and skip step 5b
230
+ (ontology); in steps 3 and 7, <company> means that record's own company, not
231
+ this run's. After EACH record, append one line to <runDir>/report.md naming
232
+ its thread and what you did, so a run stopped mid-queue shows how far it got.
233
+ If the array is empty, skip this step.
234
+ 10. Drain the queue, still within the same limit of <maxQueued> queued records
235
+ per run counted across step 9 and this step: while
236
+ workspace/checkpoints/sibling/pending.jsonl exists and is non-empty and you
237
+ have processed fewer than <maxQueued> queued records, claim
211
238
  it atomically by renaming it aside —
212
239
  mv workspace/checkpoints/sibling/pending.jsonl <runDir>/pending-claimed-N.jsonl
213
- (N counting up from 2) — and process the claimed payloads with this same
214
- flow. Repeat until a claim finds nothing left, then update the report.
240
+ (N counting up from 2) — and process its records as in step 9. When you
241
+ reach the limit, append every record of that claimed file you did not
242
+ process back to workspace/checkpoints/sibling/pending.jsonl (append with >>,
243
+ never overwrite) so the next sibling takes it, and record that in the report.
215
244
  NEVER read the queue and truncate it in place: a payload appended between
216
245
  your read and the truncate is lost, and its checkpoint is never enriched.
217
246
  `;
@@ -241,7 +270,8 @@ export function renderSiblingPrompt(runDir, payloadPath, options = {}) {
241
270
  .replaceAll("<ontologyStep>", () => ontologyStep)
242
271
  .replaceAll("<runDir>", () => runDir)
243
272
  .replaceAll("<payloadPath>", () => payloadPath)
244
- .replaceAll("<hqRoot>", () => options.hqRoot ?? "");
273
+ .replaceAll("<hqRoot>", () => options.hqRoot ?? "")
274
+ .replaceAll("<maxQueued>", () => String(MAX_QUEUED_PAYLOADS_PER_RUN));
245
275
  }
246
276
  function collect(value, previous) {
247
277
  return [...previous, value];
@@ -1137,7 +1167,8 @@ function appendPending(pendingPath, payload) {
1137
1167
  * describes is never enriched. Renaming is atomic, so this run receives exactly
1138
1168
  * the file that existed at the moment of the claim while concurrent callers go
1139
1169
  * on appending to a fresh queue. The claimed file is left in the run directory
1140
- * as an audit trail and as a recovery point if this sibling dies.
1170
+ * as an audit trail and as a recovery point if this sibling dies: the next
1171
+ * launch re-queues its unprocessed records through recoverOrphanedClaims.
1141
1172
  */
1142
1173
  function claimPending(pendingPath, claimPath) {
1143
1174
  try {
@@ -1162,6 +1193,124 @@ function claimPending(pendingPath, claimPath) {
1162
1193
  }
1163
1194
  return entries;
1164
1195
  }
1196
+ /**
1197
+ * Keep the oldest {@link MAX_QUEUED_PAYLOADS_PER_RUN} claimed payloads for this
1198
+ * run and append the rest back to the queue, so a deep queue is worked off
1199
+ * across several runs instead of being lost when one run hits its cap. The
1200
+ * claim file is rewritten to hold only what this run owns.
1201
+ */
1202
+ export function limitClaimedPayloads(entries, claimPath, pendingPath, limit = MAX_QUEUED_PAYLOADS_PER_RUN) {
1203
+ if (entries.length <= limit)
1204
+ return entries;
1205
+ const kept = entries.slice(0, limit);
1206
+ const overflow = entries.slice(limit);
1207
+ // Re-queue first: if the rewrite below fails, every record is still on disk.
1208
+ fs.appendFileSync(pendingPath, overflow.map((entry) => `${JSON.stringify(entry)}\n`).join(""));
1209
+ fs.writeFileSync(claimPath, kept.map((entry) => `${JSON.stringify(entry)}\n`).join(""));
1210
+ return kept;
1211
+ }
1212
+ /** Times one queued record may be recovered from a finished run before it is dropped. */
1213
+ export const MAX_CLAIM_RECOVERIES = 3;
1214
+ /**
1215
+ * Put back the queued records a finished sibling claimed but never processed.
1216
+ *
1217
+ * A sibling stopped at its wall-clock or turn cap leaves its claim files
1218
+ * (pending-claimed*.jsonl) in its run directory. A record whose thread file
1219
+ * still exists at its thread_path was not upgraded (the sibling renames the
1220
+ * -auto- file when it is done), so it is appended back to pending.jsonl for
1221
+ * this launch to claim. Only runs whose process is gone are read, records
1222
+ * already queued are not duplicated, and each record is recovered at most
1223
+ * {@link MAX_CLAIM_RECOVERIES} times so one the sibling keeps skipping cannot
1224
+ * cycle forever. Each claim file read is renamed to *.recovered.
1225
+ */
1226
+ export function recoverOrphanedClaims(siblingRoot, pendingPath, liveRoot) {
1227
+ let runDirs;
1228
+ try {
1229
+ runDirs = fs.readdirSync(siblingRoot).filter((entry) => entry !== "pending.jsonl");
1230
+ }
1231
+ catch {
1232
+ return 0;
1233
+ }
1234
+ const queuedThreads = new Set();
1235
+ try {
1236
+ for (const line of fs.readFileSync(pendingPath, "utf8").split("\n")) {
1237
+ if (!line.trim())
1238
+ continue;
1239
+ try {
1240
+ const threadPath = JSON.parse(line).thread_path;
1241
+ if (typeof threadPath === "string")
1242
+ queuedThreads.add(threadPath);
1243
+ }
1244
+ catch {
1245
+ // A torn line names no thread.
1246
+ }
1247
+ }
1248
+ }
1249
+ catch {
1250
+ // No queue yet.
1251
+ }
1252
+ const recovered = [];
1253
+ let count = 0;
1254
+ for (const runDir of runDirs.sort()) {
1255
+ const absolute = path.join(siblingRoot, runDir);
1256
+ let claimFiles;
1257
+ try {
1258
+ claimFiles = fs.readdirSync(absolute).filter((entry) => /^pending-claimed(?:-\d+)?\.jsonl$/u.test(entry));
1259
+ }
1260
+ catch {
1261
+ continue;
1262
+ }
1263
+ if (claimFiles.length === 0)
1264
+ continue;
1265
+ let pid = Number.NaN;
1266
+ try {
1267
+ pid = Number.parseInt(fs.readFileSync(path.join(absolute, "sibling.pid"), "utf8").trim(), 10);
1268
+ }
1269
+ catch {
1270
+ // A run that never recorded a PID cannot still be running.
1271
+ }
1272
+ if (Number.isSafeInteger(pid) && pid > 0 && processAlive(pid))
1273
+ continue;
1274
+ for (const claimFile of claimFiles.sort()) {
1275
+ const claimPath = path.join(absolute, claimFile);
1276
+ let content;
1277
+ try {
1278
+ content = fs.readFileSync(claimPath, "utf8");
1279
+ }
1280
+ catch {
1281
+ continue;
1282
+ }
1283
+ for (const line of content.split("\n")) {
1284
+ if (!line.trim())
1285
+ continue;
1286
+ let entry;
1287
+ try {
1288
+ entry = JSON.parse(line);
1289
+ }
1290
+ catch {
1291
+ continue;
1292
+ }
1293
+ const threadPath = entry.thread_path;
1294
+ if (typeof threadPath !== "string" || !threadPath || queuedThreads.has(threadPath))
1295
+ continue;
1296
+ if (!fs.existsSync(path.resolve(liveRoot, threadPath)))
1297
+ continue;
1298
+ const attempts = typeof entry.claim_recoveries === "number" ? entry.claim_recoveries : 0;
1299
+ if (attempts >= MAX_CLAIM_RECOVERIES)
1300
+ continue;
1301
+ queuedThreads.add(threadPath);
1302
+ recovered.push(`${JSON.stringify({ ...entry, claim_recoveries: attempts + 1 })}\n`);
1303
+ count += 1;
1304
+ }
1305
+ // Re-queue before retiring the claim file, so a failure leaves the
1306
+ // records on disk; a repeat read is deduplicated by thread_path above.
1307
+ if (recovered.length > 0)
1308
+ fs.appendFileSync(pendingPath, recovered.splice(0).join(""));
1309
+ fs.renameSync(claimPath, `${claimPath}.recovered`);
1310
+ }
1311
+ }
1312
+ return count;
1313
+ }
1165
1314
  async function startSibling(liveRoot, input, threadPath, backend) {
1166
1315
  const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
1167
1316
  const siblingRoot = path.join(liveRoot, "workspace", "checkpoints", "sibling");
@@ -1183,9 +1332,17 @@ async function startSibling(liveRoot, input, threadPath, backend) {
1183
1332
  printResult(`checkpoint: sibling busy — payload queued (${depth} pending)`);
1184
1333
  return;
1185
1334
  }
1335
+ // Before this run's directory exists, so only earlier runs are read.
1336
+ try {
1337
+ recoverOrphanedClaims(siblingRoot, pendingPath, liveRoot);
1338
+ }
1339
+ catch {
1340
+ // Recovery is best effort; the claim files stay on disk for the next launch.
1341
+ }
1186
1342
  const runDir = path.join(siblingRoot, `${formatTimestamp(new Date())}-${summarySlug(input.summary ?? "checkpoint")}`);
1187
1343
  fs.mkdirSync(runDir, { recursive: true });
1188
- payload.pending_payloads = claimPending(pendingPath, path.join(runDir, "pending-claimed.jsonl"));
1344
+ const claimPath = path.join(runDir, "pending-claimed.jsonl");
1345
+ payload.pending_payloads = limitClaimedPayloads(claimPending(pendingPath, claimPath), claimPath, pendingPath);
1189
1346
  if (!payload.company) {
1190
1347
  let queued;
1191
1348
  try {
@@ -195,6 +195,40 @@ function emailFromWhoami(output) {
195
195
  }
196
196
  return "";
197
197
  }
198
+ const EMAIL_RECIPIENT = /^[^\s@]+@[^\s@]+$/;
199
+ const COGNITO_SUB = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
200
+ /**
201
+ * Outpost jobs may record `owner` as the owner's Cognito user id (the box's
202
+ * OUTPOST_USER_ID; the reconciler matches company jobs on it). `hq dm` cannot
203
+ * address a Cognito id: any token that is not an email or prs_/agt_ uid is
204
+ * treated as a channel name and bounces with "No channel named …". Map the id
205
+ * to the signed-in identity's email when `hq whoami --json` reports the same
206
+ * subject, directly or as the human an Outpost session acts for.
207
+ */
208
+ function resolveCognitoOwner(owner, deps) {
209
+ if (!deps.commandExists("hq"))
210
+ return "";
211
+ let parsed;
212
+ try {
213
+ const raw = deps.run("hq", ["whoami", "--json"], { cwd: deps.cwd, env: deps.env, captureStdout: true }).stdout;
214
+ const value = JSON.parse(raw);
215
+ if (!value || typeof value !== "object" || Array.isArray(value))
216
+ return "";
217
+ parsed = value;
218
+ }
219
+ catch {
220
+ return "";
221
+ }
222
+ const target = owner.toLowerCase();
223
+ for (const [subKey, emailKey] of [["sub", "email"], ["delegatedSub", "delegatedEmail"]]) {
224
+ const sub = parsed[subKey];
225
+ const email = parsed[emailKey];
226
+ if (typeof sub === "string" && sub.toLowerCase() === target
227
+ && typeof email === "string" && EMAIL_RECIPIENT.test(email))
228
+ return email;
229
+ }
230
+ return "";
231
+ }
198
232
  function normalizedEpoch(value) {
199
233
  if (!/^-?\d+$/.test(value))
200
234
  throw new Error(`invalid HQ_JOB_NOTIFY_NOW_EPOCH: ${value}`);
@@ -260,6 +294,17 @@ export function runJobNotify(argv, options = {}) {
260
294
  log("no owner recipient — cannot dm (logged only); never fails the job");
261
295
  deps.appendFile(deliveryLog, '{"delivered":false,"reason":"missing_owner"}\n');
262
296
  }
297
+ let recipient = owner;
298
+ const cognitoOwner = Boolean(owner) && COGNITO_SUB.test(owner);
299
+ if (cognitoOwner) {
300
+ recipient = resolveCognitoOwner(owner, deps);
301
+ if (recipient)
302
+ log(`owner ${owner} is a Cognito user id — delivering to ${recipient}`);
303
+ else {
304
+ log(`owner ${owner} is a Cognito user id that hq dm cannot address and hq whoami does not report it — set the job owner to an email or prs_ uid (logged only); never fails the job`);
305
+ deps.appendFile(deliveryLog, '{"delivered":false,"reason":"unresolved_owner_id"}\n');
306
+ }
307
+ }
263
308
  let previous = {};
264
309
  if (deps.exists(stateFile))
265
310
  previous = JSON.parse(deps.readFile(stateFile));
@@ -306,18 +351,22 @@ export function runJobNotify(argv, options = {}) {
306
351
  log("skip dm send — missing owner");
307
352
  deliverRc = 1;
308
353
  }
354
+ else if (!recipient) {
355
+ log("skip dm send — owner id has no dm recipient");
356
+ deliverRc = 1;
357
+ }
309
358
  else if (!dmBin) {
310
359
  log("skip dm send — hq CLI not found");
311
360
  deliverRc = 1;
312
361
  }
313
362
  else {
314
363
  const args = dmBin === "hq"
315
- ? ["dm", owner, headline, "--details", details]
316
- : [owner, headline, details];
364
+ ? ["dm", recipient, headline, "--details", details]
365
+ : [recipient, headline, details];
317
366
  deliverRc = deps.run(dmBin, args, { cwd: deps.cwd, env: deps.env, captureStdout: false, logPath: deliveryLog }).code;
318
367
  if (deliverRc === 0) {
319
368
  delivered = 1;
320
- log(`dm delivered to ${owner} kind=${kind} job=${parsed.jobId}`);
369
+ log(`dm delivered to ${recipient} kind=${kind} job=${parsed.jobId}`);
321
370
  }
322
371
  else
323
372
  log(`dm delivery failed rc=${deliverRc} job=${parsed.jobId} (logged; not failing run)`);
@@ -344,6 +393,7 @@ export function runJobNotify(argv, options = {}) {
344
393
  delivered: delivered === 1,
345
394
  kind,
346
395
  owner,
396
+ ...(cognitoOwner ? { recipient: recipient || null } : {}),
347
397
  channel_unimplemented: channelUnimplemented === 1,
348
398
  deliver_rc: deliverRc,
349
399
  }, "compact"));
@@ -15,6 +15,14 @@ export type ReconcileOptions = {
15
15
  fetcher?: typeof fetch;
16
16
  now?: () => Date;
17
17
  };
18
+ /**
19
+ * A system unit with User=ec2-user (outpost-jobs-reconcile.service) or a cron
20
+ * job has no login session, so XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS are
21
+ * unset and `systemctl --user` fails with "Failed to connect to bus: No medium
22
+ * found" (feedback_6ce027d7). When the lingering user manager's runtime dir
23
+ * exists, point systemctl at it. Caller-provided values are kept.
24
+ */
25
+ export declare function withUserBusEnvironment(env: NodeJS.ProcessEnv, uid: number | undefined, runtimeBase?: string, exists?: (file: string) => boolean): NodeJS.ProcessEnv;
18
26
  export declare function runReconcile(argv: string[], rootArg?: string, options?: ReconcileOptions): Promise<number>;
19
27
  export declare function registerCoreJobsNativeCommand(core: Command): void;
20
28
  export declare function rawArgumentsForCommand(command: Command, fallback: string[], raw?: string[]): string[];
@@ -379,9 +379,36 @@ Env / overrides (tests):
379
379
 
380
380
  Exit: 0 success; 1 partial failures; 2 usage/config.
381
381
  `;
382
- function commandResult(command, args) {
382
+ /**
383
+ * A system unit with User=ec2-user (outpost-jobs-reconcile.service) or a cron
384
+ * job has no login session, so XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS are
385
+ * unset and `systemctl --user` fails with "Failed to connect to bus: No medium
386
+ * found" (feedback_6ce027d7). When the lingering user manager's runtime dir
387
+ * exists, point systemctl at it. Caller-provided values are kept.
388
+ */
389
+ export function withUserBusEnvironment(env, uid, runtimeBase = "/run/user", exists = fs.existsSync) {
390
+ if (uid === undefined)
391
+ return env;
392
+ const next = { ...env };
393
+ if (!next.XDG_RUNTIME_DIR) {
394
+ const directory = path.posix.join(runtimeBase, String(uid));
395
+ if (!exists(directory))
396
+ return next;
397
+ next.XDG_RUNTIME_DIR = directory;
398
+ }
399
+ if (!next.DBUS_SESSION_BUS_ADDRESS && exists(path.posix.join(next.XDG_RUNTIME_DIR, "bus"))) {
400
+ next.DBUS_SESSION_BUS_ADDRESS = `unix:path=${next.XDG_RUNTIME_DIR}/bus`;
401
+ }
402
+ return next;
403
+ }
404
+ function schedulerCommandResult(command, args) {
405
+ const env = withUserBusEnvironment(process.env, typeof process.getuid === "function" ? process.getuid() : undefined, process.env.HQ_JOB_USER_RUNTIME_BASE || "/run/user");
406
+ return commandResult(command, args, env);
407
+ }
408
+ function commandResult(command, args, env = process.env) {
383
409
  const result = spawnSync(command, args, {
384
410
  encoding: "utf8",
411
+ env,
385
412
  stdio: ["ignore", "pipe", "pipe"],
386
413
  });
387
414
  return {
@@ -486,6 +513,11 @@ function resolveBoxOwners(options) {
486
513
  email = shellText(record.email ?? user?.email);
487
514
  add(email);
488
515
  add(shellText(record.personUid ?? record.person_uid ?? record.uid));
516
+ // The person (or the human an Outpost session acts for) by Cognito
517
+ // id and email, so a job owner written either way matches the box.
518
+ add(shellText(record.sub));
519
+ add(shellText(record.delegatedEmail));
520
+ add(shellText(record.delegatedSub));
489
521
  }
490
522
  if (!email) {
491
523
  const humanEmail = /.*Email:\s*(.*)/i.exec(who)?.[1]
@@ -757,7 +789,7 @@ function systemctlResult(args, dryRun, command, options) {
757
789
  return { status: 127, stdout: "", stderr: "scheduler stub was requested but no injected runner was supplied" };
758
790
  }
759
791
  try {
760
- const runner = options.schedulerRunner ?? commandResult;
792
+ const runner = options.schedulerRunner ?? schedulerCommandResult;
761
793
  return runner(command, ["--user", ...args]);
762
794
  }
763
795
  catch (error) {
@@ -3483,6 +3483,7 @@ async function runCreate(ctx, opts) {
3483
3483
  collectorEnabled: readCollectorMark(ledgerOptions)?.enabled ?? null,
3484
3484
  capacityStale: capacityViewAge(view.generatedAt, ctx.now().getTime()).stale,
3485
3485
  lanesError: routeLanesError,
3486
+ allowExtraUsage: opts.allowExtraUsage === true,
3486
3487
  });
3487
3488
  if (!result.ok || !result.pick) {
3488
3489
  const resetSuffix = result.next_reset ? `; next reset ${result.next_reset}` : "";
@@ -9935,6 +9936,7 @@ export function registerLanesCommand(parent, deps = {}) {
9935
9936
  .option("--remote-control", "Open a Claude lane in Remote Control using account a for login and the selected account for inference")
9936
9937
  .option("--title <text>", "Human-readable Claude Remote Control session title; required with --remote-control")
9937
9938
  .option("--account <id>", "Provider account id, or 'auto' to pick the account with the most room (all accounts are one pool across companies)")
9939
+ .option("--allow-extra-usage", "With --account auto, allow a Claude account whose plan limit is used up but whose paid extra usage is on; ranked last")
9938
9940
  .option("--model <id>", "Model id for the provider CLI")
9939
9941
  .option("--effort <level>", "Effort level; claude: low|medium|high|xhigh|max, codex: minimal|low|medium|high|xhigh|max|ultra, grok: low|medium|high|xhigh")
9940
9942
  .option("--senior <ref>", "session:<id> | lane:<id> | user")
@@ -145,6 +145,26 @@ export declare class InviteHttpError extends Error {
145
145
  }
146
146
  export declare function formatInviteHttpError(status: number, fallback: string, code?: string): string;
147
147
  export declare function listPendingInvites(token: string, companyUid: string): Promise<PendingInvite[]>;
148
+ /** JSON row for `hq members list --json`. `person_id` is the prs_/agt_ id grants use. */
149
+ export declare function activeMemberJson(m: ActiveMember): {
150
+ person_id: string;
151
+ email: string | null;
152
+ name: string | null;
153
+ slug: string | null;
154
+ role: string;
155
+ status: string;
156
+ membership_key: string;
157
+ };
158
+ /** JSON row for `hq members list --pending --json`. */
159
+ export declare function pendingInviteJson(inv: PendingInvite): {
160
+ person_id: string | null;
161
+ email: string | null;
162
+ role: string;
163
+ status: string;
164
+ invited_by: string;
165
+ invited_at: string;
166
+ membership_key: string;
167
+ };
148
168
  export declare function listActiveMembers(token: string, companyUid: string): Promise<ActiveMember[]>;
149
169
  /**
150
170
  * Resolve a role-change target to an active membership key.
@@ -237,6 +237,30 @@ export async function listPendingInvites(token, companyUid) {
237
237
  const data = (await res.json());
238
238
  return data?.pending ?? data?.invites ?? [];
239
239
  }
240
+ /** JSON row for `hq members list --json`. `person_id` is the prs_/agt_ id grants use. */
241
+ export function activeMemberJson(m) {
242
+ return {
243
+ person_id: m.personUid,
244
+ email: m.personEmail ?? null,
245
+ name: m.personName ?? null,
246
+ slug: m.personSlug ?? null,
247
+ role: m.role,
248
+ status: m.status,
249
+ membership_key: m.membershipKey,
250
+ };
251
+ }
252
+ /** JSON row for `hq members list --pending --json`. */
253
+ export function pendingInviteJson(inv) {
254
+ return {
255
+ person_id: inv.personUid ?? null,
256
+ email: inv.inviteeEmail ?? null,
257
+ role: inv.role,
258
+ status: inv.status,
259
+ invited_by: inv.invitedBy,
260
+ invited_at: inv.invitedAt,
261
+ membership_key: inv.membershipKey,
262
+ };
263
+ }
240
264
  export async function listActiveMembers(token, companyUid) {
241
265
  const res = await vaultApiFetch({
242
266
  token,
@@ -750,6 +774,8 @@ export function registerMembersCommand(program) {
750
774
  .command("list")
751
775
  .description("List the company's active members (use --pending for pending invites)")
752
776
  .option("--pending", "List pending invites instead of active members")
777
+ .option("--ids", "Add a PERSON_ID column (the prs_ id used in grants and membership keys)")
778
+ .option("--json", "Print machine-readable JSON, including each person id")
753
779
  .action(async (opts) => {
754
780
  try {
755
781
  const token = (await resolveVaultCredential()).token;
@@ -758,6 +784,13 @@ export function registerMembersCommand(program) {
758
784
  if (opts.pending) {
759
785
  // --pending: preserve the original pending-invites view verbatim.
760
786
  const invites = await listPendingInvites(token, companyUid);
787
+ if (opts.json) {
788
+ console.log(JSON.stringify({
789
+ company_uid: companyUid,
790
+ pending: invites.map(pendingInviteJson),
791
+ }, null, 2));
792
+ return;
793
+ }
761
794
  if (invites.length === 0) {
762
795
  console.log(chalk.gray("No pending invites for this company."));
763
796
  return;
@@ -788,17 +821,33 @@ export function registerMembersCommand(program) {
788
821
  // Default: list ACTIVE members so their emails drop straight into
789
822
  // `hq secrets share <path> --with <email>`.
790
823
  const activeMembers = await listActiveMembers(token, companyUid);
824
+ if (opts.json) {
825
+ console.log(JSON.stringify({
826
+ company_uid: companyUid,
827
+ members: activeMembers.map(activeMemberJson),
828
+ }, null, 2));
829
+ return;
830
+ }
791
831
  if (activeMembers.length === 0) {
792
832
  console.log(chalk.gray("No active members found for this company."));
793
833
  return;
794
834
  }
795
835
  const emailW = Math.max(5, ...activeMembers.map((m) => (m.personEmail ?? m.personUid).length));
796
836
  const roleW = Math.max(4, ...activeMembers.map((m) => m.role.length));
797
- console.log(chalk.bold(["EMAIL".padEnd(emailW), "ROLE".padEnd(roleW), "NAME"].join(" ")));
837
+ const idW = Math.max(9, ...activeMembers.map((m) => m.personUid.length));
838
+ const header = ["EMAIL".padEnd(emailW), "ROLE".padEnd(roleW)];
839
+ if (opts.ids)
840
+ header.push("PERSON_ID".padEnd(idW));
841
+ header.push("NAME");
842
+ console.log(chalk.bold(header.join(" ")));
798
843
  for (const m of activeMembers) {
799
844
  const email = m.personEmail ?? m.personUid;
800
845
  const name = m.personName ?? m.personSlug ?? "";
801
- console.log([email.padEnd(emailW), m.role.padEnd(roleW), name].join(" "));
846
+ const row = [email.padEnd(emailW), m.role.padEnd(roleW)];
847
+ if (opts.ids)
848
+ row.push(m.personUid.padEnd(idW));
849
+ row.push(name);
850
+ console.log(row.join(" "));
802
851
  }
803
852
  }
804
853
  catch (err) {
@@ -153,6 +153,15 @@ export function registerWhoamiCommand(program) {
153
153
  authenticated: machine ? Boolean(machineCreds) : Boolean(cached && !isExpiring(cached, 0)),
154
154
  tokenSource: machine ? 'machine' : 'person',
155
155
  email: machine ? null : displayedIdentity.email ?? null,
156
+ // Cognito subject of the person shown in `email` (null for machine
157
+ // identities). Outpost jobs record owners by this id.
158
+ sub: machine ? null : displayedIdentity.sub ?? null,
159
+ // The human an Outpost session acts for, from the token's
160
+ // delegated claims, for machine and person sources alike.
161
+ delegatedEmail: identity.delegatedEmail !== undefined && identity.delegatedSub !== undefined
162
+ ? identity.delegatedEmail : null,
163
+ delegatedSub: identity.delegatedEmail !== undefined && identity.delegatedSub !== undefined
164
+ ? identity.delegatedSub : null,
156
165
  personUid: !machine && identity.entityType === 'person' ? identity.entityUid ?? null : null,
157
166
  agentUid,
158
167
  username: machineCreds?.username ?? null,
@@ -1,6 +1,6 @@
1
1
  import type { LaneRecord, LaneState, LanesConfig } from "../lanes/types.js";
2
2
  import { type LaneProvider } from "../lanes/types.js";
3
- import { type NowLimit, type NowView } from "./ledger.js";
3
+ import { type NowAccount, type NowLimit, type NowView } from "./ledger.js";
4
4
  /** All lane providers, in the stable order boss-route uses for ranking ties. */
5
5
  export declare const ROUTE_PROVIDERS: readonly LaneProvider[];
6
6
  /** Lane states that count against an account's live load. */
@@ -28,6 +28,12 @@ export declare const OLD_READING_PENALTY = 10;
28
28
  * view stale). Sits below any account with fresh known headroom.
29
29
  */
30
30
  export declare const UNKNOWN_USAGE_SCORE = -100;
31
+ /**
32
+ * Score for an account whose plan limit is used up but which still runs on
33
+ * paid extra usage. Ranked only with --allow-extra-usage, below every account
34
+ * with plan room or unknown usage.
35
+ */
36
+ export declare const EXTRA_USAGE_SCORE = -200;
31
37
  /** Default --floor applied by the CLI (percent headroom to clear). */
32
38
  export declare const DEFAULT_FLOOR = 15;
33
39
  /** Default --prefer order applied by the CLI. */
@@ -85,6 +91,8 @@ export interface RouteRanked {
85
91
  lane_flags: string;
86
92
  /** True when no usable reading exists and the account is ranked as unknown. */
87
93
  usage_unknown: boolean;
94
+ /** True when a plan limit is used up and the lane would spend paid extra usage. */
95
+ extra_usage?: boolean;
88
96
  }
89
97
  /** Live lane on an account the router would avoid; command names a replacement. */
90
98
  export interface RouteMove {
@@ -130,6 +138,12 @@ export interface PickAccountInput {
130
138
  capacityStale?: boolean;
131
139
  /** Error summary when the lanes list could not be read. */
132
140
  lanesError?: string | null;
141
+ /**
142
+ * Owner opt-in (2026-10-17): rank an account whose plan limit is used up
143
+ * but whose Claude extra usage is enabled and not capped. Off by default so
144
+ * lanes never spend paid usage without being asked to.
145
+ */
146
+ allowExtraUsage?: boolean;
133
147
  }
134
148
  export declare class RouteUsageError extends Error {
135
149
  readonly code = "route_usage";
@@ -141,6 +155,12 @@ export declare function resolveRequestedProviders(raw: readonly string[] | undef
141
155
  export declare function resolvePreferOrder(raw: readonly string[] | undefined): LaneProvider[];
142
156
  /** Parse `--floor <n>`; 0..100 inclusive, default DEFAULT_FLOOR when missing. */
143
157
  export declare function resolveFloor(raw: string | number | undefined): number;
158
+ /**
159
+ * True when the account's Claude extra usage reading says the provider will
160
+ * keep serving it after a plan limit runs out: enabled, not at its monthly
161
+ * cap, and not stopped by a spend control.
162
+ */
163
+ export declare function hasExtraUsageRoom(account: NowAccount): boolean;
144
164
  /**
145
165
  * Pure routing decision over a snapshot: scores every signed-in lane account,
146
166
  * applies the --prefer order under the --floor headroom, returns the pick (or
@@ -47,6 +47,12 @@ export const OLD_READING_PENALTY = 10;
47
47
  * view stale). Sits below any account with fresh known headroom.
48
48
  */
49
49
  export const UNKNOWN_USAGE_SCORE = -100;
50
+ /**
51
+ * Score for an account whose plan limit is used up but which still runs on
52
+ * paid extra usage. Ranked only with --allow-extra-usage, below every account
53
+ * with plan room or unknown usage.
54
+ */
55
+ export const EXTRA_USAGE_SCORE = -200;
50
56
  /** Default --floor applied by the CLI (percent headroom to clear). */
51
57
  export const DEFAULT_FLOOR = 15;
52
58
  /** Default --prefer order applied by the CLI. */
@@ -178,6 +184,27 @@ function liveLaneCount(lanes, provider, account) {
178
184
  }
179
185
  return count;
180
186
  }
187
+ /**
188
+ * True when the account's Claude extra usage reading says the provider will
189
+ * keep serving it after a plan limit runs out: enabled, not at its monthly
190
+ * cap, and not stopped by a spend control.
191
+ */
192
+ export function hasExtraUsageRoom(account) {
193
+ const limits = account.limits ?? [];
194
+ const extra = limits.find((l) => l.kind === "credits" && l.limitId === "extra_usage");
195
+ if (!extra || extra.hasCredits !== true)
196
+ return false;
197
+ if (extra.spendControlReached === true)
198
+ return false;
199
+ if (typeof extra.usedPercent === "number" && extra.usedPercent >= 100)
200
+ return false;
201
+ const spend = limits.find((l) => l.kind === "credits" && l.limitId === "spend");
202
+ if (spend?.spendControlReached === true)
203
+ return false;
204
+ if (spend && typeof spend.usedPercent === "number" && spend.usedPercent >= 100)
205
+ return false;
206
+ return true;
207
+ }
181
208
  function limitAgeMs(view, nowMs) {
182
209
  if (!view.resetPassed || !view.limit.resetsAt)
183
210
  return view.readAge;
@@ -368,9 +395,49 @@ export function pickAccount(input) {
368
395
  && row.readAge <= LIMIT_STALE_MS
369
396
  && (row.limit.state === "exhausted" || (typeof row.used === "number" && row.used >= 100)));
370
397
  if (exhausted) {
398
+ const extraRoom = providerId === "claude" && hasExtraUsageRoom(account);
399
+ if (extraRoom && input.allowExtraUsage === true) {
400
+ const live = liveLaneCount(lanes, providerId, laneMatch.id);
401
+ const weekly = applicable.find((l) => l.kind === "weekly_all");
402
+ const whyParts = [
403
+ `${labelLimit(exhausted.limit)} used up; runs on paid extra usage (--allow-extra-usage)`,
404
+ ];
405
+ if (live)
406
+ whyParts.push(`${live} live lane${live === 1 ? "" : "s"} already on it`);
407
+ if (tokenExpired)
408
+ whyParts.push("sign-in renews when the lane starts");
409
+ ranked.push({
410
+ provider: providerId,
411
+ account: laneMatch.id,
412
+ label,
413
+ organization: account.organization ?? null,
414
+ headroom: 0,
415
+ score: EXTRA_USAGE_SCORE - live * LANE_PENALTY,
416
+ live_lanes: live,
417
+ reset_soon: false,
418
+ token_expired: tokenExpired,
419
+ reading_age_h: Math.round(limitAgeMs(exhausted, nowMs) / 36e5),
420
+ binding: {
421
+ limit: labelLimit(exhausted.limit),
422
+ used_percent: typeof exhausted.used === "number" ? exhausted.used : 100,
423
+ resets_at: exhausted.limit.resetsAt ?? null,
424
+ },
425
+ weekly_resets_at: weekly?.resetsAt ?? null,
426
+ why: whyParts.join("; "),
427
+ lane_flags: `--provider ${providerId} --account ${laneMatch.id}`,
428
+ usage_unknown: false,
429
+ extra_usage: true,
430
+ });
431
+ continue;
432
+ }
371
433
  skipped.push({
372
434
  ...base,
373
- reason: `${labelLimit(exhausted.limit)} used up`,
435
+ reason: extraRoom
436
+ ? `${labelLimit(exhausted.limit)} used up; extra usage is on`
437
+ : `${labelLimit(exhausted.limit)} used up`,
438
+ ...(extraRoom
439
+ ? { fix: "pass --allow-extra-usage to run on paid extra usage" }
440
+ : {}),
374
441
  resets_at: exhausted.limit.resetsAt ?? null,
375
442
  });
376
443
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.144",
3
+ "version": "5.345.146",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {