@indigoai-us/hq-cli 5.341.2 → 5.341.4
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 +12 -0
- package/assets/hqd/windows-pipe-host.ps1 +282 -0
- package/dist/commands/billing-sponsor.js +20 -1
- package/dist/commands/core-derive-trigger-facts-native.d.ts +2 -0
- package/dist/commands/core-derive-trigger-facts-native.js +92 -25
- package/dist/commands/core-migrate-policy-triggers-native.d.ts +1 -0
- package/dist/commands/core-migrate-policy-triggers-native.js +26 -12
- package/dist/commands/doctor.js +2 -2
- package/dist/lib/aws-sdk-node-warning-output.js +2 -3
- package/dist/lib/billing/plan-lock.js +19 -2
- package/dist/lib/bot/owner-context.d.ts +2 -0
- package/dist/lib/bot/owner-context.js +19 -1
- package/dist/lib/daemon/flag-monitor.d.ts +5 -0
- package/dist/lib/daemon/flag-monitor.js +14 -1
- package/dist/lib/daemon/protocol.d.ts +26 -3
- package/dist/lib/daemon/protocol.js +9 -5
- package/dist/lib/daemon/remote-control.d.ts +7 -0
- package/dist/lib/daemon/remote-control.js +135 -2
- package/dist/lib/daemon/run.js +14 -16
- package/dist/lib/daemon/server.d.ts +13 -0
- package/dist/lib/daemon/server.js +283 -28
- package/dist/lib/daemon/windows-pipe-host.d.ts +17 -0
- package/dist/lib/daemon/windows-pipe-host.js +218 -0
- package/dist/lib/diagnostics.d.ts +9 -0
- package/dist/lib/diagnostics.js +12 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +6 -1
- package/dist/lib/doctor/checks/anywhere.js +131 -13
- package/dist/lib/doctor/json-output.d.ts +2 -0
- package/dist/lib/doctor/json-output.js +1 -0
- package/dist/lib/doctor/registry.js +1 -3
- package/dist/lib/doctor/types.d.ts +4 -0
- package/dist/utils/run-bundled-script.d.ts +2 -0
- package/dist/utils/run-bundled-script.js +10 -1
- package/docs/anywhere-runtime.md +32 -5
- package/package.json +2 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -37,7 +37,6 @@ import { isCompanyJournalSlug } from "../lib/doctor/checks/sync-health.js";
|
|
|
37
37
|
import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
|
|
38
38
|
import * as readline from "node:readline";
|
|
39
39
|
import { runSideEffectTests } from "../lib/doctor/deep/sandbox.js";
|
|
40
|
-
import { anywhereRuntimeEnabled } from "./anywhere-runtime-flag.js";
|
|
41
40
|
/**
|
|
42
41
|
* Marker directories that identify an HQ tree root. A directory is an HQ root
|
|
43
42
|
* only when it contains ALL of these. `.claude` + `core` are present in both
|
|
@@ -91,7 +90,7 @@ export async function runDoctor(options = {}) {
|
|
|
91
90
|
const write = options.stdout ?? ((chunk) => void process.stdout.write(chunk));
|
|
92
91
|
const writeErr = options.stderr ?? ((chunk) => void process.stderr.write(chunk));
|
|
93
92
|
const hqRoot = options.root ?? resolveHqRoot(options.cwd);
|
|
94
|
-
const standaloneAnywhere = !hqRoot && options.only?.length === 1 && options.only[0] === "anywhere"
|
|
93
|
+
const standaloneAnywhere = !hqRoot && options.only?.length === 1 && options.only[0] === "anywhere";
|
|
95
94
|
if (!hqRoot && !standaloneAnywhere) {
|
|
96
95
|
const from = options.cwd ?? process.cwd();
|
|
97
96
|
writeErr(`hq doctor: not inside an HQ tree.\n` +
|
|
@@ -126,6 +125,7 @@ export async function runDoctor(options = {}) {
|
|
|
126
125
|
// Anywhere checks inspect ~/.hq and are intentionally runnable from a foreign
|
|
127
126
|
// repo. No other family is selected in this standalone path.
|
|
128
127
|
hqRoot: hqRoot ?? "",
|
|
128
|
+
cwd: options.cwd ?? process.cwd(),
|
|
129
129
|
platform: { id: platform.id, evidence: platform.evidence },
|
|
130
130
|
sessionId: options.sessionId,
|
|
131
131
|
liveRuntimes: options.liveRuntimes === true,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { companyErrorsSdkWarningEnabled } from "./company-errors-sdk-warning-flag.js";
|
|
2
|
+
import { diagnosticsEnabled } from "./diagnostics.js";
|
|
2
3
|
export const AWS_SDK_NODE_VERSION_WARNING_ENV = "AWS_SDK_JS_NODE_VERSION_SUPPORT_WARNING_DISABLED";
|
|
3
4
|
/**
|
|
4
5
|
* Configure the AWS SDK's documented, warning-specific switch before loading
|
|
@@ -10,9 +11,7 @@ export function configureAwsSdkNodeVersionWarningOutput(options = {}) {
|
|
|
10
11
|
const enabled = options.enabled ?? companyErrorsSdkWarningEnabled();
|
|
11
12
|
if (!enabled)
|
|
12
13
|
return;
|
|
13
|
-
|
|
14
|
-
const debug = /^(1|true|yes)$/i.test(env.HQ_DEBUG?.trim() ?? "");
|
|
15
|
-
if (verbose || debug) {
|
|
14
|
+
if (diagnosticsEnabled(env, argv)) {
|
|
16
15
|
delete env[AWS_SDK_NODE_VERSION_WARNING_ENV];
|
|
17
16
|
}
|
|
18
17
|
else {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* missing data (hq-absent-field-never-means-constraining-value).
|
|
22
22
|
*/
|
|
23
23
|
import chalk from "chalk";
|
|
24
|
+
import { diagnosticsEnabled } from "../diagnostics.js";
|
|
24
25
|
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
25
26
|
import { parseFreeMonth } from "./free-month.js";
|
|
26
27
|
import { selectPlanLimitUpgradeUrl } from "./plan-limit-upgrade-url.js";
|
|
@@ -212,6 +213,16 @@ function statusFromUsage(companySlug, companyUid, body, upgradeCompanySlug) {
|
|
|
212
213
|
checkedAt: new Date().toISOString(),
|
|
213
214
|
};
|
|
214
215
|
}
|
|
216
|
+
function isSilentPlanLimitBodyError(error) {
|
|
217
|
+
return (error instanceof Error &&
|
|
218
|
+
(error.name === "AbortError" || error.name === "TimeoutError"));
|
|
219
|
+
}
|
|
220
|
+
function notePlanLimitBodyUnreadable(error) {
|
|
221
|
+
if (isSilentPlanLimitBodyError(error) || !diagnosticsEnabled())
|
|
222
|
+
return;
|
|
223
|
+
const errorName = error instanceof Error ? error.name : "Error";
|
|
224
|
+
console.error(`plan-limit body unreadable errorName=${errorName}`);
|
|
225
|
+
}
|
|
215
226
|
/**
|
|
216
227
|
* Read the live nag for one company from usage-limits. `/membership/me` is
|
|
217
228
|
* used only to resolve slug → companyUid. Returns null when the server did
|
|
@@ -226,7 +237,10 @@ export async function fetchPlanLimitStatus(token, companyRef, opts = {}) {
|
|
|
226
237
|
});
|
|
227
238
|
if (!res.ok)
|
|
228
239
|
return null;
|
|
229
|
-
const selected = selectCompanyMembership(await res.json().catch(() =>
|
|
240
|
+
const selected = selectCompanyMembership(await res.json().catch((error) => {
|
|
241
|
+
notePlanLimitBodyUnreadable(error);
|
|
242
|
+
return null;
|
|
243
|
+
}), companyRef);
|
|
230
244
|
if (!selected?.companyUid)
|
|
231
245
|
return null;
|
|
232
246
|
try {
|
|
@@ -238,7 +252,10 @@ export async function fetchPlanLimitStatus(token, companyRef, opts = {}) {
|
|
|
238
252
|
});
|
|
239
253
|
if (!usage.ok)
|
|
240
254
|
return null;
|
|
241
|
-
const body = await usage.json().catch(() =>
|
|
255
|
+
const body = await usage.json().catch((error) => {
|
|
256
|
+
notePlanLimitBodyUnreadable(error);
|
|
257
|
+
return null;
|
|
258
|
+
});
|
|
242
259
|
if (!body)
|
|
243
260
|
return null;
|
|
244
261
|
const selectedSlug = selected.companySlug ??
|
|
@@ -67,6 +67,8 @@ export declare class OwnerContextCache {
|
|
|
67
67
|
private value;
|
|
68
68
|
private at;
|
|
69
69
|
private pending;
|
|
70
|
+
/** True only after the wait ended before this lookup, so a later rejection is the hidden one. */
|
|
71
|
+
private waitEnded;
|
|
70
72
|
constructor(lookup: () => Promise<OwnerContext>, nowMs?: () => number, waitMs?: number, ownerUid?: string);
|
|
71
73
|
get(): Promise<OwnerContext>;
|
|
72
74
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* slugs, uids, roles) reaches the model; the owner's token never does. A failed
|
|
14
14
|
* lookup is reported as "could not check", never as "no companies".
|
|
15
15
|
*/
|
|
16
|
+
import { diagnosticsEnabled } from "../diagnostics.js";
|
|
16
17
|
import { ensureCognitoToken } from "../../utils/cognito-session.js";
|
|
17
18
|
import { resolveCallerPersonUid, vaultApiFetch } from "../../utils/vault-api.js";
|
|
18
19
|
function message(err) {
|
|
@@ -85,6 +86,12 @@ export const OWNER_CONTEXT_TTL_MS = 5 * 60_000;
|
|
|
85
86
|
export const OWNER_CONTEXT_RETRY_MS = 60_000;
|
|
86
87
|
/** A turn never waits longer than this for a refresh; the lookup keeps going in the background. */
|
|
87
88
|
const OWNER_CONTEXT_WAIT_MS = 8_000;
|
|
89
|
+
function noteOwnerContextLookupRejected(error) {
|
|
90
|
+
if (!diagnosticsEnabled())
|
|
91
|
+
return;
|
|
92
|
+
const errorName = error instanceof Error ? error.name : "Error";
|
|
93
|
+
console.error(`owner-context lookup failed errorName=${errorName}`);
|
|
94
|
+
}
|
|
88
95
|
export class OwnerContextCache {
|
|
89
96
|
lookup;
|
|
90
97
|
nowMs;
|
|
@@ -93,6 +100,8 @@ export class OwnerContextCache {
|
|
|
93
100
|
value = null;
|
|
94
101
|
at = 0;
|
|
95
102
|
pending = null;
|
|
103
|
+
/** True only after the wait ended before this lookup, so a later rejection is the hidden one. */
|
|
104
|
+
waitEnded = false;
|
|
96
105
|
constructor(lookup, nowMs = Date.now, waitMs = OWNER_CONTEXT_WAIT_MS, ownerUid = "") {
|
|
97
106
|
this.lookup = lookup;
|
|
98
107
|
this.nowMs = nowMs;
|
|
@@ -104,6 +113,7 @@ export class OwnerContextCache {
|
|
|
104
113
|
if (this.value && age < (this.value.status === "ok" ? OWNER_CONTEXT_TTL_MS : OWNER_CONTEXT_RETRY_MS))
|
|
105
114
|
return this.value;
|
|
106
115
|
if (!this.pending) {
|
|
116
|
+
this.waitEnded = false;
|
|
107
117
|
this.pending = this.lookup()
|
|
108
118
|
.then((v) => {
|
|
109
119
|
this.value = v;
|
|
@@ -113,7 +123,14 @@ export class OwnerContextCache {
|
|
|
113
123
|
.finally(() => {
|
|
114
124
|
this.pending = null;
|
|
115
125
|
});
|
|
116
|
-
|
|
126
|
+
// An in-time rejection is rethrown by the race below and leaves
|
|
127
|
+
// waitEnded false, so it is not logged. A rejection that lands after
|
|
128
|
+
// the wait already returned is otherwise invisible.
|
|
129
|
+
void this.pending.catch((error) => {
|
|
130
|
+
if (!this.waitEnded)
|
|
131
|
+
return;
|
|
132
|
+
noteOwnerContextLookupRejected(error);
|
|
133
|
+
});
|
|
117
134
|
}
|
|
118
135
|
let timer;
|
|
119
136
|
const slow = new Promise((resolve) => {
|
|
@@ -123,6 +140,7 @@ export class OwnerContextCache {
|
|
|
123
140
|
const got = await Promise.race([this.pending, slow]);
|
|
124
141
|
if (got)
|
|
125
142
|
return got;
|
|
143
|
+
this.waitEnded = true;
|
|
126
144
|
}
|
|
127
145
|
finally {
|
|
128
146
|
if (timer)
|
|
@@ -8,6 +8,11 @@ export interface BoundedDaemonFlagMonitorOptions {
|
|
|
8
8
|
}
|
|
9
9
|
export interface BoundedDaemonFlagMonitor {
|
|
10
10
|
enabled(): Promise<boolean>;
|
|
11
|
+
status(): {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
lastSuccessfulReadAgeMs: number | null;
|
|
14
|
+
heldThroughReadError: boolean;
|
|
15
|
+
};
|
|
11
16
|
close(): void;
|
|
12
17
|
}
|
|
13
18
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
1
2
|
export const HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS = 30_000;
|
|
2
3
|
export const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60_000;
|
|
3
4
|
/**
|
|
@@ -5,10 +6,11 @@ export const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60_000;
|
|
|
5
6
|
* A failed read keeps the last successful decision only until its 60s age limit.
|
|
6
7
|
*/
|
|
7
8
|
export function createBoundedDaemonFlagMonitor(options) {
|
|
8
|
-
const now = options.now ??
|
|
9
|
+
const now = options.now ?? (() => performance.now());
|
|
9
10
|
let value;
|
|
10
11
|
let lastSuccessAt;
|
|
11
12
|
let lastAttemptAt;
|
|
13
|
+
let lastReadFailed = false;
|
|
12
14
|
let successVersion = 0;
|
|
13
15
|
let refreshInFlight;
|
|
14
16
|
let expiryTimer;
|
|
@@ -34,6 +36,7 @@ export function createBoundedDaemonFlagMonitor(options) {
|
|
|
34
36
|
if (closed)
|
|
35
37
|
return false;
|
|
36
38
|
lastSuccessAt = now();
|
|
39
|
+
lastReadFailed = false;
|
|
37
40
|
const thisSuccessVersion = ++successVersion;
|
|
38
41
|
publish(next);
|
|
39
42
|
if (expiryTimer)
|
|
@@ -46,6 +49,7 @@ export function createBoundedDaemonFlagMonitor(options) {
|
|
|
46
49
|
expiryTimer.unref?.();
|
|
47
50
|
return next;
|
|
48
51
|
}).catch((error) => {
|
|
52
|
+
lastReadFailed = true;
|
|
49
53
|
options.onReadError?.(error);
|
|
50
54
|
expireIfStale();
|
|
51
55
|
return value ?? false;
|
|
@@ -74,6 +78,15 @@ export function createBoundedDaemonFlagMonitor(options) {
|
|
|
74
78
|
}
|
|
75
79
|
return value;
|
|
76
80
|
},
|
|
81
|
+
status() {
|
|
82
|
+
const age = lastSuccessAt === undefined ? null : Math.max(0, now() - lastSuccessAt);
|
|
83
|
+
const fresh = age !== null && age < HQ_ANYWHERE_FLAG_MAX_STALENESS_MS;
|
|
84
|
+
return {
|
|
85
|
+
enabled: value === true && fresh,
|
|
86
|
+
lastSuccessfulReadAgeMs: age,
|
|
87
|
+
heldThroughReadError: lastReadFailed && fresh,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
77
90
|
close() {
|
|
78
91
|
closed = true;
|
|
79
92
|
if (refreshTimer)
|
|
@@ -7,8 +7,31 @@
|
|
|
7
7
|
* out of order, so clients match them by `id`.
|
|
8
8
|
*/
|
|
9
9
|
export declare const HQD_SOCKET_NAME = "hqd.sock";
|
|
10
|
-
export declare const HQD_OPS: readonly ["ping", "resolve", "session.open", "session.get", "session.close", "policy.check", "policy.index", "journal.append", "memory.get", "memory.put", "mesh.presence", "mcp.call", "auth.login", "auth.status", "shutdown"];
|
|
10
|
+
export declare const HQD_OPS: readonly ["ping", "health", "resolve", "session.open", "session.get", "session.close", "policy.check", "policy.index", "journal.append", "memory.get", "memory.put", "mesh.presence", "mcp.call", "auth.login", "auth.status", "shutdown"];
|
|
11
11
|
export type HqdOp = (typeof HQD_OPS)[number];
|
|
12
|
+
export type HqdHealthFallbackReason = "registry_unavailable" | "manifest_missing_or_malformed" | "company_not_in_manifest" | "no_match";
|
|
13
|
+
export interface HqdHealthResult {
|
|
14
|
+
reachable: boolean;
|
|
15
|
+
version: string | null;
|
|
16
|
+
uptimeMs: number | null;
|
|
17
|
+
flag: {
|
|
18
|
+
enabled: boolean | null;
|
|
19
|
+
lastSuccessfulReadAgeMs: number | null;
|
|
20
|
+
heldThroughReadError: boolean;
|
|
21
|
+
};
|
|
22
|
+
resolution: {
|
|
23
|
+
scope: "company" | "personal" | "unknown";
|
|
24
|
+
company: string | null;
|
|
25
|
+
fallbackReason: HqdHealthFallbackReason | null;
|
|
26
|
+
};
|
|
27
|
+
socket: {
|
|
28
|
+
path: string;
|
|
29
|
+
transport: "unix_socket" | "windows_named_pipe";
|
|
30
|
+
mode: string | null;
|
|
31
|
+
ownerMatchesUser: boolean | null;
|
|
32
|
+
userOnly: boolean | null;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
12
35
|
/** A request line larger than this is rejected and the connection closed. */
|
|
13
36
|
export declare const HQD_MAX_LINE_BYTES: number;
|
|
14
37
|
export declare const HQD_DEFAULT_REQUEST_TIMEOUT_MS = 2000;
|
|
@@ -31,10 +54,10 @@ export type HqdResponse = {
|
|
|
31
54
|
error: HqdError;
|
|
32
55
|
};
|
|
33
56
|
/**
|
|
34
|
-
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
|
|
57
|
+
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/daemon/hqd.sock.
|
|
35
58
|
* On Windows the socket is a per-user named pipe.
|
|
36
59
|
*/
|
|
37
|
-
export declare function hqdSocketPath(home?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
|
|
60
|
+
export declare function hqdSocketPath(home?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, currentUserSid?: () => string | null): string;
|
|
38
61
|
export declare function encodeMessage(message: HqdRequest | HqdResponse): string;
|
|
39
62
|
export type ParsedLine = {
|
|
40
63
|
ok: true;
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
import * as net from "node:net";
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
|
+
import { readCurrentUserSid } from "../work-context/windows-acl.js";
|
|
12
13
|
export const HQD_SOCKET_NAME = "hqd.sock";
|
|
13
14
|
export const HQD_OPS = [
|
|
14
15
|
"ping",
|
|
16
|
+
"health",
|
|
15
17
|
"resolve",
|
|
16
18
|
"session.open",
|
|
17
19
|
"session.get",
|
|
@@ -31,19 +33,21 @@ export const HQD_OPS = [
|
|
|
31
33
|
export const HQD_MAX_LINE_BYTES = 1024 * 1024;
|
|
32
34
|
export const HQD_DEFAULT_REQUEST_TIMEOUT_MS = 2_000;
|
|
33
35
|
/**
|
|
34
|
-
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
|
|
36
|
+
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/daemon/hqd.sock.
|
|
35
37
|
* On Windows the socket is a per-user named pipe.
|
|
36
38
|
*/
|
|
37
|
-
export function hqdSocketPath(home = os.homedir(), env = process.env, platform = process.platform) {
|
|
39
|
+
export function hqdSocketPath(home = os.homedir(), env = process.env, platform = process.platform, currentUserSid = readCurrentUserSid) {
|
|
38
40
|
const override = env.HQ_HQD_SOCKET?.trim();
|
|
39
41
|
if (override)
|
|
40
42
|
return override;
|
|
41
43
|
if (platform === "win32") {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
+
const userSid = currentUserSid();
|
|
45
|
+
if (!userSid)
|
|
46
|
+
throw new Error("Cannot determine the current Windows user SID for the hqd pipe name");
|
|
47
|
+
return `\\\\.\\pipe\\hqd-${userSid.toLowerCase()}`;
|
|
44
48
|
}
|
|
45
49
|
const dir = env.HQ_REGISTRY_DIR?.trim() ? path.resolve(env.HQ_REGISTRY_DIR.trim()) : path.join(home, ".hq");
|
|
46
|
-
return path.join(dir, HQD_SOCKET_NAME);
|
|
50
|
+
return path.join(dir, "daemon", HQD_SOCKET_NAME);
|
|
47
51
|
}
|
|
48
52
|
export function encodeMessage(message) {
|
|
49
53
|
return `${JSON.stringify(message)}\n`;
|
|
@@ -69,6 +69,13 @@ export interface RemoteControlHostDeps {
|
|
|
69
69
|
pollMs: number;
|
|
70
70
|
signal: AbortSignal;
|
|
71
71
|
log: (line: string) => void;
|
|
72
|
+
/** Claude's per-folder bridge pointer. Codex hosts leave this unset. */
|
|
73
|
+
bridgePointerPath?: string | null;
|
|
74
|
+
/** Process probes are injectable so ownership checks are deterministic in tests. */
|
|
75
|
+
pidAlive?: (pid: number) => boolean;
|
|
76
|
+
processCommandLine?: (pid: number) => string[] | null;
|
|
77
|
+
/** Process start identity, when the pointer carries one; injectable for tests. */
|
|
78
|
+
processStartTime?: (pid: number) => string | null;
|
|
72
79
|
}
|
|
73
80
|
export declare function runRemoteControlHost(deps: RemoteControlHostDeps): Promise<number>;
|
|
74
81
|
//# sourceMappingURL=remote-control.d.ts.map
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* user's own tmux sessions.
|
|
22
22
|
*/
|
|
23
23
|
import * as fs from "node:fs";
|
|
24
|
+
import * as os from "node:os";
|
|
24
25
|
import * as path from "node:path";
|
|
25
26
|
const TMUX_SOCKET = "hq-daemon";
|
|
26
27
|
export const TMUX_SESSION = "remote-control";
|
|
@@ -101,6 +102,106 @@ export function serviceTool(name, given, find) {
|
|
|
101
102
|
export const IDLE_BEFORE_RESTART_MS = 10 * 60_000;
|
|
102
103
|
/** ...or after this long, however busy the session is. */
|
|
103
104
|
export const MAX_RESTART_WAIT_MS = 2 * 60 * 60_000;
|
|
105
|
+
function processAlive(pid) {
|
|
106
|
+
try {
|
|
107
|
+
process.kill(pid, 0);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
return error.code === "EPERM";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function commandLine(pid, run) {
|
|
115
|
+
try {
|
|
116
|
+
if (process.platform === "linux") {
|
|
117
|
+
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
const result = run("ps", ["-p", String(pid), "-o", "command="]);
|
|
120
|
+
return result.status === 0 ? result.stdout.trim().split(/\s+/).filter(Boolean) : null;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function defaultBridgePointerPath(cwd) {
|
|
127
|
+
const project = path.resolve(cwd).replace(/[^A-Za-z0-9]/g, "-");
|
|
128
|
+
return path.join(os.homedir(), ".claude", "projects", project, "bridge-pointer.json");
|
|
129
|
+
}
|
|
130
|
+
function processStartTime(pid, log) {
|
|
131
|
+
if (process.platform !== "linux")
|
|
132
|
+
return null;
|
|
133
|
+
try {
|
|
134
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
135
|
+
const close = stat.lastIndexOf(")");
|
|
136
|
+
if (close < 0) {
|
|
137
|
+
log("could not parse Linux process start time");
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
// The remaining fields start at field 3; starttime is field 22 (index 19).
|
|
141
|
+
const startTime = stat.slice(close + 1).trim().split(/\s+/)[19];
|
|
142
|
+
if (startTime === undefined)
|
|
143
|
+
log("Linux process stat did not include a start time");
|
|
144
|
+
return startTime ?? null;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
log("could not read Linux process start time");
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function foreignClaudeOwner(deps) {
|
|
152
|
+
if (deps.session !== TMUX_SESSION)
|
|
153
|
+
return null;
|
|
154
|
+
const pointerPath = deps.bridgePointerPath === undefined ? defaultBridgePointerPath(deps.cwd) : deps.bridgePointerPath;
|
|
155
|
+
if (!pointerPath)
|
|
156
|
+
return null;
|
|
157
|
+
let pointer;
|
|
158
|
+
try {
|
|
159
|
+
pointer = JSON.parse(fs.readFileSync(pointerPath, "utf8"));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const pid = pointer.pid;
|
|
165
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid)
|
|
166
|
+
return null;
|
|
167
|
+
const numericPid = pid;
|
|
168
|
+
if (!(deps.pidAlive ?? processAlive)(numericPid))
|
|
169
|
+
return null;
|
|
170
|
+
const args = (deps.processCommandLine ?? ((processId) => commandLine(processId, deps.run)))(numericPid);
|
|
171
|
+
if (!args)
|
|
172
|
+
return null;
|
|
173
|
+
const claudeIndex = args.findIndex((arg) => path.basename(arg) === "claude");
|
|
174
|
+
if (claudeIndex < 0)
|
|
175
|
+
return null;
|
|
176
|
+
const pointerStart = typeof pointer.procStart === "string" || typeof pointer.procStart === "number"
|
|
177
|
+
? String(pointer.procStart)
|
|
178
|
+
: null;
|
|
179
|
+
if (pointerStart !== null) {
|
|
180
|
+
const liveStart = deps.processStartTime
|
|
181
|
+
? deps.processStartTime(numericPid)
|
|
182
|
+
: processStartTime(numericPid, deps.log);
|
|
183
|
+
if (liveStart !== null && liveStart !== pointerStart)
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
if (pointer.source !== "repl" && !args.slice(claudeIndex + 1).includes("remote-control"))
|
|
187
|
+
return null;
|
|
188
|
+
return numericPid;
|
|
189
|
+
}
|
|
190
|
+
function safePaneTail(output) {
|
|
191
|
+
const rows = output.split(/\r?\n/);
|
|
192
|
+
while (rows.length > 0 && rows.at(-1)?.trim() === "")
|
|
193
|
+
rows.pop();
|
|
194
|
+
return rows
|
|
195
|
+
.slice(-20)
|
|
196
|
+
.map((line) => line
|
|
197
|
+
.replace(/https?:\/\/\S+/gi, "[redacted URL]")
|
|
198
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "[redacted session id]")
|
|
199
|
+
.replace(/\b(authorization\s*[:=]\s*(?:(?:basic|bearer)\s+)?)[^\r\n]+/gi, "$1[redacted]")
|
|
200
|
+
.replace(/\b(session[ _-]?id|token|api[ _-]?key|secret)\s*[:=]\s*\S+/gi, "$1=[redacted]")
|
|
201
|
+
.replace(/\b[A-Za-z0-9_-]{32,}\b/g, "[redacted token]")
|
|
202
|
+
.slice(0, 400))
|
|
203
|
+
.join("\n");
|
|
204
|
+
}
|
|
104
205
|
export async function runRemoteControlHost(deps) {
|
|
105
206
|
const tmux = (...args) => deps.run(deps.tmux, ["-L", TMUX_SOCKET, ...args]);
|
|
106
207
|
const killSession = () => tmux("kill-session", "-t", deps.session);
|
|
@@ -110,10 +211,34 @@ export async function runRemoteControlHost(deps) {
|
|
|
110
211
|
const secs = r.status === 0 ? Number.parseInt(r.stdout.trim(), 10) : Number.NaN;
|
|
111
212
|
return Number.isFinite(secs) ? secs * 1000 : null;
|
|
112
213
|
};
|
|
113
|
-
//
|
|
214
|
+
// Remove this daemon's leftover private session before checking the process
|
|
215
|
+
// pointer, so its previous command is not mistaken for a foreign owner.
|
|
114
216
|
killSession();
|
|
115
|
-
|
|
217
|
+
let waitingFor = null;
|
|
218
|
+
for (;;) {
|
|
219
|
+
if (deps.signal.aborted)
|
|
220
|
+
return 0;
|
|
221
|
+
const owner = foreignClaudeOwner(deps);
|
|
222
|
+
if (owner === null)
|
|
223
|
+
break;
|
|
224
|
+
if (waitingFor !== owner) {
|
|
225
|
+
deps.log(`${deps.cwd} is already served by claude remote-control pid ${owner} (not started by this daemon); waiting`);
|
|
226
|
+
waitingFor = owner;
|
|
227
|
+
}
|
|
228
|
+
await deps.sleep(deps.pollMs);
|
|
229
|
+
}
|
|
230
|
+
// Start the replacement only after no live foreign Claude owner remains.
|
|
231
|
+
const created = tmux("new-session", "-d", "-s", deps.session, "-x", "200", "-y", "50", "-c", deps.cwd);
|
|
232
|
+
if (created.status !== 0) {
|
|
233
|
+
deps.log(`could not create the tmux session: ${created.stderr.trim() || `tmux exited ${created.status}`}`);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
const retained = tmux("set-window-option", "-t", deps.session, "remain-on-exit", "on");
|
|
237
|
+
const started = retained.status === 0
|
|
238
|
+
? tmux("respawn-pane", "-k", "-t", deps.session, ...deps.argv)
|
|
239
|
+
: retained;
|
|
116
240
|
if (started.status !== 0) {
|
|
241
|
+
killSession();
|
|
117
242
|
deps.log(`could not start the tmux session: ${started.stderr.trim() || `tmux exited ${started.status}`}`);
|
|
118
243
|
return 1;
|
|
119
244
|
}
|
|
@@ -130,6 +255,14 @@ export async function runRemoteControlHost(deps) {
|
|
|
130
255
|
deps.log(`the ${deps.label} session ended`);
|
|
131
256
|
return 1;
|
|
132
257
|
}
|
|
258
|
+
const paneDead = tmux("display-message", "-p", "-t", deps.session, "#{pane_dead}");
|
|
259
|
+
if (paneDead.status === 0 && paneDead.stdout.trim() === "1") {
|
|
260
|
+
const pane = tmux("capture-pane", "-p", "-S", "-20", "-t", deps.session);
|
|
261
|
+
const tail = pane.status === 0 ? safePaneTail(pane.stdout) : "";
|
|
262
|
+
deps.log(`the ${deps.label} session ended${tail ? `\n${tail}` : ""}`);
|
|
263
|
+
killSession();
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
133
266
|
const now = deps.now();
|
|
134
267
|
if (updatedAt === null) {
|
|
135
268
|
const current = deps.binaryFingerprint();
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -693,24 +693,22 @@ export async function runDaemon(opts) {
|
|
|
693
693
|
let hqdShutdown = null;
|
|
694
694
|
if (anywhereRuntimeEnabled()) {
|
|
695
695
|
const refusal = hqdStartupRefusal(platform, true);
|
|
696
|
-
|
|
697
|
-
|
|
696
|
+
try {
|
|
697
|
+
const startConfig = readDaemonConfig(paths.configPath);
|
|
698
|
+
hqd = await startHqdServer({
|
|
699
|
+
socketPath: hqdSocketPath(home, env),
|
|
700
|
+
registryPath: registryFile(registryDir(home, env)),
|
|
701
|
+
hqRoot: startConfig.hqRoot ?? (env.HQ_ROOT?.trim() || undefined),
|
|
702
|
+
log,
|
|
703
|
+
onShutdown: () => hqdShutdown?.(),
|
|
704
|
+
reportError: reportHqdCrash,
|
|
705
|
+
});
|
|
698
706
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
socketPath: hqdSocketPath(home, env),
|
|
704
|
-
registryPath: registryFile(registryDir(home, env)),
|
|
705
|
-
hqRoot: startConfig.hqRoot ?? (env.HQ_ROOT?.trim() || undefined),
|
|
706
|
-
log,
|
|
707
|
-
onShutdown: () => hqdShutdown?.(),
|
|
708
|
-
reportError: reportHqdCrash,
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
|
-
catch (err) {
|
|
707
|
+
catch (err) {
|
|
708
|
+
if (refusal && err.message === refusal)
|
|
709
|
+
log("error", refusal);
|
|
710
|
+
else
|
|
712
711
|
log("error", `hqd socket did not start; services run without it: ${err.message}`);
|
|
713
|
-
}
|
|
714
712
|
}
|
|
715
713
|
}
|
|
716
714
|
// US-012: 60s presence heartbeat with version and session count.
|
|
@@ -6,13 +6,26 @@
|
|
|
6
6
|
* machine. Sessions in any repo ask it which company they belong to and
|
|
7
7
|
* register themselves here.
|
|
8
8
|
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
9
10
|
import { readRegistry, type ResolveResult } from "../registry/registry.js";
|
|
10
11
|
import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
|
|
11
12
|
import { SessionRegistry } from "./session-registry.js";
|
|
12
13
|
import { HqMcpBridge, type RunWorkerRequest } from "./ops/mcp.js";
|
|
13
14
|
import { type StdinMachineCredentials } from "./machine-login.js";
|
|
15
|
+
import { type WindowsPipeAclReport, type WindowsPipeHost } from "./windows-pipe-host.js";
|
|
14
16
|
export interface HqdServerOptions {
|
|
15
17
|
socketPath: string;
|
|
18
|
+
/** Test seam for selecting the Windows transport without changing host behavior. */
|
|
19
|
+
platform?: NodeJS.Platform;
|
|
20
|
+
/** Test seam for verifying socket ownership without changing the host uid. */
|
|
21
|
+
socketLstat?: (path: string) => fs.Stats;
|
|
22
|
+
/** Test seam for helper startup and its OS-read ACL report. */
|
|
23
|
+
windowsPipeHostFactory?: (socketPath: string) => Promise<{
|
|
24
|
+
host: WindowsPipeHost;
|
|
25
|
+
acl: WindowsPipeAclReport;
|
|
26
|
+
}>;
|
|
27
|
+
/** Test seam for rejecting an invalid helper ACL report. */
|
|
28
|
+
windowsPipeAclCheck?: (report: WindowsPipeAclReport) => WindowsPipeAclReport;
|
|
16
29
|
/** registry.json to resolve against (US-004). */
|
|
17
30
|
registryPath?: string;
|
|
18
31
|
hqRoot?: string;
|