@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.
- package/CHANGELOG.md +76 -0
- package/assets/scaffold/core/scripts/hq-delegate-pickup.sh +99 -0
- package/assets/scaffold/core/scripts/hq-delegate-repo.sh +158 -0
- package/assets/scaffold/core/scripts/hq-delegate-resolve.sh +145 -0
- package/assets/scaffold/core/scripts/hq-delegate-secrets.sh +129 -0
- package/assets/scaffold/core/scripts/hq-delegate-transfer.sh +175 -0
- package/assets/scaffold/core/scripts/lib/secret-patterns.sh +58 -0
- package/dist/command-catalog.generated.d.ts +239 -1
- package/dist/command-catalog.generated.js +302 -1
- package/dist/commands/core-delegate.d.ts +6 -0
- package/dist/commands/core-delegate.js +987 -0
- package/dist/commands/core.js +10 -0
- package/dist/commands/daemon.d.ts +3 -0
- package/dist/commands/daemon.js +173 -1
- package/dist/commands/dm.d.ts +2 -0
- package/dist/commands/dm.js +11 -4
- package/dist/commands/lanes-accounts.js +44 -2
- package/dist/commands/lanes-message.js +69 -12
- package/dist/commands/lanes-resume.js +2 -0
- package/dist/commands/lanes-shared.d.ts +3 -0
- package/dist/commands/lanes-shared.js +12 -2
- package/dist/commands/lanes.d.ts +5 -1
- package/dist/commands/lanes.js +399 -47
- package/dist/commands/secrets.d.ts +16 -1
- package/dist/commands/secrets.js +77 -48
- package/dist/commands/sync-mode.d.ts +11 -0
- package/dist/commands/sync-mode.js +30 -0
- package/dist/fast-scaffold.d.ts +17 -0
- package/dist/fast-scaffold.js +91 -0
- package/dist/index.js +17 -1
- package/dist/lanes-drain-hook.js +154 -7
- package/dist/lib/agent-kit/run/inbox.js +3 -2
- package/dist/lib/checkpoint-ontology/apply.d.ts +6 -0
- package/dist/lib/checkpoint-ontology/apply.js +60 -3
- package/dist/lib/checkpoint-ontology/plan.d.ts +13 -0
- package/dist/lib/checkpoint-ontology/plan.js +13 -0
- package/dist/lib/checkpoint-ontology/tree.d.ts +2 -0
- package/dist/lib/checkpoint-ontology/tree.js +34 -0
- package/dist/lib/core-utils/jq-json-serializer.d.ts +5 -0
- package/dist/lib/core-utils/jq-json-serializer.js +10 -0
- package/dist/lib/core-utils/secret-patterns.d.ts +41 -0
- package/dist/lib/core-utils/secret-patterns.js +33 -0
- package/dist/lib/daemon/catalog.d.ts +18 -2
- package/dist/lib/daemon/catalog.js +18 -2
- package/dist/lib/daemon/config.d.ts +6 -0
- package/dist/lib/daemon/config.js +15 -2
- package/dist/lib/daemon/requests.d.ts +4 -0
- package/dist/lib/daemon/requests.js +2 -0
- package/dist/lib/daemon/run.js +94 -8
- package/dist/lib/daemon/supervisor.d.ts +8 -0
- package/dist/lib/daemon/supervisor.js +101 -1
- package/dist/lib/daemon/sync-runner.d.ts +58 -0
- package/dist/lib/daemon/sync-runner.js +277 -0
- package/dist/lib/doctor/__testing__/run-doctor.d.ts +8 -0
- package/dist/lib/doctor/__testing__/run-doctor.js +52 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/claude-balance.d.ts +6 -1
- package/dist/lib/lanes/claude-balance.js +46 -8
- package/dist/lib/lanes/config.js +15 -0
- package/dist/lib/lanes/hire-grant-dispatch.d.ts +2 -0
- package/dist/lib/lanes/hire-grant-dispatch.js +41 -0
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/keep-alive.d.ts +3 -0
- package/dist/lib/lanes/keep-alive.js +3 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +4 -0
- package/dist/lib/lanes/outpost-dispatch.js +11 -2
- package/dist/lib/lanes/spawn.js +17 -10
- package/dist/lib/lanes/store.js +45 -1
- package/dist/lib/lanes/types.d.ts +30 -1
- package/dist/lib/lanes/types.js +2 -2
- package/dist/lib/lanes/watcher.js +3 -2
- package/dist/secrets-env-fast-path.d.ts +5 -0
- package/dist/secrets-env-fast-path.js +16 -0
- package/dist/secrets-env-fast-route.d.ts +18 -0
- package/dist/secrets-env-fast-route.js +54 -0
- 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(
|
|
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({
|
|
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.
|
|
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 = {
|
|
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
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -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
|
-
|
|
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()
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
});
|