@indigoai-us/hq-cli 5.274.1 → 5.276.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 (77) hide show
  1. package/CHANGELOG.md +88 -4
  2. package/README.md +12 -0
  3. package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
  4. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +74 -18
  5. package/dist/command-catalog.generated.d.ts +34 -1
  6. package/dist/command-catalog.generated.js +43 -1
  7. package/dist/commands/cloud.js +22 -9
  8. package/dist/commands/company.js +66 -3
  9. package/dist/commands/core-checkpoint-gate-setting.d.ts +69 -0
  10. package/dist/commands/core-checkpoint-gate-setting.js +163 -0
  11. package/dist/commands/core-checkpoint.d.ts +4 -2
  12. package/dist/commands/core-checkpoint.js +34 -3
  13. package/dist/commands/core-command-catalog.d.ts +4 -2
  14. package/dist/commands/core-command-catalog.js +5 -2
  15. package/dist/commands/core-ontology.js +3 -3
  16. package/dist/commands/core.js +3 -1
  17. package/dist/commands/daemon.d.ts +1 -0
  18. package/dist/commands/daemon.js +9 -3
  19. package/dist/commands/feedback.d.ts +6 -0
  20. package/dist/commands/feedback.js +86 -37
  21. package/dist/commands/lanes.js +33 -11
  22. package/dist/commands/run-feature-telemetry.d.ts +3 -0
  23. package/dist/commands/run-feature-telemetry.js +6 -0
  24. package/dist/commands/run.js +3 -2
  25. package/dist/commands/secrets.js +7 -1
  26. package/dist/commands/whoami.d.ts +4 -6
  27. package/dist/commands/whoami.js +39 -8
  28. package/dist/fast-core.d.ts +6 -7
  29. package/dist/fast-core.js +22 -8
  30. package/dist/fast-scaffold.d.ts +16 -7
  31. package/dist/fast-scaffold.js +13 -8
  32. package/dist/index.js +16 -1
  33. package/dist/lib/billing/plan-lock.d.ts +3 -1
  34. package/dist/lib/billing/plan-lock.js +7 -1
  35. package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
  36. package/dist/lib/checkpoint-ontology/company.js +15 -7
  37. package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
  38. package/dist/lib/checkpoint-ontology/plan.js +53 -4
  39. package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
  40. package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
  41. package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
  42. package/dist/lib/core-utils/timeout-guard-native.js +40 -0
  43. package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
  44. package/dist/lib/core-utils/timeout-guard.js +28 -13
  45. package/dist/lib/daemon/catalog.d.ts +7 -0
  46. package/dist/lib/daemon/catalog.js +6 -0
  47. package/dist/lib/daemon/flag.d.ts +23 -0
  48. package/dist/lib/daemon/flag.js +57 -0
  49. package/dist/lib/daemon/install.js +6 -1
  50. package/dist/lib/daemon/remote-control.d.ts +6 -0
  51. package/dist/lib/daemon/remote-control.js +12 -0
  52. package/dist/lib/daemon/run.js +115 -2
  53. package/dist/lib/daemon/succession.d.ts +116 -0
  54. package/dist/lib/daemon/succession.js +262 -0
  55. package/dist/lib/daemon/supervisor.d.ts +34 -1
  56. package/dist/lib/daemon/supervisor.js +190 -23
  57. package/dist/lib/flag-registry.d.ts +12 -7
  58. package/dist/lib/flag-registry.js +66 -75
  59. package/dist/lib/flag-snapshot-file.d.ts +20 -0
  60. package/dist/lib/flag-snapshot-file.js +91 -0
  61. package/dist/lib/lanes/host-capacity.js +7 -1
  62. package/dist/lib/lanes/models.d.ts +2 -0
  63. package/dist/lib/lanes/models.js +6 -1
  64. package/dist/lib/lanes/reconcile.js +20 -4
  65. package/dist/lib/process-flag-company-context.d.ts +8 -0
  66. package/dist/lib/process-flag-company-context.js +67 -0
  67. package/dist/main.js +3 -1
  68. package/dist/native-fast-route.d.ts +3 -0
  69. package/dist/native-fast-route.js +39 -0
  70. package/dist/utils/cli-telemetry.d.ts +26 -2
  71. package/dist/utils/cli-telemetry.js +178 -3
  72. package/dist/utils/hq-root-resolver.d.ts +13 -0
  73. package/dist/utils/hq-root-resolver.js +103 -0
  74. package/dist/utils/scaffold-fast-import-guard.js +6 -1
  75. package/dist/utils/vault-api.d.ts +8 -0
  76. package/dist/utils/vault-api.js +13 -1
  77. package/package.json +1 -1
@@ -13,6 +13,7 @@ import * as path from "node:path";
13
13
  import { writeCompanyBrief } from "./company-brief.js";
14
14
  import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
15
15
  import { startMultiWriterRun } from "./multi-writer.js";
16
+ import { claimQueuedTouches, recordQueuedTouches } from "./queued-touches.js";
16
17
  import { checkSyncClient, readSyncClientVersions } from "./sync-client.js";
17
18
  import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
18
19
  export const ONTOLOGY_FLAG = "checkpoint.ontology";
@@ -98,6 +99,15 @@ export const CONCEPT_PROJECT_LINK_FLAG = "checkpoint.ontology.concept-project-li
98
99
  * and lost no gold link.
99
100
  */
100
101
  export const NAMED_EVIDENCE_FLAG = "checkpoint.ontology.named-evidence";
102
+ /**
103
+ * CO-59: consume the files touched by company checkpoints that were queued
104
+ * behind a busy sibling (see queued-touches.ts). On, the plan adds the
105
+ * company's queued touches to this run's session-touched set, and a run whose
106
+ * own payload names no company plans the company the parent picked for it
107
+ * (`ontology_company`), without that run's session text. Off, queued touches
108
+ * wait on disk and a company-less run skips the ontology step as before.
109
+ */
110
+ export const QUEUED_TOUCHES_FLAG = "checkpoint.ontology.queued-touches";
101
111
  export const PLAN_FILE = "ontology-plan.json";
102
112
  export const CANDIDATES_FILE = "ontology-candidates.json";
103
113
  export const REPORT_FILE = "ontology-report.json";
@@ -255,13 +265,32 @@ export function refreshLedger(company, existing, touched) {
255
265
  }
256
266
  return { ledger: { schema: LEDGER_SCHEMA, company: company.slug, boundary, entries }, firstRun };
257
267
  }
268
+ /**
269
+ * The company this run's ontology step works on: the payload's own company,
270
+ * or else the `ontology_company` the checkpoint parent picked for queued
271
+ * touches (`borrowed`).
272
+ */
273
+ export function payloadOntologyCompany(payload) {
274
+ if (typeof payload.company === "string" && payload.company)
275
+ return { slug: payload.company, borrowed: false };
276
+ if (typeof payload.ontology_company === "string" && payload.ontology_company) {
277
+ return { slug: payload.ontology_company, borrowed: true };
278
+ }
279
+ return { slug: null, borrowed: false };
280
+ }
258
281
  /** Plan this run. Never throws for a skip; returns the reason instead. */
259
282
  export async function planOntologyRun(options) {
260
283
  const { hqRoot, runDir, payload } = options;
261
- const resolution = resolveOntologyCompany(hqRoot, typeof payload.company === "string" ? payload.company : null);
284
+ const { slug, borrowed } = payloadOntologyCompany(payload);
285
+ const resolution = resolveOntologyCompany(hqRoot, slug);
262
286
  if (!resolution.ok)
263
287
  return { status: "skipped", reason: resolution.reason };
264
288
  const company = resolution.company;
289
+ // A borrowed company exists only for queued touches, so its run needs the
290
+ // queued-touches flag before anything else is read or written.
291
+ if (borrowed && !(await options.readFlag(QUEUED_TOUCHES_FLAG, company))) {
292
+ return { status: "skipped", reason: "flag off" };
293
+ }
265
294
  const ontologyOn = (await options.readFlag(ONTOLOGY_FLAG, company)) || (await options.readFlag(ONTOLOGY_SYNC_GATED_FLAG, company));
266
295
  const syncClient = ontologyOn ? checkSyncClient((options.syncClientVersions ?? readSyncClientVersions)()) : null;
267
296
  if (!ontologyOn || !syncClient?.ok) {
@@ -278,7 +307,14 @@ export async function planOntologyRun(options) {
278
307
  const skipAmbiguousConceptLinks = await options.readFlag(SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG, company);
279
308
  const conceptProjectLink = await options.readFlag(CONCEPT_PROJECT_LINK_FLAG, company);
280
309
  const namedEvidence = await options.readFlag(NAMED_EVIDENCE_FLAG, company);
281
- const touched = sessionTouched(hqRoot, company, payload.files);
310
+ const queuedTouchesOn = borrowed || (await options.readFlag(QUEUED_TOUCHES_FLAG, company));
311
+ // A borrowed company's run belongs to another session: its own files and
312
+ // session text are not this company's input.
313
+ const touched = sessionTouched(hqRoot, company, borrowed ? [] : payload.files);
314
+ if (queuedTouchesOn) {
315
+ for (const rel of sessionTouched(hqRoot, company, claimQueuedTouches(hqRoot, company, runDir)))
316
+ touched.add(rel);
317
+ }
282
318
  // A first run takes its boundary from the entity tree, so the ledger is
283
319
  // refreshed before merge-back moves any `last_updated` forward.
284
320
  const { ledger, firstRun } = refreshLedger(company, readLedger(hqRoot, company), touched);
@@ -293,9 +329,11 @@ export async function planOntologyRun(options) {
293
329
  const inputs = [];
294
330
  let tokens = 0;
295
331
  let remaining = 0;
332
+ const leftover = [];
296
333
  for (const [rel, entry] of pending) {
297
334
  if (inputs.length >= BUDGET.maxInputs || tokens >= BUDGET.maxTokens) {
298
335
  remaining += 1;
336
+ leftover.push(rel);
299
337
  continue;
300
338
  }
301
339
  const text = fs.readFileSync(path.join(company.dir, rel), "utf8");
@@ -329,10 +367,21 @@ export async function planOntologyRun(options) {
329
367
  });
330
368
  tokens += cost;
331
369
  }
332
- if (index < chunks.length)
370
+ if (index < chunks.length) {
333
371
  remaining += 1;
372
+ leftover.push(rel);
373
+ }
334
374
  }
335
375
  writeLedger(hqRoot, company, ledger);
376
+ // The leftover files stay queued so a later company-less run can borrow this company again.
377
+ if (queuedTouchesOn && leftover.length > 0) {
378
+ try {
379
+ recordQueuedTouches(hqRoot, company.slug, leftover.map((rel) => path.relative(hqRoot, path.join(company.dir, rel))), options.now ?? new Date());
380
+ }
381
+ catch {
382
+ // Re-queueing leftovers must not fail the plan.
383
+ }
384
+ }
336
385
  if (inputs.length === 0) {
337
386
  const reason = pending.length > 0 ? "over budget" : "nothing changed";
338
387
  return {
@@ -345,7 +394,7 @@ export async function planOntologyRun(options) {
345
394
  // Session content is scoped to the session's user (design section 6, D2).
346
395
  // v1 records the existing entities it names as links in the report and
347
396
  // writes nothing from it, so it rides along only when files changed.
348
- const sessionText = sessionContent(payload);
397
+ const sessionText = borrowed ? null : sessionContent(payload);
349
398
  if (sessionText) {
350
399
  const cost = estTokens(Buffer.byteLength(sessionText, "utf8"));
351
400
  inputs.unshift({
@@ -0,0 +1,32 @@
1
+ import { type OntologyCompany } from "./company.js";
2
+ export declare const QUEUED_TOUCHES_FILE = "queued-touches.jsonl";
3
+ export declare const QUEUED_TOUCHES_CLAIM_FILE = "queued-touches-claimed.jsonl";
4
+ export declare const QUEUED_TOUCHES_BORROWED_FILE = "queued-touches-borrowed-at";
5
+ export declare const MAX_QUEUED_TOUCHES = 200;
6
+ export declare function queuedTouchesPath(hqRoot: string, slug: string): string;
7
+ /**
8
+ * Record the files a queued checkpoint touched inside its company. Files
9
+ * outside `companies/<slug>/` are dropped. Returns the number of files
10
+ * recorded; 0 when the company is not a manifest company or nothing is inside
11
+ * it. The queue keeps its newest {@link MAX_QUEUED_TOUCHES} lines.
12
+ */
13
+ export declare function recordQueuedTouches(hqRoot: string, slug: string | null, files: string[], now: Date): number;
14
+ /**
15
+ * Record that a company-less run borrowed `slug`, so the next such run prefers
16
+ * a company that has waited longer.
17
+ */
18
+ export declare function markBorrowed(hqRoot: string, slug: string, now: Date): void;
19
+ /**
20
+ * The manifest company whose queued touches have waited longest, or null.
21
+ * The order key is the later of the first queued record and the last borrow,
22
+ * so a company a company-less run just borrowed goes to the back. A run whose
23
+ * own payload names no company uses it for its ontology step.
24
+ */
25
+ export declare function oldestQueuedCompany(hqRoot: string): string | null;
26
+ /**
27
+ * Take the company's queued touches by renaming the file into the run
28
+ * directory, then return the HQ-relative paths it lists. Renaming is atomic,
29
+ * so a touch recorded during the claim lands in a fresh file for a later run.
30
+ */
31
+ export declare function claimQueuedTouches(hqRoot: string, company: OntologyCompany, runDir: string): string[];
32
+ //# sourceMappingURL=queued-touches.d.ts.map
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Company files touched by checkpoints that were queued behind a busy sibling.
3
+ *
4
+ * `hq core checkpoint` queues its payload when a sibling is already running,
5
+ * and the next run claims the queue. That run renders the ontology step only
6
+ * for its own payload's company, and the ontology plan records any changed
7
+ * file the run's own payload did not touch as seen. Without this file, the
8
+ * files a queued company checkpoint touched never reach the ontology (CO-59).
9
+ *
10
+ * The checkpoint parent performs no network I/O, so it cannot read the
11
+ * `checkpoint.ontology.queued-touches` flag. It always records the touches in
12
+ * the company's local, unsynced state directory; the ontology plan consumes
13
+ * them only while the flag is on. The file is capped so it stays small while
14
+ * the flag is off.
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { assertSafeTarget, resolveOntologyCompany, safeWriteFile, WriteBoundsError, } from "./company.js";
19
+ export const QUEUED_TOUCHES_FILE = "queued-touches.jsonl";
20
+ export const QUEUED_TOUCHES_CLAIM_FILE = "queued-touches-claimed.jsonl";
21
+ export const QUEUED_TOUCHES_BORROWED_FILE = "queued-touches-borrowed-at";
22
+ export const MAX_QUEUED_TOUCHES = 200;
23
+ function ontologyStateBase(hqRoot) {
24
+ return path.join(hqRoot, "workspace", "checkpoints", "ontology");
25
+ }
26
+ function companyStateDir(hqRoot, slug) {
27
+ return path.join(ontologyStateBase(hqRoot), slug);
28
+ }
29
+ export function queuedTouchesPath(hqRoot, slug) {
30
+ return path.join(companyStateDir(hqRoot, slug), QUEUED_TOUCHES_FILE);
31
+ }
32
+ /**
33
+ * Record the files a queued checkpoint touched inside its company. Files
34
+ * outside `companies/<slug>/` are dropped. Returns the number of files
35
+ * recorded; 0 when the company is not a manifest company or nothing is inside
36
+ * it. The queue keeps its newest {@link MAX_QUEUED_TOUCHES} lines.
37
+ */
38
+ export function recordQueuedTouches(hqRoot, slug, files, now) {
39
+ if (!slug)
40
+ return 0;
41
+ const resolution = resolveOntologyCompany(hqRoot, slug);
42
+ if (!resolution.ok)
43
+ return 0;
44
+ const inside = files.filter((file) => {
45
+ if (typeof file !== "string" || !file.trim())
46
+ return false;
47
+ const rel = path.relative(resolution.company.dir, path.resolve(hqRoot, file.trim()));
48
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
49
+ });
50
+ if (inside.length === 0)
51
+ return 0;
52
+ const target = queuedTouchesPath(hqRoot, slug);
53
+ try {
54
+ assertSafeTarget(hqRoot, [companyStateDir(hqRoot, slug)], target);
55
+ }
56
+ catch (error) {
57
+ if (error instanceof WriteBoundsError)
58
+ return 0;
59
+ throw error;
60
+ }
61
+ const fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW);
62
+ try {
63
+ fs.writeSync(fd, `${JSON.stringify({ queued_at: now.toISOString(), files: inside })}\n`);
64
+ }
65
+ finally {
66
+ fs.closeSync(fd);
67
+ }
68
+ const lines = fs.readFileSync(target, "utf8").split("\n").filter((line) => line.trim());
69
+ if (lines.length > MAX_QUEUED_TOUCHES) {
70
+ // Best-effort trim, as for the sibling's pending queue: it can race a
71
+ // concurrent append, but only once the file is already over its cap.
72
+ safeWriteFile(hqRoot, [companyStateDir(hqRoot, slug)], target, `${lines.slice(-MAX_QUEUED_TOUCHES).join("\n")}\n`);
73
+ }
74
+ return inside.length;
75
+ }
76
+ /**
77
+ * Record that a company-less run borrowed `slug`, so the next such run prefers
78
+ * a company that has waited longer.
79
+ */
80
+ export function markBorrowed(hqRoot, slug, now) {
81
+ const target = path.join(companyStateDir(hqRoot, slug), QUEUED_TOUCHES_BORROWED_FILE);
82
+ safeWriteFile(hqRoot, [companyStateDir(hqRoot, slug)], target, `${now.toISOString()}\n`);
83
+ }
84
+ /** `queued_at` of the oldest record, or the file mtime when no line has one. */
85
+ function firstQueuedMs(file, mtimeMs) {
86
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
87
+ if (!line.trim())
88
+ continue;
89
+ try {
90
+ const parsed = JSON.parse(line);
91
+ if (typeof parsed.queued_at !== "string")
92
+ continue;
93
+ const ms = Date.parse(parsed.queued_at);
94
+ if (!Number.isNaN(ms))
95
+ return ms;
96
+ }
97
+ catch {
98
+ // A partial line is not a record; a later valid line still counts.
99
+ }
100
+ }
101
+ return mtimeMs;
102
+ }
103
+ /** When this company was last borrowed, or -Infinity when it never was. */
104
+ function borrowedMs(hqRoot, slug) {
105
+ const file = path.join(companyStateDir(hqRoot, slug), QUEUED_TOUCHES_BORROWED_FILE);
106
+ let stat;
107
+ try {
108
+ stat = fs.lstatSync(file);
109
+ }
110
+ catch {
111
+ return Number.NEGATIVE_INFINITY;
112
+ }
113
+ if (!stat.isFile())
114
+ return Number.NEGATIVE_INFINITY;
115
+ const ms = Date.parse(fs.readFileSync(file, "utf8").trim());
116
+ return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms;
117
+ }
118
+ /**
119
+ * The manifest company whose queued touches have waited longest, or null.
120
+ * The order key is the later of the first queued record and the last borrow,
121
+ * so a company a company-less run just borrowed goes to the back. A run whose
122
+ * own payload names no company uses it for its ontology step.
123
+ */
124
+ export function oldestQueuedCompany(hqRoot) {
125
+ let entries;
126
+ try {
127
+ entries = fs.readdirSync(ontologyStateBase(hqRoot));
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ let oldest = null;
133
+ for (const slug of entries.sort()) {
134
+ let stat;
135
+ try {
136
+ stat = fs.lstatSync(queuedTouchesPath(hqRoot, slug));
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ if (!stat.isFile() || stat.size === 0)
142
+ continue;
143
+ if (!resolveOntologyCompany(hqRoot, slug).ok)
144
+ continue;
145
+ const key = Math.max(firstQueuedMs(queuedTouchesPath(hqRoot, slug), stat.mtimeMs), borrowedMs(hqRoot, slug));
146
+ if (!oldest || key < oldest.key)
147
+ oldest = { slug, key };
148
+ }
149
+ return oldest?.slug ?? null;
150
+ }
151
+ /**
152
+ * Take the company's queued touches by renaming the file into the run
153
+ * directory, then return the HQ-relative paths it lists. Renaming is atomic,
154
+ * so a touch recorded during the claim lands in a fresh file for a later run.
155
+ */
156
+ export function claimQueuedTouches(hqRoot, company, runDir) {
157
+ const queuedPath = queuedTouchesPath(hqRoot, company.slug);
158
+ try {
159
+ assertSafeTarget(hqRoot, [companyStateDir(hqRoot, company.slug)], queuedPath);
160
+ }
161
+ catch (error) {
162
+ if (error instanceof WriteBoundsError)
163
+ return [];
164
+ throw error;
165
+ }
166
+ const claimPath = path.join(runDir, QUEUED_TOUCHES_CLAIM_FILE);
167
+ try {
168
+ fs.renameSync(queuedPath, claimPath);
169
+ }
170
+ catch (error) {
171
+ if (error?.code === "ENOENT")
172
+ return [];
173
+ throw error;
174
+ }
175
+ const files = [];
176
+ for (const line of fs.readFileSync(claimPath, "utf8").split("\n")) {
177
+ if (!line.trim())
178
+ continue;
179
+ try {
180
+ const parsed = JSON.parse(line);
181
+ if (Array.isArray(parsed.files)) {
182
+ for (const file of parsed.files)
183
+ if (typeof file === "string")
184
+ files.push(file);
185
+ }
186
+ }
187
+ catch {
188
+ // A partial line must not drop the valid lines around it.
189
+ }
190
+ }
191
+ return files;
192
+ }
193
+ //# sourceMappingURL=queued-touches.js.map
@@ -0,0 +1,7 @@
1
+ /** Native fast-route adapter for the shared timeout-guard command. */
2
+ import type { FlagReader } from "../flag-registry.js";
3
+ import { type PersistedFlagSnapshot } from "../flag-snapshot-file.js";
4
+ import { type TimeoutGuardDeps } from "./timeout-guard.js";
5
+ export declare function createNativeSnapshotFlagReader(persistedSnapshot: PersistedFlagSnapshot | null, companyIdentifiers: readonly string[], env: NodeJS.ProcessEnv): FlagReader;
6
+ export declare function runTimeoutGuardFast(_argv: readonly string[], stdin: string, _cwd: string, _hqRoot: string | undefined, dependencies?: TimeoutGuardDeps): Promise<number>;
7
+ //# sourceMappingURL=timeout-guard-native.d.ts.map
@@ -0,0 +1,40 @@
1
+ /** Native fast-route adapter for the shared timeout-guard command. */
2
+ import { readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
3
+ import { resolveProcessFlagCompanyContext } from "../process-flag-company-context.js";
4
+ import { readFlagSnapshotFile, } from "../flag-snapshot-file.js";
5
+ import { timeoutGuardCommand } from "./timeout-guard.js";
6
+ export function createNativeSnapshotFlagReader(persistedSnapshot, companyIdentifiers, env) {
7
+ const snapshot = persistedSnapshot
8
+ ? { version: 0, flags: persistedSnapshot.flags }
9
+ : null;
10
+ return {
11
+ isEnabled(flagKey, lookup = {}) {
12
+ if (snapshot) {
13
+ return resolveFlag({
14
+ flagKey,
15
+ env,
16
+ snapshot,
17
+ companyIdentifiers,
18
+ lookup,
19
+ }).value;
20
+ }
21
+ return readLocalOverride(flagKey, env) ?? (lookup.fallback ?? false);
22
+ },
23
+ };
24
+ }
25
+ function snapshotFlagReader(companyUid, companySlug, env) {
26
+ const persistedSnapshot = companyUid ? readFlagSnapshotFile(companyUid, { env }) : null;
27
+ const companyIdentifiers = [companyUid, companySlug].filter((value) => Boolean(value));
28
+ return createNativeSnapshotFlagReader(persistedSnapshot, companyIdentifiers, env);
29
+ }
30
+ export async function runTimeoutGuardFast(_argv, stdin, _cwd, _hqRoot, dependencies = {}) {
31
+ const env = dependencies.env ?? process.env;
32
+ const company = resolveProcessFlagCompanyContext(env);
33
+ const flagReader = dependencies.flagReader ?? snapshotFlagReader(company.companyUid, company.companySlug, env);
34
+ return timeoutGuardCommand(stdin, {
35
+ ...dependencies,
36
+ env,
37
+ flagReader,
38
+ });
39
+ }
40
+ //# sourceMappingURL=timeout-guard-native.js.map
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Exit codes: 0 = allow, 2 = block.
22
22
  */
23
- import { type FlagReader } from "../flag-registry.js";
23
+ import type { FlagReader } from "../flag-registry.js";
24
24
  /**
25
25
  * Worst-case inner deadline (seconds) declared in a command: a `timeout` /
26
26
  * `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
@@ -55,11 +55,11 @@ export interface TimeoutGuardDeps {
55
55
  flagReader?: FlagReader;
56
56
  }
57
57
  /** Whether the guard is active for this identity (rollout gate). */
58
- export declare function isGatedUser(email: string | undefined, flagReader?: FlagReader): boolean;
58
+ export declare function isGatedUser(email: string | undefined, flagReader?: FlagReader): Promise<boolean>;
59
59
  /**
60
60
  * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
61
61
  * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
62
62
  * missing identity fails OPEN (allow), because a hook must not break tool calls.
63
63
  */
64
- export declare function timeoutGuardCommand(stdin: string, deps?: TimeoutGuardDeps): number;
64
+ export declare function timeoutGuardCommand(stdin: string, deps?: TimeoutGuardDeps): Promise<number>;
65
65
  //# sourceMappingURL=timeout-guard.d.ts.map
@@ -20,9 +20,6 @@
20
20
  *
21
21
  * Exit codes: 0 = allow, 2 = block.
22
22
  */
23
- import { loadCachedTokens } from "@indigoai-us/hq-cloud";
24
- import { peekIdToken } from "../../utils/id-token.js";
25
- import { resolveFlagGate, resolveProcessFlagGate, } from "../flag-registry.js";
26
23
  // Observed Claude Code Bash-tool bounds. The tool `timeout` (ms) raises the
27
24
  // deadline up to the max; with none declared the default applies.
28
25
  const HARNESS_DEFAULT_MS = 120_000; // 2 min
@@ -153,8 +150,14 @@ supervise yourself.)
153
150
 
154
151
  Policy: hq-foreground-timeout-killed-by-harness-deadline.
155
152
  Bypass for one sanctioned foreground run: prefix HQ_ALLOW_LONG_FOREGROUND=1.`;
156
- function defaultEmail() {
153
+ async function defaultEmail() {
157
154
  try {
155
+ // The cached identity is needed only for commands that would otherwise
156
+ // block. Keep hq-cloud and the JWT decoder out of the common allow path.
157
+ const [{ loadCachedTokens }, { peekIdToken }] = await Promise.all([
158
+ import("@indigoai-us/hq-cloud"),
159
+ import("../../utils/id-token.js"),
160
+ ]);
158
161
  const cached = loadCachedTokens();
159
162
  if (!cached)
160
163
  return undefined;
@@ -166,23 +169,35 @@ function defaultEmail() {
166
169
  }
167
170
  }
168
171
  /** Whether the guard is active for this identity (rollout gate). */
169
- export function isGatedUser(email, flagReader) {
172
+ export async function isGatedUser(email, flagReader) {
170
173
  const legacyFallback = () => typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
171
174
  const lookup = {
172
175
  defaultEmailDomains: ["getindigo.ai"],
173
176
  personEmail: email,
174
177
  fallback: legacyFallback(),
175
178
  };
176
- return flagReader
177
- ? resolveFlagGate(flagReader, "cli.foreground-timeout-guard", lookup, legacyFallback)
178
- : resolveProcessFlagGate("cli.foreground-timeout-guard", lookup, legacyFallback);
179
+ if (flagReader) {
180
+ try {
181
+ return flagReader.isEnabled("cli.foreground-timeout-guard", lookup);
182
+ }
183
+ catch {
184
+ return legacyFallback();
185
+ }
186
+ }
187
+ try {
188
+ const { resolveProcessFlagGate } = await import("../flag-registry.js");
189
+ return resolveProcessFlagGate("cli.foreground-timeout-guard", lookup, legacyFallback);
190
+ }
191
+ catch {
192
+ return legacyFallback();
193
+ }
179
194
  }
180
195
  /**
181
196
  * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
182
197
  * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
183
198
  * missing identity fails OPEN (allow), because a hook must not break tool calls.
184
199
  */
185
- export function timeoutGuardCommand(stdin, deps = {}) {
200
+ export async function timeoutGuardCommand(stdin, deps = {}) {
186
201
  const env = deps.env ?? process.env;
187
202
  const err = deps.stderr ?? process.stderr;
188
203
  let payload;
@@ -201,15 +216,15 @@ export function timeoutGuardCommand(stdin, deps = {}) {
201
216
  return 0;
202
217
  if (/(^|\s)HQ_ALLOW_LONG_FOREGROUND=1(\s|$)/.test(command))
203
218
  return 0;
204
- // Rollout gate: only act for @getindigo.ai identities.
205
- const getEmail = deps.getEmail ?? defaultEmail;
206
- if (!isGatedUser(getEmail(), deps.flagReader))
207
- return 0;
208
219
  const runInBackground = ti.run_in_background === true;
209
220
  const toolTimeoutMs = typeof ti.timeout === "number" ? ti.timeout : undefined;
210
221
  const verdict = evaluateTimeoutGuard({ command, toolTimeoutMs, runInBackground });
211
222
  if (!verdict.block)
212
223
  return 0;
224
+ // Rollout gate: only act for @getindigo.ai identities.
225
+ const getEmail = deps.getEmail ?? defaultEmail;
226
+ if (!await isGatedUser(await getEmail(), deps.flagReader))
227
+ return 0;
213
228
  err.write(`${BLOCK_MESSAGE(verdict.reason ?? "declared timeout exceeds the harness ceiling")}\n`);
214
229
  return 2;
215
230
  }
@@ -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. */