@indigoai-us/hq-cli 5.283.0 → 5.285.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.285.0] — 2026-09-29
6
+
7
+ ## [5.284.0] — 2026-09-29
8
+ - The existing Work Mesh MQTT subscriber can signal agent inbox wakes through a private local marker without persisting MQTT message contents.
9
+ - Add `hq agents jobs resume <uid> <jobId>` to re-enable paused agent job schedules.
10
+
11
+ ### Fixed
12
+
13
+ - A remote `hq lanes list` read no longer dispatches owner hire summaries,
14
+ capacity probes, or mirror snapshots to other Outposts. Two Outposts that
15
+ each held a lane placed on the other ran `hq lanes list --json` on each other
16
+ without end, which filled both boxes with SSM commands.
17
+
18
+ ### Fixed
19
+
20
+ - Refresh remote lane mirrors on hosts with an Outpost identity, while excluding
21
+ mirrors owned by that host. Resolve company attribution before checking the
22
+ mirror gate for refreshes and remote logs; unresolved lanes stay local.
23
+ Remote-read invocations skip refresh to prevent reciprocal Outpost fan-out.
24
+
5
25
  ## [5.283.0] — 2026-09-29
6
26
 
7
27
  ### Added
@@ -4257,7 +4257,7 @@ export declare const COMMAND_CATALOG: readonly [{
4257
4257
  readonly subcommands: readonly [];
4258
4258
  }, {
4259
4259
  readonly name: "jobs";
4260
- readonly description: "List, pause, or cancel an agent's scheduled jobs";
4260
+ readonly description: "List, pause, resume, or cancel an agent's scheduled jobs";
4261
4261
  readonly aliases: readonly [];
4262
4262
  readonly hidden: false;
4263
4263
  readonly usage: "[options] [command]";
@@ -4302,6 +4302,26 @@ export declare const COMMAND_CATALOG: readonly [{
4302
4302
  readonly description: "Company slug (to resolve an agent by name/slug)";
4303
4303
  }];
4304
4304
  readonly subcommands: readonly [];
4305
+ }, {
4306
+ readonly name: "resume";
4307
+ readonly description: "Resume a paused job's schedule";
4308
+ readonly aliases: readonly [];
4309
+ readonly hidden: false;
4310
+ readonly usage: "[options] <agentUid> <jobId>";
4311
+ readonly arguments: readonly [{
4312
+ readonly name: "agentUid";
4313
+ readonly required: true;
4314
+ readonly variadic: false;
4315
+ }, {
4316
+ readonly name: "jobId";
4317
+ readonly required: true;
4318
+ readonly variadic: false;
4319
+ }];
4320
+ readonly options: readonly [{
4321
+ readonly flags: "--company <slug>";
4322
+ readonly description: "Company slug (to resolve an agent by name/slug)";
4323
+ }];
4324
+ readonly subcommands: readonly [];
4305
4325
  }, {
4306
4326
  readonly name: "cancel";
4307
4327
  readonly description: "Cancel a job and delete its schedule";
@@ -5512,7 +5512,7 @@ export const COMMAND_CATALOG = [
5512
5512
  },
5513
5513
  {
5514
5514
  "name": "jobs",
5515
- "description": "List, pause, or cancel an agent's scheduled jobs",
5515
+ "description": "List, pause, resume, or cancel an agent's scheduled jobs",
5516
5516
  "aliases": [],
5517
5517
  "hidden": false,
5518
5518
  "usage": "[options] [command]",
@@ -5570,6 +5570,32 @@ export const COMMAND_CATALOG = [
5570
5570
  ],
5571
5571
  "subcommands": []
5572
5572
  },
5573
+ {
5574
+ "name": "resume",
5575
+ "description": "Resume a paused job's schedule",
5576
+ "aliases": [],
5577
+ "hidden": false,
5578
+ "usage": "[options] <agentUid> <jobId>",
5579
+ "arguments": [
5580
+ {
5581
+ "name": "agentUid",
5582
+ "required": true,
5583
+ "variadic": false
5584
+ },
5585
+ {
5586
+ "name": "jobId",
5587
+ "required": true,
5588
+ "variadic": false
5589
+ }
5590
+ ],
5591
+ "options": [
5592
+ {
5593
+ "flags": "--company <slug>",
5594
+ "description": "Company slug (to resolve an agent by name/slug)"
5595
+ }
5596
+ ],
5597
+ "subcommands": []
5598
+ },
5573
5599
  {
5574
5600
  "name": "cancel",
5575
5601
  "description": "Cancel a job and delete its schedule",
@@ -19,6 +19,7 @@
19
19
  * hq agents revoke <uid> --yes — revoke an external agent (--remove deletes it)
20
20
  * hq agents jobs list <uid> — off-box job roster (schedule + rate)
21
21
  * hq agents jobs pause <uid> <jobId> — flip schedule State=DISABLED
22
+ * hq agents jobs resume <uid> <jobId> — flip schedule State=ENABLED
22
23
  * hq agents jobs cancel <uid> <jobId> — delete schedule + drop the job
23
24
  *
24
25
  * Agents are company-scoped. `--company <slug>` may sit on the group
@@ -320,12 +321,20 @@ export interface PauseJobResult {
320
321
  changed: boolean;
321
322
  previousState?: string;
322
323
  }
324
+ export interface ResumeJobResult {
325
+ ok: true;
326
+ jobId: string;
327
+ scheduleState: "ENABLED" | string;
328
+ changed: boolean;
329
+ previousState?: string;
330
+ }
323
331
  export interface CancelJobResult {
324
332
  ok: true;
325
333
  jobId: string;
326
334
  }
327
335
  export declare function listAgentJobs(token: string, agentUid: string): Promise<AgentJobView[]>;
328
336
  export declare function pauseAgentJob(token: string, agentUid: string, jobId: string): Promise<PauseJobResult>;
337
+ export declare function resumeAgentJob(token: string, agentUid: string, jobId: string): Promise<ResumeJobResult>;
329
338
  export declare function cancelAgentJob(token: string, agentUid: string, jobId: string): Promise<CancelJobResult>;
330
339
  /**
331
340
  * Map a jobs-control HTTP error to a single operator-facing line. Pure so the
@@ -336,10 +345,11 @@ export declare function cancelAgentJob(token: string, agentUid: string, jobId: s
336
345
  * the agent is cross-company) must print a clear message and exit non-zero,
337
346
  * never throw a stack trace.
338
347
  */
339
- export declare function formatJobsHttpError(err: AgentsHttpError, jobId?: string): string;
348
+ export declare function formatJobsHttpError(err: AgentsHttpError, jobId?: string, action?: "resume"): string;
340
349
  /** Stable padEnd table: jobId, scheduleState, rate, lastRunOutcome, status. */
341
350
  export declare function formatJobsTable(jobs: AgentJobView[]): string;
342
351
  export declare function formatPauseResult(result: PauseJobResult): string;
352
+ export declare function formatResumeResult(result: ResumeJobResult): string;
343
353
  /** Human-readable "hot-applied to the running box" vs "saved for next launch". */
344
354
  export declare function appliedHint(applied: boolean): string;
345
355
  /** Stable human table shared by the all-provider and provider-filtered forms. */
@@ -19,6 +19,7 @@
19
19
  * hq agents revoke <uid> --yes — revoke an external agent (--remove deletes it)
20
20
  * hq agents jobs list <uid> — off-box job roster (schedule + rate)
21
21
  * hq agents jobs pause <uid> <jobId> — flip schedule State=DISABLED
22
+ * hq agents jobs resume <uid> <jobId> — flip schedule State=ENABLED
22
23
  * hq agents jobs cancel <uid> <jobId> — delete schedule + drop the job
23
24
  *
24
25
  * Agents are company-scoped. `--company <slug>` may sit on the group
@@ -790,6 +791,13 @@ export async function pauseAgentJob(token, agentUid, jobId) {
790
791
  method: "POST",
791
792
  });
792
793
  }
794
+ export async function resumeAgentJob(token, agentUid, jobId) {
795
+ return agentsRequest({
796
+ token,
797
+ path: `/v1/agents/${encodeURIComponent(agentUid)}/jobs/${encodeURIComponent(jobId)}/resume`,
798
+ method: "POST",
799
+ });
800
+ }
793
801
  export async function cancelAgentJob(token, agentUid, jobId) {
794
802
  return agentsRequest({
795
803
  token,
@@ -806,7 +814,7 @@ export async function cancelAgentJob(token, agentUid, jobId) {
806
814
  * the agent is cross-company) must print a clear message and exit non-zero,
807
815
  * never throw a stack trace.
808
816
  */
809
- export function formatJobsHttpError(err, jobId) {
817
+ export function formatJobsHttpError(err, jobId, action) {
810
818
  if (err.status === 401)
811
819
  return "Not authenticated — run `hq login`.";
812
820
  // A 403 CROSS_AGENT_JOB is hq-pro's self-only jobs guard, not a role problem:
@@ -816,12 +824,17 @@ export function formatJobsHttpError(err, jobId) {
816
824
  // closed locally before the request, see crossAgentJobsRefusal).
817
825
  if (err.status === 403 && err.code === "CROSS_AGENT_JOB") {
818
826
  return ("Jobs are scoped to the calling agent's own identity — an agent may only " +
819
- "list, pause, or cancel its OWN jobs (use the on-box `hq-agent-jobs` tool). " +
827
+ "list, pause, resume, or cancel its OWN jobs (use the on-box `hq-agent-jobs` tool). " +
820
828
  "Managing another agent's jobs needs a person session with owner/admin on " +
821
829
  "the company.");
822
830
  }
823
831
  if (err.status === 403)
824
832
  return "You need owner/admin on this company.";
833
+ if (action === "resume" &&
834
+ err.status === 404 &&
835
+ (err.code === "JOB_NOT_FOUND" || err.code === "SCHEDULE_NOT_FOUND")) {
836
+ return "job not found or its schedule was deleted; it cannot be resumed";
837
+ }
825
838
  if (err.status === 404 && err.code === "JOB_NOT_FOUND") {
826
839
  const id = jobId ?? extractQuotedJobId(err.message) ?? "unknown";
827
840
  return `Job '${id}' not found.`;
@@ -860,6 +873,12 @@ export function formatPauseResult(result) {
860
873
  const from = result.previousState ?? "ENABLED";
861
874
  return `Paused ${result.jobId} (${from} → ${result.scheduleState})`;
862
875
  }
876
+ export function formatResumeResult(result) {
877
+ if (!result.changed)
878
+ return "Already active";
879
+ const from = result.previousState ?? "DISABLED";
880
+ return `Resumed ${result.jobId} (${from} → ${result.scheduleState})`;
881
+ }
863
882
  /** Human-readable "hot-applied to the running box" vs "saved for next launch". */
864
883
  export function appliedHint(applied) {
865
884
  return applied
@@ -1119,9 +1138,9 @@ function fail(err) {
1119
1138
  }
1120
1139
  process.exit(1);
1121
1140
  }
1122
- function failJobs(err, jobId) {
1141
+ function failJobs(err, jobId, action) {
1123
1142
  if (err instanceof AgentsHttpError) {
1124
- console.error(chalk.red(formatJobsHttpError(err, jobId)));
1143
+ console.error(chalk.red(formatJobsHttpError(err, jobId, action)));
1125
1144
  process.exit(1);
1126
1145
  }
1127
1146
  fail(err);
@@ -1922,11 +1941,11 @@ export function registerAgentsCommand(program) {
1922
1941
  }
1923
1942
  });
1924
1943
  // Off-box job control (US-003). Nested group so `hq agents jobs --help`
1925
- // lists list|pause|cancel. Same vaultApiFetch + person JWT as the rest of
1944
+ // lists list|pause|resume|cancel. Same vaultApiFetch + person JWT as the rest of
1926
1945
  // this file; keyed HQ_API_KEY rewrites /v1/agents → /v1/keys/agents.
1927
1946
  const jobs = agents
1928
1947
  .command("jobs")
1929
- .description("List, pause, or cancel an agent's scheduled jobs");
1948
+ .description("List, pause, resume, or cancel an agent's scheduled jobs");
1930
1949
  jobs
1931
1950
  .command("list <agentUid>")
1932
1951
  .description("List an agent's scheduled jobs")
@@ -1964,6 +1983,20 @@ export function registerAgentsCommand(program) {
1964
1983
  failJobs(err, jobId);
1965
1984
  }
1966
1985
  });
1986
+ jobs
1987
+ .command("resume <agentUid> <jobId>")
1988
+ .description("Resume a paused job's schedule")
1989
+ .option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
1990
+ .action(async function (agentUid, jobId) {
1991
+ const { token, targetUid } = await resolveJobsTargetOrExit(agentUid, companyOf(this));
1992
+ try {
1993
+ const result = await resumeAgentJob(token, targetUid, jobId);
1994
+ console.log(chalk.green(formatResumeResult(result)));
1995
+ }
1996
+ catch (err) {
1997
+ failJobs(err, jobId, "resume");
1998
+ }
1999
+ });
1967
2000
  jobs
1968
2001
  .command("cancel <agentUid> <jobId>")
1969
2002
  .description("Cancel a job and delete its schedule")
@@ -37,7 +37,7 @@ import { createOutpostProfileRepinContext, parseOutpostProfileRepinContext, } fr
37
37
  import { createOutpostRegistryWorkerApprovalContext, parseOutpostRegistryWorkerApprovalContext, } from "../lib/lanes/outpost-registry-worker-approval.js";
38
38
  import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
39
39
  export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
40
- import { buildOutpostCapacityCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostQuestionListCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, buildOutpostLaneListCommand, outpostRejectedEffortOption, parseOutpostPickupPayload, PICKUP_WAIT_MS, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
40
+ import { buildOutpostCapacityCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostQuestionListCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, buildOutpostLaneListCommand, REMOTE_LANE_READ_ENV, outpostRejectedEffortOption, parseOutpostPickupPayload, PICKUP_WAIT_MS, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
41
41
  import { outpostHireSummaryCacheKey, LANE_OUTPOST_SNAPSHOT_CACHE_FLAG, OUTPOST_HIRE_SUMMARY_CACHE_TTL_MS, readOutpostHireSummaryCache, readOutpostHireSummaryCacheIncludingStale, writeOutpostHireSummaryCache, } from "../lib/lanes/outpost-hire-cache.js";
42
42
  import { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "../lib/lanes/host-capacity.js";
43
43
  import { BALANCE_MOVABLE_STATES, selectClaudeAccount, } from "../lib/lanes/claude-balance.js";
@@ -1238,17 +1238,17 @@ async function readOutpostLaneRecords(ctx, outpostId, requestedQuery = {}, dispa
1238
1238
  }
1239
1239
  return invocationMemo.getOrCreate(outpostId, query, () => readOutpostSnapshotWithCache(ctx, outpostLaneListSnapshotKey(outpostId, query), isLaneRecordArray, () => readOutpostLaneRecordsUncached(ctx, outpostId, query, dispatchOptions)));
1240
1240
  }
1241
- /** Refresh only primary-created, non-terminal Outpost mirrors in one bounded owner query per Outpost. */
1241
+ /** Refresh only this host's non-terminal Outpost mirrors in one bounded owner query per Outpost. */
1242
1242
  async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
1243
1243
  const result = {
1244
1244
  updated: new Map(),
1245
1245
  unverifiedRemote: new Map(),
1246
1246
  };
1247
1247
  const host = reconcileHostFromCtx(ctx);
1248
- if (host.outpostId !== null || parentLaneIds?.size === 0)
1248
+ if (ctx.env.HQ_LANES_REMOTE_READ === "1" || parentLaneIds?.size === 0)
1249
1249
  return result;
1250
1250
  let primaryIdentity;
1251
- const originMatchesThisPrimary = (lane) => {
1251
+ const originMatchesThisHost = (lane) => {
1252
1252
  if (lane.origin_host_source !== "imds") {
1253
1253
  return lane.origin_host === host.hostname;
1254
1254
  }
@@ -1261,7 +1261,8 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
1261
1261
  };
1262
1262
  const candidates = records
1263
1263
  .filter((lane) => lane.host.kind === "outpost" &&
1264
- originMatchesThisPrimary(lane) &&
1264
+ (host.outpostId === null || lane.host.id !== host.outpostId) &&
1265
+ originMatchesThisHost(lane) &&
1265
1266
  (parentLaneIds === undefined || (lane.senior.kind === "lane" &&
1266
1267
  lane.senior.id !== undefined &&
1267
1268
  parentLaneIds.has(lane.senior.id))) &&
@@ -1272,11 +1273,14 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
1272
1273
  const enabledByCompany = new Map();
1273
1274
  const enabledCandidates = [];
1274
1275
  for (const lane of candidates) {
1275
- const companyKey = JSON.stringify([lane.company?.uid ?? null, lane.company?.slug ?? null]);
1276
+ const company = resolveLaneCompany(hqRoot, lane).company;
1277
+ if (company === null)
1278
+ continue;
1279
+ const companyKey = JSON.stringify([company.uid, company.slug]);
1276
1280
  let enabled = enabledByCompany.get(companyKey);
1277
1281
  if (enabled === undefined) {
1278
1282
  enabled = await readLanesRegistryFlag(LANE_REMOTE_MIRROR_SYNC_FLAG, {
1279
- company: lane.company,
1283
+ company,
1280
1284
  env: ctx.env,
1281
1285
  });
1282
1286
  enabledByCompany.set(companyKey, enabled);
@@ -1346,7 +1350,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
1346
1350
  : undefined;
1347
1351
  const before = readLane(hqRoot, mirror.lane_id);
1348
1352
  if (before.host.kind !== "outpost" || before.host.id !== outpostId ||
1349
- !originMatchesThisPrimary(before) || isTerminalLaneState(before.state)) {
1353
+ !originMatchesThisHost(before) || isTerminalLaneState(before.state)) {
1350
1354
  result.updated.set(mirror.lane_id, before);
1351
1355
  continue;
1352
1356
  }
@@ -1365,7 +1369,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds) {
1365
1369
  }
1366
1370
  const updated = retryUpdateLane(hqRoot, mirror.lane_id, "reconcile", (current) => {
1367
1371
  if (current.host.kind !== "outpost" || current.host.id !== outpostId ||
1368
- !originMatchesThisPrimary(current) || isTerminalLaneState(current.state))
1372
+ !originMatchesThisHost(current) || isTerminalLaneState(current.state))
1369
1373
  return current;
1370
1374
  return {
1371
1375
  ...current,
@@ -4543,9 +4547,12 @@ async function runList(ctx, opts, fastRouteFlags) {
4543
4547
  });
4544
4548
  return;
4545
4549
  }
4546
- const remoteRefresh = await refreshRemoteLaneMirrors(ctx, hqRoot, laneSource, new Set(selectedLanes
4547
- .filter((lane) => !isTerminalLaneState(lane.state))
4548
- .map((lane) => lane.lane_id)));
4550
+ const remoteRead = ctx.env[REMOTE_LANE_READ_ENV] === "1";
4551
+ const remoteRefresh = remoteRead
4552
+ ? { updated: new Map(), unverifiedRemote: new Map() }
4553
+ : await refreshRemoteLaneMirrors(ctx, hqRoot, laneSource, new Set(selectedLanes
4554
+ .filter((lane) => !isTerminalLaneState(lane.state))
4555
+ .map((lane) => lane.lane_id)));
4549
4556
  for (const [laneId, updated] of remoteRefresh.updated) {
4550
4557
  const index = laneSource.findIndex((lane) => lane.lane_id === laneId);
4551
4558
  if (index >= 0)
@@ -4563,7 +4570,8 @@ async function runList(ctx, opts, fastRouteFlags) {
4563
4570
  }
4564
4571
  const ownerEligibleLaneIds = new Set();
4565
4572
  for (const lane of selectedLanes) {
4566
- if (lane.host.kind !== "outpost" ||
4573
+ if (remoteRead ||
4574
+ lane.host.kind !== "outpost" ||
4567
4575
  isTerminalLaneState(lane.state) ||
4568
4576
  laneRunsHere(lane, host)) {
4569
4577
  continue;
@@ -4713,8 +4721,8 @@ async function runList(ctx, opts, fastRouteFlags) {
4713
4721
  };
4714
4722
  });
4715
4723
  const placementCompany = company ?? processLanePlacementCompany(ctx);
4716
- const placementEnabled = fastRouteFlags?.lanePlacementEnabled ??
4717
- await readLanePlacementEnabled(ctx, placementCompany);
4724
+ const placementEnabled = !remoteRead && (fastRouteFlags?.lanePlacementEnabled ??
4725
+ await readLanePlacementEnabled(ctx, placementCompany));
4718
4726
  const host_capacity = placementEnabled
4719
4727
  ? (await loadHostCapacitySnapshots(ctx, hqRoot, undefined, allLanes)).entries
4720
4728
  : undefined;
@@ -6801,14 +6809,18 @@ const CANONICAL_LOG_STREAMS = [
6801
6809
  async function runLogs(ctx, laneId, opts) {
6802
6810
  const hqRoot = ctx.hqRoot();
6803
6811
  const lane = readLane(hqRoot, laneId);
6812
+ const remoteMirrorCompany = lane.host.kind === "outpost"
6813
+ ? resolveLaneCompany(hqRoot, lane).company
6814
+ : null;
6804
6815
  const tail = parseOptionalInt(opts.tail, "--tail");
6805
6816
  if (tail === 0) {
6806
6817
  throw new LanesError("invalid_integer", "--tail must be a positive integer", { flag: "--tail", value: opts.tail });
6807
6818
  }
6808
6819
  if (lane.host.kind === "outpost" &&
6809
6820
  !laneRunsHere(lane, reconcileHostFromCtx(ctx)) &&
6821
+ remoteMirrorCompany !== null &&
6810
6822
  await readLanesRegistryFlag(LANE_REMOTE_MIRROR_SYNC_FLAG, {
6811
- company: lane.company,
6823
+ company: remoteMirrorCompany,
6812
6824
  env: ctx.env,
6813
6825
  })) {
6814
6826
  const outpostId = lane.host.id;
@@ -81,6 +81,14 @@ export declare function parseOutpostPickupPayload(raw: unknown): OutpostPickupPa
81
81
  export declare function buildOutpostPickupCommand(payloadUrl: string): string;
82
82
  /** Bounded, read-only capacity query executed by the target Outpost CLI. */
83
83
  export declare function buildOutpostCapacityCommand(): string;
84
+ /**
85
+ * Marks a lane list that runs on an Outpost for another host. Such a list
86
+ * answers from the box's own records and never dispatches to other Outposts:
87
+ * two boxes that each hold a lane placed on the other would otherwise run
88
+ * lists on each other without end. `env` keeps older CLIs working, since they
89
+ * ignore the variable instead of rejecting an unknown flag.
90
+ */
91
+ export declare const REMOTE_LANE_READ_ENV = "HQ_LANES_REMOTE_READ";
84
92
  /** Read lane records owned by an Outpost, using its configured HQ binary when set. */
85
93
  export declare function buildOutpostLaneListCommand(input?: {
86
94
  hqBinary?: string;
@@ -65,9 +65,19 @@ export function buildOutpostPickupCommand(payloadUrl) {
65
65
  export function buildOutpostCapacityCommand() {
66
66
  return withRemoteHqDir(joinCommandParts(["hq", "lanes", "capacity", "--json"]));
67
67
  }
68
+ /**
69
+ * Marks a lane list that runs on an Outpost for another host. Such a list
70
+ * answers from the box's own records and never dispatches to other Outposts:
71
+ * two boxes that each hold a lane placed on the other would otherwise run
72
+ * lists on each other without end. `env` keeps older CLIs working, since they
73
+ * ignore the variable instead of rejecting an unknown flag.
74
+ */
75
+ export const REMOTE_LANE_READ_ENV = "HQ_LANES_REMOTE_READ";
68
76
  /** Read lane records owned by an Outpost, using its configured HQ binary when set. */
69
77
  export function buildOutpostLaneListCommand(input = {}) {
70
78
  const args = [
79
+ "env",
80
+ `${REMOTE_LANE_READ_ENV}=1`,
71
81
  input.hqBinary?.trim() || "hq",
72
82
  "lanes",
73
83
  "list",
@@ -562,13 +562,21 @@ export function normalizePersonalRealtimeBundle(raw) {
562
562
  }
563
563
  if (!topics.dm && typeof r.topic === "string")
564
564
  topics.dm = r.topic;
565
+ const agentInboxTopic = typeof r.agentInboxTopic === "string" ? r.agentInboxTopic.trim() : "";
565
566
  const actorUid = (typeof r.principalUid === "string" && /^(?:prs|agt)_[A-Za-z0-9]+$/.test(r.principalUid)
566
567
  ? r.principalUid
567
568
  : undefined) ||
568
569
  actorUidFromPersonalTopic(topics.dm) ||
570
+ actorUidFromPersonalTopic(agentInboxTopic) ||
569
571
  "";
570
572
  if (!actorUid)
571
573
  throw new Error("contract-2 vend could not derive actorUid");
574
+ // hq-pro advertises the agent's own inbox topic as a top-level capability.
575
+ // Keep it only for the exact actor-owned topic; it reuses this personal MQTT
576
+ // session and never grants the subscriber a topic supplied by another actor.
577
+ if (actorUid.startsWith("agt_") && agentInboxTopic === `hq/${actorUid}/inbox`) {
578
+ topics.inbox = agentInboxTopic;
579
+ }
572
580
  // Only keep topics that belong to the vended principal.
573
581
  for (const [k, v] of Object.entries(topics)) {
574
582
  if (actorUidFromPersonalTopic(v) !== actorUid)
@@ -14,6 +14,7 @@ export interface WorkCacheMqtt {
14
14
  export interface WorkCacheSubscriber {
15
15
  stop: () => Promise<void>;
16
16
  }
17
+ export declare const AGENT_INBOX_PUSH_STATE_BASENAME = "agent-inbox-push.json";
17
18
  export declare function startWorkCacheSubscriber(opts: {
18
19
  meshRoot: string;
19
20
  fetchCredentials?: PersonalCredentialsFetcher;
@@ -2,10 +2,57 @@
2
2
  * Contract-2 subscriber for the personal work topic.
3
3
  * Owns live-cache.json writes. Presence publish stays on PresenceClient.
4
4
  */
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
5
7
  import { createPersonalRealtimeFetcher, } from "./credentials.js";
6
8
  import { presignIotWssUrl } from "./presign.js";
7
9
  import { applyProjectViewPush, parseProjectViewPush } from "./live-cache.js";
8
10
  import { backoffDelayMs, RECONNECT_MIN_DELAY_MS } from "./presence.js";
11
+ export const AGENT_INBOX_PUSH_STATE_BASENAME = "agent-inbox-push.json";
12
+ const INBOX_PUSH_HEARTBEAT_MS = 5_000;
13
+ function readInboxWakeSequence(meshRoot, onInfo) {
14
+ const target = path.join(meshRoot, AGENT_INBOX_PUSH_STATE_BASENAME);
15
+ try {
16
+ const info = fs.lstatSync(target);
17
+ if (!info.isFile() || info.isSymbolicLink()) {
18
+ onInfo?.("agent inbox push state read ignored (not a regular file)");
19
+ return 0;
20
+ }
21
+ const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
22
+ if (Number.isSafeInteger(parsed.wakeSequence) && Number(parsed.wakeSequence) >= 0) {
23
+ return Number(parsed.wakeSequence);
24
+ }
25
+ onInfo?.("agent inbox push state read ignored (invalid wake sequence)");
26
+ return 0;
27
+ }
28
+ catch (error) {
29
+ if (error.code !== "ENOENT") {
30
+ const name = error instanceof Error ? error.name : "UnknownError";
31
+ onInfo?.(`agent inbox push state read failed (${name})`);
32
+ }
33
+ return 0;
34
+ }
35
+ }
36
+ function writeInboxPushState(meshRoot, state) {
37
+ fs.mkdirSync(meshRoot, { recursive: true, mode: 0o700 });
38
+ const target = path.join(meshRoot, AGENT_INBOX_PUSH_STATE_BASENAME);
39
+ const temporary = `${target}.${process.pid}.tmp`;
40
+ try {
41
+ fs.writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 });
42
+ fs.chmodSync(temporary, 0o600);
43
+ fs.renameSync(temporary, target);
44
+ }
45
+ catch (error) {
46
+ try {
47
+ fs.unlinkSync(temporary);
48
+ }
49
+ catch (cleanupError) {
50
+ if (cleanupError.code !== "ENOENT")
51
+ throw cleanupError;
52
+ }
53
+ throw error;
54
+ }
55
+ }
9
56
  export async function startWorkCacheSubscriber(opts) {
10
57
  const fetchCredentials = opts.fetchCredentials ??
11
58
  (opts.token
@@ -28,10 +75,48 @@ export async function startWorkCacheSubscriber(opts) {
28
75
  let reconnectLogged = false;
29
76
  let attempt = 0;
30
77
  let timer = null;
78
+ let heartbeatTimer = null;
31
79
  let generation = 0;
80
+ let inboxSubscribed = false;
81
+ // Carry the sequence forward from the last state marker so a daemon restart
82
+ // cannot reuse a value the watcher has already consumed.
83
+ let wakeSequence = readInboxWakeSequence(opts.meshRoot, opts.onInfo);
84
+ const clearInboxHeartbeat = () => {
85
+ if (heartbeatTimer !== null)
86
+ timers.clearTimeout(heartbeatTimer);
87
+ heartbeatTimer = null;
88
+ };
89
+ const persistInboxState = (connected) => {
90
+ try {
91
+ writeInboxPushState(opts.meshRoot, {
92
+ connected,
93
+ heartbeatAtMs: now().getTime(),
94
+ wakeSequence,
95
+ });
96
+ }
97
+ catch (error) {
98
+ const name = error instanceof Error ? error.name : "UnknownError";
99
+ opts.onInfo?.(`agent inbox push state write failed (${name})`);
100
+ }
101
+ };
102
+ const scheduleInboxHeartbeat = () => {
103
+ if (stopped || !inboxSubscribed)
104
+ return;
105
+ clearInboxHeartbeat();
106
+ heartbeatTimer = timers.setTimeout(() => {
107
+ heartbeatTimer = null;
108
+ if (!stopped && inboxSubscribed) {
109
+ persistInboxState(true);
110
+ scheduleInboxHeartbeat();
111
+ }
112
+ }, INBOX_PUSH_HEARTBEAT_MS);
113
+ };
32
114
  const connect = async () => {
33
115
  if (stopped)
34
116
  return;
117
+ inboxSubscribed = false;
118
+ clearInboxHeartbeat();
119
+ persistInboxState(false);
35
120
  const gen = ++generation;
36
121
  let bundle;
37
122
  try {
@@ -44,8 +129,12 @@ export async function startWorkCacheSubscriber(opts) {
44
129
  if (stopped || gen !== generation)
45
130
  return;
46
131
  const topic = bundle.topics.work;
47
- if (!topic) {
48
- opts.onInfo?.("work cache subscribe skipped: credentials have no work topic");
132
+ const inboxTopic = bundle.actorUid.startsWith("agt_") &&
133
+ bundle.topics.inbox === `hq/${bundle.actorUid}/inbox`
134
+ ? bundle.topics.inbox
135
+ : undefined;
136
+ if (!topic && !inboxTopic) {
137
+ opts.onInfo?.("work cache subscribe skipped: credentials have no supported topic");
49
138
  return;
50
139
  }
51
140
  const url = presignIotWssUrl(bundle.credentials, bundle.iotEndpoint, bundle.region, now());
@@ -60,12 +149,55 @@ export async function startWorkCacheSubscriber(opts) {
60
149
  if (stopped || gen !== generation)
61
150
  return;
62
151
  reconnectLogged = false;
63
- attempt = 0;
64
- next.subscribe(topic, { qos: 1 }, () => undefined);
152
+ let pendingSubscriptions = Number(Boolean(topic)) + Number(Boolean(inboxTopic));
153
+ let subscriptionFailed = false;
154
+ const finishSubscription = (err) => {
155
+ if (err) {
156
+ if (subscriptionFailed)
157
+ return;
158
+ subscriptionFailed = true;
159
+ schedule(err);
160
+ next.end(true);
161
+ return;
162
+ }
163
+ if (subscriptionFailed)
164
+ return;
165
+ pendingSubscriptions -= 1;
166
+ if (pendingSubscriptions === 0)
167
+ attempt = 0;
168
+ };
169
+ if (topic) {
170
+ next.subscribe(topic, { qos: 1 }, (err) => finishSubscription(err));
171
+ }
172
+ if (inboxTopic) {
173
+ next.subscribe(inboxTopic, { qos: 1 }, (err) => {
174
+ if (stopped || gen !== generation)
175
+ return;
176
+ if (err) {
177
+ inboxSubscribed = false;
178
+ clearInboxHeartbeat();
179
+ persistInboxState(false);
180
+ opts.onInfo?.("agent inbox push subscribe failed");
181
+ finishSubscription(err);
182
+ return;
183
+ }
184
+ inboxSubscribed = true;
185
+ persistInboxState(true);
186
+ scheduleInboxHeartbeat();
187
+ finishSubscription();
188
+ });
189
+ }
65
190
  });
66
191
  next.on("message", (_topic, payload) => {
67
192
  if (stopped || gen !== generation)
68
193
  return;
194
+ if (inboxTopic && _topic === inboxTopic && inboxSubscribed) {
195
+ // A topic message is a doorbell only. Never parse, persist, or log its
196
+ // content; the authenticated inbox GET remains the source of truth.
197
+ wakeSequence += 1;
198
+ persistInboxState(true);
199
+ return;
200
+ }
69
201
  if (_topic !== topic)
70
202
  return;
71
203
  const push = parseProjectViewPush(payload);
@@ -79,11 +211,14 @@ export async function startWorkCacheSubscriber(opts) {
79
211
  next.on("close", () => {
80
212
  if (stopped || gen !== generation)
81
213
  return;
214
+ inboxSubscribed = false;
215
+ clearInboxHeartbeat();
216
+ persistInboxState(false);
82
217
  schedule();
83
218
  });
84
219
  };
85
220
  const schedule = (err) => {
86
- if (stopped)
221
+ if (stopped || timer !== null)
87
222
  return;
88
223
  if (!reconnectLogged) {
89
224
  reconnectLogged = true;
@@ -106,6 +241,9 @@ export async function startWorkCacheSubscriber(opts) {
106
241
  generation += 1;
107
242
  if (timer !== null)
108
243
  timers.clearTimeout(timer);
244
+ clearInboxHeartbeat();
245
+ inboxSubscribed = false;
246
+ persistInboxState(false);
109
247
  const current = client;
110
248
  client = null;
111
249
  if (!current)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.283.0",
3
+ "version": "5.285.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {