huddle-cli 0.0.0-stage → 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/LICENSE +21 -0
- package/QUICKSTART.md +102 -0
- package/README.md +376 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +54 -0
- package/dist/adapters/claude.js +86 -0
- package/dist/adapters/codex.js +64 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +92 -0
- package/dist/adapters/custom.js +342 -0
- package/dist/adapters/droid.js +58 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +57 -0
- package/dist/adapters/index.js +161 -0
- package/dist/adapters/kiro.js +41 -0
- package/dist/adapters/opencode.js +63 -0
- package/dist/adapters/qwen.js +67 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/usage.js +141 -0
- package/dist/attach/connect.js +412 -0
- package/dist/attach/guard.js +83 -0
- package/dist/attach/live.js +226 -0
- package/dist/attach/plug.js +185 -0
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +177 -0
- package/dist/cli.js +244 -0
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +267 -0
- package/dist/orchestrator/ask.js +54 -0
- package/dist/orchestrator/brain.js +174 -0
- package/dist/orchestrator/council.js +615 -0
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +171 -0
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +212 -0
- package/dist/orchestrator/qa.js +398 -0
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +1207 -0
- package/dist/orchestrator/takeover.js +240 -0
- package/dist/platform.js +273 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +680 -0
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +348 -0
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +149 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +882 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +86 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +392 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1955 -0
- package/dist/ui/catalog.js +113 -0
- package/dist/ui/dashboard.js +335 -0
- package/dist/ui/doctor.js +58 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +338 -0
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +1529 -0
- package/dist/ui/term.js +249 -0
- package/dist/ui/watch.js +271 -0
- package/install.sh +44 -0
- package/package.json +63 -4
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Takeovers: agents take on other agents' tasks.
|
|
2
|
+
//
|
|
3
|
+
// Rescue (scheduler-driven): a task whose agent crashed, stayed red after its
|
|
4
|
+
// fix round, or went silent is re-offered to a DIFFERENT agent, which continues
|
|
5
|
+
// in the original worktree — the first agent's edits are already there — with
|
|
6
|
+
// a brief: why it stopped, the test output, and what the first agent posted.
|
|
7
|
+
// One takeover per task; if the rescuer can't land it either, it's parked for
|
|
8
|
+
// you with the branch kept.
|
|
9
|
+
//
|
|
10
|
+
// Split (agent-driven): a busy agent hands a part of its task to the room
|
|
11
|
+
// (huddle_split); a free agent picks it up like any backlog task. The
|
|
12
|
+
// scheduler nudges busy agents when a teammate is free (huddle_offer_help).
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
const cap = (s, n) => (s.length > n ? s.slice(0, n) + "…" : s);
|
|
15
|
+
const parse = (s) => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(s || "{}");
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/** Minutes without output or a huddle_* call before an agent counts as silent. HUDDLE_SILENCE_MIN overrides. */
|
|
24
|
+
export const DEFAULT_SILENCE_MS = 10 * 60_000;
|
|
25
|
+
/** How long a task runs before a free agent offers to take part of it. HUDDLE_HELP_AFTER_SEC overrides; 0 = at once, "off" = never. */
|
|
26
|
+
export const DEFAULT_HELP_AFTER_MS = 60_000;
|
|
27
|
+
export function silenceMsFromEnv() {
|
|
28
|
+
const v = Number(process.env.HUDDLE_SILENCE_MIN);
|
|
29
|
+
return v > 0 ? v * 60_000 : DEFAULT_SILENCE_MS;
|
|
30
|
+
}
|
|
31
|
+
export function helpAfterMsFromEnv() {
|
|
32
|
+
const raw = process.env.HUDDLE_HELP_AFTER_SEC;
|
|
33
|
+
if (raw === undefined || raw === "")
|
|
34
|
+
return DEFAULT_HELP_AFTER_MS;
|
|
35
|
+
if (/^(off|no|never|-)/i.test(raw))
|
|
36
|
+
return null;
|
|
37
|
+
const v = Number(raw);
|
|
38
|
+
return Number.isFinite(v) && v >= 0 ? v * 1000 : DEFAULT_HELP_AFTER_MS;
|
|
39
|
+
}
|
|
40
|
+
function tail(file, bytes) {
|
|
41
|
+
try {
|
|
42
|
+
const st = fs.statSync(file);
|
|
43
|
+
const fd = fs.openSync(file, "r");
|
|
44
|
+
const n = Math.min(bytes, st.size);
|
|
45
|
+
const buf = Buffer.alloc(n);
|
|
46
|
+
fs.readSync(fd, buf, 0, n, st.size - n);
|
|
47
|
+
fs.closeSync(fd);
|
|
48
|
+
return buf.toString("utf8").trim();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** What the rescuer is told: why the first agent stopped, and everything it left behind. */
|
|
55
|
+
export function takeoverBrief(db, o) {
|
|
56
|
+
const evs = db.eventsOfType(["plan.announced", "task.progress"], o.since - 1, 400).filter((e) => e.task_id === o.taskId);
|
|
57
|
+
const posted = [];
|
|
58
|
+
for (const e of evs) {
|
|
59
|
+
const p = parse(e.payload);
|
|
60
|
+
if (e.type === "plan.announced" && e.agent_id === o.from)
|
|
61
|
+
posted.push(` plan: ${cap(String(p.intent ?? ""), 300)}${Array.isArray(p.touches) && p.touches.length ? ` — touches ${p.touches.slice(0, 12).join(", ")}` : ""}`);
|
|
62
|
+
if (e.type === "task.progress" && p.note)
|
|
63
|
+
posted.push(` progress ${p.pct}%: ${cap(String(p.note), 300)}`);
|
|
64
|
+
}
|
|
65
|
+
const notes = db
|
|
66
|
+
.recall()
|
|
67
|
+
.filter((m) => m.author === o.from && m.created_at >= o.since)
|
|
68
|
+
.reverse()
|
|
69
|
+
.slice(-6)
|
|
70
|
+
.map((m) => ` ${m.kind}: ${cap(m.body, 300)}${m.why ? ` (why: ${cap(m.why, 160)})` : ""}`);
|
|
71
|
+
const summary = db.getHandoff(o.taskId)?.summary;
|
|
72
|
+
const left = [...posted.slice(-10), ...notes, ...(summary ? [` done summary: ${cap(summary, 400)}`] : [])];
|
|
73
|
+
const log = o.testOutput === undefined && o.logPath ? tail(o.logPath, 1200) : "";
|
|
74
|
+
return `
|
|
75
|
+
|
|
76
|
+
TAKEOVER — ${o.from} started this task and couldn't finish it: ${o.reason}.
|
|
77
|
+
You are taking it over. ${o.from}'s work is ALREADY in your current directory (branch ${o.branch}, uncommitted edits included):
|
|
78
|
+
continue from it — don't start over. Look first (git status, git diff), keep what's right, fix what's wrong, finish the task,
|
|
79
|
+
and make the whole test suite pass. There is one takeover per task: if this doesn't land, it goes to the user.
|
|
80
|
+
|
|
81
|
+
WHAT ${o.from} POSTED:
|
|
82
|
+
${left.length ? left.join("\n") : " (nothing — it never called huddle_progress / huddle_remember)"}
|
|
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
|
+
}
|
|
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
|
+
}
|
|
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
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Runs before anything else in cli.ts (it's the first import, and ES modules
|
|
2
|
+
// evaluate in import order): refuse old Node with a plain sentence instead of
|
|
3
|
+
// a crash deep inside a dependency. No imports beyond node: builtins, so this
|
|
4
|
+
// file works on any Node that can parse the CLI at all.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
export const MIN_NODE = 20;
|
|
9
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
10
|
+
if (major < MIN_NODE) {
|
|
11
|
+
console.error(`huddle needs Node.js ${MIN_NODE} or newer — this is ${process.version}.\n` +
|
|
12
|
+
` Update Node (https://nodejs.org, or \`nvm install ${MIN_NODE}\`), then: npm install -g huddle-cli`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
/** Our own package.json — found by walking up, so it works from dist/ and from the test build. */
|
|
16
|
+
export function packageInfo() {
|
|
17
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
for (let i = 0; i < 5; i++) {
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
21
|
+
if (pkg.name === "huddle-cli")
|
|
22
|
+
return { name: pkg.name, version: pkg.version };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* not here — keep walking */
|
|
26
|
+
}
|
|
27
|
+
dir = path.dirname(dir);
|
|
28
|
+
}
|
|
29
|
+
return { name: "huddle-cli", version: "unknown" };
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=preflight.js.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The relay: a tiny, stateless switchboard so rooms work across any networks.
|
|
2
|
+
//
|
|
3
|
+
// Both sides dial OUT to it (which works through NAT, campus Wi-Fi, anything):
|
|
4
|
+
// host wss://relay/host/<room>?key=<secret> — one per room
|
|
5
|
+
// member wss://relay/join/<room> — anyone with the address
|
|
6
|
+
// The relay only forwards frames between a room's members and its host. It
|
|
7
|
+
// can't let anyone in: every request is checked by the host's Room (invite
|
|
8
|
+
// codes, GitHub proofs, member tokens). Nothing is stored; when the host
|
|
9
|
+
// disconnects, the room is gone.
|
|
10
|
+
//
|
|
11
|
+
// Run it anywhere Node runs: huddle relay --port 8080
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
15
|
+
const MAX_FRAME = 512 * 1024;
|
|
16
|
+
const MAX_MEMBERS = 64;
|
|
17
|
+
const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
|
18
|
+
export function startRelay(port, log = console.log) {
|
|
19
|
+
const rooms = new Map();
|
|
20
|
+
const server = http.createServer((req, res) => {
|
|
21
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
22
|
+
res.end(JSON.stringify({ huddle: "relay", rooms: rooms.size }));
|
|
23
|
+
});
|
|
24
|
+
const wss = new WebSocketServer({ server, maxPayload: MAX_FRAME });
|
|
25
|
+
wss.on("connection", (ws, req) => {
|
|
26
|
+
const url = new URL(req.url ?? "/", "http://relay");
|
|
27
|
+
const [, kind, roomId] = url.pathname.split("/");
|
|
28
|
+
if (!roomId || !/^[\w-]{8,64}$/.test(roomId))
|
|
29
|
+
return ws.close(4400, "bad room id");
|
|
30
|
+
if (kind === "host") {
|
|
31
|
+
const key = url.searchParams.get("key") ?? "";
|
|
32
|
+
const existing = rooms.get(roomId);
|
|
33
|
+
if (existing && existing.keyHash !== sha(key))
|
|
34
|
+
return ws.close(4403, "room id taken");
|
|
35
|
+
if (existing)
|
|
36
|
+
existing.host.close(4409, "replaced by a new host connection");
|
|
37
|
+
const link = { host: ws, keyHash: sha(key), members: existing?.members ?? new Map() };
|
|
38
|
+
rooms.set(roomId, link);
|
|
39
|
+
log(`room ${roomId.slice(0, 6)}… host connected`);
|
|
40
|
+
ws.on("message", (raw) => {
|
|
41
|
+
let f;
|
|
42
|
+
try {
|
|
43
|
+
f = JSON.parse(String(raw));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// host → one member
|
|
49
|
+
if (f.t === "to" && typeof f.cid === "string") {
|
|
50
|
+
const m = link.members.get(f.cid);
|
|
51
|
+
if (m?.readyState === WebSocket.OPEN)
|
|
52
|
+
m.send(JSON.stringify(f.f));
|
|
53
|
+
if (f.close)
|
|
54
|
+
m?.close(f.close, "closed by room");
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
ws.on("close", () => {
|
|
58
|
+
if (rooms.get(roomId)?.host !== ws)
|
|
59
|
+
return;
|
|
60
|
+
rooms.delete(roomId);
|
|
61
|
+
for (const m of link.members.values()) {
|
|
62
|
+
if (m.readyState === WebSocket.OPEN)
|
|
63
|
+
m.send(JSON.stringify({ t: "closed", code: 1001 }));
|
|
64
|
+
m.close(1001, "room closed");
|
|
65
|
+
}
|
|
66
|
+
log(`room ${roomId.slice(0, 6)}… closed`);
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (kind === "join") {
|
|
71
|
+
const link = rooms.get(roomId);
|
|
72
|
+
if (!link)
|
|
73
|
+
return ws.close(4404, "no such room (is the host's Huddle open?)");
|
|
74
|
+
if (link.members.size >= MAX_MEMBERS)
|
|
75
|
+
return ws.close(4429, "room is full");
|
|
76
|
+
const cid = crypto.randomBytes(8).toString("hex");
|
|
77
|
+
link.members.set(cid, ws);
|
|
78
|
+
ws.on("message", (raw) => {
|
|
79
|
+
let f;
|
|
80
|
+
try {
|
|
81
|
+
f = JSON.parse(String(raw));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (link.host.readyState === WebSocket.OPEN)
|
|
87
|
+
link.host.send(JSON.stringify({ t: "from", cid, f }));
|
|
88
|
+
});
|
|
89
|
+
ws.on("close", () => {
|
|
90
|
+
link.members.delete(cid);
|
|
91
|
+
if (link.host.readyState === WebSocket.OPEN)
|
|
92
|
+
link.host.send(JSON.stringify({ t: "gone", cid }));
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
ws.close(4400, "unknown path");
|
|
97
|
+
});
|
|
98
|
+
wss.on("error", () => { }); // listen errors are reported below
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
server.once("error", (e) => reject(new Error(e?.code === "EADDRINUSE" ? `port ${port} is already in use — try --port <another>` : String(e?.message ?? e))));
|
|
101
|
+
server.listen(port, "0.0.0.0", () => {
|
|
102
|
+
const actual = server.address().port;
|
|
103
|
+
resolve({
|
|
104
|
+
port: actual,
|
|
105
|
+
close: () => {
|
|
106
|
+
wss.close();
|
|
107
|
+
server.close();
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=server.js.map
|