@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.
Files changed (49) 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/secret-values.js +132 -0
  19. package/src/core/engines/mock.js +15 -1
  20. package/src/core/logging.js +10 -3
  21. package/src/core/memory/compactor.js +65 -56
  22. package/src/core/memory/summarizer.js +125 -0
  23. package/src/core/stores/conversations-compactor.js +24 -31
  24. package/src/host/daemon/api/admin-config.js +5 -0
  25. package/src/host/daemon/api/artifact-preview.js +82 -0
  26. package/src/host/daemon/api/web.js +1 -1
  27. package/src/host/daemon/api.js +2 -0
  28. package/src/host/daemon/index.js +16 -1
  29. package/src/interfaces/acp/index.js +363 -0
  30. package/src/interfaces/acp/jsonrpc.js +180 -0
  31. package/src/interfaces/acp/session.js +205 -0
  32. package/src/interfaces/cli/commands/acp.js +10 -0
  33. package/src/interfaces/cli/commands/artifact.js +115 -0
  34. package/src/interfaces/cli/index.js +74 -0
  35. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js +803 -0
  36. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js.map +1 -0
  37. package/src/interfaces/web/dist/assets/index-BPGECxzm.css +1 -0
  38. package/src/interfaces/web/dist/index.html +2 -2
  39. package/src/interfaces/web/package-lock.json +6 -6
  40. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
  41. package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
  42. package/src/interfaces/web/src/i18n/en.ts +47 -0
  43. package/src/interfaces/web/src/i18n/es.ts +47 -0
  44. package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
  45. package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
  46. package/src/interfaces/web/src/types/daemon.ts +16 -0
  47. package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
  48. package/src/interfaces/web/dist/assets/index-YmMRG--4.js +0 -778
  49. package/src/interfaces/web/dist/assets/index-YmMRG--4.js.map +0 -1
@@ -0,0 +1,82 @@
1
+ // Artifact preview + sharing routes.
2
+ // POST /projects/:pid/artifacts/:name/preview body: { watch? }
3
+ // GET /projects/:pid/previews
4
+ // GET /previews
5
+ // DELETE /previews/:id
6
+ // POST /previews/:id/tunnel body: { provider? }
7
+ // DELETE /previews/:id/tunnel
8
+ //
9
+ // Preview servers and tunnels live in process-wide singletons (see
10
+ // #core/artifacts/preview.js and tunnel.js) so they survive across requests
11
+ // for the daemon's lifetime.
12
+ import { previews } from "#core/artifacts/preview.js";
13
+ import { tunnels, detectProviders } from "#core/artifacts/tunnel.js";
14
+
15
+ export function register(app, { project }) {
16
+ // Start (or reuse) an ephemeral preview server for an artifact.
17
+ app.post("/projects/:pid/artifacts/:name/preview", async (req, res) => {
18
+ const p = project(req, res);
19
+ if (!p) return;
20
+ const name = decodeURIComponent(req.params.name);
21
+ const watch = req.body?.watch !== false;
22
+ try {
23
+ const view = await previews.start({
24
+ storagePath: p.storagePath,
25
+ name,
26
+ projectId: p.id,
27
+ watch,
28
+ });
29
+ res.status(201).json(view);
30
+ } catch (e) {
31
+ res.status(400).json({ error: e.message });
32
+ }
33
+ });
34
+
35
+ // List preview servers scoped to a project.
36
+ app.get("/projects/:pid/previews", (req, res) => {
37
+ const p = project(req, res);
38
+ if (!p) return;
39
+ res.json(previews.list(p.id));
40
+ });
41
+
42
+ // List every live preview (all projects).
43
+ app.get("/previews", (_req, res) => {
44
+ res.json(previews.list());
45
+ });
46
+
47
+ // Which tunnel providers this host can use, best first.
48
+ app.get("/previews/tunnel-providers", (_req, res) => {
49
+ res.json({ providers: detectProviders() });
50
+ });
51
+
52
+ // Stop a preview server (also closes its tunnel).
53
+ app.delete("/previews/:id", async (req, res) => {
54
+ const rec = previews.get(req.params.id);
55
+ if (rec?.tunnel) tunnels.close(rec.tunnel.id);
56
+ const ok = await previews.stop(req.params.id);
57
+ res.status(ok ? 204 : 404).end();
58
+ });
59
+
60
+ // Open a public tunnel to a preview's local port.
61
+ app.post("/previews/:id/tunnel", async (req, res) => {
62
+ const rec = previews.get(req.params.id);
63
+ if (!rec) return res.status(404).json({ error: "preview not found" });
64
+ if (rec.tunnel) return res.json(rec.tunnel); // already shared
65
+ try {
66
+ const tunnel = await tunnels.open(rec.port, { provider: req.body?.provider });
67
+ previews.attachTunnel(rec.id, tunnel);
68
+ res.status(201).json(tunnel);
69
+ } catch (e) {
70
+ res.status(502).json({ error: e.message });
71
+ }
72
+ });
73
+
74
+ // Close a preview's tunnel but keep the local server running.
75
+ app.delete("/previews/:id/tunnel", (req, res) => {
76
+ const rec = previews.get(req.params.id);
77
+ if (!rec || !rec.tunnel) return res.status(404).end();
78
+ tunnels.close(rec.tunnel.id);
79
+ previews.attachTunnel(rec.id, null);
80
+ res.status(204).end();
81
+ });
82
+ }
@@ -24,7 +24,7 @@ const API_PREFIXES = [
24
24
  "/health", "/admin", "/projects", "/telegram", "/engines", "/runtimes",
25
25
  "/messages", "/sessions", "/tools", "/mcp", "/voice", "/tts", "/desktop", "/overlay",
26
26
  "/transcribe", "/run", "/files", "/memory", "/env", "/pair", "/deck",
27
- "/super-agent", "/identity",
27
+ "/super-agent", "/identity", "/skills",
28
28
  ];
29
29
 
30
30
  export function isApiPath(p) {
@@ -33,6 +33,7 @@ import { register as registerConnections } from "./api/connections.js";
33
33
  import { register as registerRuntimes } from "./api/runtimes.js";
34
34
  import { register as registerRoutines } from "./api/routines.js";
35
35
  import { register as registerArtifacts } from "./api/artifacts.js";
36
+ import { register as registerArtifactPreview } from "./api/artifact-preview.js";
36
37
  import { register as registerTasks } from "./api/tasks.js";
37
38
  import { register as registerOrganization } from "./api/organization.js";
38
39
  import { register as registerProjectFiles } from "./api/files-project.js";
@@ -124,6 +125,7 @@ export function buildApi({
124
125
  registerRuntimes(app, ctx);
125
126
  registerRoutines(app, ctx);
126
127
  registerArtifacts(app, ctx);
128
+ registerArtifactPreview(app, ctx);
127
129
  registerTasks(app, ctx);
128
130
  registerOrganization(app, ctx);
129
131
  registerProjectFiles(app, ctx);
@@ -15,6 +15,12 @@ import {
15
15
  APX_HOME,
16
16
  TOKEN_PATH,
17
17
  } from "#core/config/index.js";
18
+ import {
19
+ collectSecretValues,
20
+ collectMcpSecretValues,
21
+ registerSecretValues,
22
+ } from "#core/config/secret-values.js";
23
+ import { readGlobalMcps, readRuntimeMcps } from "#core/mcp/sources.js";
18
24
  import { ProjectManager } from "./db.js";
19
25
  import { McpRegistry } from "#core/mcp/runner.js";
20
26
  import { PluginManager } from "./plugins/index.js";
@@ -156,17 +162,26 @@ async function main() {
156
162
  const host = effectiveHost(cfg);
157
163
  const port = effectivePort(cfg);
158
164
 
165
+ // Value-based secret masking: register every known secret VALUE (engine
166
+ // keys, telegram tokens, MCP env/header tokens) so core/logging.js can
167
+ // scrub them from any log text they leak into. Refreshed on config PATCH
168
+ // in api/admin-config.js.
169
+ registerSecretValues(collectSecretValues(cfg));
170
+ registerSecretValues(collectMcpSecretValues(readGlobalMcps()));
171
+
159
172
  const projects = new ProjectManager(cfg);
160
173
  const registries = new RegistryCache();
161
174
 
162
175
  // Default project (id=0) is always available — no local .apc/ required.
163
- projects.registerDefault();
176
+ const defaultProject = projects.registerDefault();
177
+ registerSecretValues(collectMcpSecretValues(readRuntimeMcps(defaultProject.storagePath)));
164
178
 
165
179
  // Load registered projects from config.
166
180
  for (const entry of cfg.projects) {
167
181
  try {
168
182
  const p = projects.register(entry.path);
169
183
  registries.ensure(p);
184
+ registerSecretValues(collectMcpSecretValues(readRuntimeMcps(p.storagePath)));
170
185
  log(`loaded project #${p.id} ${p.path}`);
171
186
  } catch (e) {
172
187
  log(`skipping project ${entry.path}: ${e.message}`);
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+ // APX ACP agent — serves the APX super-agent over the Agent Client Protocol
3
+ // (https://agentclientprotocol.com, JSON-RPC 2.0 over newline-delimited JSON
4
+ // on stdio), so ACP clients (Zed, JetBrains, marimo, …) can drive it.
5
+ // Canonical launch: `apx acp` (bin alias `apx-acp` mirrors `apx-mcp`).
6
+ //
7
+ // Like the mcp-server surface, this is a thin adapter over the daemon HTTP
8
+ // API: each `session/prompt` becomes a POST to the NDJSON stream endpoint
9
+ // `/projects/:pid/super-agent/chat/stream` and the stream events are mapped
10
+ // onto ACP `session/update` notifications:
11
+ //
12
+ // assistant_text → agent_message_chunk
13
+ // tool_start → tool_call (status: in_progress)
14
+ // tool_result → tool_call_update (status: completed|failed)
15
+ // confirmation_required → session/request_permission round-trip, answered
16
+ // via POST /super-agent/confirm/:correlationId
17
+ // final → session/prompt response { stopReason }
18
+ //
19
+ // IMPORTANT: stdout carries the protocol. Never log to stdout here — errors
20
+ // go to stderr and the unified file log only.
21
+
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { randomUUID } from "node:crypto";
25
+ import { fileURLToPath, pathToFileURL } from "node:url";
26
+ import { CHANNELS } from "#core/constants/channels.js";
27
+ import { loggerFor } from "#core/logging.js";
28
+ import { APX_HOME } from "#core/config/index.js";
29
+ import { JsonRpcConnection, JsonRpcError, JSONRPC_ERROR_CODES } from "./jsonrpc.js";
30
+ import {
31
+ createDaemonClient,
32
+ createSession,
33
+ extractPromptText,
34
+ resolveProjectForCwd,
35
+ summarizeToolResult,
36
+ toolKindFor,
37
+ } from "./session.js";
38
+
39
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
+
41
+ // Single major version integer per the ACP initialization spec.
42
+ export const ACP_PROTOCOL_VERSION = 1;
43
+
44
+ const log = loggerFor("acp");
45
+
46
+ function packageVersion() {
47
+ try {
48
+ return JSON.parse(
49
+ fs.readFileSync(path.join(__dirname, "..", "..", "..", "package.json"), "utf8")
50
+ ).version;
51
+ } catch {
52
+ return "0.0.0";
53
+ }
54
+ }
55
+
56
+ export class AcpAgentServer {
57
+ /**
58
+ * @param {{ input: import("node:stream").Readable,
59
+ * output: import("node:stream").Writable,
60
+ * daemon: { baseUrl: string, token?: string | (() => string) },
61
+ * ensureDaemon?: (() => Promise<void>) | null,
62
+ * version?: string }} opts
63
+ * `daemon` and the streams are injectable so tests can run fully offline
64
+ * against an in-process daemon API and PassThrough pipes.
65
+ */
66
+ constructor({ input, output, daemon, ensureDaemon = null, version = packageVersion() }) {
67
+ this.version = version;
68
+ this.sessions = new Map();
69
+ this.clientCapabilities = {};
70
+ this.client = createDaemonClient({
71
+ baseUrl: daemon.baseUrl,
72
+ token: daemon.token || "",
73
+ ensureReady: ensureDaemon,
74
+ });
75
+ this.connection = new JsonRpcConnection({
76
+ input,
77
+ output,
78
+ onError: (e) => this.#logError("connection error", e),
79
+ });
80
+ }
81
+
82
+ start() {
83
+ this.connection
84
+ .method("initialize", (params) => this.#initialize(params))
85
+ // No auth methods are advertised, so authenticate is a no-op accept —
86
+ // daemon access control is the local bearer token, not an ACP concern.
87
+ .method("authenticate", () => ({}))
88
+ .method("session/new", (params) => this.#newSession(params))
89
+ .method("session/prompt", (params) => this.#prompt(params))
90
+ .method("session/cancel", (params) => this.#cancel(params));
91
+ return this;
92
+ }
93
+
94
+ whenClosed() {
95
+ return this.connection.whenClosed();
96
+ }
97
+
98
+ #logError(msg, err) {
99
+ const detail = err?.message || String(err || "");
100
+ try {
101
+ log.error(`${msg}: ${detail}`);
102
+ } catch {
103
+ /* file log is best-effort */
104
+ }
105
+ process.stderr.write(`apx acp: ${msg}: ${detail}\n`);
106
+ }
107
+
108
+ #initialize(params) {
109
+ const requested = Number(params?.protocolVersion);
110
+ this.clientCapabilities = params?.clientCapabilities || {};
111
+ return {
112
+ // Same version when we support what the client asked for, otherwise the
113
+ // latest we do support — the client decides whether to disconnect.
114
+ protocolVersion:
115
+ requested === ACP_PROTOCOL_VERSION ? requested : ACP_PROTOCOL_VERSION,
116
+ agentCapabilities: {
117
+ loadSession: false,
118
+ promptCapabilities: { image: false, audio: false, embeddedContext: false },
119
+ mcpCapabilities: { http: false, sse: false },
120
+ },
121
+ agentInfo: { name: "apx", title: "APX Super-Agent", version: this.version },
122
+ authMethods: [],
123
+ };
124
+ }
125
+
126
+ async #newSession(params) {
127
+ const cwd = params?.cwd;
128
+ if (!cwd || typeof cwd !== "string" || !path.isAbsolute(cwd)) {
129
+ throw new JsonRpcError(
130
+ JSONRPC_ERROR_CODES.INVALID_PARAMS,
131
+ "cwd must be an absolute path"
132
+ );
133
+ }
134
+ let project;
135
+ try {
136
+ project = await resolveProjectForCwd(this.client, cwd);
137
+ } catch (e) {
138
+ throw new JsonRpcError(JSONRPC_ERROR_CODES.INTERNAL_ERROR, e.message);
139
+ }
140
+ const sessionId = `sess_${randomUUID().replace(/-/g, "")}`;
141
+ this.sessions.set(sessionId, createSession({ id: sessionId, project, cwd }));
142
+ return { sessionId };
143
+ }
144
+
145
+ #session(params) {
146
+ const session = this.sessions.get(params?.sessionId);
147
+ if (!session) {
148
+ throw new JsonRpcError(
149
+ JSONRPC_ERROR_CODES.INVALID_PARAMS,
150
+ `unknown sessionId: ${params?.sessionId}`
151
+ );
152
+ }
153
+ return session;
154
+ }
155
+
156
+ async #prompt(params) {
157
+ const session = this.#session(params);
158
+ if (session.activeTurn) {
159
+ throw new JsonRpcError(
160
+ JSONRPC_ERROR_CODES.INVALID_REQUEST,
161
+ "a prompt turn is already in progress for this session"
162
+ );
163
+ }
164
+ const promptText = extractPromptText(params.prompt);
165
+ if (!promptText) {
166
+ throw new JsonRpcError(
167
+ JSONRPC_ERROR_CODES.INVALID_PARAMS,
168
+ "prompt must include at least one text content block"
169
+ );
170
+ }
171
+
172
+ const turn = {
173
+ abort: new AbortController(),
174
+ cancelled: false,
175
+ lastToolCallId: null,
176
+ messageSeq: 0,
177
+ sentTexts: new Set(),
178
+ };
179
+ session.activeTurn = turn;
180
+ try {
181
+ const final = await this.client.streamPost(
182
+ `/projects/${session.project.id}/super-agent/chat/stream`,
183
+ {
184
+ prompt: promptText,
185
+ // ACP clients are coding surfaces (IDEs) — the `code` channel gives
186
+ // them the coding system prompt + git/code tools, same as apx code.
187
+ channel: CHANNELS.CODE,
188
+ previousMessages: session.history,
189
+ },
190
+ (event) => this.#onDaemonEvent(session, turn, event),
191
+ { signal: turn.abort.signal }
192
+ );
193
+ if (turn.cancelled) return { stopReason: "cancelled" };
194
+ if (!final) {
195
+ throw new JsonRpcError(
196
+ JSONRPC_ERROR_CODES.INTERNAL_ERROR,
197
+ "daemon stream ended without a final result"
198
+ );
199
+ }
200
+ // In-process history feeds previousMessages on the next turn, so the
201
+ // conversation keeps context without daemon-side session storage.
202
+ session.history.push({ role: "user", content: promptText });
203
+ if (final.text) session.history.push({ role: "assistant", content: final.text });
204
+ return { stopReason: "end_turn" };
205
+ } catch (e) {
206
+ // Aborts surface as generic stream errors — per spec, cancellation MUST
207
+ // resolve the prompt with the "cancelled" stop reason, never an error.
208
+ if (turn.cancelled) return { stopReason: "cancelled" };
209
+ if (e instanceof JsonRpcError) throw e;
210
+ throw new JsonRpcError(JSONRPC_ERROR_CODES.INTERNAL_ERROR, e.message);
211
+ } finally {
212
+ session.activeTurn = null;
213
+ }
214
+ }
215
+
216
+ // session/cancel is a notification — no response, just abort the in-flight
217
+ // daemon stream; #prompt observes `cancelled` and resolves the turn.
218
+ #cancel(params) {
219
+ const session = this.sessions.get(params?.sessionId);
220
+ const turn = session?.activeTurn;
221
+ if (!turn) return;
222
+ turn.cancelled = true;
223
+ turn.abort.abort();
224
+ }
225
+
226
+ #notifyUpdate(session, update) {
227
+ this.connection.notify("session/update", { sessionId: session.id, update });
228
+ }
229
+
230
+ #sendMessageChunk(session, turn, text) {
231
+ const value = String(text || "");
232
+ if (!value || turn.sentTexts.has(value)) return;
233
+ turn.sentTexts.add(value);
234
+ turn.messageSeq += 1;
235
+ this.#notifyUpdate(session, {
236
+ sessionUpdate: "agent_message_chunk",
237
+ messageId: `msg_${session.seq}_${turn.messageSeq}`,
238
+ content: { type: "text", text: value },
239
+ });
240
+ }
241
+
242
+ async #onDaemonEvent(session, turn, event) {
243
+ switch (event?.type) {
244
+ case "assistant_text":
245
+ return this.#sendMessageChunk(session, turn, event.text);
246
+ // The loop only emits assistant_text for mid-turn progress; the closing
247
+ // reply usually travels solely inside the final result, so surface it
248
+ // as a chunk here (deduped against anything already streamed).
249
+ case "final":
250
+ return this.#sendMessageChunk(session, turn, event.result?.text);
251
+ case "tool_start": {
252
+ const trace = event.trace || {};
253
+ const toolCallId = String(trace.id || `call_${randomUUID().slice(0, 8)}`);
254
+ turn.lastToolCallId = toolCallId;
255
+ this.#notifyUpdate(session, {
256
+ sessionUpdate: "tool_call",
257
+ toolCallId,
258
+ title: String(trace.tool || "tool"),
259
+ kind: toolKindFor(trace.tool),
260
+ status: "in_progress",
261
+ ...(trace.args && typeof trace.args === "object" ? { rawInput: trace.args } : {}),
262
+ });
263
+ return;
264
+ }
265
+ case "tool_result": {
266
+ const trace = event.trace || {};
267
+ const failed =
268
+ trace.result && typeof trace.result === "object" && trace.result.error != null;
269
+ this.#notifyUpdate(session, {
270
+ sessionUpdate: "tool_call_update",
271
+ toolCallId: String(trace.id || turn.lastToolCallId || "call_unknown"),
272
+ status: failed ? "failed" : "completed",
273
+ content: [
274
+ {
275
+ type: "content",
276
+ content: { type: "text", text: summarizeToolResult(trace.result) },
277
+ },
278
+ ],
279
+ ...(trace.result && typeof trace.result === "object"
280
+ ? { rawOutput: trace.result }
281
+ : {}),
282
+ });
283
+ return;
284
+ }
285
+ case "confirmation_required":
286
+ return this.#requestPermission(session, turn, event);
287
+ default:
288
+ // model_start, model_routed, skill_inspector, … are APX-internal
289
+ // progress events with no ACP counterpart.
290
+ return;
291
+ }
292
+ }
293
+
294
+ async #requestPermission(session, turn, event) {
295
+ let confirmed = false;
296
+ try {
297
+ const res = await this.connection.request("session/request_permission", {
298
+ sessionId: session.id,
299
+ toolCall: {
300
+ toolCallId: turn.lastToolCallId || String(event.correlationId),
301
+ title: String(event.description || event.tool || "tool call"),
302
+ },
303
+ options: [
304
+ { optionId: "allow", name: "Allow", kind: "allow_once" },
305
+ { optionId: "reject", name: "Reject", kind: "reject_once" },
306
+ ],
307
+ });
308
+ const outcome = res?.outcome;
309
+ confirmed = outcome?.outcome === "selected" && outcome.optionId === "allow";
310
+ } catch (e) {
311
+ // Client gone or errored — never self-approve.
312
+ this.#logError("permission request failed", e);
313
+ confirmed = false;
314
+ }
315
+ try {
316
+ await this.client.post(
317
+ `/super-agent/confirm/${encodeURIComponent(event.correlationId)}`,
318
+ { confirmed }
319
+ );
320
+ } catch (e) {
321
+ // The pending-store entry times out on its own; log and move on.
322
+ this.#logError("confirm resolution failed", e);
323
+ }
324
+ }
325
+ }
326
+
327
+ /** Run the ACP agent on the current process stdio against the local daemon. */
328
+ export async function startStdioAcpServer() {
329
+ // Lazy import: the CLI http helper auto-starts the daemon; tests never
330
+ // reach this path (they inject their own base URL).
331
+ const { ensureDaemon } = await import("#interfaces/cli/http.js");
332
+ const host = process.env.APX_HOST || "127.0.0.1";
333
+ const port = parseInt(process.env.APX_PORT || "7430", 10);
334
+ const tokenPath = path.join(APX_HOME, "daemon.token");
335
+ const server = new AcpAgentServer({
336
+ input: process.stdin,
337
+ output: process.stdout,
338
+ daemon: {
339
+ baseUrl: `http://${host}:${port}`,
340
+ // Re-read per request so a daemon restart with a rotated token mid-
341
+ // session keeps working.
342
+ token: () => {
343
+ try {
344
+ return fs.readFileSync(tokenPath, "utf8").trim();
345
+ } catch {
346
+ return "";
347
+ }
348
+ },
349
+ },
350
+ ensureDaemon: () => ensureDaemon({ silent: true }),
351
+ });
352
+ server.start();
353
+ await server.whenClosed();
354
+ }
355
+
356
+ const isMain =
357
+ process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
358
+ if (isMain) {
359
+ startStdioAcpServer().catch((e) => {
360
+ process.stderr.write(`apx acp: fatal: ${e?.message || e}\n`);
361
+ process.exit(1);
362
+ });
363
+ }
@@ -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
+ }