@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 +12 -0
- package/dist/commands/mesh.js +7 -2
- package/dist/lib/bot/runtime/claude.d.ts +1 -1
- package/dist/lib/bot/runtime/claude.js +16 -3
- package/dist/lib/bot/runtime/codex.js +8 -2
- package/dist/lib/bot/runtime/grok-acp.d.ts +40 -0
- package/dist/lib/bot/runtime/grok-acp.js +265 -0
- package/dist/lib/bot/runtime/grok.d.ts +2 -0
- package/dist/lib/bot/runtime/grok.js +25 -1
- package/dist/lib/bot/runtime/index.d.ts +1 -0
- package/dist/lib/bot/runtime/index.js +131 -5
- package/dist/lib/bot/runtime/types.d.ts +16 -1
- package/dist/lib/bot/runtime/windows.d.ts +10 -0
- package/dist/lib/bot/runtime/windows.js +53 -0
- package/dist/lib/work-context/prd-sync.d.ts +21 -3
- package/dist/lib/work-context/prd-sync.js +68 -17
- package/dist/utils/windows-spawn.d.ts +6 -0
- package/dist/utils/windows-spawn.js +37 -0
- package/package.json +1 -1
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
|
package/dist/commands/mesh.js
CHANGED
|
@@ -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,
|
|
102
|
+
return ["exec", "resume", ...common, input.sessionId, promptArg];
|
|
100
103
|
}
|
|
101
|
-
return ["exec", ...common, "-C", input.cwd,
|
|
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(
|
|
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
|
|
@@ -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
|
|
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({
|
|
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
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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
|