@agentprojectcontext/apx 1.66.0 → 1.67.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/package.json +3 -2
- package/skills/apx/SKILL.md +3 -0
- package/src/core/agent/index.js +2 -0
- package/src/core/agent/judge.js +174 -0
- package/src/core/agent/model-router.js +107 -5
- package/src/core/agent/prompts/modes/code-build.md +1 -1
- package/src/core/agent/run-agent.js +149 -12
- package/src/core/agent/security.js +97 -0
- package/src/core/agent/stuck-detector.js +89 -0
- package/src/core/agent/super-agent.js +58 -17
- package/src/core/agent/tools/handlers/run-subagent.js +117 -0
- package/src/core/agent/tools/helpers.js +11 -1
- package/src/core/agent/tools/names.js +2 -0
- package/src/core/agent/tools/registry.js +10 -0
- package/src/core/artifacts/preview.js +392 -0
- package/src/core/artifacts/tunnel.js +169 -0
- package/src/core/config/index.js +61 -0
- package/src/core/config/secret-values.js +132 -0
- package/src/core/engines/mock.js +15 -1
- package/src/core/logging.js +10 -3
- package/src/core/memory/compactor.js +65 -56
- package/src/core/memory/summarizer.js +125 -0
- package/src/core/stores/conversations-compactor.js +24 -31
- package/src/host/daemon/api/admin-config.js +5 -0
- package/src/host/daemon/api/artifact-preview.js +82 -0
- package/src/host/daemon/api/web.js +1 -1
- package/src/host/daemon/api.js +2 -0
- package/src/host/daemon/index.js +16 -1
- package/src/interfaces/acp/index.js +363 -0
- package/src/interfaces/acp/jsonrpc.js +180 -0
- package/src/interfaces/acp/session.js +205 -0
- package/src/interfaces/cli/commands/acp.js +10 -0
- package/src/interfaces/cli/commands/artifact.js +115 -0
- package/src/interfaces/cli/index.js +74 -0
- package/src/interfaces/web/dist/assets/index-B3pEwe1m.js +803 -0
- package/src/interfaces/web/dist/assets/index-B3pEwe1m.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-BPGECxzm.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +6 -6
- package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
- package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
- package/src/interfaces/web/src/i18n/en.ts +47 -0
- package/src/interfaces/web/src/i18n/es.ts +47 -0
- package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
- package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
- package/src/interfaces/web/src/types/daemon.ts +16 -0
- package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
- package/src/interfaces/web/dist/assets/index-YmMRG--4.js +0 -778
- package/src/interfaces/web/dist/assets/index-YmMRG--4.js.map +0 -1
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// Ephemeral artifact preview servers.
|
|
2
|
+
//
|
|
3
|
+
// Given a managed artifact (see #core/stores/artifacts.js), spin up a tiny
|
|
4
|
+
// local HTTP server that renders it in a browser:
|
|
5
|
+
// - .html/.htm → served as-is, with a live-reload snippet injected
|
|
6
|
+
// - .jsx/.tsx/.js (React) → wrapped in an HTML shell (React UMD + Babel +
|
|
7
|
+
// Tailwind Play CDN) so single-file components render
|
|
8
|
+
// - a directory / index → served statically as a mini web root
|
|
9
|
+
// - anything else → served as text
|
|
10
|
+
//
|
|
11
|
+
// Each server listens on an ephemeral 127.0.0.1 port and watches its source
|
|
12
|
+
// files; on change it pushes a "reload" event over Server-Sent Events so the
|
|
13
|
+
// open browser tab refreshes itself. Servers are tracked in a process-wide
|
|
14
|
+
// registry so the CLI/web/API can list, share (tunnel), and stop them.
|
|
15
|
+
//
|
|
16
|
+
// This module is intentionally dependency-free (node http/fs only) so it can
|
|
17
|
+
// run inside the daemon without pulling in a bundler.
|
|
18
|
+
import http from "node:http";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { artifactPath } from "#core/stores/artifacts.js";
|
|
23
|
+
|
|
24
|
+
// Extensions we treat as single-file React components to wrap in a shell.
|
|
25
|
+
const REACT_EXT = new Set([".jsx", ".tsx"]);
|
|
26
|
+
// Plain HTML documents served verbatim (plus reload injection).
|
|
27
|
+
const HTML_EXT = new Set([".html", ".htm"]);
|
|
28
|
+
|
|
29
|
+
// Minimal content-type table for the static file server. Anything not listed
|
|
30
|
+
// falls back to application/octet-stream (browser will download/guess).
|
|
31
|
+
const MIME = {
|
|
32
|
+
".html": "text/html; charset=utf-8",
|
|
33
|
+
".htm": "text/html; charset=utf-8",
|
|
34
|
+
".js": "text/javascript; charset=utf-8",
|
|
35
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
36
|
+
".jsx": "text/javascript; charset=utf-8",
|
|
37
|
+
".css": "text/css; charset=utf-8",
|
|
38
|
+
".json": "application/json; charset=utf-8",
|
|
39
|
+
".svg": "image/svg+xml",
|
|
40
|
+
".png": "image/png",
|
|
41
|
+
".jpg": "image/jpeg",
|
|
42
|
+
".jpeg": "image/jpeg",
|
|
43
|
+
".gif": "image/gif",
|
|
44
|
+
".webp": "image/webp",
|
|
45
|
+
".ico": "image/x-icon",
|
|
46
|
+
".woff": "font/woff",
|
|
47
|
+
".woff2": "font/woff2",
|
|
48
|
+
".ttf": "font/ttf",
|
|
49
|
+
".map": "application/json; charset=utf-8",
|
|
50
|
+
".txt": "text/plain; charset=utf-8",
|
|
51
|
+
".md": "text/plain; charset=utf-8",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Path the browser polls for live-reload events (SSE). Namespaced so it can't
|
|
55
|
+
// collide with a real asset the artifact ships.
|
|
56
|
+
const RELOAD_PATH = "/__apx/reload";
|
|
57
|
+
|
|
58
|
+
// Snippet injected into every served HTML page. Opens an SSE channel and
|
|
59
|
+
// reloads the tab whenever the server signals a source change. Silently no-ops
|
|
60
|
+
// if EventSource is unavailable.
|
|
61
|
+
const RELOAD_SNIPPET = `
|
|
62
|
+
<script>(function(){try{
|
|
63
|
+
var es=new EventSource(${JSON.stringify(RELOAD_PATH)});
|
|
64
|
+
es.onmessage=function(e){if(e.data==="reload"){es.close();location.reload();}};
|
|
65
|
+
}catch(_){}})();</script>`;
|
|
66
|
+
|
|
67
|
+
// Debounce window for fs.watch — editors fire several events per save.
|
|
68
|
+
const WATCH_DEBOUNCE_MS = 120;
|
|
69
|
+
|
|
70
|
+
// Classify what kind of preview an artifact needs.
|
|
71
|
+
function classify(absPath) {
|
|
72
|
+
let stat;
|
|
73
|
+
try {
|
|
74
|
+
stat = fs.statSync(absPath);
|
|
75
|
+
} catch {
|
|
76
|
+
return { kind: "missing" };
|
|
77
|
+
}
|
|
78
|
+
if (stat.isDirectory()) return { kind: "static", root: absPath, entry: "index.html" };
|
|
79
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
80
|
+
if (HTML_EXT.has(ext)) return { kind: "html", root: path.dirname(absPath), entry: path.basename(absPath) };
|
|
81
|
+
if (REACT_EXT.has(ext)) return { kind: "react", root: path.dirname(absPath), entry: path.basename(absPath) };
|
|
82
|
+
// .js is ambiguous: treat as React only when it clearly looks like JSX/React.
|
|
83
|
+
if (ext === ".js") {
|
|
84
|
+
let head = "";
|
|
85
|
+
try { head = fs.readFileSync(absPath, "utf8").slice(0, 4000); } catch { /* ignore */ }
|
|
86
|
+
if (/\breact\b|useState|useEffect|ReactDOM|export\s+default|<[A-Za-z]/.test(head)) {
|
|
87
|
+
return { kind: "react", root: path.dirname(absPath), entry: path.basename(absPath) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { kind: "text", root: path.dirname(absPath), entry: path.basename(absPath) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Insert the live-reload snippet before </body> (or append if there's none).
|
|
94
|
+
function injectReload(html) {
|
|
95
|
+
if (html.includes("</body>")) return html.replace("</body>", `${RELOAD_SNIPPET}\n</body>`);
|
|
96
|
+
return html + RELOAD_SNIPPET;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Turn a single-file React/JSX component into a full HTML document. We can't
|
|
100
|
+
// run a bundler in-process, so we lean on the same CDN approach Claude's
|
|
101
|
+
// artifacts use: React UMD + Babel standalone (JSX/TS in the browser) +
|
|
102
|
+
// Tailwind Play CDN for styling. `import` lines are stripped (React globals are
|
|
103
|
+
// destructured for you) and `export default X` is rewired to a mount call.
|
|
104
|
+
function reactShell(source, title) {
|
|
105
|
+
const preamble =
|
|
106
|
+
"const { useState, useEffect, useRef, useMemo, useCallback, useReducer, " +
|
|
107
|
+
"useContext, createContext, useLayoutEffect, Fragment } = React;\n";
|
|
108
|
+
// Drop bare `import ... from '...'` lines — dependencies aren't resolvable in
|
|
109
|
+
// this lightweight shell; React hooks are provided by the preamble above.
|
|
110
|
+
let code = source.replace(/^[ \t]*import\s.*(?:\n|$)/gm, "");
|
|
111
|
+
// `export default <expr>` → capture the component so we can render it.
|
|
112
|
+
code = code.replace(/export\s+default\s+/g, "window.__APX_ARTIFACT__ = ");
|
|
113
|
+
// Strip remaining named `export ` keywords (declarations stay valid without).
|
|
114
|
+
code = code.replace(/^[ \t]*export\s+(?=(const|function|let|var|class)\b)/gm, "");
|
|
115
|
+
return `<!doctype html>
|
|
116
|
+
<html lang="en">
|
|
117
|
+
<head>
|
|
118
|
+
<meta charset="utf-8" />
|
|
119
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
120
|
+
<title>${escapeHtml(title)}</title>
|
|
121
|
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
|
122
|
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
|
123
|
+
<script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
|
|
124
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
125
|
+
<style>body{margin:0}#apx-error{font:13px/1.5 ui-monospace,monospace;color:#b91c1c;white-space:pre-wrap;padding:16px}</style>
|
|
126
|
+
</head>
|
|
127
|
+
<body>
|
|
128
|
+
<div id="root"></div>
|
|
129
|
+
<div id="apx-error"></div>
|
|
130
|
+
<script type="text/babel" data-presets="react,typescript" data-type="module">
|
|
131
|
+
${preamble}${code}
|
|
132
|
+
try {
|
|
133
|
+
var C = window.__APX_ARTIFACT__;
|
|
134
|
+
if (!C) { C = (typeof App !== "undefined") ? App : null; }
|
|
135
|
+
if (!C) throw new Error("No default export or App component found to render.");
|
|
136
|
+
var el = React.isValidElement(C) ? C : React.createElement(C);
|
|
137
|
+
ReactDOM.createRoot(document.getElementById("root")).render(el);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
document.getElementById("apx-error").textContent = "APX preview error: " + (err && err.message || err);
|
|
140
|
+
}
|
|
141
|
+
</script>
|
|
142
|
+
${RELOAD_SNIPPET}
|
|
143
|
+
</body>
|
|
144
|
+
</html>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function escapeHtml(s) {
|
|
148
|
+
return String(s).replace(/[&<>"]/g, (c) =>
|
|
149
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Resolve a request path to a real file inside `root`, guarding against
|
|
153
|
+
// traversal. Returns null when the target escapes root or doesn't exist.
|
|
154
|
+
function resolveStatic(root, urlPath) {
|
|
155
|
+
const rel = decodeURIComponent(urlPath.split("?")[0]).replace(/^\/+/, "");
|
|
156
|
+
const abs = path.resolve(root, rel);
|
|
157
|
+
const rootResolved = path.resolve(root);
|
|
158
|
+
if (abs !== rootResolved && !abs.startsWith(rootResolved + path.sep)) return null;
|
|
159
|
+
try {
|
|
160
|
+
const st = fs.statSync(abs);
|
|
161
|
+
if (st.isDirectory()) {
|
|
162
|
+
const idx = path.join(abs, "index.html");
|
|
163
|
+
return fs.existsSync(idx) ? idx : null;
|
|
164
|
+
}
|
|
165
|
+
return abs;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export class PreviewManager {
|
|
172
|
+
constructor() {
|
|
173
|
+
/** @type {Map<string, object>} id → record */
|
|
174
|
+
this.servers = new Map();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Public, serializable view of a preview record (no live handles).
|
|
178
|
+
static view(rec) {
|
|
179
|
+
return {
|
|
180
|
+
id: rec.id,
|
|
181
|
+
projectId: rec.projectId,
|
|
182
|
+
name: rec.name,
|
|
183
|
+
kind: rec.kind,
|
|
184
|
+
port: rec.port,
|
|
185
|
+
url: rec.url,
|
|
186
|
+
watch: rec.watch,
|
|
187
|
+
createdAt: rec.createdAt,
|
|
188
|
+
hits: rec.hits,
|
|
189
|
+
tunnel: rec.tunnel
|
|
190
|
+
? { id: rec.tunnel.id, url: rec.tunnel.url, provider: rec.tunnel.provider }
|
|
191
|
+
: null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
list(projectId) {
|
|
196
|
+
const all = [...this.servers.values()].map((r) => PreviewManager.view(r));
|
|
197
|
+
return projectId == null ? all : all.filter((r) => String(r.projectId) === String(projectId));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
get(id) {
|
|
201
|
+
return this.servers.get(id) || null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Start (or reuse) a preview server for the given artifact.
|
|
205
|
+
// { storagePath, name, projectId, watch, host }
|
|
206
|
+
// Reuses an existing server for the same (projectId, name) so repeated
|
|
207
|
+
// previews don't leak ports.
|
|
208
|
+
async start({ storagePath, name, projectId = null, watch = true, host = "127.0.0.1" }) {
|
|
209
|
+
if (!name) throw new Error("preview: missing artifact name");
|
|
210
|
+
const absPath = artifactPath(storagePath, name);
|
|
211
|
+
const c = classify(absPath);
|
|
212
|
+
if (c.kind === "missing") throw new Error(`artifact "${name}" not found`);
|
|
213
|
+
|
|
214
|
+
// Reuse a live server for the same artifact in the same project.
|
|
215
|
+
for (const rec of this.servers.values()) {
|
|
216
|
+
if (String(rec.projectId) === String(projectId) && rec.name === name) {
|
|
217
|
+
return PreviewManager.view(rec);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const id = randomUUID().slice(0, 8);
|
|
222
|
+
const record = {
|
|
223
|
+
id,
|
|
224
|
+
projectId,
|
|
225
|
+
name,
|
|
226
|
+
kind: c.kind,
|
|
227
|
+
root: c.root,
|
|
228
|
+
entry: c.entry,
|
|
229
|
+
entryAbs: absPath,
|
|
230
|
+
watch: !!watch,
|
|
231
|
+
createdAt: new Date().toISOString(),
|
|
232
|
+
hits: 0,
|
|
233
|
+
clients: new Set(),
|
|
234
|
+
watcher: null,
|
|
235
|
+
server: null,
|
|
236
|
+
port: null,
|
|
237
|
+
url: null,
|
|
238
|
+
tunnel: null,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const server = http.createServer((req, res) => this._handle(record, req, res));
|
|
242
|
+
record.server = server;
|
|
243
|
+
|
|
244
|
+
await new Promise((resolve, reject) => {
|
|
245
|
+
server.once("error", reject);
|
|
246
|
+
server.listen(0, host, () => {
|
|
247
|
+
server.removeListener("error", reject);
|
|
248
|
+
resolve();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const addr = server.address();
|
|
253
|
+
record.port = addr.port;
|
|
254
|
+
// localhost (not 127.0.0.1) so the printed link is friendlier & tunnelable.
|
|
255
|
+
record.url = `http://localhost:${addr.port}/`;
|
|
256
|
+
|
|
257
|
+
if (record.watch) this._watch(record);
|
|
258
|
+
this.servers.set(id, record);
|
|
259
|
+
return PreviewManager.view(record);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
_handle(record, req, res) {
|
|
263
|
+
const url = req.url || "/";
|
|
264
|
+
const pathname = url.split("?")[0];
|
|
265
|
+
|
|
266
|
+
// Live-reload SSE channel.
|
|
267
|
+
if (pathname === RELOAD_PATH) {
|
|
268
|
+
res.writeHead(200, {
|
|
269
|
+
"Content-Type": "text/event-stream",
|
|
270
|
+
"Cache-Control": "no-cache",
|
|
271
|
+
Connection: "keep-alive",
|
|
272
|
+
});
|
|
273
|
+
res.write(": connected\n\n");
|
|
274
|
+
record.clients.add(res);
|
|
275
|
+
const ping = setInterval(() => {
|
|
276
|
+
try { res.write(": ping\n\n"); } catch { /* ignore */ }
|
|
277
|
+
}, 25_000);
|
|
278
|
+
req.on("close", () => {
|
|
279
|
+
clearInterval(ping);
|
|
280
|
+
record.clients.delete(res);
|
|
281
|
+
});
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
record.hits++;
|
|
286
|
+
|
|
287
|
+
// Root request → render the entry according to its kind.
|
|
288
|
+
if (pathname === "/" || pathname === "" || pathname === "/" + record.entry) {
|
|
289
|
+
return this._renderEntry(record, res);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Everything else → static file served from the artifact's directory.
|
|
293
|
+
const file = resolveStatic(record.root, pathname);
|
|
294
|
+
if (!file) {
|
|
295
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
296
|
+
return res.end("Not found");
|
|
297
|
+
}
|
|
298
|
+
return this._sendFile(record, res, file);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_renderEntry(record, res) {
|
|
302
|
+
let source;
|
|
303
|
+
try {
|
|
304
|
+
source = fs.readFileSync(record.entryAbs, "utf8");
|
|
305
|
+
} catch (e) {
|
|
306
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
307
|
+
return res.end(`artifact "${record.name}" is gone: ${e.message}`);
|
|
308
|
+
}
|
|
309
|
+
let html;
|
|
310
|
+
if (record.kind === "react") {
|
|
311
|
+
html = reactShell(source, record.name);
|
|
312
|
+
} else if (record.kind === "html") {
|
|
313
|
+
html = injectReload(source);
|
|
314
|
+
} else if (record.kind === "static") {
|
|
315
|
+
// Directory root: serve its index.html if present, else a listing.
|
|
316
|
+
const idx = path.join(record.root, "index.html");
|
|
317
|
+
if (fs.existsSync(idx)) return this._sendFile(record, res, idx);
|
|
318
|
+
html = injectReload(`<!doctype html><meta charset=utf-8><title>${escapeHtml(record.name)}</title>` +
|
|
319
|
+
`<pre>${escapeHtml(fs.readdirSync(record.root).join("\n"))}</pre>`);
|
|
320
|
+
} else {
|
|
321
|
+
// Plain text: show it in a <pre> with reload wired up.
|
|
322
|
+
html = injectReload(`<!doctype html><meta charset=utf-8><title>${escapeHtml(record.name)}</title>` +
|
|
323
|
+
`<pre style="font:13px/1.5 ui-monospace,monospace;padding:16px;white-space:pre-wrap">${escapeHtml(source)}</pre>`);
|
|
324
|
+
}
|
|
325
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
326
|
+
res.end(html);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
_sendFile(record, res, file) {
|
|
330
|
+
const ext = path.extname(file).toLowerCase();
|
|
331
|
+
// HTML assets get reload injection too so linked pages stay live.
|
|
332
|
+
if (HTML_EXT.has(ext)) {
|
|
333
|
+
let src = "";
|
|
334
|
+
try { src = fs.readFileSync(file, "utf8"); } catch { /* ignore */ }
|
|
335
|
+
res.writeHead(200, { "Content-Type": MIME[ext], "Cache-Control": "no-store" });
|
|
336
|
+
return res.end(injectReload(src));
|
|
337
|
+
}
|
|
338
|
+
const type = MIME[ext] || "application/octet-stream";
|
|
339
|
+
res.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" });
|
|
340
|
+
fs.createReadStream(file).on("error", () => {
|
|
341
|
+
try { res.end(); } catch { /* ignore */ }
|
|
342
|
+
}).pipe(res);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Watch the artifact's directory and notify SSE clients on change.
|
|
346
|
+
_watch(record) {
|
|
347
|
+
let timer = null;
|
|
348
|
+
const fire = () => {
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
timer = setTimeout(() => {
|
|
351
|
+
for (const client of record.clients) {
|
|
352
|
+
try { client.write("data: reload\n\n"); } catch { /* ignore */ }
|
|
353
|
+
}
|
|
354
|
+
}, WATCH_DEBOUNCE_MS);
|
|
355
|
+
};
|
|
356
|
+
try {
|
|
357
|
+
record.watcher = fs.watch(record.root, { persistent: false }, fire);
|
|
358
|
+
} catch {
|
|
359
|
+
// Watching unsupported here — preview still works, just no auto-reload.
|
|
360
|
+
record.watch = false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Stop a preview server (and its tunnel, if any). Returns true if it existed.
|
|
365
|
+
async stop(id) {
|
|
366
|
+
const rec = this.servers.get(id);
|
|
367
|
+
if (!rec) return false;
|
|
368
|
+
try { rec.watcher?.close(); } catch { /* ignore */ }
|
|
369
|
+
for (const client of rec.clients) { try { client.end(); } catch { /* ignore */ } }
|
|
370
|
+
rec.clients.clear();
|
|
371
|
+
await new Promise((resolve) => {
|
|
372
|
+
try { rec.server.close(() => resolve()); } catch { resolve(); }
|
|
373
|
+
// Don't hang shutdown on lingering keep-alive sockets.
|
|
374
|
+
setTimeout(resolve, 500);
|
|
375
|
+
});
|
|
376
|
+
this.servers.delete(id);
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async stopAll() {
|
|
381
|
+
await Promise.all([...this.servers.keys()].map((id) => this.stop(id)));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Attach an opened tunnel to a preview record so listings can surface it.
|
|
385
|
+
attachTunnel(id, tunnel) {
|
|
386
|
+
const rec = this.servers.get(id);
|
|
387
|
+
if (rec) rec.tunnel = tunnel;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Process-wide singleton — the daemon holds exactly one preview registry.
|
|
392
|
+
export const previews = new PreviewManager();
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Quick public tunnels for local preview ports.
|
|
2
|
+
//
|
|
3
|
+
// Wraps zero-config tunnel providers so an ephemeral artifact preview running
|
|
4
|
+
// on http://localhost:<port> can be shared with a temporary public URL:
|
|
5
|
+
// - cloudflared → `cloudflared tunnel --url http://localhost:PORT`
|
|
6
|
+
// (no account needed; prints an https://*.trycloudflare.com URL)
|
|
7
|
+
// - localtunnel → `npx -y localtunnel --port PORT`
|
|
8
|
+
// (prints https://*.loca.lt; fallback when cloudflared absent)
|
|
9
|
+
//
|
|
10
|
+
// The manager spawns the provider, scrapes the public URL from its output, and
|
|
11
|
+
// tracks the child so it can be closed later. Children are best-effort killed
|
|
12
|
+
// on daemon exit.
|
|
13
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
// Recognise the public URL each provider prints to stdout/stderr.
|
|
17
|
+
const URL_RE = /https?:\/\/[-a-z0-9.]+\.(?:trycloudflare\.com|loca\.lt)[^\s"']*/i;
|
|
18
|
+
|
|
19
|
+
// How long to wait for a provider to announce its URL before giving up.
|
|
20
|
+
const OPEN_TIMEOUT_MS = 25_000;
|
|
21
|
+
|
|
22
|
+
// Is `cloudflared` on PATH? Cached after first probe.
|
|
23
|
+
let _cloudflared = null;
|
|
24
|
+
function hasCloudflared() {
|
|
25
|
+
if (_cloudflared !== null) return _cloudflared;
|
|
26
|
+
try {
|
|
27
|
+
const r = spawnSync("cloudflared", ["--version"], { stdio: "ignore" });
|
|
28
|
+
_cloudflared = !r.error && r.status === 0;
|
|
29
|
+
} catch {
|
|
30
|
+
_cloudflared = false;
|
|
31
|
+
}
|
|
32
|
+
return _cloudflared;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// npx ships with npm; assume it's present when node is. Used for localtunnel.
|
|
36
|
+
function hasNpx() {
|
|
37
|
+
try {
|
|
38
|
+
const r = spawnSync("npx", ["--version"], { stdio: "ignore" });
|
|
39
|
+
return !r.error && r.status === 0;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Which providers are usable on this machine, best first.
|
|
46
|
+
export function detectProviders() {
|
|
47
|
+
const out = [];
|
|
48
|
+
if (hasCloudflared()) out.push("cloudflared");
|
|
49
|
+
if (hasNpx()) out.push("localtunnel");
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function spawnProvider(provider, port) {
|
|
54
|
+
if (provider === "cloudflared") {
|
|
55
|
+
return spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
|
|
56
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (provider === "localtunnel") {
|
|
60
|
+
return spawn("npx", ["-y", "localtunnel", "--port", String(port)], {
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`unknown tunnel provider "${provider}"`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class TunnelManager {
|
|
68
|
+
constructor() {
|
|
69
|
+
/** @type {Map<string, object>} id → record */
|
|
70
|
+
this.tunnels = new Map();
|
|
71
|
+
// Kill any surviving children when the daemon process goes down.
|
|
72
|
+
const cleanup = () => this.closeAllSync();
|
|
73
|
+
process.once("exit", cleanup);
|
|
74
|
+
process.once("SIGINT", () => { cleanup(); process.exit(130); });
|
|
75
|
+
process.once("SIGTERM", () => { cleanup(); process.exit(143); });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static view(rec) {
|
|
79
|
+
return { id: rec.id, url: rec.url, provider: rec.provider, port: rec.port, createdAt: rec.createdAt };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
list() {
|
|
83
|
+
return [...this.tunnels.values()].map((r) => TunnelManager.view(r));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Open a tunnel to a local port. `provider` optional — auto-picks the best
|
|
87
|
+
// available. Resolves once the public URL is announced.
|
|
88
|
+
open(port, { provider } = {}) {
|
|
89
|
+
const providers = detectProviders();
|
|
90
|
+
if (providers.length === 0) {
|
|
91
|
+
return Promise.reject(new Error(
|
|
92
|
+
"no tunnel provider available. Install cloudflared (brew install cloudflared) " +
|
|
93
|
+
"or ensure npx is on PATH for localtunnel."));
|
|
94
|
+
}
|
|
95
|
+
const chosen = provider && providers.includes(provider) ? provider : providers[0];
|
|
96
|
+
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
let child;
|
|
99
|
+
try {
|
|
100
|
+
child = spawnProvider(chosen, port);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return reject(e);
|
|
103
|
+
}
|
|
104
|
+
const id = randomUUID().slice(0, 8);
|
|
105
|
+
let settled = false;
|
|
106
|
+
let buf = "";
|
|
107
|
+
|
|
108
|
+
const onData = (chunk) => {
|
|
109
|
+
buf += chunk.toString("utf8");
|
|
110
|
+
const m = buf.match(URL_RE);
|
|
111
|
+
if (m && !settled) {
|
|
112
|
+
settled = true;
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
const rec = {
|
|
115
|
+
id, url: m[0], provider: chosen, port, child,
|
|
116
|
+
createdAt: new Date().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
this.tunnels.set(id, rec);
|
|
119
|
+
resolve(TunnelManager.view(rec));
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
child.stdout?.on("data", onData);
|
|
123
|
+
child.stderr?.on("data", onData);
|
|
124
|
+
|
|
125
|
+
child.on("error", (err) => {
|
|
126
|
+
if (settled) return;
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
reject(new Error(`tunnel (${chosen}) failed to start: ${err.message}`));
|
|
130
|
+
});
|
|
131
|
+
child.on("exit", (code) => {
|
|
132
|
+
this._forget(id);
|
|
133
|
+
if (settled) return;
|
|
134
|
+
settled = true;
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
reject(new Error(`tunnel (${chosen}) exited before announcing a URL (code ${code}).`));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
try { child.kill("SIGTERM"); } catch { /* ignore */ }
|
|
143
|
+
reject(new Error(`tunnel (${chosen}) timed out after ${OPEN_TIMEOUT_MS / 1000}s.`));
|
|
144
|
+
}, OPEN_TIMEOUT_MS);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_forget(id) {
|
|
149
|
+
this.tunnels.delete(id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
close(id) {
|
|
153
|
+
const rec = this.tunnels.get(id);
|
|
154
|
+
if (!rec) return false;
|
|
155
|
+
try { rec.child.kill("SIGTERM"); } catch { /* ignore */ }
|
|
156
|
+
this.tunnels.delete(id);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
closeAllSync() {
|
|
161
|
+
for (const rec of this.tunnels.values()) {
|
|
162
|
+
try { rec.child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
163
|
+
}
|
|
164
|
+
this.tunnels.clear();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Process-wide singleton, mirroring the preview registry.
|
|
169
|
+
export const tunnels = new TunnelManager();
|
package/src/core/config/index.js
CHANGED
|
@@ -76,6 +76,47 @@ const DEFAULT_CONFIG = {
|
|
|
76
76
|
],
|
|
77
77
|
health_timeout_ms: 800,
|
|
78
78
|
},
|
|
79
|
+
// Inline security-risk analysis (OpenHands LLMSecurityAnalyzer pattern):
|
|
80
|
+
// every tool schema gains a required `security_risk` enum the model must
|
|
81
|
+
// fill; calls at/above `confirm_at` pause for user confirmation. Opt-in.
|
|
82
|
+
// When enabled it REPLACES the static dangerous-flag confirmation of
|
|
83
|
+
// permission_mode "automatico" (permiso/total semantics are unchanged).
|
|
84
|
+
security_risk: {
|
|
85
|
+
enabled: false,
|
|
86
|
+
confirm_at: "HIGH", // LOW | MEDIUM | HIGH
|
|
87
|
+
confirm_unknown: true, // ungraded calls (weak models) also pause
|
|
88
|
+
},
|
|
89
|
+
// Stuck detection (OpenHands pattern): same call + same result
|
|
90
|
+
// `action_repeat` times, or same call erroring `error_repeat` times in a
|
|
91
|
+
// row → in-band nudge, then a forced wrap-up if it keeps looping.
|
|
92
|
+
stuck_detection: {
|
|
93
|
+
enabled: true,
|
|
94
|
+
action_repeat: 4,
|
|
95
|
+
error_repeat: 3,
|
|
96
|
+
},
|
|
97
|
+
// Content-based routing (RouterLLM pattern): ordered rules inspected per
|
|
98
|
+
// turn; first match prefers a model for it (health-checked, falls back
|
|
99
|
+
// down the regular chain). Rule shape: { model: "<provider>:<model>",
|
|
100
|
+
// when: { has_image?, min_prompt_chars?, max_prompt_chars?,
|
|
101
|
+
// min_context_chars?, channels?: [], keywords?: [] } }.
|
|
102
|
+
// Enabled by default but a NO-OP until the user adds rules (empty rules →
|
|
103
|
+
// nothing reroutes), so it's safe to ship on and configure from the web
|
|
104
|
+
// Routing panel.
|
|
105
|
+
routing: {
|
|
106
|
+
enabled: true,
|
|
107
|
+
rules: [],
|
|
108
|
+
},
|
|
109
|
+
// Goal-completion judge (OpenHands critic pattern): after a
|
|
110
|
+
// completion-contract turn declares done, an LLM judge scores goal
|
|
111
|
+
// completion (0..1); below success_threshold the agent gets a
|
|
112
|
+
// verification follow-up and continues, up to max_iterations rounds.
|
|
113
|
+
// model "" → judge runs on super_agent.model.
|
|
114
|
+
judge: {
|
|
115
|
+
enabled: false,
|
|
116
|
+
success_threshold: 0.6,
|
|
117
|
+
max_iterations: 2,
|
|
118
|
+
model: "",
|
|
119
|
+
},
|
|
79
120
|
},
|
|
80
121
|
engines: {
|
|
81
122
|
anthropic: { api_key: "" },
|
|
@@ -119,6 +160,7 @@ const DEFAULT_CONFIG = {
|
|
|
119
160
|
broker_budget_ms: 800, // hard cap on the Memory Broker
|
|
120
161
|
compact_threshold: 60, // compact once a chat exceeds this many turns
|
|
121
162
|
keep_recent: 40, // verbatim turns always kept after compaction
|
|
163
|
+
keep_first: 2, // opening turns quoted verbatim into the condenser prompt (they hold the original goal)
|
|
122
164
|
compact_model: "ollama:gemma4:31b-cloud", // light LLM for compaction (Ollama, local endpoint)
|
|
123
165
|
compact_fallback_model: "", // "" → falls back to super_agent.model (APX default)
|
|
124
166
|
},
|
|
@@ -380,6 +422,25 @@ export function mergeDefaults(cfg) {
|
|
|
380
422
|
...DEFAULT_CONFIG.super_agent,
|
|
381
423
|
...(cfg.super_agent || {}),
|
|
382
424
|
model_fallback: mergeModelFallback(cfg.super_agent?.model_fallback),
|
|
425
|
+
security_risk: {
|
|
426
|
+
...DEFAULT_CONFIG.super_agent.security_risk,
|
|
427
|
+
...(cfg.super_agent?.security_risk || {}),
|
|
428
|
+
},
|
|
429
|
+
stuck_detection: {
|
|
430
|
+
...DEFAULT_CONFIG.super_agent.stuck_detection,
|
|
431
|
+
...(cfg.super_agent?.stuck_detection || {}),
|
|
432
|
+
},
|
|
433
|
+
routing: {
|
|
434
|
+
...DEFAULT_CONFIG.super_agent.routing,
|
|
435
|
+
...(cfg.super_agent?.routing || {}),
|
|
436
|
+
rules: Array.isArray(cfg.super_agent?.routing?.rules)
|
|
437
|
+
? cfg.super_agent.routing.rules
|
|
438
|
+
: [],
|
|
439
|
+
},
|
|
440
|
+
judge: {
|
|
441
|
+
...DEFAULT_CONFIG.super_agent.judge,
|
|
442
|
+
...(cfg.super_agent?.judge || {}),
|
|
443
|
+
},
|
|
383
444
|
},
|
|
384
445
|
engines: {
|
|
385
446
|
...DEFAULT_CONFIG.engines,
|