@agentprojectcontext/apx 1.65.3 → 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.
Files changed (55) 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 +62 -1
  18. package/src/core/config/secret-values.js +132 -0
  19. package/src/core/engines/mock.js +15 -1
  20. package/src/core/engines/presets.js +102 -0
  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/engines.js +6 -0
  28. package/src/host/daemon/api/web.js +1 -1
  29. package/src/host/daemon/api.js +2 -0
  30. package/src/host/daemon/index.js +16 -1
  31. package/src/interfaces/acp/index.js +363 -0
  32. package/src/interfaces/acp/jsonrpc.js +180 -0
  33. package/src/interfaces/acp/session.js +205 -0
  34. package/src/interfaces/cli/commands/acp.js +10 -0
  35. package/src/interfaces/cli/commands/artifact.js +115 -0
  36. package/src/interfaces/cli/commands/setup.js +6 -3
  37. package/src/interfaces/cli/index.js +74 -0
  38. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js +803 -0
  39. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js.map +1 -0
  40. package/src/interfaces/web/dist/assets/index-BPGECxzm.css +1 -0
  41. package/src/interfaces/web/dist/index.html +2 -2
  42. package/src/interfaces/web/package-lock.json +6 -6
  43. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
  44. package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
  45. package/src/interfaces/web/src/components/settings/providers/typeStyles.ts +44 -25
  46. package/src/interfaces/web/src/i18n/en.ts +47 -0
  47. package/src/interfaces/web/src/i18n/es.ts +47 -0
  48. package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
  49. package/src/interfaces/web/src/lib/api/engines.ts +14 -0
  50. package/src/interfaces/web/src/main.tsx +5 -0
  51. package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
  52. package/src/interfaces/web/src/types/daemon.ts +16 -0
  53. package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
  54. package/src/interfaces/web/dist/assets/index-CFcs16SV.js +0 -778
  55. package/src/interfaces/web/dist/assets/index-CFcs16SV.js.map +0 -1
@@ -0,0 +1,180 @@
1
+ // Newline-delimited JSON-RPC 2.0 connection — the ACP stdio framing.
2
+ //
3
+ // Per the ACP transport spec (agentclientprotocol.com, transports.mdx):
4
+ // messages are individual JSON-RPC requests/notifications/responses, UTF-8,
5
+ // delimited by "\n", never containing embedded newlines. Both peers can act
6
+ // as caller and callee (the agent calls `session/request_permission` on the
7
+ // client), so this connection is symmetric: it dispatches incoming requests
8
+ // to registered handlers AND tracks ids of our own outgoing requests.
9
+ //
10
+ // Hand-rolled on purpose — the repo rule is no new npm dependencies, and the
11
+ // framing is small enough that a library would cost more than it saves.
12
+
13
+ export const JSONRPC_ERROR_CODES = Object.freeze({
14
+ PARSE_ERROR: -32700,
15
+ INVALID_REQUEST: -32600,
16
+ METHOD_NOT_FOUND: -32601,
17
+ INVALID_PARAMS: -32602,
18
+ INTERNAL_ERROR: -32603,
19
+ });
20
+
21
+ export class JsonRpcError extends Error {
22
+ constructor(code, message, data) {
23
+ super(message);
24
+ this.code = code;
25
+ if (data !== undefined) this.data = data;
26
+ }
27
+ }
28
+
29
+ export class JsonRpcConnection {
30
+ /**
31
+ * @param {{ input: import("node:stream").Readable,
32
+ * output: import("node:stream").Writable,
33
+ * onError?: (err: Error) => void }} opts
34
+ */
35
+ constructor({ input, output, onError = null }) {
36
+ this.output = output;
37
+ this.onError = onError;
38
+ this.handlers = new Map();
39
+ this.pending = new Map(); // id → {resolve, reject} for our outgoing requests
40
+ this.nextId = 0;
41
+ this.buffer = "";
42
+ this.closed = false;
43
+ this._closeResolvers = [];
44
+
45
+ input.setEncoding?.("utf8");
46
+ input.on("data", (chunk) => this._onData(String(chunk)));
47
+ input.on("end", () => this._close());
48
+ input.on("close", () => this._close());
49
+ input.on("error", () => this._close());
50
+ }
51
+
52
+ /** Register a handler for an incoming method (request or notification). */
53
+ method(name, handler) {
54
+ this.handlers.set(name, handler);
55
+ return this;
56
+ }
57
+
58
+ /** Resolves when the peer closes its side of the pipe. */
59
+ whenClosed() {
60
+ if (this.closed) return Promise.resolve();
61
+ return new Promise((resolve) => this._closeResolvers.push(resolve));
62
+ }
63
+
64
+ /** Send a one-way notification to the peer. */
65
+ notify(method, params) {
66
+ this._send({ jsonrpc: "2.0", method, params });
67
+ }
68
+
69
+ /** Send a request to the peer and await its response. */
70
+ request(method, params) {
71
+ const id = ++this.nextId;
72
+ return new Promise((resolve, reject) => {
73
+ if (this.closed) return reject(new Error("connection closed"));
74
+ this.pending.set(id, { resolve, reject });
75
+ this._send({ jsonrpc: "2.0", id, method, params });
76
+ });
77
+ }
78
+
79
+ _close() {
80
+ if (this.closed) return;
81
+ this.closed = true;
82
+ for (const { reject } of this.pending.values()) {
83
+ reject(new Error("connection closed"));
84
+ }
85
+ this.pending.clear();
86
+ for (const resolve of this._closeResolvers) resolve();
87
+ this._closeResolvers = [];
88
+ }
89
+
90
+ _send(msg) {
91
+ if (this.closed) return;
92
+ try {
93
+ this.output.write(JSON.stringify(msg) + "\n");
94
+ } catch (e) {
95
+ this.onError?.(e);
96
+ }
97
+ }
98
+
99
+ _onData(text) {
100
+ this.buffer += text;
101
+ let idx;
102
+ while ((idx = this.buffer.indexOf("\n")) !== -1) {
103
+ const line = this.buffer.slice(0, idx).trim();
104
+ this.buffer = this.buffer.slice(idx + 1);
105
+ if (!line) continue;
106
+ let msg;
107
+ try {
108
+ msg = JSON.parse(line);
109
+ } catch {
110
+ this._send({
111
+ jsonrpc: "2.0",
112
+ id: null,
113
+ error: { code: JSONRPC_ERROR_CODES.PARSE_ERROR, message: "parse error" },
114
+ });
115
+ continue;
116
+ }
117
+ // Fire-and-forget: a long-running request (session/prompt) must not
118
+ // block later frames — `session/cancel` has to be processed while the
119
+ // prompt handler is still awaiting the daemon stream.
120
+ this._dispatch(msg).catch((e) => this.onError?.(e));
121
+ }
122
+ }
123
+
124
+ async _dispatch(msg) {
125
+ if (!msg || typeof msg !== "object") return;
126
+
127
+ if (typeof msg.method === "string") {
128
+ const hasId = msg.id !== undefined && msg.id !== null;
129
+ const handler = this.handlers.get(msg.method);
130
+ if (!handler) {
131
+ if (hasId) {
132
+ this._send({
133
+ jsonrpc: "2.0",
134
+ id: msg.id,
135
+ error: {
136
+ code: JSONRPC_ERROR_CODES.METHOD_NOT_FOUND,
137
+ message: `method not found: ${msg.method}`,
138
+ },
139
+ });
140
+ }
141
+ return;
142
+ }
143
+ try {
144
+ const result = await handler(msg.params ?? {});
145
+ if (hasId) this._send({ jsonrpc: "2.0", id: msg.id, result: result ?? null });
146
+ } catch (e) {
147
+ if (hasId) {
148
+ this._send({
149
+ jsonrpc: "2.0",
150
+ id: msg.id,
151
+ error: {
152
+ code: typeof e?.code === "number" ? e.code : JSONRPC_ERROR_CODES.INTERNAL_ERROR,
153
+ message: e?.message || "internal error",
154
+ ...(e?.data !== undefined ? { data: e.data } : {}),
155
+ },
156
+ });
157
+ } else {
158
+ this.onError?.(e);
159
+ }
160
+ }
161
+ return;
162
+ }
163
+
164
+ // Response to one of our outgoing requests.
165
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
166
+ const { resolve, reject } = this.pending.get(msg.id);
167
+ this.pending.delete(msg.id);
168
+ if (msg.error) {
169
+ reject(
170
+ Object.assign(new Error(msg.error.message || "remote error"), {
171
+ code: msg.error.code,
172
+ data: msg.error.data,
173
+ })
174
+ );
175
+ } else {
176
+ resolve(msg.result);
177
+ }
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,205 @@
1
+ // ACP session plumbing — daemon HTTP client, project resolution for a
2
+ // session's cwd, and the small mapping helpers between APX daemon stream
3
+ // events and ACP wire shapes.
4
+ //
5
+ // The daemon client is deliberately NOT the CLI's http.js: that module pins
6
+ // its base URL to env vars at import time, while tests (and future embeddings)
7
+ // need a per-instance {baseUrl, token}. The NDJSON reader mirrors
8
+ // src/interfaces/cli/http.js streamRequest so both surfaces parse the daemon
9
+ // stream identically.
10
+
11
+ import path from "node:path";
12
+ import { findApfRoot } from "#core/apc/parser.js";
13
+
14
+ /**
15
+ * Minimal daemon HTTP client bound to an injected base URL + token.
16
+ * `token` may be a string or a () => string (re-read per request so a daemon
17
+ * restart with a rotated token keeps working mid-session).
18
+ */
19
+ export function createDaemonClient({ baseUrl, token = "", ensureReady = null }) {
20
+ const authHeaders = () => {
21
+ const t = typeof token === "function" ? token() : token;
22
+ return t ? { authorization: `Bearer ${t}` } : {};
23
+ };
24
+
25
+ async function request(method, p, body) {
26
+ if (ensureReady) await ensureReady();
27
+ const res = await fetch(`${baseUrl}${p}`, {
28
+ method,
29
+ headers: {
30
+ ...(body ? { "content-type": "application/json" } : {}),
31
+ ...authHeaders(),
32
+ },
33
+ body: body ? JSON.stringify(body) : undefined,
34
+ });
35
+ const text = await res.text();
36
+ let json = null;
37
+ try {
38
+ json = text ? JSON.parse(text) : null;
39
+ } catch {
40
+ /* non-JSON body — handled below */
41
+ }
42
+ if (!res.ok) throw new Error(json?.error || `${method} ${p} → ${res.status}`);
43
+ return json;
44
+ }
45
+
46
+ /**
47
+ * POST to an NDJSON stream endpoint. Awaits `onEvent` per event so a
48
+ * confirmation round-trip naturally back-pressures the stream. Returns the
49
+ * `result` of the `{type:"final"}` event, or null when aborted early.
50
+ */
51
+ async function streamPost(p, body, onEvent, { signal } = {}) {
52
+ if (ensureReady) await ensureReady();
53
+ const res = await fetch(`${baseUrl}${p}`, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json", ...authHeaders() },
56
+ body: JSON.stringify(body),
57
+ signal,
58
+ });
59
+ if (!res.ok) {
60
+ const text = await res.text();
61
+ let json = null;
62
+ try {
63
+ json = text ? JSON.parse(text) : null;
64
+ } catch {}
65
+ throw new Error(json?.error || `POST ${p} → ${res.status}`);
66
+ }
67
+ if (!res.body?.getReader) {
68
+ throw new Error("streaming response is not supported by this Node.js runtime");
69
+ }
70
+
71
+ const reader = res.body.getReader();
72
+ const decoder = new TextDecoder();
73
+ let buffer = "";
74
+ let finalResult = null;
75
+
76
+ if (signal) {
77
+ signal.addEventListener("abort", () => reader.cancel().catch(() => {}), { once: true });
78
+ }
79
+
80
+ const handleLine = async (line) => {
81
+ if (!line.trim()) return;
82
+ const event = JSON.parse(line);
83
+ if (event.type === "final") finalResult = event.result;
84
+ if (event.type === "error") throw new Error(event.error || "stream error");
85
+ await onEvent?.(event);
86
+ };
87
+
88
+ while (true) {
89
+ let chunk;
90
+ try {
91
+ chunk = await reader.read();
92
+ } catch {
93
+ break; // abort/cancel — treat as clean end, caller checks its own flag
94
+ }
95
+ if (chunk.done) break;
96
+ buffer += decoder.decode(chunk.value, { stream: true });
97
+ const lines = buffer.split(/\r?\n/);
98
+ buffer = lines.pop() || "";
99
+ for (const line of lines) await handleLine(line);
100
+ }
101
+
102
+ buffer += decoder.decode();
103
+ if (buffer.trim()) {
104
+ try {
105
+ await handleLine(buffer);
106
+ } catch {}
107
+ }
108
+
109
+ return finalResult;
110
+ }
111
+
112
+ return {
113
+ baseUrl,
114
+ get: (p) => request("GET", p),
115
+ post: (p, body) => request("POST", p, body),
116
+ streamPost,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Resolve the APC project for an ACP session's cwd — same contract as the
122
+ * mcp-server surface: walk up to the .apc root, match a registered daemon
123
+ * project by path, register it when unknown.
124
+ */
125
+ export async function resolveProjectForCwd(client, cwd) {
126
+ const root = findApfRoot(cwd || process.cwd());
127
+ if (!root) {
128
+ throw new Error(
129
+ `No APC project found at or above: ${cwd}. Run \`apx init\` in the workspace first.`
130
+ );
131
+ }
132
+ const projects = await client.get("/projects");
133
+ const match = (projects || []).find(
134
+ (p) => path.resolve(p.path) === path.resolve(root)
135
+ );
136
+ if (match) return match;
137
+ return client.post("/projects", { path: root });
138
+ }
139
+
140
+ /** Flatten an ACP prompt (ContentBlock[]) into the text the daemon expects. */
141
+ export function extractPromptText(blocks) {
142
+ if (typeof blocks === "string") return blocks;
143
+ if (!Array.isArray(blocks)) return "";
144
+ const parts = [];
145
+ for (const block of blocks) {
146
+ if (!block || typeof block !== "object") continue;
147
+ if (block.type === "text" && typeof block.text === "string") {
148
+ parts.push(block.text);
149
+ } else if (block.type === "resource_link" && block.uri) {
150
+ // Baseline capability: resource links arrive as URIs; surface them to
151
+ // the model as plain references (we don't fetch on the agent side).
152
+ parts.push(String(block.uri));
153
+ }
154
+ }
155
+ return parts.join("\n").trim();
156
+ }
157
+
158
+ // Best-effort mapping from APX tool names (snake_case verbs) to ACP ToolKind.
159
+ const KIND_RULES = [
160
+ [/^(search|find)_/, "search"],
161
+ [/^(read|list|get|show)_/, "read"],
162
+ [/^(edit|write|update|set|create|add|remember|import)_?/, "edit"],
163
+ [/^(delete|remove)_/, "delete"],
164
+ [/(shell|exec|run|call)/, "execute"],
165
+ [/(fetch|http|web|download)/, "fetch"],
166
+ ];
167
+
168
+ export function toolKindFor(toolName) {
169
+ const name = String(toolName || "").toLowerCase();
170
+ for (const [re, kind] of KIND_RULES) {
171
+ if (re.test(name)) return kind;
172
+ }
173
+ return "other";
174
+ }
175
+
176
+ /** Compact text summary of a tool result for tool_call_update content. */
177
+ export function summarizeToolResult(result, max = 2000) {
178
+ let text;
179
+ if (result == null) text = "";
180
+ else if (typeof result === "string") text = result;
181
+ else {
182
+ try {
183
+ text = JSON.stringify(result, null, 2);
184
+ } catch {
185
+ text = String(result);
186
+ }
187
+ }
188
+ return text.length > max ? text.slice(0, max - 1) + "…" : text;
189
+ }
190
+
191
+ let sessionCounter = 0;
192
+
193
+ /** Per-connection session state. History feeds `previousMessages` so multi-turn
194
+ * ACP sessions keep context without any daemon-side session storage. */
195
+ export function createSession({ id, project, cwd }) {
196
+ sessionCounter += 1;
197
+ return {
198
+ id,
199
+ project,
200
+ cwd,
201
+ history: [], // [{role: "user"|"assistant", content: string}]
202
+ activeTurn: null, // { abort: AbortController, cancelled: boolean }
203
+ seq: sessionCounter,
204
+ };
205
+ }
@@ -0,0 +1,10 @@
1
+ // apx acp — serve the APX super-agent over the Agent Client Protocol on the
2
+ // current stdio. ACP clients (Zed, JetBrains, marimo, …) spawn this command
3
+ // as a subprocess and speak JSON-RPC over stdin/stdout, so the command must
4
+ // never print to stdout itself; all human-facing output goes to stderr.
5
+
6
+ export async function cmdAcp() {
7
+ const { startStdioAcpServer } = await import("#interfaces/acp/index.js");
8
+ process.stderr.write("apx acp: Agent Client Protocol server on stdio (ctrl-c to stop)\n");
9
+ await startStdioAcpServer();
10
+ }
@@ -1,9 +1,20 @@
1
1
  import fs from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
3
  import path from "node:path";
4
+ import open from "open";
4
5
  import { http } from "../http.js";
5
6
  import { resolveProjectId } from "./project.js";
6
7
 
8
+ // Emit an OSC 8 terminal hyperlink when stdout is a TTY that likely supports
9
+ // it (iTerm2, modern VS Code, kitty, WezTerm, …). Falls back to the raw URL
10
+ // elsewhere so the link is always at least copy-pasteable.
11
+ function hyperlink(url, label = url) {
12
+ if (process.stdout.isTTY) {
13
+ return `\x1b]8;;${url}\x07${label}\x1b]8;;\x07`;
14
+ }
15
+ return label === url ? url : `${label} (${url})`;
16
+ }
17
+
7
18
  // First two bytes of an executable script. Used as a hint when the file
8
19
  // doesn't have the exec bit but clearly intends to run (shebang line).
9
20
  const SHEBANG = "#!";
@@ -142,3 +153,107 @@ export async function cmdArtifactRun(args) {
142
153
  });
143
154
  });
144
155
  }
156
+
157
+ // `apx artifact preview <name> [--open] [--share] [--no-watch]`
158
+ //
159
+ // Asks the daemon to spin up an ephemeral local web server that renders the
160
+ // artifact (HTML / React / static) and prints an interactive localhost link.
161
+ // The page auto-reloads when the artifact file changes (unless --no-watch).
162
+ // With --open the link is opened in the default browser; with --share a public
163
+ // tunnel URL is created too.
164
+ export async function cmdArtifactPreview(args) {
165
+ const name = args._[0];
166
+ if (!name) throw new Error("apx artifact preview: missing <name>");
167
+ const pid = await resolveProjectId(args?.flags?.project);
168
+ const watch = args.flags["no-watch"] ? false : true;
169
+
170
+ let view;
171
+ try {
172
+ view = await http.post(
173
+ `/projects/${pid}/artifacts/${encodeURIComponent(name)}/preview`,
174
+ { watch }
175
+ );
176
+ } catch (e) {
177
+ throw new Error(`could not preview "${name}": ${e.message}`);
178
+ }
179
+
180
+ console.log(`preview ready — ${view.kind} artifact "${view.name}"`);
181
+ console.log(` local: ${hyperlink(view.url)}`);
182
+ if (view.watch) console.log(" (auto-reloads on change — edit the artifact and the tab refreshes)");
183
+ console.log(` stop: apx artifact stop ${view.id}`);
184
+
185
+ if (args.flags.share) {
186
+ await sharePreview(view.id, { open: !!args.flags.open });
187
+ } else if (args.flags.open) {
188
+ await open(view.url);
189
+ console.log(" opened in your browser.");
190
+ } else {
191
+ console.log(` open: apx artifact preview ${name} --open`);
192
+ }
193
+ }
194
+
195
+ // Shared helper: open a tunnel for an existing preview id and print the URL.
196
+ async function sharePreview(previewId, { open: doOpen = false } = {}) {
197
+ let tunnel;
198
+ try {
199
+ tunnel = await http.post(`/previews/${previewId}/tunnel`, {});
200
+ } catch (e) {
201
+ throw new Error(`could not create tunnel: ${e.message}`);
202
+ }
203
+ console.log(` public: ${hyperlink(tunnel.url)} (${tunnel.provider})`);
204
+ console.log(" ⚠ anyone with this URL can reach the preview while it's open.");
205
+ if (doOpen) {
206
+ await open(tunnel.url);
207
+ console.log(" opened public URL in your browser.");
208
+ }
209
+ }
210
+
211
+ // `apx artifact share <name> [--open] [--no-watch]`
212
+ // Convenience: preview + tunnel in one shot.
213
+ export async function cmdArtifactShare(args) {
214
+ const name = args._[0];
215
+ if (!name) throw new Error("apx artifact share: missing <name>");
216
+ const pid = await resolveProjectId(args?.flags?.project);
217
+ const watch = args.flags["no-watch"] ? false : true;
218
+
219
+ const view = await http.post(
220
+ `/projects/${pid}/artifacts/${encodeURIComponent(name)}/preview`,
221
+ { watch }
222
+ );
223
+ console.log(`sharing ${view.kind} artifact "${view.name}"`);
224
+ console.log(` local: ${hyperlink(view.url)}`);
225
+ await sharePreview(view.id, { open: !!args.flags.open });
226
+ console.log(` stop: apx artifact stop ${view.id}`);
227
+ }
228
+
229
+ // `apx artifact previews` — list running preview servers.
230
+ export async function cmdArtifactPreviews(args = {}) {
231
+ const rows = await http.get(`/previews`);
232
+ if (!rows.length) {
233
+ console.log("(no running previews)");
234
+ return;
235
+ }
236
+ console.log("ID".padEnd(10) + "NAME".padEnd(24) + "KIND".padEnd(8) + "URL");
237
+ for (const r of rows) {
238
+ console.log(
239
+ r.id.padEnd(10) +
240
+ String(r.name).slice(0, 22).padEnd(24) +
241
+ String(r.kind).padEnd(8) +
242
+ r.url + (r.tunnel ? ` → ${r.tunnel.url}` : "")
243
+ );
244
+ }
245
+ }
246
+
247
+ // `apx artifact stop <id> | --all` — stop preview server(s).
248
+ export async function cmdArtifactStop(args) {
249
+ if (args.flags.all) {
250
+ const rows = await http.get(`/previews`);
251
+ for (const r of rows) await http.delete(`/previews/${r.id}`);
252
+ console.log(`stopped ${rows.length} preview(s)`);
253
+ return;
254
+ }
255
+ const id = args._[0];
256
+ if (!id) throw new Error("apx artifact stop: missing <id> (or --all)");
257
+ await http.delete(`/previews/${encodeURIComponent(id)}`);
258
+ console.log(`stopped preview ${id}`);
259
+ }
@@ -8,6 +8,7 @@ import http from "node:http";
8
8
  import readline from "node:readline";
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { readConfig, writeConfig } from "#core/config/index.js";
11
+ import { ENGINE_PRESETS } from "#core/engines/presets.js";
11
12
  import { mascot } from "#core/mascot.js";
12
13
  import { setupClaudePermissions } from "../claude-permissions.js";
13
14
  import { PERMISSION_MODES, DEFAULT_PERMISSION_MODE } from "#core/constants/permissions.js";
@@ -59,6 +60,8 @@ async function fetchOllamaModels(baseUrl) {
59
60
  }
60
61
 
61
62
  // ── Provider definitions ──────────────────────────────────────────────────────
63
+ // Model lists come from the shared catalog (#core/engines/presets.js) so the CLI
64
+ // and the web admin panel never drift. Ollama stays dynamic (fetched at runtime).
62
65
  const PROVIDERS = [
63
66
  {
64
67
  id: "anthropic",
@@ -66,7 +69,7 @@ const PROVIDERS = [
66
69
  needsKey: true,
67
70
  keyLabel: "Anthropic API key",
68
71
  keyHint: "sk-ant-...",
69
- models: ["claude-sonnet-4-5", "claude-haiku-4-5", "claude-opus-4-5"],
72
+ models: ENGINE_PRESETS.anthropic.known_models,
70
73
  },
71
74
  {
72
75
  id: "openai",
@@ -74,7 +77,7 @@ const PROVIDERS = [
74
77
  needsKey: true,
75
78
  keyLabel: "OpenAI API key",
76
79
  keyHint: "sk-...",
77
- models: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"],
80
+ models: ENGINE_PRESETS.openai.known_models,
78
81
  },
79
82
  {
80
83
  id: "ollama",
@@ -88,7 +91,7 @@ const PROVIDERS = [
88
91
  needsKey: true,
89
92
  keyLabel: "Gemini API key",
90
93
  keyHint: "AIza...",
91
- models: ["gemini-3.5-flash", "gemini-3.1-pro-preview", "gemini-2.5-flash"],
94
+ models: ENGINE_PRESETS.gemini.known_models,
92
95
  },
93
96
  ];
94
97