@yuandc/aica 0.1.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/README.md +9 -0
- package/dist/acp/agent.js +54 -0
- package/dist/acp/client/acp-client.js +102 -0
- package/dist/acp/client/acp-content.js +13 -0
- package/dist/acp/client/acp-events.js +106 -0
- package/dist/acp/client/acp-process.js +34 -0
- package/dist/acp/client/acp-runtime-pool.js +248 -0
- package/dist/acp/client/context-usage.js +29 -0
- package/dist/acp/client/json-rpc.js +128 -0
- package/dist/acp/provider-types.js +1 -0
- package/dist/acp/providers/codex/codex-process.js +51 -0
- package/dist/acp/providers/codex/events.js +1473 -0
- package/dist/acp/providers/codex/permissions.js +49 -0
- package/dist/acp/providers/codex/provider.js +376 -0
- package/dist/acp/providers/codex-acp/adapter.js +947 -0
- package/dist/acp/providers/codex-acp/context-maintenance.js +148 -0
- package/dist/acp/providers/codex-acp/launch.js +35 -0
- package/dist/acp/providers/codex-acp/provider.js +486 -0
- package/dist/acp/providers/mimo/provider.js +448 -0
- package/dist/acp/providers/opencode/provider.js +489 -0
- package/dist/acp/providers/registry.js +23 -0
- package/dist/acp/standard-events.js +167 -0
- package/dist/commands/start.js +137 -0
- package/dist/commands/worker-auth.js +100 -0
- package/dist/commands/worker-project.js +57 -0
- package/dist/core/aca-config.js +74 -0
- package/dist/core/aca-server-client.js +57 -0
- package/dist/core/acp-event-coalescer.js +108 -0
- package/dist/core/acp-event-upload-filter.js +16 -0
- package/dist/core/acp-orphan-cleanup.js +91 -0
- package/dist/core/affected-files.js +268 -0
- package/dist/core/auth.js +36 -0
- package/dist/core/file-transfer-worker.js +169 -0
- package/dist/core/fs.js +28 -0
- package/dist/core/heartbeat.js +578 -0
- package/dist/core/job-permission-policy.js +42 -0
- package/dist/core/job-worker.js +749 -0
- package/dist/core/logger.js +42 -0
- package/dist/core/long-poll-worker.js +26 -0
- package/dist/core/machine-filesystem-worker.js +352 -0
- package/dist/core/paths.js +26 -0
- package/dist/core/process-identity.js +34 -0
- package/dist/core/process.js +33 -0
- package/dist/core/provider-health.js +54 -0
- package/dist/core/runtime-options.js +38 -0
- package/dist/core/worktree.js +95 -0
- package/dist/worker-cli.js +27 -0
- package/dist/worker-single-cli.js +17 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# AICA
|
|
2
|
+
|
|
3
|
+
AICA connects a machine to ACA Server and executes remote Agent jobs.
|
|
4
|
+
|
|
5
|
+
Run directly with `npx @yuandc/aica`, or install globally with `npm install -g @yuandc/aica`. The installed command is `aica`.
|
|
6
|
+
|
|
7
|
+
Runtime configuration is stored in `~/.aica` by default and can be overridden with `--aica-home` or `AICA_HOME`.
|
|
8
|
+
|
|
9
|
+
The package does not include local Session storage or better-sqlite3.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createAgentProvider } from "./providers/registry.js";
|
|
3
|
+
export async function sendAgentPrompt(input) {
|
|
4
|
+
if (!fs.existsSync(input.cwd) || !fs.statSync(input.cwd).isDirectory()) {
|
|
5
|
+
throw new Error(`Project root does not exist or is not a directory: ${input.cwd}`);
|
|
6
|
+
}
|
|
7
|
+
const provider = createAgentProvider({
|
|
8
|
+
agentType: input.agentType || "codex",
|
|
9
|
+
cliType: input.cliType || "builtin"
|
|
10
|
+
});
|
|
11
|
+
const session = await provider.createSession({
|
|
12
|
+
cwd: input.cwd,
|
|
13
|
+
providerSessionId: input.acpSessionId ?? null,
|
|
14
|
+
timeoutMs: input.timeoutMs
|
|
15
|
+
});
|
|
16
|
+
try {
|
|
17
|
+
const result = await session.sendPrompt({
|
|
18
|
+
...input,
|
|
19
|
+
providerSessionId: input.acpSessionId ?? null
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
...result,
|
|
23
|
+
acpSessionId: result.providerSessionId
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
await session.close().catch(() => void 0);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function compactAgentContext(input) {
|
|
31
|
+
const provider = createAgentProvider({
|
|
32
|
+
agentType: input.agentType || "codex",
|
|
33
|
+
cliType: input.cliType || "builtin"
|
|
34
|
+
});
|
|
35
|
+
const session = await provider.createSession({
|
|
36
|
+
cwd: input.cwd,
|
|
37
|
+
providerSessionId: input.acpSessionId,
|
|
38
|
+
timeoutMs: input.timeoutMs
|
|
39
|
+
});
|
|
40
|
+
if (!session.compactContext) {
|
|
41
|
+
await session.close().catch(() => void 0);
|
|
42
|
+
throw new Error(`Provider does not support context compaction: ${input.agentType || "codex"}`);
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const result = await session.compactContext(input);
|
|
46
|
+
return {
|
|
47
|
+
...result,
|
|
48
|
+
acpSessionId: result.providerSessionId
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await session.close().catch(() => void 0);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { JsonLineRpcClient } from "./json-rpc.js";
|
|
2
|
+
export class AcpClient {
|
|
3
|
+
onSessionUpdate;
|
|
4
|
+
onClientRequest;
|
|
5
|
+
rpc;
|
|
6
|
+
constructor(child, onSessionUpdate, onClientRequest) {
|
|
7
|
+
this.onSessionUpdate = onSessionUpdate;
|
|
8
|
+
this.onClientRequest = onClientRequest;
|
|
9
|
+
this.rpc = new JsonLineRpcClient(child, {
|
|
10
|
+
peerName: "ACP server",
|
|
11
|
+
includeJsonRpc: true,
|
|
12
|
+
onNotification: (method, params, message) => {
|
|
13
|
+
if (method === "session/update")
|
|
14
|
+
this.onSessionUpdate(params, message);
|
|
15
|
+
},
|
|
16
|
+
onRequest: async (method, params, message) => {
|
|
17
|
+
if (this.onClientRequest)
|
|
18
|
+
return this.onClientRequest(method, params, message);
|
|
19
|
+
throw new Error(`Unsupported ACP client request: ${method}`);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
initialize(timeoutMs = 60_000) {
|
|
24
|
+
return this.rpc.request("initialize", {
|
|
25
|
+
protocolVersion: 1,
|
|
26
|
+
clientCapabilities: {
|
|
27
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
28
|
+
terminal: false
|
|
29
|
+
},
|
|
30
|
+
clientInfo: { name: "aca", version: "0.1.0" }
|
|
31
|
+
}, timeoutMs);
|
|
32
|
+
}
|
|
33
|
+
newSession(input, timeoutMs = 120_000) {
|
|
34
|
+
return this.rpc.request("session/new", {
|
|
35
|
+
cwd: input.cwd,
|
|
36
|
+
mcpServers: input.mcpServers ?? [],
|
|
37
|
+
...(input.config ? { config: input.config } : {})
|
|
38
|
+
}, timeoutMs);
|
|
39
|
+
}
|
|
40
|
+
resumeSession(input, timeoutMs = 120_000) {
|
|
41
|
+
return this.rpc.request("session/resume", {
|
|
42
|
+
sessionId: input.sessionId,
|
|
43
|
+
cwd: input.cwd,
|
|
44
|
+
mcpServers: input.mcpServers ?? [],
|
|
45
|
+
...(input.config ? { config: input.config } : {})
|
|
46
|
+
}, timeoutMs);
|
|
47
|
+
}
|
|
48
|
+
loadSession(input, timeoutMs = 120_000) {
|
|
49
|
+
return this.rpc.request("session/load", {
|
|
50
|
+
sessionId: input.sessionId,
|
|
51
|
+
cwd: input.cwd,
|
|
52
|
+
mcpServers: input.mcpServers ?? [],
|
|
53
|
+
...(input.config ? { config: input.config } : {})
|
|
54
|
+
}, timeoutMs);
|
|
55
|
+
}
|
|
56
|
+
closeSession(sessionId, timeoutMs = 30_000) {
|
|
57
|
+
return this.rpc.request("session/close", { sessionId }, timeoutMs);
|
|
58
|
+
}
|
|
59
|
+
listSessions(params = {}, timeoutMs = 60_000) {
|
|
60
|
+
return this.rpc.request("session/list", params, timeoutMs);
|
|
61
|
+
}
|
|
62
|
+
deleteSession(sessionId, timeoutMs = 60_000) {
|
|
63
|
+
return this.rpc.request("session/delete", { sessionId }, timeoutMs);
|
|
64
|
+
}
|
|
65
|
+
forkSession(input, timeoutMs = 120_000) {
|
|
66
|
+
return this.rpc.request("session/fork", {
|
|
67
|
+
sessionId: input.sessionId,
|
|
68
|
+
...(input.cwd ? { cwd: input.cwd } : {}),
|
|
69
|
+
...(input.mcpServers ? { mcpServers: input.mcpServers } : {})
|
|
70
|
+
}, timeoutMs);
|
|
71
|
+
}
|
|
72
|
+
setSessionConfigOption(input, timeoutMs = 60_000) {
|
|
73
|
+
return this.rpc.request("session/set_config_option", input, timeoutMs);
|
|
74
|
+
}
|
|
75
|
+
setSessionMode(input, timeoutMs = 60_000) {
|
|
76
|
+
return this.rpc.request("session/set_mode", input, timeoutMs);
|
|
77
|
+
}
|
|
78
|
+
prompt(input, timeoutMs) {
|
|
79
|
+
return this.rpc.request("session/prompt", input, timeoutMs);
|
|
80
|
+
}
|
|
81
|
+
compactSession(input, timeoutMs = 10 * 60 * 1000) {
|
|
82
|
+
return this.rpc.request("session/compact", input, timeoutMs);
|
|
83
|
+
}
|
|
84
|
+
cancel(sessionId) {
|
|
85
|
+
this.rpc.notify("session/cancel", { sessionId });
|
|
86
|
+
}
|
|
87
|
+
authenticate(input, timeoutMs = 120_000) {
|
|
88
|
+
return this.rpc.request("authenticate", input, timeoutMs);
|
|
89
|
+
}
|
|
90
|
+
logout(input = {}, timeoutMs = 60_000) {
|
|
91
|
+
return this.rpc.request("logout", input, timeoutMs);
|
|
92
|
+
}
|
|
93
|
+
listProviders(input = {}, timeoutMs = 60_000) {
|
|
94
|
+
return this.rpc.request("providers/list", input, timeoutMs);
|
|
95
|
+
}
|
|
96
|
+
setProvider(input, timeoutMs = 60_000) {
|
|
97
|
+
return this.rpc.request("providers/set", input, timeoutMs);
|
|
98
|
+
}
|
|
99
|
+
disableProvider(input, timeoutMs = 60_000) {
|
|
100
|
+
return this.rpc.request("providers/disable", input, timeoutMs);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
export function promptBlocksToAcpContent(prompt, blocks) {
|
|
3
|
+
const inputBlocks = blocks?.length ? blocks : [{ type: "text", text: prompt }];
|
|
4
|
+
return inputBlocks.map((block) => {
|
|
5
|
+
if (block.type === "image") {
|
|
6
|
+
if (block.uri && fs.existsSync(block.uri)) {
|
|
7
|
+
return { type: "image", uri: block.uri, mimeType: block.mimeType };
|
|
8
|
+
}
|
|
9
|
+
return { type: "image", data: block.data, mimeType: block.mimeType };
|
|
10
|
+
}
|
|
11
|
+
return { type: "text", text: block.text };
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { STANDARD_AGENT_EVENT_TYPES, createAgentMessageChunk, createAgentProgressChunk, createAgentThoughtChunk, createToolCall, createToolCallUpdate, firstString, normalizeToolKind, normalizeToolStatus } from "../standard-events.js";
|
|
2
|
+
export function acpSessionUpdateFromParams(params) {
|
|
3
|
+
if (!params || typeof params !== "object" || Array.isArray(params))
|
|
4
|
+
return null;
|
|
5
|
+
const record = params;
|
|
6
|
+
const update = record.update;
|
|
7
|
+
if (!update || typeof update !== "object" || Array.isArray(update))
|
|
8
|
+
return null;
|
|
9
|
+
return {
|
|
10
|
+
sessionId: typeof record.sessionId === "string" ? record.sessionId : undefined,
|
|
11
|
+
update: update
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function eventFromAcpSessionUpdate(params) {
|
|
15
|
+
const parsed = acpSessionUpdateFromParams(params);
|
|
16
|
+
if (!parsed)
|
|
17
|
+
return null;
|
|
18
|
+
const updateType = String(parsed.update.sessionUpdate || "");
|
|
19
|
+
if (updateType === "agent_message_chunk") {
|
|
20
|
+
return createAgentMessageChunk({ text: textFromAcpContent(parsed.update.content), method: "session/update", params });
|
|
21
|
+
}
|
|
22
|
+
if (updateType === "agent_progress_chunk") {
|
|
23
|
+
return createAgentProgressChunk({ text: textFromAcpContent(parsed.update.content), method: "session/update", params });
|
|
24
|
+
}
|
|
25
|
+
if (updateType === "agent_thought_chunk") {
|
|
26
|
+
return createAgentThoughtChunk({ text: textFromAcpContent(parsed.update.content), method: "session/update", params });
|
|
27
|
+
}
|
|
28
|
+
if (updateType === "tool_call" || updateType === "tool_call_update") {
|
|
29
|
+
return toolEventFromAcpUpdate(updateType, parsed.update, params);
|
|
30
|
+
}
|
|
31
|
+
if (STANDARD_AGENT_EVENT_TYPES.has(updateType)) {
|
|
32
|
+
return {
|
|
33
|
+
type: updateType,
|
|
34
|
+
label: labelForUpdate(updateType),
|
|
35
|
+
...(textFromAcpContent(parsed.update.content) ? { text: textFromAcpContent(parsed.update.content) } : {}),
|
|
36
|
+
raw: { method: "session/update", params, update: parsed.update },
|
|
37
|
+
atMs: Date.now()
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
export function statusFromAcpSessionUpdate(params) {
|
|
43
|
+
const parsed = acpSessionUpdateFromParams(params);
|
|
44
|
+
if (!parsed)
|
|
45
|
+
return null;
|
|
46
|
+
const updateType = String(parsed.update.sessionUpdate || "");
|
|
47
|
+
if (updateType === "agent_message_chunk")
|
|
48
|
+
return { phase: "thinking", label: "回复中", detail: textPreview(textFromAcpContent(parsed.update.content)), updateType };
|
|
49
|
+
if (updateType === "agent_progress_chunk")
|
|
50
|
+
return { phase: "thinking", label: "处理中", detail: textPreview(textFromAcpContent(parsed.update.content)), updateType };
|
|
51
|
+
if (updateType === "agent_thought_chunk")
|
|
52
|
+
return { phase: "thinking", label: "思考中", detail: textPreview(textFromAcpContent(parsed.update.content)), updateType };
|
|
53
|
+
if (updateType === "tool_call" || updateType === "tool_call_update")
|
|
54
|
+
return { phase: "exploring", label: "执行工具", detail: firstString(parsed.update.title, parsed.update.kind), updateType };
|
|
55
|
+
if (updateType === "plan" || updateType === "plan_update")
|
|
56
|
+
return { phase: "thinking", label: "规划中", updateType };
|
|
57
|
+
if (updateType === "aca_context_maintenance") {
|
|
58
|
+
return {
|
|
59
|
+
phase: "thinking",
|
|
60
|
+
label: firstString(parsed.update.label) || "正在整理会话上下文",
|
|
61
|
+
detail: firstString(parsed.update.detail),
|
|
62
|
+
updateType
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
export function textFromAcpContent(content) {
|
|
68
|
+
if (typeof content === "string")
|
|
69
|
+
return content;
|
|
70
|
+
if (!content || typeof content !== "object" || Array.isArray(content))
|
|
71
|
+
return "";
|
|
72
|
+
const record = content;
|
|
73
|
+
if (typeof record.text === "string")
|
|
74
|
+
return record.text;
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
function toolEventFromAcpUpdate(updateType, update, params) {
|
|
78
|
+
const toolCallId = firstString(update.toolCallId, update.id) || `acp.${Date.now()}`;
|
|
79
|
+
const kind = normalizeToolKind(firstString(update.kind, update.title));
|
|
80
|
+
const status = normalizeToolStatus(firstString(update.status));
|
|
81
|
+
const input = update.rawInput ?? update.input;
|
|
82
|
+
const output = update.rawOutput ?? update.output;
|
|
83
|
+
const createInput = {
|
|
84
|
+
method: "session/update",
|
|
85
|
+
params,
|
|
86
|
+
toolCallId,
|
|
87
|
+
kind,
|
|
88
|
+
title: firstString(update.title, update.kind),
|
|
89
|
+
status,
|
|
90
|
+
rawInput: input,
|
|
91
|
+
rawOutput: output,
|
|
92
|
+
content: update.content,
|
|
93
|
+
locations: Array.isArray(update.locations) ? update.locations.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item))) : undefined,
|
|
94
|
+
meta: update._meta && typeof update._meta === "object" && !Array.isArray(update._meta) ? update._meta : undefined
|
|
95
|
+
};
|
|
96
|
+
return updateType === "tool_call" ? createToolCall(createInput) : createToolCallUpdate(createInput);
|
|
97
|
+
}
|
|
98
|
+
function labelForUpdate(updateType) {
|
|
99
|
+
return updateType.replace(/_/g, " ");
|
|
100
|
+
}
|
|
101
|
+
function textPreview(value) {
|
|
102
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
103
|
+
if (!normalized)
|
|
104
|
+
return undefined;
|
|
105
|
+
return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized;
|
|
106
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { acpChildEnvironment } from "../../core/process-identity.js";
|
|
3
|
+
export function startAcpProcess(command, args, cwd) {
|
|
4
|
+
const child = spawn(command, args, {
|
|
5
|
+
cwd,
|
|
6
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
7
|
+
env: acpChildEnvironment()
|
|
8
|
+
});
|
|
9
|
+
return { command, args, child };
|
|
10
|
+
}
|
|
11
|
+
export async function closeAcpProcess(child) {
|
|
12
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
13
|
+
return;
|
|
14
|
+
child.stdin.end();
|
|
15
|
+
if (await waitForProcessClose(child, 2_000))
|
|
16
|
+
return;
|
|
17
|
+
child.kill("SIGTERM");
|
|
18
|
+
if (await waitForProcessClose(child, 1_000))
|
|
19
|
+
return;
|
|
20
|
+
child.kill("SIGKILL");
|
|
21
|
+
await waitForProcessClose(child, 1_000);
|
|
22
|
+
}
|
|
23
|
+
function waitForProcessClose(child, timeoutMs) {
|
|
24
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
25
|
+
return Promise.resolve(true);
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const timer = setTimeout(() => resolve(false), timeoutMs);
|
|
28
|
+
timer.unref();
|
|
29
|
+
child.once("close", () => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
resolve(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { AcpClient } from "./acp-client.js";
|
|
4
|
+
import { closeAcpProcess, startAcpProcess } from "./acp-process.js";
|
|
5
|
+
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
|
6
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000;
|
|
7
|
+
const DEFAULT_MAX_IDLE_RUNTIMES = 4;
|
|
8
|
+
export class AcpRuntimePool {
|
|
9
|
+
runtimes = new Map();
|
|
10
|
+
idleTtlMs;
|
|
11
|
+
sweepIntervalMs;
|
|
12
|
+
maxIdleRuntimes;
|
|
13
|
+
sweepTimer = null;
|
|
14
|
+
disposeAllPromise = null;
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.idleTtlMs = Math.max(1_000, options.idleTtlMs ?? readRuntimePoolIdleTtlMs());
|
|
17
|
+
this.sweepIntervalMs = Math.max(1_000, options.sweepIntervalMs ?? readRuntimePoolSweepIntervalMs());
|
|
18
|
+
this.maxIdleRuntimes = Math.max(0, options.maxIdleRuntimes ?? readRuntimePoolMaxIdleRuntimes());
|
|
19
|
+
}
|
|
20
|
+
async acquire(input) {
|
|
21
|
+
if (this.disposeAllPromise)
|
|
22
|
+
await this.disposeAllPromise;
|
|
23
|
+
const cwd = fs.realpathSync(input.cwd);
|
|
24
|
+
this.ensureSweep();
|
|
25
|
+
const existingKey = input.providerSessionId ? runtimeKey(input.providerId, cwd, input.providerSessionId) : null;
|
|
26
|
+
const existing = existingKey ? this.runtimes.get(existingKey) : null;
|
|
27
|
+
if (existing && !this.isRuntimeAlive(existing)) {
|
|
28
|
+
await this.disposeEntry(existing).catch(() => void 0);
|
|
29
|
+
}
|
|
30
|
+
const reusable = existingKey ? this.runtimes.get(existingKey) : null;
|
|
31
|
+
if (reusable) {
|
|
32
|
+
this.activateEntry(reusable, input);
|
|
33
|
+
return this.createLease(reusable);
|
|
34
|
+
}
|
|
35
|
+
const tempSessionId = input.providerSessionId || `pending:${randomUUID()}`;
|
|
36
|
+
const key = runtimeKey(input.providerId, cwd, tempSessionId);
|
|
37
|
+
const entry = this.createEntry({ ...input, cwd, key, providerSessionId: input.providerSessionId ?? null });
|
|
38
|
+
this.runtimes.set(key, entry);
|
|
39
|
+
return this.createLease(entry);
|
|
40
|
+
}
|
|
41
|
+
disposeAll() {
|
|
42
|
+
if (this.disposeAllPromise)
|
|
43
|
+
return this.disposeAllPromise;
|
|
44
|
+
const promise = this.performDisposeAll();
|
|
45
|
+
this.disposeAllPromise = promise;
|
|
46
|
+
void promise.finally(() => {
|
|
47
|
+
if (this.disposeAllPromise === promise)
|
|
48
|
+
this.disposeAllPromise = null;
|
|
49
|
+
});
|
|
50
|
+
return promise;
|
|
51
|
+
}
|
|
52
|
+
async performDisposeAll() {
|
|
53
|
+
if (this.sweepTimer) {
|
|
54
|
+
clearInterval(this.sweepTimer);
|
|
55
|
+
this.sweepTimer = null;
|
|
56
|
+
}
|
|
57
|
+
const entries = [...this.runtimes.values()];
|
|
58
|
+
await Promise.all(entries.map((entry) => this.disposeEntry(entry).catch(() => void 0)));
|
|
59
|
+
}
|
|
60
|
+
async sweepIdle(nowMs = Date.now()) {
|
|
61
|
+
const entries = [...this.runtimes.values()];
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (entry.inUse || entry.disposed)
|
|
64
|
+
continue;
|
|
65
|
+
if (nowMs - entry.lastUsedAtMs < this.idleTtlMs)
|
|
66
|
+
continue;
|
|
67
|
+
await this.disposeEntry(entry).catch(() => void 0);
|
|
68
|
+
}
|
|
69
|
+
await this.trimIdleRuntimes();
|
|
70
|
+
}
|
|
71
|
+
stats() {
|
|
72
|
+
const entries = [...this.runtimes.values()].filter((entry) => !entry.disposed);
|
|
73
|
+
return {
|
|
74
|
+
total: entries.length,
|
|
75
|
+
inUse: entries.filter((entry) => entry.inUse).length,
|
|
76
|
+
idle: entries.filter((entry) => !entry.inUse).length,
|
|
77
|
+
pids: entries.map((entry) => entry.started.child.pid).filter((pid) => Number.isInteger(pid))
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
createEntry(input) {
|
|
81
|
+
const started = startAcpProcess(input.command, input.args, input.cwd);
|
|
82
|
+
const entry = {
|
|
83
|
+
key: input.key,
|
|
84
|
+
providerId: input.providerId,
|
|
85
|
+
cwd: input.cwd,
|
|
86
|
+
providerSessionId: input.providerSessionId,
|
|
87
|
+
started,
|
|
88
|
+
client: null,
|
|
89
|
+
initialized: false,
|
|
90
|
+
sessionEstablished: false,
|
|
91
|
+
inUse: true,
|
|
92
|
+
disposed: false,
|
|
93
|
+
disposePromise: null,
|
|
94
|
+
lastUsedAtMs: Date.now(),
|
|
95
|
+
sessionResponse: null,
|
|
96
|
+
activeHandlers: {
|
|
97
|
+
onSessionUpdate: input.onSessionUpdate,
|
|
98
|
+
onClientRequest: input.onClientRequest
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
entry.client = new AcpClient(started.child, (params, message) => entry.activeHandlers?.onSessionUpdate(params, message), async (method, params, message) => {
|
|
102
|
+
if (!entry.activeHandlers?.onClientRequest)
|
|
103
|
+
throw new Error(`Unsupported ACP client request: ${method}`);
|
|
104
|
+
return entry.activeHandlers.onClientRequest(method, params, message);
|
|
105
|
+
});
|
|
106
|
+
started.child.once("close", () => {
|
|
107
|
+
if (this.runtimes.get(entry.key) === entry)
|
|
108
|
+
this.runtimes.delete(entry.key);
|
|
109
|
+
entry.disposed = true;
|
|
110
|
+
entry.inUse = false;
|
|
111
|
+
entry.activeHandlers = null;
|
|
112
|
+
});
|
|
113
|
+
return entry;
|
|
114
|
+
}
|
|
115
|
+
activateEntry(entry, input) {
|
|
116
|
+
if (entry.inUse) {
|
|
117
|
+
throw new Error(`ACP runtime is already in use: provider=${entry.providerId} session=${entry.providerSessionId ?? "unknown"}`);
|
|
118
|
+
}
|
|
119
|
+
entry.inUse = true;
|
|
120
|
+
entry.activeHandlers = {
|
|
121
|
+
onSessionUpdate: input.onSessionUpdate,
|
|
122
|
+
onClientRequest: input.onClientRequest
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
createLease(entry) {
|
|
126
|
+
let released = false;
|
|
127
|
+
return {
|
|
128
|
+
get client() {
|
|
129
|
+
return entry.client;
|
|
130
|
+
},
|
|
131
|
+
get started() {
|
|
132
|
+
return entry.started;
|
|
133
|
+
},
|
|
134
|
+
get cwd() {
|
|
135
|
+
return entry.cwd;
|
|
136
|
+
},
|
|
137
|
+
get providerSessionId() {
|
|
138
|
+
return entry.providerSessionId;
|
|
139
|
+
},
|
|
140
|
+
get sessionResponse() {
|
|
141
|
+
return entry.sessionResponse;
|
|
142
|
+
},
|
|
143
|
+
get initialized() {
|
|
144
|
+
return entry.initialized;
|
|
145
|
+
},
|
|
146
|
+
get sessionEstablished() {
|
|
147
|
+
return entry.sessionEstablished;
|
|
148
|
+
},
|
|
149
|
+
markInitialized: () => {
|
|
150
|
+
entry.initialized = true;
|
|
151
|
+
},
|
|
152
|
+
setProviderSession: (input) => {
|
|
153
|
+
entry.providerSessionId = input.providerSessionId;
|
|
154
|
+
entry.sessionEstablished = true;
|
|
155
|
+
if ("sessionResponse" in input)
|
|
156
|
+
entry.sessionResponse = input.sessionResponse ?? null;
|
|
157
|
+
this.rekeyEntry(entry, runtimeKey(entry.providerId, entry.cwd, input.providerSessionId));
|
|
158
|
+
},
|
|
159
|
+
setSessionResponse: (sessionResponse) => {
|
|
160
|
+
entry.sessionResponse = sessionResponse;
|
|
161
|
+
},
|
|
162
|
+
release: () => {
|
|
163
|
+
if (released)
|
|
164
|
+
return;
|
|
165
|
+
released = true;
|
|
166
|
+
entry.inUse = false;
|
|
167
|
+
entry.activeHandlers = null;
|
|
168
|
+
entry.lastUsedAtMs = Date.now();
|
|
169
|
+
void this.trimIdleRuntimes();
|
|
170
|
+
},
|
|
171
|
+
dispose: async () => {
|
|
172
|
+
released = true;
|
|
173
|
+
await this.disposeEntry(entry);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
rekeyEntry(entry, nextKey) {
|
|
178
|
+
if (entry.key === nextKey)
|
|
179
|
+
return;
|
|
180
|
+
const previous = this.runtimes.get(nextKey);
|
|
181
|
+
if (previous && previous !== entry) {
|
|
182
|
+
if (previous.inUse) {
|
|
183
|
+
throw new Error(`ACP runtime key is already active: provider=${previous.providerId} session=${previous.providerSessionId ?? "unknown"}`);
|
|
184
|
+
}
|
|
185
|
+
void this.disposeEntry(previous);
|
|
186
|
+
}
|
|
187
|
+
if (this.runtimes.get(entry.key) === entry)
|
|
188
|
+
this.runtimes.delete(entry.key);
|
|
189
|
+
entry.key = nextKey;
|
|
190
|
+
this.runtimes.set(nextKey, entry);
|
|
191
|
+
}
|
|
192
|
+
async disposeEntry(entry) {
|
|
193
|
+
if (entry.disposePromise)
|
|
194
|
+
return entry.disposePromise;
|
|
195
|
+
if (entry.disposed)
|
|
196
|
+
return;
|
|
197
|
+
const promise = this.performDisposeEntry(entry);
|
|
198
|
+
entry.disposePromise = promise;
|
|
199
|
+
return promise;
|
|
200
|
+
}
|
|
201
|
+
async performDisposeEntry(entry) {
|
|
202
|
+
entry.disposed = true;
|
|
203
|
+
entry.inUse = false;
|
|
204
|
+
entry.activeHandlers = null;
|
|
205
|
+
if (this.runtimes.get(entry.key) === entry)
|
|
206
|
+
this.runtimes.delete(entry.key);
|
|
207
|
+
if (entry.providerSessionId) {
|
|
208
|
+
await entry.client.closeSession(entry.providerSessionId, 5_000).catch(() => void 0);
|
|
209
|
+
}
|
|
210
|
+
await closeAcpProcess(entry.started.child);
|
|
211
|
+
}
|
|
212
|
+
async trimIdleRuntimes() {
|
|
213
|
+
const idle = [...this.runtimes.values()]
|
|
214
|
+
.filter((entry) => !entry.inUse && !entry.disposed)
|
|
215
|
+
.sort((left, right) => left.lastUsedAtMs - right.lastUsedAtMs);
|
|
216
|
+
const excess = Math.max(0, idle.length - this.maxIdleRuntimes);
|
|
217
|
+
for (const entry of idle.slice(0, excess)) {
|
|
218
|
+
await this.disposeEntry(entry).catch(() => void 0);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
isRuntimeAlive(entry) {
|
|
222
|
+
return !entry.disposed && entry.started.child.exitCode === null && entry.started.child.signalCode === null;
|
|
223
|
+
}
|
|
224
|
+
ensureSweep() {
|
|
225
|
+
if (this.sweepTimer)
|
|
226
|
+
return;
|
|
227
|
+
this.sweepTimer = setInterval(() => {
|
|
228
|
+
void this.sweepIdle();
|
|
229
|
+
}, this.sweepIntervalMs);
|
|
230
|
+
this.sweepTimer.unref();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
export const defaultAcpRuntimePool = new AcpRuntimePool();
|
|
234
|
+
function runtimeKey(providerId, cwd, providerSessionId) {
|
|
235
|
+
return `${providerId}\0${cwd}\0${providerSessionId}`;
|
|
236
|
+
}
|
|
237
|
+
function readRuntimePoolIdleTtlMs() {
|
|
238
|
+
const parsed = Number.parseInt(process.env.ACA_ACP_RUNTIME_IDLE_TTL_MS ?? "", 10);
|
|
239
|
+
return Number.isInteger(parsed) && parsed >= 1_000 ? parsed : DEFAULT_IDLE_TTL_MS;
|
|
240
|
+
}
|
|
241
|
+
function readRuntimePoolSweepIntervalMs() {
|
|
242
|
+
const parsed = Number.parseInt(process.env.ACA_ACP_RUNTIME_SWEEP_INTERVAL_MS ?? "", 10);
|
|
243
|
+
return Number.isInteger(parsed) && parsed >= 1_000 ? parsed : DEFAULT_SWEEP_INTERVAL_MS;
|
|
244
|
+
}
|
|
245
|
+
function readRuntimePoolMaxIdleRuntimes() {
|
|
246
|
+
const parsed = Number.parseInt(process.env.ACA_ACP_RUNTIME_MAX_IDLE ?? "", 10);
|
|
247
|
+
return Number.isInteger(parsed) && parsed >= 0 ? Math.min(parsed, 32) : DEFAULT_MAX_IDLE_RUNTIMES;
|
|
248
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { acpSessionUpdateFromParams } from "./acp-events.js";
|
|
2
|
+
/**
|
|
3
|
+
* Converts the standard ACP UsageUpdate shape into ACA's persisted session
|
|
4
|
+
* usage shape. ACP defines `used` as tokens currently in context and `size`
|
|
5
|
+
* as the model context-window size. Do not derive this from message length.
|
|
6
|
+
*/
|
|
7
|
+
export function contextUsageFromAcpSessionUpdate(params, fallbackContextWindow) {
|
|
8
|
+
const parsed = acpSessionUpdateFromParams(params);
|
|
9
|
+
if (!parsed || parsed.update.sessionUpdate !== "usage_update")
|
|
10
|
+
return null;
|
|
11
|
+
const usedTokens = nonNegativeNumber(parsed.update.used);
|
|
12
|
+
const reportedWindow = nonNegativeNumber(parsed.update.size);
|
|
13
|
+
const contextWindow = reportedWindow || nonNegativeNumber(fallbackContextWindow);
|
|
14
|
+
if (usedTokens <= 0 || contextWindow <= 0)
|
|
15
|
+
return null;
|
|
16
|
+
return {
|
|
17
|
+
usedTokens,
|
|
18
|
+
// ACP's standard update intentionally has no input-token breakdown.
|
|
19
|
+
inputTokens: usedTokens,
|
|
20
|
+
contextWindow,
|
|
21
|
+
ratio: Math.min(1, usedTokens / contextWindow),
|
|
22
|
+
observedAtMs: Date.now(),
|
|
23
|
+
state: "active",
|
|
24
|
+
maintenanceAction: null
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function nonNegativeNumber(value) {
|
|
28
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
29
|
+
}
|