@indigoai-us/hq-cli 5.151.0 → 5.152.1

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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.152.1] — 2026-09-23
6
+
7
+ ### Fixed
8
+
9
+ - Windows bot runtimes resolve provider binaries through PATH and PATHEXT, safely launch npm .cmd and .bat shims, pass multiline and long prompts through stdin or Grok ACP, and stop the full process tree.
10
+
11
+ ## [5.152.0] — 2026-09-23
12
+
13
+ ### Fixed
14
+
15
+ - `hq mesh daemon install` (plain and `--if-stale`) now bootouts, bootstraps, and kickstarts the LaunchAgent after writing the plist. Bootstrap retries launchd `5: Input/output error` up to five times, one second apart. After a CLI upgrade, a loaded job whose program no longer matches the plist is reloaded the same way.
16
+
5
17
  ## [5.151.0] — 2026-09-23
6
18
 
7
19
  - `hq report publish` no longer fails when another app it cannot see holds the report's address. It publishes at the next free address (`<slug>-2`, then `-3`) and saves that address on the report.
@@ -36,6 +36,7 @@ import { readLegacyBacklog } from "../lib/mesh/live/emit-replay.js";
36
36
  import { resolveMeshEmitMode, writeMeshConfigMode, } from "../lib/mesh/live/daemon/mode.js";
37
37
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
38
38
  import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, findAncestorHqRoot, resolveInstallHqRoot, detectPlatform, formatCliIdentityLines, formatDaemonDoctor, installDaemonIfStale, installDaemonService, readDaemonState, resolveAbsoluteNodeBinary, resolveRunningCliEntry, runMeshDaemon, uninstallDaemonService, daemonDir, } from "../lib/mesh/live/daemon/index.js";
39
+ import { defaultRunCommand } from "../lib/service-manager/launchd.js";
39
40
  import { workContextRoot } from "../lib/work-context/paths.js";
40
41
  import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-context/migrate.js";
41
42
  import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
@@ -2050,6 +2051,7 @@ async function runDaemonInstall(opts) {
2050
2051
  platform: detectPlatform(),
2051
2052
  paths,
2052
2053
  systemUser: opts.systemUser,
2054
+ run: defaultRunCommand,
2053
2055
  });
2054
2056
  if (opts.json) {
2055
2057
  console.log(JSON.stringify({
@@ -44,6 +44,6 @@ export declare function onPath(name: string, env: NodeJS.ProcessEnv): boolean;
44
44
  * skipped), matching the HQ desktop app's own lookup.
45
45
  */
46
46
  export declare function bundledClaudeBin(homeDir?: string): string | null;
47
- export declare function resolveClaudeBinary(env: NodeJS.ProcessEnv, homeDir?: string): string;
47
+ export declare function resolveClaudeBinary(env: NodeJS.ProcessEnv, homeDir?: string, platform?: NodeJS.Platform, cwd?: string): string;
48
48
  export declare function createClaudeRuntime(env?: NodeJS.ProcessEnv): BotRuntime;
49
49
  //# sourceMappingURL=claude.d.ts.map
@@ -18,6 +18,7 @@ import * as fs from "node:fs";
18
18
  import * as os from "node:os";
19
19
  import * as path from "node:path";
20
20
  import { messagesStreamEvents, messagesStreamFailure, messagesStreamResult } from "./messages-stream.js";
21
+ import { resolveRuntimeBinary } from "./windows.js";
21
22
  export const CLAUDE_PERMISSION_MODE_ENV = "HQ_BOT_CLAUDE_PERMISSION_MODE";
22
23
  export const DEFAULT_CLAUDE_PERMISSION_MODE = "bypassPermissions";
23
24
  export const GATED_CLAUDE_PERMISSION_MODE = "acceptEdits";
@@ -102,7 +103,9 @@ export function bundledClaudeBin(homeDir = os.homedir()) {
102
103
  });
103
104
  return candidates.at(-1)?.bin ?? null;
104
105
  }
105
- export function resolveClaudeBinary(env, homeDir) {
106
+ export function resolveClaudeBinary(env, homeDir, platform = process.platform, cwd = process.cwd()) {
107
+ if (platform === "win32")
108
+ return resolveRuntimeBinary("claude", env, platform, fs.existsSync, cwd);
106
109
  if (onPath("claude", env))
107
110
  return "claude";
108
111
  return bundledClaudeBin(homeDir ?? env.HOME ?? os.homedir()) ?? "claude";
@@ -111,13 +114,15 @@ export function createClaudeRuntime(env = process.env) {
111
114
  return {
112
115
  id: "claude",
113
116
  binary: "claude",
114
- resolveBinary: (turnEnv) => resolveClaudeBinary(turnEnv),
117
+ resolveBinary: (turnEnv, cwd, platform) => resolveClaudeBinary(turnEnv, undefined, platform ?? process.platform, cwd),
115
118
  buildArgs(input, scratch) {
119
+ const usesWindowsStdin = input.platform === "win32";
116
120
  const args = [
117
121
  "-p",
118
- input.prompt,
122
+ ...(usesWindowsStdin ? [] : [input.prompt]),
119
123
  "--output-format",
120
124
  "stream-json",
125
+ ...(usesWindowsStdin ? ["--input-format", "stream-json"] : []),
121
126
  "--verbose",
122
127
  "--permission-mode",
123
128
  claudePermissionMode(env, input.autoApprove !== false, input.permissionMode),
@@ -137,6 +142,14 @@ export function createClaudeRuntime(env = process.env) {
137
142
  args.push("--session-id", scratch.newSessionId);
138
143
  return args;
139
144
  },
145
+ stdinInput(input) {
146
+ return input.platform === "win32"
147
+ ? JSON.stringify({
148
+ type: "user",
149
+ message: { role: "user", content: [{ type: "text", text: input.prompt }] },
150
+ }) + "\n"
151
+ : undefined;
152
+ },
140
153
  parseOutput(stdout) {
141
154
  return messagesStreamResult(stdout, "claude");
142
155
  },
@@ -14,6 +14,7 @@
14
14
  * cannot run under this machine's account).
15
15
  */
16
16
  import { parseJsonLine, RuntimeError } from "./types.js";
17
+ import { resolveRuntimeBinary } from "./windows.js";
17
18
  /** Progress events in one codex JSONL line: finished agent messages and tool starts. */
18
19
  export function codexStreamEvents(line) {
19
20
  const ev = parseJsonLine(line);
@@ -82,7 +83,9 @@ export function createCodexRuntime() {
82
83
  return {
83
84
  id: "codex",
84
85
  binary: "codex",
86
+ resolveBinary: (env, cwd, platform) => resolveRuntimeBinary("codex", env, platform ?? process.platform, undefined, cwd),
85
87
  buildArgs(input, scratch) {
88
+ const promptArg = input.platform === "win32" ? "-" : codexPrompt(input);
86
89
  const common = ["--json", "--skip-git-repo-check", "-o", scratch.outputFile];
87
90
  if (input.autoApprove !== false) {
88
91
  // Headless: nobody can answer an approval prompt, so a gated command
@@ -96,9 +99,12 @@ export function createCodexRuntime() {
96
99
  common.push("-m", input.model);
97
100
  common.push("-c", `model_reasoning_effort=${JSON.stringify(input.effort?.trim() || "medium")}`);
98
101
  if (input.sessionId) {
99
- return ["exec", "resume", ...common, input.sessionId, codexPrompt(input)];
102
+ return ["exec", "resume", ...common, input.sessionId, promptArg];
100
103
  }
101
- return ["exec", ...common, "-C", input.cwd, codexPrompt(input)];
104
+ return ["exec", ...common, "-C", input.cwd, promptArg];
105
+ },
106
+ stdinInput(input) {
107
+ return input.platform === "win32" ? codexPrompt(input) : undefined;
102
108
  },
103
109
  parseOutput(stdout, extra) {
104
110
  let sessionId;
@@ -0,0 +1,40 @@
1
+ export interface GrokAcpClientOptions {
2
+ cwd: string;
3
+ env: NodeJS.ProcessEnv;
4
+ prompt: string;
5
+ sessionId?: string;
6
+ send: (message: Record<string, unknown>) => void;
7
+ onText: (line: string) => void;
8
+ onComplete: (sessionId: string, text: string) => void;
9
+ onFailure: (message: string) => void;
10
+ }
11
+ /** A minimal ACP client for Grok's documented stdio runtime. */
12
+ export declare class GrokAcpClient {
13
+ private readonly options;
14
+ private nextId;
15
+ private readonly pending;
16
+ private readonly terminals;
17
+ private sessionId;
18
+ private text;
19
+ private finished;
20
+ constructor(options: GrokAcpClientOptions);
21
+ start(): void;
22
+ receive(line: string): void;
23
+ private request;
24
+ private notify;
25
+ private authenticate;
26
+ private openSession;
27
+ private prompt;
28
+ private receiveUpdate;
29
+ private complete;
30
+ private fail;
31
+ private receiveRequest;
32
+ private handleClientRequest;
33
+ private createTerminal;
34
+ private getTerminal;
35
+ private terminalOutput;
36
+ private waitForTerminal;
37
+ private killTerminal;
38
+ private releaseTerminal;
39
+ }
40
+ //# sourceMappingURL=grok-acp.d.ts.map
@@ -0,0 +1,265 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { planBotRuntimeSpawn } from "./windows.js";
4
+ /** A minimal ACP client for Grok's documented stdio runtime. */
5
+ export class GrokAcpClient {
6
+ options;
7
+ nextId = 1;
8
+ pending = new Map();
9
+ terminals = new Map();
10
+ sessionId;
11
+ text = "";
12
+ finished = false;
13
+ constructor(options) {
14
+ this.options = options;
15
+ }
16
+ start() {
17
+ this.request("initialize", {
18
+ protocolVersion: 1,
19
+ clientCapabilities: { terminal: true },
20
+ }, (result) => this.authenticate(result));
21
+ }
22
+ receive(line) {
23
+ let message;
24
+ try {
25
+ const parsed = JSON.parse(line);
26
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
27
+ return;
28
+ message = parsed;
29
+ }
30
+ catch {
31
+ return;
32
+ }
33
+ if (message.method === "session/update") {
34
+ this.receiveUpdate(message);
35
+ return;
36
+ }
37
+ if (typeof message.method === "string" && (typeof message.id === "number" || typeof message.id === "string")) {
38
+ void this.receiveRequest(message);
39
+ return;
40
+ }
41
+ if (typeof message.id !== "number" && typeof message.id !== "string")
42
+ return;
43
+ const resolve = this.pending.get(message.id);
44
+ if (!resolve)
45
+ return;
46
+ this.pending.delete(message.id);
47
+ if (message.error && typeof message.error === "object") {
48
+ const error = message.error;
49
+ this.fail(typeof error.message === "string" ? error.message : "Grok ACP request failed");
50
+ return;
51
+ }
52
+ const result = message.result && typeof message.result === "object" ? message.result : {};
53
+ resolve(result);
54
+ }
55
+ request(method, params, complete) {
56
+ if (this.finished)
57
+ return;
58
+ const id = this.nextId++;
59
+ this.pending.set(id, complete);
60
+ this.options.send({ jsonrpc: "2.0", id, method, params });
61
+ }
62
+ notify(method, params = {}) {
63
+ this.options.send({ jsonrpc: "2.0", method, params });
64
+ }
65
+ authenticate(result) {
66
+ const methods = Array.isArray(result.authMethods) ? result.authMethods : [];
67
+ const authMethods = new Set(methods.flatMap((method) => method && typeof method === "object" && typeof method.id === "string"
68
+ ? [String(method.id)]
69
+ : []));
70
+ const apiKey = Object.entries(this.options.env).some(([key, value]) => key.toUpperCase() === "XAI_API_KEY" && Boolean(value?.trim()));
71
+ const methodId = apiKey && authMethods.has("xai.api_key") ? "xai.api_key" : authMethods.has("cached_token") ? "cached_token" : null;
72
+ if (!methodId) {
73
+ this.fail("Grok has no cached login; run `grok login` or configure XAI_API_KEY before starting the bot.");
74
+ return;
75
+ }
76
+ this.request("authenticate", { methodId, _meta: { headless: true } }, () => this.openSession());
77
+ }
78
+ openSession() {
79
+ if (this.options.sessionId) {
80
+ this.request("session/load", { sessionId: this.options.sessionId, cwd: this.options.cwd, mcpServers: [], _meta: { yoloMode: true } }, (result) => this.prompt(result));
81
+ return;
82
+ }
83
+ this.request("session/new", { cwd: this.options.cwd, mcpServers: [], _meta: { yoloMode: true } }, (result) => this.prompt(result));
84
+ }
85
+ prompt(result) {
86
+ if (typeof result.sessionId !== "string" || !result.sessionId) {
87
+ this.fail("Grok ACP did not return a session ID");
88
+ return;
89
+ }
90
+ this.sessionId = result.sessionId;
91
+ this.request("session/prompt", { sessionId: this.sessionId, prompt: [{ type: "text", text: this.options.prompt }] }, (response) => this.complete(response));
92
+ }
93
+ receiveUpdate(message) {
94
+ const params = message.params && typeof message.params === "object" ? message.params : {};
95
+ const update = params.update && typeof params.update === "object" ? params.update : {};
96
+ const content = update.content && typeof update.content === "object" ? update.content : {};
97
+ if (update.sessionUpdate !== "agent_message_chunk" || typeof content.text !== "string")
98
+ return;
99
+ this.text += content.text;
100
+ this.options.onText(JSON.stringify({
101
+ type: "assistant",
102
+ message: { role: "assistant", content: [{ type: "text", text: content.text }] },
103
+ parent_tool_use_id: null,
104
+ session_id: this.sessionId,
105
+ }));
106
+ }
107
+ complete(response) {
108
+ if (!this.sessionId) {
109
+ this.fail("Grok ACP completed without a session ID");
110
+ return;
111
+ }
112
+ if (typeof response.stopReason === "string" && response.stopReason.toLowerCase() === "cancelled") {
113
+ this.fail("Grok ACP cancelled the bot turn");
114
+ return;
115
+ }
116
+ this.finished = true;
117
+ this.notify("exit");
118
+ this.options.onComplete(this.sessionId, this.text);
119
+ }
120
+ fail(message) {
121
+ if (this.finished)
122
+ return;
123
+ this.finished = true;
124
+ this.pending.clear();
125
+ this.options.onFailure(message);
126
+ }
127
+ async receiveRequest(message) {
128
+ const method = message.method;
129
+ const params = message.params && typeof message.params === "object" ? message.params : {};
130
+ try {
131
+ const result = await this.handleClientRequest(method, params);
132
+ this.options.send({ jsonrpc: "2.0", id: message.id, result });
133
+ }
134
+ catch (err) {
135
+ this.options.send({
136
+ jsonrpc: "2.0",
137
+ id: message.id,
138
+ error: { code: -32000, message: err instanceof Error ? err.message : String(err) },
139
+ });
140
+ }
141
+ }
142
+ async handleClientRequest(method, params) {
143
+ const shortMethod = method.startsWith("x.ai/") ? method.slice("x.ai/".length) : method;
144
+ if (shortMethod === "session/request_permission") {
145
+ const options = Array.isArray(params.options) ? params.options : [];
146
+ const allow = options.find((option) => {
147
+ if (!option || typeof option !== "object")
148
+ return false;
149
+ const candidate = option;
150
+ return typeof candidate.optionId === "string" && /allow|approve/i.test(String(candidate.kind ?? candidate.name ?? ""));
151
+ });
152
+ if (!allow || typeof allow.optionId !== "string")
153
+ return { outcome: "cancelled" };
154
+ return { outcome: "selected", optionId: allow.optionId };
155
+ }
156
+ if (shortMethod === "terminal/create")
157
+ return this.createTerminal(params);
158
+ if (shortMethod === "terminal/output")
159
+ return this.terminalOutput(params);
160
+ if (shortMethod === "terminal/wait_for_exit")
161
+ return this.waitForTerminal(params);
162
+ if (shortMethod === "terminal/kill")
163
+ return this.killTerminal(params);
164
+ if (shortMethod === "terminal/release")
165
+ return this.releaseTerminal(params);
166
+ throw new Error(`Unsupported Grok ACP client request: ${method}`);
167
+ }
168
+ createTerminal(params) {
169
+ if (typeof params.command !== "string" || !params.command)
170
+ throw new Error("Grok ACP terminal request has no command");
171
+ const args = Array.isArray(params.args) ? params.args.filter((arg) => typeof arg === "string") : [];
172
+ const env = { ...this.options.env };
173
+ if (Array.isArray(params.env)) {
174
+ for (const entry of params.env) {
175
+ if (!entry || typeof entry !== "object")
176
+ continue;
177
+ const item = entry;
178
+ const name = item.name;
179
+ const value = item.value;
180
+ if (typeof name === "string" && typeof value === "string") {
181
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name.toUpperCase()) ?? name;
182
+ env[key] = value;
183
+ }
184
+ }
185
+ }
186
+ const commandPlan = planBotRuntimeSpawn(params.command, args, env);
187
+ const child = spawn(commandPlan.cmd, commandPlan.args, {
188
+ cwd: typeof params.cwd === "string" ? params.cwd : this.options.cwd,
189
+ env,
190
+ stdio: ["ignore", "pipe", "pipe"],
191
+ ...(commandPlan.shell ? { shell: commandPlan.shell } : {}),
192
+ ...(commandPlan.windowsVerbatimArguments !== undefined
193
+ ? { windowsVerbatimArguments: commandPlan.windowsVerbatimArguments }
194
+ : {}),
195
+ windowsHide: true,
196
+ });
197
+ let resolveExit;
198
+ const exited = new Promise((resolve) => {
199
+ resolveExit = resolve;
200
+ });
201
+ const handle = { child, output: "", truncated: false, exitCode: null, exited };
202
+ const append = (chunk) => {
203
+ const max = 1_000_000;
204
+ handle.output += chunk.toString();
205
+ if (Buffer.byteLength(handle.output) > max) {
206
+ const bytes = Buffer.from(handle.output);
207
+ handle.output = bytes.subarray(bytes.length - max).toString();
208
+ handle.truncated = true;
209
+ }
210
+ };
211
+ child.stdout?.on("data", append);
212
+ child.stderr?.on("data", append);
213
+ child.once("error", (err) => {
214
+ handle.output += `\n[hq bot] terminal command failed to start: ${err.message}`;
215
+ handle.exitCode = 1;
216
+ resolveExit(1);
217
+ });
218
+ child.once("close", (code) => {
219
+ handle.exitCode = code;
220
+ resolveExit(code);
221
+ });
222
+ const terminalId = randomUUID();
223
+ this.terminals.set(terminalId, handle);
224
+ return { terminalId };
225
+ }
226
+ getTerminal(params) {
227
+ if (typeof params.terminalId !== "string")
228
+ throw new Error("Grok ACP terminal request has no terminalId");
229
+ const terminal = this.terminals.get(params.terminalId);
230
+ if (!terminal)
231
+ throw new Error("Grok ACP requested an unknown terminal");
232
+ return terminal;
233
+ }
234
+ terminalOutput(params) {
235
+ const terminal = this.getTerminal(params);
236
+ const limit = typeof params.outputByteLimit === "number" && params.outputByteLimit > 0 ? params.outputByteLimit : 65_536;
237
+ const bytes = Buffer.from(terminal.output);
238
+ const output = bytes.length > limit ? bytes.subarray(bytes.length - limit).toString() : terminal.output;
239
+ return {
240
+ output,
241
+ exitStatus: terminal.exitCode === null ? null : { exitCode: terminal.exitCode },
242
+ truncated: terminal.truncated || bytes.length > limit,
243
+ };
244
+ }
245
+ async waitForTerminal(params) {
246
+ const terminal = this.getTerminal(params);
247
+ const exitCode = terminal.exitCode ?? (await terminal.exited);
248
+ return { exitCode };
249
+ }
250
+ killTerminal(params) {
251
+ const terminal = this.getTerminal(params);
252
+ if (terminal.child.exitCode === null && terminal.child.signalCode === null)
253
+ terminal.child.kill();
254
+ return {};
255
+ }
256
+ releaseTerminal(params) {
257
+ const terminal = this.getTerminal(params);
258
+ if (terminal.child.exitCode === null && terminal.child.signalCode === null) {
259
+ throw new Error("Grok ACP cannot release a terminal command that is still running");
260
+ }
261
+ this.terminals.delete(String(params.terminalId));
262
+ return {};
263
+ }
264
+ }
265
+ //# sourceMappingURL=grok-acp.js.map
@@ -20,11 +20,13 @@
20
20
  * still accepted.
21
21
  */
22
22
  import { type BotRuntime, type RuntimeTurnInput } from "./types.js";
23
+ import type { SpawnPlan } from "../../../utils/windows-spawn.js";
23
24
  export declare const GROK_OUTPUT_FORMAT_LEGACY = "streaming-messages-json";
24
25
  export declare const GROK_OUTPUT_FORMAT_STREAMING_JSON = "streaming-json";
25
26
  export declare function resetGrokOutputFormatCache(): void;
26
27
  /** Pick the streaming `--output-format` grok's `--help` says it accepts. */
27
28
  export declare function grokOutputFormatFromHelp(helpText: string): string;
29
+ export declare function grokHelpSpawnPlan(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, existsSync?: (candidate: string) => boolean): SpawnPlan;
28
30
  /** Probe `grok --help` once and cache the streaming format. */
29
31
  export declare function probeGrokOutputFormat(readHelp?: () => string): string;
30
32
  /**
@@ -22,6 +22,7 @@
22
22
  import { execFileSync } from "node:child_process";
23
23
  import { messagesStreamEvents, messagesStreamFailure, messagesStreamResult } from "./messages-stream.js";
24
24
  import { parseJsonLine, RuntimeError } from "./types.js";
25
+ import { planBotRuntimeSpawn, resolveRuntimeBinary } from "./windows.js";
25
26
  export const GROK_OUTPUT_FORMAT_LEGACY = "streaming-messages-json";
26
27
  export const GROK_OUTPUT_FORMAT_STREAMING_JSON = "streaming-json";
27
28
  let cachedOutputFormat;
@@ -45,12 +46,21 @@ export function grokOutputFormatFromHelp(helpText) {
45
46
  return GROK_OUTPUT_FORMAT_STREAMING_JSON;
46
47
  return GROK_OUTPUT_FORMAT_LEGACY;
47
48
  }
49
+ export function grokHelpSpawnPlan(env = process.env, platform = process.platform, existsSync) {
50
+ const command = resolveRuntimeBinary("grok", env, platform, existsSync);
51
+ return planBotRuntimeSpawn(command, ["--help"], env, platform);
52
+ }
48
53
  function defaultGrokHelp() {
54
+ const plan = grokHelpSpawnPlan();
49
55
  try {
50
- return execFileSync("grok", ["--help"], {
56
+ return execFileSync(plan.cmd, plan.args, {
51
57
  encoding: "utf8",
52
58
  timeout: 5_000,
53
59
  stdio: ["ignore", "pipe", "pipe"],
60
+ ...(plan.shell ? { shell: plan.shell } : {}),
61
+ ...(plan.windowsVerbatimArguments !== undefined
62
+ ? { windowsVerbatimArguments: plan.windowsVerbatimArguments }
63
+ : {}),
54
64
  });
55
65
  }
56
66
  catch (err) {
@@ -99,7 +109,16 @@ export function createGrokRuntime(opts = {}) {
99
109
  return {
100
110
  id: "grok",
101
111
  binary: "grok",
112
+ resolveBinary: (env, cwd, platform) => resolveRuntimeBinary("grok", env, platform ?? process.platform, undefined, cwd),
102
113
  buildArgs(input) {
114
+ if (input.platform === "win32") {
115
+ const args = ["--no-auto-update", "agent", "--always-approve", "--no-subagents"];
116
+ if (input.model)
117
+ args.push("--model", input.model);
118
+ args.push("--effort", input.effort?.trim() || "medium");
119
+ args.push("stdio");
120
+ return args;
121
+ }
103
122
  const args = [
104
123
  "-p",
105
124
  grokPrompt(input),
@@ -117,6 +136,11 @@ export function createGrokRuntime(opts = {}) {
117
136
  args.push("-r", input.sessionId);
118
137
  return args;
119
138
  },
139
+ spawnProtocol(input) {
140
+ return input.platform === "win32"
141
+ ? { kind: "grok-acp", prompt: grokPrompt(input), ...(input.sessionId ? { sessionId: input.sessionId } : {}) }
142
+ : undefined;
143
+ },
120
144
  parseOutput(stdout) {
121
145
  // Older grok: one `{ text, sessionId }` object.
122
146
  const legacy = stdout
@@ -26,6 +26,7 @@ export interface RunTurnDeps {
26
26
  timeoutMs?: number;
27
27
  scratchDir?: string;
28
28
  newSessionId?: () => string;
29
+ platform?: NodeJS.Platform;
29
30
  }
30
31
  /**
31
32
  * Run one turn: spawn the runtime CLI, parse the reply, clean up scratch.
@@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
+ import { planBotRuntimeSpawn } from "./windows.js";
7
+ import { GrokAcpClient } from "./grok-acp.js";
6
8
  import { createClaudeRuntime } from "./claude.js";
7
9
  import { createCodexRuntime } from "./codex.js";
8
10
  import { createGrokRuntime, grokRetryArgsAfterInvalidOutputFormat, probeGrokOutputFormat } from "./grok.js";
@@ -72,12 +74,19 @@ export const defaultSpawn = (command, args, options) => new Promise((resolve, re
72
74
  let stderr = "";
73
75
  let partial = "";
74
76
  let settled = false;
75
- const child = spawn(command, args, {
77
+ const plan = planBotRuntimeSpawn(command, args, options.env);
78
+ const child = spawn(plan.cmd, plan.args, {
76
79
  cwd: options.cwd,
77
80
  env: options.env,
78
- stdio: ["ignore", "pipe", "pipe"],
81
+ stdio: [options.stdin !== undefined || options.protocol ? "pipe" : "ignore", "pipe", "pipe"],
82
+ ...(plan.shell ? { shell: plan.shell } : {}),
83
+ ...(plan.windowsVerbatimArguments !== undefined
84
+ ? { windowsVerbatimArguments: plan.windowsVerbatimArguments }
85
+ : {}),
86
+ windowsHide: true,
79
87
  });
80
88
  let stopping = false;
89
+ let protocolComplete = false;
81
90
  const signalTree = (sig, pids) => {
82
91
  for (const p of pids) {
83
92
  try {
@@ -90,6 +99,55 @@ export const defaultSpawn = (command, args, options) => new Promise((resolve, re
90
99
  };
91
100
  const kill = () => {
92
101
  stopping = true;
102
+ if (process.platform === "win32" && child.pid !== undefined) {
103
+ const rootKey = Object.keys(options.env).find((key) => key.toUpperCase() === "SYSTEMROOT");
104
+ const systemRoot = rootKey ? options.env[rootKey] : undefined;
105
+ const taskkill = path.win32.join(systemRoot || "C:\\Windows", "System32", "taskkill.exe");
106
+ let finished = false;
107
+ const fallbackKill = (reason) => {
108
+ if (finished)
109
+ return;
110
+ finished = true;
111
+ stderr += `\n[hq bot] could not stop the Windows process tree with taskkill.exe: ${reason}`;
112
+ try {
113
+ child.kill();
114
+ }
115
+ catch (err) {
116
+ stderr += `\n[hq bot] could not stop the runtime wrapper: ${err instanceof Error ? err.message : String(err)}`;
117
+ }
118
+ };
119
+ let killer;
120
+ try {
121
+ killer = spawn(taskkill, ["/PID", String(child.pid), "/T", "/F"], {
122
+ env: options.env,
123
+ stdio: "ignore",
124
+ windowsHide: true,
125
+ });
126
+ }
127
+ catch (err) {
128
+ fallbackKill(err instanceof Error ? err.message : String(err));
129
+ return;
130
+ }
131
+ const fallbackTimer = setTimeout(() => fallbackKill("taskkill.exe did not finish within 5 seconds"), 5_000);
132
+ killer.once("error", (err) => {
133
+ clearTimeout(fallbackTimer);
134
+ fallbackKill(err.message);
135
+ });
136
+ killer.once("close", (code) => {
137
+ clearTimeout(fallbackTimer);
138
+ finished = true;
139
+ if (code !== 0 && child.exitCode === null && child.signalCode === null) {
140
+ stderr += `\n[hq bot] taskkill.exe exited with code ${code} while stopping the runtime process tree`;
141
+ try {
142
+ child.kill();
143
+ }
144
+ catch (err) {
145
+ stderr += `\n[hq bot] could not stop the runtime wrapper: ${err instanceof Error ? err.message : String(err)}`;
146
+ }
147
+ }
148
+ });
149
+ return;
150
+ }
93
151
  // Snapshot the tree before the parent dies (its children get re-parented).
94
152
  const tree = process.platform === "win32" || child.pid === undefined ? [] : descendantPids(child.pid);
95
153
  try {
@@ -139,13 +197,63 @@ export const defaultSpawn = (command, args, options) => new Promise((resolve, re
139
197
  /* a listener must never break the turn */
140
198
  }
141
199
  };
200
+ const emitProtocolLine = (line) => {
201
+ stdout += line + "\n";
202
+ emit(line);
203
+ };
142
204
  const cleanup = () => {
143
205
  if (timer)
144
206
  clearTimeout(timer);
145
207
  options.signal?.removeEventListener("abort", onAbort);
146
208
  };
209
+ let protocolPartial = "";
210
+ const protocol = options.protocol?.kind === "grok-acp"
211
+ ? new GrokAcpClient({
212
+ cwd: options.cwd,
213
+ env: options.env,
214
+ prompt: options.protocol.prompt,
215
+ ...(options.protocol.sessionId ? { sessionId: options.protocol.sessionId } : {}),
216
+ send: (message) => {
217
+ try {
218
+ child.stdin?.write(JSON.stringify(message) + "\n");
219
+ }
220
+ catch (err) {
221
+ stderr += `\n[hq bot] could not write to the Grok ACP process: ${err instanceof Error ? err.message : String(err)}`;
222
+ }
223
+ },
224
+ onText: emitProtocolLine,
225
+ onComplete: (sessionId, text) => {
226
+ protocolComplete = true;
227
+ emitProtocolLine(JSON.stringify({ type: "result", subtype: "success", is_error: false, result: text, session_id: sessionId }));
228
+ child.stdin?.end();
229
+ setTimeout(() => {
230
+ if (!settled && child.exitCode === null && child.signalCode === null)
231
+ kill();
232
+ }, STOP_SETTLE_GRACE_MS).unref?.();
233
+ },
234
+ onFailure: (message) => {
235
+ stderr += `\n[hq bot] ${message}`;
236
+ stopping = true;
237
+ child.stdin?.end();
238
+ setTimeout(() => {
239
+ if (!settled && child.exitCode === null && child.signalCode === null)
240
+ kill();
241
+ }, STOP_SETTLE_GRACE_MS).unref?.();
242
+ },
243
+ })
244
+ : undefined;
147
245
  child.stdout?.on("data", (d) => {
148
246
  const chunk = d.toString();
247
+ if (protocol) {
248
+ protocolPartial += chunk;
249
+ let nl = protocolPartial.indexOf("\n");
250
+ while (nl >= 0) {
251
+ protocol.receive(protocolPartial.slice(0, nl).replace(/\r$/, ""));
252
+ protocolPartial = protocolPartial.slice(nl + 1);
253
+ nl = protocolPartial.indexOf("\n");
254
+ }
255
+ return;
256
+ }
149
257
  stdout += chunk;
150
258
  if (!options.onStdoutLine)
151
259
  return;
@@ -160,6 +268,10 @@ export const defaultSpawn = (command, args, options) => new Promise((resolve, re
160
268
  child.stderr?.on("data", (d) => {
161
269
  stderr += d.toString();
162
270
  });
271
+ child.stdin?.on("error", (err) => {
272
+ if (!settled)
273
+ stderr += `\n[hq bot] could not write input to ${command}: ${err.message}`;
274
+ });
163
275
  child.on("error", (err) => {
164
276
  if (settled)
165
277
  return;
@@ -167,6 +279,10 @@ export const defaultSpawn = (command, args, options) => new Promise((resolve, re
167
279
  cleanup();
168
280
  reject(err);
169
281
  });
282
+ if (protocol && !options.signal?.aborted)
283
+ protocol.start();
284
+ else if (options.stdin !== undefined)
285
+ child.stdin?.end(options.stdin);
170
286
  // A stopped process can exit while something it started (and we could not
171
287
  // reach) still holds stdout open; settle shortly after the exit instead of
172
288
  // waiting for that pipe.
@@ -192,7 +308,12 @@ export const defaultSpawn = (command, args, options) => new Promise((resolve, re
192
308
  cleanup();
193
309
  if (partial)
194
310
  emit(partial);
195
- resolve({ code, signal, stdout, stderr });
311
+ resolve({
312
+ code: protocol ? (protocolComplete ? 0 : 1) : code,
313
+ signal: protocolComplete ? null : signal,
314
+ stdout,
315
+ stderr,
316
+ });
196
317
  });
197
318
  });
198
319
  /**
@@ -204,9 +325,12 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
204
325
  const scratchDir = deps.scratchDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "hq-bot-turn-"));
205
326
  const outputFile = path.join(scratchDir, "last-message.txt");
206
327
  const newSessionId = input.newSessionId ?? (deps.newSessionId ?? randomUUID)();
207
- const args = runtime.buildArgs(input, { outputFile, newSessionId });
328
+ const turnInput = { ...input, platform: deps.platform ?? input.platform ?? process.platform };
329
+ const args = runtime.buildArgs(turnInput, { outputFile, newSessionId });
208
330
  const env = input.env ?? deps.env ?? process.env;
209
- const binary = runtime.resolveBinary?.(env) ?? runtime.binary;
331
+ const binary = runtime.resolveBinary?.(env, input.cwd, turnInput.platform) ?? runtime.binary;
332
+ const stdin = runtime.stdinInput?.(turnInput);
333
+ const protocol = runtime.spawnProtocol?.(turnInput);
210
334
  const onEvent = input.onEvent;
211
335
  const onStdoutLine = onEvent && runtime.parseStreamLine
212
336
  ? (line) => {
@@ -224,6 +348,8 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
224
348
  const spawnOptions = {
225
349
  cwd: input.cwd,
226
350
  env,
351
+ ...(stdin !== undefined ? { stdin } : {}),
352
+ ...(protocol ? { protocol } : {}),
227
353
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
228
354
  ...(onStdoutLine ? { onStdoutLine } : {}),
229
355
  ...(input.signal ? { signal: input.signal } : {}),
@@ -42,6 +42,8 @@ export interface RuntimeTurnInput {
42
42
  onEvent?: (event: RuntimeStreamEvent) => void;
43
43
  /** Aborting kills the runtime process (owner stop); the turn then fails with `aborted`. */
44
44
  signal?: AbortSignal;
45
+ /** Runtime host platform; set by runRuntimeTurn and injectable for platform tests. */
46
+ platform?: NodeJS.Platform;
45
47
  }
46
48
  /**
47
49
  * What a runtime's streaming output means to the bot, per stdout line:
@@ -76,7 +78,16 @@ export interface SpawnOptions {
76
78
  onStdoutLine?: (line: string) => void;
77
79
  /** Kill the process when aborted. */
78
80
  signal?: AbortSignal;
81
+ /** Optional full input stream for CLIs that accept prompts through stdin. */
82
+ stdin?: string;
83
+ /** Optional interactive protocol implemented over the child stdin/stdout streams. */
84
+ protocol?: SpawnProtocol;
79
85
  }
86
+ export type SpawnProtocol = {
87
+ kind: "grok-acp";
88
+ prompt: string;
89
+ sessionId?: string;
90
+ };
80
91
  export interface SpawnLike {
81
92
  (command: string, args: string[], options: SpawnOptions): Promise<SpawnResult>;
82
93
  }
@@ -101,7 +112,11 @@ export interface BotRuntime {
101
112
  * PATH (e.g. Claude Code that only exists inside the Claude desktop app).
102
113
  * Resolved per turn so an app update that swaps the version folder is picked up.
103
114
  */
104
- resolveBinary?(env: NodeJS.ProcessEnv): string;
115
+ resolveBinary?(env: NodeJS.ProcessEnv, cwd?: string, platform?: NodeJS.Platform): string;
116
+ /** Optional full prompt stream sent through stdin instead of argv. */
117
+ stdinInput?(input: RuntimeTurnInput): string | undefined;
118
+ /** Optional bidirectional process protocol for this runtime. */
119
+ spawnProtocol?(input: RuntimeTurnInput): SpawnProtocol | undefined;
105
120
  /** Build the argv for one turn. Pure. */
106
121
  buildArgs(input: RuntimeTurnInput, scratch: {
107
122
  outputFile: string;
@@ -0,0 +1,10 @@
1
+ import { type SpawnPlan } from "../../../utils/windows-spawn.js";
2
+ type ExistsSync = (candidate: string) => boolean;
3
+ /** Resolve an npm-installed provider command using Windows PATH and PATHEXT. */
4
+ export declare function resolveWindowsExecutable(name: string, env: NodeJS.ProcessEnv, existsSync?: ExistsSync, cwd?: string): string | null;
5
+ /** Preserve non-Windows lookup. Windows returns the first PATH/PATHEXT match. */
6
+ export declare function resolveRuntimeBinary(name: string, env: NodeJS.ProcessEnv, platform?: NodeJS.Platform, existsSync?: ExistsSync, cwd?: string): string;
7
+ /** Directly spawn native files; pass only batch shims through cmd.exe. */
8
+ export declare function planBotRuntimeSpawn(command: string, args: readonly string[], env: NodeJS.ProcessEnv, platform?: NodeJS.Platform): SpawnPlan;
9
+ export {};
10
+ //# sourceMappingURL=windows.d.ts.map
@@ -0,0 +1,53 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { buildCmdExeSpawnPlan, isWindowsBatchFile, } from "../../../utils/windows-spawn.js";
4
+ const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
5
+ const SUPPORTED_PATHEXT = new Set([".com", ".exe", ".cmd", ".bat"]);
6
+ function envValue(env, name) {
7
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name);
8
+ return key ? env[key] : undefined;
9
+ }
10
+ /** Resolve an npm-installed provider command using Windows PATH and PATHEXT. */
11
+ export function resolveWindowsExecutable(name, env, existsSync = fs.existsSync, cwd = process.cwd()) {
12
+ const pathValue = envValue(env, "PATH") ?? "";
13
+ const rawExtensions = (envValue(env, "PATHEXT") || DEFAULT_PATHEXT)
14
+ .split(";")
15
+ .map((extension) => {
16
+ const trimmed = extension.trim();
17
+ if (!trimmed)
18
+ return "";
19
+ return (trimmed.startsWith(".") ? trimmed : "." + trimmed).toLowerCase();
20
+ });
21
+ const extensions = [...new Set(rawExtensions.filter((extension) => SUPPORTED_PATHEXT.has(extension)))];
22
+ if (extensions.includes(".exe")) {
23
+ extensions.splice(extensions.indexOf(".exe"), 1);
24
+ extensions.unshift(".exe");
25
+ }
26
+ const searchExtensions = path.win32.extname(name) ? [""] : extensions;
27
+ for (const rawDir of pathValue.split(";")) {
28
+ const dir = rawDir.trim().replace(/^"(.*)"$/, "$1");
29
+ if (!dir)
30
+ continue;
31
+ const absoluteDir = path.win32.resolve(cwd, dir);
32
+ for (const extension of searchExtensions) {
33
+ const candidate = path.win32.join(absoluteDir, name + extension);
34
+ if (existsSync(candidate))
35
+ return candidate;
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ /** Preserve non-Windows lookup. Windows returns the first PATH/PATHEXT match. */
41
+ export function resolveRuntimeBinary(name, env, platform = process.platform, existsSync = fs.existsSync, cwd = process.cwd()) {
42
+ if (platform !== "win32")
43
+ return name;
44
+ return resolveWindowsExecutable(name, env, existsSync, cwd) ?? name;
45
+ }
46
+ /** Directly spawn native files; pass only batch shims through cmd.exe. */
47
+ export function planBotRuntimeSpawn(command, args, env, platform = process.platform) {
48
+ if (isWindowsBatchFile(command, platform)) {
49
+ return buildCmdExeSpawnPlan(command, args, env, platform);
50
+ }
51
+ return { cmd: command, args: [...args], shell: false };
52
+ }
53
+ //# sourceMappingURL=windows.js.map
@@ -12,7 +12,7 @@
12
12
  * Tests must not actually install services — use a temp HOME/prefix.
13
13
  */
14
14
  import * as fs from "node:fs";
15
- import { type RunCommand } from "../../../service-manager/launchd.js";
15
+ import { type RunCommand, type SleepFn } from "../../../service-manager/launchd.js";
16
16
  export type DaemonInstallPlatform = "darwin" | "linux" | "win32" | "other";
17
17
  /** Result of one schtasks.exe invocation (tests inject; never the real binary). */
18
18
  export interface SchtasksResult {
@@ -93,11 +93,6 @@ export interface RestartDaemonResult {
93
93
  restarted: boolean;
94
94
  message?: string;
95
95
  }
96
- /**
97
- * Restart the installed user daemon after a CLI upgrade. This is deliberately
98
- * best-effort: an update must remain successful if a user has no service,
99
- * launchctl/systemd is unavailable, or the user manager is not running.
100
- */
101
96
  export declare function restartDaemonServiceIfInstalled(opts?: {
102
97
  platform?: DaemonInstallPlatform;
103
98
  home?: string;
@@ -110,7 +105,10 @@ export declare function restartDaemonServiceIfInstalled(opts?: {
110
105
  writeFileSync?: typeof fs.writeFileSync;
111
106
  run?: (command: string, args: string[]) => {
112
107
  status: number | null;
108
+ stdout?: string;
109
+ stderr?: string;
113
110
  };
111
+ sleep?: SleepFn;
114
112
  /** Windows: terminate the recorded daemon pid after `schtasks /End` (tests inject). */
115
113
  kill?: (pid: number) => void;
116
114
  }): RestartDaemonResult;
@@ -149,6 +147,7 @@ export declare function installDaemonIfStale(opts: {
149
147
  systemUser?: string;
150
148
  run?: RunCommand;
151
149
  uid?: number;
150
+ sleep?: SleepFn;
152
151
  writeFileSync?: typeof fs.writeFileSync;
153
152
  mkdirSync?: typeof fs.mkdirSync;
154
153
  reloadSystemd?: () => {
@@ -282,6 +281,10 @@ export declare function installDaemonService(opts: {
282
281
  stderr: string;
283
282
  };
284
283
  windowsLegacyTaskPresent?: boolean;
284
+ /** launchctl/systemctl seam — when set on darwin, bootout/bootstrap/kickstart after writing the plist. */
285
+ run?: RunCommand;
286
+ uid?: number;
287
+ sleep?: SleepFn;
285
288
  }): InstallResult;
286
289
  export declare function uninstallDaemonService(opts: {
287
290
  platform?: DaemonInstallPlatform;
@@ -22,7 +22,7 @@ import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqR
22
22
  import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
23
23
  import { retireLegacyListen } from "./legacy-listen.js";
24
24
  import { readDaemonState } from "./state.js";
25
- import { activateLaunchd } from "../../../service-manager/launchd.js";
25
+ import { activateLaunchd, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
26
26
  import { activateSystemd } from "../../../service-manager/systemd.js";
27
27
  /**
28
28
  * `schtasks /End` terminates the task's root process only. The daemon is
@@ -139,13 +139,30 @@ function migrateLegacyDaemonUnit(opts) {
139
139
  * best-effort: an update must remain successful if a user has no service,
140
140
  * launchctl/systemd is unavailable, or the user manager is not running.
141
141
  */
142
+ function asRunCommand(run) {
143
+ return (command, args) => {
144
+ const result = run(command, args);
145
+ return {
146
+ status: result.status,
147
+ stdout: result.stdout ?? "",
148
+ stderr: result.stderr ?? "",
149
+ };
150
+ };
151
+ }
142
152
  export function restartDaemonServiceIfInstalled(opts = {}) {
143
153
  const platform = opts.platform ?? detectPlatform();
144
154
  const home = opts.home ?? os.homedir();
145
155
  const existsSync = opts.existsSync ?? fs.existsSync;
146
156
  const readFileSync = opts.readFileSync ?? fs.readFileSync;
147
157
  const writeFileSync = opts.writeFileSync ?? fs.writeFileSync;
148
- const run = opts.run ?? ((command, args) => spawnSync(command, args, { stdio: "ignore" }));
158
+ const run = opts.run ?? ((command, args) => {
159
+ const result = spawnSync(command, args, { encoding: "utf8" });
160
+ return {
161
+ status: result.status,
162
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
163
+ stderr: result.error ? result.error.message : (typeof result.stderr === "string" ? result.stderr : ""),
164
+ };
165
+ });
149
166
  try {
150
167
  migrateLegacyDaemonUnit({ platform, home, hqRoot: opts.hqRoot, env: opts.env, existsSync, readFileSync, writeFileSync, run });
151
168
  if (platform === "darwin") {
@@ -155,7 +172,22 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
155
172
  const uid = opts.uid ?? process.getuid?.();
156
173
  if (typeof uid !== "number")
157
174
  return { attempted: false, restarted: false };
158
- const result = run("launchctl", ["kickstart", "-k", `gui/${uid}/${LAUNCHD_LABEL}`]);
175
+ const target = `gui/${uid}/${LAUNCHD_LABEL}`;
176
+ const printed = run("launchctl", ["print", target]);
177
+ const loadedArgs = parseLaunchctlPrintArguments(printed.stdout ?? "");
178
+ const rawPlist = readFileSync(plist);
179
+ const plistText = Buffer.isBuffer(rawPlist) ? rawPlist.toString("utf8") : String(rawPlist);
180
+ const plistArgs = launchdPlistProgramArguments(plistText);
181
+ if (launchdProgramArgumentsDiffer(loadedArgs, plistArgs)) {
182
+ try {
183
+ activateLaunchd(plist, asRunCommand(run), uid, { sleep: opts.sleep });
184
+ return { attempted: true, restarted: true };
185
+ }
186
+ catch {
187
+ return { attempted: true, restarted: false };
188
+ }
189
+ }
190
+ const result = run("launchctl", ["kickstart", "-k", target]);
159
191
  return { attempted: true, restarted: result.status === 0 };
160
192
  }
161
193
  if (platform === "linux") {
@@ -306,11 +338,11 @@ export function installDaemonIfStale(opts) {
306
338
  kill: opts.kill,
307
339
  runCommand: opts.runCommand,
308
340
  windowsLegacyTaskPresent: opts.windowsLegacyTaskPresent,
341
+ run,
342
+ uid: opts.uid,
343
+ sleep: opts.sleep,
309
344
  });
310
- if (platform === "darwin") {
311
- activateLaunchd(opts.paths.plistPath, run, opts.uid);
312
- }
313
- else if (platform === "linux" && !opts.systemUser && !opts.paths.systemUser) {
345
+ if (platform === "linux" && !opts.systemUser && !opts.paths.systemUser) {
314
346
  activateSystemd(SYSTEMD_UNIT_NAME, run);
315
347
  }
316
348
  return {
@@ -753,7 +785,12 @@ export function installDaemonService(opts) {
753
785
  const rendered = renderLaunchdPlist(paths);
754
786
  mkdirSync(paths.launchAgentsDir, { recursive: true });
755
787
  writeFileSync(paths.plistPath, rendered, { mode: 0o600 });
756
- opts.activate?.(platform, paths.plistPath);
788
+ if (opts.run) {
789
+ activateLaunchd(paths.plistPath, opts.run, opts.uid, { sleep: opts.sleep });
790
+ }
791
+ else {
792
+ opts.activate?.(platform, paths.plistPath);
793
+ }
757
794
  return {
758
795
  platform,
759
796
  action: "install",
@@ -9,9 +9,8 @@ import { type RunCommand } from "./launchd.js";
9
9
  import { type ServiceHostPaths, type ServicePlatform, type ServiceSpec } from "./types.js";
10
10
  export type { ServiceSpec, ServiceHostPaths, ServicePlatform } from "./types.js";
11
11
  export { detectServicePlatform } from "./types.js";
12
- export { renderLaunchdPlist } from "./launchd.js";
12
+ export { defaultRunCommand, renderLaunchdPlist } from "./launchd.js";
13
13
  export { renderSystemdUserUnit, systemdUnitName } from "./systemd.js";
14
- export declare const defaultRunCommand: RunCommand;
15
14
  export interface ServiceManagerDeps {
16
15
  platform?: ServicePlatform;
17
16
  run?: RunCommand;
@@ -4,22 +4,13 @@
4
4
  * injectable so the kit tests exercise the real rendering and dispatch
5
5
  * without touching the host's service manager.
6
6
  */
7
- import { spawnSync } from "node:child_process";
8
7
  import * as fs from "node:fs";
9
- import { activateLaunchd, deactivateLaunchd, launchAgentsDir, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "./launchd.js";
8
+ import { activateLaunchd, deactivateLaunchd, defaultRunCommand, launchAgentsDir, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "./launchd.js";
10
9
  import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdIsActive, systemdUnitName, systemdUnitPath, systemdUserDir, } from "./systemd.js";
11
10
  import { detectServicePlatform, } from "./types.js";
12
11
  export { detectServicePlatform } from "./types.js";
13
- export { renderLaunchdPlist } from "./launchd.js";
12
+ export { defaultRunCommand, renderLaunchdPlist } from "./launchd.js";
14
13
  export { renderSystemdUserUnit, systemdUnitName } from "./systemd.js";
15
- export const defaultRunCommand = (cmd, args) => {
16
- const r = spawnSync(cmd, args, { encoding: "utf8" });
17
- return {
18
- status: r.status,
19
- stdout: r.stdout ?? "",
20
- stderr: r.error ? r.error.message : (r.stderr ?? ""),
21
- };
22
- };
23
14
  function unitPathFor(platform, home, spec) {
24
15
  return platform === "darwin" ? launchdPlistPath(home, spec) : systemdUnitPath(home, spec);
25
16
  }
@@ -16,8 +16,36 @@ export type RunCommand = (cmd: string, args: string[]) => {
16
16
  stdout: string;
17
17
  stderr: string;
18
18
  };
19
- /** (Re)load a plist: bootout is best-effort (not loaded yet is fine). */
20
- export declare function activateLaunchd(plistPath: string, run: RunCommand, uid?: number): void;
19
+ export type SleepFn = (ms: number) => void;
20
+ /** Bootstrap attempts after bootout (first try + EIO retries). */
21
+ export declare const LAUNCHD_BOOTSTRAP_ATTEMPTS = 5;
22
+ /** Delay between bootstrap retries while the old job is still exiting. */
23
+ export declare const LAUNCHD_BOOTSTRAP_RETRY_MS = 1000;
24
+ export declare const defaultRunCommand: RunCommand;
25
+ export declare function launchdServiceTarget(plistPath: string, uid?: number): string;
26
+ /** launchd returns EIO (5) when bootstrap races the previous job's exit. */
27
+ export declare function isRetryableLaunchdBootstrap(r: {
28
+ status: number | null;
29
+ stdout: string;
30
+ stderr: string;
31
+ }): boolean;
32
+ /** ProgramArguments from a LaunchAgent plist. */
33
+ export declare function launchdPlistProgramArguments(plist: string): string[];
34
+ /**
35
+ * Arguments of a loaded job from `launchctl print gui/<uid>/<label>`.
36
+ * Prefers the `arguments = { ... }` block; falls back to `program =`.
37
+ */
38
+ export declare function parseLaunchctlPrintArguments(stdout: string): string[];
39
+ export declare function launchdProgramArgumentsDiffer(loaded: string[], plist: string[]): boolean;
40
+ /**
41
+ * (Re)load a plist so launchd picks up a rewritten ProgramArguments.
42
+ * bootout is best-effort (not loaded yet is fine). bootstrap retries EIO a
43
+ * bounded number of times — bootout returns before the old job has actually
44
+ * exited. kickstart then starts the newly loaded program.
45
+ */
46
+ export declare function activateLaunchd(plistPath: string, run: RunCommand, uid?: number, opts?: {
47
+ sleep?: SleepFn;
48
+ }): void;
21
49
  export declare function deactivateLaunchd(plistPath: string, run: RunCommand, uid?: number): void;
22
50
  export declare function launchdIsLoaded(label: string, run: RunCommand, uid?: number): boolean;
23
51
  //# sourceMappingURL=launchd.d.ts.map
@@ -6,6 +6,7 @@
6
6
  * picks up a changed plist). Command execution is injected so tests never
7
7
  * touch launchctl.
8
8
  */
9
+ import { spawnSync } from "node:child_process";
9
10
  import * as os from "node:os";
10
11
  import * as path from "node:path";
11
12
  import { escapeXml, } from "./types.js";
@@ -59,17 +60,92 @@ ${envXml}
59
60
  </plist>
60
61
  `;
61
62
  }
63
+ /** Bootstrap attempts after bootout (first try + EIO retries). */
64
+ export const LAUNCHD_BOOTSTRAP_ATTEMPTS = 5;
65
+ /** Delay between bootstrap retries while the old job is still exiting. */
66
+ export const LAUNCHD_BOOTSTRAP_RETRY_MS = 1000;
67
+ export const defaultRunCommand = (cmd, args) => {
68
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
69
+ return {
70
+ status: r.status,
71
+ stdout: typeof r.stdout === "string" ? r.stdout : "",
72
+ stderr: r.error ? r.error.message : (typeof r.stderr === "string" ? r.stderr : ""),
73
+ };
74
+ };
75
+ function blockingSleep(ms) {
76
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
77
+ }
62
78
  function guiDomain(uid = os.userInfo().uid) {
63
79
  return `gui/${uid}`;
64
80
  }
65
- /** (Re)load a plist: bootout is best-effort (not loaded yet is fine). */
66
- export function activateLaunchd(plistPath, run, uid) {
81
+ export function launchdServiceTarget(plistPath, uid) {
82
+ return `${guiDomain(uid)}/${path.basename(plistPath, ".plist")}`;
83
+ }
84
+ function bootstrapErrorText(r) {
85
+ return `${r.stderr} ${r.stdout} exit ${r.status ?? ""}`;
86
+ }
87
+ /** launchd returns EIO (5) when bootstrap races the previous job's exit. */
88
+ export function isRetryableLaunchdBootstrap(r) {
89
+ if (r.status === 0)
90
+ return false;
91
+ return /Input\/output error|\bEIO\b|Bootstrap failed: 5|Boot-strap failed: 5/i.test(bootstrapErrorText(r));
92
+ }
93
+ function unescapeXml(value) {
94
+ return value
95
+ .replace(/&quot;/g, '"')
96
+ .replace(/&lt;/g, "<")
97
+ .replace(/&gt;/g, ">")
98
+ .replace(/&amp;/g, "&");
99
+ }
100
+ /** ProgramArguments from a LaunchAgent plist. */
101
+ export function launchdPlistProgramArguments(plist) {
102
+ const block = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
103
+ if (!block)
104
+ return [];
105
+ return [...block[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => unescapeXml(m[1]));
106
+ }
107
+ /**
108
+ * Arguments of a loaded job from `launchctl print gui/<uid>/<label>`.
109
+ * Prefers the `arguments = { ... }` block; falls back to `program =`.
110
+ */
111
+ export function parseLaunchctlPrintArguments(stdout) {
112
+ const block = /(?:program\s+)?arguments\s*=\s*\{([^}]*)\}/i.exec(stdout);
113
+ if (block) {
114
+ return block[1]
115
+ .split(/\r?\n/)
116
+ .map((line) => line.trim().replace(/;+$/, ""))
117
+ .filter((line) => line.length > 0);
118
+ }
119
+ const program = /^\s*program\s*=\s*(.+?);?\s*$/m.exec(stdout);
120
+ return program ? [program[1].trim()] : [];
121
+ }
122
+ export function launchdProgramArgumentsDiffer(loaded, plist) {
123
+ if (loaded.length === 0 || plist.length === 0)
124
+ return loaded.length !== plist.length;
125
+ if (loaded.length !== plist.length)
126
+ return true;
127
+ return loaded.some((value, i) => value !== plist[i]);
128
+ }
129
+ /**
130
+ * (Re)load a plist so launchd picks up a rewritten ProgramArguments.
131
+ * bootout is best-effort (not loaded yet is fine). bootstrap retries EIO a
132
+ * bounded number of times — bootout returns before the old job has actually
133
+ * exited. kickstart then starts the newly loaded program.
134
+ */
135
+ export function activateLaunchd(plistPath, run, uid, opts) {
67
136
  const domain = guiDomain(uid);
68
- run("launchctl", ["bootout", domain, plistPath]);
69
- const r = run("launchctl", ["bootstrap", domain, plistPath]);
70
- if (r.status !== 0) {
71
- throw new Error(`launchctl bootstrap failed for ${plistPath}: ${r.stderr.trim() || r.stdout.trim() || `exit ${r.status}`}`);
137
+ const target = launchdServiceTarget(plistPath, uid);
138
+ run("launchctl", ["bootout", target]);
139
+ const sleep = opts?.sleep ?? blockingSleep;
140
+ let last = run("launchctl", ["bootstrap", domain, plistPath]);
141
+ for (let attempt = 1; attempt < LAUNCHD_BOOTSTRAP_ATTEMPTS && last.status !== 0 && isRetryableLaunchdBootstrap(last); attempt++) {
142
+ sleep(LAUNCHD_BOOTSTRAP_RETRY_MS);
143
+ last = run("launchctl", ["bootstrap", domain, plistPath]);
144
+ }
145
+ if (last.status !== 0) {
146
+ throw new Error(`launchctl bootstrap failed for ${plistPath}: ${last.stderr.trim() || last.stdout.trim() || `exit ${last.status}`}`);
72
147
  }
148
+ run("launchctl", ["kickstart", "-k", target]);
73
149
  }
74
150
  export function deactivateLaunchd(plistPath, run, uid) {
75
151
  run("launchctl", ["bootout", guiDomain(uid), plistPath]);
@@ -2,7 +2,13 @@ export type SpawnPlan = {
2
2
  cmd: string;
3
3
  args: string[];
4
4
  shell: boolean;
5
+ windowsVerbatimArguments?: boolean;
5
6
  };
7
+ /**
8
+ * Explicit cmd.exe plan for a resolved .cmd/.bat target. Keeping cmd.exe's
9
+ * argv construction here makes the shell boundary testable without Windows.
10
+ */
11
+ export declare function buildCmdExeSpawnPlan(command: string, args: readonly string[], env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): SpawnPlan;
6
12
  /**
7
13
  * Quote one entry for a Windows `cmd.exe` invocation. Node does not quote for
8
14
  * `shell: true` on Windows — it joins the array with spaces — so a bare prefix
@@ -13,6 +13,43 @@
13
13
  // This module is the single implementation of that policy. The self-update path
14
14
  // (version-gate) and the qmd spawn path (search-index) both import it, so there
15
15
  // is exactly one Windows spawn recipe in the repo.
16
+ const CMD_META_CHARS = /([()\][%!\x5e\x60"<>&|;, *?])/g;
17
+ function assertCmdArgument(value) {
18
+ if (/[\r\n\0]/.test(value)) {
19
+ throw Object.assign(new Error("Windows batch commands cannot accept newline or NUL characters"), {
20
+ code: "EINVAL",
21
+ });
22
+ }
23
+ }
24
+ function escapeCmdCommand(value) {
25
+ assertCmdArgument(value);
26
+ return value.replace(CMD_META_CHARS, "^$1");
27
+ }
28
+ function escapeCmdArgument(value) {
29
+ assertCmdArgument(value);
30
+ let escaped = value.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
31
+ escaped = escaped.replace(/(?=(\\+?)?)\1$/, "$1$1");
32
+ return ('"' + escaped + '"').replace(CMD_META_CHARS, "^$1");
33
+ }
34
+ function environmentValue(env, name) {
35
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name);
36
+ return key ? env[key] : undefined;
37
+ }
38
+ /**
39
+ * Explicit cmd.exe plan for a resolved .cmd/.bat target. Keeping cmd.exe's
40
+ * argv construction here makes the shell boundary testable without Windows.
41
+ */
42
+ export function buildCmdExeSpawnPlan(command, args, env = process.env, platform = process.platform) {
43
+ if (platform !== "win32")
44
+ return { cmd: command, args: [...args], shell: false };
45
+ const commandLine = [escapeCmdCommand(command), ...args.map(escapeCmdArgument)].join(" ");
46
+ return {
47
+ cmd: environmentValue(env, "COMSPEC")?.trim() || "cmd.exe",
48
+ args: ["/d", "/s", "/c", '"' + commandLine + '"'],
49
+ shell: false,
50
+ windowsVerbatimArguments: true,
51
+ };
52
+ }
16
53
  /**
17
54
  * Quote one entry for a Windows `cmd.exe` invocation. Node does not quote for
18
55
  * `shell: true` on Windows — it joins the array with spaces — so a bare prefix
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.151.0",
3
+ "version": "5.152.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {