maqcli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -0
- package/dist/core/audit.d.ts +43 -0
- package/dist/core/audit.js +77 -0
- package/dist/core/board.d.ts +78 -0
- package/dist/core/board.js +256 -0
- package/dist/core/catalog.d.ts +50 -0
- package/dist/core/catalog.js +103 -0
- package/dist/core/command-catalog.d.ts +44 -0
- package/dist/core/command-catalog.js +86 -0
- package/dist/core/completion.d.ts +24 -0
- package/dist/core/completion.js +309 -0
- package/dist/core/complexity.d.ts +17 -0
- package/dist/core/complexity.js +87 -0
- package/dist/core/config-store.d.ts +33 -0
- package/dist/core/config-store.js +61 -0
- package/dist/core/connectivity.d.ts +34 -0
- package/dist/core/connectivity.js +49 -0
- package/dist/core/cost-tracker.d.ts +89 -0
- package/dist/core/cost-tracker.js +189 -0
- package/dist/core/cost.d.ts +35 -0
- package/dist/core/cost.js +89 -0
- package/dist/core/exec.d.ts +43 -0
- package/dist/core/exec.js +154 -0
- package/dist/core/flows.d.ts +36 -0
- package/dist/core/flows.js +96 -0
- package/dist/core/headroom.d.ts +36 -0
- package/dist/core/headroom.js +88 -0
- package/dist/core/help-topics.d.ts +26 -0
- package/dist/core/help-topics.js +294 -0
- package/dist/core/init-wizard.d.ts +26 -0
- package/dist/core/init-wizard.js +168 -0
- package/dist/core/interactive-registry.d.ts +50 -0
- package/dist/core/interactive-registry.js +86 -0
- package/dist/core/interactive.d.ts +48 -0
- package/dist/core/interactive.js +137 -0
- package/dist/core/logger.d.ts +16 -0
- package/dist/core/logger.js +46 -0
- package/dist/core/memory.d.ts +28 -0
- package/dist/core/memory.js +70 -0
- package/dist/core/metered.d.ts +9 -0
- package/dist/core/metered.js +16 -0
- package/dist/core/model.d.ts +74 -0
- package/dist/core/model.js +199 -0
- package/dist/core/pipeline.d.ts +33 -0
- package/dist/core/pipeline.js +223 -0
- package/dist/core/plugins.d.ts +21 -0
- package/dist/core/plugins.js +38 -0
- package/dist/core/probe.d.ts +48 -0
- package/dist/core/probe.js +156 -0
- package/dist/core/profiles.d.ts +42 -0
- package/dist/core/profiles.js +153 -0
- package/dist/core/providers.d.ts +84 -0
- package/dist/core/providers.js +275 -0
- package/dist/core/recall.d.ts +29 -0
- package/dist/core/recall.js +83 -0
- package/dist/core/registry.d.ts +41 -0
- package/dist/core/registry.js +162 -0
- package/dist/core/router.d.ts +33 -0
- package/dist/core/router.js +40 -0
- package/dist/core/sandbox.d.ts +78 -0
- package/dist/core/sandbox.js +268 -0
- package/dist/core/session.d.ts +105 -0
- package/dist/core/session.js +252 -0
- package/dist/core/skills.d.ts +56 -0
- package/dist/core/skills.js +289 -0
- package/dist/core/subagent.d.ts +40 -0
- package/dist/core/subagent.js +55 -0
- package/dist/core/supervisor.d.ts +37 -0
- package/dist/core/supervisor.js +40 -0
- package/dist/core/tools.d.ts +39 -0
- package/dist/core/tools.js +159 -0
- package/dist/core/types.d.ts +87 -0
- package/dist/core/types.js +10 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1032 -0
- package/dist/phases/execute.d.ts +39 -0
- package/dist/phases/execute.js +166 -0
- package/dist/phases/plan.d.ts +11 -0
- package/dist/phases/plan.js +118 -0
- package/dist/phases/scout.d.ts +10 -0
- package/dist/phases/scout.js +113 -0
- package/dist/phases/verify.d.ts +22 -0
- package/dist/phases/verify.js +81 -0
- package/dist/server/daemon.d.ts +50 -0
- package/dist/server/daemon.js +377 -0
- package/dist/server/relay-bridge.d.ts +44 -0
- package/dist/server/relay-bridge.js +175 -0
- package/package.json +39 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MAQ daemon — a small, dependency-free HTTP + SSE server (Node built-in
|
|
3
|
+
* `http`) that exposes the orchestrator to the app track over one normalized
|
|
4
|
+
* seam. This is the concrete "master <-> app" contract from the product docs:
|
|
5
|
+
* the app never speaks any worker CLI's dialect, only MAQ's normalized events.
|
|
6
|
+
*
|
|
7
|
+
* SECURITY:
|
|
8
|
+
* - Binds to 127.0.0.1 by default (loopback only). Binding to 0.0.0.0 is
|
|
9
|
+
* allowed but logged loudly, because that exposes device control to the LAN.
|
|
10
|
+
* - Every route except /health requires `Authorization: Bearer <token>`.
|
|
11
|
+
* The token comes from MAQ_TOKEN, else config, else a freshly generated one
|
|
12
|
+
* that is printed once on startup. There is no unauthenticated path to run
|
|
13
|
+
* commands. Auth comparison is constant-time.
|
|
14
|
+
* - CORS is opt-in (MAQ_CORS_ORIGIN); off by default.
|
|
15
|
+
*
|
|
16
|
+
* Endpoints (all JSON unless noted):
|
|
17
|
+
* GET /health -> liveness (no auth)
|
|
18
|
+
* GET /v1/agents -> detected worker CLIs
|
|
19
|
+
* GET /v1/connectivity -> connectivity tier probe
|
|
20
|
+
* GET /v1/sessions -> list session summaries
|
|
21
|
+
* POST /v1/sessions -> start a session {task,target?,cwd?,dryRun?}
|
|
22
|
+
* GET /v1/sessions/:id -> one session (summary + events)
|
|
23
|
+
* GET /v1/sessions/:id/events -> SSE stream (replay history, then live)
|
|
24
|
+
* POST /v1/sessions/:id/message -> deliver a message to a session {text}
|
|
25
|
+
*/
|
|
26
|
+
import { createServer as httpCreateServer } from "node:http";
|
|
27
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
|
+
import { SessionRegistry } from "../core/session.js";
|
|
31
|
+
import { detectAgents, authAdvisory } from "../core/registry.js";
|
|
32
|
+
import { probeConnectivity } from "../core/probe.js";
|
|
33
|
+
import { execSafe } from "../core/exec.js";
|
|
34
|
+
import { commandCatalog, maqCommands } from "../core/command-catalog.js";
|
|
35
|
+
import { InteractiveRegistry } from "../core/interactive-registry.js";
|
|
36
|
+
/** Generate a URL-safe token. */
|
|
37
|
+
export function generateToken() {
|
|
38
|
+
return randomBytes(24).toString("base64url");
|
|
39
|
+
}
|
|
40
|
+
function constantTimeEqual(a, b) {
|
|
41
|
+
const ab = Buffer.from(a);
|
|
42
|
+
const bb = Buffer.from(b);
|
|
43
|
+
if (ab.length !== bb.length)
|
|
44
|
+
return false;
|
|
45
|
+
return timingSafeEqual(ab, bb);
|
|
46
|
+
}
|
|
47
|
+
export function createDaemon(opts = {}) {
|
|
48
|
+
const host = opts.host ?? process.env.MAQ_HOST ?? "127.0.0.1";
|
|
49
|
+
const port = opts.port ?? Number(process.env.MAQ_PORT ?? 7717);
|
|
50
|
+
const token = opts.token ?? process.env.MAQ_TOKEN ?? generateToken();
|
|
51
|
+
const version = opts.version ?? "0.2.0";
|
|
52
|
+
const corsOrigin = opts.corsOrigin ?? process.env.MAQ_CORS_ORIGIN;
|
|
53
|
+
const registry = opts.registry ?? new SessionRegistry();
|
|
54
|
+
const interactive = new InteractiveRegistry();
|
|
55
|
+
const startedAt = Date.now();
|
|
56
|
+
// Track live SSE responses so shutdown can end them deterministically.
|
|
57
|
+
// Without this, server.close() blocks forever on the long-lived event
|
|
58
|
+
// streams (they only emit a keep-alive ping every 15s and never end on their
|
|
59
|
+
// own), so SIGINT/SIGTERM would hang the daemon.
|
|
60
|
+
const sseClients = new Set();
|
|
61
|
+
const server = httpCreateServer((req, res) => handle(req, res).catch((err) => {
|
|
62
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
63
|
+
}));
|
|
64
|
+
function setCors(res) {
|
|
65
|
+
if (!corsOrigin)
|
|
66
|
+
return;
|
|
67
|
+
res.setHeader("access-control-allow-origin", corsOrigin);
|
|
68
|
+
res.setHeader("access-control-allow-headers", "authorization, content-type");
|
|
69
|
+
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
70
|
+
}
|
|
71
|
+
function authorized(req) {
|
|
72
|
+
const header = req.headers["authorization"];
|
|
73
|
+
if (!header || Array.isArray(header))
|
|
74
|
+
return false;
|
|
75
|
+
const m = /^Bearer\s+(.+)$/i.exec(header);
|
|
76
|
+
if (!m)
|
|
77
|
+
return false;
|
|
78
|
+
return constantTimeEqual(m[1], token);
|
|
79
|
+
}
|
|
80
|
+
async function handle(req, res) {
|
|
81
|
+
setCors(res);
|
|
82
|
+
const url = new URL(req.url ?? "/", `http://${host}:${port}`);
|
|
83
|
+
const path = url.pathname;
|
|
84
|
+
const method = req.method ?? "GET";
|
|
85
|
+
if (method === "OPTIONS") {
|
|
86
|
+
res.writeHead(204);
|
|
87
|
+
res.end();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// Liveness needs no auth.
|
|
91
|
+
if (path === "/health" && method === "GET") {
|
|
92
|
+
sendJson(res, 200, {
|
|
93
|
+
ok: true,
|
|
94
|
+
version,
|
|
95
|
+
sessions: registry.list().length,
|
|
96
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
97
|
+
uptimeMs: Date.now() - startedAt,
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!authorized(req)) {
|
|
102
|
+
sendJson(res, 401, { error: "unauthorized: send Authorization: Bearer <token>" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (path === "/v1/agents" && method === "GET") {
|
|
106
|
+
const agents = detectAgents();
|
|
107
|
+
sendJson(res, 200, { agents, advisory: authAdvisory(agents) });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (path === "/v1/connectivity" && method === "GET") {
|
|
111
|
+
sendJson(res, 200, await probeConnectivity());
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (path === "/v1/commands" && method === "GET") {
|
|
115
|
+
sendJson(res, 200, commandCatalog());
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Whitelisted CLI runner — powers the app's Master (terminal) edition.
|
|
119
|
+
// Only known `maq` subcommands run; never arbitrary shell.
|
|
120
|
+
if (path === "/v1/exec" && method === "POST") {
|
|
121
|
+
const body = await readJson(req);
|
|
122
|
+
const argv = Array.isArray(body.argv) ? body.argv.map(String) : [];
|
|
123
|
+
const cmd = argv[0];
|
|
124
|
+
const allowed = new Set([...maqCommands.map((c) => c.name), "help", "version", "-h", "--help"]);
|
|
125
|
+
if (!cmd || !allowed.has(cmd)) {
|
|
126
|
+
sendJson(res, 400, { error: `command not allowed: ${cmd ?? "(empty)"}. Allowed: ${[...allowed].join(", ")}` });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const entry = join(dirname(fileURLToPath(import.meta.url)), "..", "index.js");
|
|
130
|
+
const outcome = await execSafe(process.execPath, [entry, ...argv], {
|
|
131
|
+
cwd: typeof body.cwd === "string" ? body.cwd : process.cwd(),
|
|
132
|
+
timeoutMs: 120000,
|
|
133
|
+
});
|
|
134
|
+
sendJson(res, 200, { argv, code: outcome.code, stdout: outcome.stdout, stderr: outcome.stderr });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (path === "/v1/interactive" && method === "GET") {
|
|
138
|
+
sendJson(res, 200, { terminals: interactive.list() });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (path === "/v1/interactive" && method === "POST") {
|
|
142
|
+
const body = await readJson(req);
|
|
143
|
+
const target = typeof body.target === "string" ? body.target : "auto";
|
|
144
|
+
try {
|
|
145
|
+
const info = interactive.start(target, {
|
|
146
|
+
cwd: typeof body.cwd === "string" ? body.cwd : undefined,
|
|
147
|
+
task: typeof body.task === "string" ? body.task : undefined,
|
|
148
|
+
});
|
|
149
|
+
sendJson(res, 201, info);
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
sendJson(res, 400, { error: e.message });
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const interMatch = /^\/v1\/interactive\/([^/]+)(\/events|\/input|\/close)?$/.exec(path);
|
|
157
|
+
if (interMatch) {
|
|
158
|
+
const id = decodeURIComponent(interMatch[1]);
|
|
159
|
+
const sub = interMatch[2];
|
|
160
|
+
const entry = interactive.get(id);
|
|
161
|
+
if (!entry) {
|
|
162
|
+
sendJson(res, 404, { error: "no such terminal" });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!sub && method === "GET") {
|
|
166
|
+
sendJson(res, 200, { ...entry.info, events: entry.events });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (sub === "/events" && method === "GET") {
|
|
170
|
+
streamInteractive(res, id);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (sub === "/input" && method === "POST") {
|
|
174
|
+
const body = await readJson(req);
|
|
175
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
176
|
+
sendJson(res, 202, { delivered: interactive.input(id, text) });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (sub === "/close" && method === "POST") {
|
|
180
|
+
sendJson(res, 202, { closed: interactive.close(id) });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (path === "/v1/sessions" && method === "GET") {
|
|
185
|
+
sendJson(res, 200, { sessions: registry.list() });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (path === "/v1/sessions" && method === "POST") {
|
|
189
|
+
const body = await readJson(req);
|
|
190
|
+
const task = typeof body.task === "string" ? body.task.trim() : "";
|
|
191
|
+
if (!task) {
|
|
192
|
+
sendJson(res, 400, { error: "missing 'task'" });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const s = registry.assign(task, {
|
|
196
|
+
target: typeof body.target === "string" ? body.target : undefined,
|
|
197
|
+
cwd: typeof body.cwd === "string" ? body.cwd : undefined,
|
|
198
|
+
dryRun: Boolean(body.dryRun),
|
|
199
|
+
timeoutMs: typeof body.timeoutMs === "number" ? body.timeoutMs : undefined,
|
|
200
|
+
});
|
|
201
|
+
sendJson(res, 201, registry.summarize(s));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const sessionMatch = /^\/v1\/sessions\/([^/]+)(\/events|\/message|\/control)?$/.exec(path);
|
|
205
|
+
if (sessionMatch) {
|
|
206
|
+
const id = decodeURIComponent(sessionMatch[1]);
|
|
207
|
+
const sub = sessionMatch[2];
|
|
208
|
+
const session = registry.get(id);
|
|
209
|
+
if (!session) {
|
|
210
|
+
sendJson(res, 404, { error: "no such session" });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (!sub && method === "GET") {
|
|
214
|
+
sendJson(res, 200, { ...registry.summarize(session), events: session.events, error: session.error });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (sub === "/events" && method === "GET") {
|
|
218
|
+
streamEvents(res, session.id);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (sub === "/message" && method === "POST") {
|
|
222
|
+
const body = await readJson(req);
|
|
223
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
224
|
+
if (!text) {
|
|
225
|
+
sendJson(res, 400, { error: "missing 'text'" });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
registry.sendMessage(id, text, typeof body.from === "string" ? body.from : "user");
|
|
229
|
+
sendJson(res, 202, { delivered: true });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (sub === "/control" && method === "POST") {
|
|
233
|
+
const body = await readJson(req);
|
|
234
|
+
const action = String(body.action ?? "");
|
|
235
|
+
let ok = false;
|
|
236
|
+
if (action === "pause")
|
|
237
|
+
ok = registry.pause(id);
|
|
238
|
+
else if (action === "resume")
|
|
239
|
+
ok = registry.resume(id);
|
|
240
|
+
else if (action === "cancel")
|
|
241
|
+
ok = registry.cancel(id);
|
|
242
|
+
else {
|
|
243
|
+
sendJson(res, 400, { error: "action must be pause|resume|cancel" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
sendJson(res, ok ? 202 : 409, { action, ok, status: registry.get(id)?.status });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
sendJson(res, 404, { error: "not found" });
|
|
251
|
+
}
|
|
252
|
+
function streamEvents(res, id) {
|
|
253
|
+
res.writeHead(200, {
|
|
254
|
+
"content-type": "text/event-stream",
|
|
255
|
+
"cache-control": "no-cache",
|
|
256
|
+
connection: "keep-alive",
|
|
257
|
+
});
|
|
258
|
+
sseClients.add(res);
|
|
259
|
+
const session = registry.get(id);
|
|
260
|
+
// Replay history so a late subscriber is not missing context.
|
|
261
|
+
for (const e of session?.events ?? [])
|
|
262
|
+
writeSse(res, e);
|
|
263
|
+
if (session && session.status !== "running") {
|
|
264
|
+
writeSse(res, { type: "stream.end", ts: new Date().toISOString(), data: { status: session.status } });
|
|
265
|
+
sseClients.delete(res);
|
|
266
|
+
res.end();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const unsub = registry.subscribe(id, (e) => {
|
|
270
|
+
writeSse(res, e);
|
|
271
|
+
if (e.type === "task.done" || e.type === "task.error" || e.type === "task.cancelled") {
|
|
272
|
+
writeSse(res, { type: "stream.end", ts: new Date().toISOString(), data: { status: "closed" } });
|
|
273
|
+
cleanup();
|
|
274
|
+
res.end();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
const keepAlive = setInterval(() => res.write(": ping\n\n"), 15000);
|
|
278
|
+
const cleanup = () => {
|
|
279
|
+
clearInterval(keepAlive);
|
|
280
|
+
unsub();
|
|
281
|
+
sseClients.delete(res);
|
|
282
|
+
};
|
|
283
|
+
res.on("close", cleanup);
|
|
284
|
+
}
|
|
285
|
+
function streamInteractive(res, id) {
|
|
286
|
+
res.writeHead(200, {
|
|
287
|
+
"content-type": "text/event-stream",
|
|
288
|
+
"cache-control": "no-cache",
|
|
289
|
+
connection: "keep-alive",
|
|
290
|
+
});
|
|
291
|
+
sseClients.add(res);
|
|
292
|
+
const entry = interactive.get(id);
|
|
293
|
+
for (const e of entry?.events ?? [])
|
|
294
|
+
writeSse(res, e);
|
|
295
|
+
const unsub = interactive.subscribe(id, (e) => {
|
|
296
|
+
writeSse(res, e);
|
|
297
|
+
const status = e.data.status;
|
|
298
|
+
if (status === "exited") {
|
|
299
|
+
writeSse(res, { type: "stream.end", ts: new Date().toISOString(), data: { status: "exited" } });
|
|
300
|
+
cleanup();
|
|
301
|
+
res.end();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
const keepAlive = setInterval(() => res.write(": ping\n\n"), 15000);
|
|
305
|
+
const cleanup = () => {
|
|
306
|
+
clearInterval(keepAlive);
|
|
307
|
+
unsub();
|
|
308
|
+
sseClients.delete(res);
|
|
309
|
+
};
|
|
310
|
+
res.on("close", cleanup);
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
server,
|
|
314
|
+
registry,
|
|
315
|
+
token,
|
|
316
|
+
host,
|
|
317
|
+
port,
|
|
318
|
+
listen() {
|
|
319
|
+
return new Promise((resolve, reject) => {
|
|
320
|
+
server.once("error", reject);
|
|
321
|
+
server.listen(port, host, () => {
|
|
322
|
+
const addr = server.address();
|
|
323
|
+
const boundPort = typeof addr === "object" && addr ? addr.port : port;
|
|
324
|
+
resolve({ host, port: boundPort });
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
},
|
|
328
|
+
close() {
|
|
329
|
+
return new Promise((resolve) => {
|
|
330
|
+
// Deterministically end long-lived SSE streams first, otherwise
|
|
331
|
+
// server.close() waits on them indefinitely.
|
|
332
|
+
for (const client of sseClients) {
|
|
333
|
+
try {
|
|
334
|
+
writeSse(client, { type: "stream.end", ts: new Date().toISOString(), data: { status: "shutdown" } });
|
|
335
|
+
client.end();
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
/* ignore */
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
sseClients.clear();
|
|
342
|
+
server.close(() => resolve());
|
|
343
|
+
// Safety net: drop any lingering keep-alive/active sockets so the
|
|
344
|
+
// process can exit promptly (Node >= 18.2).
|
|
345
|
+
server.closeAllConnections?.();
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function sendJson(res, status, body) {
|
|
351
|
+
const payload = JSON.stringify(body);
|
|
352
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
353
|
+
res.end(payload);
|
|
354
|
+
}
|
|
355
|
+
function writeSse(res, event) {
|
|
356
|
+
res.write(`event: ${event.type}\n`);
|
|
357
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
358
|
+
}
|
|
359
|
+
async function readJson(req) {
|
|
360
|
+
const chunks = [];
|
|
361
|
+
let size = 0;
|
|
362
|
+
const MAX = 1024 * 1024;
|
|
363
|
+
for await (const chunk of req) {
|
|
364
|
+
size += chunk.length;
|
|
365
|
+
if (size > MAX)
|
|
366
|
+
throw new Error("request body too large");
|
|
367
|
+
chunks.push(chunk);
|
|
368
|
+
}
|
|
369
|
+
if (chunks.length === 0)
|
|
370
|
+
return {};
|
|
371
|
+
try {
|
|
372
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return {};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay bridge — lets the daemon be reached through the outbound-only relay
|
|
3
|
+
* (Tier 2) with NO inbound ports. The daemon dials OUT to the relay as the
|
|
4
|
+
* `agent` for a deviceId; the phone app connects as the `controller`. Controller
|
|
5
|
+
* requests (tunnelled as `{t:"msg", data:{op:...}}`) are executed against the
|
|
6
|
+
* local SessionRegistry and answers/events are streamed back the same way.
|
|
7
|
+
*
|
|
8
|
+
* App ⇄ relay protocol (data payloads):
|
|
9
|
+
* → {op:"sessions", reqId} ← {kind:"sessions", sessions, reqId}
|
|
10
|
+
* → {op:"agents", reqId} ← {kind:"agents", agents, advisory, reqId}
|
|
11
|
+
* → {op:"connectivity", reqId} ← {kind:"connectivity", connectivity, reqId}
|
|
12
|
+
* → {op:"create", task,target,cwd,dryRun,reqId} ← {kind:"created", session, reqId}
|
|
13
|
+
* → {op:"subscribe", id} ← {kind:"event", id, event} (stream)
|
|
14
|
+
* → {op:"control", id, action} ← {kind:"ok"|"error", ...}
|
|
15
|
+
* → {op:"message", id, text} ← {kind:"ok"}
|
|
16
|
+
*
|
|
17
|
+
* Uses Node's global WebSocket client (Node >= 22) — zero dependencies.
|
|
18
|
+
*/
|
|
19
|
+
import type { SessionRegistry } from "../core/session.js";
|
|
20
|
+
export interface RelayBridgeOptions {
|
|
21
|
+
url: string;
|
|
22
|
+
deviceId: string;
|
|
23
|
+
token?: string;
|
|
24
|
+
registry: SessionRegistry;
|
|
25
|
+
/** Reconnect backoff base in ms. */
|
|
26
|
+
reconnectMs?: number;
|
|
27
|
+
log?: (msg: string) => void;
|
|
28
|
+
}
|
|
29
|
+
export declare class RelayBridge {
|
|
30
|
+
private opts;
|
|
31
|
+
private ws;
|
|
32
|
+
private closed;
|
|
33
|
+
private subscribed;
|
|
34
|
+
private unsubs;
|
|
35
|
+
constructor(opts: RelayBridgeOptions);
|
|
36
|
+
start(): void;
|
|
37
|
+
stop(): void;
|
|
38
|
+
private connect;
|
|
39
|
+
private handleOp;
|
|
40
|
+
private subscribe;
|
|
41
|
+
private reply;
|
|
42
|
+
private send;
|
|
43
|
+
private log;
|
|
44
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay bridge — lets the daemon be reached through the outbound-only relay
|
|
3
|
+
* (Tier 2) with NO inbound ports. The daemon dials OUT to the relay as the
|
|
4
|
+
* `agent` for a deviceId; the phone app connects as the `controller`. Controller
|
|
5
|
+
* requests (tunnelled as `{t:"msg", data:{op:...}}`) are executed against the
|
|
6
|
+
* local SessionRegistry and answers/events are streamed back the same way.
|
|
7
|
+
*
|
|
8
|
+
* App ⇄ relay protocol (data payloads):
|
|
9
|
+
* → {op:"sessions", reqId} ← {kind:"sessions", sessions, reqId}
|
|
10
|
+
* → {op:"agents", reqId} ← {kind:"agents", agents, advisory, reqId}
|
|
11
|
+
* → {op:"connectivity", reqId} ← {kind:"connectivity", connectivity, reqId}
|
|
12
|
+
* → {op:"create", task,target,cwd,dryRun,reqId} ← {kind:"created", session, reqId}
|
|
13
|
+
* → {op:"subscribe", id} ← {kind:"event", id, event} (stream)
|
|
14
|
+
* → {op:"control", id, action} ← {kind:"ok"|"error", ...}
|
|
15
|
+
* → {op:"message", id, text} ← {kind:"ok"}
|
|
16
|
+
*
|
|
17
|
+
* Uses Node's global WebSocket client (Node >= 22) — zero dependencies.
|
|
18
|
+
*/
|
|
19
|
+
import { detectAgents, authAdvisory } from "../core/registry.js";
|
|
20
|
+
import { probeConnectivity } from "../core/probe.js";
|
|
21
|
+
export class RelayBridge {
|
|
22
|
+
opts;
|
|
23
|
+
ws = null;
|
|
24
|
+
closed = false;
|
|
25
|
+
subscribed = new Set();
|
|
26
|
+
unsubs = [];
|
|
27
|
+
constructor(opts) {
|
|
28
|
+
this.opts = opts;
|
|
29
|
+
}
|
|
30
|
+
start() {
|
|
31
|
+
this.connect();
|
|
32
|
+
}
|
|
33
|
+
stop() {
|
|
34
|
+
this.closed = true;
|
|
35
|
+
for (const u of this.unsubs)
|
|
36
|
+
u();
|
|
37
|
+
this.unsubs = [];
|
|
38
|
+
try {
|
|
39
|
+
this.ws?.close();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* ignore */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
connect() {
|
|
46
|
+
if (this.closed)
|
|
47
|
+
return;
|
|
48
|
+
const WsCtor = globalThis.WebSocket;
|
|
49
|
+
if (!WsCtor) {
|
|
50
|
+
this.log("relay bridge requires a global WebSocket (Node >= 22); skipping");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const ws = new WsCtor(this.opts.url);
|
|
54
|
+
this.ws = ws;
|
|
55
|
+
ws.addEventListener("open", () => {
|
|
56
|
+
this.log(`relay bridge connected: ${this.opts.url} (device ${this.opts.deviceId})`);
|
|
57
|
+
this.send({ t: "hello", role: "agent", deviceId: this.opts.deviceId, token: this.opts.token ?? "" });
|
|
58
|
+
});
|
|
59
|
+
ws.addEventListener("message", (ev) => {
|
|
60
|
+
let msg;
|
|
61
|
+
try {
|
|
62
|
+
msg = JSON.parse(String(ev.data));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (msg.t === "msg" && msg.data && typeof msg.data === "object") {
|
|
68
|
+
this.handleOp(msg.data);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
ws.addEventListener("close", () => {
|
|
72
|
+
if (this.closed)
|
|
73
|
+
return;
|
|
74
|
+
const delay = this.opts.reconnectMs ?? 2000;
|
|
75
|
+
this.log(`relay bridge disconnected; reconnecting in ${delay}ms`);
|
|
76
|
+
setTimeout(() => this.connect(), delay);
|
|
77
|
+
});
|
|
78
|
+
ws.addEventListener("error", () => {
|
|
79
|
+
try {
|
|
80
|
+
ws.close();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* ignore */
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
handleOp(op) {
|
|
88
|
+
const reqId = op.reqId;
|
|
89
|
+
const reg = this.opts.registry;
|
|
90
|
+
switch (op.op) {
|
|
91
|
+
case "sessions":
|
|
92
|
+
this.reply({ kind: "sessions", sessions: reg.list(), reqId });
|
|
93
|
+
break;
|
|
94
|
+
case "agents": {
|
|
95
|
+
const agents = detectAgents();
|
|
96
|
+
this.reply({ kind: "agents", agents, advisory: authAdvisory(agents), reqId });
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "connectivity":
|
|
100
|
+
probeConnectivity()
|
|
101
|
+
.then((connectivity) => this.reply({ kind: "connectivity", connectivity, reqId }))
|
|
102
|
+
.catch((e) => this.reply({ kind: "error", message: e.message, reqId }));
|
|
103
|
+
break;
|
|
104
|
+
case "create": {
|
|
105
|
+
const task = typeof op.task === "string" ? op.task.trim() : "";
|
|
106
|
+
if (!task) {
|
|
107
|
+
this.reply({ kind: "error", message: "missing task", reqId });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const s = reg.assign(task, {
|
|
111
|
+
target: typeof op.target === "string" ? op.target : undefined,
|
|
112
|
+
cwd: typeof op.cwd === "string" ? op.cwd : undefined,
|
|
113
|
+
dryRun: Boolean(op.dryRun),
|
|
114
|
+
});
|
|
115
|
+
this.reply({ kind: "created", session: reg.summarize(s), reqId });
|
|
116
|
+
this.subscribe(s.id);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "subscribe":
|
|
120
|
+
if (typeof op.id === "string")
|
|
121
|
+
this.subscribe(op.id);
|
|
122
|
+
break;
|
|
123
|
+
case "control": {
|
|
124
|
+
const id = String(op.id ?? "");
|
|
125
|
+
const action = String(op.action ?? "");
|
|
126
|
+
let ok = false;
|
|
127
|
+
if (action === "pause")
|
|
128
|
+
ok = reg.pause(id);
|
|
129
|
+
else if (action === "resume")
|
|
130
|
+
ok = reg.resume(id);
|
|
131
|
+
else if (action === "cancel")
|
|
132
|
+
ok = reg.cancel(id);
|
|
133
|
+
this.reply({ kind: ok ? "ok" : "error", action, id, reqId });
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "message":
|
|
137
|
+
if (typeof op.id === "string" && typeof op.text === "string") {
|
|
138
|
+
reg.sendMessage(op.id, op.text);
|
|
139
|
+
this.reply({ kind: "ok", reqId });
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
default:
|
|
143
|
+
this.reply({ kind: "error", message: `unknown op: ${String(op.op)}`, reqId });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
subscribe(id) {
|
|
147
|
+
const session = this.opts.registry.get(id);
|
|
148
|
+
if (!session) {
|
|
149
|
+
this.reply({ kind: "error", message: "no such session" });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Replay history so a late controller sees full context.
|
|
153
|
+
for (const e of session.events)
|
|
154
|
+
this.reply({ kind: "event", id, event: e });
|
|
155
|
+
if (this.subscribed.has(id))
|
|
156
|
+
return;
|
|
157
|
+
this.subscribed.add(id);
|
|
158
|
+
const unsub = this.opts.registry.subscribe(id, (e) => this.reply({ kind: "event", id, event: e }));
|
|
159
|
+
this.unsubs.push(unsub);
|
|
160
|
+
}
|
|
161
|
+
reply(data) {
|
|
162
|
+
this.send({ t: "msg", data });
|
|
163
|
+
}
|
|
164
|
+
send(obj) {
|
|
165
|
+
try {
|
|
166
|
+
this.ws?.send(JSON.stringify(obj));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* ignore */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
log(msg) {
|
|
173
|
+
this.opts.log?.(msg);
|
|
174
|
+
}
|
|
175
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "maqcli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MAQ master orchestrator — a token-efficient, agent-agnostic supervisor CLI that sits on top of any worker CLI (AI or not) via a Scout -> Plan -> Execute -> Verify pipeline.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"maqcli": "dist/index.js",
|
|
8
|
+
"maq": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.json",
|
|
19
|
+
"prepare": "npm run build",
|
|
20
|
+
"test": "npm run build && node --test",
|
|
21
|
+
"start": "node dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"cli",
|
|
28
|
+
"orchestrator",
|
|
29
|
+
"ai-agents",
|
|
30
|
+
"token-efficiency",
|
|
31
|
+
"scout-plan-execute-verify",
|
|
32
|
+
"supervisor"
|
|
33
|
+
],
|
|
34
|
+
"license": "Apache-2.0",
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"typescript": "^5.6.0",
|
|
37
|
+
"@types/node": "^22.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|