@indigoai-us/hq-cli 5.152.0 → 5.153.0

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.153.0] — 2026-09-23
6
+
7
+ ### Fixed
8
+
9
+ - `hq mesh context prd-sync` rebinds a session to the PRD's project when that project is in the same company, then emits `task_status` against the new project so the Board and channel line land there. A PRD in another company is still skipped and does not rebind.
10
+
11
+ ## [5.152.1] — 2026-09-23
12
+
13
+ ### Fixed
14
+
15
+ - 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.
16
+
5
17
  ## [5.152.0] — 2026-09-23
6
18
 
7
19
  ### Fixed
@@ -44,7 +44,7 @@ import { readSessionState, updateSessionState } from "../lib/work-context/state.
44
44
  import { findLocalPrd, listNeedsProjectSessions } from "../lib/work-context/bind-best.js";
45
45
  import { bindCallingSession, formatSessionBoundLine } from "../lib/work-context/bind-calling-session.js";
46
46
  import { rebindNeedsProjectOnPrompt } from "../lib/work-context/prompt-rebind.js";
47
- import { planPrdSync } from "../lib/work-context/prd-sync.js";
47
+ import { planPrdSync, rebindSessionToProject } from "../lib/work-context/prd-sync.js";
48
48
  import { reserveSessionSeq } from "../lib/mesh/live/session-seq.js";
49
49
  import { WORK_CONTEXT_CONTRACT_VERSION } from "../lib/work-context/contract.js";
50
50
  import { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "../lib/work-context/reconcile.js";
@@ -550,6 +550,9 @@ async function runPrdSync(opts) {
550
550
  fail("Usage: hq mesh context prd-sync --session <sid> --file <prd.json>");
551
551
  const root = workContextHomeRoot();
552
552
  const plan = planPrdSync({ sessionId, file, root });
553
+ if (plan.rebindTo) {
554
+ rebindSessionToProject({ sessionId, root, projectId: plan.rebindTo });
555
+ }
553
556
  if (!plan.changed) {
554
557
  const payload = { ok: true, action: "context.prd-sync", changed: 0, ...(plan.skip ? { skip: plan.skip } : {}) };
555
558
  if (opts.json)
@@ -570,7 +573,9 @@ async function runPrdSync(opts) {
570
573
  return;
571
574
  }
572
575
  // Attribution evidence: without company and project the server files these
573
- // as unassigned and the Board never moves.
576
+ // as unassigned and the Board never moves. After a same-company switch,
577
+ // this is the new project's id so ingest writes SESSION_STATE.projectId
578
+ // and bound-forward posts the channel line there.
574
579
  const bound = readSessionState(sessionId, root);
575
580
  const evidenceBase = {
576
581
  ...(bound?.companySlug ? { companySlug: bound.companySlug } : {}),
@@ -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
@@ -1,8 +1,11 @@
1
1
  /**
2
- * US-040: diff a bound project's prd.json and describe task_status events.
3
- * Planning never touches the network. The snapshot is written only after
4
- * the caller accepts the plan (commit), so a failed emit can retry.
2
+ * US-040 / F14: diff a bound project's prd.json and describe task_status events.
3
+ * A same-company PRD for a different project is a project switch (rebindTo).
4
+ * A PRD in another company is skipped. Planning never touches the network.
5
+ * The snapshot is written only after the caller accepts the plan (commit),
6
+ * so a failed emit can retry.
5
7
  */
8
+ import { type SessionStateFile } from "./state.js";
6
9
  export interface PrdStatusEvent {
7
10
  taskId: string;
8
11
  status: "done" | "in_progress";
@@ -13,14 +16,29 @@ export interface PrdSnapshot {
13
16
  status?: string;
14
17
  }>;
15
18
  seq: number;
19
+ /** Project the snapshot belongs to. A switch starts a fresh story set. */
20
+ projectId?: string;
16
21
  }
17
22
  export declare function prdSnapshotPath(sessionId: string, root: string): string;
18
23
  export declare function readPrdSnapshot(sessionId: string, root: string): PrdSnapshot | null;
24
+ /** `companies/<slug>/projects/<projectId>/prd.json` — nowhere else. */
25
+ export declare function prdPathIdentity(file: string): {
26
+ companySlug: string;
27
+ projectId: string;
28
+ } | null;
19
29
  export declare function prdBelongsToProject(file: string, projectId: string, companySlug: string | undefined): boolean;
30
+ export declare function rebindSessionToProject(input: {
31
+ sessionId: string;
32
+ root: string;
33
+ projectId: string;
34
+ now?: () => Date;
35
+ }): SessionStateFile | null;
20
36
  export interface PrdSyncPlan {
21
37
  changed: boolean;
22
38
  skip?: "no_state" | "not_bound" | "different_project" | "unreadable";
23
39
  events: PrdStatusEvent[];
40
+ /** Same-company project the session should switch to before emit. */
41
+ rebindTo?: string;
24
42
  /** Story most recently moved to in_progress, if any. */
25
43
  taskId?: string;
26
44
  seqStart: number;
@@ -1,11 +1,13 @@
1
1
  /**
2
- * US-040: diff a bound project's prd.json and describe task_status events.
3
- * Planning never touches the network. The snapshot is written only after
4
- * the caller accepts the plan (commit), so a failed emit can retry.
2
+ * US-040 / F14: diff a bound project's prd.json and describe task_status events.
3
+ * A same-company PRD for a different project is a project switch (rebindTo).
4
+ * A PRD in another company is skipped. Planning never touches the network.
5
+ * The snapshot is written only after the caller accepts the plan (commit),
6
+ * so a failed emit can retry.
5
7
  */
6
8
  import * as fs from "node:fs";
7
9
  import * as path from "node:path";
8
- import { readSessionState } from "./state.js";
10
+ import { readSessionState, updateSessionState } from "./state.js";
9
11
  export function prdSnapshotPath(sessionId, root) {
10
12
  return path.join(root, "sessions", `${sessionId}.prd-snapshot.json`);
11
13
  }
@@ -14,12 +16,33 @@ export function readPrdSnapshot(sessionId, root) {
14
16
  const parsed = JSON.parse(fs.readFileSync(prdSnapshotPath(sessionId, root), "utf8"));
15
17
  if (!parsed || typeof parsed !== "object" || !parsed.stories)
16
18
  return null;
17
- return { stories: parsed.stories, seq: typeof parsed.seq === "number" ? parsed.seq : 0 };
19
+ return {
20
+ stories: parsed.stories,
21
+ seq: typeof parsed.seq === "number" ? parsed.seq : 0,
22
+ ...(typeof parsed.projectId === "string" && parsed.projectId.trim()
23
+ ? { projectId: parsed.projectId.trim() }
24
+ : {}),
25
+ };
18
26
  }
19
27
  catch {
20
28
  return null;
21
29
  }
22
30
  }
31
+ /** `companies/<slug>/projects/<projectId>/prd.json` — nowhere else. */
32
+ export function prdPathIdentity(file) {
33
+ const parts = path.resolve(file).split(path.sep);
34
+ if (parts[parts.length - 1] !== "prd.json")
35
+ return null;
36
+ const projectsAt = parts.lastIndexOf("projects");
37
+ if (projectsAt < 2 || parts[projectsAt - 2] !== "companies" || parts.length !== projectsAt + 3) {
38
+ return null;
39
+ }
40
+ const companySlug = parts[projectsAt - 1];
41
+ const projectId = parts[projectsAt + 1];
42
+ if (!companySlug || !projectId)
43
+ return null;
44
+ return { companySlug, projectId };
45
+ }
23
46
  function writeSnapshot(sessionId, root, snapshot) {
24
47
  const dest = prdSnapshotPath(sessionId, root);
25
48
  fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -28,15 +51,29 @@ function writeSnapshot(sessionId, root, snapshot) {
28
51
  export function prdBelongsToProject(file, projectId, companySlug) {
29
52
  if (!companySlug)
30
53
  return false;
31
- const parts = path.resolve(file).split(path.sep);
32
- if (parts[parts.length - 1] !== "prd.json")
33
- return false;
34
- const projectsAt = parts.lastIndexOf("projects");
35
- return (projectsAt >= 2 &&
36
- parts[projectsAt - 2] === "companies" &&
37
- parts[projectsAt - 1] === companySlug &&
38
- parts[projectsAt + 1] === projectId &&
39
- parts.length === projectsAt + 3);
54
+ const identity = prdPathIdentity(file);
55
+ return Boolean(identity &&
56
+ identity.companySlug.toLowerCase() === companySlug.toLowerCase() &&
57
+ identity.projectId === projectId);
58
+ }
59
+ export function rebindSessionToProject(input) {
60
+ const nowIso = (input.now ?? (() => new Date()))().toISOString();
61
+ return updateSessionState(input.sessionId, input.root, (current) => {
62
+ if (!current)
63
+ return null;
64
+ if (current.projectId === input.projectId)
65
+ return current;
66
+ return {
67
+ ...current,
68
+ projectId: input.projectId,
69
+ taskId: undefined,
70
+ contextStatus: current.contextStatus === "bound" || current.contextStatus === "needs_task"
71
+ ? "needs_task"
72
+ : current.contextStatus,
73
+ projectBindReason: `prd-sync project switch to ${input.projectId}`,
74
+ updatedAt: nowIso,
75
+ };
76
+ });
40
77
  }
41
78
  function storiesFromPrd(file) {
42
79
  const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -79,8 +116,14 @@ export function planPrdSync(input) {
79
116
  if (!state.projectId || (state.contextStatus !== "bound" && state.contextStatus !== "needs_task")) {
80
117
  return empty("not_bound");
81
118
  }
82
- if (!prdBelongsToProject(input.file, state.projectId, state.companySlug))
119
+ const identity = prdPathIdentity(input.file);
120
+ if (!identity || !state.companySlug)
121
+ return empty("different_project");
122
+ if (identity.companySlug.toLowerCase() !== state.companySlug.toLowerCase()) {
83
123
  return empty("different_project");
124
+ }
125
+ const targetProjectId = identity.projectId;
126
+ const rebindTo = state.projectId !== targetProjectId ? targetProjectId : undefined;
84
127
  let stories;
85
128
  try {
86
129
  stories = storiesFromPrd(input.file);
@@ -88,7 +131,9 @@ export function planPrdSync(input) {
88
131
  catch {
89
132
  return empty("unreadable");
90
133
  }
91
- const previous = readPrdSnapshot(input.sessionId, input.root);
134
+ const previousRaw = readPrdSnapshot(input.sessionId, input.root);
135
+ const previousProject = previousRaw?.projectId ?? state.projectId;
136
+ const previous = previousProject === targetProjectId ? previousRaw : null;
92
137
  const before = previous?.stories ?? {};
93
138
  const events = [];
94
139
  let taskId;
@@ -108,10 +153,15 @@ export function planPrdSync(input) {
108
153
  taskId = story.id;
109
154
  }
110
155
  }
111
- const snapshot = { stories: nextStories, seq: (previous?.seq ?? 0) + events.length };
156
+ const snapshot = {
157
+ stories: nextStories,
158
+ seq: (previous?.seq ?? 0) + events.length,
159
+ projectId: targetProjectId,
160
+ };
112
161
  return {
113
162
  changed: events.length > 0,
114
163
  events,
164
+ ...(rebindTo ? { rebindTo } : {}),
115
165
  ...(taskId ? { taskId } : {}),
116
166
  seqStart: previous?.seq ?? 0,
117
167
  commit: (acceptedTaskIds) => {
@@ -134,6 +184,7 @@ export function planPrdSync(input) {
134
184
  writeSnapshot(input.sessionId, input.root, {
135
185
  stories,
136
186
  seq: (previous?.seq ?? 0) + advanced,
187
+ projectId: targetProjectId,
137
188
  });
138
189
  },
139
190
  };
@@ -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.152.0",
3
+ "version": "5.153.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {