@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
@@ -25,30 +25,22 @@
25
25
  import fs from "node:fs";
26
26
  import path from "node:path";
27
27
  import { parseConversation } from "./conversations.js";
28
- import { callEngine } from "#core/engines/index.js";
28
+ import {
29
+ renderEvents,
30
+ buildCondenserPrompt,
31
+ summarizeStructured,
32
+ } from "#core/memory/summarizer.js";
29
33
 
30
34
  const KEEP_LAST = 6;
31
35
 
32
36
  const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
33
37
 
34
- const COMPACT_SYSTEM =
35
- "You summarize conversations for AI agent context continuity. " +
36
- "Be dense and factual another AI will read this to continue the work.";
37
-
38
- const COMPACT_PROMPT = `Summarize this conversation for future context.
39
-
40
- Cover:
41
- - Main task or goal being worked on
42
- - Key decisions made and why
43
- - Files, code, commands modified (exact paths where relevant)
44
- - Current state: what's done, what's pending or unresolved
45
- - Errors encountered and how they were resolved
46
-
47
- Style: dense and factual. No pleasantries. No meta-commentary. Just the facts.
48
-
49
- ---
50
-
51
- `;
38
+ // Map a parsed conversation role to the summarizer's normalized event role.
39
+ function toEventRole(role) {
40
+ if (role === "user") return "user";
41
+ if (role === "tool") return "tool";
42
+ return "assistant"; // assistant / system assistant side
43
+ }
52
44
 
53
45
  // Resolve the most-recent conversation file for an agent, or the one explicitly
54
46
  // named. Returns the full filepath.
@@ -90,19 +82,21 @@ export async function compactConversation({
90
82
  const realTurns = turns.filter((t) => t.role !== "compact");
91
83
  if (realTurns.length === 0) throw new Error("nothing to compact — no user/assistant turns");
92
84
 
93
- // Build a readable transcript for the model.
94
- const transcript = realTurns
95
- .map((t) => `[${t.role.toUpperCase()}]\n${t.content}`)
96
- .join("\n\n---\n\n");
97
-
98
- const result = await callEngine({
99
- modelId,
100
- system: COMPACT_SYSTEM,
101
- messages: [{ role: "user", content: COMPACT_PROMPT + transcript }],
85
+ // Same summarizer service as the automatic condenser (structured state),
86
+ // just a different store/entry point. The whole conversation is condensed
87
+ // (no previous-summary threading here — a fresh compact per call).
88
+ const eventsBlock = renderEvents(
89
+ realTurns.map((t) => ({ role: toEventRole(t.role), content: t.content }))
90
+ );
91
+ const prompt = buildCondenserPrompt({ eventsBlock });
92
+ const out = await summarizeStructured({
93
+ prompt,
94
+ models: { primary: modelId, fallback: config?.super_agent?.model || "" },
102
95
  config,
103
96
  });
97
+ if (!out) throw new Error("compaction failed — no model produced a summary");
104
98
 
105
- const summary = result.text.trim();
99
+ const summary = out.text;
106
100
  const ts = nowIso();
107
101
  const turnCount = realTurns.length;
108
102
 
@@ -133,8 +127,7 @@ export async function compactConversation({
133
127
  filename: path.basename(filepath),
134
128
  compacted_turns: turnCount,
135
129
  kept_turns: recentTurns.length,
136
- model: modelId,
130
+ model: out.model,
137
131
  summary,
138
- usage: result.usage,
139
132
  };
140
133
  }
@@ -14,6 +14,7 @@ import {
14
14
  isSecretMarker,
15
15
  mergeRedactedChannels,
16
16
  } from "#core/config/redact.js";
17
+ import { collectSecretValues, registerSecretValues } from "#core/config/secret-values.js";
17
18
 
18
19
  export function register(app, { config, scheduler, plugins }) {
19
20
  app.get("/admin/config", (_req, res) => {
@@ -57,6 +58,10 @@ export function register(app, { config, scheduler, plugins }) {
57
58
  const fresh = readConfig();
58
59
  for (const key of Object.keys(config)) delete config[key];
59
60
  Object.assign(config, fresh);
61
+ // Keep the log-masking registry current: any secret just added via PATCH
62
+ // must be masked from this point on (registry is additive — removed
63
+ // secrets stay masked, which is the safe direction).
64
+ registerSecretValues(collectSecretValues(fresh));
60
65
  if (scheduler) scheduler.globalConfig = config;
61
66
  if (plugins) plugins.config = config;
62
67
  res.json({ ok: true, config: redact(fresh) });
@@ -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
+ }
@@ -1,12 +1,18 @@
1
1
  // GET /engines — list engine adapter ids known to core/engines.
2
+ // GET /engines/presets — curated catalog (known models, defaults) per engine.
2
3
  // POST /engines/models — live model catalog from a provider.
3
4
  // GET /engines/models — legacy (Ollama only, no auth).
4
5
  import { ENGINE_IDS } from "#core/engines/index.js";
5
6
  import { listModels } from "#core/engines/catalog.js";
7
+ import { ENGINE_PRESETS } from "#core/engines/presets.js";
6
8
 
7
9
  export function register(app, { config }) {
8
10
  app.get("/engines", (_req, res) => res.json({ engines: ENGINE_IDS }));
9
11
 
12
+ // Curated fallback catalog shared with the CLI wizard. The web hydrates its
13
+ // provider forms from here so model lists never drift between surfaces.
14
+ app.get("/engines/presets", (_req, res) => res.json({ presets: ENGINE_PRESETS }));
15
+
10
16
  app.post("/engines/models", async (req, res) => {
11
17
  const b = req.body || {};
12
18
  const engine = String(b.engine || "").toLowerCase();
@@ -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
+ }