@indigoai-us/hq-cli 5.345.144 → 5.345.145

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,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.145] — 2026-10-17
6
+ - `hq core checkpoint`: the background checkpoint helper now fills in automatic
7
+ saves that were queued before it started. It writes its own report first,
8
+ then works through at most 5 queued saves per run; any extra queued saves go
9
+ back on the queue for the next run instead of being dropped. If a run is
10
+ stopped partway through its queued saves, the next run puts the ones it did
11
+ not finish back on the queue.
12
+ - Outpost scheduled-job alerts now reach owners recorded by their Cognito user
13
+ id. `hq core job notify` used to pass the id to `hq dm`, which read it as a
14
+ channel name and failed with "No channel named …". It now sends to the email
15
+ `hq whoami --json` reports for that id (the signed-in person, or the person
16
+ an Outpost acts for), and logs a clear reason when it cannot match one.
17
+ - `hq whoami --json` adds `sub`, `delegatedEmail` and `delegatedSub`.
18
+ - `hq core jobs reconcile` matches company jobs owned by the Outpost owner's
19
+ email or id, and reaches the user systemd manager when it runs from a system
20
+ service with no login session (it used to fail with "Failed to connect to
21
+ bus: No medium found").
22
+ - `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.
23
+
5
24
  ## [5.345.144] — 2026-10-16
6
25
  - New `hq library add <file|url>...` uploads files or http(s) URLs straight to
7
26
  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
  }, {
@@ -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": []
@@ -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) {
@@ -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,
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.145",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {