@indigoai-us/hq-cli 5.274.0 → 5.275.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +74 -18
  3. package/dist/command-catalog.generated.d.ts +34 -1
  4. package/dist/command-catalog.generated.js +43 -1
  5. package/dist/commands/cloud.js +22 -9
  6. package/dist/commands/company.js +66 -3
  7. package/dist/commands/core-checkpoint-gate-setting.d.ts +69 -0
  8. package/dist/commands/core-checkpoint-gate-setting.js +163 -0
  9. package/dist/commands/core.js +2 -0
  10. package/dist/commands/daemon.js +7 -2
  11. package/dist/commands/lanes-message.js +40 -7
  12. package/dist/commands/lanes-shared.d.ts +4 -0
  13. package/dist/commands/lanes-shared.js +5 -0
  14. package/dist/commands/lanes.js +41 -17
  15. package/dist/lib/daemon/catalog.d.ts +7 -0
  16. package/dist/lib/daemon/catalog.js +6 -0
  17. package/dist/lib/daemon/flag.d.ts +23 -0
  18. package/dist/lib/daemon/flag.js +57 -0
  19. package/dist/lib/daemon/install.js +6 -1
  20. package/dist/lib/daemon/remote-control.d.ts +6 -0
  21. package/dist/lib/daemon/remote-control.js +12 -0
  22. package/dist/lib/daemon/run.js +115 -2
  23. package/dist/lib/daemon/succession.d.ts +116 -0
  24. package/dist/lib/daemon/succession.js +262 -0
  25. package/dist/lib/daemon/supervisor.d.ts +34 -1
  26. package/dist/lib/daemon/supervisor.js +190 -23
  27. package/dist/lib/lanes/hire-grant-dispatch.d.ts +2 -1
  28. package/dist/lib/lanes/hire-grant-dispatch.js +37 -2
  29. package/dist/lib/lanes/host-capacity.js +7 -1
  30. package/dist/lib/lanes/keep-alive.js +2 -2
  31. package/dist/lib/lanes/models.d.ts +2 -0
  32. package/dist/lib/lanes/models.js +6 -1
  33. package/package.json +1 -1
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `hq core checkpoint-gate-setting` — keeps the local cache that lets a
3
+ * company owner turn the checkpoint Stop gate off for their company (CO-58).
4
+ *
5
+ * The owner sets `companySettings.checkpointGateEnabled` on hq-pro
6
+ * (`hq company settings set --checkpoint-gate-enabled false`, or the console
7
+ * Settings page). The Stop gate script never calls the network: it reads
8
+ * `~/.hq/cache/checkpoint-gate/<slug>.json` and, when that is missing or older
9
+ * than ten minutes, starts this command detached with `--refresh`.
10
+ *
11
+ * `--refresh` reads the setting through the member-readable
12
+ * `GET /company-settings/checkpoint-gate` route and writes the cache only while
13
+ * the hq-flag `checkpoint.gate.company-setting` is on for the company. With the
14
+ * flag off it removes the cache, so the gate keeps today's behaviour. Any
15
+ * failure (no login, API unreachable, timeout) leaves the cache as it was: the
16
+ * gate then uses the last known value, or today's behaviour when there is none.
17
+ * Only the named company's setting is ever read.
18
+ */
19
+ import * as fs from "node:fs";
20
+ import * as os from "node:os";
21
+ import * as path from "node:path";
22
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
23
+ import { getEntityUid, vaultApiFetch } from "../utils/vault-api.js";
24
+ import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
25
+ import { describeCoreCommand } from "./core-command-catalog.js";
26
+ export const CHECKPOINT_GATE_SETTING_FLAG = "checkpoint.gate.company-setting";
27
+ /** The refresh gives up after this long; the gate keeps the cached value. */
28
+ export const CHECKPOINT_GATE_REFRESH_TIMEOUT_MS = 3_000;
29
+ /** Same character set the gate script accepts; anything else is refused. */
30
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
31
+ export function assertCompanySlug(slug) {
32
+ if (!SLUG_RE.test(slug) || slug === "personal") {
33
+ throw new Error(`checkpoint-gate-setting: '${slug}' is not a company slug`);
34
+ }
35
+ }
36
+ export function checkpointGateCacheDir(home = os.homedir()) {
37
+ return path.join(home, ".hq", "cache", "checkpoint-gate");
38
+ }
39
+ export function checkpointGateCachePath(slug, home = os.homedir()) {
40
+ return path.join(checkpointGateCacheDir(home), `${slug}.json`);
41
+ }
42
+ /**
43
+ * `readLanesRegistryFlag` resolves a failed registry read to `false`. The
44
+ * refresh must not treat that as "flag off" and delete a valid cache, so a
45
+ * reported failure becomes null (unknown).
46
+ */
47
+ export async function readCheckpointGateSettingFlag(slug, companyUid) {
48
+ let failed = false;
49
+ const enabled = await readLanesRegistryFlag(CHECKPOINT_GATE_SETTING_FLAG, {
50
+ company: { slug, uid: companyUid },
51
+ reportFailure: () => {
52
+ failed = true;
53
+ },
54
+ });
55
+ return failed ? null : enabled;
56
+ }
57
+ async function defaultFetchSetting(token, companyUid, signal) {
58
+ const res = await vaultApiFetch({
59
+ token,
60
+ path: "/company-settings/checkpoint-gate",
61
+ query: { companyUid },
62
+ signal,
63
+ });
64
+ if (!res.ok)
65
+ throw new Error(`GET /company-settings/checkpoint-gate returned ${res.status}`);
66
+ const body = (await res.json());
67
+ if (typeof body.checkpointGateEnabled !== "boolean") {
68
+ throw new Error("GET /company-settings/checkpoint-gate returned no boolean checkpointGateEnabled");
69
+ }
70
+ return body.checkpointGateEnabled;
71
+ }
72
+ function writeCacheAtomic(file, entry) {
73
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
74
+ const tmp = `${file}.${process.pid}.tmp`;
75
+ fs.writeFileSync(tmp, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
76
+ fs.renameSync(tmp, file);
77
+ }
78
+ function withTimeout(work, ms) {
79
+ const controller = new AbortController();
80
+ let timer;
81
+ const timeout = new Promise((_, reject) => {
82
+ timer = setTimeout(() => {
83
+ controller.abort();
84
+ reject(new Error(`timed out after ${ms} ms`));
85
+ }, ms);
86
+ });
87
+ return Promise.race([work(controller.signal), timeout]).finally(() => clearTimeout(timer));
88
+ }
89
+ /**
90
+ * Refresh one company's cache entry. Never throws for a network or auth
91
+ * failure: those return `kept` and leave the cache untouched.
92
+ */
93
+ export async function refreshCheckpointGateSetting(slug, deps = {}) {
94
+ assertCompanySlug(slug);
95
+ const file = checkpointGateCachePath(slug, deps.home);
96
+ // Never interactive: a detached refresh must not open a browser login.
97
+ const getToken = deps.getToken ?? (() => ensureCognitoToken({ interactive: false }));
98
+ const resolveCompanyUid = deps.resolveCompanyUid ?? ((token, companySlug) => getEntityUid(token, { companySlug }));
99
+ const fetchSetting = deps.fetchSetting ?? defaultFetchSetting;
100
+ const readFlag = deps.readFlag ?? readCheckpointGateSettingFlag;
101
+ // One timeout covers every network step, the flag read included.
102
+ let fetched;
103
+ try {
104
+ fetched = await withTimeout(async (signal) => {
105
+ const token = await getToken();
106
+ const uid = await resolveCompanyUid(token, slug);
107
+ const enabled = await fetchSetting(token, uid, signal);
108
+ return { enabled, flag: await readFlag(slug, uid) };
109
+ }, deps.timeoutMs ?? CHECKPOINT_GATE_REFRESH_TIMEOUT_MS);
110
+ }
111
+ catch (error) {
112
+ return { status: "kept", company: slug, reason: error instanceof Error ? error.message : String(error) };
113
+ }
114
+ if (fetched.flag === null) {
115
+ return { status: "kept", company: slug, reason: `could not read ${CHECKPOINT_GATE_SETTING_FLAG}` };
116
+ }
117
+ if (!fetched.flag) {
118
+ fs.rmSync(file, { force: true });
119
+ return { status: "removed", company: slug, reason: "flag_off" };
120
+ }
121
+ const now = (deps.now?.() ?? new Date()).getTime();
122
+ writeCacheAtomic(file, { company: slug, checkpointGateEnabled: fetched.enabled, fetched_at: Math.floor(now / 1000) });
123
+ return { status: "written", company: slug, checkpointGateEnabled: fetched.enabled };
124
+ }
125
+ export function describeRefreshOutcome(outcome) {
126
+ switch (outcome.status) {
127
+ case "written":
128
+ return `checkpoint gate for ${outcome.company}: ${outcome.checkpointGateEnabled ? "on" : "off"} (cached)`;
129
+ case "removed":
130
+ return `checkpoint gate for ${outcome.company}: company setting not in use (${CHECKPOINT_GATE_SETTING_FLAG} is off); cache cleared`;
131
+ case "kept":
132
+ return `checkpoint gate for ${outcome.company}: could not refresh (${outcome.reason}); cache unchanged`;
133
+ }
134
+ }
135
+ export function registerCoreCheckpointGateSettingCommand(core) {
136
+ const command = core
137
+ .command("checkpoint-gate-setting")
138
+ .description("Refresh the local cache of a company's checkpoint gate setting (used by the Stop gate)")
139
+ .requiredOption("--company <slug>", "company whose setting to read")
140
+ .option("--refresh", "read the setting from HQ and update the local cache")
141
+ .option("--json", "print the result as JSON")
142
+ .action(async (options) => {
143
+ try {
144
+ assertCompanySlug(options.company);
145
+ if (!options.refresh) {
146
+ const file = checkpointGateCachePath(options.company);
147
+ const cached = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null;
148
+ process.stdout.write(`${options.json ? JSON.stringify({ company: options.company, cached }) : cached ? `checkpoint gate for ${options.company}: ${cached.checkpointGateEnabled ? "on" : "off"} (cached at ${new Date(cached.fetched_at * 1000).toISOString()})` : `checkpoint gate for ${options.company}: no cached setting`}\n`);
149
+ return;
150
+ }
151
+ const outcome = await refreshCheckpointGateSetting(options.company);
152
+ process.stdout.write(`${options.json ? JSON.stringify(outcome) : describeRefreshOutcome(outcome)}\n`);
153
+ // A timed-out request can keep sockets open; the answer is already out.
154
+ process.exit(0);
155
+ }
156
+ catch (error) {
157
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
158
+ process.exit(1);
159
+ }
160
+ });
161
+ describeCoreCommand(command, { execution: "native", root: "cwd" });
162
+ }
163
+ //# sourceMappingURL=core-checkpoint-gate-setting.js.map
@@ -31,6 +31,7 @@
31
31
  import { Option } from "commander";
32
32
  import { registerCoreCheckpointCommand } from "./core-checkpoint.js";
33
33
  import { registerCoreOntologyCommand } from "./core-ontology.js";
34
+ import { registerCoreCheckpointGateSettingCommand } from "./core-checkpoint-gate-setting.js";
34
35
  import { resolveLiveRoot } from "../utils/hq-roots.js";
35
36
  import { interpreterFor, runBundledScript } from "../utils/run-bundled-script.js";
36
37
  import { renderIndexTarget } from "../lib/index-render/index.js";
@@ -286,6 +287,7 @@ export function registerCoreCommands(program) {
286
287
  // hosts native TypeScript plumbing when a scaffold contract needs it.
287
288
  registerCoreCheckpointCommand(core);
288
289
  registerCoreOntologyCommand(core);
290
+ registerCoreCheckpointGateSettingCommand(core);
289
291
  // These command names intentionally own native implementations. Their
290
292
  // similarly named scaffold assets are forwarders, so sending them through
291
293
  // runEntry would recurse back into this command forever.
@@ -489,8 +489,13 @@ function binaryFingerprint(bin) {
489
489
  }
490
490
  }
491
491
  async function runTmuxService(d, opts) {
492
- const { runRemoteControlHost } = await import("../lib/daemon/remote-control.js");
492
+ const { installFingerprint, runRemoteControlHost } = await import("../lib/daemon/remote-control.js");
493
493
  const { findOnPath } = await import("../lib/daemon/run.js");
494
+ const { resolveRunningInstall } = await import("../utils/version-gate.js");
495
+ // The daemon keeps this host running across a CLI update, so a new hq-cli
496
+ // install also counts as an update and restarts the session once it is quiet.
497
+ const cliRoot = resolveRunningInstall().packageRoot;
498
+ const watched = cliRoot ? [opts.watch, path.join(cliRoot, "package.json")] : [opts.watch];
494
499
  const hqRoot = d.env.HQ_ROOT?.trim();
495
500
  if (!hqRoot)
496
501
  throw new DaemonCommandError(`${opts.label} needs HQ_ROOT (the daemon sets it from the saved HQ folder)`);
@@ -508,7 +513,7 @@ async function runTmuxService(d, opts) {
508
513
  cwd: hqRoot,
509
514
  argv: opts.argv,
510
515
  label: opts.label,
511
- binaryFingerprint: () => binaryFingerprint(opts.watch),
516
+ binaryFingerprint: () => installFingerprint(watched, binaryFingerprint),
512
517
  now: () => Date.now(),
513
518
  run: d.run,
514
519
  pollMs: 5_000,
@@ -13,7 +13,7 @@ import { keepAliveExclusion, keepAliveSupersededBy, LANE_SUPERSESSION_LINEAGE_AW
13
13
  import { managerStatusGateEnabledForLane } from "../lib/lanes/manager-status-gate.js";
14
14
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
15
15
  import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
16
- import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
16
+ import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, callerMatchesSessionSenior, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
17
17
  export function createLaneMessageHandlers(deps) {
18
18
  const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, casUpdateLane, dispatchOutpostLaneCommand, drainHookStatusFor, laneIsHostedHere, laneWithRecoveredProviderSession, loadAllLanes, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveActiveQuestionCompany, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
19
19
  function currentSuccessorOf(lane, lanes, lineageAware, companyForLane) {
@@ -30,7 +30,19 @@ export function createLaneMessageHandlers(deps) {
30
30
  }
31
31
  return current.lane_id === lane.lane_id ? undefined : current;
32
32
  }
33
- function callerMayMessageSupersededLane(ctx, lane, lanes, lineageAware, companyForLane, callerLaneIdOverride) {
33
+ function callerMayMessageSupersededLane(ctx, lane, lanes, lineageAware, companyForLane, callerLaneIdOverride, callerSessionIdOverride) {
34
+ if (lane.senior.kind === "session") {
35
+ const sessionSeniorId = lane.senior.id;
36
+ if (!sessionSeniorId)
37
+ return false;
38
+ if (callerLaneIdOverride !== undefined || ctx.env.HQ_LANE_ID?.trim())
39
+ return false;
40
+ if (callerSessionIdOverride === null)
41
+ return false;
42
+ return callerSessionIdOverride === undefined
43
+ ? callerMatchesSessionSenior(ctx, sessionSeniorId)
44
+ : callerSessionIdOverride === sessionSeniorId;
45
+ }
34
46
  if (lane.senior.kind !== "lane")
35
47
  return false;
36
48
  const callerLaneId = callerLaneIdOverride ?? ctx.env.HQ_LANE_ID?.trim();
@@ -86,11 +98,15 @@ export function createLaneMessageHandlers(deps) {
86
98
  let lanes = localLanes;
87
99
  const retiredLaneIds = loadRetiredLaneIds(ctx.hqRoot());
88
100
  const dispatchedCallerId = grantContext?.superseded_message_caller_id;
89
- const dispatchedContextAuthorized = grantContext !== undefined &&
90
- dispatchedCallerId !== undefined &&
101
+ const dispatchedSessionId = grantContext?.superseded_message_session_id;
102
+ const dispatchedContextBound = grantContext !== undefined &&
91
103
  ctx.isSsmOwnerDispatch() &&
92
104
  sameHireGrantSenior(grantContext.expected_senior, currentLane.senior);
93
- if (dispatchedContextAuthorized && grantContext) {
105
+ const dispatchedSessionContext = dispatchedContextBound && currentLane.senior.kind === "session";
106
+ const dispatchedContextAuthorized = dispatchedContextBound && (dispatchedCallerId !== undefined ||
107
+ (dispatchedSessionId !== undefined && dispatchedSessionId === currentLane.senior.id));
108
+ if (dispatchedContextBound && grantContext &&
109
+ (dispatchedCallerId !== undefined || dispatchedSessionContext)) {
94
110
  const merged = new Map(localLanes.map((record) => [record.lane_id, record]));
95
111
  for (const snapshot of grantContext.hierarchy) {
96
112
  const contextLane = keepAliveLaneFromSnapshot(snapshot);
@@ -101,14 +117,23 @@ export function createLaneMessageHandlers(deps) {
101
117
  lanes = [...merged.values()];
102
118
  }
103
119
  const exclusion = keepAliveExclusion(currentLane, lanes, retiredLaneIds, lineageAware, companyForLane);
104
- const callerLaneId = dispatchedContextAuthorized ? dispatchedCallerId : undefined;
105
- if (exclusion === "superseded" && callerMayMessageSupersededLane(ctx, currentLane, lanes, lineageAware, companyForLane, callerLaneId)) {
120
+ const callerLaneId = dispatchedContextBound ? dispatchedCallerId : undefined;
121
+ const callerSessionId = dispatchedContextBound
122
+ ? (dispatchedSessionId === undefined ? null : dispatchedSessionId)
123
+ : undefined;
124
+ if (exclusion === "superseded" && callerMayMessageSupersededLane(ctx, currentLane, lanes, lineageAware, companyForLane, callerLaneId, callerSessionId)) {
106
125
  return {
107
126
  lane: currentLane,
108
127
  exclusion: undefined,
109
128
  ...(!dispatchedContextAuthorized && ctx.env.HQ_LANE_ID?.trim()
110
129
  ? { supersededMessageCallerId: ctx.env.HQ_LANE_ID.trim() }
111
130
  : {}),
131
+ ...(!dispatchedContextBound && currentLane.senior.kind === "session" &&
132
+ typeof currentLane.senior.id === "string" &&
133
+ currentLane.senior.id.length > 0 &&
134
+ callerMatchesSessionSenior(ctx, currentLane.senior.id)
135
+ ? { supersededMessageSessionId: currentLane.senior.id }
136
+ : {}),
112
137
  };
113
138
  }
114
139
  return { lane: currentLane, exclusion };
@@ -149,9 +174,17 @@ export function createLaneMessageHandlers(deps) {
149
174
  const supersededMessageCallerId = callerLaneId === undefined || resolution.remoteOnly
150
175
  ? undefined
151
176
  : (await messageLaneExclusion(ctx, lane)).supersededMessageCallerId;
177
+ const sessionSeniorId = lane.senior.kind === "session" ? lane.senior.id : undefined;
178
+ const supersededMessageSessionId = !resolution.remoteOnly &&
179
+ typeof sessionSeniorId === "string" &&
180
+ sessionSeniorId.length > 0 &&
181
+ callerMatchesSessionSenior(ctx, sessionSeniorId)
182
+ ? sessionSeniorId
183
+ : undefined;
152
184
  await dispatchOutpostLaneCommand(ctx, lane, "message", {
153
185
  ...opts,
154
186
  ...(supersededMessageCallerId === undefined ? {} : { supersededMessageCallerId }),
187
+ ...(supersededMessageSessionId === undefined ? {} : { supersededMessageSessionId }),
155
188
  }, resolution.remoteOnly
156
189
  ? { syncPrimary: false, expectedOwnerHost: resolution.ownerHost }
157
190
  : undefined);
@@ -43,6 +43,8 @@ export interface LaneMessageOptions {
43
43
  grantContext?: string;
44
44
  /** Primary-verified caller identity for a superseded Outpost message. */
45
45
  supersededMessageCallerId?: string;
46
+ /** Primary-verified session senior for a superseded Outpost message. */
47
+ supersededMessageSessionId?: string;
46
48
  monitorEvent?: {
47
49
  monitor_id: string;
48
50
  description: string;
@@ -248,6 +250,8 @@ export declare function readLanePlacementEnabled(ctx: Ctx, company: LaneCompanyR
248
250
  export declare function processLanePlacementCompany(ctx: Ctx): LaneCompanyRef | undefined;
249
251
  export declare function parsePlacementPolicy(raw: string): LanePlacementPolicy;
250
252
  export declare function assertPlacementEnabled(ctx: Ctx, company: LaneCompanyRef | undefined, readPlacementEnabled?: () => Promise<boolean>): Promise<void>;
253
+ /** Match a direct session-senior caller while refusing lane-worker identities. */
254
+ export declare function callerMatchesSessionSenior(ctx: Pick<Ctx, "env">, seniorSessionId: string): boolean;
251
255
  export interface LaneWaitCommandOptions {
252
256
  any?: string[];
253
257
  for?: string[];
@@ -4,6 +4,7 @@ import { resolveLaneEffort } from "../lib/lanes/effort.js";
4
4
  import { LanesError } from "../lib/lanes/errors.js";
5
5
  import { AccountResolutionError, accountStatus, loadLanesConfig } from "../lib/lanes/index.js";
6
6
  import { resolveLaneModel } from "../lib/lanes/spawn.js";
7
+ import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
7
8
  export function accountRequestKeepsCurrentLane(lane, rawAccount) {
8
9
  const raw = rawAccount.trim();
9
10
  const colon = raw.indexOf(":");
@@ -151,4 +152,8 @@ export async function assertPlacementEnabled(ctx, company, readPlacementEnabled
151
152
  return;
152
153
  throw new LanesError("lane_placement_disabled", `Lane placement options require hq-flags gate ${LANE_OUTPOST_LOAD_BALANCING_FLAG}`, { flag: LANE_OUTPOST_LOAD_BALANCING_FLAG });
153
154
  }
155
+ /** Match a direct session-senior caller while refusing lane-worker identities. */
156
+ export function callerMatchesSessionSenior(ctx, seniorSessionId) {
157
+ return !ctx.env.HQ_LANE_ID?.trim() && sessionIdFromEnv(ctx.env) === seniorSessionId;
158
+ }
154
159
  //# sourceMappingURL=lanes-shared.js.map
@@ -17,7 +17,7 @@ import { createLaneMoveHandlers } from "./lanes-move.js";
17
17
  import { createLaneQuestionsHandlers } from "./lanes-questions.js";
18
18
  import { createLaneResumeHandlers } from "./lanes-resume.js";
19
19
  import { createLaneWatchHandlers } from "./lanes-watch.js";
20
- import { assertPlacementEnabled, defaultLanePlacementEnabled, parsePlacementPolicy, processLanePlacementCompany, readLanePlacementEnabled, } from "./lanes-shared.js";
20
+ import { callerMatchesSessionSenior, assertPlacementEnabled, defaultLanePlacementEnabled, parsePlacementPolicy, processLanePlacementCompany, readLanePlacementEnabled, } from "./lanes-shared.js";
21
21
  export { LANE_OUTPOST_LOAD_BALANCING_FLAG } from "./lanes-shared.js";
22
22
  export { resolveAnswerChoice } from "./lanes-questions.js";
23
23
  import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
@@ -826,9 +826,9 @@ async function withResumeHireAdmission(ctx, lane, fn, grantContext) {
826
826
  const hqRoot = ctx.hqRoot();
827
827
  if (grantContext !== undefined && (!ctx.isSsmOwnerDispatch() ||
828
828
  !laneGrantTargetHostedHere(ctx, lane) ||
829
- lane.senior.kind !== "lane" ||
829
+ (lane.senior.kind !== "lane" && lane.senior.kind !== "session") ||
830
830
  !sameHireGrantSenior(lane.senior, grantContext.expected_senior))) {
831
- throw new LanesError("hire_grant_senior_changed", `Lane ${lane.lane_id}'s senior changed after the primary authorized its resume`, { lane_id: lane.lane_id, expected_senior: grantContext.expected_senior, current_senior: lane.senior });
831
+ throw new LanesError("hire_grant_senior_changed", `Lane ${lane.lane_id}'s senior changed after the primary authorized its Outpost dispatch`, { lane_id: lane.lane_id, expected_senior: grantContext.expected_senior, current_senior: lane.senior });
832
832
  }
833
833
  if (!isTerminalLaneState(lane.state) ||
834
834
  lane.senior.kind !== "lane" ||
@@ -1158,7 +1158,7 @@ async function loadClaudeBalanceHostSnapshots(ctx, hqRoot, config) {
1158
1158
  const actualLocalLanes = hostname
1159
1159
  ? localLanes.filter((lane) => lane.host.kind === "local" && lane.host.id === hostname)
1160
1160
  : [];
1161
- if (actualLocalLanes.length > 0) {
1161
+ if (hostname) {
1162
1162
  snapshots.push({
1163
1163
  capacity: buildHostCapacitySnapshot({
1164
1164
  host: actualLocalHost,
@@ -2402,6 +2402,11 @@ async function runCreate(ctx, opts) {
2402
2402
  let placementHostOverride;
2403
2403
  let autoPlacementChoice;
2404
2404
  let autoPlacementSkippedHosts = [];
2405
+ let placementMachineIdentity;
2406
+ const resolvePlacementMachineIdentity = () => {
2407
+ placementMachineIdentity ??= ctx.resolveMachineIdentity();
2408
+ return placementMachineIdentity;
2409
+ };
2405
2410
  let balanceHostLoad;
2406
2411
  let balanceTargetHost;
2407
2412
  let inheritedPlacement;
@@ -2485,23 +2490,39 @@ async function runCreate(ctx, opts) {
2485
2490
  }
2486
2491
  throw err;
2487
2492
  }
2488
- placementHostOverride = autoPlacementChoice.host;
2489
- if (autoPlacementChoice.host.kind === currentHost.kind &&
2490
- autoPlacementChoice.host.id === currentHost.id) {
2491
- outpostId = undefined;
2493
+ const chosenHost = autoPlacementChoice.host;
2494
+ const localHostname = ctx.hostname().trim();
2495
+ if (!localHostname) {
2496
+ throw new LanesError("host_identity_unknown", "Cannot resolve this host for automatic lane placement", {});
2492
2497
  }
2493
- else if (autoPlacementChoice.host.kind === "outpost") {
2494
- outpostId = autoPlacementChoice.host.id;
2498
+ const localHost = { kind: "local", id: localHostname };
2499
+ let chosenHostIsCaller = chosenHost.id === currentHost.id ||
2500
+ sanitizeHostComponent(chosenHost.id) === sanitizeHostComponent(localHostname);
2501
+ if (!chosenHostIsCaller && chosenHost.kind === "outpost") {
2502
+ const identity = resolvePlacementMachineIdentity();
2503
+ if (identity.status === "ok") {
2504
+ const machineIds = [identity.id, identity.imds, identity.hostname]
2505
+ .filter((id) => typeof id === "string" && id.trim() !== "");
2506
+ chosenHostIsCaller = machineIds.some((id) => sanitizeHostComponent(chosenHost.id) === sanitizeHostComponent(id.trim()));
2507
+ }
2495
2508
  }
2496
- else {
2509
+ if (chosenHostIsCaller) {
2510
+ // A capacity snapshot can identify this process as an Outpost (or an
2511
+ // alias of its machine). Auto placement must spawn locally so the
2512
+ // Outpost self-target guard is never reached.
2513
+ placementHostOverride = localHost;
2497
2514
  outpostId = undefined;
2498
2515
  }
2516
+ else {
2517
+ placementHostOverride = chosenHost;
2518
+ outpostId = chosenHost.kind === "outpost" ? chosenHost.id : undefined;
2519
+ }
2499
2520
  }
2500
2521
  }
2501
2522
  let originHost;
2502
2523
  let originHostSource;
2503
2524
  if (outpostId) {
2504
- const identity = ctx.resolveMachineIdentity();
2525
+ const identity = resolvePlacementMachineIdentity();
2505
2526
  if (identity.status !== "ok") {
2506
2527
  throw new LanesError("origin_host_unresolvable", "Cannot record origin_host; this machine's identity is unresolvable", { identity });
2507
2528
  }
@@ -3486,10 +3507,10 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options, behavior =
3486
3507
  grantHierarchy = hierarchy;
3487
3508
  grantContext = createOutpostHireGrantContext(lane, hierarchy);
3488
3509
  }
3489
- else if ((action === "resume" || action === "message") &&
3490
- lane.senior.kind === "lane") {
3510
+ else if ((action === "resume" && lane.senior.kind === "lane") ||
3511
+ (action === "message" && (lane.senior.kind === "lane" || lane.senior.kind === "session"))) {
3491
3512
  const hierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneRecords(ctx, outpostId));
3492
- grantContext = createOutpostHireGrantContext(lane, hierarchy, action === "message" ? options.supersededMessageCallerId : undefined);
3513
+ grantContext = createOutpostHireGrantContext(lane, hierarchy, action === "message" ? options.supersededMessageCallerId : undefined, action === "message" ? options.supersededMessageSessionId : undefined);
3493
3514
  }
3494
3515
  let dispatched;
3495
3516
  let grantContextRejected = false;
@@ -4468,9 +4489,12 @@ function callerSeniorAuthorization(ctx, seniorRef) {
4468
4489
  if (!sessionId)
4469
4490
  return { authorized: false, reason: "no session id" };
4470
4491
  if (seniorRef.kind === "session") {
4471
- return sessionId === seniorRef.id
4492
+ const seniorSessionId = seniorRef.id;
4493
+ if (!seniorSessionId)
4494
+ return { authorized: false, reason: "session senior has no id" };
4495
+ return callerMatchesSessionSenior(ctx, seniorSessionId)
4472
4496
  ? { authorized: true }
4473
- : { authorized: false, reason: `session id ${sessionId} is not senior session ${seniorRef.id}` };
4497
+ : { authorized: false, reason: `session id ${sessionId} is not senior session ${seniorSessionId}` };
4474
4498
  }
4475
4499
  if (seniorRef.kind !== "user")
4476
4500
  return { authorized: false, reason: "senior is not a user" };
@@ -54,6 +54,13 @@ export interface UnitPlan {
54
54
  command: UnitCommand | null;
55
55
  env: Record<string, string>;
56
56
  blocked?: UnitBlock;
57
+ /**
58
+ * Signal that asks the unit to finish its current work and exit, used when a
59
+ * new daemon replaces it after a CLI update. Without one it gets SIGTERM.
60
+ */
61
+ drainSignal?: "SIGUSR2";
62
+ /** A new CLI version alone does not restart this unit; it restarts itself when idle. */
63
+ keepAcrossCliUpdates?: true;
57
64
  }
58
65
  export interface PlanContext {
59
66
  machineType: MachineType;
@@ -183,6 +183,8 @@ function planService(service, ctx) {
183
183
  description: `runs the local bot ${name}`,
184
184
  restart: "on-failure",
185
185
  command: { type: "hq", args: ["bot", "run", name] },
186
+ // A bot drains on SIGUSR2: running turns finish, then it exits.
187
+ drainSignal: "SIGUSR2",
186
188
  }));
187
189
  }
188
190
  case "session-host":
@@ -267,6 +269,8 @@ function planService(service, ctx) {
267
269
  type: "hq",
268
270
  args: ["daemon", "service", "codex-remote-control", "--script", script ?? "", "--codex", ctx.codexPath ?? ""],
269
271
  },
272
+ // Restarting the host ends the tmux session people are using.
273
+ keepAcrossCliUpdates: true,
270
274
  env: ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {},
271
275
  blocked: firstBlock([
272
276
  [!script, `this machine has no Codex remote-control runner (${CODEX_RUNNER_SCRIPT})`],
@@ -290,6 +294,8 @@ function planService(service, ctx) {
290
294
  base(service, {
291
295
  command: { type: "hq", args },
292
296
  env: ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {},
297
+ // Restarting the host ends the tmux session people are using.
298
+ keepAcrossCliUpdates: true,
293
299
  blocked: firstBlock([
294
300
  [!ctx.claudePath, "the claude CLI is not installed"],
295
301
  [!ctx.tmuxPath, "tmux is not installed (remote control runs Claude inside tmux)"],
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Feature flags read by the daemon. The daemon runs for weeks, so each read
3
+ * asks the registry again instead of holding a per-process answer, and it
4
+ * reads as off unless the registry itself says on (or a local override does).
5
+ */
6
+ import { type FlagClientOptions, type FlagResolution } from "@indigoai-us/hq-flags-client";
7
+ /** Outposts hand running services to the next daemon on a CLI update instead of restarting them all. */
8
+ export declare const SUPERVISOR_HANDOFF_FLAG = "daemon.supervisor-handoff";
9
+ export interface DaemonFlagClient {
10
+ ready(): Promise<void>;
11
+ explain(flagKey: string, options?: {
12
+ fallback?: boolean;
13
+ }): FlagResolution;
14
+ close(): void;
15
+ }
16
+ export interface ReadDaemonFlagOptions {
17
+ env?: NodeJS.ProcessEnv;
18
+ createClient?: (options: FlagClientOptions) => DaemonFlagClient;
19
+ getToken?: () => Promise<string>;
20
+ onError?: (error: unknown) => void;
21
+ }
22
+ export declare function readDaemonFlag(flagKey: string, options?: ReadDaemonFlagOptions): Promise<boolean>;
23
+ //# sourceMappingURL=flag.d.ts.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Feature flags read by the daemon. The daemon runs for weeks, so each read
3
+ * asks the registry again instead of holding a per-process answer, and it
4
+ * reads as off unless the registry itself says on (or a local override does).
5
+ */
6
+ import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
7
+ import { ensureCognitoIdToken } from "../../utils/cognito-session.js";
8
+ import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
9
+ import { resolveProcessFlagCompanyContext } from "../flag-registry.js";
10
+ /** Outposts hand running services to the next daemon on a CLI update instead of restarting them all. */
11
+ export const SUPERVISOR_HANDOFF_FLAG = "daemon.supervisor-handoff";
12
+ const READ_TIMEOUT_MS = 5_000;
13
+ export async function readDaemonFlag(flagKey, options = {}) {
14
+ const env = options.env ?? process.env;
15
+ const override = readLocalOverride(flagKey, env);
16
+ if (override !== null)
17
+ return override;
18
+ let failed = false;
19
+ const onError = (error) => {
20
+ failed = true;
21
+ options.onError?.(error);
22
+ };
23
+ let client;
24
+ try {
25
+ const company = resolveProcessFlagCompanyContext(env);
26
+ // Signed out reads as off without reaching the registry.
27
+ const token = await (options.getToken ?? (() => ensureCognitoIdToken({ interactive: false })))();
28
+ if (typeof token !== "string" || token.trim() === "")
29
+ return false;
30
+ client = (options.createClient ?? createFlagClient)({
31
+ endpoint: resolveFlagRegistryEndpoint(undefined, env),
32
+ getToken: () => token,
33
+ env: {},
34
+ refreshIntervalMs: 0,
35
+ requestTimeoutMs: READ_TIMEOUT_MS,
36
+ ...(company.companyUid ? { companyUid: company.companyUid } : {}),
37
+ ...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
38
+ onError,
39
+ });
40
+ await client.ready();
41
+ const resolution = client.explain(flagKey, { fallback: false });
42
+ return !failed && resolution.source === "snapshot" && resolution.value === true;
43
+ }
44
+ catch (error) {
45
+ onError(error);
46
+ return false;
47
+ }
48
+ finally {
49
+ try {
50
+ client?.close();
51
+ }
52
+ catch {
53
+ // closing a one-shot client cannot change the answer
54
+ }
55
+ }
56
+ }
57
+ //# sourceMappingURL=flag.js.map
@@ -61,6 +61,11 @@ function serviceHost(host) {
61
61
  * Output goes to the journal: an `append:` log file would be created by
62
62
  * systemd as root inside the user's home, and the daemon (running as the user)
63
63
  * writes its own logs under ~/.hq/daemon/logs.
64
+ *
65
+ * `KillMode=process`: systemd signals only the daemon. The daemon stops its
66
+ * services itself on SIGTERM, and after a CLI update it can leave them running
67
+ * for the next daemon to adopt (see succession.ts). A daemon that starts and
68
+ * finds services it did not adopt stops them first.
64
69
  */
65
70
  function renderSystemdSystemUnit(host, user, userHome) {
66
71
  const exec = [host.nodeBinary, host.hqBinary, "daemon", "run", "--managed"].map(shellQuote).join(" ");
@@ -87,7 +92,7 @@ ExecStart=${exec}
87
92
  WorkingDirectory=${userHome}
88
93
  Restart=always
89
94
  RestartSec=10
90
- KillMode=control-group
95
+ KillMode=process
91
96
  TimeoutStopSec=20
92
97
  ${envLines}
93
98
 
@@ -31,6 +31,12 @@ export declare function remoteControlArgs(opts: {
31
31
  * (`--name outpost-1-2`), so taking the runner over keeps the name people see.
32
32
  */
33
33
  export declare function runnerSessionName(script: string): string | null;
34
+ /**
35
+ * Fingerprint of what a host runs: the tool first, then other installs it
36
+ * depends on (the hq-cli package). Null while the tool cannot be read; an
37
+ * unreadable extra install is left out rather than hiding a tool update.
38
+ */
39
+ export declare function installFingerprint(paths: readonly string[], fingerprint: (file: string) => string | null): string | null;
34
40
  /** A tool updated on disk restarts its session once quiet this long... */
35
41
  export declare const IDLE_BEFORE_RESTART_MS: number;
36
42
  /** ...or after this long, however busy the session is. */
@@ -35,6 +35,18 @@ export function runnerSessionName(script) {
35
35
  const m = /remote-control\b[^\n]*?--name[= ]+"?([^\s"]+)"?/.exec(script);
36
36
  return m ? m[1] : null;
37
37
  }
38
+ /**
39
+ * Fingerprint of what a host runs: the tool first, then other installs it
40
+ * depends on (the hq-cli package). Null while the tool cannot be read; an
41
+ * unreadable extra install is left out rather than hiding a tool update.
42
+ */
43
+ export function installFingerprint(paths, fingerprint) {
44
+ const [tool, ...rest] = paths;
45
+ const toolFp = tool === undefined ? null : fingerprint(tool);
46
+ if (toolFp === null)
47
+ return null;
48
+ return [toolFp, ...rest.map(fingerprint).filter((fp) => fp !== null)].join("|");
49
+ }
38
50
  /** A tool updated on disk restarts its session once quiet this long... */
39
51
  export const IDLE_BEFORE_RESTART_MS = 10 * 60_000;
40
52
  /** ...or after this long, however busy the session is. */