huddle-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/QUICKSTART.md +102 -0
  3. package/README.md +23 -4
  4. package/dist/adapters/amp.js +1 -1
  5. package/dist/adapters/antigravity.js +2 -0
  6. package/dist/adapters/claude.js +19 -3
  7. package/dist/adapters/codex.js +6 -1
  8. package/dist/adapters/cursor.js +12 -5
  9. package/dist/adapters/custom.js +4 -2
  10. package/dist/adapters/droid.js +1 -0
  11. package/dist/adapters/goose.js +2 -0
  12. package/dist/adapters/index.js +44 -18
  13. package/dist/adapters/kiro.js +1 -1
  14. package/dist/adapters/opencode.js +1 -1
  15. package/dist/attach/connect.js +4 -1
  16. package/dist/attach/guard.js +21 -2
  17. package/dist/attach/live.js +5 -1
  18. package/dist/attach/plug.js +14 -3
  19. package/dist/attach/screen.js +150 -0
  20. package/dist/attach/session.js +7 -2
  21. package/dist/cli.js +108 -40
  22. package/dist/mcp/context-tools.js +79 -0
  23. package/dist/mcp/server.js +38 -11
  24. package/dist/orchestrator/ask.js +13 -10
  25. package/dist/orchestrator/brain.js +13 -3
  26. package/dist/orchestrator/council.js +427 -86
  27. package/dist/orchestrator/goal.js +196 -0
  28. package/dist/orchestrator/handoff.js +2 -2
  29. package/dist/orchestrator/launcher.js +29 -13
  30. package/dist/orchestrator/learn.js +105 -0
  31. package/dist/orchestrator/planner.js +25 -7
  32. package/dist/orchestrator/qa.js +62 -14
  33. package/dist/orchestrator/radar.js +719 -0
  34. package/dist/orchestrator/scheduler.js +498 -39
  35. package/dist/orchestrator/takeover.js +155 -0
  36. package/dist/platform.js +273 -0
  37. package/dist/room/context.js +1285 -0
  38. package/dist/room/contract.js +132 -0
  39. package/dist/room/db.js +79 -16
  40. package/dist/room/followup.js +233 -0
  41. package/dist/room/identity.js +2 -2
  42. package/dist/room/lessons.js +275 -0
  43. package/dist/room/live.js +75 -20
  44. package/dist/room/mail.js +212 -0
  45. package/dist/room/progress.js +40 -1
  46. package/dist/room/server.js +169 -39
  47. package/dist/room/tunnel.js +6 -5
  48. package/dist/sandbox/sync.js +4 -4
  49. package/dist/sandbox/worktree.js +145 -14
  50. package/dist/ui/app.js +554 -133
  51. package/dist/ui/catalog.js +62 -24
  52. package/dist/ui/dashboard.js +194 -51
  53. package/dist/ui/doctor.js +15 -11
  54. package/dist/ui/lessons.js +81 -0
  55. package/dist/ui/paste.js +515 -0
  56. package/dist/ui/remote.js +11 -5
  57. package/dist/ui/setup.js +152 -0
  58. package/dist/ui/story.js +666 -69
  59. package/dist/ui/term.js +151 -16
  60. package/dist/ui/watch.js +145 -48
  61. package/package.json +6 -3
@@ -82,4 +82,159 @@ WHAT ${o.from} POSTED:
82
82
  ${left.length ? left.join("\n") : " (nothing — it never called huddle_progress / huddle_remember)"}
83
83
  ${o.testOutput !== undefined ? `\nLAST TEST OUTPUT (tail):\n${cap(o.testOutput || "(no output)", 3000)}\n` : ""}${log ? `\nEND OF ${o.from}'S LOG (tail):\n${log}\n` : ""}`;
84
84
  }
85
+ // --- buddy checks and pairing ---------------------------------------------
86
+ //
87
+ // Found live (3 capstone runs): no takeover or split ever happened — every
88
+ // help offer was declined — and the only reviews came after huddle_done
89
+ // (~9s rubber-stamp APPROVEs). So Huddle now acts on its own, mid-task:
90
+ // a buddy check (another agent reads the work in progress at ~40% and the
91
+ // author gets its notes right away), and pairing (a free agent takes a part
92
+ // of the slowest task unless its author says no).
93
+ /** Progress at which a task's work in progress gets a buddy check; the second only if the first had notes. */
94
+ export const BUDDY_AT = [40, 75];
95
+ /** Pairing only helps a task that is this far behind. */
96
+ export const PAIR_MAX_PCT = 50;
97
+ /**
98
+ * Can pairing help this task? One that reports progress: while it is under
99
+ * PAIR_MAX_PCT. One that never reported has no percentage at all (it is not
100
+ * 0%: it may be nearly done), so only when it has run over twice the help
101
+ * threshold AND its work so far touches under half of its planned files.
102
+ * `touched` is lazy: it reads the worktree, only needed for a quiet task.
103
+ */
104
+ export function pairEligible(t) {
105
+ if (t.reported)
106
+ return t.pct < PAIR_MAX_PCT;
107
+ if (t.ranMs <= 2 * t.helpAfterMs)
108
+ return false;
109
+ return t.touched() * 2 < Math.max(t.planned, 1);
110
+ }
111
+ /** How long an author has to say no to a pairing. HUDDLE_PAIR_WAIT_SEC overrides. */
112
+ export const DEFAULT_PAIR_WAIT_MS = 60_000;
113
+ /** First buddy check for a task that never reports progress. HUDDLE_BUDDY_AFTER_SEC overrides; "off" = never. */
114
+ export const DEFAULT_BUDDY_AFTER_MS = 120_000;
115
+ export function buddyAfterMsFromEnv() {
116
+ const raw = process.env.HUDDLE_BUDDY_AFTER_SEC;
117
+ if (raw === undefined || raw === "")
118
+ return DEFAULT_BUDDY_AFTER_MS;
119
+ if (/^(off|never|no)$/i.test(raw))
120
+ return null;
121
+ const v = Number(raw);
122
+ return Number.isFinite(v) && v >= 0 ? v * 1000 : DEFAULT_BUDDY_AFTER_MS;
123
+ }
124
+ export function pairWaitMsFromEnv() {
125
+ const v = Number(process.env.HUDDLE_PAIR_WAIT_SEC);
126
+ return process.env.HUDDLE_PAIR_WAIT_SEC !== undefined && Number.isFinite(v) && v >= 0 ? v * 1000 : DEFAULT_PAIR_WAIT_MS;
127
+ }
128
+ /** QA rounds, QA bug fixes and conflict resolutions are never buddy-checked or paired. */
129
+ export function isSpecialTask(t) {
130
+ return /^(QA round \d+:|Fix: |Resolve merge conflict)/.test(t.title) || /"(qa|quickfix)"/.test(t.prefers ?? "");
131
+ }
132
+ const stripMd = (l) => l.replace(/[*`#_>]/g, "").trim();
133
+ export function buddyPrompt(o) {
134
+ return `You're a teammate looking over ${o.author}'s shoulder ${o.when} into their task. Another AI agent is still writing it; nothing is merged.
135
+
136
+ TASK: ${o.title}
137
+ ${cap(o.spec, 1500)}
138
+ ${o.contract ? `\n${cap(o.contract, 2500)}\n` : ""}
139
+ OTHER TASKS IN FLIGHT (their work so far):
140
+ ${o.others || "(none)"}
141
+
142
+ ${o.author.toUpperCase()}'S WORK SO FAR (diff against main, uncommitted included):
143
+ ${o.diff || "(nothing yet)"}
144
+
145
+ You have NO tools and don't need them. Reply with up to 5 short concrete lines: what will break, what contradicts the task/contract or another in-flight task (name it: function, field, file), what they should do differently.
146
+ Nothing worth changing? Reply exactly LOOKS GOOD. Do not edit anything.`;
147
+ }
148
+ /** Replies that mean "nothing to change", however a model words or decorates them. */
149
+ const ALL_CLEAR = /^(LOOKS (GOOD|FINE)|LGTM|APPROVED?|NO (ISSUES|PROBLEMS|CONCERNS|NOTES)( FOUND)?|NOTHING (TO CHANGE|WORTH CHANGING))( (SO FAR|OVERALL|TO ME))?\b[\s.!:]*$/i;
150
+ /** The buddy's notes, or null for LOOKS GOOD / nothing usable. */
151
+ export function parseBuddy(text) {
152
+ const lines = String(text ?? "")
153
+ .split("\n")
154
+ .map((l) => stripMd(l).replace(/^(?:[-•]|\d+[.)])\s+/, "").trim())
155
+ .filter(Boolean);
156
+ // Only a pure all-clear: "Looks good, but cli.js calls listItems" is a note
157
+ // (real models open with praise, then name the mismatch the check is for).
158
+ if (!lines.length || ALL_CLEAR.test(lines[0]))
159
+ return null;
160
+ // "Here's my take:\nLOOKS GOOD" — a preamble and the all-clear is still the all-clear.
161
+ if (lines.length <= 2 && lines.some((l) => ALL_CLEAR.test(l)))
162
+ return null;
163
+ return lines
164
+ .slice(0, 5)
165
+ .map((l) => cap(l, 300))
166
+ .join("\n");
167
+ }
168
+ export function pairPrompt(o) {
169
+ return `${o.author} is behind on a task and you (${o.helper}) are free. Propose ONE part of it you could do alongside them, in files they won't edit.
170
+
171
+ TASK: ${o.title}
172
+ ${cap(o.spec, 1500)}
173
+ FILES: ${o.files.join(", ") || "(none listed)"}
174
+ ${o.contract ? `\n${cap(o.contract, 2000)}\n` : ""}
175
+ THEIR WORK SO FAR:
176
+ ${o.diff || "(nothing yet)"}
177
+
178
+ You have NO tools. Reply in exactly this form:
179
+ PART: <short title>
180
+ FILES: <comma-separated files you'd own — none they've already edited>
181
+ SPEC: <2-4 lines: what to build, the names/shapes it must match>
182
+ If nothing is cleanly separable, reply exactly NONE.`;
183
+ }
184
+ /** A FILES list as models write it: commas, spaces, backticks or quotes (a quoted name may hold spaces). */
185
+ function fileList(raw) {
186
+ const out = [];
187
+ for (const piece of raw.split(/[,;]/)) {
188
+ const quoted = /^\s*[`'"](.+)[`'"]\s*$/.exec(piece);
189
+ if (quoted)
190
+ out.push(quoted[1].trim());
191
+ else
192
+ out.push(...piece.split(/\s+/).map((f) => f.replace(/[`'"]/g, "")));
193
+ }
194
+ return out.map((f) => f.trim().replace(/^\.\//, "")).filter((f) => f && !/^(none|n\/a|-)$/i.test(f));
195
+ }
196
+ /**
197
+ * The helper's proposal, or null: NONE, unparseable, a path outside the repo,
198
+ * a file the author already edited, or the whole of the task's file list
199
+ * (that isn't a part, it's the task).
200
+ */
201
+ export function parsePart(text, authorFiles, taskFiles = []) {
202
+ text = String(text ?? "");
203
+ const get = (k) => new RegExp(`^\\s*[*_]*${k}[*_]*\\s*:[*_]*\\s*(.+)$`, "im").exec(text)?.[1]?.replace(/[*_]+$/, "").trim();
204
+ const title = get("PART");
205
+ if (!title || /^NONE\b/i.test(stripMd(title)))
206
+ return null;
207
+ const files = fileList(get("FILES") ?? "");
208
+ const specAt = text.search(/^\s*[*_]*SPEC[*_]*\s*:/im);
209
+ const spec = specAt >= 0 ? text.slice(specAt).replace(/^\s*[*_]*SPEC[*_]*\s*:[*_]*\s*/i, "").trim() : "";
210
+ if (!files.length || !spec)
211
+ return null;
212
+ if (files.some((f) => f.startsWith("/") || /^[a-z]:[\\/]/i.test(f) || f.split(/[\\/]/).includes("..")))
213
+ return null;
214
+ const norm = (p) => p.replace(/^\.\//, "").replace(/\/+$/, "");
215
+ const covers = (a, f) => norm(a) === norm(f) || norm(a).startsWith(norm(f) + "/");
216
+ if (files.some((f) => authorFiles.some((a) => covers(a, f))))
217
+ return null;
218
+ if (taskFiles.length && taskFiles.every((t) => files.some((f) => covers(t, f))))
219
+ return null;
220
+ return { title: cap(stripMd(title), 120), spec: cap(spec, 2000), files: files.slice(0, 12), tests: false };
221
+ }
222
+ /** Always separable: tests/verification of the task against its spec (+ contract), in a test file of the helper's own. */
223
+ export function testsPart(title, spec) {
224
+ return {
225
+ title: cap(`Tests for: ${title}`, 120),
226
+ spec: `Write the tests / verification for this task against its spec, in a NEW test file of your own (follow the repo's test layout and runner) — ` +
227
+ `don't edit the task's own files. Test what the spec promises (acceptance points, error paths), not the implementation's internals. ` +
228
+ `Your tests run against the task's code once it lands.\n\nTHE TASK'S SPEC: ${title}\n${cap(spec, 1500)}`,
229
+ files: [],
230
+ tests: true,
231
+ };
232
+ }
233
+ /** What the author is told: who takes which part, and how to say no. */
234
+ export function pairNote(helper, part, waitMs) {
235
+ const secs = Math.round(waitMs / 1000);
236
+ const files = part.files.length ? part.files.join(", ") : "a new test file";
237
+ return (`${helper} is taking “${part.title}” (files ${files}) in ${secs}s unless you reply huddle_send(to="huddle", message="no: <why>"). ` +
238
+ (part.files.length ? `Don't edit ${files}; ${helper} owns ${part.files.length > 1 ? "them" : "it"}.` : `Keep building; ${helper} writes the tests against your spec.`));
239
+ }
85
240
  //# sourceMappingURL=takeover.js.map
@@ -0,0 +1,273 @@
1
+ // Running other programs on every OS. Huddle drives CLIs it didn't write, and
2
+ // on Windows most of them aren't .exe files: npm installs `codex` as a
3
+ // `codex.cmd` batch shim, which child_process.spawn can't find without a shell
4
+ // (ENOENT) and refuses to run without one (EINVAL, Node's 2024 fix for
5
+ // CVE-2024-27980). A shell is no answer either: cmd.exe re-parses the whole
6
+ // command line, and prompts are full of quotes, &, %, ^ and newlines.
7
+ //
8
+ // So every agent/tool spawn goes through toolCommand():
9
+ // macOS / Linux unchanged — spawn(cmd[0], args), no shell
10
+ // Windows .exe/.com the resolved path, no shell
11
+ // Windows npm shim node <the shim's script> args — exact argv, no cmd.exe
12
+ // other .cmd/.bat cmd.exe /d /s /c with every argument escaped for cmd
13
+ // .ps1 powershell -File
14
+ // The Windows paths are pure functions of a Host, so tests run them on macOS.
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ export function host() {
19
+ return {
20
+ platform: process.platform,
21
+ env: process.env,
22
+ isFile: (p) => {
23
+ try {
24
+ return fs.statSync(p).isFile();
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ },
30
+ read: (p) => fs.readFileSync(p, "utf8"),
31
+ execPath: process.execPath,
32
+ };
33
+ }
34
+ export const isWindows = (h = process) => h.platform === "win32";
35
+ /** An env var by name, case-insensitively — Windows env names are ("Path" vs "PATH"). */
36
+ function envGet(env, name) {
37
+ if (env[name] !== undefined)
38
+ return env[name];
39
+ const k = Object.keys(env).find((x) => x.toUpperCase() === name);
40
+ return k ? env[k] : undefined;
41
+ }
42
+ /**
43
+ * Where Windows would find `bin`: each PATH dir, trying each PATHEXT
44
+ * extension — what `where` does. The current directory is NOT searched (cmd
45
+ * does that; a repo could plant a codex.cmd). null = not found.
46
+ */
47
+ export function findOnWindowsPath(bin, h = host()) {
48
+ const P = path.win32;
49
+ const exts = (envGet(h.env, "PATHEXT") || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
50
+ const hasExt = exts.some((e) => bin.toLowerCase().endsWith(e.toLowerCase()));
51
+ const tries = (base) => (hasExt ? [base] : exts.map((e) => base + e.toLowerCase()));
52
+ if (/[\\/]/.test(bin))
53
+ return tries(bin).find(h.isFile) ?? null;
54
+ for (const raw of (envGet(h.env, "PATH") ?? "").split(";")) {
55
+ const dir = raw.trim().replace(/^"(.*)"$/, "$1");
56
+ if (!dir)
57
+ continue;
58
+ const hit = tries(P.join(dir, bin)).find(h.isFile);
59
+ if (hit)
60
+ return hit;
61
+ }
62
+ return null;
63
+ }
64
+ // Cursor's docs now name the CLI `agent` (https://cursor.com/docs/cli/installation);
65
+ // macOS/Linux installs still add `cursor-agent`. If a Windows install only has
66
+ // `agent`, use that.
67
+ const WINDOWS_ALIASES = { "cursor-agent": ["agent"] };
68
+ /** findOnWindowsPath, plus the known alternate names of a CLI. */
69
+ export function resolveWindowsBin(bin, h = host()) {
70
+ for (const b of [bin, ...(WINDOWS_ALIASES[bin] ?? [])]) {
71
+ const hit = findOnWindowsPath(b, h);
72
+ if (hit)
73
+ return hit;
74
+ }
75
+ return null;
76
+ }
77
+ /**
78
+ * The JS file an npm/cmd-shim batch file runs, if it is one: the shim's
79
+ * last line is `"%_prog%" "%dp0%\node_modules\@openai\codex\bin\codex.js" %*`
80
+ * (older shims: `"%~dp0\node.exe" "%~dp0\..\x.js" %*`).
81
+ */
82
+ export function npmShimTarget(shim, h = host()) {
83
+ let text = "";
84
+ try {
85
+ text = h.read(shim);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ const m = /"%~?dp0%?\\?([^"%]+\.[cm]?js)"\s+%\*/i.exec(text);
91
+ if (!m)
92
+ return null;
93
+ const target = path.win32.join(path.win32.dirname(shim), m[1]);
94
+ return h.isFile(target) ? target : null;
95
+ }
96
+ // cmd.exe's special characters. Escaping every one with ^ (the quote too) keeps
97
+ // cmd's quote state from ever toggling, so it sees no operators anywhere.
98
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
99
+ /**
100
+ * One argument for a cmd.exe command line that runs a batch file. First the
101
+ * MSVCRT quoting every Windows program parses argv with (backslashes are only
102
+ * special before a quote), then ^-escape for cmd — twice, because cmd parses
103
+ * the line once and the batch file's %* expansion parses it again.
104
+ */
105
+ export function quoteCmdArg(arg) {
106
+ // cmd can't carry a line break inside a command: it ends the command there.
107
+ // Only reached for batch files that aren't npm shims, and only a prompt has
108
+ // newlines, so they become spaces (adapters that can take the prompt on
109
+ // stdin avoid this path entirely).
110
+ let a = arg.replace(/\r?\n/g, " ");
111
+ a = a.replace(/(\\*)"/g, (_m, bs) => bs + bs + '\\"').replace(/(\\*)$/, (_m, bs) => bs + bs);
112
+ a = `"${a}"`;
113
+ return a.replace(CMD_META, "^$1").replace(CMD_META, "^$1");
114
+ }
115
+ /** The program itself on that command line (it is never re-parsed by %*). */
116
+ export function quoteCmdProgram(file) {
117
+ return file.replace(CMD_META, "^$1");
118
+ }
119
+ /** How to spawn `cmd` (argv form) on this platform — see the header comment. */
120
+ export function toolCommand(cmd, h = host()) {
121
+ const [bin, ...args] = cmd;
122
+ if (!isWindows(h))
123
+ return { file: bin, args };
124
+ const resolved = resolveWindowsBin(bin, h);
125
+ if (!resolved)
126
+ return { file: bin, args }; // spawn reports ENOENT, as it should
127
+ const ext = path.win32.extname(resolved).toLowerCase();
128
+ if (ext === ".cmd" || ext === ".bat") {
129
+ const script = npmShimTarget(resolved, h);
130
+ if (script) {
131
+ // The shim prefers a node.exe next to it, else node on PATH; Huddle's own node is that.
132
+ const local = path.win32.join(path.win32.dirname(resolved), "node.exe");
133
+ return { file: h.isFile(local) ? local : h.execPath, args: [script, ...args] };
134
+ }
135
+ const line = [quoteCmdProgram(resolved), ...args.map(quoteCmdArg)].join(" ");
136
+ return { file: envGet(h.env, "COMSPEC") || "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], verbatim: true };
137
+ }
138
+ if (ext === ".ps1")
139
+ return { file: "powershell.exe", args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolved, ...args] };
140
+ return { file: resolved, args };
141
+ }
142
+ /**
143
+ * Some CLIs read the prompt from stdin when it's left off the command line
144
+ * (`stdinPrompt`: what goes in its place — "" drops it, "-" for codex). On
145
+ * Windows Huddle always does that: no quoting, no 32K command-line limit.
146
+ */
147
+ export function promptOnStdin(cmd, prompt, stdinPrompt, h = process) {
148
+ if (!isWindows(h) || prompt === undefined || stdinPrompt === undefined)
149
+ return { cmd };
150
+ const i = cmd.lastIndexOf(prompt);
151
+ if (i <= 0)
152
+ return { cmd };
153
+ const out = [...cmd];
154
+ if (stdinPrompt === "")
155
+ out.splice(i, 1);
156
+ else
157
+ out[i] = stdinPrompt;
158
+ return { cmd: out, stdin: prompt };
159
+ }
160
+ /** spawn() for an agent or tool CLI. `plan` lets the prompt travel on stdin (Windows). */
161
+ export function spawnTool(cmd, opts = {}, plan = {}) {
162
+ const p = promptOnStdin(cmd, plan.prompt, plan.stdinPrompt);
163
+ const t = toolCommand(p.cmd);
164
+ const stdio = p.stdin === undefined ? opts.stdio : withStdinPipe(opts.stdio);
165
+ const child = spawn(t.file, t.args, { ...opts, stdio, windowsVerbatimArguments: t.verbatim || opts.windowsVerbatimArguments });
166
+ if (p.stdin !== undefined && child.stdin) {
167
+ child.stdin.on("error", () => { }); // the CLI may exit before reading it all
168
+ child.stdin.end(p.stdin);
169
+ }
170
+ return child;
171
+ }
172
+ function withStdinPipe(stdio) {
173
+ if (Array.isArray(stdio))
174
+ return ["pipe", ...stdio.slice(1)];
175
+ if (stdio === "inherit")
176
+ return ["pipe", "inherit", "inherit"];
177
+ return ["pipe", "pipe", "pipe"];
178
+ }
179
+ export function spawnToolSync(cmd, opts = {}) {
180
+ const t = toolCommand(cmd);
181
+ return spawnSync(t.file, t.args, { ...opts, windowsVerbatimArguments: t.verbatim || opts.windowsVerbatimArguments });
182
+ }
183
+ /** Is `bin` on PATH? (`which`, or the PATHEXT search on Windows.) */
184
+ export function onPath(bin, h = host()) {
185
+ if (isWindows(h))
186
+ return resolveWindowsBin(bin, h) !== null;
187
+ return spawnSync("which", [bin], { stdio: "ignore" }).status === 0;
188
+ }
189
+ /**
190
+ * A shell command line, as [file, args, verbatim]. Installers are written for
191
+ * the platform's shell: sh on macOS/Linux, PowerShell on Windows
192
+ * (`irm … | iex`). `cmd.exe` is for project commands like `npm test`.
193
+ */
194
+ export function shellCommand(line, shell = "default", h = process) {
195
+ if (!isWindows(h))
196
+ return { file: "sh", args: ["-c", line] };
197
+ if (shell === "cmd")
198
+ return { file: envGet(h.env, "COMSPEC") || "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], verbatim: true };
199
+ // Bypass: npm's own npm.ps1 is blocked under the default Restricted policy.
200
+ return { file: "powershell.exe", args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", line] };
201
+ }
202
+ /** Run a shell command line with the terminal handed over. */
203
+ export function runShell(line, opts = { stdio: "inherit" }, shell = "default") {
204
+ const t = shellCommand(line, shell);
205
+ return spawnSync(t.file, t.args, { ...opts, windowsVerbatimArguments: t.verbatim });
206
+ }
207
+ /**
208
+ * Stop a process and everything it started. POSIX: SIGTERM (the caller
209
+ * escalates to SIGKILL). Windows has no signals for a process tree —
210
+ * child.kill() would end only the node shim and orphan the real agent — so
211
+ * taskkill /T /F ends the whole tree at once.
212
+ */
213
+ export function killTree(child, h = process, run = spawnSync) {
214
+ if (isWindows(h) && child.pid) {
215
+ const r = run("taskkill", ["/T", "/F", "/PID", String(child.pid)], { stdio: "ignore", windowsHide: true });
216
+ if (r.status === 0)
217
+ return;
218
+ }
219
+ child.kill("SIGTERM");
220
+ }
221
+ /** How long after 'exit' a child's pipes get to drain before we stop waiting for 'close'. */
222
+ export const DRAIN_MS = 2000;
223
+ /**
224
+ * Call `done` once, when `child` has exited AND its output is in. 'exit' can
225
+ * come before the last of stdout has been read — when our event loop is busy
226
+ * (a merge's test run blocks it) an agent's final lines (its token usage, the
227
+ * log's tail) were lost. So: on 'close' (every pipe drained), but at most
228
+ * `drainMs` after 'exit', since a CLI's leftover grandchild can hold the pipes
229
+ * open forever. 'error' (it never ran) is code -1.
230
+ */
231
+ export function onFinished(child, done, drainMs = DRAIN_MS) {
232
+ let over = false;
233
+ const finish = (code) => {
234
+ if (over)
235
+ return;
236
+ over = true;
237
+ done(code);
238
+ };
239
+ child.once("close", (code) => finish(code));
240
+ child.once("exit", (code) => void setTimeout(() => finish(code), drainMs).unref());
241
+ child.once("error", () => finish(-1));
242
+ }
243
+ /** Repo-relative path with forward slashes, whatever the OS gave us ("src\\a.ts" → "src/a.ts"). */
244
+ export const slash = (p) => p.replace(/\\/g, "/");
245
+ /**
246
+ * A hook command line an agent CLI runs through its shell (Claude's
247
+ * PostToolUse mail hook). sh: single quotes, nothing expands. Windows: the
248
+ * hook may run under Git Bash, cmd or PowerShell, so every part is
249
+ * double-quoted with forward slashes (node takes them; C:\Users\Some Name\…
250
+ * survives all three) — what `huddle connect`'s guard hook does too.
251
+ */
252
+ export function hookCommand(argv, h = process) {
253
+ if (!isWindows(h))
254
+ return argv.map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(" ");
255
+ return argv.map((a) => `"${slash(a)}"`).join(" ");
256
+ }
257
+ /** How to install git here, as one line for a message. */
258
+ export function gitFix(platform = process.platform) {
259
+ if (platform === "win32")
260
+ return "winget install --id Git.Git -e --source winget · https://git-scm.com/install/windows";
261
+ if (platform === "darwin")
262
+ return "xcode-select --install · https://git-scm.com";
263
+ return "sudo apt install git (Debian/Ubuntu), or your package manager · https://git-scm.com";
264
+ }
265
+ /** How to install (or upgrade to) Node.js LTS here. */
266
+ export function nodeFix(platform = process.platform) {
267
+ if (platform === "win32")
268
+ return "winget install OpenJS.NodeJS.LTS · https://nodejs.org";
269
+ if (platform === "darwin")
270
+ return "brew install node · https://nodejs.org";
271
+ return "https://nodejs.org (or your package manager / nvm)";
272
+ }
273
+ //# sourceMappingURL=platform.js.map