@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.
- 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 +62 -1
- package/src/core/config/secret-values.js +132 -0
- package/src/core/engines/mock.js +15 -1
- package/src/core/engines/presets.js +102 -0
- 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/engines.js +6 -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/commands/setup.js +6 -3
- 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/components/settings/providers/typeStyles.ts +44 -25
- 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/lib/api/engines.ts +14 -0
- package/src/interfaces/web/src/main.tsx +5 -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-CFcs16SV.js +0 -778
- 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 {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 =
|
|
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:
|
|
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) {
|
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
|
+
}
|