@indigoai-us/hq-cli 5.276.0 → 5.277.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 +76 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-pickup.sh +99 -0
  3. package/assets/scaffold/core/scripts/hq-delegate-repo.sh +158 -0
  4. package/assets/scaffold/core/scripts/hq-delegate-resolve.sh +145 -0
  5. package/assets/scaffold/core/scripts/hq-delegate-secrets.sh +129 -0
  6. package/assets/scaffold/core/scripts/hq-delegate-transfer.sh +175 -0
  7. package/assets/scaffold/core/scripts/lib/secret-patterns.sh +58 -0
  8. package/dist/command-catalog.generated.d.ts +239 -1
  9. package/dist/command-catalog.generated.js +302 -1
  10. package/dist/commands/core-delegate.d.ts +6 -0
  11. package/dist/commands/core-delegate.js +987 -0
  12. package/dist/commands/core.js +10 -0
  13. package/dist/commands/daemon.d.ts +3 -0
  14. package/dist/commands/daemon.js +173 -1
  15. package/dist/commands/dm.d.ts +2 -0
  16. package/dist/commands/dm.js +11 -4
  17. package/dist/commands/lanes-accounts.js +44 -2
  18. package/dist/commands/lanes-message.js +69 -12
  19. package/dist/commands/lanes-resume.js +2 -0
  20. package/dist/commands/lanes-shared.d.ts +3 -0
  21. package/dist/commands/lanes-shared.js +12 -2
  22. package/dist/commands/lanes.d.ts +5 -1
  23. package/dist/commands/lanes.js +399 -47
  24. package/dist/commands/secrets.d.ts +16 -1
  25. package/dist/commands/secrets.js +77 -48
  26. package/dist/commands/sync-mode.d.ts +11 -0
  27. package/dist/commands/sync-mode.js +30 -0
  28. package/dist/fast-scaffold.d.ts +17 -0
  29. package/dist/fast-scaffold.js +91 -0
  30. package/dist/index.js +17 -1
  31. package/dist/lanes-drain-hook.js +154 -7
  32. package/dist/lib/agent-kit/run/inbox.js +3 -2
  33. package/dist/lib/checkpoint-ontology/apply.d.ts +6 -0
  34. package/dist/lib/checkpoint-ontology/apply.js +60 -3
  35. package/dist/lib/checkpoint-ontology/plan.d.ts +13 -0
  36. package/dist/lib/checkpoint-ontology/plan.js +13 -0
  37. package/dist/lib/checkpoint-ontology/tree.d.ts +2 -0
  38. package/dist/lib/checkpoint-ontology/tree.js +34 -0
  39. package/dist/lib/core-utils/jq-json-serializer.d.ts +5 -0
  40. package/dist/lib/core-utils/jq-json-serializer.js +10 -0
  41. package/dist/lib/core-utils/secret-patterns.d.ts +41 -0
  42. package/dist/lib/core-utils/secret-patterns.js +33 -0
  43. package/dist/lib/daemon/catalog.d.ts +18 -2
  44. package/dist/lib/daemon/catalog.js +18 -2
  45. package/dist/lib/daemon/config.d.ts +6 -0
  46. package/dist/lib/daemon/config.js +15 -2
  47. package/dist/lib/daemon/requests.d.ts +4 -0
  48. package/dist/lib/daemon/requests.js +2 -0
  49. package/dist/lib/daemon/run.js +94 -8
  50. package/dist/lib/daemon/supervisor.d.ts +8 -0
  51. package/dist/lib/daemon/supervisor.js +101 -1
  52. package/dist/lib/daemon/sync-runner.d.ts +58 -0
  53. package/dist/lib/daemon/sync-runner.js +277 -0
  54. package/dist/lib/doctor/__testing__/run-doctor.d.ts +8 -0
  55. package/dist/lib/doctor/__testing__/run-doctor.js +52 -0
  56. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  57. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  58. package/dist/lib/lanes/claude-balance.d.ts +6 -1
  59. package/dist/lib/lanes/claude-balance.js +46 -8
  60. package/dist/lib/lanes/config.js +15 -0
  61. package/dist/lib/lanes/hire-grant-dispatch.d.ts +2 -0
  62. package/dist/lib/lanes/hire-grant-dispatch.js +41 -0
  63. package/dist/lib/lanes/index.d.ts +1 -1
  64. package/dist/lib/lanes/keep-alive.d.ts +3 -0
  65. package/dist/lib/lanes/keep-alive.js +3 -0
  66. package/dist/lib/lanes/outpost-dispatch.d.ts +4 -0
  67. package/dist/lib/lanes/outpost-dispatch.js +11 -2
  68. package/dist/lib/lanes/spawn.js +17 -10
  69. package/dist/lib/lanes/store.js +45 -1
  70. package/dist/lib/lanes/types.d.ts +30 -1
  71. package/dist/lib/lanes/types.js +2 -2
  72. package/dist/lib/lanes/watcher.js +3 -2
  73. package/dist/secrets-env-fast-path.d.ts +5 -0
  74. package/dist/secrets-env-fast-path.js +16 -0
  75. package/dist/secrets-env-fast-route.d.ts +18 -0
  76. package/dist/secrets-env-fast-route.js +54 -0
  77. package/package.json +2 -2
@@ -16,7 +16,7 @@ import { checkSyncClient, readSyncClientVersions } from "./sync-client.js";
16
16
  import { appendBackRefs, parseEntityFile, patchEntityFile, previewSignalContent, renderNewEntity, validateEntityFrontmatter, } from "./entity-file.js";
17
17
  import { canonicalizeName, entityId, isRejectedName, isWritableEntityType, normalizeEntityName, squash, toSlug, } from "./identity.js";
18
18
  import { CANDIDATES_FILE, CANDIDATES_SCHEMA, PLAN_FILE, PLAN_SCHEMA, REPORT_FILE, readLedger, writeLedger, } from "./plan.js";
19
- import { buildEntityIndex, indexAlias, indexEntity, listSignalFiles, readSignal } from "./tree.js";
19
+ import { buildEntityIndex, countInboundLinks, indexAlias, indexEntity, listSignalFiles, readSignal } from "./tree.js";
20
20
  export const CAPS = {
21
21
  creates: 25,
22
22
  updates: 100,
@@ -26,6 +26,10 @@ export const CAPS = {
26
26
  nameLength: MAX_NAME_LENGTH,
27
27
  };
28
28
  export const MIN_CREATE_CONFIDENCE = 0.7;
29
+ /** Inbound links the dominant sibling must have for each one the named entity has. */
30
+ const DOMINANT_SIBLING_RATIO = 3;
31
+ /** Inbound links the dominant sibling must have before a redirect. */
32
+ const DOMINANT_SIBLING_MIN_INBOUND = 5;
29
33
  export class ApplyError extends Error {
30
34
  constructor(message) {
31
35
  super(message);
@@ -211,6 +215,7 @@ export function applyOntologyRun(options) {
211
215
  matched: 0,
212
216
  session_links: [],
213
217
  links: [],
218
+ ...(plan.dominant_sibling_link === true ? { dominant_sibling_redirects: [] } : {}),
214
219
  rejected: [],
215
220
  deferred: [],
216
221
  suspect_duplicates: [],
@@ -300,6 +305,44 @@ export function applyOntologyRun(options) {
300
305
  if (types.size > 1)
301
306
  crossTypeNames.add(name);
302
307
  }
308
+ // checkpoint.ontology.dominant-sibling-link: cross-type names, and how many other entity files link to each.
309
+ const inbound = plan.dominant_sibling_link === true ? countInboundLinks(company.dir, index) : new Map();
310
+ const siblings = new Map();
311
+ if (plan.dominant_sibling_link === true) {
312
+ const grouped = new Map();
313
+ for (const entity of index.byRel.values()) {
314
+ const key = crossTypeKey(entity.canonical_name);
315
+ if (key === "")
316
+ continue;
317
+ const members = grouped.get(key) ?? [];
318
+ members.push({ rel: entity.rel, type: entity.type });
319
+ grouped.set(key, members);
320
+ }
321
+ for (const [key, members] of grouped) {
322
+ if (new Set(members.map((member) => member.type)).size < 2)
323
+ continue;
324
+ siblings.set(key, members.map((member) => member.rel));
325
+ }
326
+ }
327
+ const dominantSibling = (rel) => {
328
+ const group = siblings.get(crossTypeKey(index.byRel.get(rel).canonical_name));
329
+ if (!group)
330
+ return null;
331
+ let best = group[0];
332
+ for (const member of group) {
333
+ const count = inbound.get(member) ?? 0;
334
+ const bestCount = inbound.get(best) ?? 0;
335
+ if (count > bestCount || (count === bestCount && member.localeCompare(best) < 0))
336
+ best = member;
337
+ }
338
+ const relCount = inbound.get(rel) ?? 0;
339
+ const bestCount = inbound.get(best) ?? 0;
340
+ if (relCount >= bestCount)
341
+ return null;
342
+ if (bestCount >= DOMINANT_SIBLING_RATIO * relCount && bestCount >= DOMINANT_SIBLING_MIN_INBOUND)
343
+ return best;
344
+ return null;
345
+ };
303
346
  // checkpoint.ontology.named-evidence: the company's own company entity.
304
347
  const ownCompanyRel = `ontology/entities/company/${company.slug}.md`;
305
348
  const isOwnCompany = (entity, rel) => entity.type === "company" && (rel === ownCompanyRel || crossTypeKey(entity.canonical_name) === crossTypeKey(company.slug));
@@ -361,19 +404,33 @@ export function applyOntologyRun(options) {
361
404
  note(report.rejected, "ambiguous_type");
362
405
  continue;
363
406
  }
407
+ const redirect = input.kind === "knowledge" && plan.dominant_sibling_link === true ? dominantSibling(resolved.target) : null;
408
+ const targetRel = redirect ?? resolved.target;
409
+ const targetEntity = redirect ? index.byRel.get(targetRel) : linked;
364
410
  if (plan.named_evidence === true &&
365
411
  input.kind === "knowledge" &&
366
- isOwnCompany(linked, resolved.target) &&
412
+ isOwnCompany(targetEntity, targetRel) &&
367
413
  !evidenceNames(candidate.evidence, [candidate.name, ...candidate.aliases])) {
368
414
  note(report.rejected, "unnamed_in_evidence");
369
415
  continue;
370
416
  }
371
417
  report.matched += 1;
372
- report.links.push({ input: candidate.input, action: "link", type: linked.type, canonical_name: linked.canonical_name, path: resolved.target });
418
+ report.links.push({
419
+ input: candidate.input,
420
+ action: "link",
421
+ type: targetEntity.type,
422
+ canonical_name: targetEntity.canonical_name,
423
+ path: targetRel,
424
+ });
373
425
  if (resolved.tier === 1 && resolved.duplicates.length > 0) {
374
426
  report.suspect_duplicates.push({ name: canonical, type: candidate.type, paths: [resolved.target, ...resolved.duplicates] });
427
+ }
428
+ if (redirect) {
429
+ report.dominant_sibling_redirects?.push({ input: candidate.input, from: resolved.target, to: targetRel });
375
430
  continue;
376
431
  }
432
+ if (resolved.tier === 1 && resolved.duplicates.length > 0)
433
+ continue;
377
434
  // checkpoint.ontology.no-link-aliases: the link is reported and the entity file is left as it is.
378
435
  if (plan.link_aliases === false)
379
436
  continue;
@@ -70,6 +70,17 @@ export declare const FIRST_NAME_PEOPLE_FLAG = "checkpoint.ontology.first-name-pe
70
70
  * to 0.803 and cost recall 0.023 on a replay of six runs.
71
71
  */
72
72
  export declare const SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG = "checkpoint.ontology.skip-ambiguous-concept-links";
73
+ /**
74
+ * A knowledge link to the weaker of two entities that share a canonical name
75
+ * across types is redirected to the sibling other entity files link to
76
+ * (`project/build-your-own-agi` beside `concept/build-your-own-agi`, or
77
+ * `project/hq-core 2` beside `project/hq-core` when a concept also holds the
78
+ * name). The plan carries `dominant_sibling_link: true`, and apply reports
79
+ * the move in `dominant_sibling_redirects` without writing the entity files.
80
+ * CO-60: a replay of six runs raised knowledge precision from 0.725 to 0.754
81
+ * and recall from 0.784 to 0.816, and never changed a create.
82
+ */
83
+ export declare const DOMINANT_SIBLING_LINK_FLAG = "checkpoint.ontology.dominant-sibling-link";
73
84
  /**
74
85
  * A knowledge concept candidate whose name is an alias of more than one
75
86
  * entity, and which apply would defer as `ambiguous_alias`, links instead to
@@ -173,6 +184,8 @@ export interface OntologyPlan {
173
184
  first_name_people?: true;
174
185
  /** Set by checkpoint.ontology.skip-ambiguous-concept-links: apply rejects a knowledge link to a concept whose name another type also holds. */
175
186
  skip_ambiguous_concept_links?: true;
187
+ /** Set by checkpoint.ontology.dominant-sibling-link: apply redirects a knowledge link from a weak cross-type sibling to the one other files link to. */
188
+ dominant_sibling_link?: true;
176
189
  /** Set by checkpoint.ontology.concept-project-link: apply links a knowledge concept with an ambiguous alias to the one project of that canonical name. */
177
190
  concept_project_link?: true;
178
191
  /** Set by checkpoint.ontology.named-evidence: apply rejects a knowledge link to the company's own entity, or a concept-project-link redirect, whose evidence does not name it as a word. */
@@ -75,6 +75,17 @@ export const FIRST_NAME_PEOPLE_FLAG = "checkpoint.ontology.first-name-people";
75
75
  * to 0.803 and cost recall 0.023 on a replay of six runs.
76
76
  */
77
77
  export const SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG = "checkpoint.ontology.skip-ambiguous-concept-links";
78
+ /**
79
+ * A knowledge link to the weaker of two entities that share a canonical name
80
+ * across types is redirected to the sibling other entity files link to
81
+ * (`project/build-your-own-agi` beside `concept/build-your-own-agi`, or
82
+ * `project/hq-core 2` beside `project/hq-core` when a concept also holds the
83
+ * name). The plan carries `dominant_sibling_link: true`, and apply reports
84
+ * the move in `dominant_sibling_redirects` without writing the entity files.
85
+ * CO-60: a replay of six runs raised knowledge precision from 0.725 to 0.754
86
+ * and recall from 0.784 to 0.816, and never changed a create.
87
+ */
88
+ export const DOMINANT_SIBLING_LINK_FLAG = "checkpoint.ontology.dominant-sibling-link";
78
89
  /**
79
90
  * A knowledge concept candidate whose name is an alias of more than one
80
91
  * entity, and which apply would defer as `ambiguous_alias`, links instead to
@@ -305,6 +316,7 @@ export async function planOntologyRun(options) {
305
316
  const noLinkAliases = await options.readFlag(NO_LINK_ALIASES_FLAG, company);
306
317
  const firstNamePeople = await options.readFlag(FIRST_NAME_PEOPLE_FLAG, company);
307
318
  const skipAmbiguousConceptLinks = await options.readFlag(SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG, company);
319
+ const dominantSiblingLink = await options.readFlag(DOMINANT_SIBLING_LINK_FLAG, company);
308
320
  const conceptProjectLink = await options.readFlag(CONCEPT_PROJECT_LINK_FLAG, company);
309
321
  const namedEvidence = await options.readFlag(NAMED_EVIDENCE_FLAG, company);
310
322
  const queuedTouchesOn = borrowed || (await options.readFlag(QUEUED_TOUCHES_FLAG, company));
@@ -428,6 +440,7 @@ export async function planOntologyRun(options) {
428
440
  ...(noLinkAliases ? { link_aliases: false } : {}),
429
441
  ...(firstNamePeople ? { first_name_people: true } : {}),
430
442
  ...(skipAmbiguousConceptLinks ? { skip_ambiguous_concept_links: true } : {}),
443
+ ...(dominantSiblingLink ? { dominant_sibling_link: true } : {}),
431
444
  ...(conceptProjectLink ? { concept_project_link: true } : {}),
432
445
  ...(namedEvidence ? { named_evidence: true } : {}),
433
446
  inputs,
@@ -36,6 +36,8 @@ export declare function indexAlias(index: EntityIndex, type: string, rel: string
36
36
  * type conflict, but they are never written.
37
37
  */
38
38
  export declare function buildEntityIndex(companyDir: string): EntityIndex;
39
+ /** Entity path → number of other entity files whose body links to it. */
40
+ export declare function countInboundLinks(companyDir: string, index: EntityIndex): Map<string, number>;
39
41
  export interface SignalRecord {
40
42
  /** Company-relative path, `signals/<type>/<file>.md`. */
41
43
  rel: string;
@@ -90,6 +90,40 @@ export function buildEntityIndex(companyDir) {
90
90
  }
91
91
  return index;
92
92
  }
93
+ // Spaces are allowed in the target, as merge-copies writes `../person/jane-doe 2.md`.
94
+ const INBOUND_LINK = /\]\(([^)\n]+?\.md)(?:#[^)\n]*)?\)/g;
95
+ /** Entity path → number of other entity files whose body links to it. */
96
+ export function countInboundLinks(companyDir, index) {
97
+ const counts = new Map();
98
+ for (const rel of index.byRel.keys())
99
+ counts.set(rel, 0);
100
+ for (const rel of index.byRel.keys()) {
101
+ const parsed = parseEntityFile(fs.readFileSync(path.join(companyDir, rel), "utf8"));
102
+ if (!parsed)
103
+ continue;
104
+ const seen = new Set();
105
+ for (const match of parsed.body.matchAll(INBOUND_LINK)) {
106
+ const target = match[1];
107
+ if (target.startsWith("http:") || target.startsWith("https:") || target.startsWith("mailto:"))
108
+ continue;
109
+ let decoded = target;
110
+ try {
111
+ decoded = decodeURI(target);
112
+ }
113
+ catch (error) {
114
+ if (!(error instanceof URIError))
115
+ throw error;
116
+ }
117
+ const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(rel), decoded));
118
+ if (resolved === rel || !index.byRel.has(resolved))
119
+ continue;
120
+ seen.add(resolved);
121
+ }
122
+ for (const targetRel of seen)
123
+ counts.set(targetRel, (counts.get(targetRel) ?? 0) + 1);
124
+ }
125
+ return counts;
126
+ }
93
127
  /** Parse the frontmatter of one company-audience signal file. */
94
128
  export function readSignal(companyDir, rel) {
95
129
  let content;
@@ -0,0 +1,5 @@
1
+ /** JSON output formats used by the scaffold scripts that previously called jq. */
2
+ export type JqJsonOutput = "compact" | "raw" | "pretty";
3
+ /** Serialize one jq result, including jq's output newline. */
4
+ export declare function serializeJqJson(value: unknown, output?: JqJsonOutput): string;
5
+ //# sourceMappingURL=jq-json-serializer.d.ts.map
@@ -0,0 +1,10 @@
1
+ /** Serialize one jq result, including jq's output newline. */
2
+ export function serializeJqJson(value, output = "pretty") {
3
+ if (output === "raw" && typeof value === "string")
4
+ return `${value}\n`;
5
+ const serialized = output === "compact"
6
+ ? JSON.stringify(value)
7
+ : JSON.stringify(value, null, 2);
8
+ return serialized === undefined ? "" : `${serialized.replace(/\u007f/g, "\\u007f")}\n`;
9
+ }
10
+ //# sourceMappingURL=jq-json-serializer.js.map
@@ -0,0 +1,41 @@
1
+ /** Shared secret detector patterns mirrored from the scaffold shell library. */
2
+ export declare const SECRET_PATTERN_BOUNDARY = "(^|[^a-zA-Z0-9])";
3
+ export declare const SECRET_PATTERNS: readonly [{
4
+ readonly pattern: "sk-[a-zA-Z0-9._-]{20,}";
5
+ readonly name: "OpenAI/Stripe key";
6
+ }, {
7
+ readonly pattern: "ghp_[a-zA-Z0-9]{36,}";
8
+ readonly name: "GitHub PAT";
9
+ }, {
10
+ readonly pattern: "AKIA[0-9A-Z]{16}";
11
+ readonly name: "AWS access key";
12
+ }, {
13
+ readonly pattern: "xox[bpsa]-[a-zA-Z0-9-]+";
14
+ readonly name: "Slack token";
15
+ }, {
16
+ readonly pattern: "Bearer [a-zA-Z0-9._-]{20,}";
17
+ readonly name: "Bearer token";
18
+ }, {
19
+ readonly pattern: "-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----";
20
+ readonly name: "Private key";
21
+ }, {
22
+ readonly pattern: "glpat-[a-zA-Z0-9_-]{20,}";
23
+ readonly name: "GitLab PAT";
24
+ }, {
25
+ readonly pattern: "gho_[a-zA-Z0-9]{36,}";
26
+ readonly name: "GitHub OAuth token";
27
+ }, {
28
+ readonly pattern: "github_pat_[a-zA-Z0-9_]{22,}";
29
+ readonly name: "Fine-grained GitHub PAT";
30
+ }, {
31
+ readonly pattern: "eyJ[a-zA-Z0-9_-]{20,}\\.[a-zA-Z0-9_-]{20,}\\.[a-zA-Z0-9_-]{10,}";
32
+ readonly name: "JWT";
33
+ }];
34
+ /** Return the pattern names that match, in the shell library's declared order. */
35
+ export declare function matchingSecretPatterns(value: string): string[];
36
+ /** Print only pattern names and paths; never include matched file content. */
37
+ export declare function scanSecretFiles(files: readonly string[]): {
38
+ code: 0 | 1;
39
+ hits: string[];
40
+ };
41
+ //# sourceMappingURL=secret-patterns.d.ts.map
@@ -0,0 +1,33 @@
1
+ /** Shared secret detector patterns mirrored from the scaffold shell library. */
2
+ import * as fs from "node:fs";
3
+ export const SECRET_PATTERN_BOUNDARY = "(^|[^a-zA-Z0-9])";
4
+ export const SECRET_PATTERNS = [
5
+ { pattern: "sk-[a-zA-Z0-9._-]{20,}", name: "OpenAI/Stripe key" },
6
+ { pattern: "ghp_[a-zA-Z0-9]{36,}", name: "GitHub PAT" },
7
+ { pattern: "AKIA[0-9A-Z]{16}", name: "AWS access key" },
8
+ { pattern: "xox[bpsa]-[a-zA-Z0-9-]+", name: "Slack token" },
9
+ { pattern: "Bearer [a-zA-Z0-9._-]{20,}", name: "Bearer token" },
10
+ { pattern: "-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----", name: "Private key" },
11
+ { pattern: "glpat-[a-zA-Z0-9_-]{20,}", name: "GitLab PAT" },
12
+ { pattern: "gho_[a-zA-Z0-9]{36,}", name: "GitHub OAuth token" },
13
+ { pattern: "github_pat_[a-zA-Z0-9_]{22,}", name: "Fine-grained GitHub PAT" },
14
+ { pattern: "eyJ[a-zA-Z0-9_-]{20,}\\.[a-zA-Z0-9_-]{20,}\\.[a-zA-Z0-9_-]{10,}", name: "JWT" },
15
+ ];
16
+ /** Return the pattern names that match, in the shell library's declared order. */
17
+ export function matchingSecretPatterns(value) {
18
+ return SECRET_PATTERNS.flatMap(({ pattern, name }) => new RegExp(`${SECRET_PATTERN_BOUNDARY}${pattern}`).test(value) ? [name] : []);
19
+ }
20
+ /** Print only pattern names and paths; never include matched file content. */
21
+ export function scanSecretFiles(files) {
22
+ const hits = [];
23
+ for (const file of files) {
24
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile())
25
+ continue;
26
+ const contents = fs.readFileSync(file, "utf8");
27
+ for (const name of matchingSecretPatterns(contents)) {
28
+ hits.push(`secret-scan: pattern '${name}' matched in ${file}`);
29
+ }
30
+ }
31
+ return { code: hits.length > 0 ? 1 : 0, hits };
32
+ }
33
+ //# sourceMappingURL=secret-patterns.js.map
@@ -10,6 +10,8 @@
10
10
  * them instead of silently dropping them.
11
11
  */
12
12
  import type { MachineType } from "./machine-type.js";
13
+ import type { SyncOwner } from "./config.js";
14
+ import { type SyncRunnerProcess } from "./sync-runner.js";
13
15
  export declare const SERVICE_NAMES: readonly ["sync", "mesh", "heartbeat", "inbox", "bots", "remote-control", "codex-remote-control", "session-host", "lanes-reconcile", "search-index", "jobs-reconcile", "updater", "housekeeping"];
14
16
  export type ServiceName = (typeof SERVICE_NAMES)[number];
15
17
  export declare function isServiceName(value: unknown): value is ServiceName;
@@ -51,6 +53,8 @@ export interface UnitPlan {
51
53
  restart: "always" | "on-failure";
52
54
  intervalSec?: number;
53
55
  initialDelaySec?: number;
56
+ /** Kill and restart this child after silence on its liveness stream. */
57
+ heartbeatTimeoutMs?: number;
54
58
  command: UnitCommand | null;
55
59
  env: Record<string, string>;
56
60
  blocked?: UnitBlock;
@@ -75,11 +79,23 @@ export interface PlanContext {
75
79
  sessionHostRunnerPath: string | null;
76
80
  /** Environment retained from legacy outpost sync systemd drop-ins. */
77
81
  legacySyncEnv: Record<string, string>;
82
+ /** Keep the local sync runner stopped until the operator resumes it. */
83
+ syncPaused: boolean;
84
+ /** Persisted/effective owner selected for this machine. */
85
+ syncOwner: SyncOwner;
86
+ /** A legacy desktop app or sync unit is still managing sync. */
87
+ desktopAppManagesSync: boolean;
88
+ /** Full process list, or null when the daemon could not read it. */
89
+ syncRunnerProcesses: readonly SyncRunnerProcess[] | null;
90
+ /** PID recorded in the watch-owner status, when it is readable. */
91
+ syncRunnerOwnerPid: number | null;
92
+ /** Sync runner children already started by this daemon before their lease is written. */
93
+ daemonSyncRunnerPids?: ReadonlySet<number>;
94
+ /** The protocol-event heartbeat watchdog timeout for the sync runner. */
95
+ syncHeartbeatTimeoutMs: number;
78
96
  signedIn: boolean;
79
97
  /** ~/.hq-agent/kit.json exists. */
80
98
  kitConfigured: boolean;
81
- /** The HQ desktop app runs its own sync on this machine. */
82
- desktopAppManagesSync: boolean;
83
99
  bots: readonly string[];
84
100
  /** Name of a pre-daemon unit that still runs this unit id, or null. */
85
101
  legacyOwner: (unitId: string) => string | null;
@@ -9,6 +9,7 @@
9
9
  * with no entrypoint yet stay in the catalog as `unavailable` so status shows
10
10
  * them instead of silently dropping them.
11
11
  */
12
+ import { SYNC_RUNNER_OWNER, syncRunnerUnleasedBlockReason } from "./sync-runner.js";
12
13
  export const SERVICE_NAMES = [
13
14
  "sync",
14
15
  "mesh",
@@ -127,14 +128,29 @@ function planService(service, ctx) {
127
128
  command: {
128
129
  type: "node",
129
130
  script: ctx.syncRunnerPath,
130
- args: ["--companies", "--direction", "both", "--on-conflict", "keep", "--hq-root", ctx.hqRoot ?? "", "--watch", "--event-push"],
131
+ args: ["--companies", "--direction", "both", "--on-conflict", "keep", "--hq-root", ctx.hqRoot ?? "", "--watch", "--event-push", "--owner", SYNC_RUNNER_OWNER],
131
132
  },
132
133
  env: { ...ctx.legacySyncEnv },
134
+ heartbeatTimeoutMs: ctx.syncHeartbeatTimeoutMs,
133
135
  });
136
+ let ownerBlock;
137
+ if (ctx.machineType === "desktop" && ctx.syncOwner === "desktop-app" && ctx.desktopAppManagesSync) {
138
+ ownerBlock = "the desktop app is still managing sync (run `hq daemon sync take-ownership` after it stops its runner)";
139
+ }
140
+ else if (ctx.machineType === "desktop" &&
141
+ ctx.syncOwner === "daemon" &&
142
+ !ctx.syncPaused &&
143
+ ctx.hqRoot &&
144
+ ctx.signedIn) {
145
+ ownerBlock = ctx.syncRunnerProcesses === null
146
+ ? "could not check for an existing sync runner; waiting to avoid starting a duplicate"
147
+ : syncRunnerUnleasedBlockReason(ctx.hqRoot, ctx.syncRunnerProcesses, ctx.syncRunnerOwnerPid, ctx.platform, ctx.daemonSyncRunnerPids);
148
+ }
134
149
  unit.blocked = firstBlock([
135
- [ctx.desktopAppManagesSync, "the HQ desktop app is handling sync on this machine"],
150
+ [ctx.syncPaused, "sync is paused (run `hq daemon sync resume`)"],
136
151
  [!ctx.hqRoot, NO_HQ_ROOT],
137
152
  [!ctx.signedIn, NOT_SIGNED_IN],
153
+ [!!ownerBlock, ownerBlock ?? ""],
138
154
  ]);
139
155
  return [unit];
140
156
  }
@@ -12,11 +12,17 @@ export interface DaemonConfig {
12
12
  hqRoot?: string;
13
13
  /** Exact hq-cloud package version preferred by this daemon, including prereleases. */
14
14
  hqCloudVersion?: string;
15
+ /** Persisted machine-local sync pause. */
16
+ syncPaused?: boolean;
17
+ /** Explicit desktop handoff; absent values preserve the legacy per-machine owner. */
18
+ syncOwner?: SyncOwner;
15
19
  /** Services turned on here beyond the machine type's defaults. */
16
20
  enable: string[];
17
21
  /** Default services turned off here. */
18
22
  disable: string[];
19
23
  }
24
+ export type SyncOwner = "daemon" | "desktop-app";
25
+ export declare function effectiveSyncOwner(config: Pick<DaemonConfig, "syncOwner">, machineType: MachineType): SyncOwner;
20
26
  export declare function emptyDaemonConfig(): DaemonConfig;
21
27
  export declare function readDaemonConfig(file: string): DaemonConfig;
22
28
  export declare function writeDaemonConfig(file: string, config: DaemonConfig): void;
@@ -10,8 +10,11 @@ import { valid as validSemver } from "semver";
10
10
  import { isServiceName } from "./catalog.js";
11
11
  import { isMachineType } from "./machine-type.js";
12
12
  import { ensurePrivateDir } from "./paths.js";
13
+ export function effectiveSyncOwner(config, machineType) {
14
+ return config.syncOwner ?? (machineType === "desktop" ? "desktop-app" : "daemon");
15
+ }
13
16
  export function emptyDaemonConfig() {
14
- return { version: 1, enable: [], disable: [] };
17
+ return { version: 1, syncPaused: false, enable: [], disable: [] };
15
18
  }
16
19
  function stringList(value) {
17
20
  return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
@@ -36,9 +39,16 @@ export function readDaemonConfig(file) {
36
39
  if (!raw || typeof raw !== "object")
37
40
  throw new Error(`hq daemon config at ${file} is not a JSON object`);
38
41
  const obj = raw;
39
- const config = { version: 1, enable: stringList(obj.enable), disable: stringList(obj.disable) };
42
+ const config = {
43
+ version: 1,
44
+ syncPaused: obj.syncPaused === true,
45
+ enable: stringList(obj.enable),
46
+ disable: stringList(obj.disable),
47
+ };
40
48
  if (isMachineType(obj.machineType))
41
49
  config.machineType = obj.machineType;
50
+ if (obj.syncOwner === "daemon" || obj.syncOwner === "desktop-app")
51
+ config.syncOwner = obj.syncOwner;
42
52
  if (typeof obj.hqRoot === "string" && obj.hqRoot.trim())
43
53
  config.hqRoot = obj.hqRoot;
44
54
  if (typeof obj.hqCloudVersion === "string" && validSemver(obj.hqCloudVersion) === obj.hqCloudVersion) {
@@ -56,6 +66,9 @@ export function writeDaemonConfig(file, config) {
56
66
  ordered.hqRoot = config.hqRoot;
57
67
  if (config.hqCloudVersion)
58
68
  ordered.hqCloudVersion = config.hqCloudVersion;
69
+ ordered.syncPaused = config.syncPaused === true;
70
+ if (config.syncOwner)
71
+ ordered.syncOwner = config.syncOwner;
59
72
  ordered.enable = config.enable;
60
73
  ordered.disable = config.disable;
61
74
  fs.writeFileSync(tmp, `${JSON.stringify(ordered, null, 2)}\n`, { mode: 0o600 });
@@ -15,6 +15,10 @@ export type ControlRequest = {
15
15
  action: "stop";
16
16
  unit: string;
17
17
  }
18
+ /** Re-read persisted configuration and apply any changed blocks immediately. */
19
+ | {
20
+ action: "reconcile";
21
+ }
18
22
  /** Restart the whole daemon, for example after the updater installed a new CLI. */
19
23
  | {
20
24
  action: "restart-daemon";
@@ -19,6 +19,8 @@ function isControlRequest(value) {
19
19
  return typeof v.unit === "string";
20
20
  if (v.action === "restart-daemon")
21
21
  return true;
22
+ if (v.action === "reconcile")
23
+ return true;
22
24
  return false;
23
25
  }
24
26
  // Orders requests written by one process within the same millisecond; the
@@ -17,7 +17,7 @@ import { agentKitPaths } from "../agent-kit/paths.js";
17
17
  import { readBotConfig } from "../bot/config.js";
18
18
  import { botsRoot } from "../bot/paths.js";
19
19
  import { CLAUDE_RUNNER_SCRIPT, CODEX_RUNNER_SCRIPT, enabledServices, planUnits } from "./catalog.js";
20
- import { readDaemonConfig } from "./config.js";
20
+ import { effectiveSyncOwner, readDaemonConfig } from "./config.js";
21
21
  import { SUPERVISOR_HANDOFF_FLAG, readDaemonFlag } from "./flag.js";
22
22
  import { findLegacyUnits, legacyOwnerLookup } from "./legacy.js";
23
23
  import { readLegacySyncEnv } from "./legacy-sync-env.js";
@@ -29,6 +29,7 @@ import { takeControlRequests } from "./requests.js";
29
29
  import { currentHqCloud, hqCloudRunners } from "./hq-cloud.js";
30
30
  import { runnerSessionName } from "./remote-control.js";
31
31
  import { adoptChild, createChildTracker, finishSuccession, handoffEligible, readProcStart, stopOrphans, successionPaths, systemdKillMode, takeSuccession, writeHandoff, } from "./succession.js";
32
+ import { listSyncRunnerProcesses, readWatchRunnerOwnerStatus, syncHeartbeatTimeoutMs, syncRunnerNodeOptions, watchRunnerOwnerStatusPath, } from "./sync-runner.js";
32
33
  import { STOP_ESCALATION_MS, createSupervisor, } from "./supervisor.js";
33
34
  import { userToolDirs } from "./tools.js";
34
35
  export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
@@ -218,7 +219,10 @@ function makeLogger(paths, foreground) {
218
219
  * an on-failure unit down. The daemon's updater handles CLI updates.
219
220
  */
220
221
  export function unitProcessEnv(base, unit) {
221
- return { ...base, ...unit.env, HQ_DAEMON_UNIT: unit.id, HQ_NO_UPDATE_CHECK: "1" };
222
+ const env = { ...base, ...unit.env, HQ_DAEMON_UNIT: unit.id, HQ_NO_UPDATE_CHECK: "1" };
223
+ if (unit.service === "sync")
224
+ env.NODE_OPTIONS = syncRunnerNodeOptions(env);
225
+ return env;
222
226
  }
223
227
  export async function runDaemon(opts) {
224
228
  const env = opts.env ?? process.env;
@@ -289,10 +293,11 @@ export async function runDaemon(opts) {
289
293
  return legacyCache.units;
290
294
  };
291
295
  let warnedMissingPin = null;
296
+ const daemonSyncRunnerPids = new Set();
292
297
  const plan = () => {
293
298
  const config = readDaemonConfig(paths.configPath);
294
299
  const legacy = legacyUnits();
295
- const configured = config.hqRoot ?? env.HQ_ROOT?.trim() ?? undefined;
300
+ const configured = config.hqRoot ?? (env.HQ_ROOT?.trim() || undefined);
296
301
  const hqRoot = configured && fs.existsSync(configured) ? configured : undefined;
297
302
  // Re-picked every reconcile, so a newer hq-cloud from the updater is used as soon as it lands.
298
303
  const hqCloud = currentHqCloud(paths.root);
@@ -304,7 +309,33 @@ export async function runDaemon(opts) {
304
309
  }
305
310
  }
306
311
  const runners = hqCloud ? hqCloudRunners(hqCloud.root) : { syncRunner: null, heartbeat: null, sessionHost: null };
307
- return planUnits(enabledServices(machineType, config), {
312
+ const services = enabledServices(machineType, config);
313
+ const isSignedIn = signedIn(env, home, log);
314
+ const syncOwner = effectiveSyncOwner(config, machineType);
315
+ let syncRunnerProcesses = [];
316
+ let syncRunnerOwnerPid = null;
317
+ if (machineType === "desktop" &&
318
+ syncOwner === "daemon" &&
319
+ services.includes("sync") &&
320
+ !config.syncPaused &&
321
+ hqRoot &&
322
+ isSignedIn) {
323
+ const processList = listSyncRunnerProcesses(platform);
324
+ if (!processList.ok) {
325
+ syncRunnerProcesses = null;
326
+ log("warn", `${processList.error}; sync will wait before taking ownership`);
327
+ }
328
+ else {
329
+ syncRunnerProcesses = processList.processes;
330
+ try {
331
+ syncRunnerOwnerPid = readWatchRunnerOwnerStatus(watchRunnerOwnerStatusPath(hqRoot, home, env))?.pid ?? null;
332
+ }
333
+ catch (error) {
334
+ log("warn", `could not verify the sync watch-owner status: ${error instanceof Error ? error.message : String(error)}`);
335
+ }
336
+ }
337
+ }
338
+ return planUnits(services, {
308
339
  machineType,
309
340
  platform,
310
341
  home,
@@ -313,14 +344,20 @@ export async function runDaemon(opts) {
313
344
  heartbeatRunnerPath: runners.heartbeat,
314
345
  sessionHostRunnerPath: runners.sessionHost,
315
346
  legacySyncEnv: readLegacySyncEnv(platform),
316
- signedIn: signedIn(env, home, log),
347
+ signedIn: isSignedIn,
317
348
  kitConfigured: fs.existsSync(agentKitPaths(home, env).kitConfigPath),
349
+ syncPaused: config.syncPaused === true,
350
+ syncOwner,
318
351
  desktopAppManagesSync: desktopAppManagesSync({
319
352
  machineType,
320
353
  host: opts.host,
321
354
  legacy,
322
355
  appRunning: () => desktopAppRunning(platform),
323
356
  }),
357
+ syncRunnerProcesses,
358
+ syncRunnerOwnerPid,
359
+ daemonSyncRunnerPids,
360
+ syncHeartbeatTimeoutMs: syncHeartbeatTimeoutMs(env),
324
361
  bots: listLocalBots(),
325
362
  legacyOwner: legacyOwnerLookup(legacy),
326
363
  cliSelfUpdatable: updatable,
@@ -342,9 +379,37 @@ export async function runDaemon(opts) {
342
379
  : command.type === "node"
343
380
  ? [opts.nodeBinary, [command.script, ...command.args]]
344
381
  : [command.file, command.args];
345
- const fd = openPrivateAppend(paths.unitLogPath(unit.id));
382
+ const logPath = paths.unitLogPath(unit.id);
383
+ const cwd = unit.env.HQ_ROOT && fs.existsSync(unit.env.HQ_ROOT) ? unit.env.HQ_ROOT : home;
384
+ if (unit.heartbeatTimeoutMs) {
385
+ // The supervisor reads this unit's protocol heartbeats from stdout, so its
386
+ // output goes through the daemon. A piped child cannot outlive the daemon,
387
+ // so it is never handed over: a handoff stops it and the next daemon
388
+ // starts it again.
389
+ const logFd = openPrivateAppend(logPath);
390
+ const logStream = fs.createWriteStream(logPath, { fd: logFd, autoClose: true });
391
+ logStream.on("error", (err) => log("error", `could not write ${unit.id} log ${logPath}: ${err.message}`));
392
+ const child = spawn(file, args, {
393
+ cwd,
394
+ env: unitProcessEnv(env, unit),
395
+ stdio: ["ignore", "pipe", "pipe"],
396
+ windowsHide: true,
397
+ });
398
+ spawned.add(child);
399
+ if (unit.service === "sync" && child.pid !== undefined)
400
+ daemonSyncRunnerPids.add(child.pid);
401
+ child.stdout?.pipe(logStream, { end: false });
402
+ child.stderr?.pipe(logStream, { end: false });
403
+ child.on("close", () => {
404
+ spawned.delete(child);
405
+ if (child.pid !== undefined)
406
+ daemonSyncRunnerPids.delete(child.pid);
407
+ logStream.end();
408
+ });
409
+ return child;
410
+ }
411
+ const fd = openPrivateAppend(logPath);
346
412
  try {
347
- const cwd = unit.env.HQ_ROOT && fs.existsSync(unit.env.HQ_ROOT) ? unit.env.HQ_ROOT : home;
348
413
  const child = spawn(file, args, {
349
414
  cwd,
350
415
  env: unitProcessEnv(env, unit),
@@ -352,7 +417,13 @@ export async function runDaemon(opts) {
352
417
  windowsHide: true,
353
418
  });
354
419
  spawned.add(child);
355
- child.once("exit", () => spawned.delete(child));
420
+ if (unit.service === "sync" && child.pid !== undefined)
421
+ daemonSyncRunnerPids.add(child.pid);
422
+ child.once("exit", () => {
423
+ spawned.delete(child);
424
+ if (child.pid !== undefined)
425
+ daemonSyncRunnerPids.delete(child.pid);
426
+ });
356
427
  // Only long-running services are handed over; a scheduled run left behind finishes on its own.
357
428
  if (unit.kind === "long-running")
358
429
  tracker?.track(unit.id, child);
@@ -382,6 +453,21 @@ export async function runDaemon(opts) {
382
453
  restartDaemon: () => restartDaemon(),
383
454
  writeState: (state) => writeJsonAtomic(paths.statePath, state),
384
455
  log,
456
+ syncOwnerBlockReason: () => {
457
+ const config = readDaemonConfig(paths.configPath);
458
+ const hqRoot = config.hqRoot ?? env.HQ_ROOT?.trim();
459
+ if (!hqRoot)
460
+ return undefined;
461
+ const statusPath = watchRunnerOwnerStatusPath(hqRoot, home, env);
462
+ try {
463
+ const owner = readWatchRunnerOwnerStatus(statusPath);
464
+ return owner ? `sync owned by ${owner.owner} pid ${owner.pid}` : undefined;
465
+ }
466
+ catch (err) {
467
+ log("error", `could not read sync owner status at ${statusPath}: ${err instanceof Error ? err.message : String(err)}`);
468
+ return undefined;
469
+ }
470
+ },
385
471
  cliVersion: CLI_VERSION,
386
472
  adopt,
387
473
  });