agentweaver 0.1.17 → 0.1.18
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/README.md +104 -23
- package/dist/artifacts.js +41 -0
- package/dist/index.js +252 -27
- package/dist/interactive/controller.js +249 -13
- package/dist/interactive/ink/index.js +2 -2
- package/dist/interactive/state.js +1 -0
- package/dist/interactive/web/index.js +179 -0
- package/dist/interactive/web/protocol.js +154 -0
- package/dist/interactive/web/server.js +575 -0
- package/dist/interactive/web/static/app.js +709 -0
- package/dist/interactive/web/static/index.html +77 -0
- package/dist/interactive/web/static/styles.css +2 -0
- package/dist/interactive/web/static/styles.input.css +469 -0
- package/dist/pipeline/flow-catalog.js +4 -0
- package/dist/pipeline/flow-specs/auto-common-guided.json +313 -0
- package/dist/pipeline/flow-specs/auto-common.json +3 -1
- package/dist/pipeline/flow-specs/design-review/design-review-loop.json +2 -0
- package/dist/pipeline/flow-specs/design-review.json +2 -0
- package/dist/pipeline/flow-specs/implement.json +3 -1
- package/dist/pipeline/flow-specs/plan.json +4 -0
- package/dist/pipeline/flow-specs/playbook-init.json +199 -0
- package/dist/pipeline/flow-specs/review/review-fix.json +3 -1
- package/dist/pipeline/flow-specs/review/review-loop.json +4 -0
- package/dist/pipeline/flow-specs/review/review.json +2 -0
- package/dist/pipeline/node-registry.js +45 -0
- package/dist/pipeline/nodes/flow-run-node.js +13 -1
- package/dist/pipeline/nodes/playbook-ensure-node.js +115 -0
- package/dist/pipeline/nodes/playbook-inventory-node.js +51 -0
- package/dist/pipeline/nodes/playbook-questions-form-node.js +166 -0
- package/dist/pipeline/nodes/playbook-write-node.js +243 -0
- package/dist/pipeline/nodes/project-guidance-node.js +69 -0
- package/dist/pipeline/prompt-registry.js +4 -1
- package/dist/pipeline/prompt-runtime.js +6 -2
- package/dist/pipeline/spec-types.js +19 -0
- package/dist/pipeline/value-resolver.js +39 -1
- package/dist/playbook/practice-candidates.js +12 -0
- package/dist/playbook/repo-inventory.js +208 -0
- package/dist/prompts.js +31 -0
- package/dist/runtime/playbook.js +485 -0
- package/dist/runtime/project-guidance.js +339 -0
- package/dist/structured-artifact-schema-registry.js +8 -0
- package/dist/structured-artifact-schemas.json +235 -0
- package/dist/structured-artifacts.js +7 -1
- package/docs/declarative-workflows.md +565 -0
- package/docs/features.md +77 -0
- package/docs/playbook.md +327 -0
- package/package.json +8 -3
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parseClientAction } from "./protocol.js";
|
|
9
|
+
const STATIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
|
|
10
|
+
const CONTENT_TYPES = new Map([
|
|
11
|
+
[".html", "text/html; charset=utf-8"],
|
|
12
|
+
[".css", "text/css; charset=utf-8"],
|
|
13
|
+
[".js", "text/javascript; charset=utf-8"],
|
|
14
|
+
[".json", "application/json; charset=utf-8"],
|
|
15
|
+
[".svg", "image/svg+xml; charset=utf-8"],
|
|
16
|
+
]);
|
|
17
|
+
const BASIC_AUTH_REALM = "AgentWeaver Web UI";
|
|
18
|
+
function hashCredential(value) {
|
|
19
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
20
|
+
}
|
|
21
|
+
function timingSafeStringEqual(actual, expected) {
|
|
22
|
+
return timingSafeEqual(hashCredential(actual), hashCredential(expected));
|
|
23
|
+
}
|
|
24
|
+
function parseBasicAuthorization(header) {
|
|
25
|
+
if (!header) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const match = header.match(/^Basic\s+(.+)$/i);
|
|
29
|
+
if (!match?.[1]) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
let decoded;
|
|
33
|
+
try {
|
|
34
|
+
decoded = Buffer.from(match[1], "base64").toString("utf8");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const separatorIndex = decoded.indexOf(":");
|
|
40
|
+
if (separatorIndex < 0) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
username: decoded.slice(0, separatorIndex),
|
|
45
|
+
password: decoded.slice(separatorIndex + 1),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function isAuthorized(request, auth) {
|
|
49
|
+
if (!auth) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
const credentials = parseBasicAuthorization(request.headers.authorization);
|
|
53
|
+
if (!credentials) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return timingSafeStringEqual(credentials.username, auth.username) && timingSafeStringEqual(credentials.password, auth.password);
|
|
57
|
+
}
|
|
58
|
+
function writeAuthRequired(response) {
|
|
59
|
+
response.writeHead(401, {
|
|
60
|
+
"content-type": "text/plain; charset=utf-8",
|
|
61
|
+
"www-authenticate": `Basic realm="${BASIC_AUTH_REALM}"`,
|
|
62
|
+
"cache-control": "no-store",
|
|
63
|
+
});
|
|
64
|
+
response.end("Authentication required");
|
|
65
|
+
}
|
|
66
|
+
function rejectUnauthorizedUpgrade(socket) {
|
|
67
|
+
socket.write([
|
|
68
|
+
"HTTP/1.1 401 Unauthorized",
|
|
69
|
+
`WWW-Authenticate: Basic realm="${BASIC_AUTH_REALM}"`,
|
|
70
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
71
|
+
"Connection: close",
|
|
72
|
+
"",
|
|
73
|
+
"Authentication required",
|
|
74
|
+
].join("\r\n"));
|
|
75
|
+
socket.destroy();
|
|
76
|
+
}
|
|
77
|
+
function staticAssetPath(requestUrl) {
|
|
78
|
+
const parsed = new URL(requestUrl ?? "/", "http://agentweaver.local");
|
|
79
|
+
const pathname = parsed.pathname === "/" ? "/index.html" : parsed.pathname;
|
|
80
|
+
if (pathname !== "/index.html" && !pathname.startsWith("/static/")) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const relativePath = pathname === "/index.html" ? "index.html" : pathname.slice("/static/".length);
|
|
84
|
+
const normalized = path.normalize(relativePath);
|
|
85
|
+
if (normalized.startsWith("..") || path.isAbsolute(normalized)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const assetPath = path.join(STATIC_DIR, normalized);
|
|
89
|
+
if (!assetPath.startsWith(STATIC_DIR) || !existsSync(assetPath) || !statSync(assetPath).isFile()) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return assetPath;
|
|
93
|
+
}
|
|
94
|
+
function serveStaticAsset(request, response) {
|
|
95
|
+
if (request.method !== "GET") {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const assetPath = staticAssetPath(request.url);
|
|
99
|
+
if (!assetPath) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
response.writeHead(200, {
|
|
103
|
+
"content-type": CONTENT_TYPES.get(path.extname(assetPath)) ?? "application/octet-stream",
|
|
104
|
+
"cache-control": "no-store",
|
|
105
|
+
});
|
|
106
|
+
response.end(readFileSync(assetPath));
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
function htmlShell() {
|
|
110
|
+
return `<!doctype html>
|
|
111
|
+
<html lang="en">
|
|
112
|
+
<head>
|
|
113
|
+
<meta charset="utf-8">
|
|
114
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
115
|
+
<title>AgentWeaver Web UI</title>
|
|
116
|
+
<style>
|
|
117
|
+
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
118
|
+
body { margin: 0; background: #f6f7f9; color: #172026; }
|
|
119
|
+
main { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; }
|
|
120
|
+
header { display: flex; justify-content: space-between; gap: 16px; align-items: center; border-bottom: 1px solid #d8dee6; padding-bottom: 12px; }
|
|
121
|
+
h1 { margin: 0; font-size: 24px; font-weight: 650; letter-spacing: 0; }
|
|
122
|
+
button { border: 1px solid #b8c2cc; background: #ffffff; color: #172026; border-radius: 6px; padding: 8px 12px; cursor: pointer; }
|
|
123
|
+
section { display: grid; gap: 8px; }
|
|
124
|
+
pre, textarea { border: 1px solid #d8dee6; border-radius: 6px; background: #ffffff; padding: 12px; white-space: pre-wrap; overflow: auto; }
|
|
125
|
+
pre { min-height: 96px; }
|
|
126
|
+
.grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 0.45fr); gap: 16px; align-items: start; }
|
|
127
|
+
.muted { color: #5d6875; }
|
|
128
|
+
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
129
|
+
label { display: grid; gap: 4px; font-size: 14px; }
|
|
130
|
+
input, textarea, select { font: inherit; border: 1px solid #b8c2cc; border-radius: 6px; padding: 8px; background: #ffffff; color: #172026; }
|
|
131
|
+
@media (prefers-color-scheme: dark) {
|
|
132
|
+
body { background: #101418; color: #eef2f6; }
|
|
133
|
+
header, pre, textarea, input, select, button { border-color: #34404c; }
|
|
134
|
+
pre, textarea, input, select, button { background: #171d23; color: #eef2f6; }
|
|
135
|
+
.muted { color: #9aa6b2; }
|
|
136
|
+
}
|
|
137
|
+
@media (max-width: 760px) { main { padding: 16px; } .grid { grid-template-columns: 1fr; } }
|
|
138
|
+
</style>
|
|
139
|
+
</head>
|
|
140
|
+
<body>
|
|
141
|
+
<main>
|
|
142
|
+
<header>
|
|
143
|
+
<div>
|
|
144
|
+
<h1>AgentWeaver Web UI</h1>
|
|
145
|
+
<div id="scope" class="muted">Connecting...</div>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="row">
|
|
148
|
+
<button id="help">Help</button>
|
|
149
|
+
<button id="clear-log">Clear Log</button>
|
|
150
|
+
</div>
|
|
151
|
+
</header>
|
|
152
|
+
<div class="grid">
|
|
153
|
+
<section>
|
|
154
|
+
<h2>Summary</h2>
|
|
155
|
+
<pre id="summary">Task summary is not available yet.</pre>
|
|
156
|
+
<h2>Activity</h2>
|
|
157
|
+
<pre id="logs"></pre>
|
|
158
|
+
</section>
|
|
159
|
+
<section>
|
|
160
|
+
<h2>Action</h2>
|
|
161
|
+
<div id="flows" class="row"></div>
|
|
162
|
+
<div id="action" class="muted">No action is pending.</div>
|
|
163
|
+
</section>
|
|
164
|
+
</div>
|
|
165
|
+
</main>
|
|
166
|
+
<script>
|
|
167
|
+
const scope = document.getElementById("scope");
|
|
168
|
+
const summary = document.getElementById("summary");
|
|
169
|
+
const logs = document.getElementById("logs");
|
|
170
|
+
const action = document.getElementById("action");
|
|
171
|
+
const flows = document.getElementById("flows");
|
|
172
|
+
const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/__agentweaver/ws");
|
|
173
|
+
let viewModel = null;
|
|
174
|
+
function send(message) { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message)); }
|
|
175
|
+
function appendLogLine(line) {
|
|
176
|
+
logs.textContent += (logs.textContent ? "\\n" : "") + line;
|
|
177
|
+
}
|
|
178
|
+
function renderState(next) {
|
|
179
|
+
viewModel = next;
|
|
180
|
+
scope.textContent = next.header || next.title || "AgentWeaver";
|
|
181
|
+
summary.textContent = next.summaryText || "Task summary is not available yet.";
|
|
182
|
+
logs.textContent = next.logText || "";
|
|
183
|
+
flows.innerHTML = "";
|
|
184
|
+
for (const [index, flow] of (next.flowItems || []).entries()) {
|
|
185
|
+
const button = document.createElement("button");
|
|
186
|
+
button.textContent = flow.label;
|
|
187
|
+
button.title = flow.key;
|
|
188
|
+
button.onclick = () => send({ type: "flow.select", index });
|
|
189
|
+
button.ondblclick = () => {
|
|
190
|
+
if (flow.key.startsWith("folder:")) send({ type: "folder.toggle", key: flow.key });
|
|
191
|
+
else send({ type: "run.openConfirm", key: flow.key });
|
|
192
|
+
};
|
|
193
|
+
flows.append(button);
|
|
194
|
+
}
|
|
195
|
+
renderAction();
|
|
196
|
+
}
|
|
197
|
+
function renderAction() {
|
|
198
|
+
action.innerHTML = "";
|
|
199
|
+
if (!viewModel) {
|
|
200
|
+
action.textContent = "No action is pending.";
|
|
201
|
+
action.className = "muted";
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (viewModel.confirmation || viewModel.confirmText) {
|
|
205
|
+
const confirmation = viewModel.confirmation;
|
|
206
|
+
const label = document.createElement("div");
|
|
207
|
+
label.textContent = confirmation ? confirmation.text : viewModel.confirmText;
|
|
208
|
+
const row = document.createElement("div");
|
|
209
|
+
row.className = "row";
|
|
210
|
+
const actions = confirmation ? confirmation.actions : ["resume", "continue", "restart", "stop", "ok", "cancel"].filter((name) => viewModel.confirmText.toLowerCase().includes(name === "ok" ? "ok" : name));
|
|
211
|
+
for (const name of actions) {
|
|
212
|
+
const button = document.createElement("button");
|
|
213
|
+
button.textContent = name === "ok" ? "OK" : name[0].toUpperCase() + name.slice(1);
|
|
214
|
+
button.onclick = () => {
|
|
215
|
+
send({ type: "confirm.select", action: name });
|
|
216
|
+
send({ type: "confirm.accept" });
|
|
217
|
+
};
|
|
218
|
+
row.append(button);
|
|
219
|
+
}
|
|
220
|
+
if (!actions.includes("cancel")) {
|
|
221
|
+
const cancel = document.createElement("button");
|
|
222
|
+
cancel.textContent = "Cancel";
|
|
223
|
+
cancel.onclick = () => send({ type: "confirm.cancel" });
|
|
224
|
+
row.append(cancel);
|
|
225
|
+
}
|
|
226
|
+
action.append(label, row);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (viewModel.form) {
|
|
230
|
+
const formModel = viewModel.form;
|
|
231
|
+
const form = document.createElement("form");
|
|
232
|
+
const title = document.createElement("strong");
|
|
233
|
+
title.textContent = formModel.definition.title;
|
|
234
|
+
form.append(title);
|
|
235
|
+
for (const field of formModel.fields || formModel.definition.fields) {
|
|
236
|
+
const label = document.createElement("label");
|
|
237
|
+
label.textContent = field.label;
|
|
238
|
+
let input;
|
|
239
|
+
if (field.type === "boolean") {
|
|
240
|
+
input = document.createElement("input");
|
|
241
|
+
input.type = "checkbox";
|
|
242
|
+
input.checked = Boolean(formModel.values[field.id]);
|
|
243
|
+
} else if (field.type === "text") {
|
|
244
|
+
input = document.createElement(field.multiline ? "textarea" : "input");
|
|
245
|
+
input.value = String(formModel.values[field.id] || "");
|
|
246
|
+
} else {
|
|
247
|
+
input = document.createElement("select");
|
|
248
|
+
input.multiple = field.type === "multi-select";
|
|
249
|
+
for (const option of field.options || []) {
|
|
250
|
+
const opt = document.createElement("option");
|
|
251
|
+
opt.value = option.value;
|
|
252
|
+
opt.textContent = option.label;
|
|
253
|
+
const current = formModel.values[field.id];
|
|
254
|
+
opt.selected = Array.isArray(current) ? current.includes(option.value) : current === option.value;
|
|
255
|
+
input.append(opt);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
input.dataset.fieldId = field.id;
|
|
259
|
+
input.dataset.fieldType = field.type;
|
|
260
|
+
label.append(input);
|
|
261
|
+
form.append(label);
|
|
262
|
+
}
|
|
263
|
+
const row = document.createElement("div");
|
|
264
|
+
row.className = "row";
|
|
265
|
+
const submit = document.createElement("button");
|
|
266
|
+
submit.textContent = formModel.definition.submitLabel || "Submit";
|
|
267
|
+
const cancel = document.createElement("button");
|
|
268
|
+
cancel.type = "button";
|
|
269
|
+
cancel.textContent = "Cancel";
|
|
270
|
+
cancel.onclick = () => send({ type: "form.cancel" });
|
|
271
|
+
row.append(submit, cancel);
|
|
272
|
+
form.append(row);
|
|
273
|
+
function collectValues() {
|
|
274
|
+
const values = {};
|
|
275
|
+
for (const el of form.querySelectorAll("[data-field-id]")) {
|
|
276
|
+
if (el.dataset.fieldType === "boolean") values[el.dataset.fieldId] = el.checked;
|
|
277
|
+
else if (el.dataset.fieldType === "multi-select") values[el.dataset.fieldId] = Array.from(el.selectedOptions).map((option) => option.value);
|
|
278
|
+
else values[el.dataset.fieldId] = el.value;
|
|
279
|
+
}
|
|
280
|
+
return values;
|
|
281
|
+
}
|
|
282
|
+
form.oninput = (event) => {
|
|
283
|
+
const target = event.target;
|
|
284
|
+
if (target && target.dataset && target.dataset.fieldId) {
|
|
285
|
+
const fieldId = target.dataset.fieldId;
|
|
286
|
+
const values = collectValues();
|
|
287
|
+
send({ type: "form.fieldUpdate", fieldId, value: values[fieldId] });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
send({ type: "form.update", values: collectValues() });
|
|
291
|
+
};
|
|
292
|
+
form.onsubmit = (event) => {
|
|
293
|
+
event.preventDefault();
|
|
294
|
+
send({ type: "form.submit", values: collectValues() });
|
|
295
|
+
};
|
|
296
|
+
action.append(form);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const row = document.createElement("div");
|
|
300
|
+
row.className = "row";
|
|
301
|
+
const run = document.createElement("button");
|
|
302
|
+
run.textContent = "Run Selected";
|
|
303
|
+
run.onclick = () => send({ type: "run.openConfirm" });
|
|
304
|
+
const interrupt = document.createElement("button");
|
|
305
|
+
interrupt.textContent = "Interrupt";
|
|
306
|
+
interrupt.onclick = () => send({ type: "interrupt.openConfirm" });
|
|
307
|
+
row.append(run, interrupt);
|
|
308
|
+
action.append(row);
|
|
309
|
+
action.className = "muted";
|
|
310
|
+
}
|
|
311
|
+
ws.onmessage = (event) => {
|
|
312
|
+
const message = JSON.parse(event.data);
|
|
313
|
+
if (message.type === "snapshot") renderState(message.viewModel);
|
|
314
|
+
if (message.type === "log.append") for (const line of message.appendedLines) appendLogLine(line);
|
|
315
|
+
if (message.type === "error") appendLogLine("[protocol] " + message.message);
|
|
316
|
+
if (message.type === "closed") appendLogLine("[closed] " + (message.reason || "Session closed."));
|
|
317
|
+
};
|
|
318
|
+
document.getElementById("help").onclick = () => send({ type: "help.toggle" });
|
|
319
|
+
document.getElementById("clear-log").onclick = () => send({ type: "log.clear" });
|
|
320
|
+
</script>
|
|
321
|
+
</body>
|
|
322
|
+
</html>`;
|
|
323
|
+
}
|
|
324
|
+
function acceptKey(key) {
|
|
325
|
+
return createHash("sha1")
|
|
326
|
+
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
327
|
+
.digest("base64");
|
|
328
|
+
}
|
|
329
|
+
function encodeFrame(payload) {
|
|
330
|
+
const data = Buffer.from(payload);
|
|
331
|
+
if (data.length < 126) {
|
|
332
|
+
return Buffer.concat([Buffer.from([0x81, data.length]), data]);
|
|
333
|
+
}
|
|
334
|
+
if (data.length <= 0xffff) {
|
|
335
|
+
const header = Buffer.alloc(4);
|
|
336
|
+
header[0] = 0x81;
|
|
337
|
+
header[1] = 126;
|
|
338
|
+
header.writeUInt16BE(data.length, 2);
|
|
339
|
+
return Buffer.concat([header, data]);
|
|
340
|
+
}
|
|
341
|
+
const header = Buffer.alloc(10);
|
|
342
|
+
header[0] = 0x81;
|
|
343
|
+
header[1] = 127;
|
|
344
|
+
header.writeBigUInt64BE(BigInt(data.length), 2);
|
|
345
|
+
return Buffer.concat([header, data]);
|
|
346
|
+
}
|
|
347
|
+
function decodeFrames(buffer) {
|
|
348
|
+
const messages = [];
|
|
349
|
+
let offset = 0;
|
|
350
|
+
let close = false;
|
|
351
|
+
while (buffer.length - offset >= 2) {
|
|
352
|
+
const first = buffer[offset] ?? 0;
|
|
353
|
+
const second = buffer[offset + 1] ?? 0;
|
|
354
|
+
const opcode = first & 0x0f;
|
|
355
|
+
const masked = (second & 0x80) !== 0;
|
|
356
|
+
let length = second & 0x7f;
|
|
357
|
+
let headerLength = 2;
|
|
358
|
+
if (length === 126) {
|
|
359
|
+
if (buffer.length - offset < 4) {
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
length = buffer.readUInt16BE(offset + 2);
|
|
363
|
+
headerLength = 4;
|
|
364
|
+
}
|
|
365
|
+
else if (length === 127) {
|
|
366
|
+
if (buffer.length - offset < 10) {
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
const longLength = buffer.readBigUInt64BE(offset + 2);
|
|
370
|
+
if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
371
|
+
close = true;
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
length = Number(longLength);
|
|
375
|
+
headerLength = 10;
|
|
376
|
+
}
|
|
377
|
+
const maskLength = masked ? 4 : 0;
|
|
378
|
+
const frameLength = headerLength + maskLength + length;
|
|
379
|
+
if (buffer.length - offset < frameLength) {
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
if (opcode === 0x8) {
|
|
383
|
+
close = true;
|
|
384
|
+
offset += frameLength;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (opcode === 0x1) {
|
|
388
|
+
const mask = masked ? buffer.subarray(offset + headerLength, offset + headerLength + 4) : null;
|
|
389
|
+
const payload = Buffer.from(buffer.subarray(offset + headerLength + maskLength, offset + frameLength));
|
|
390
|
+
if (mask) {
|
|
391
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
392
|
+
payload[index] = (payload[index] ?? 0) ^ (mask[index % 4] ?? 0);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
messages.push(payload.toString("utf8"));
|
|
396
|
+
}
|
|
397
|
+
offset += frameLength;
|
|
398
|
+
}
|
|
399
|
+
return { messages, rest: buffer.subarray(offset), close };
|
|
400
|
+
}
|
|
401
|
+
function defaultOpenBrowser(url) {
|
|
402
|
+
return new Promise((resolve, reject) => {
|
|
403
|
+
const platform = process.platform;
|
|
404
|
+
const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
405
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
406
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
407
|
+
child.once("error", reject);
|
|
408
|
+
child.once("spawn", () => {
|
|
409
|
+
child.unref();
|
|
410
|
+
resolve();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
function formatHostForUrl(host) {
|
|
415
|
+
if (host.includes(":") && !host.startsWith("[")) {
|
|
416
|
+
return `[${host}]`;
|
|
417
|
+
}
|
|
418
|
+
return host;
|
|
419
|
+
}
|
|
420
|
+
export async function startWebServer(options) {
|
|
421
|
+
const clients = new Set();
|
|
422
|
+
const sockets = new Set();
|
|
423
|
+
const host = options.host?.trim() || "127.0.0.1";
|
|
424
|
+
const auth = options.auth;
|
|
425
|
+
let closed = false;
|
|
426
|
+
const server = http.createServer((request, response) => {
|
|
427
|
+
if (request.method === "GET" && request.url === "/__agentweaver/health") {
|
|
428
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
429
|
+
response.end(JSON.stringify({ ok: true }));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (request.method === "GET" && staticAssetPath(request.url)) {
|
|
433
|
+
if (!isAuthorized(request, auth)) {
|
|
434
|
+
writeAuthRequired(response);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (serveStaticAsset(request, response)) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (request.method === "POST" && request.url === "/__agentweaver/exit") {
|
|
442
|
+
if (!isAuthorized(request, auth)) {
|
|
443
|
+
writeAuthRequired(response);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
response.writeHead(202, { "content-type": "application/json; charset=utf-8" });
|
|
447
|
+
response.end(JSON.stringify({ ok: true }));
|
|
448
|
+
options.onExitRequested();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
452
|
+
response.end("Not found");
|
|
453
|
+
});
|
|
454
|
+
server.on("connection", (socket) => {
|
|
455
|
+
sockets.add(socket);
|
|
456
|
+
socket.on("close", () => sockets.delete(socket));
|
|
457
|
+
socket.on("error", () => sockets.delete(socket));
|
|
458
|
+
});
|
|
459
|
+
server.on("upgrade", (request, socket) => {
|
|
460
|
+
if (request.url !== "/__agentweaver/ws") {
|
|
461
|
+
socket.destroy();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (!isAuthorized(request, auth)) {
|
|
465
|
+
rejectUnauthorizedUpgrade(socket);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const key = request.headers["sec-websocket-key"];
|
|
469
|
+
if (typeof key !== "string") {
|
|
470
|
+
socket.destroy();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
socket.write([
|
|
474
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
475
|
+
"Upgrade: websocket",
|
|
476
|
+
"Connection: Upgrade",
|
|
477
|
+
`Sec-WebSocket-Accept: ${acceptKey(key)}`,
|
|
478
|
+
"",
|
|
479
|
+
"",
|
|
480
|
+
].join("\r\n"));
|
|
481
|
+
let buffered = Buffer.alloc(0);
|
|
482
|
+
const client = {
|
|
483
|
+
socket,
|
|
484
|
+
send: (message) => {
|
|
485
|
+
if (!socket.destroyed) {
|
|
486
|
+
socket.write(encodeFrame(JSON.stringify(message)));
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
close: () => {
|
|
490
|
+
if (!socket.destroyed) {
|
|
491
|
+
socket.end(Buffer.from([0x88, 0x00]));
|
|
492
|
+
socket.destroy();
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
clients.add(client);
|
|
497
|
+
socket.on("data", (chunk) => {
|
|
498
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
499
|
+
const decoded = decodeFrames(buffered);
|
|
500
|
+
buffered = decoded.rest;
|
|
501
|
+
if (decoded.close) {
|
|
502
|
+
client.close();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
for (const message of decoded.messages) {
|
|
506
|
+
try {
|
|
507
|
+
options.onClientAction(parseClientAction(message), client);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
client.send({ type: "error", message: error.message });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
socket.on("close", () => clients.delete(client));
|
|
515
|
+
socket.on("error", () => clients.delete(client));
|
|
516
|
+
options.onClientConnected(client);
|
|
517
|
+
});
|
|
518
|
+
await new Promise((resolve, reject) => {
|
|
519
|
+
server.once("error", reject);
|
|
520
|
+
server.listen(0, host, () => {
|
|
521
|
+
server.off("error", reject);
|
|
522
|
+
resolve();
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
const address = server.address();
|
|
526
|
+
if (!address || typeof address === "string" || typeof address.port !== "number" || address.port <= 0) {
|
|
527
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
528
|
+
throw new Error("Unable to determine assigned Web UI port.");
|
|
529
|
+
}
|
|
530
|
+
const url = `http://${formatHostForUrl(host)}:${address.port}/`;
|
|
531
|
+
process.stdout.write(`AgentWeaver Web UI: ${url}\n`);
|
|
532
|
+
if (!options.noOpen) {
|
|
533
|
+
try {
|
|
534
|
+
await (options.openBrowser ?? defaultOpenBrowser)(url);
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
options.printInfo?.(`Warning: failed to open browser: ${error.message}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
url,
|
|
542
|
+
host,
|
|
543
|
+
broadcast(message) {
|
|
544
|
+
for (const client of clients) {
|
|
545
|
+
client.send(message);
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
close: async () => {
|
|
549
|
+
if (closed) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
closed = true;
|
|
553
|
+
for (const client of clients) {
|
|
554
|
+
client.send({ type: "closed", reason: "Server shutting down." });
|
|
555
|
+
client.close();
|
|
556
|
+
}
|
|
557
|
+
clients.clear();
|
|
558
|
+
for (const socket of sockets) {
|
|
559
|
+
if (!socket.destroyed) {
|
|
560
|
+
socket.destroy();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
sockets.clear();
|
|
564
|
+
await new Promise((resolve, reject) => {
|
|
565
|
+
server.close((error) => {
|
|
566
|
+
if (error) {
|
|
567
|
+
reject(error);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
resolve();
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
}
|