@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.
- package/package.json +3 -2
- package/skills/apx/SKILL.md +3 -0
- package/src/core/agent/index.js +2 -0
- package/src/core/agent/judge.js +174 -0
- package/src/core/agent/model-router.js +107 -5
- package/src/core/agent/prompts/modes/code-build.md +1 -1
- package/src/core/agent/run-agent.js +149 -12
- package/src/core/agent/security.js +97 -0
- package/src/core/agent/stuck-detector.js +89 -0
- package/src/core/agent/super-agent.js +58 -17
- package/src/core/agent/tools/handlers/run-subagent.js +117 -0
- package/src/core/agent/tools/helpers.js +11 -1
- package/src/core/agent/tools/names.js +2 -0
- package/src/core/agent/tools/registry.js +10 -0
- package/src/core/artifacts/preview.js +392 -0
- package/src/core/artifacts/tunnel.js +169 -0
- package/src/core/config/index.js +61 -0
- package/src/core/config/secret-values.js +132 -0
- package/src/core/engines/mock.js +15 -1
- package/src/core/logging.js +10 -3
- package/src/core/memory/compactor.js +65 -56
- package/src/core/memory/summarizer.js +125 -0
- package/src/core/stores/conversations-compactor.js +24 -31
- package/src/host/daemon/api/admin-config.js +5 -0
- package/src/host/daemon/api/artifact-preview.js +82 -0
- package/src/host/daemon/api/web.js +1 -1
- package/src/host/daemon/api.js +2 -0
- package/src/host/daemon/index.js +16 -1
- package/src/interfaces/acp/index.js +363 -0
- package/src/interfaces/acp/jsonrpc.js +180 -0
- package/src/interfaces/acp/session.js +205 -0
- package/src/interfaces/cli/commands/acp.js +10 -0
- package/src/interfaces/cli/commands/artifact.js +115 -0
- package/src/interfaces/cli/index.js +74 -0
- package/src/interfaces/web/dist/assets/index-B3pEwe1m.js +803 -0
- package/src/interfaces/web/dist/assets/index-B3pEwe1m.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-BPGECxzm.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +6 -6
- package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
- package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
- package/src/interfaces/web/src/i18n/en.ts +47 -0
- package/src/interfaces/web/src/i18n/es.ts +47 -0
- package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
- package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
- package/src/interfaces/web/src/types/daemon.ts +16 -0
- package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
- package/src/interfaces/web/dist/assets/index-YmMRG--4.js +0 -778
- 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) {
|
package/src/host/daemon/api.js
CHANGED
|
@@ -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);
|
package/src/host/daemon/index.js
CHANGED
|
@@ -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
|
+
}
|