@agentprojectcontext/apx 1.66.0 → 1.68.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.
Files changed (61) hide show
  1. package/package.json +3 -2
  2. package/skills/apx/SKILL.md +3 -0
  3. package/src/core/agent/index.js +2 -0
  4. package/src/core/agent/judge.js +174 -0
  5. package/src/core/agent/model-router.js +107 -5
  6. package/src/core/agent/prompts/modes/code-build.md +1 -1
  7. package/src/core/agent/run-agent.js +149 -12
  8. package/src/core/agent/security.js +97 -0
  9. package/src/core/agent/stuck-detector.js +89 -0
  10. package/src/core/agent/super-agent.js +58 -17
  11. package/src/core/agent/tools/handlers/run-subagent.js +117 -0
  12. package/src/core/agent/tools/helpers.js +11 -1
  13. package/src/core/agent/tools/names.js +2 -0
  14. package/src/core/agent/tools/registry.js +10 -0
  15. package/src/core/artifacts/preview.js +392 -0
  16. package/src/core/artifacts/tunnel.js +169 -0
  17. package/src/core/config/index.js +61 -0
  18. package/src/core/config/redact.js +44 -0
  19. package/src/core/config/secret-values.js +132 -0
  20. package/src/core/engines/mock.js +15 -1
  21. package/src/core/logging.js +10 -3
  22. package/src/core/memory/compactor.js +65 -56
  23. package/src/core/memory/summarizer.js +125 -0
  24. package/src/core/stores/conversations-compactor.js +24 -31
  25. package/src/host/daemon/api/admin-config.js +5 -0
  26. package/src/host/daemon/api/artifact-preview.js +82 -0
  27. package/src/host/daemon/api/config.js +17 -5
  28. package/src/host/daemon/api/sessions.js +9 -0
  29. package/src/host/daemon/api/web.js +1 -1
  30. package/src/host/daemon/api.js +2 -0
  31. package/src/host/daemon/index.js +16 -1
  32. package/src/interfaces/acp/index.js +363 -0
  33. package/src/interfaces/acp/jsonrpc.js +180 -0
  34. package/src/interfaces/acp/session.js +205 -0
  35. package/src/interfaces/cli/commands/acp.js +10 -0
  36. package/src/interfaces/cli/commands/artifact.js +115 -0
  37. package/src/interfaces/cli/index.js +74 -0
  38. package/src/interfaces/web/dist/assets/index-D4BmWoDM.css +1 -0
  39. package/src/interfaces/web/dist/assets/index-vwd6yQVw.js +803 -0
  40. package/src/interfaces/web/dist/assets/index-vwd6yQVw.js.map +1 -0
  41. package/src/interfaces/web/dist/index.html +2 -2
  42. package/src/interfaces/web/package-lock.json +9 -9
  43. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
  44. package/src/interfaces/web/src/components/config/ConfigTabsEditor.tsx +46 -31
  45. package/src/interfaces/web/src/components/config/project-config-sections.ts +9 -11
  46. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +162 -0
  47. package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
  48. package/src/interfaces/web/src/i18n/en.ts +53 -0
  49. package/src/interfaces/web/src/i18n/es.ts +53 -0
  50. package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
  51. package/src/interfaces/web/src/lib/api/sessions.ts +2 -1
  52. package/src/interfaces/web/src/screens/ProjectScreen.tsx +50 -60
  53. package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
  54. package/src/interfaces/web/src/screens/base/SessionsTab.tsx +11 -5
  55. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +110 -25
  56. package/src/interfaces/web/src/screens/project/MemoriesTab.tsx +7 -128
  57. package/src/interfaces/web/src/screens/project/Overview.tsx +3 -2
  58. package/src/interfaces/web/src/types/daemon.ts +16 -0
  59. package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
  60. package/src/interfaces/web/dist/assets/index-YmMRG--4.js +0 -778
  61. 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
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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();
@@ -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,
@@ -67,6 +67,50 @@ export function redactConfig(cfg) {
67
67
  return out;
68
68
  }
69
69
 
70
+ /** Walk a dotted path on an object, returning the value or undefined. */
71
+ function getDotted(obj, dotted) {
72
+ let cur = obj;
73
+ for (const part of dotted.split(".")) {
74
+ if (!cur || typeof cur !== "object") return undefined;
75
+ cur = cur[part];
76
+ }
77
+ return cur;
78
+ }
79
+
80
+ /** Set a dotted path on an object, creating intermediate objects. */
81
+ function setDotted(obj, dotted, value) {
82
+ const parts = dotted.split(".");
83
+ let cur = obj;
84
+ for (let i = 0; i < parts.length - 1; i++) {
85
+ if (!cur[parts[i]] || typeof cur[parts[i]] !== "object") cur[parts[i]] = {};
86
+ cur = cur[parts[i]];
87
+ }
88
+ cur[parts[parts.length - 1]] = value;
89
+ }
90
+
91
+ /**
92
+ * Merge a possibly-redacted config `next` against the prior on-disk config.
93
+ * Anywhere a secret path in `next` holds a marker (the value a redacted view
94
+ * echoes back), restore the prior real secret — so saving the redacted view
95
+ * never clobbers a real key with the literal "*** set ***" string. Mutates and
96
+ * returns `next`.
97
+ */
98
+ export function mergeRedactedSecrets(next, prior) {
99
+ if (!next || typeof next !== "object") return next;
100
+ for (const dotted of SECRET_PATHS) {
101
+ if (dotted.includes("*")) continue;
102
+ if (isSecretMarker(getDotted(next, dotted))) {
103
+ const priorVal = getDotted(prior, dotted);
104
+ if (typeof priorVal === "string" && priorVal.length) setDotted(next, dotted, priorVal);
105
+ }
106
+ }
107
+ const nextChannels = next?.telegram?.channels;
108
+ if (Array.isArray(nextChannels)) {
109
+ next.telegram.channels = mergeRedactedChannels(nextChannels, prior?.telegram?.channels);
110
+ }
111
+ return next;
112
+ }
113
+
70
114
  /** Redact a single Telegram channel record. */
71
115
  export function redactChannel(channel) {
72
116
  if (!channel?.bot_token) return channel;