@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/assets/hqd/windows-pipe-host.ps1 +282 -0
  3. package/dist/commands/billing-sponsor.js +20 -1
  4. package/dist/commands/core-derive-trigger-facts-native.d.ts +2 -0
  5. package/dist/commands/core-derive-trigger-facts-native.js +92 -25
  6. package/dist/commands/core-migrate-policy-triggers-native.d.ts +1 -0
  7. package/dist/commands/core-migrate-policy-triggers-native.js +26 -12
  8. package/dist/commands/doctor.js +2 -2
  9. package/dist/lib/aws-sdk-node-warning-output.js +2 -3
  10. package/dist/lib/billing/plan-lock.js +19 -2
  11. package/dist/lib/bot/owner-context.d.ts +2 -0
  12. package/dist/lib/bot/owner-context.js +19 -1
  13. package/dist/lib/daemon/flag-monitor.d.ts +5 -0
  14. package/dist/lib/daemon/flag-monitor.js +14 -1
  15. package/dist/lib/daemon/protocol.d.ts +26 -3
  16. package/dist/lib/daemon/protocol.js +9 -5
  17. package/dist/lib/daemon/remote-control.d.ts +7 -0
  18. package/dist/lib/daemon/remote-control.js +135 -2
  19. package/dist/lib/daemon/run.js +14 -16
  20. package/dist/lib/daemon/server.d.ts +13 -0
  21. package/dist/lib/daemon/server.js +283 -28
  22. package/dist/lib/daemon/windows-pipe-host.d.ts +17 -0
  23. package/dist/lib/daemon/windows-pipe-host.js +218 -0
  24. package/dist/lib/diagnostics.d.ts +9 -0
  25. package/dist/lib/diagnostics.js +12 -0
  26. package/dist/lib/doctor/checks/anywhere.d.ts +6 -1
  27. package/dist/lib/doctor/checks/anywhere.js +131 -13
  28. package/dist/lib/doctor/json-output.d.ts +2 -0
  29. package/dist/lib/doctor/json-output.js +1 -0
  30. package/dist/lib/doctor/registry.js +1 -3
  31. package/dist/lib/doctor/types.d.ts +4 -0
  32. package/dist/utils/run-bundled-script.d.ts +2 -0
  33. package/dist/utils/run-bundled-script.js +10 -1
  34. package/docs/anywhere-runtime.md +32 -5
  35. package/package.json +2 -1
@@ -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" && anywhereRuntimeEnabled();
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
- const verbose = argv.includes("--verbose");
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(() => null), companyRef);
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(() => null);
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
- this.pending.catch(() => undefined);
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 ?? Date.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 user = (env.USERNAME ?? os.userInfo().username).replace(/[^A-Za-z0-9_.-]/g, "-");
43
- return `\\\\.\\pipe\\hqd-${user}`;
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
- // A session left by an earlier host (for example after a crash) is replaced.
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
- const started = tmux("new-session", "-d", "-s", deps.session, "-x", "200", "-y", "50", "-c", deps.cwd, ...deps.argv);
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();
@@ -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
- if (refusal) {
697
- log("error", refusal);
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
- else {
700
- try {
701
- const startConfig = readDaemonConfig(paths.configPath);
702
- hqd = await startHqdServer({
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;