@indigoai-us/hq-cli 5.341.3 → 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 CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.341.4] — 2026-10-03
6
+ - Prevent daemon restarts from waiting on stale or already-owned Claude bridge sessions, and keep captured startup diagnostics useful without exposing authorization credentials.
7
+
5
8
  ## [5.341.3] — 2026-10-03
6
9
  - Preserve policy-trigger parity for Windows PATH resolution, filesystem paths, and frontmatter parsing.
7
10
  - Report unexpected company-resolve, plan-limit body and late owner-context failures that a bare catch used to hide. The report prints only with `--verbose` or `HQ_DEBUG` set.
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.341.3",
3
+ "version": "5.341.4",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {