@indigoai-us/hq-cli 5.345.49 → 5.345.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/command-catalog.generated.d.ts +56 -11
  3. package/dist/command-catalog.generated.js +67 -11
  4. package/dist/commands/core-hq-session-native.js +2 -2
  5. package/dist/commands/core-resolve-company-native.js +2 -2
  6. package/dist/commands/db-destroy.js +13 -11
  7. package/dist/commands/db-dump.js +3 -3
  8. package/dist/commands/db-remote-deps.d.ts +3 -0
  9. package/dist/commands/db-remote-deps.js +13 -0
  10. package/dist/commands/db-sql.js +4 -3
  11. package/dist/commands/db-usage.js +4 -3
  12. package/dist/commands/dm-forward-flag.d.ts +4 -0
  13. package/dist/commands/dm-forward-flag.js +7 -0
  14. package/dist/commands/dm.d.ts +8 -0
  15. package/dist/commands/dm.js +219 -27
  16. package/dist/commands/lanes.js +52 -11
  17. package/dist/commands/secrets.js +2 -2
  18. package/dist/commands/workers.js +2 -2
  19. package/dist/lib/bot/daemon.d.ts +1 -1
  20. package/dist/lib/bot/daemon.js +35 -29
  21. package/dist/lib/daemon/hq-cloud.d.ts +2 -2
  22. package/dist/lib/daemon/hq-cloud.js +38 -9
  23. package/dist/lib/daemon/install.js +34 -3
  24. package/dist/lib/daemon/run.js +45 -19
  25. package/dist/lib/db/app-ref.d.ts +21 -0
  26. package/dist/lib/db/app-ref.js +45 -0
  27. package/dist/lib/doctor/checks/bots.js +3 -8
  28. package/dist/lib/lanes/keep-alive-audit.d.ts +5 -1
  29. package/dist/lib/lanes/keep-alive-audit.js +85 -3
  30. package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
  31. package/dist/lib/lanes/outpost-dispatch.js +2 -0
  32. package/dist/lib/mesh/live/daemon/install.js +28 -15
  33. package/dist/lib/service-manager/index.d.ts +3 -0
  34. package/dist/lib/service-manager/index.js +22 -6
  35. package/dist/lib/service-manager/launchd.d.ts +40 -3
  36. package/dist/lib/service-manager/launchd.js +106 -5
  37. package/dist/lib/service-manager/migration-state.d.ts +12 -0
  38. package/dist/lib/service-manager/migration-state.js +46 -0
  39. package/dist/lib/service-manager/migration.d.ts +22 -0
  40. package/dist/lib/service-manager/migration.js +85 -0
  41. package/dist/lib/service-manager/startup-migration.d.ts +24 -0
  42. package/dist/lib/service-manager/startup-migration.js +27 -0
  43. package/dist/lib/service-manager/types.d.ts +3 -0
  44. package/dist/main.js +22 -0
  45. package/dist/native-fast-route.d.ts +4 -0
  46. package/dist/native-fast-route.js +20 -0
  47. package/dist/utils/resident-package-preload.js +2 -1
  48. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
5
5
  import { isMachineIdentity } from "@indigoai-us/hq-cloud";
6
6
  import { peekIdToken } from "../utils/id-token.js";
7
7
  import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN } from "../lib/notify-recipient.js";
8
+ import { dmForwardCliEnabled } from "./dm-forward-flag.js";
8
9
  /**
9
10
  * Classify a recipient arg as an email or a person/agent uid. Mirrors the
10
11
  * email/uid heuristic used by `hq members`. Returns null for neither.
@@ -346,6 +347,32 @@ function friendlyDmError(status, code, fallback) {
346
347
  return `Server error: ${fallback}`;
347
348
  return fallback;
348
349
  }
350
+ function friendlyForwardError(code) {
351
+ switch (code) {
352
+ case "FORWARD_SOURCE_NOT_FOUND":
353
+ return "Source message not found or you cannot read it.";
354
+ case "INVALID_FORWARD_OF":
355
+ return "Invalid source message reference; provide a valid eventId and --from value.";
356
+ case "INVALID_FILE_ACCESS":
357
+ return "Invalid file access choice; use --grant-files or --omit-files.";
358
+ case "FORWARD_FILE_ACCESS_REQUIRED":
359
+ return "Some forwarded files are not readable by the recipient.";
360
+ case "FORWARD_FILES_NOT_ALLOWED":
361
+ return "Files cannot be granted for this channel or cross-company forward.";
362
+ case "CROSS_COMPANY_ACK_REQUIRED":
363
+ return "Warning: this forward crosses company boundaries; review the destination and retry with --cross-company.";
364
+ case "CROSS_COMPANY_FORBIDDEN":
365
+ return "Cross-company forwarding requires owner or admin access in both companies.";
366
+ case "FORWARD_FILE_SHARE_FORBIDDEN":
367
+ return "You do not have permission to share one or more forwarded files.";
368
+ case "FORWARD_FILE_GRANT_FAILED":
369
+ return "The recipient could not be granted access to a forwarded file; no message was sent.";
370
+ case "RECIPIENT_NOT_FOUND":
371
+ return "Recipient not found or not reachable.";
372
+ default:
373
+ return "Message forwarding failed; check the source and destination, then try again.";
374
+ }
375
+ }
349
376
  /**
350
377
  * Resolve a connection request from a pending-requests list by a caller-supplied
351
378
  * identifier (email, personUid, or pairKey). Pure → unit-testable.
@@ -529,6 +556,21 @@ export async function fetchChannelMembers(token, channelId) {
529
556
  const data = (await res.json());
530
557
  return data.members ?? [];
531
558
  }
559
+ /** Resolve structured mentions in a channel message body, failing before post on invalid names. */
560
+ async function resolveChannelBodyMentions(token, channelId, body) {
561
+ const tokens = parseMentionTokens(body);
562
+ if (tokens.length === 0)
563
+ return { ok: true, body, mentions: [] };
564
+ const members = await fetchChannelMembers(token, channelId);
565
+ const resolved = resolveMentions(tokens, members);
566
+ if (!resolved.ok)
567
+ return resolved;
568
+ return {
569
+ ok: true,
570
+ body: stripMentionQuotes(body),
571
+ mentions: resolved.mentions,
572
+ };
573
+ }
532
574
  /** Fetch a channel history page (GET /v1/notify/channels/{id}/messages). Newest-first. */
533
575
  async function fetchChannelMessages(token, channelId, query = {}) {
534
576
  const res = await vaultApiFetch({
@@ -546,9 +588,9 @@ async function fetchChannelMessages(token, channelId, query = {}) {
546
588
  /**
547
589
  * Channel DM path: `hq dm vyg-dev "msg"`, `hq dm '#vyg-dev' "msg"`, or
548
590
  * `hq dm --channel vyg-dev "msg"`. Resolves the caller's channel by name via
549
- * GET /v1/notify/channels, then posts the message into it. Scheduling /
550
- * prompt / details are 1:1-DM features and are rejected here rather than
551
- * silently dropped.
591
+ * GET /v1/notify/channels, then posts the message into it. Scheduling remains
592
+ * a 1:1-DM feature; prompt and details are supported for channel sends while
593
+ * the dm.forward-cli kill switch is enabled.
552
594
  */
553
595
  async function runChannelSend(channelName, message, opts) {
554
596
  try {
@@ -557,12 +599,17 @@ async function runChannelSend(channelName, message, opts) {
557
599
  console.error(chalk.red(`A message body is required: hq dm ${channelName} "<message>" (or hq dm --channel ${channelName} "<message>").`));
558
600
  process.exit(1);
559
601
  }
560
- const unsupported = [
561
- opts.prompt || opts.promptFile ? "--prompt/--prompt-file" : null,
562
- opts.details || opts.detailsFile ? "--details/--details-file" : null,
563
- opts.at ? "--at" : null,
564
- opts.in ? "--in" : null,
565
- ].filter(Boolean);
602
+ const hasChannelContext = opts.prompt !== undefined || opts.promptFile !== undefined ||
603
+ opts.details !== undefined || opts.detailsFile !== undefined;
604
+ if (hasChannelContext && !dmForwardCliEnabled()) {
605
+ console.error(chalk.red("Channel --details/--prompt support is disabled by the dm.forward-cli kill switch."));
606
+ process.exit(1);
607
+ }
608
+ if (opts.promptFile === "" || opts.detailsFile === "") {
609
+ console.error(chalk.red("--prompt-file and --details-file require a non-empty path."));
610
+ process.exit(1);
611
+ }
612
+ const unsupported = [opts.at ? "--at" : null, opts.in ? "--in" : null].filter(Boolean);
566
613
  if (unsupported.length > 0) {
567
614
  console.error(chalk.red(`${unsupported.join(", ")} ${unsupported.length === 1 ? "is" : "are"} only supported for 1:1 DMs, not channel messages.`));
568
615
  process.exit(1);
@@ -599,21 +646,16 @@ async function runChannelSend(channelName, message, opts) {
599
646
  // and posts the text literally.
600
647
  let mentions = [];
601
648
  if (opts.mentions !== false) {
602
- const tokens = parseMentionTokens(body);
603
- if (tokens.length > 0) {
604
- const members = await fetchChannelMembers(token, channel.channelId);
605
- const resolved = resolveMentions(tokens, members);
606
- if (!resolved.ok) {
607
- console.error(chalk.red(resolved.error));
608
- process.exit(1);
609
- return;
610
- }
611
- mentions = resolved.mentions;
649
+ const resolved = await resolveChannelBodyMentions(token, channel.channelId, body);
650
+ if (!resolved.ok) {
651
+ console.error(chalk.red(resolved.error));
652
+ process.exit(1);
653
+ return;
612
654
  }
655
+ body = resolved.body;
656
+ mentions = resolved.mentions;
613
657
  }
614
658
  // Send the human-readable form: the quotes only existed for parsing.
615
- if (mentions.length > 0)
616
- body = stripMentionQuotes(body);
617
659
  let audience;
618
660
  if (opts.audience !== undefined && opts.audience !== "") {
619
661
  if (!DM_AUDIENCE_VALUES.includes(opts.audience)) {
@@ -622,6 +664,12 @@ async function runChannelSend(channelName, message, opts) {
622
664
  audience = opts.audience;
623
665
  }
624
666
  const payload = { body };
667
+ const prompt = opts.promptFile ? readFileSync(opts.promptFile, "utf8") : opts.prompt;
668
+ const details = opts.detailsFile ? readFileSync(opts.detailsFile, "utf8") : opts.details;
669
+ if (prompt !== undefined)
670
+ payload.prompt = prompt;
671
+ if (details !== undefined)
672
+ payload.details = details;
625
673
  if (mentions.length > 0)
626
674
  payload.mentions = mentions;
627
675
  if (rootEventId)
@@ -736,6 +784,130 @@ async function runDmSend(recipient, message, opts) {
736
784
  process.exit(1);
737
785
  }
738
786
  }
787
+ async function resolveForwardSourceConversation(token, source) {
788
+ if (source.includes(",")) {
789
+ throw new Error("--from accepts one person or channel, not a group recipient list.");
790
+ }
791
+ const channelName = parseChannelName(source);
792
+ if (channelName !== null) {
793
+ const channel = await resolveChannel(token, channelName);
794
+ return { conversationId: channel.channelId };
795
+ }
796
+ const recipient = detectRecipient(source);
797
+ if (!recipient) {
798
+ throw new Error("--from must be a person email, person/agent UID, or channel name/ID.");
799
+ }
800
+ if (recipient.toPersonUid)
801
+ return { conversationId: recipient.toPersonUid };
802
+ const res = await vaultApiFetch({ token, path: "/v1/notify/contacts" });
803
+ if (!res.ok)
804
+ throw new Error("Could not resolve the source person; use their person UID.");
805
+ const data = (await res.json());
806
+ const peer = (data.contacts ?? []).find((contact) => contact.email?.trim().toLowerCase() === recipient.toEmail);
807
+ if (!peer?.personUid)
808
+ throw new Error("Source person not found in your contacts; use their person UID.");
809
+ return { conversationId: peer.personUid };
810
+ }
811
+ async function runDmForward(eventId, opts) {
812
+ try {
813
+ if (!dmForwardCliEnabled()) {
814
+ console.error(chalk.red("Message forwarding is disabled by the dm.forward-cli kill switch."));
815
+ process.exit(1);
816
+ }
817
+ if (opts.grantFiles && opts.omitFiles) {
818
+ console.error(chalk.red("Choose only one of --grant-files or --omit-files."));
819
+ process.exit(1);
820
+ }
821
+ if (opts.note !== undefined && opts.noteFile !== undefined) {
822
+ console.error(chalk.red("Choose only one of --note or --note-file."));
823
+ process.exit(1);
824
+ }
825
+ if (!eventId.trim()) {
826
+ console.error(chalk.red("A source eventId is required."));
827
+ process.exit(1);
828
+ }
829
+ if (opts.to.includes(",")) {
830
+ console.error(chalk.red("--to accepts one recipient or channel, not a group recipient list."));
831
+ process.exit(1);
832
+ }
833
+ const note = opts.noteFile !== undefined
834
+ ? readFileSync(opts.noteFile, "utf8")
835
+ : opts.note;
836
+ const token = await ensureCognitoToken();
837
+ const source = await resolveForwardSourceConversation(token, opts.from);
838
+ const destinationChannelName = parseChannelName(opts.to);
839
+ const fileAccess = opts.grantFiles ? "grant" : opts.omitFiles ? "omit" : undefined;
840
+ const shared = {
841
+ ...(note !== undefined ? { body: note } : {}),
842
+ forwardOf: { conversationId: source.conversationId, eventId: eventId.trim() },
843
+ ...(fileAccess ? { fileAccess } : {}),
844
+ ...(opts.crossCompany ? { acknowledgeCrossCompany: true } : {}),
845
+ };
846
+ let path;
847
+ let payload;
848
+ let destinationLabel;
849
+ if (destinationChannelName !== null) {
850
+ const channel = await resolveChannel(token, destinationChannelName);
851
+ path = `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`;
852
+ payload = { ...shared };
853
+ if (note !== undefined) {
854
+ const resolved = await resolveChannelBodyMentions(token, channel.channelId, note);
855
+ if (!resolved.ok) {
856
+ console.error(chalk.red(resolved.error));
857
+ process.exit(1);
858
+ return;
859
+ }
860
+ payload.body = resolved.body;
861
+ if (resolved.mentions.length > 0)
862
+ payload.mentions = resolved.mentions;
863
+ }
864
+ destinationLabel = `#${channel.name ?? destinationChannelName}`;
865
+ }
866
+ else {
867
+ const recipient = detectRecipient(opts.to);
868
+ if (!recipient) {
869
+ console.error(chalk.red("--to must be a person email, person/agent UID, or channel name/ID."));
870
+ process.exit(1);
871
+ }
872
+ path = "/v1/notify/dm";
873
+ payload = { ...recipient, ...shared };
874
+ destinationLabel = opts.to;
875
+ }
876
+ const res = await vaultApiFetch({ token, path, method: "POST", body: payload });
877
+ if (!res.ok) {
878
+ const err = (await res.json().catch(() => ({})));
879
+ const code = typeof err.code === "string" ? err.code : undefined;
880
+ if (code === "FORWARD_FILE_ACCESS_REQUIRED") {
881
+ console.error(chalk.yellow(friendlyForwardError(code)));
882
+ const files = Array.isArray(err.files) ? err.files : [];
883
+ for (const item of files) {
884
+ if (!item || typeof item !== "object")
885
+ continue;
886
+ const file = item;
887
+ const label = typeof file.name === "string" ? file.name : "forwarded file";
888
+ const location = typeof file.vaultPath === "string" ? file.vaultPath :
889
+ typeof file.path === "string" ? file.path : undefined;
890
+ console.error(chalk.dim(` - ${label}${location ? ` (${location})` : ""}`));
891
+ }
892
+ console.error(chalk.dim("Retry with --grant-files or --omit-files."));
893
+ }
894
+ else {
895
+ console.error(chalk.red(friendlyForwardError(code)));
896
+ }
897
+ process.exit(1);
898
+ }
899
+ const data = (await res.json().catch(() => ({})));
900
+ if (typeof data.eventId !== "string" || !data.eventId.trim()) {
901
+ console.error(chalk.red("Forward succeeded without returning an eventId."));
902
+ process.exit(1);
903
+ }
904
+ console.log(chalk.green(`Message forwarded to ${destinationLabel}. New eventId: ${data.eventId}`));
905
+ }
906
+ catch (err) {
907
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
908
+ process.exit(1);
909
+ }
910
+ }
739
911
  async function runDmRequests() {
740
912
  try {
741
913
  const token = await ensureCognitoToken();
@@ -871,7 +1043,11 @@ export function formatThreadMessage(m, nowMs) {
871
1043
  const who = m.direction === "out" ? "you" : senderLabel(m);
872
1044
  const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
873
1045
  const audienceTag = m.audience === "agent" ? chalk.dim(" [agent]") : "";
874
- return `${arrow} ${chalk.bold(who)} ${when}${audienceTag}\n ${firstLine(m.body)}${formatAttachments(m)}`;
1046
+ const id = m.eventId ? ` ${chalk.dim(m.eventId)}` : "";
1047
+ const forwarded = m.forwardedFrom?.senderName
1048
+ ? `\n ${chalk.dim(`Forwarded from ${m.forwardedFrom.senderName}`)}`
1049
+ : "";
1050
+ return `${arrow} ${chalk.bold(who)} ${when}${audienceTag}${id}${forwarded}\n ${firstLine(m.body)}${formatAttachments(m)}`;
875
1051
  }
876
1052
  /** Render one channel/group message line. */
877
1053
  export function formatChannelMessage(m, nowMs, opts = {}) {
@@ -886,7 +1062,10 @@ export function formatChannelMessage(m, nowMs, opts = {}) {
886
1062
  (senderLabel(m).toLowerCase() === "hq"
887
1063
  ? "System notification (no text provided)."
888
1064
  : "(no message text)");
889
- return `${indent}${who} ${when}${idPart}\n${bodyIndent}${readableBody}${formatAttachments(m, bodyIndent)}`;
1065
+ const forwarded = m.forwardedFrom?.senderName
1066
+ ? `${bodyIndent}${chalk.dim(`Forwarded from ${m.forwardedFrom.senderName}`)}\n`
1067
+ : "";
1068
+ return `${indent}${who} ${when}${idPart}\n${forwarded}${bodyIndent}${readableBody}${formatAttachments(m, bodyIndent)}`;
890
1069
  }
891
1070
  /**
892
1071
  * Read the caller's person/agent uid and email from a JWT we already hold.
@@ -1330,16 +1509,29 @@ export function registerDmCommand(program) {
1330
1509
  .option("--channel <name>", "Post the message to one of your DM channels by name (e.g. --channel vyg-dev)")
1331
1510
  .option("--no-mentions", "Do not resolve `@name` in a channel message into structured mentions — post the text literally")
1332
1511
  .option("--thread <eventId>", 'Reply in a channel thread. Pass a root eventId from `hq dm channel`, or `last` for your most recent top-level message in that channel')
1333
- .option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)")
1334
- .option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs only)")
1335
- .option("--details <text>", "Longer detail shown in the recipient's DM detail window (1:1 DMs only)")
1336
- .option("--details-file <path>", "Read the details from a file (1:1 DMs only)")
1512
+ .option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs; channel sends also supported)")
1513
+ .option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs; channel sends also supported)")
1514
+ .option("--details <text>", "Longer detail shown in the recipient's DM detail window (1:1 DMs; channel sends also supported)")
1515
+ .option("--details-file <path>", "Read the details from a file (1:1 DMs; channel sends also supported)")
1337
1516
  .option("--at <iso>", "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time; 1:1 DMs only)")
1338
1517
  .option("--in <duration>", "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)")
1339
1518
  .option("--audience <human|agent|both>", "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both")
1340
1519
  .action(async (recipient, message, opts) => {
1341
1520
  await runDmSend(recipient, message, opts);
1342
1521
  });
1522
+ dm
1523
+ .command("forward <eventId>")
1524
+ .description("Forward a DM or channel message through the existing send routes.")
1525
+ .requiredOption("--from <person|channel>", "Source person email/UID or channel name/ID")
1526
+ .requiredOption("--to <person|agent|channel>", "Destination person email/UID, agent UID, or channel name/ID")
1527
+ .option("--note <text>", "Optional note to add above the forwarded message")
1528
+ .option("--note-file <path>", "Read the optional forward note from a file")
1529
+ .option("--grant-files", "Grant the recipient access to files they cannot currently open")
1530
+ .option("--omit-files", "Omit files the recipient cannot currently open")
1531
+ .option("--cross-company", "Confirm forwarding across company boundaries")
1532
+ .action(async (eventId, opts) => {
1533
+ await runDmForward(eventId, opts);
1534
+ });
1343
1535
  dm
1344
1536
  .command("inbox")
1345
1537
  .description("List your recent incoming direct messages.")
@@ -947,6 +947,7 @@ function laneListQueryFlags(query) {
947
947
  ...(query.laneUlids?.length ? ["--lane-ulid"] : []),
948
948
  ...(query.active ? ["--active"] : []),
949
949
  ...(query.hierarchy?.length ? ["--hierarchy"] : []),
950
+ ...(query.senior !== undefined ? ["--senior"] : []),
950
951
  ...(query.snapshot ? ["--snapshot"] : []),
951
952
  ];
952
953
  }
@@ -1404,6 +1405,7 @@ function normalizedLaneListQuery(query) {
1404
1405
  ...(laneUlids === undefined ? {} : { laneUlids }),
1405
1406
  ...(query.active ? { active: true } : {}),
1406
1407
  ...(hierarchy === undefined ? {} : { hierarchy }),
1408
+ ...(query.senior === undefined ? {} : { senior: query.senior }),
1407
1409
  ...(query.snapshot ? { snapshot: true } : {}),
1408
1410
  ...(query.strictSnapshot ? { strictSnapshot: true } : {}),
1409
1411
  ...(query.includeForeignHostRows ? { includeForeignHostRows: true } : {}),
@@ -1503,6 +1505,48 @@ async function readOutpostLaneRecords(ctx, outpostId, requestedQuery = {}, dispa
1503
1505
  }
1504
1506
  return invocationMemo.getOrCreate(outpostId, query, () => readOutpostLaneRecordsUncached(ctx, outpostId, query, dispatchOptions));
1505
1507
  }
1508
+ /**
1509
+ * Read the owner rows the hire-grant context for `laneId` is built from.
1510
+ *
1511
+ * The owner's `--hierarchy` selector also returns every lane that shares the
1512
+ * target's company and project, so on a store with hundreds of lanes in one
1513
+ * project the response reaches the Outpost exec output limit and the read
1514
+ * fails before a single row is parsed. When that happens, and the target has a
1515
+ * lane senior, assemble the rows the context builder needs from bounded reads
1516
+ * every owner CLI supports: the target and its senior by id, the senior's
1517
+ * direct children (the target's siblings) and the target's own children by
1518
+ * `--senior`. The owner rejects `--senior` together with `--snapshot`, so the
1519
+ * two `--senior` reads use the plain JSON list; the dispatched command sets
1520
+ * HQ_LANES_REMOTE_READ=1, which keeps that list from refreshing remote
1521
+ * mirrors, and the view-only fields it adds are stripped before parsing. A
1522
+ * session-senior target needs same-company profile rows that no bounded
1523
+ * filter selects, so its oversized read stays an error.
1524
+ */
1525
+ async function readOutpostLaneHierarchy(ctx, outpostId, laneId) {
1526
+ let tooLarge;
1527
+ try {
1528
+ return await readOutpostLaneRecords(ctx, outpostId, { hierarchy: [laneId], snapshot: true });
1529
+ }
1530
+ catch (err) {
1531
+ if (!isLanesError(err) || err.error !== "outpost_lane_list_too_large")
1532
+ throw err;
1533
+ tooLarge = err;
1534
+ }
1535
+ const targets = await readOutpostLaneRecords(ctx, outpostId, { laneIds: [laneId], snapshot: true });
1536
+ const target = targets.find((lane) => lane.lane_id === laneId);
1537
+ if (target === undefined)
1538
+ return targets;
1539
+ if (target.senior.kind !== "lane" || !target.senior.id) {
1540
+ throw new LanesError(tooLarge.error, `${tooLarge.message}; lane ${laneId} has a ${target.senior.kind} senior, whose hierarchy cannot be read in bounded parts`, { ...tooLarge.details, lane_id: laneId, senior: target.senior });
1541
+ }
1542
+ const seniorId = target.senior.id;
1543
+ const [seniorRows, siblings, children] = await Promise.all([
1544
+ readOutpostLaneRecords(ctx, outpostId, { laneIds: [seniorId], snapshot: true }),
1545
+ readOutpostLaneRecords(ctx, outpostId, { senior: `lane:${seniorId}` }),
1546
+ readOutpostLaneRecords(ctx, outpostId, { senior: `lane:${laneId}` }),
1547
+ ]);
1548
+ return mergeOwnerLaneRecords([...targets, ...seniorRows, ...siblings], children);
1549
+ }
1506
1550
  /** Refresh this host's selected Outpost mirrors in one bounded owner query per Outpost. */
1507
1551
  async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds, options = {}) {
1508
1552
  const result = {
@@ -1829,9 +1873,12 @@ async function resolveCommandLaneTarget(ctx, laneId, options = {}) {
1829
1873
  for (const outpostId of lookupOutpostIds) {
1830
1874
  let ownerLanes;
1831
1875
  try {
1832
- ownerLanes = await readOutpostLaneRecords(ctx, outpostId, hostlessUlidLookup
1833
- ? { laneUlids: [requestedUlid], snapshot: true }
1834
- : { hierarchy: [laneId], snapshot: true });
1876
+ // The hierarchy read is shared with the grant context built after
1877
+ // resolution, so one owner round trip serves both whenever the Outpost
1878
+ // exec output limit can carry the owner's response.
1879
+ ownerLanes = hostlessUlidLookup
1880
+ ? await readOutpostLaneRecords(ctx, outpostId, { laneUlids: [requestedUlid], snapshot: true })
1881
+ : await readOutpostLaneHierarchy(ctx, outpostId, laneId);
1835
1882
  probedOutpostIds.push(outpostId);
1836
1883
  }
1837
1884
  catch (err) {
@@ -4665,20 +4712,14 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options, behavior =
4665
4712
  let grantContext;
4666
4713
  let grantHierarchy;
4667
4714
  if (action === "grant") {
4668
- const ownerHierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneRecords(ctx, outpostId, {
4669
- hierarchy: [lane.lane_id],
4670
- snapshot: true,
4671
- }));
4715
+ const ownerHierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneHierarchy(ctx, outpostId, lane.lane_id));
4672
4716
  const hierarchy = await hierarchyWithVerifiedRemoteSenior(ctx, ctx.hqRoot(), lane, ownerHierarchy);
4673
4717
  grantHierarchy = hierarchy;
4674
4718
  grantContext = createOutpostHireGrantContext(lane, hierarchy);
4675
4719
  }
4676
4720
  else if ((action === "resume" && lane.senior.kind === "lane") ||
4677
4721
  (action === "message" && (lane.senior.kind === "lane" || lane.senior.kind === "session"))) {
4678
- const ownerHierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneRecords(ctx, outpostId, {
4679
- hierarchy: [lane.lane_id],
4680
- snapshot: true,
4681
- }));
4722
+ const ownerHierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneHierarchy(ctx, outpostId, lane.lane_id));
4682
4723
  const hierarchy = await hierarchyWithVerifiedRemoteSenior(ctx, ctx.hqRoot(), lane, ownerHierarchy);
4683
4724
  grantContext = createOutpostHireGrantContext(lane, hierarchy, action === "message" ? options.supersededMessageCallerId : undefined, action === "message" ? options.supersededMessageSessionId : undefined);
4684
4725
  }
@@ -145,7 +145,7 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
145
145
  import { removeCacheEntry, clearAllCache } from "../utils/secrets-cache.js";
146
146
  import { isHqCliChild, releaseChildWrapperPackageUseLeaseAfterStart } from "../utils/child-wrapper-lease.js";
147
147
  import { resolveDefaultHqRoot } from "../utils/hq-root-resolver.js";
148
- import { fastFlagEnabled } from "../native-fast-route.js";
148
+ import { fastFlagEnabledDefaultOn } from "../native-fast-route.js";
149
149
  import { MULTI_COMPANY_SESSION_LOCK_FLAG } from "../lib/cli-flag-keys.js";
150
150
  import { resolveSessionId } from "../lib/core-utils/session-id.js";
151
151
  import { secretsExecChildExitCode } from "../utils/child-wrapper-exit.js";
@@ -1249,7 +1249,7 @@ export function registerSecretsCommand(program) {
1249
1249
  process.exit(1);
1250
1250
  }
1251
1251
  const keys = parseSecretNameList(_opts.only);
1252
- const multiCompanyLockEnabled = await fastFlagEnabled(MULTI_COMPANY_SESSION_LOCK_FLAG);
1252
+ const multiCompanyLockEnabled = await fastFlagEnabledDefaultOn(MULTI_COMPANY_SESSION_LOCK_FLAG);
1253
1253
  const sessionLock = multiCompanyLockEnabled ? readSessionCompanyLock(process.env) : undefined;
1254
1254
  const parentScope = secrets.opts();
1255
1255
  const inheritedCompany = parentScope.company;
@@ -7,7 +7,7 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
7
7
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
8
8
  import { GROUP_ID_PATTERN, EMAIL_PATTERN, toSharedFolderGlob } from "./_patterns.js";
9
9
  import { findHqRoot } from "../utils/manifest.js";
10
- import { fastFlagEnabled } from "../native-fast-route.js";
10
+ import { fastFlagEnabledDefaultOn } from "../native-fast-route.js";
11
11
  import { MULTI_COMPANY_SESSION_LOCK_FLAG } from "../lib/cli-flag-keys.js";
12
12
  import { resolveSessionId } from "../lib/core-utils/session-id.js";
13
13
  export { readWorkerRegistry } from "../lib/workers-registry/read.js";
@@ -198,7 +198,7 @@ async function runWorkersShare(workerId, opts) {
198
198
  }
199
199
  async function runWorkersList(opts) {
200
200
  const hqRoot = findHqRoot();
201
- const enabled = await fastFlagEnabled(MULTI_COMPANY_SESSION_LOCK_FLAG);
201
+ const enabled = await fastFlagEnabledDefaultOn(MULTI_COMPANY_SESSION_LOCK_FLAG);
202
202
  const lockedCompanies = resolveActiveCompanies(hqRoot, enabled);
203
203
  if (enabled && opts.company && denyWorkerCompanyOutsideLock(opts.company, lockedCompanies))
204
204
  return;
@@ -64,7 +64,7 @@ export interface BotDaemonDeps {
64
64
  platform?: BotDaemonPlatform;
65
65
  launchctl?: LaunchctlExec;
66
66
  uid?: number;
67
- fsImpl?: Pick<typeof fs, "existsSync" | "writeFileSync" | "mkdirSync" | "unlinkSync">;
67
+ fsImpl?: Pick<typeof fs, "existsSync" | "readFileSync" | "writeFileSync" | "mkdirSync" | "unlinkSync"> & Partial<Pick<typeof fs, "chmodSync" | "renameSync">>;
68
68
  /** Blocking sleep, injectable for tests. */
69
69
  sleepMs?: (ms: number) => void;
70
70
  /**
@@ -15,7 +15,7 @@ import * as path from "node:path";
15
15
  import { spawn } from "node:child_process";
16
16
  import { resolveHqBinary, resolveNodeBinary, preferStableHqBinary } from "../mesh/live/daemon/install.js";
17
17
  import { botLogPath, launchdLabel } from "./paths.js";
18
- import { reloadLaunchd } from "../service-manager/launchd.js";
18
+ import { discoverLaunchdIdentity, installLaunchdPlist, launchdProgramArguments, launchdPlistProgramArguments as parsePlistArguments, parseLaunchdCommand, reloadLaunchd } from "../service-manager/launchd.js";
19
19
  import { nodeBinaryProblem, preferStableNodeBinary } from "../node-binary.js";
20
20
  export function detectBotPlatform(platform = process.platform) {
21
21
  if (platform === "darwin")
@@ -66,6 +66,12 @@ function escapeXml(value) {
66
66
  .replace(/"/g, "&quot;");
67
67
  }
68
68
  export function renderBotLaunchdPlist(p) {
69
+ const identity = discoverLaunchdIdentity(p.home);
70
+ const args = launchdProgramArguments({
71
+ nodeBinary: p.nodeBinary,
72
+ hqBinary: p.hqBinary,
73
+ launchdLauncherBinary: identity.launcherBinary,
74
+ }, ["bot", "run", p.name]);
69
75
  const envRows = [
70
76
  ["HOME", p.home],
71
77
  ["PATH", p.pathEntries.join(":")],
@@ -80,13 +86,9 @@ export function renderBotLaunchdPlist(p) {
80
86
  <dict>
81
87
  <key>Label</key>
82
88
  <string>${escapeXml(p.label)}</string>
83
- <key>ProgramArguments</key>
89
+ ${identity.associatedBundleIdentifier ? ` <key>AssociatedBundleIdentifiers</key>\n <array>\n <string>${escapeXml(identity.associatedBundleIdentifier)}</string>\n </array>\n` : ""} <key>ProgramArguments</key>
84
90
  <array>
85
- <string>${escapeXml(p.nodeBinary)}</string>
86
- <string>${escapeXml(p.hqBinary)}</string>
87
- <string>bot</string>
88
- <string>run</string>
89
- <string>${escapeXml(p.name)}</string>
91
+ ${args.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n")}
90
92
  </array>
91
93
  <key>WorkingDirectory</key>
92
94
  <string>${escapeXml(p.hqRoot ?? p.botDir)}</string>
@@ -190,16 +192,13 @@ function launchdLoaded(p, deps) {
190
192
  return deps.launchctl(["print", `${launchdDomain(deps.uid)}/${p.label}`]).ok;
191
193
  }
192
194
  export function launchdPlistProgramArguments(plist) {
193
- const block = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
194
- if (!block)
195
- return [];
196
- return [...block[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
195
+ return parsePlistArguments(plist);
197
196
  }
198
197
  /** The hq script path baked into an installed unit, or null if none. */
199
198
  export function installedDaemonHqBinary(p, io = fs) {
200
199
  if (io.existsSync(p.plistPath)) {
201
200
  const args = launchdPlistProgramArguments(io.readFileSync(p.plistPath, "utf8"));
202
- return args[1] ?? null;
201
+ return parseLaunchdCommand(args)?.hq ?? null;
203
202
  }
204
203
  if (io.existsSync(p.systemdUnitPath)) {
205
204
  const unit = io.readFileSync(p.systemdUnitPath, "utf8");
@@ -212,7 +211,7 @@ export function installedDaemonHqBinary(p, io = fs) {
212
211
  export function installedDaemonNodeBinary(p, io = fs) {
213
212
  if (io.existsSync(p.plistPath)) {
214
213
  const args = launchdPlistProgramArguments(io.readFileSync(p.plistPath, "utf8"));
215
- return args[0] ?? null;
214
+ return parseLaunchdCommand(args)?.node ?? null;
216
215
  }
217
216
  if (io.existsSync(p.systemdUnitPath)) {
218
217
  const unit = io.readFileSync(p.systemdUnitPath, "utf8");
@@ -383,19 +382,27 @@ export function installBotDaemon(p, deps = {}) {
383
382
  io.mkdirSync(path.dirname(p.logPath), { recursive: true, mode: 0o700 });
384
383
  if (platform === "darwin") {
385
384
  io.mkdirSync(path.dirname(p.plistPath), { recursive: true });
386
- // Replace rather than overwrite, so re-adopting a bot over the stale plist
387
- // a wiped ~/.hq left behind lands a fresh file at 0600 (writeFileSync
388
- // keeps the mode an existing file already has).
389
- if (io.existsSync(p.plistPath))
390
- io.unlinkSync(p.plistPath);
391
- io.writeFileSync(p.plistPath, renderBotLaunchdPlist(p), { mode: 0o600 });
392
- let loaded = false;
393
- if (deps.launchctl) {
394
- // bootout first so a re-install picks up a changed plist.
395
- const r = rebootstrapLaunchd(p, deps);
396
- if (!r.ok) {
397
- const detail = `launchctl bootstrap failed after ${r.bootstrapAttempts} attempts: ${(r.error ?? "").slice(0, 200)}`;
398
- if (r.loaded) {
385
+ const reloadState = { result: null };
386
+ const written = installLaunchdPlist(p.plistPath, p.label, renderBotLaunchdPlist(p), {
387
+ files: {
388
+ existsSync: io.existsSync,
389
+ readFileSync: (file) => io.readFileSync(file, "utf8"),
390
+ writeFileSync: io.writeFileSync,
391
+ ...(io.chmodSync ? { chmodSync: io.chmodSync } : {}),
392
+ ...(io.renameSync ? { renameSync: io.renameSync } : {}),
393
+ unlinkSync: io.unlinkSync,
394
+ },
395
+ activate: Boolean(deps.launchctl),
396
+ isLoaded: () => launchdLoaded(p, deps),
397
+ ...(deps.launchctl ? { reload: () => { reloadState.result = rebootstrapLaunchd(p, deps); return reloadState.result; } } : {}),
398
+ });
399
+ let loaded = written.loaded;
400
+ if (deps.launchctl && reloadState.result) {
401
+ const r = reloadState.result;
402
+ loaded = r.loaded;
403
+ if (!r?.ok) {
404
+ const detail = `launchctl bootstrap failed after ${r?.bootstrapAttempts ?? 0} attempts: ${(r?.error ?? "").slice(0, 200)}`;
405
+ if (r?.loaded) {
399
406
  // The previous definition survived the bootout; keep it running.
400
407
  deps.launchctl(["kickstart", "-k", `${launchdDomain(deps.uid)}/${p.label}`]);
401
408
  }
@@ -403,14 +410,13 @@ export function installBotDaemon(p, deps = {}) {
403
410
  platform,
404
411
  action: "install",
405
412
  installed: true,
406
- loaded: r.loaded,
413
+ loaded: r?.loaded ?? false,
407
414
  dest: p.plistPath,
408
- message: r.loaded
415
+ message: r?.loaded
409
416
  ? `Wrote ${p.plistPath} but ${detail}. The previous definition of ${p.label} is still loaded and was restarted; the rewritten plist is not active yet.`
410
417
  : `Wrote ${p.plistPath} but ${detail}. ${p.label} is NOT loaded. Load it with: launchctl bootstrap ${launchdDomain(deps.uid)} ${p.plistPath}`,
411
418
  };
412
419
  }
413
- loaded = true;
414
420
  }
415
421
  return {
416
422
  platform,
@@ -14,8 +14,6 @@ export interface HqCloudCopy {
14
14
  }
15
15
  export interface PickedHqCloud extends HqCloudCopy {
16
16
  source: "bundled" | "managed";
17
- /** Set when a version is pinned but that version is not the installed managed copy. */
18
- pinnedVersionMissing?: string;
19
17
  }
20
18
  export type ManagedHqCloud = PickedHqCloud & {
21
19
  source: "managed";
@@ -85,6 +83,8 @@ export interface UpdateHqCloudDeps {
85
83
  run?: (file: string, args: string[], shell: boolean, env: NodeJS.ProcessEnv) => NpmRunResult;
86
84
  bundled?: HqCloudCopy | null;
87
85
  platform?: NodeJS.Platform;
86
+ /** Test seam for bounded install retry delays. */
87
+ sleep?: (ms: number) => void;
88
88
  }
89
89
  /** Install or update the updater's copy, as this (the login) user. */
90
90
  export declare function updateManagedHqCloud(daemonRoot: string, env: NodeJS.ProcessEnv, deps?: UpdateHqCloudDeps): HqCloudUpdateOutcome;