@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
|
-
//
|
|
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();
|