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.
- package/CHANGELOG.md +31 -0
- package/QUICKSTART.md +102 -0
- package/README.md +23 -4
- package/dist/adapters/amp.js +1 -1
- package/dist/adapters/antigravity.js +2 -0
- package/dist/adapters/claude.js +19 -3
- package/dist/adapters/codex.js +6 -1
- package/dist/adapters/cursor.js +12 -5
- package/dist/adapters/custom.js +4 -2
- package/dist/adapters/droid.js +1 -0
- package/dist/adapters/goose.js +2 -0
- package/dist/adapters/index.js +44 -18
- package/dist/adapters/kiro.js +1 -1
- package/dist/adapters/opencode.js +1 -1
- package/dist/attach/connect.js +4 -1
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +14 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +108 -40
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +38 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +13 -3
- package/dist/orchestrator/council.js +427 -86
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +29 -13
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +25 -7
- package/dist/orchestrator/qa.js +62 -14
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +498 -39
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +273 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +79 -16
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +2 -2
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +75 -20
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +169 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +554 -133
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +194 -51
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +11 -5
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +666 -69
- package/dist/ui/term.js +151 -16
- package/dist/ui/watch.js +145 -48
- 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
|
package/dist/platform.js
ADDED
|
@@ -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
|