@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,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
+ }
@@ -91,6 +91,7 @@ import {
91
91
  cmdConversationsGet,
92
92
  } from "./commands/chat.js";
93
93
  import { cmdCode } from "./commands/code.js";
94
+ import { cmdAcp } from "./commands/acp.js";
94
95
  import { cmdRun, cmdEnvDetect } from "./commands/runtime.js";
95
96
  import { cmdSend, cmdConnections } from "./commands/a2a.js";
96
97
  import {
@@ -130,6 +131,10 @@ import {
130
131
  cmdArtifactShow,
131
132
  cmdArtifactRemove,
132
133
  cmdArtifactRun,
134
+ cmdArtifactPreview,
135
+ cmdArtifactShare,
136
+ cmdArtifactPreviews,
137
+ cmdArtifactStop,
133
138
  } from "./commands/artifact.js";
134
139
  import {
135
140
  cmdTaskAdd,
@@ -1133,6 +1138,17 @@ const HELP_TOPICS = new Map(Object.entries({
1133
1138
  ],
1134
1139
  examples: ["apx chat reviewer", "apx chat reviewer --conversation abc123"],
1135
1140
  }),
1141
+ acp: topic({
1142
+ title: "apx acp",
1143
+ summary: "Serve the APX super-agent over the Agent Client Protocol (ACP) on stdio.",
1144
+ usage: ["apx acp"],
1145
+ notes: [
1146
+ "For ACP clients (Zed, JetBrains, marimo, …) that spawn agents as subprocesses.",
1147
+ "stdout carries the protocol — logs go to stderr and ~/.apx/logs/apx.log.",
1148
+ "Sessions resolve the APC project from the client's workspace cwd.",
1149
+ ],
1150
+ examples: ["apx acp"],
1151
+ }),
1136
1152
  code: topic({
1137
1153
  title: "apx code",
1138
1154
  summary: "Start the APX terminal coding assistant with system and workspace context.",
@@ -1315,6 +1331,10 @@ const HELP_TOPICS = new Map(Object.entries({
1315
1331
  ["list | ls", "List artifacts in the project."],
1316
1332
  ["show <name>", "Print artifact content."],
1317
1333
  ["run <name> [args...]", "Execute a runnable artifact (shebang or +x). Stdio is inherited."],
1334
+ ["preview <name> [--open] [--share]", "Serve HTML/React/static artifacts on an ephemeral local server with live-reload."],
1335
+ ["share <name> [--open]", "Preview + open a public tunnel URL (cloudflared/localtunnel)."],
1336
+ ["previews", "List running preview servers."],
1337
+ ["stop <id> | --all", "Stop a preview server."],
1318
1338
  ["remove | rm <name>", "Delete an artifact."],
1319
1339
  ],
1320
1340
  examples: [
@@ -1322,8 +1342,49 @@ const HELP_TOPICS = new Map(Object.entries({
1322
1342
  "apx artifact list",
1323
1343
  "apx artifact show check_asana.sh",
1324
1344
  "apx artifact run check_asana.sh",
1345
+ "apx artifact preview dashboard.html --open",
1346
+ "apx artifact share report.jsx",
1347
+ ],
1348
+ }),
1349
+ "artifact preview": topic({
1350
+ title: "apx artifact preview",
1351
+ summary: "Serve an artifact on an ephemeral local web server and print an interactive link. HTML, single-file React (.jsx/.tsx), static dirs, and text are all rendered; the page auto-reloads when the file changes.",
1352
+ usage: ["apx artifact preview <name> [--open] [--share] [--no-watch] [--project <name|id|path>]"],
1353
+ options: [
1354
+ ["--open", "Open the local URL in your default browser."],
1355
+ ["--share", "Also create a public tunnel URL for quick sharing."],
1356
+ ["--no-watch", "Disable live-reload on file change."],
1357
+ ["--project <name|id|path>", "Pin command to a specific project."],
1358
+ ],
1359
+ examples: [
1360
+ "apx artifact preview dashboard.html --open",
1361
+ "apx artifact preview app.jsx --share",
1325
1362
  ],
1326
1363
  }),
1364
+ "artifact share": topic({
1365
+ title: "apx artifact share",
1366
+ summary: "Preview an artifact and expose it through a secure public tunnel (cloudflared, falling back to localtunnel). Prints a temporary public URL anyone can open.",
1367
+ usage: ["apx artifact share <name> [--open] [--no-watch] [--project <name|id|path>]"],
1368
+ options: [
1369
+ ["--open", "Open the public URL in your default browser."],
1370
+ ["--no-watch", "Disable live-reload on file change."],
1371
+ ["--project <name|id|path>", "Pin command to a specific project."],
1372
+ ],
1373
+ examples: ["apx artifact share report.jsx --open"],
1374
+ }),
1375
+ "artifact previews": topic({
1376
+ title: "apx artifact previews",
1377
+ summary: "List running artifact preview servers (id, name, kind, local + public URLs).",
1378
+ usage: ["apx artifact previews"],
1379
+ examples: ["apx artifact previews"],
1380
+ }),
1381
+ "artifact stop": topic({
1382
+ title: "apx artifact stop",
1383
+ summary: "Stop a running preview server (and its tunnel).",
1384
+ usage: ["apx artifact stop <id>", "apx artifact stop --all"],
1385
+ options: [["--all", "Stop every running preview."]],
1386
+ examples: ["apx artifact stop 3f9a1c2b", "apx artifact stop --all"],
1387
+ }),
1327
1388
  "artifact create": topic({
1328
1389
  title: "apx artifact create",
1329
1390
  summary: "Create a new managed artifact file.",
@@ -2099,6 +2160,7 @@ function buildHelp(version) {
2099
2160
  hCmd("apx search \"query\"", 36, "web search (ddg | brave | browser) --mode <m> -n N"),
2100
2161
  hCmd("apx conversations list", 36, "stored exec/chat conversations for <agent>"),
2101
2162
  hCmd("apx conversations get", 36, "<agent> <id>"),
2163
+ hCmd("apx acp", 36, "serve the super-agent over the Agent Client Protocol (stdio, for IDEs)"),
2102
2164
 
2103
2165
  hSec("Runtimes"),
2104
2166
  hCmd("apx run <agent>", 36, "--runtime <id> \"prompt\" --timeout <s>"),
@@ -2137,6 +2199,9 @@ function buildHelp(version) {
2137
2199
  hCmd("apx artifact create <name>", 36, "create managed file in project storage [--content '...'] [--project 0]"),
2138
2200
  hCmd("apx artifact list", 36, "list artifacts"),
2139
2201
  hCmd("apx artifact show <name>", 36, "print artifact content"),
2202
+ hCmd("apx artifact preview <name>",36, "serve HTML/React on a local URL w/ live-reload [--open] [--share]"),
2203
+ hCmd("apx artifact share <name>", 36, "preview + public tunnel URL"),
2204
+ hCmd("apx artifact previews", 36, "list running previews (stop with: apx artifact stop <id>)"),
2140
2205
  hCmd("apx artifact remove <name>", 36, ""),
2141
2206
 
2142
2207
  hSec("Commands & Skills"),
@@ -2516,6 +2581,11 @@ async function dispatch(cmd, rest) {
2516
2581
  await cmdExec(parseArgs(rest));
2517
2582
  break;
2518
2583
 
2584
+ case "acp":
2585
+ // ACP server owns stdio until the client closes the pipe.
2586
+ await cmdAcp(parseArgs(rest));
2587
+ return;
2588
+
2519
2589
  case "search":
2520
2590
  await cmdSearch(parseArgs(rest));
2521
2591
  break;
@@ -2613,6 +2683,10 @@ async function dispatch(cmd, rest) {
2613
2683
  else if (sub === "show" || sub === "get") await cmdArtifactShow(a);
2614
2684
  else if (sub === "remove" || sub === "rm") await cmdArtifactRemove(a);
2615
2685
  else if (sub === "run") await cmdArtifactRun(a);
2686
+ else if (sub === "preview" || sub === "serve") await cmdArtifactPreview(a);
2687
+ else if (sub === "share") await cmdArtifactShare(a);
2688
+ else if (sub === "previews") await cmdArtifactPreviews(a);
2689
+ else if (sub === "stop") await cmdArtifactStop(a);
2616
2690
  else die(`unknown artifact subcommand: ${sub}`);
2617
2691
  break;
2618
2692
  }