@indigoai-us/hq-cli 5.345.112 → 5.345.114
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 +31 -0
- package/dist/command-catalog.generated.d.ts +4 -0
- package/dist/command-catalog.generated.js +5 -0
- package/dist/commands/capacity.js +3 -1
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +70 -1
- package/dist/commands/lanes.js +1 -0
- package/dist/lib/capacity/collectors/claude-oauth.d.ts +16 -0
- package/dist/lib/capacity/collectors/claude-oauth.js +57 -5
- package/dist/lib/capacity/lane-login.js +73 -6
- package/dist/lib/capacity/route.d.ts +1 -1
- package/dist/lib/capacity/route.js +16 -1
- package/dist/lib/capacity/sign-in.d.ts +12 -0
- package/dist/lib/capacity/sign-in.js +65 -16
- package/dist/lib/daemon/catalog.d.ts +2 -0
- package/dist/lib/daemon/catalog.js +1 -1
- package/dist/lib/daemon/paths.d.ts +2 -0
- package/dist/lib/daemon/paths.js +1 -0
- package/dist/lib/daemon/run.js +2 -0
- package/dist/lib/daemon/supervisor.d.ts +2 -0
- package/dist/lib/daemon/supervisor.js +30 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.114] — 2026-10-13
|
|
6
|
+
- `hq capacity login <provider> --account <id>` signs a registered account
|
|
7
|
+
in again into its existing home. The account id, home, and Keychain item
|
|
8
|
+
stay the same. `--account default` (or the id registered at `~/.claude`)
|
|
9
|
+
signs in the default home without setting `CLAUDE_CONFIG_DIR`. A reserved
|
|
10
|
+
id that names no registered account is refused before any sign-in runs.
|
|
11
|
+
- `hq capacity login --repair` signs in each signed-out account one at a
|
|
12
|
+
time, keeps going when one fails, and ends with a Fixed/Skipped report.
|
|
13
|
+
It exits 1 when any signed-out account was skipped.
|
|
14
|
+
- `hq capacity route` and `hq lanes create --account auto` list every
|
|
15
|
+
signed-out account under `skipped` with its reason and the exact
|
|
16
|
+
`hq capacity login <provider> --account <id>` fix, using the lanes
|
|
17
|
+
account id. The `no_account_with_room` error now carries `skipped`.
|
|
18
|
+
|
|
19
|
+
## [5.345.113] — 2026-10-13
|
|
20
|
+
- `hq daemon sync now --company <slug>` asks the running sync daemon to sync one company right away, without restarting sync. It needs hq-cloud 6.18.68 or later in the daemon; with an older daemon it falls back to the previous restart. `--json` reports which path it took. The desktop app uses this for Sync on the "Added to" banner.
|
|
21
|
+
|
|
22
|
+
- `hq capacity` no longer ranks a Claude account the ledger marks as stale
|
|
23
|
+
(sign-in expired and no reading since). The router skips it with "no fresh
|
|
24
|
+
reading (sign-in expired)" and prints the account's fix command.
|
|
25
|
+
- A Claude home listed under `accounts` now clears an old revoked marker
|
|
26
|
+
once a new sign-in lands, including when a fresh usage poll would
|
|
27
|
+
otherwise skip reading the credential. A marker that still matches the
|
|
28
|
+
current refresh token keeps the account blocked as revoked.
|
|
29
|
+
- After a token refresh, the account stays blocked as "missing profile
|
|
30
|
+
scope" when the refreshed grant lacks that scope, instead of being
|
|
31
|
+
cleared.
|
|
32
|
+
- The live-lane gate finds the `hq` CLI from the running process instead of
|
|
33
|
+
`PATH`, so the refresh gate works when `PATH` is empty or minimal.
|
|
34
|
+
- `hq capacity doctor` renames the "TOKEN AGE" column to "SINCE REFRESH".
|
|
35
|
+
|
|
5
36
|
## [5.345.112] — 2026-10-13
|
|
6
37
|
- The bundled sync engine now uses hq-cloud 6.18.71. The hq-core update no longer replaces pack-installed worker links (for example `core/workers/public/qa-tester`) with a copy of the release folder.
|
|
7
38
|
|
|
@@ -15484,6 +15484,10 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
15484
15484
|
readonly helpText: readonly [];
|
|
15485
15485
|
readonly arguments: readonly [];
|
|
15486
15486
|
readonly options: readonly [{
|
|
15487
|
+
readonly flags: "--company <slug>";
|
|
15488
|
+
readonly description: "Sync one company";
|
|
15489
|
+
readonly hidden: false;
|
|
15490
|
+
}, {
|
|
15487
15491
|
readonly flags: "--json";
|
|
15488
15492
|
readonly description: "Print machine-readable JSON";
|
|
15489
15493
|
readonly hidden: false;
|
|
@@ -18848,6 +18848,11 @@ export const COMMAND_CATALOG = [
|
|
|
18848
18848
|
"helpText": [],
|
|
18849
18849
|
"arguments": [],
|
|
18850
18850
|
"options": [
|
|
18851
|
+
{
|
|
18852
|
+
"flags": "--company <slug>",
|
|
18853
|
+
"description": "Sync one company",
|
|
18854
|
+
"hidden": false
|
|
18855
|
+
},
|
|
18851
18856
|
{
|
|
18852
18857
|
"flags": "--json",
|
|
18853
18858
|
"description": "Print machine-readable JSON",
|
|
@@ -260,7 +260,7 @@ export function formatDoctor(report) {
|
|
|
260
260
|
: report.providers.map((provider) => `${provider.provider} ${provider.state}`).join("\n");
|
|
261
261
|
const claudeAccounts = report.claudeAccounts.length === 0
|
|
262
262
|
? "none"
|
|
263
|
-
: formatTable(["ACCOUNT", "LAST ATTEMPT", "OUTCOME", "
|
|
263
|
+
: formatTable(["ACCOUNT", "LAST ATTEMPT", "OUTCOME", "SINCE REFRESH", "EXPIRES IN", "BACKOFF UNTIL"], report.claudeAccounts.map((account) => [
|
|
264
264
|
account.label,
|
|
265
265
|
account.lastRefreshAttempt ?? "—",
|
|
266
266
|
account.lastRefreshOutcome ?? "—",
|
|
@@ -533,9 +533,11 @@ export function registerCapacityCommand(program) {
|
|
|
533
533
|
});
|
|
534
534
|
if (wantsJson(opts, command)) {
|
|
535
535
|
console.log(JSON.stringify(result.json ?? loginJson(rows, clock.nowMs, provider), null, 2));
|
|
536
|
+
process.exitCode = result.exitCode;
|
|
536
537
|
return;
|
|
537
538
|
}
|
|
538
539
|
console.log(result.text);
|
|
540
|
+
process.exitCode = result.exitCode;
|
|
539
541
|
}
|
|
540
542
|
catch (err) {
|
|
541
543
|
failCapacity(err);
|
|
@@ -19,6 +19,8 @@ import type { HostApp } from "../lib/daemon/supervisor.js";
|
|
|
19
19
|
import { type SyncModeVaultClient } from "./sync-mode.js";
|
|
20
20
|
import type { RunCommand } from "../lib/service-manager/launchd.js";
|
|
21
21
|
type DaemonRunnerModule = typeof import("../lib/daemon/run.js");
|
|
22
|
+
/** First hq-cloud release whose watch runner accepts pass-request files. */
|
|
23
|
+
export declare const PASS_REQUEST_MIN_HQ_CLOUD = "6.18.68";
|
|
22
24
|
/** Load every lazy daemon runtime module before releasing its package-use lease. */
|
|
23
25
|
export declare function loadDaemonRunner(release?: () => void, load?: () => Promise<DaemonRunnerModule>, preload?: () => Promise<void>, reportPreloadFailure?: (error: unknown) => void): Promise<DaemonRunnerModule["runDaemon"]>;
|
|
24
26
|
export interface DaemonCommandDeps {
|
package/dist/commands/daemon.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import * as os from "node:os";
|
|
18
18
|
import * as path from "node:path";
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
19
20
|
import { spawn, spawnSync } from "node:child_process";
|
|
20
21
|
import { once } from "node:events";
|
|
21
22
|
import { compare as semverCompare, major as semverMajor, valid as validSemver } from "semver";
|
|
@@ -38,6 +39,7 @@ import { acknowledgeConflictNotice, conflictNoticeStateDir } from "../lib/daemon
|
|
|
38
39
|
import { isSyncRunnerCommandLine, readWatchRunnerOwnerStatus, syncRunnerPathFromCommandLine, watchRunnerOwnerStatusPath } from "../lib/daemon/sync-runner.js";
|
|
39
40
|
import { stagedSyncRunnerVersion, syncRunnerBundleStageRoot } from "../lib/daemon/sync-runner-bundle.js";
|
|
40
41
|
import { getSyncModeForCompany, setSyncMode } from "./sync-mode.js";
|
|
42
|
+
import { assertSafeCompanySlug } from "../utils/people.js";
|
|
41
43
|
import { anywhereRuntimeAllowed } from "../lib/anywhere-person-setting.js";
|
|
42
44
|
import { hqdRequest, hqdSocketPath, HqdClientError } from "../lib/daemon/protocol.js";
|
|
43
45
|
import { parseStdinMachineCredentials } from "../lib/daemon/machine-login.js";
|
|
@@ -45,6 +47,8 @@ import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
|
|
|
45
47
|
import { releaseCurrentPackageUseLease, releasePackageUseLeaseAfterResidentPreload } from "../utils/package-use-lease.js";
|
|
46
48
|
import { preloadResidentPackageModules, reportResidentPackagePreloadFailure, runResidentCommandWithPackageLease, } from "../utils/resident-package-preload.js";
|
|
47
49
|
import { followLogFile } from "../lib/log-follow.js";
|
|
50
|
+
/** First hq-cloud release whose watch runner accepts pass-request files. */
|
|
51
|
+
export const PASS_REQUEST_MIN_HQ_CLOUD = "6.18.68";
|
|
48
52
|
/** Load every lazy daemon runtime module before releasing its package-use lease. */
|
|
49
53
|
export async function loadDaemonRunner(release = releaseCurrentPackageUseLease, load = () => import("../lib/daemon/run.js"), preload = () => preloadResidentPackageModules("daemon"), reportPreloadFailure = reportResidentPackagePreloadFailure) {
|
|
50
54
|
const { runDaemon } = await load();
|
|
@@ -545,6 +549,11 @@ function daemonSyncStatus(d) {
|
|
|
545
549
|
syncOwner: effectiveSyncOwner(config, host.detected.type),
|
|
546
550
|
owner: ownerStatus?.owner ?? (syncUnit?.status === "running" ? "hq-daemon" : null),
|
|
547
551
|
runnerPid: ownerStatus?.pid ?? syncUnit?.pid ?? null,
|
|
552
|
+
passRequestDir: syncUnit?.status === "running"
|
|
553
|
+
&& syncUnit.pid !== undefined
|
|
554
|
+
&& (ownerStatus === null || ownerStatus.pid === syncUnit.pid)
|
|
555
|
+
? syncUnit.passRequestDir ?? null
|
|
556
|
+
: null,
|
|
548
557
|
hqCloudVersion,
|
|
549
558
|
hqCloudVersionSource,
|
|
550
559
|
runnerMatchesPin,
|
|
@@ -592,6 +601,53 @@ function queueDaemonSyncControl(d, request) {
|
|
|
592
601
|
handOver(d, host);
|
|
593
602
|
return runningDaemonPid(host.paths.lockPath, d.daemonAlive) !== null;
|
|
594
603
|
}
|
|
604
|
+
function writeSyncPassRequest(paths, company, source) {
|
|
605
|
+
ensurePrivateDir(paths.syncPassRequestsDir);
|
|
606
|
+
const request = {
|
|
607
|
+
version: 1,
|
|
608
|
+
company,
|
|
609
|
+
requestedAt: new Date().toISOString(),
|
|
610
|
+
source,
|
|
611
|
+
};
|
|
612
|
+
const encoded = JSON.stringify(request);
|
|
613
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
614
|
+
const basename = `${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
615
|
+
const temporary = path.join(paths.syncPassRequestsDir, `${basename}.tmp`);
|
|
616
|
+
const destination = path.join(paths.syncPassRequestsDir, `${basename}.json`);
|
|
617
|
+
try {
|
|
618
|
+
fs.writeFileSync(temporary, encoded, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
619
|
+
fs.renameSync(temporary, destination);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
try {
|
|
624
|
+
fs.unlinkSync(temporary);
|
|
625
|
+
}
|
|
626
|
+
catch (cleanupError) {
|
|
627
|
+
if (cleanupError.code !== "ENOENT")
|
|
628
|
+
throw cleanupError;
|
|
629
|
+
}
|
|
630
|
+
if (error.code === "EEXIST")
|
|
631
|
+
continue;
|
|
632
|
+
throw error;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
throw new DaemonCommandError("could not allocate a unique sync pass request filename");
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* The version alone does not prove the runner reads requests: a runner adopted
|
|
639
|
+
* across a CLI update keeps the env it was started with. The daemon records the
|
|
640
|
+
* request folder for a child it started with one.
|
|
641
|
+
*/
|
|
642
|
+
function supportsPassRequests(status, paths) {
|
|
643
|
+
const version = status.hqCloudVersion;
|
|
644
|
+
return status.running
|
|
645
|
+
&& status.passRequestDir !== null
|
|
646
|
+
&& path.resolve(status.passRequestDir) === path.resolve(paths.syncPassRequestsDir)
|
|
647
|
+
&& version !== null
|
|
648
|
+
&& validSemver(version) !== null
|
|
649
|
+
&& semverCompare(version, PASS_REQUEST_MIN_HQ_CLOUD) >= 0;
|
|
650
|
+
}
|
|
595
651
|
async function runDaemonSyncNow(d, opts) {
|
|
596
652
|
const host = currentHost(d);
|
|
597
653
|
const config = readDaemonConfig(host.paths.configPath);
|
|
@@ -600,8 +656,20 @@ async function runDaemonSyncNow(d, opts) {
|
|
|
600
656
|
if (!enabledServices(host.detected.type, config).includes("sync")) {
|
|
601
657
|
throw new DaemonCommandError("sync is disabled for this machine; run `hq daemon enable sync` first");
|
|
602
658
|
}
|
|
659
|
+
const company = opts.company ?? null;
|
|
660
|
+
if (company !== null)
|
|
661
|
+
assertSafeCompanySlug(company);
|
|
662
|
+
const status = daemonSyncStatus(d);
|
|
663
|
+
if (supportsPassRequests(status, host.paths)) {
|
|
664
|
+
const source = d.env.HQ_SYNC_PASS_REQUEST_SOURCE === "desktop" ? "desktop" : "cli";
|
|
665
|
+
writeSyncPassRequest(host.paths, company, source);
|
|
666
|
+
handOver(d, host);
|
|
667
|
+
const result = { accepted: true, action: "pass-request", company, daemonRunning: true };
|
|
668
|
+
d.out(opts.json ? JSON.stringify(result, null, 2) : "Asked the daemon to run a fresh sync pass.");
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
603
671
|
const running = queueDaemonSyncControl(d, { action: "restart", unit: "sync" });
|
|
604
|
-
const result = { accepted: true, action: "restart-sync-runner", daemonRunning: running };
|
|
672
|
+
const result = { accepted: true, action: "restart-sync-runner", company, daemonRunning: running };
|
|
605
673
|
d.out(opts.json ? JSON.stringify(result, null, 2) : running ? "Asked the daemon to start a fresh sync pass." : "Queued a fresh sync pass for the next daemon start.");
|
|
606
674
|
}
|
|
607
675
|
async function runDaemonSyncInstantSyncSet(d, enabled, opts) {
|
|
@@ -1336,6 +1404,7 @@ export function registerDaemonCommand(program, over = {}) {
|
|
|
1336
1404
|
sync
|
|
1337
1405
|
.command("now")
|
|
1338
1406
|
.description("Start a fresh sync pass immediately")
|
|
1407
|
+
.option("--company <slug>", "Sync one company")
|
|
1339
1408
|
.option("--json", "Print machine-readable JSON")
|
|
1340
1409
|
.action(wrap((d, opts) => runDaemonSyncNow(d, opts)));
|
|
1341
1410
|
const instantSync = sync.command("instant-sync").description("Control daemon Instant Sync");
|
package/dist/commands/lanes.js
CHANGED
|
@@ -3307,6 +3307,7 @@ async function runCreate(ctx, opts) {
|
|
|
3307
3307
|
throw new LanesError("no_account_with_room", `No account has room${resetSuffix}`, {
|
|
3308
3308
|
next_reset: result.next_reset,
|
|
3309
3309
|
request: result.request,
|
|
3310
|
+
skipped: result.skipped,
|
|
3310
3311
|
});
|
|
3311
3312
|
}
|
|
3312
3313
|
autoChosenProvider = parseLaneProvider(result.pick.provider);
|
|
@@ -332,6 +332,22 @@ interface RawAccountRow {
|
|
|
332
332
|
auth_state?: unknown;
|
|
333
333
|
provider?: unknown;
|
|
334
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* How to re-invoke the running hq CLI without a PATH lookup. When argv[1] is
|
|
337
|
+
* the entry of an installed hq-cli package, run it with the same node binary;
|
|
338
|
+
* the collector runs under launchd and lane workers where PATH may be empty.
|
|
339
|
+
* Falls back to `hq` on PATH when this process is not the hq CLI (tests,
|
|
340
|
+
* embedders). Exported for tests.
|
|
341
|
+
*/
|
|
342
|
+
export declare function resolveHqInvocation(argv?: readonly string[], execPath?: string): {
|
|
343
|
+
command: string;
|
|
344
|
+
args: string[];
|
|
345
|
+
};
|
|
346
|
+
/** Exported for tests; `invocation` defaults to {@link resolveHqInvocation}. */
|
|
347
|
+
export declare function execHqJson(args: readonly string[], invocation?: {
|
|
348
|
+
command: string;
|
|
349
|
+
args: string[];
|
|
350
|
+
}, env?: NodeJS.ProcessEnv): unknown;
|
|
335
351
|
/**
|
|
336
352
|
* Parse `hq lanes list --json` payload into typed rows.
|
|
337
353
|
* Exported for tests; fail-closed semantics live in defaultListLiveLanes.
|
|
@@ -251,7 +251,12 @@ export async function collectClaudeOAuth(options = {}) {
|
|
|
251
251
|
});
|
|
252
252
|
if (refreshResult?.outcome === "refreshed" && refreshResult.newAccessToken) {
|
|
253
253
|
currentToken = refreshResult.newAccessToken;
|
|
254
|
-
|
|
254
|
+
// A refresh renews the token but does not grant scopes the sign-in
|
|
255
|
+
// lacked. Re-check the profile scope before clearing the block.
|
|
256
|
+
const refreshedScopes = refreshResult.newScopes && refreshResult.newScopes.length > 0
|
|
257
|
+
? refreshResult.newScopes
|
|
258
|
+
: meta.scopes;
|
|
259
|
+
currentBlocked = hasProfileScope(refreshedScopes) ? undefined : "unavailable: missing_scope";
|
|
255
260
|
}
|
|
256
261
|
else if (refreshResult?.outcome === "revoked" || refreshResult?.outcome === "skipped-revoked") {
|
|
257
262
|
currentBlocked = SIGN_IN_REVOKED_REASON;
|
|
@@ -410,13 +415,17 @@ async function collectEnumeratedClaude(options, scoped, root, clock, cliVersion)
|
|
|
410
415
|
if (refreshResult) {
|
|
411
416
|
if (refreshResult.outcome === "refreshed" && refreshResult.newAccessToken) {
|
|
412
417
|
account.token = refreshResult.newAccessToken;
|
|
413
|
-
account.blocked = null;
|
|
414
418
|
if (refreshResult.newExpiresAt !== undefined)
|
|
415
419
|
account.expiresAt = refreshResult.newExpiresAt;
|
|
416
420
|
if (refreshResult.newRefreshToken)
|
|
417
421
|
account.refreshToken = refreshResult.newRefreshToken;
|
|
418
422
|
if (refreshResult.newScopes && refreshResult.newScopes.length > 0)
|
|
419
423
|
account.scopes = refreshResult.newScopes;
|
|
424
|
+
// A refresh renews the token but does not grant scopes the sign-in
|
|
425
|
+
// lacked. Re-check the profile scope before clearing the block.
|
|
426
|
+
account.blocked = hasProfileScope(account.scopes)
|
|
427
|
+
? null
|
|
428
|
+
: credentialBlockReason("unavailable: missing_scope");
|
|
420
429
|
}
|
|
421
430
|
else if (refreshResult.outcome === "revoked" || refreshResult.outcome === "skipped-revoked") {
|
|
422
431
|
account.blocked = SIGN_IN_REVOKED_REASON;
|
|
@@ -537,7 +546,10 @@ async function readEnumeratedHome(group, options, nowMs, root) {
|
|
|
537
546
|
platform,
|
|
538
547
|
};
|
|
539
548
|
}
|
|
540
|
-
|
|
549
|
+
// A fresh poll normally skips the credential read. When a revoked marker
|
|
550
|
+
// exists, read anyway: the marker check needs the current refresh token to
|
|
551
|
+
// tell a re-sign-in (fingerprint changed) from the same revoked token.
|
|
552
|
+
if (pollIsFresh(root, accountId, nowMs) && revokedMarkerForCurrentCredential(root, accountId, null) === "none") {
|
|
541
553
|
return {
|
|
542
554
|
accountId,
|
|
543
555
|
labels,
|
|
@@ -1623,6 +1635,10 @@ const defaultWriteClaudeCredentials = async (request) => {
|
|
|
1623
1635
|
return { ok: false, raceDetected: true };
|
|
1624
1636
|
}
|
|
1625
1637
|
const body = JSON.stringify(request.document);
|
|
1638
|
+
// `security add-generic-password` only takes the secret as `-w <value>`
|
|
1639
|
+
// on argv, so the credential JSON is visible to `ps` for the duration of
|
|
1640
|
+
// this call. Claude Code writes its own Keychain item the same way; this
|
|
1641
|
+
// matches that exposure rather than adding a new one.
|
|
1626
1642
|
const writeArgs = selector === "s"
|
|
1627
1643
|
? ["add-generic-password", "-U", "-s", request.service, "-w", body]
|
|
1628
1644
|
: ["add-generic-password", "-U", "-s", request.service, "-a", username, "-w", body];
|
|
@@ -1670,8 +1686,44 @@ const defaultWriteClaudeCredentials = async (request) => {
|
|
|
1670
1686
|
return { ok: false, error: message };
|
|
1671
1687
|
}
|
|
1672
1688
|
};
|
|
1673
|
-
|
|
1674
|
-
|
|
1689
|
+
const HQ_CLI_PACKAGE_NAME = "@indigoai-us/hq-cli";
|
|
1690
|
+
/**
|
|
1691
|
+
* How to re-invoke the running hq CLI without a PATH lookup. When argv[1] is
|
|
1692
|
+
* the entry of an installed hq-cli package, run it with the same node binary;
|
|
1693
|
+
* the collector runs under launchd and lane workers where PATH may be empty.
|
|
1694
|
+
* Falls back to `hq` on PATH when this process is not the hq CLI (tests,
|
|
1695
|
+
* embedders). Exported for tests.
|
|
1696
|
+
*/
|
|
1697
|
+
export function resolveHqInvocation(argv = process.argv, execPath = process.execPath) {
|
|
1698
|
+
const entry = argv[1];
|
|
1699
|
+
if (entry) {
|
|
1700
|
+
try {
|
|
1701
|
+
const real = fs.realpathSync(entry);
|
|
1702
|
+
let dir = path.dirname(real);
|
|
1703
|
+
for (let depth = 0; depth < 4; depth += 1) {
|
|
1704
|
+
const manifest = path.join(dir, "package.json");
|
|
1705
|
+
if (fs.existsSync(manifest)) {
|
|
1706
|
+
const parsed = JSON.parse(fs.readFileSync(manifest, "utf8"));
|
|
1707
|
+
if (parsed.name === HQ_CLI_PACKAGE_NAME)
|
|
1708
|
+
return { command: execPath, args: [real] };
|
|
1709
|
+
break;
|
|
1710
|
+
}
|
|
1711
|
+
const parent = path.dirname(dir);
|
|
1712
|
+
if (parent === dir)
|
|
1713
|
+
break;
|
|
1714
|
+
dir = parent;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
catch {
|
|
1718
|
+
// Unresolvable entry: fall through to the PATH lookup below.
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
return { command: "hq", args: [] };
|
|
1722
|
+
}
|
|
1723
|
+
/** Exported for tests; `invocation` defaults to {@link resolveHqInvocation}. */
|
|
1724
|
+
export function execHqJson(args, invocation = resolveHqInvocation(), env = process.env) {
|
|
1725
|
+
const result = spawnSync(invocation.command, [...invocation.args, ...args], {
|
|
1726
|
+
env,
|
|
1675
1727
|
encoding: "utf8",
|
|
1676
1728
|
timeout: 5_000,
|
|
1677
1729
|
windowsHide: true,
|
|
@@ -10,30 +10,76 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { createLaneAccountsHandlers } from "../../commands/lanes-accounts.js";
|
|
13
|
+
import { effectiveAccountHome } from "../lanes/account-home.js";
|
|
14
|
+
import { canonicalizeHome, DEFAULT_ACCOUNT_HOMES, loadLanesConfig } from "../lanes/config.js";
|
|
13
15
|
import { resolveLanesHqRoot } from "../lanes/hq-root.js";
|
|
14
16
|
import { defaultProbe } from "../lanes/models.js";
|
|
17
|
+
import { parseLaneProvider } from "../lanes/types.js";
|
|
15
18
|
const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
16
19
|
const RESERVED_ACCOUNT_IDS = new Set(["a", "default"]);
|
|
17
20
|
export async function loginLaneAccount(input) {
|
|
18
|
-
if (!ACCOUNT_ID_PATTERN.test(input.accountId)
|
|
19
|
-
throw new Error(`Refusing account id ${input.accountId}. Use letters, numbers, dots, underscores, or hyphens
|
|
21
|
+
if (!ACCOUNT_ID_PATTERN.test(input.accountId)) {
|
|
22
|
+
throw new Error(`Refusing account id ${input.accountId}. Use letters, numbers, dots, underscores, or hyphens.`);
|
|
20
23
|
}
|
|
21
24
|
const hqRoot = input.hqRoot ?? resolveLanesHqRoot({ env: input.env });
|
|
25
|
+
const provider = parseLaneProvider(input.provider);
|
|
26
|
+
const homedir = input.homedir ?? os.homedir;
|
|
27
|
+
const hostname = input.hostname ?? os.hostname();
|
|
28
|
+
const existing = existingAccount(hqRoot, provider, input.accountId, hostname, homedir);
|
|
22
29
|
let captured;
|
|
23
30
|
const handlers = createLaneAccountsHandlers(handlerDeps((value) => {
|
|
24
31
|
captured = value;
|
|
25
32
|
}));
|
|
26
33
|
// One registry write. capacityOnly stores enabled:false and the capacity-only
|
|
27
34
|
// reason in that write, re-checking whether lanes already use the account.
|
|
28
|
-
|
|
35
|
+
// A registered id signs in again into the home the registry already names.
|
|
36
|
+
await handlers.runAccountsLogin(loginCtx(input, hqRoot), provider, existing?.id ?? input.accountId, {
|
|
29
37
|
capacityOnly: true,
|
|
30
38
|
});
|
|
31
39
|
const account = captured?.account;
|
|
32
40
|
if (!account?.id || !account.home) {
|
|
33
|
-
throw new Error(`lanes accounts login did not register ${
|
|
41
|
+
throw new Error(`lanes accounts login did not register ${provider} ${input.accountId}`);
|
|
42
|
+
}
|
|
43
|
+
if (existing && (account.id !== existing.id || account.home !== existing.home)) {
|
|
44
|
+
throw new Error(`Sign-in for ${provider} ${existing.id} moved it to ${account.id} at ${account.home}; expected ${existing.home}`);
|
|
34
45
|
}
|
|
35
46
|
return { id: account.id, home: account.home };
|
|
36
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* The registry account an id names, with the home lanes use on this host.
|
|
50
|
+
* `default` names the account registered at the provider's default home.
|
|
51
|
+
* Reserved ids sign in again only when they name a registered account, so a
|
|
52
|
+
* new account never takes `a` or `default`.
|
|
53
|
+
*/
|
|
54
|
+
function existingAccount(hqRoot, provider, accountId, hostname, homedir) {
|
|
55
|
+
const accounts = loadLanesConfig(hqRoot).providers[provider].accounts;
|
|
56
|
+
const canonical = (home) => canonicalHome(home, homedir);
|
|
57
|
+
if (accountId === "default") {
|
|
58
|
+
const defaultHome = canonical(DEFAULT_ACCOUNT_HOMES[provider]);
|
|
59
|
+
for (const [id, entry] of Object.entries(accounts)) {
|
|
60
|
+
if (!entry)
|
|
61
|
+
continue;
|
|
62
|
+
const home = canonical(effectiveAccountHome(entry, hostname).home);
|
|
63
|
+
if (home === defaultHome)
|
|
64
|
+
return { id, home };
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`No ${provider} account is registered at the default home. Run hq capacity login ${provider} to add one.`);
|
|
67
|
+
}
|
|
68
|
+
const entry = accounts[accountId];
|
|
69
|
+
if (entry)
|
|
70
|
+
return { id: accountId, home: canonical(effectiveAccountHome(entry, hostname).home) };
|
|
71
|
+
if (RESERVED_ACCOUNT_IDS.has(accountId)) {
|
|
72
|
+
throw new Error(`Refusing account id ${accountId}. It is reserved and no ${provider} account uses it.`);
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function canonicalHome(home, homedir) {
|
|
77
|
+
return canonicalizeHome(home, {
|
|
78
|
+
homedir,
|
|
79
|
+
realpathSync: (target) => fs.realpathSync(target),
|
|
80
|
+
existsSync: (target) => fs.existsSync(target),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
37
83
|
function handlerDeps(printJson) {
|
|
38
84
|
return {
|
|
39
85
|
automaticBalanceMoveHistoryReason: () => unused("automaticBalanceMoveHistoryReason"),
|
|
@@ -59,8 +105,8 @@ function loginCtx(input, hqRoot) {
|
|
|
59
105
|
hostname: () => input.hostname ?? os.hostname(),
|
|
60
106
|
homedir: input.homedir ?? os.homedir,
|
|
61
107
|
existsSync: (target) => fs.existsSync(target),
|
|
62
|
-
spawnLogin: input.spawnLogin ?? defaultSpawnLogin,
|
|
63
|
-
probeProvider: input.probeProvider ?? defaultProbe,
|
|
108
|
+
spawnLogin: defaultHomeEnv(input.spawnLogin ?? defaultSpawnLogin, input.homedir ?? os.homedir),
|
|
109
|
+
probeProvider: defaultHomeProbe(input.probeProvider ?? defaultProbe, input.homedir ?? os.homedir),
|
|
64
110
|
};
|
|
65
111
|
return new Proxy(fields, {
|
|
66
112
|
get(target, prop) {
|
|
@@ -72,6 +118,27 @@ function loginCtx(input, hqRoot) {
|
|
|
72
118
|
},
|
|
73
119
|
});
|
|
74
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Claude Code keys its Keychain item on CLAUDE_CONFIG_DIR. The default home
|
|
123
|
+
* keeps the plain `Claude Code-credentials` item only when the variable is
|
|
124
|
+
* unset, so a sign-in into ~/.claude runs without it.
|
|
125
|
+
*/
|
|
126
|
+
function defaultHomeEnv(spawnLogin, homedir) {
|
|
127
|
+
return (opts) => spawnLogin({ ...opts, env: withoutDefaultClaudeDir(opts.env, homedir) });
|
|
128
|
+
}
|
|
129
|
+
/** The sign-in check reads the same Keychain item the sign-in wrote. */
|
|
130
|
+
function defaultHomeProbe(probe, homedir) {
|
|
131
|
+
return (command, args, env) => probe(command, args, withoutDefaultClaudeDir(env, homedir));
|
|
132
|
+
}
|
|
133
|
+
function withoutDefaultClaudeDir(env, homedir) {
|
|
134
|
+
const configDir = env.CLAUDE_CONFIG_DIR;
|
|
135
|
+
if (!configDir || canonicalHome(configDir, homedir) !== canonicalHome(DEFAULT_ACCOUNT_HOMES.claude, homedir)) {
|
|
136
|
+
return env;
|
|
137
|
+
}
|
|
138
|
+
const next = { ...env };
|
|
139
|
+
delete next.CLAUDE_CONFIG_DIR;
|
|
140
|
+
return next;
|
|
141
|
+
}
|
|
75
142
|
function defaultSpawnLogin(opts) {
|
|
76
143
|
return new Promise((resolve, reject) => {
|
|
77
144
|
const child = spawn(opts.command, opts.args, {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LaneRecord, LaneState, LanesConfig } from "../lanes/types.js";
|
|
2
2
|
import { type LaneProvider } from "../lanes/types.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type NowLimit, type NowView } from "./ledger.js";
|
|
4
4
|
/** All lane providers, in the stable order boss-route uses for ranking ties. */
|
|
5
5
|
export declare const ROUTE_PROVIDERS: readonly LaneProvider[];
|
|
6
6
|
/** Lane states that count against an account's live load. */
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { canonicalizeHome } from "../lanes/config.js";
|
|
17
17
|
import { LANE_PROVIDERS } from "../lanes/types.js";
|
|
18
|
+
import { capacityLoginCommand } from "./ledger.js";
|
|
18
19
|
/** All lane providers, in the stable order boss-route uses for ranking ties. */
|
|
19
20
|
export const ROUTE_PROVIDERS = LANE_PROVIDERS;
|
|
20
21
|
/** Lane states that count against an account's live load. */
|
|
@@ -225,7 +226,10 @@ export function pickAccount(input) {
|
|
|
225
226
|
skipped.push({
|
|
226
227
|
...base,
|
|
227
228
|
reason: account.reason ?? `sign-in ${authState}`,
|
|
228
|
-
|
|
229
|
+
// Every signed-out reason (revoked, missing credential, missing
|
|
230
|
+
// scope, auth-failed lane) is fixed by signing the lane account in
|
|
231
|
+
// again, so the fix names the registry id lanes use.
|
|
232
|
+
fix: capacityLoginCommand(providerId, laneMatch.id),
|
|
229
233
|
});
|
|
230
234
|
continue;
|
|
231
235
|
}
|
|
@@ -250,6 +254,17 @@ export function pickAccount(input) {
|
|
|
250
254
|
});
|
|
251
255
|
continue;
|
|
252
256
|
}
|
|
257
|
+
// The ledger sets usageStale when the sign-in expired and no reading has
|
|
258
|
+
// landed since the expiry. The limits on file describe the old token, so
|
|
259
|
+
// the account cannot be ranked on them.
|
|
260
|
+
if (account.usageStale === true) {
|
|
261
|
+
skipped.push({
|
|
262
|
+
...base,
|
|
263
|
+
reason: "no fresh reading (sign-in expired)",
|
|
264
|
+
fix: account.fixCommand ?? null,
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
253
268
|
const applicable = (account.limits ?? []).filter((l) => limitApplies(providerId, l, model));
|
|
254
269
|
const viewRows = applicable.map((limit) => {
|
|
255
270
|
const resetMs = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN;
|
|
@@ -88,6 +88,18 @@ export interface CapacityLoginResult {
|
|
|
88
88
|
json?: unknown;
|
|
89
89
|
}
|
|
90
90
|
export declare function executeCapacityLogin(request: CapacityLoginRequest, deps: CapacityLoginDeps): Promise<CapacityLoginResult>;
|
|
91
|
+
export interface RepairFixed {
|
|
92
|
+
provider: string;
|
|
93
|
+
label: string;
|
|
94
|
+
account: string;
|
|
95
|
+
home: string;
|
|
96
|
+
}
|
|
97
|
+
export interface RepairSkipped {
|
|
98
|
+
provider: string;
|
|
99
|
+
label: string;
|
|
100
|
+
reason: string;
|
|
101
|
+
fix: string | null;
|
|
102
|
+
}
|
|
91
103
|
export declare function organizationFor(view: NowView, provider: string, home: string): string | null;
|
|
92
104
|
export declare function renameCapacityAccount(hqRoot: string, providerRaw: string, fromRaw: string, toRaw: string, options?: {
|
|
93
105
|
force?: boolean;
|
|
@@ -278,8 +278,11 @@ export async function executeCapacityLogin(request, deps) {
|
|
|
278
278
|
if (request.account && !request.provider) {
|
|
279
279
|
throw new SignInUsageError("Pass a provider before --account.");
|
|
280
280
|
}
|
|
281
|
+
// A reserved id (a, default) is accepted here so a registered account can
|
|
282
|
+
// sign in again into its home. The lane login refuses one that names no
|
|
283
|
+
// registered account.
|
|
281
284
|
if (request.account)
|
|
282
|
-
|
|
285
|
+
assertAccountIdShape(request.account);
|
|
283
286
|
const providerFilter = request.provider;
|
|
284
287
|
const wantsSignIn = Boolean(request.provider || request.account || request.repair);
|
|
285
288
|
if (!request.interactive && wantsSignIn && !request.json) {
|
|
@@ -308,19 +311,69 @@ export async function executeCapacityLogin(request, deps) {
|
|
|
308
311
|
}
|
|
309
312
|
return { exitCode: 0, text: lines.join("\n\n") };
|
|
310
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Signs in again for each signed-out account, one at a time, in roster order.
|
|
316
|
+
* A failed sign-in is reported and the next account still runs. Expired
|
|
317
|
+
* accounts are reported as skipped: opening the account refreshes them.
|
|
318
|
+
*/
|
|
311
319
|
async function runRepair(request, deps) {
|
|
312
320
|
const plan = repairSelection(deps.rows, request.provider);
|
|
313
321
|
const lines = [formatRepairPlan(plan)];
|
|
322
|
+
const fixed = [];
|
|
323
|
+
const skipped = [];
|
|
314
324
|
for (const row of plan.signedOut) {
|
|
315
|
-
|
|
316
|
-
|
|
325
|
+
const fix = row.command ?? `hq capacity login ${row.provider} --account ${row.label}`;
|
|
326
|
+
let logged;
|
|
327
|
+
try {
|
|
328
|
+
logged = await deps.loginAccount(row.provider, row.label);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
skipped.push({ provider: row.provider, label: row.label, reason: errorText(err), fix });
|
|
317
332
|
continue;
|
|
318
333
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
334
|
+
fixed.push({ provider: row.provider, label: row.label, account: logged.id, home: logged.home });
|
|
335
|
+
try {
|
|
336
|
+
const view = await deps.readAccount(row.provider, logged.id, logged.home);
|
|
337
|
+
lines.push(deps.formatReading(view, row.provider, row.label, logged.home));
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
lines.push(`${row.provider} ${row.label}: signed in; usage read failed: ${errorText(err)}`);
|
|
341
|
+
}
|
|
322
342
|
}
|
|
323
|
-
|
|
343
|
+
for (const row of plan.expired) {
|
|
344
|
+
skipped.push({
|
|
345
|
+
provider: row.provider,
|
|
346
|
+
label: row.label,
|
|
347
|
+
reason: row.reason ?? EXPIRED_REASON,
|
|
348
|
+
fix: row.command ? formatRefreshLine(row.provider, row.command) : null,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
lines.push(formatRepairSummary(fixed, skipped));
|
|
352
|
+
const failed = skipped.some((item) => plan.signedOut.some((row) => row.provider === item.provider && row.label === item.label));
|
|
353
|
+
return {
|
|
354
|
+
exitCode: failed ? 1 : 0,
|
|
355
|
+
text: lines.join("\n\n"),
|
|
356
|
+
json: { repair: { fixed, skipped } },
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function formatRepairSummary(fixed, skipped) {
|
|
360
|
+
const lines = ["Fixed:"];
|
|
361
|
+
if (fixed.length === 0)
|
|
362
|
+
lines.push(" none");
|
|
363
|
+
for (const item of fixed)
|
|
364
|
+
lines.push(` ${item.provider} ${item.label}`);
|
|
365
|
+
lines.push("Skipped:");
|
|
366
|
+
if (skipped.length === 0)
|
|
367
|
+
lines.push(" none");
|
|
368
|
+
for (const item of skipped) {
|
|
369
|
+
lines.push(` ${item.provider} ${item.label}: ${item.reason}`);
|
|
370
|
+
if (item.fix)
|
|
371
|
+
lines.push(` ${item.fix}`);
|
|
372
|
+
}
|
|
373
|
+
return lines.join("\n");
|
|
374
|
+
}
|
|
375
|
+
function errorText(err) {
|
|
376
|
+
return err instanceof Error ? err.message : String(err);
|
|
324
377
|
}
|
|
325
378
|
async function signInOne(provider, account, deps) {
|
|
326
379
|
const id = account ?? deps.newAccountId();
|
|
@@ -489,20 +542,16 @@ function isAncestor(parent, child) {
|
|
|
489
542
|
const relative = path.relative(parent, child);
|
|
490
543
|
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
491
544
|
}
|
|
545
|
+
function assertAccountIdShape(id) {
|
|
546
|
+
if (!ID_PATTERN.test(id)) {
|
|
547
|
+
throw new SignInUsageError("Account id must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens.");
|
|
548
|
+
}
|
|
549
|
+
}
|
|
492
550
|
function assertRegistryAccountId(id) {
|
|
493
551
|
if (!ID_PATTERN.test(id) || RESERVED_IDS.has(id)) {
|
|
494
552
|
throw new SignInUsageError("Account id must start with a letter or number, contain only letters, numbers, dots, underscores, or hyphens, and must not be reserved (a, default).");
|
|
495
553
|
}
|
|
496
554
|
}
|
|
497
|
-
function defaultVendorLogin(provider) {
|
|
498
|
-
if (provider === "claude")
|
|
499
|
-
return "claude auth login";
|
|
500
|
-
if (provider === "codex")
|
|
501
|
-
return "codex login";
|
|
502
|
-
if (provider === "grok")
|
|
503
|
-
return "grok login";
|
|
504
|
-
return `hq capacity login ${provider}`;
|
|
505
|
-
}
|
|
506
555
|
function requiredId(value, name) {
|
|
507
556
|
const id = value.trim();
|
|
508
557
|
if (!id || id !== value)
|
|
@@ -87,6 +87,8 @@ export interface PlanContext {
|
|
|
87
87
|
platform: NodeJS.Platform;
|
|
88
88
|
home: string;
|
|
89
89
|
hqRoot: string | undefined;
|
|
90
|
+
/** Daemon-owned request directory consumed by the hq-cloud watch runner. */
|
|
91
|
+
syncPassRequestsDir: string;
|
|
90
92
|
/** hq-cloud's sync-runner.js, or null when it cannot be found. */
|
|
91
93
|
syncRunnerPath: string | null;
|
|
92
94
|
/** hq-cloud's `hq-outpost-heartbeat` bin, when the hq-cloud in use has one. */
|
|
@@ -142,7 +142,7 @@ function planService(service, ctx) {
|
|
|
142
142
|
SYNC_RUNNER_OWNER,
|
|
143
143
|
],
|
|
144
144
|
},
|
|
145
|
-
env: { ...ctx.legacySyncEnv },
|
|
145
|
+
env: { ...ctx.legacySyncEnv, HQ_SYNC_PASS_REQUEST_DIR: ctx.syncPassRequestsDir },
|
|
146
146
|
heartbeatTimeoutMs: ctx.syncHeartbeatTimeoutMs,
|
|
147
147
|
});
|
|
148
148
|
let ownerBlock;
|
|
@@ -11,6 +11,8 @@ export interface DaemonPaths {
|
|
|
11
11
|
lockPath: string;
|
|
12
12
|
logsDir: string;
|
|
13
13
|
requestsDir: string;
|
|
14
|
+
/** One-shot sync passes queued for the running hq-cloud watch runner. */
|
|
15
|
+
syncPassRequestsDir: string;
|
|
14
16
|
supervisorLogPath: string;
|
|
15
17
|
unitLogPath: (unitId: string) => string;
|
|
16
18
|
/** The FIFO a unit that speaks the sync protocol writes its stdout to (see protocol-tail.ts); kept out of logs/ so nothing reading logs drains it. */
|
package/dist/lib/daemon/paths.js
CHANGED
|
@@ -17,6 +17,7 @@ export function daemonPaths(home = os.homedir(), env = process.env) {
|
|
|
17
17
|
lockPath: path.join(root, "daemon.pid"),
|
|
18
18
|
logsDir,
|
|
19
19
|
requestsDir: path.join(root, "requests"),
|
|
20
|
+
syncPassRequestsDir: path.resolve(root, "sync-pass-requests"),
|
|
20
21
|
supervisorLogPath: path.join(logsDir, "daemon.log"),
|
|
21
22
|
unitLogPath: (unitId) => path.join(logsDir, `${unitId.replace(/[^A-Za-z0-9_.-]/g, "-")}.log`),
|
|
22
23
|
unitProtocolPath: (unitId) => path.join(root, "protocol", `${unitId.replace(/[^A-Za-z0-9_.-]/g, "-")}.fifo`),
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -451,6 +451,7 @@ export async function runDaemon(opts) {
|
|
|
451
451
|
const home = opts.home ?? os.homedir();
|
|
452
452
|
const platform = process.platform;
|
|
453
453
|
const paths = daemonPaths(home, env);
|
|
454
|
+
ensurePrivateDir(paths.syncPassRequestsDir);
|
|
454
455
|
const log = makeLogger(paths, opts.foreground === true);
|
|
455
456
|
const syncRunnerBundleDirectory = opts.syncRunnerBundleDirectory ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../hq-cloud-sync-runner");
|
|
456
457
|
const cliPackageRoot = opts.cliPackageRoot === undefined ? resolveRunningInstall().packageRoot : opts.cliPackageRoot;
|
|
@@ -835,6 +836,7 @@ export async function runDaemon(opts) {
|
|
|
835
836
|
platform,
|
|
836
837
|
home,
|
|
837
838
|
hqRoot,
|
|
839
|
+
syncPassRequestsDir: paths.syncPassRequestsDir,
|
|
838
840
|
syncRunnerPath: runners.syncRunner,
|
|
839
841
|
heartbeatRunnerPath: runners.heartbeat,
|
|
840
842
|
sessionHostRunnerPath: runners.sessionHost,
|
|
@@ -63,6 +63,8 @@ interface UnitState {
|
|
|
63
63
|
lastResult?: "ok" | "failed";
|
|
64
64
|
lastHeartbeatAt?: string;
|
|
65
65
|
lastPassResult?: ReturnType<typeof syncPassResult>;
|
|
66
|
+
/** The pass-request folder the running child was started with; absent when it was not. */
|
|
67
|
+
passRequestDir?: string;
|
|
66
68
|
}
|
|
67
69
|
/** An app that runs the daemon as its own child process instead of an OS unit. */
|
|
68
70
|
export type HostApp = "desktop";
|
|
@@ -37,6 +37,33 @@ const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
|
|
|
37
37
|
export const HANDOFF_STAGGER_MS = 15_000;
|
|
38
38
|
/** How long a draining unit may take to finish its work before it gets SIGTERM. */
|
|
39
39
|
export const HANDOFF_DRAIN_MAX_MS = 15 * 60_000;
|
|
40
|
+
const PASS_REQUEST_DIR_ENV = "HQ_SYNC_PASS_REQUEST_DIR";
|
|
41
|
+
function passRequestDirOf(env) {
|
|
42
|
+
const dir = env?.[PASS_REQUEST_DIR_ENV];
|
|
43
|
+
return dir ? dir : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** An adopted child carries no env, only the key it was started with (see planKey). */
|
|
46
|
+
function passRequestDirFromKey(key) {
|
|
47
|
+
try {
|
|
48
|
+
const fields = JSON.parse(key);
|
|
49
|
+
if (!Array.isArray(fields))
|
|
50
|
+
return undefined;
|
|
51
|
+
const env = fields[6];
|
|
52
|
+
if (!env || typeof env !== "object" || Array.isArray(env))
|
|
53
|
+
return undefined;
|
|
54
|
+
const dir = env[PASS_REQUEST_DIR_ENV];
|
|
55
|
+
return typeof dir === "string" && dir ? dir : undefined;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function recordPassRequestDir(state, dir) {
|
|
62
|
+
if (dir)
|
|
63
|
+
state.passRequestDir = dir;
|
|
64
|
+
else
|
|
65
|
+
delete state.passRequestDir;
|
|
66
|
+
}
|
|
40
67
|
/** Plan reads in a row that must find a running remote-control host blocked before it is paused. */
|
|
41
68
|
const BLOCKED_READS_BEFORE_PAUSE = 2;
|
|
42
69
|
function backoffMs(attempt) {
|
|
@@ -560,6 +587,7 @@ export function createSupervisor(deps) {
|
|
|
560
587
|
}
|
|
561
588
|
slot.child = child;
|
|
562
589
|
slot.childKey = slot.key;
|
|
590
|
+
recordPassRequestDir(slot.state, passRequestDirOf(slot.plan.env));
|
|
563
591
|
slot.childStartedAt = now();
|
|
564
592
|
slot.state.status = "running";
|
|
565
593
|
slot.state.startedAt = slot.childStartedAt;
|
|
@@ -651,6 +679,7 @@ export function createSupervisor(deps) {
|
|
|
651
679
|
}
|
|
652
680
|
slot.child = child;
|
|
653
681
|
slot.childKey = slot.key;
|
|
682
|
+
recordPassRequestDir(slot.state, passRequestDirOf(slot.plan.env));
|
|
654
683
|
slot.childStartedAt = now();
|
|
655
684
|
slot.state.status = "running";
|
|
656
685
|
delete slot.state.reason;
|
|
@@ -880,6 +909,7 @@ export function createSupervisor(deps) {
|
|
|
880
909
|
slot.adopted = true;
|
|
881
910
|
slot.child = unit.child;
|
|
882
911
|
slot.childKey = unit.key;
|
|
912
|
+
recordPassRequestDir(slot.state, passRequestDirFromKey(unit.key));
|
|
883
913
|
slot.childStartedAt = now();
|
|
884
914
|
slot.state.status = "running";
|
|
885
915
|
slot.state.startedAt = slot.childStartedAt;
|