@indigoai-us/hq-cli 5.126.0 → 5.128.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 (78) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
  3. package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
  4. package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
  5. package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
  6. package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
  7. package/dist/command-catalog.generated.d.ts +245 -11
  8. package/dist/command-catalog.generated.js +323 -11
  9. package/dist/command-registration-plan.d.ts +1 -0
  10. package/dist/command-registration-plan.js +11 -2
  11. package/dist/commands/agents.d.ts +12 -0
  12. package/dist/commands/agents.js +46 -2
  13. package/dist/commands/lanes.d.ts +55 -16
  14. package/dist/commands/lanes.js +1187 -250
  15. package/dist/commands/mesh.js +254 -19
  16. package/dist/lib/core-utils/sentry-report.d.ts +2 -0
  17. package/dist/lib/core-utils/sentry-report.js +47 -7
  18. package/dist/lib/lanes/accounts.d.ts +67 -7
  19. package/dist/lib/lanes/accounts.js +233 -43
  20. package/dist/lib/lanes/compaction.d.ts +96 -0
  21. package/dist/lib/lanes/compaction.js +197 -0
  22. package/dist/lib/lanes/config.d.ts +15 -0
  23. package/dist/lib/lanes/config.js +65 -8
  24. package/dist/lib/lanes/delivery.d.ts +151 -0
  25. package/dist/lib/lanes/delivery.js +354 -0
  26. package/dist/lib/lanes/dropbox.d.ts +96 -0
  27. package/dist/lib/lanes/dropbox.js +408 -0
  28. package/dist/lib/lanes/effort.d.ts +50 -0
  29. package/dist/lib/lanes/effort.js +109 -0
  30. package/dist/lib/lanes/errors.d.ts +2 -2
  31. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  32. package/dist/lib/lanes/hooks-install.js +326 -0
  33. package/dist/lib/lanes/hq-root.d.ts +19 -0
  34. package/dist/lib/lanes/hq-root.js +28 -0
  35. package/dist/lib/lanes/index.d.ts +35 -5
  36. package/dist/lib/lanes/index.js +19 -4
  37. package/dist/lib/lanes/liveness.d.ts +7 -1
  38. package/dist/lib/lanes/liveness.js +64 -5
  39. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  40. package/dist/lib/lanes/machine-identity.js +92 -0
  41. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  42. package/dist/lib/lanes/mesh-events.js +275 -0
  43. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  44. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  45. package/dist/lib/lanes/questions.d.ts +75 -0
  46. package/dist/lib/lanes/questions.js +243 -0
  47. package/dist/lib/lanes/quota.d.ts +95 -0
  48. package/dist/lib/lanes/quota.js +320 -0
  49. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  50. package/dist/lib/lanes/reconcile-install.js +221 -0
  51. package/dist/lib/lanes/reconcile.d.ts +102 -0
  52. package/dist/lib/lanes/reconcile.js +985 -0
  53. package/dist/lib/lanes/registry.d.ts +18 -5
  54. package/dist/lib/lanes/registry.js +293 -19
  55. package/dist/lib/lanes/spawn.d.ts +274 -0
  56. package/dist/lib/lanes/spawn.js +1363 -0
  57. package/dist/lib/lanes/store.d.ts +7 -3
  58. package/dist/lib/lanes/store.js +150 -32
  59. package/dist/lib/lanes/types.d.ts +346 -4
  60. package/dist/lib/lanes/types.js +138 -5
  61. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  62. package/dist/lib/lanes/watcher-main.js +22 -0
  63. package/dist/lib/lanes/watcher.d.ts +216 -0
  64. package/dist/lib/lanes/watcher.js +859 -0
  65. package/dist/lib/mesh/api.d.ts +71 -7
  66. package/dist/lib/mesh/api.js +167 -28
  67. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  68. package/dist/lib/mesh/live/backfill-held.js +66 -21
  69. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  70. package/dist/lib/mesh/live/daemon/run.js +111 -54
  71. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  72. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  73. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  74. package/dist/lib/scan-packages/index.d.ts +2 -0
  75. package/dist/lib/scan-packages/index.js +1 -1
  76. package/dist/lib/work-context/state.d.ts +12 -0
  77. package/dist/lib/work-context/state.js +131 -3
  78. package/package.json +3 -1
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Machine-written provider quota, keyed by account and model.
3
+ *
4
+ * `enabled` and `disabled_reason` live in personal/lanes/config.json and are
5
+ * the operator's hand-written switch. `quota_reset_at` and `needs_credits`
6
+ * are written here, in personal/lanes/quota.json, from the provider's own
7
+ * 429. They are separate fields because a quota hold expires on its own:
8
+ * copying it into `disabled_reason` or flipping `enabled` would erase the
9
+ * operator's reason, and would leave the account switched off after the
10
+ * provider reset. The balancer reads this file and does not rewrite the
11
+ * operator fields.
12
+ *
13
+ * A weekly-limit 429 names a reset and applies to every model on that
14
+ * account (measured: account b, every model, until the reset). A credits
15
+ * 429 names one model and no reset (measured: account d refuses
16
+ * claude-fable-5-1 and still runs claude-opus-5). The record is therefore
17
+ * per (account, model), plus an account-wide reset when the text is a
18
+ * usage limit rather than a switch-model refusal.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ export const QUOTA_STATE_REL = path.join("personal", "lanes", "quota.json");
23
+ export const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
24
+ const MONTHS = {
25
+ jan: 0,
26
+ feb: 1,
27
+ mar: 2,
28
+ apr: 3,
29
+ may: 4,
30
+ jun: 5,
31
+ jul: 6,
32
+ aug: 7,
33
+ sep: 8,
34
+ oct: 9,
35
+ nov: 10,
36
+ dec: 11,
37
+ };
38
+ function emptyState() {
39
+ return { schema_version: QUOTA_SCHEMA_VERSION, providers: {} };
40
+ }
41
+ function isPlainObject(value) {
42
+ return value !== null && typeof value === "object" && !Array.isArray(value);
43
+ }
44
+ export function quotaStatePath(hqRoot) {
45
+ return path.join(hqRoot, QUOTA_STATE_REL);
46
+ }
47
+ export function loadQuotaState(hqRoot) {
48
+ const filePath = quotaStatePath(hqRoot);
49
+ let text;
50
+ try {
51
+ text = fs.readFileSync(filePath, "utf8");
52
+ }
53
+ catch (err) {
54
+ const code = err.code;
55
+ if (code === "ENOENT")
56
+ return emptyState();
57
+ throw err;
58
+ }
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(text);
62
+ }
63
+ catch {
64
+ return emptyState();
65
+ }
66
+ if (!isPlainObject(parsed))
67
+ return emptyState();
68
+ const providers = {};
69
+ const rawProviders = isPlainObject(parsed.providers) ? parsed.providers : {};
70
+ for (const provider of ["claude", "codex", "grok"]) {
71
+ const rawAccounts = rawProviders[provider];
72
+ if (!isPlainObject(rawAccounts))
73
+ continue;
74
+ const accounts = {};
75
+ for (const [id, raw] of Object.entries(rawAccounts)) {
76
+ if (!isPlainObject(raw))
77
+ continue;
78
+ const models = {};
79
+ if (isPlainObject(raw.models)) {
80
+ for (const [model, entry] of Object.entries(raw.models)) {
81
+ if (!isPlainObject(entry))
82
+ continue;
83
+ const quota = {};
84
+ if (typeof entry.quota_reset_at === "string" && entry.quota_reset_at.length > 0) {
85
+ quota.quota_reset_at = entry.quota_reset_at;
86
+ }
87
+ if (entry.needs_credits === true)
88
+ quota.needs_credits = true;
89
+ if (typeof entry.marker === "string" && entry.marker.length > 0) {
90
+ quota.marker = entry.marker;
91
+ }
92
+ if (quota.quota_reset_at || quota.needs_credits || quota.marker) {
93
+ models[model] = quota;
94
+ }
95
+ }
96
+ }
97
+ const account = { models };
98
+ if (typeof raw.all_models_quota_reset_at === "string" &&
99
+ raw.all_models_quota_reset_at.length > 0) {
100
+ account.all_models_quota_reset_at = raw.all_models_quota_reset_at;
101
+ }
102
+ accounts[id] = account;
103
+ }
104
+ providers[provider] = accounts;
105
+ }
106
+ return { schema_version: QUOTA_SCHEMA_VERSION, providers };
107
+ }
108
+ function writeQuotaState(hqRoot, state) {
109
+ const filePath = quotaStatePath(hqRoot);
110
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
111
+ const tmp = `${filePath}.${process.pid}.tmp`;
112
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
113
+ fs.renameSync(tmp, filePath);
114
+ }
115
+ /**
116
+ * Parse "resets Sep 24, 2am (UTC)" into an ISO timestamp. The year is the
117
+ * current UTC year, rolled forward when that instant is already past.
118
+ * Returns null when the text names no reset.
119
+ */
120
+ export function parseProviderResetTime(text, now) {
121
+ const match = /resets\s+([A-Za-z]+)\s+(\d{1,2})(?:,\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm))?(?:\s*\(UTC\))?/i.exec(text);
122
+ if (!match)
123
+ return null;
124
+ const month = MONTHS[(match[1] ?? "").slice(0, 3).toLowerCase()];
125
+ if (month === undefined)
126
+ return null;
127
+ const day = Number(match[2]);
128
+ if (!Number.isInteger(day) || day < 1 || day > 31)
129
+ return null;
130
+ let hour = 0;
131
+ let minute = 0;
132
+ if (match[3] !== undefined) {
133
+ hour = Number(match[3]);
134
+ minute = match[4] !== undefined ? Number(match[4]) : 0;
135
+ const ap = (match[5] ?? "").toLowerCase();
136
+ if (ap === "pm" && hour < 12)
137
+ hour += 12;
138
+ if (ap === "am" && hour === 12)
139
+ hour = 0;
140
+ if (hour > 23 || minute > 59)
141
+ return null;
142
+ }
143
+ let year = now.getUTCFullYear();
144
+ let ts = Date.UTC(year, month, day, hour, minute, 0, 0);
145
+ if (ts < now.getTime() - 36 * 60 * 60 * 1000) {
146
+ year += 1;
147
+ ts = Date.UTC(year, month, day, hour, minute, 0, 0);
148
+ }
149
+ return new Date(ts).toISOString();
150
+ }
151
+ function isCreditsRefusal(text) {
152
+ return /requires usage credits/i.test(text) || /switch to another model/i.test(text);
153
+ }
154
+ function isUsageLimitShape(text) {
155
+ return /weekly limit|usage limit|rate limit|quota|usage credits|hit your .+ limit/i.test(text);
156
+ }
157
+ /**
158
+ * Read only `api_error_status`, `is_error`, and `result` off the last
159
+ * `type: "result"` object. Nothing else from the payload is returned, and
160
+ * this function does not log.
161
+ */
162
+ export function quotaFieldsFromOutput(text) {
163
+ const lines = text.split("\n");
164
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
165
+ const line = (lines[i] ?? "").trim();
166
+ if (!line.startsWith("{"))
167
+ continue;
168
+ let parsed;
169
+ try {
170
+ parsed = JSON.parse(line);
171
+ }
172
+ catch {
173
+ continue;
174
+ }
175
+ if (!isPlainObject(parsed) || parsed.type !== "result")
176
+ continue;
177
+ return {
178
+ api_error_status: parsed.api_error_status,
179
+ is_error: parsed.is_error,
180
+ result: parsed.result,
181
+ };
182
+ }
183
+ return null;
184
+ }
185
+ export function quotaSignalFromFields(fields, model, now) {
186
+ const text = typeof fields.result === "string" ? fields.result : "";
187
+ const is429 = fields.api_error_status === 429;
188
+ const usageShaped = fields.is_error === true && isUsageLimitShape(text);
189
+ if (!is429 && !usageShaped)
190
+ return null;
191
+ if (isCreditsRefusal(text) && !/resets\s+/i.test(text)) {
192
+ const marker = model ? `needs credits for ${model}` : "needs credits";
193
+ return {
194
+ reason: "account_model_unavailable",
195
+ model,
196
+ needs_credits: true,
197
+ marker,
198
+ };
199
+ }
200
+ const quota_reset_at = text.length > 0 ? parseProviderResetTime(text, now) ?? undefined : undefined;
201
+ return {
202
+ reason: "account_quota_exhausted",
203
+ quota_reset_at,
204
+ model,
205
+ account_wide: true,
206
+ };
207
+ }
208
+ export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
209
+ const outPath = path.join(runDir, "logs", "worker.out");
210
+ let text;
211
+ try {
212
+ const bytes = fs.readFileSync(outPath);
213
+ text = bytes.subarray(Math.min(workerOutOffset, bytes.length)).toString("utf8");
214
+ }
215
+ catch (err) {
216
+ const code = err.code;
217
+ if (code === "ENOENT")
218
+ return null;
219
+ throw err;
220
+ }
221
+ const fields = quotaFieldsFromOutput(text);
222
+ if (!fields)
223
+ return null;
224
+ return quotaSignalFromFields(fields, model, now);
225
+ }
226
+ export function recordAccountQuota(hqRoot, provider, accountId, signal) {
227
+ const state = loadQuotaState(hqRoot);
228
+ const accounts = state.providers[provider] ?? {};
229
+ const current = accounts[accountId] ?? { models: {} };
230
+ const models = { ...current.models };
231
+ if (signal.model) {
232
+ const prior = models[signal.model] ?? {};
233
+ const next = { ...prior };
234
+ if (signal.needs_credits) {
235
+ next.needs_credits = true;
236
+ next.marker = signal.marker ?? `needs credits for ${signal.model}`;
237
+ delete next.quota_reset_at;
238
+ }
239
+ if (signal.quota_reset_at && !signal.needs_credits) {
240
+ next.quota_reset_at = signal.quota_reset_at;
241
+ }
242
+ models[signal.model] = next;
243
+ }
244
+ const account = { ...current, models };
245
+ if (signal.account_wide && signal.quota_reset_at) {
246
+ account.all_models_quota_reset_at = signal.quota_reset_at;
247
+ }
248
+ accounts[accountId] = account;
249
+ state.providers[provider] = accounts;
250
+ writeQuotaState(hqRoot, state);
251
+ return state;
252
+ }
253
+ function resetStillAhead(iso, now) {
254
+ if (!iso)
255
+ return false;
256
+ const ts = Date.parse(iso);
257
+ return Number.isFinite(ts) && ts > now.getTime();
258
+ }
259
+ /**
260
+ * Skip an account only for the models it cannot run. An account-wide reset
261
+ * blocks every model. A needs-credits marker blocks only that model, with
262
+ * no expiry. A past reset does not block.
263
+ */
264
+ export function quotaBlockFor(state, provider, accountId, model, now) {
265
+ const account = state.providers[provider]?.[accountId];
266
+ if (!account)
267
+ return { blocked: false };
268
+ if (resetStillAhead(account.all_models_quota_reset_at, now)) {
269
+ return {
270
+ blocked: true,
271
+ reason: "account_quota_exhausted",
272
+ quota_reset_at: account.all_models_quota_reset_at,
273
+ model,
274
+ };
275
+ }
276
+ if (!model)
277
+ return { blocked: false };
278
+ const entry = account.models[model];
279
+ if (!entry)
280
+ return { blocked: false };
281
+ if (entry.needs_credits) {
282
+ return {
283
+ blocked: true,
284
+ reason: "account_model_unavailable",
285
+ marker: entry.marker ?? `needs credits for ${model}`,
286
+ model,
287
+ };
288
+ }
289
+ if (resetStillAhead(entry.quota_reset_at, now)) {
290
+ return {
291
+ blocked: true,
292
+ reason: "account_quota_exhausted",
293
+ quota_reset_at: entry.quota_reset_at,
294
+ model,
295
+ };
296
+ }
297
+ return { blocked: false };
298
+ }
299
+ export function accountQuotaView(state, provider, accountId) {
300
+ const account = state.providers[provider]?.[accountId];
301
+ if (!account)
302
+ return { quota_reset_at: null, model_quota: [] };
303
+ const model_quota = Object.entries(account.models)
304
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
305
+ .map(([model, entry]) => {
306
+ const row = { model };
307
+ if (entry.quota_reset_at)
308
+ row.quota_reset_at = entry.quota_reset_at;
309
+ if (entry.needs_credits)
310
+ row.needs_credits = true;
311
+ if (entry.marker)
312
+ row.marker = entry.marker;
313
+ return row;
314
+ });
315
+ return {
316
+ quota_reset_at: account.all_models_quota_reset_at ?? null,
317
+ model_quota,
318
+ };
319
+ }
320
+ //# sourceMappingURL=quota.js.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * systemd user timer install for the lanes reconcile pass.
3
+ *
4
+ * Policy: hq-infra-watchdogs-systemd-not-session. The pass is a oneshot
5
+ * invoked by a timer, never a session-bound background job. Install writes
6
+ * unit files under a systemd user dir and is idempotent and reversible.
7
+ * Enabling the timer is optional — the CLI pass works without it.
8
+ */
9
+ export declare const RECONCILE_TIMER_INTERVAL = "1min";
10
+ export interface ProviderPathEnv {
11
+ /** PATH for the systemd unit: resolved provider dirs, systemd defaults, then the shell PATH. */
12
+ path: string;
13
+ /** Providers whose binary resolved on the installing shell's PATH. */
14
+ resolved: string[];
15
+ /** Providers whose binary did not resolve. Reported, never fatal on their own. */
16
+ unresolved: string[];
17
+ }
18
+ /**
19
+ * Resolve provider commands with the installing shell's PATH. The resulting
20
+ * directories are carried into the systemd unit, whose default PATH is too
21
+ * narrow for per-user provider installations.
22
+ *
23
+ * A default config enables one account per provider, so `providers` usually
24
+ * names every provider even on a box with one installed. A provider that does
25
+ * not resolve is reported in `unresolved`; the install only refuses when none
26
+ * of the requested providers resolves, because then the timer has nothing to
27
+ * run.
28
+ */
29
+ export declare function providerPathEnv(providers: readonly string[], pathEnv?: string | undefined): ProviderPathEnv;
30
+ export interface RunCommandResult {
31
+ status: number;
32
+ stdout?: string;
33
+ stderr?: string;
34
+ }
35
+ export type RunCommand = (command: string, args: string[]) => RunCommandResult;
36
+ export interface ReconcileInstallOpts {
37
+ systemdRoot: string;
38
+ hqRoot: string;
39
+ nodeBinary?: string;
40
+ hqBinary?: string;
41
+ pathEnv?: string;
42
+ home?: string;
43
+ enable?: boolean;
44
+ runCommand?: RunCommand;
45
+ }
46
+ export interface ReconcileInstallResult {
47
+ ok: true;
48
+ action: "reconcile.install" | "reconcile.uninstall";
49
+ systemd_root: string;
50
+ unit: string;
51
+ timer: string;
52
+ written?: boolean;
53
+ enabled?: boolean;
54
+ removed?: boolean;
55
+ }
56
+ export declare function renderReconcileService(opts: {
57
+ nodeBinary: string;
58
+ hqBinary: string;
59
+ hqRoot: string;
60
+ home?: string;
61
+ pathEnv?: string;
62
+ }): string;
63
+ export declare function renderReconcileTimer(): string;
64
+ export declare function defaultSystemdUserDir(home?: string): string;
65
+ export declare function installReconcileTimer(opts: ReconcileInstallOpts): ReconcileInstallResult;
66
+ export declare function uninstallReconcileTimer(opts: Pick<ReconcileInstallOpts, "systemdRoot" | "runCommand"> & {
67
+ disable?: boolean;
68
+ }): ReconcileInstallResult;
69
+ //# sourceMappingURL=reconcile-install.d.ts.map
@@ -0,0 +1,221 @@
1
+ /**
2
+ * systemd user timer install for the lanes reconcile pass.
3
+ *
4
+ * Policy: hq-infra-watchdogs-systemd-not-session. The pass is a oneshot
5
+ * invoked by a timer, never a session-bound background job. Install writes
6
+ * unit files under a systemd user dir and is idempotent and reversible.
7
+ * Enabling the timer is optional — the CLI pass works without it.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { LanesError } from "./errors.js";
13
+ import { RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME } from "./reconcile.js";
14
+ import { resolveHqBinary, resolveNodeBinary, } from "../mesh/live/daemon/install.js";
15
+ import { SYSTEMD_PATH_ENV } from "../service-manager/systemd.js";
16
+ export const RECONCILE_TIMER_INTERVAL = "1min";
17
+ /**
18
+ * Resolve provider commands with the installing shell's PATH. The resulting
19
+ * directories are carried into the systemd unit, whose default PATH is too
20
+ * narrow for per-user provider installations.
21
+ *
22
+ * A default config enables one account per provider, so `providers` usually
23
+ * names every provider even on a box with one installed. A provider that does
24
+ * not resolve is reported in `unresolved`; the install only refuses when none
25
+ * of the requested providers resolves, because then the timer has nothing to
26
+ * run.
27
+ */
28
+ export function providerPathEnv(providers, pathEnv = process.env.PATH) {
29
+ const searched = pathEnv ?? "";
30
+ const directories = searched.split(path.delimiter).filter(Boolean);
31
+ const resolvedDirs = [];
32
+ const resolved = [];
33
+ const unresolved = [];
34
+ for (const provider of providers) {
35
+ const found = directories.find((directory) => {
36
+ try {
37
+ fs.accessSync(path.join(directory, provider), fs.constants.X_OK);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ });
44
+ if (!found) {
45
+ unresolved.push(provider);
46
+ continue;
47
+ }
48
+ resolved.push(provider);
49
+ // Keep the real binary's directory, not only a wrapper directory that
50
+ // happened to be on the installing shell's PATH. Systemd must be able to
51
+ // invoke the resolved provider after it starts with its own environment.
52
+ resolvedDirs.push(path.dirname(fs.realpathSync(path.join(found, provider))));
53
+ }
54
+ if (providers.length > 0 && resolved.length === 0) {
55
+ throw new LanesError("provider_binary_unresolved", `Cannot resolve any provider binary (${providers.join(", ")}) from PATH`, { providers: [...providers], searched_path: searched });
56
+ }
57
+ return {
58
+ path: [...new Set([...resolvedDirs, ...SYSTEMD_PATH_ENV.split(":"), ...directories])]
59
+ .filter(Boolean)
60
+ .join(":"),
61
+ resolved,
62
+ unresolved,
63
+ };
64
+ }
65
+ function shellQuote(v) {
66
+ return /^[A-Za-z0-9_./:-]+$/.test(v) ? v : `'${v.replace(/'/g, `'\\''`)}'`;
67
+ }
68
+ export function renderReconcileService(opts) {
69
+ const exec = [opts.nodeBinary, opts.hqBinary, "lanes", "reconcile"]
70
+ .map(shellQuote)
71
+ .join(" ");
72
+ const home = opts.home ?? os.homedir();
73
+ const pathEnv = opts.pathEnv ?? SYSTEMD_PATH_ENV;
74
+ return `[Unit]
75
+ Description=HQ lanes reconcile pass
76
+ Documentation=file://personal/projects/hq-cli-lanes/SPEC.md
77
+
78
+ [Service]
79
+ Type=oneshot
80
+ ExecStart=${exec}
81
+ WorkingDirectory=${shellQuote(opts.hqRoot)}
82
+ Nice=10
83
+ Environment=HOME=${shellQuote(home)}
84
+ Environment=PATH=${shellQuote(pathEnv)}
85
+ Environment=HQ_ROOT=${shellQuote(opts.hqRoot)}
86
+ Environment=HQ_NO_UPDATE_CHECK=1
87
+ KillMode=process
88
+
89
+ [Install]
90
+ WantedBy=default.target
91
+ `;
92
+ }
93
+ export function renderReconcileTimer() {
94
+ return `[Unit]
95
+ Description=Periodic HQ lanes reconcile pass
96
+
97
+ [Timer]
98
+ OnBootSec=30s
99
+ OnUnitActiveSec=${RECONCILE_TIMER_INTERVAL}
100
+ AccuracySec=10s
101
+ Persistent=true
102
+ Unit=${RECONCILE_UNIT_NAME}
103
+
104
+ [Install]
105
+ WantedBy=timers.target
106
+ `;
107
+ }
108
+ export function defaultSystemdUserDir(home = os.homedir()) {
109
+ return path.join(home, ".config", "systemd", "user");
110
+ }
111
+ function writeIfChanged(filePath, contents) {
112
+ try {
113
+ const existing = fs.readFileSync(filePath, "utf8");
114
+ if (existing === contents)
115
+ return false;
116
+ }
117
+ catch (err) {
118
+ const code = err.code;
119
+ if (code !== "ENOENT")
120
+ throw err;
121
+ }
122
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
123
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
124
+ try {
125
+ fs.writeFileSync(tmp, contents, { encoding: "utf8", mode: 0o644 });
126
+ fs.renameSync(tmp, filePath);
127
+ }
128
+ catch (err) {
129
+ try {
130
+ fs.unlinkSync(tmp);
131
+ }
132
+ catch (unlinkErr) {
133
+ const unlinkCode = unlinkErr.code;
134
+ if (unlinkCode !== "ENOENT") {
135
+ process.stderr.write(`hq lanes reconcile install: unlink tmp failed for ${tmp}: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}\n`);
136
+ }
137
+ }
138
+ throw err;
139
+ }
140
+ return true;
141
+ }
142
+ function unlinkIfExists(filePath) {
143
+ try {
144
+ fs.unlinkSync(filePath);
145
+ return true;
146
+ }
147
+ catch (err) {
148
+ const code = err.code;
149
+ if (code === "ENOENT")
150
+ return false;
151
+ throw err;
152
+ }
153
+ }
154
+ function runSystemctl(args, runCommand) {
155
+ const result = runCommand("systemctl", args);
156
+ if (result.status !== 0) {
157
+ throw new LanesError("systemd_failed", `systemctl ${args.join(" ")} failed: ${(result.stderr ?? "").trim() || `exit ${result.status}`}`, { args, status: result.status, stderr: result.stderr });
158
+ }
159
+ }
160
+ export function installReconcileTimer(opts) {
161
+ const nodeBinary = opts.nodeBinary ?? resolveNodeBinary();
162
+ const hqBinary = opts.hqBinary ?? resolveHqBinary();
163
+ const unitPath = path.join(opts.systemdRoot, RECONCILE_UNIT_NAME);
164
+ const timerPath = path.join(opts.systemdRoot, RECONCILE_TIMER_NAME);
165
+ const unitChanged = writeIfChanged(unitPath, renderReconcileService({
166
+ nodeBinary,
167
+ hqBinary,
168
+ hqRoot: opts.hqRoot,
169
+ home: opts.home,
170
+ pathEnv: opts.pathEnv,
171
+ }));
172
+ const timerChanged = writeIfChanged(timerPath, renderReconcileTimer());
173
+ let enabled = false;
174
+ if (opts.enable) {
175
+ if (!opts.runCommand) {
176
+ throw new LanesError("systemd_enable_requires_runner", "--enable needs a systemctl runner; pass runCommand or omit --enable to write unit files only", {});
177
+ }
178
+ runSystemctl(["--user", "daemon-reload"], opts.runCommand);
179
+ runSystemctl(["--user", "enable", "--now", RECONCILE_TIMER_NAME], opts.runCommand);
180
+ enabled = true;
181
+ }
182
+ return {
183
+ ok: true,
184
+ action: "reconcile.install",
185
+ systemd_root: opts.systemdRoot,
186
+ unit: unitPath,
187
+ timer: timerPath,
188
+ written: unitChanged || timerChanged,
189
+ enabled,
190
+ };
191
+ }
192
+ export function uninstallReconcileTimer(opts) {
193
+ if (opts.disable) {
194
+ if (!opts.runCommand) {
195
+ throw new LanesError("systemd_enable_requires_runner", "--disable needs a systemctl runner", {});
196
+ }
197
+ const disable = opts.runCommand("systemctl", [
198
+ "--user",
199
+ "disable",
200
+ "--now",
201
+ RECONCILE_TIMER_NAME,
202
+ ]);
203
+ if (disable.status !== 0) {
204
+ process.stderr.write(`hq lanes reconcile uninstall: systemctl disable exited ${disable.status}: ${(disable.stderr ?? "").trim()}\n`);
205
+ }
206
+ opts.runCommand("systemctl", ["--user", "daemon-reload"]);
207
+ }
208
+ const unitPath = path.join(opts.systemdRoot, RECONCILE_UNIT_NAME);
209
+ const timerPath = path.join(opts.systemdRoot, RECONCILE_TIMER_NAME);
210
+ const removedUnit = unlinkIfExists(unitPath);
211
+ const removedTimer = unlinkIfExists(timerPath);
212
+ return {
213
+ ok: true,
214
+ action: "reconcile.uninstall",
215
+ systemd_root: opts.systemdRoot,
216
+ unit: unitPath,
217
+ timer: timerPath,
218
+ removed: removedUnit || removedTimer,
219
+ };
220
+ }
221
+ //# sourceMappingURL=reconcile-install.js.map