@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.
Files changed (49) hide show
  1. package/README.md +9 -0
  2. package/dist/acp/agent.js +54 -0
  3. package/dist/acp/client/acp-client.js +102 -0
  4. package/dist/acp/client/acp-content.js +13 -0
  5. package/dist/acp/client/acp-events.js +106 -0
  6. package/dist/acp/client/acp-process.js +34 -0
  7. package/dist/acp/client/acp-runtime-pool.js +248 -0
  8. package/dist/acp/client/context-usage.js +29 -0
  9. package/dist/acp/client/json-rpc.js +128 -0
  10. package/dist/acp/provider-types.js +1 -0
  11. package/dist/acp/providers/codex/codex-process.js +51 -0
  12. package/dist/acp/providers/codex/events.js +1473 -0
  13. package/dist/acp/providers/codex/permissions.js +49 -0
  14. package/dist/acp/providers/codex/provider.js +376 -0
  15. package/dist/acp/providers/codex-acp/adapter.js +947 -0
  16. package/dist/acp/providers/codex-acp/context-maintenance.js +148 -0
  17. package/dist/acp/providers/codex-acp/launch.js +35 -0
  18. package/dist/acp/providers/codex-acp/provider.js +486 -0
  19. package/dist/acp/providers/mimo/provider.js +448 -0
  20. package/dist/acp/providers/opencode/provider.js +489 -0
  21. package/dist/acp/providers/registry.js +23 -0
  22. package/dist/acp/standard-events.js +167 -0
  23. package/dist/commands/start.js +137 -0
  24. package/dist/commands/worker-auth.js +100 -0
  25. package/dist/commands/worker-project.js +57 -0
  26. package/dist/core/aca-config.js +74 -0
  27. package/dist/core/aca-server-client.js +57 -0
  28. package/dist/core/acp-event-coalescer.js +108 -0
  29. package/dist/core/acp-event-upload-filter.js +16 -0
  30. package/dist/core/acp-orphan-cleanup.js +91 -0
  31. package/dist/core/affected-files.js +268 -0
  32. package/dist/core/auth.js +36 -0
  33. package/dist/core/file-transfer-worker.js +169 -0
  34. package/dist/core/fs.js +28 -0
  35. package/dist/core/heartbeat.js +578 -0
  36. package/dist/core/job-permission-policy.js +42 -0
  37. package/dist/core/job-worker.js +749 -0
  38. package/dist/core/logger.js +42 -0
  39. package/dist/core/long-poll-worker.js +26 -0
  40. package/dist/core/machine-filesystem-worker.js +352 -0
  41. package/dist/core/paths.js +26 -0
  42. package/dist/core/process-identity.js +34 -0
  43. package/dist/core/process.js +33 -0
  44. package/dist/core/provider-health.js +54 -0
  45. package/dist/core/runtime-options.js +38 -0
  46. package/dist/core/worktree.js +95 -0
  47. package/dist/worker-cli.js +27 -0
  48. package/dist/worker-single-cli.js +17 -0
  49. package/package.json +35 -0
@@ -0,0 +1,137 @@
1
+ import { Command } from "commander";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { randomUUID } from "node:crypto";
7
+ import { Logger } from "../core/logger.js";
8
+ import { loadCredentials, saveApiKeyCredential } from "../core/auth.js";
9
+ import { getRuntimeStatePath } from "../core/paths.js";
10
+ import { writeJsonFile } from "../core/fs.js";
11
+ import { HEARTBEAT_INTERVAL_MS, startHeartbeatLoop } from "../core/heartbeat.js";
12
+ import { startJobWorkerLoop } from "../core/job-worker.js";
13
+ import { startFileTransferWorkerLoop } from "../core/file-transfer-worker.js";
14
+ import { startMachineFilesystemWorkerLoop } from "../core/machine-filesystem-worker.js";
15
+ import { cleanupOrphanedAcpProcesses } from "../core/acp-orphan-cleanup.js";
16
+ import { defaultAcpRuntimePool } from "../acp/client/acp-runtime-pool.js";
17
+ import { writePidFile, removePidFile } from "../core/process.js";
18
+ import { applyRuntimePathOptions } from "../core/runtime-options.js";
19
+ import { loadAcaConfig, updateAcaConfig } from "../core/aca-config.js";
20
+ import { ACA_WORKER_INSTANCE_ENV, ACA_WORKER_PID_ENV, ACA_WORKER_START_TICKS_ENV, readProcessStartTicks } from "../core/process-identity.js";
21
+ export function createStartCommand() {
22
+ return new Command("start")
23
+ .description("Start agent service in native mode")
24
+ .option("--aica-home <path>", "Runtime home directory (defaults to ~/.aica or AICA_HOME)")
25
+ .option("--aica-config <path>", "Config file path (defaults to <aica-home>/config.json)")
26
+ .option("--aca-home <path>", "Deprecated alias for --aica-home")
27
+ .option("--aca-config <path>", "Deprecated alias for --aica-config")
28
+ .option("--server <url>", "ACA Server URL override")
29
+ .option("--machine-id <id>", "Machine ID override for this runtime")
30
+ .option("--machine-name <name>", "Machine name to register (defaults to hostname)")
31
+ .option("--cli-types <types...>", "Specify CLI types to register, `claude` or `codex`")
32
+ .option("--debug", "enable debug output")
33
+ .option("--heartbeat-log", "output current timestamp every 5 seconds")
34
+ .option("--auth <api-key>", "Use a CLI API key for non-interactive authentication")
35
+ .action(async (options) => {
36
+ await runStart(options);
37
+ });
38
+ }
39
+ async function runStart(options) {
40
+ applyRuntimePathOptions(options);
41
+ const workerInstanceId = process.env[ACA_WORKER_INSTANCE_ENV] || randomUUID();
42
+ const workerStartTicks = readProcessStartTicks(process.pid) || "";
43
+ process.env[ACA_WORKER_INSTANCE_ENV] = workerInstanceId;
44
+ process.env[ACA_WORKER_PID_ENV] = String(process.pid);
45
+ process.env[ACA_WORKER_START_TICKS_ENV] = workerStartTicks;
46
+ const logger = new Logger("start", options.debug ? "debug" : "info");
47
+ const loadedConfig = loadAcaConfig();
48
+ const configPatch = {
49
+ ...(options.server?.trim() ? { serverUrl: options.server.trim() } : {}),
50
+ ...(options.machineId ? { machineId: options.machineId.trim() } : {}),
51
+ ...(options.machineName ? { machineName: options.machineName.trim() } : {})
52
+ };
53
+ const acaConfig = Object.keys(configPatch).length > 0 ? updateAcaConfig(configPatch) : loadedConfig;
54
+ const machineName = options.machineName?.trim() || acaConfig.machineName || os.hostname();
55
+ const credentials = options.auth ? saveApiKeyCredential(options.auth, machineName) : loadCredentials();
56
+ writePidFile(process.pid);
57
+ writeRuntimeState({
58
+ pid: process.pid,
59
+ workerInstanceId,
60
+ workerStartTicks,
61
+ phase: "running",
62
+ startupStage: "local-compatible",
63
+ machineId: acaConfig.machineId || credentials?.machine?.machineId || "local-machine",
64
+ machineName,
65
+ cliTypes: options.cliTypes ?? detectCliTypes(),
66
+ startedAt: new Date().toISOString(),
67
+ heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
68
+ issues: [
69
+ {
70
+ severity: "info",
71
+ message: "aca is running in aca-server compatibility mode; ACP execution happens on this machine."
72
+ }
73
+ ]
74
+ });
75
+ logger.info(`Starting aca local-compatible service on machine ${machineName}`);
76
+ logger.info(`PID ${process.pid}`);
77
+ const orphanCleanup = await cleanupOrphanedAcpProcesses();
78
+ if (orphanCleanup.terminated > 0) {
79
+ logger.warn(`terminated ${orphanCleanup.terminated} orphaned ACP process(es): ${orphanCleanup.pids.join(",")}`);
80
+ }
81
+ let shuttingDown = false;
82
+ let heartbeatTimer;
83
+ let jobWorker;
84
+ let fileTransferWorker;
85
+ let machineFilesystemWorker;
86
+ const shutdown = async (exitCode) => {
87
+ if (shuttingDown)
88
+ return;
89
+ shuttingDown = true;
90
+ logger.info("Shutting down");
91
+ if (heartbeatTimer)
92
+ clearInterval(heartbeatTimer);
93
+ fileTransferWorker?.stop();
94
+ machineFilesystemWorker?.stop();
95
+ await jobWorker?.stop().catch((error) => logger.warn(`job worker shutdown failed: ${error instanceof Error ? error.message : String(error)}`));
96
+ const beforeDispose = defaultAcpRuntimePool.stats();
97
+ await defaultAcpRuntimePool.disposeAll().catch((error) => logger.warn(`ACP runtime shutdown failed: ${error instanceof Error ? error.message : String(error)}`));
98
+ logger.info(`ACP runtimes stopped total=${beforeDispose.total} inUse=${beforeDispose.inUse} idle=${beforeDispose.idle}`);
99
+ writeRuntimeState({
100
+ pid: process.pid,
101
+ phase: "stopped",
102
+ stoppedAt: new Date().toISOString(),
103
+ issues: []
104
+ });
105
+ removePidFile();
106
+ process.exit(exitCode);
107
+ };
108
+ process.once("SIGINT", () => void shutdown(130));
109
+ process.once("SIGTERM", () => void shutdown(0));
110
+ if (options.heartbeatLog) {
111
+ setInterval(() => logger.info(`heartbeat: ${new Date().toISOString()}`), 5_000);
112
+ }
113
+ heartbeatTimer = startHeartbeatLoop(logger);
114
+ jobWorker = startJobWorkerLoop(logger);
115
+ fileTransferWorker = startFileTransferWorkerLoop(logger);
116
+ machineFilesystemWorker = startMachineFilesystemWorkerLoop(logger);
117
+ await new Promise(() => { });
118
+ }
119
+ function detectCliTypes() {
120
+ return ["codex", "claude"].filter((name) => commandExists(name));
121
+ }
122
+ function commandExists(name) {
123
+ const pathValue = process.env.PATH || "";
124
+ for (const dir of pathValue.split(":")) {
125
+ try {
126
+ if (dir && fs.existsSync(path.join(dir, name)))
127
+ return true;
128
+ }
129
+ catch {
130
+ // Ignore malformed PATH entries.
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+ function writeRuntimeState(value) {
136
+ writeJsonFile(getRuntimeStatePath(), value);
137
+ }
@@ -0,0 +1,100 @@
1
+ import { Command } from "commander";
2
+ import os from "node:os";
3
+ import readline from "node:readline/promises";
4
+ import { stdin as input, stdout as output } from "node:process";
5
+ import { acaServerRequest } from "../core/aca-server-client.js";
6
+ import { applyRuntimePathOptions } from "../core/runtime-options.js";
7
+ import { DEFAULT_ACA_SERVER_URL, ensureMachineIdentity, updateAcaConfig } from "../core/aca-config.js";
8
+ import { getAcaConfigPath } from "../core/paths.js";
9
+ export function createWorkerAuthCommand() {
10
+ return new Command("login")
11
+ .description("登录 ACA Server 并生成 Worker 认证配置")
12
+ .option("--server <url>", "ACA Server URL")
13
+ .option("--username <name>", "用户名")
14
+ .option("--password <password>", "密码;不传入时交互式输入")
15
+ .option("--aica-home <path>", "Runtime home directory (defaults to ~/.aica)")
16
+ .option("--aica-config <path>", "Config file path")
17
+ .option("--aca-home <path>", "Deprecated alias for --aica-home")
18
+ .option("--aca-config <path>", "Deprecated alias for --aica-config")
19
+ .option("--machine-id <id>", "Machine ID")
20
+ .option("--machine-name <name>", "Machine display name")
21
+ .action(async (options) => {
22
+ applyRuntimePathOptions(options);
23
+ const server = options.server?.trim() || process.env.ACA_DEFAULT_SERVER_URL || DEFAULT_ACA_SERVER_URL;
24
+ const username = options.username?.trim() || await promptLine("用户名: ");
25
+ const password = options.password ?? await promptSecret("密码: ");
26
+ if (!username)
27
+ throw new Error("用户名不能为空。");
28
+ if (!password)
29
+ throw new Error("密码不能为空。");
30
+ const machine = ensureMachineIdentity({
31
+ machineId: options.machineId?.trim(),
32
+ machineName: options.machineName?.trim() || os.hostname()
33
+ });
34
+ const response = await acaServerRequest("POST", "/api/auth/login", {
35
+ username,
36
+ password,
37
+ machineId: machine.machineId,
38
+ machineName: machine.machineName
39
+ }, { serverUrl: server });
40
+ const config = updateAcaConfig({
41
+ serverUrl: server,
42
+ token: response.token,
43
+ username: response.username,
44
+ machineId: machine.machineId,
45
+ machineName: machine.machineName,
46
+ defaultWorkspaceId: "default"
47
+ });
48
+ console.log(`登录成功:${response.username}`);
49
+ console.log(`认证配置已保存:${getAcaConfigPath()}`);
50
+ console.log(`机器:${config.machineName} (${config.machineId})`);
51
+ });
52
+ }
53
+ async function promptLine(message) {
54
+ if (!input.isTTY || !output.isTTY)
55
+ throw new Error("非交互环境请使用 --username 和 --password 参数。");
56
+ const reader = readline.createInterface({ input, output });
57
+ try {
58
+ return (await reader.question(message)).trim();
59
+ }
60
+ finally {
61
+ reader.close();
62
+ }
63
+ }
64
+ async function promptSecret(message) {
65
+ if (!input.isTTY || !output.isTTY || !input.setRawMode) {
66
+ throw new Error("非交互环境请使用 --password 参数。");
67
+ }
68
+ output.write(message);
69
+ input.setRawMode(true);
70
+ input.resume();
71
+ return new Promise((resolve, reject) => {
72
+ let value = "";
73
+ const onData = (chunk) => {
74
+ const char = chunk.toString("utf8");
75
+ if (char === "\u0003") {
76
+ cleanup();
77
+ output.write("\n");
78
+ reject(new Error("已取消登录。"));
79
+ return;
80
+ }
81
+ if (char === "\r" || char === "\n") {
82
+ cleanup();
83
+ output.write("\n");
84
+ resolve(value);
85
+ return;
86
+ }
87
+ if (char === "\u007f") {
88
+ value = value.slice(0, -1);
89
+ return;
90
+ }
91
+ value += char;
92
+ };
93
+ const cleanup = () => {
94
+ input.setRawMode?.(false);
95
+ input.pause();
96
+ input.off("data", onData);
97
+ };
98
+ input.on("data", onData);
99
+ });
100
+ }
@@ -0,0 +1,57 @@
1
+ import { Command } from "commander";
2
+ import path from "node:path";
3
+ import { acaServerRequest } from "../core/aca-server-client.js";
4
+ import { applyRuntimePathOptions } from "../core/runtime-options.js";
5
+ import { loadAcaConfig, upsertLocalProject } from "../core/aca-config.js";
6
+ export function createWorkerProjectCommand() {
7
+ return new Command("project")
8
+ .description("Manage remote projects without local SQLite")
9
+ .addCommand(new Command("add")
10
+ .description("Register a local project on ACA Server")
11
+ .argument("<root>", "Project root path")
12
+ .option("--aica-home <path>", "Runtime home directory (defaults to ~/.aica)")
13
+ .option("--aica-config <path>", "Config file path")
14
+ .option("--aca-home <path>", "Deprecated alias for --aica-home")
15
+ .option("--aca-config <path>", "Deprecated alias for --aica-config")
16
+ .option("--name <name>", "Project name")
17
+ .option("--workspace <id>", "ACA workspace ID")
18
+ .option("--type <project|chat-room>", "Project type", "project")
19
+ .action(async (root, options) => {
20
+ applyRuntimePathOptions(options);
21
+ if (options.type !== "project" && options.type !== "chat-room") {
22
+ throw new Error("Project type must be 'project' or 'chat-room'.");
23
+ }
24
+ const config = loadAcaConfig();
25
+ const rootPath = path.resolve(root);
26
+ const name = options.name?.trim() || path.basename(rootPath);
27
+ const response = await acaServerRequest("POST", "/api/projects", {
28
+ workspaceId: options.workspace || config.defaultWorkspaceId,
29
+ name,
30
+ rootPath,
31
+ source: "aica",
32
+ projectType: options.type === "chat-room" ? "chat_room" : "project"
33
+ });
34
+ const item = response.item;
35
+ upsertLocalProject({
36
+ projectId: item.project_id,
37
+ workspaceId: item.workspace_id,
38
+ name: item.name,
39
+ rootPath: item.root_path
40
+ });
41
+ console.log(`${item.project_id} ${item.name} ${item.root_path}`);
42
+ }))
43
+ .addCommand(new Command("list")
44
+ .description("List projects from ACA Server")
45
+ .option("--aica-home <path>", "Runtime home directory (defaults to ~/.aica)")
46
+ .option("--aica-config <path>", "Config file path")
47
+ .option("--aca-home <path>", "Deprecated alias for --aica-home")
48
+ .option("--aca-config <path>", "Deprecated alias for --aica-config")
49
+ .option("--workspace <id>", "ACA workspace ID")
50
+ .action(async (options) => {
51
+ applyRuntimePathOptions(options);
52
+ const query = options.workspace ? `?workspaceId=${encodeURIComponent(options.workspace)}` : "";
53
+ const response = await acaServerRequest("GET", `/api/projects${query}`);
54
+ for (const item of response.items)
55
+ console.log(`${item.project_id} workspace=${item.workspace_id} name=${item.name} root=${item.root_path}`);
56
+ }));
57
+ }
@@ -0,0 +1,74 @@
1
+ import { readJsonFile, writeJsonFile } from "./fs.js";
2
+ import { getAcaConfigPath } from "./paths.js";
3
+ import { randomUUID } from "node:crypto";
4
+ import os from "node:os";
5
+ export const DEFAULT_ACA_SERVER_URL = "https://aca.kinghon.com.cn:8843/";
6
+ export function loadAcaConfig() {
7
+ const value = readJsonFile(getAcaConfigPath()) ?? {};
8
+ return {
9
+ serverUrl: value.serverUrl || process.env.ACA_DEFAULT_SERVER_URL || DEFAULT_ACA_SERVER_URL,
10
+ token: value.token,
11
+ username: value.username,
12
+ machineId: value.machineId || randomUUID(),
13
+ machineName: value.machineName || os.hostname(),
14
+ defaultWorkspaceId: value.defaultWorkspaceId || "default",
15
+ defaultProjectId: value.defaultProjectId,
16
+ projects: Array.isArray(value.projects) ? value.projects : []
17
+ };
18
+ }
19
+ export function ensureMachineIdentity(overrides = {}) {
20
+ const current = readJsonFile(getAcaConfigPath()) ?? {};
21
+ const config = {
22
+ ...loadAcaConfig(),
23
+ ...(overrides.machineId ? { machineId: overrides.machineId } : {}),
24
+ ...(overrides.machineName ? { machineName: overrides.machineName } : {})
25
+ };
26
+ if (!current.machineId || overrides.machineId || overrides.machineName) {
27
+ saveAcaConfig(config);
28
+ }
29
+ return { machineId: config.machineId, machineName: config.machineName };
30
+ }
31
+ export function saveAcaConfig(config) {
32
+ writeJsonFile(getAcaConfigPath(), config);
33
+ }
34
+ export function updateAcaConfig(patch) {
35
+ const next = { ...loadAcaConfig(), ...patch };
36
+ saveAcaConfig(next);
37
+ return next;
38
+ }
39
+ export function upsertLocalProject(project) {
40
+ const config = loadAcaConfig();
41
+ const projects = config.projects.filter((item) => item.projectId !== project.projectId && item.name !== project.name);
42
+ projects.push(project);
43
+ const next = {
44
+ ...config,
45
+ defaultWorkspaceId: project.workspaceId,
46
+ defaultProjectId: project.projectId,
47
+ projects
48
+ };
49
+ saveAcaConfig(next);
50
+ return next;
51
+ }
52
+ export function removeLocalProject(projectIdOrName) {
53
+ const config = loadAcaConfig();
54
+ const projects = config.projects.filter((item) => item.projectId !== projectIdOrName && item.name !== projectIdOrName);
55
+ const defaultProjectId = config.defaultProjectId === projectIdOrName || config.projects.find((item) => item.name === projectIdOrName)?.projectId === config.defaultProjectId
56
+ ? projects[0]?.projectId
57
+ : config.defaultProjectId;
58
+ const next = {
59
+ ...config,
60
+ defaultProjectId,
61
+ projects
62
+ };
63
+ saveAcaConfig(next);
64
+ return next;
65
+ }
66
+ export function findLocalProject(idOrName) {
67
+ const config = loadAcaConfig();
68
+ if (!idOrName && config.defaultProjectId) {
69
+ return config.projects.find((project) => project.projectId === config.defaultProjectId) ?? null;
70
+ }
71
+ if (!idOrName)
72
+ return config.projects[0] ?? null;
73
+ return config.projects.find((project) => project.projectId === idOrName || project.name === idOrName) ?? null;
74
+ }
@@ -0,0 +1,57 @@
1
+ import { Agent } from "undici";
2
+ import { loadAcaConfig } from "./aca-config.js";
3
+ // 仅对 ACA Server 请求启用,避免使用 NODE_TLS_REJECT_UNAUTHORIZED=0 影响
4
+ // Codex、Mimo、OpenCode 以及其它外部 HTTPS 请求。
5
+ const insecureTlsDispatcher = new Agent({ connect: { rejectUnauthorized: false } });
6
+ export async function acaServerRequest(method, path, body, options = {}) {
7
+ const config = loadAcaConfig();
8
+ const serverUrl = (options.serverUrl || config.serverUrl).replace(/\/$/, "");
9
+ const token = options.token ?? config.token;
10
+ const headers = {
11
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
12
+ ...(token ? { authorization: `Bearer ${token}` } : {})
13
+ };
14
+ const response = await fetch(`${serverUrl}${path}`, {
15
+ method,
16
+ headers,
17
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
18
+ ...acaServerTlsInit(serverUrl)
19
+ });
20
+ const text = await response.text();
21
+ const value = text ? JSON.parse(text) : null;
22
+ if (!response.ok) {
23
+ const message = value && typeof value === "object" && "message" in value ? String(value.message) : `HTTP ${response.status}`;
24
+ throw new Error(message);
25
+ }
26
+ return value;
27
+ }
28
+ export async function acaServerStreamRequest(method, path, body, headers = {}, options = {}) {
29
+ const config = loadAcaConfig();
30
+ const serverUrl = (options.serverUrl || config.serverUrl).replace(/\/$/, "");
31
+ const token = options.token ?? config.token;
32
+ const response = await fetch(`${serverUrl}${path}`, {
33
+ method,
34
+ headers: {
35
+ ...headers,
36
+ ...(token ? { authorization: `Bearer ${token}` } : {})
37
+ },
38
+ body,
39
+ duplex: "half",
40
+ ...acaServerTlsInit(serverUrl)
41
+ });
42
+ const text = await response.text();
43
+ const value = text ? JSON.parse(text) : null;
44
+ if (!response.ok) {
45
+ const message = value && typeof value === "object" && "message" in value ? String(value.message) : `HTTP ${response.status}`;
46
+ throw new Error(message);
47
+ }
48
+ return value;
49
+ }
50
+ function acaServerTlsInit(serverUrl) {
51
+ if (!/^https:/i.test(serverUrl) || !isTruthy(process.env.ACA_SERVER_INSECURE_TLS))
52
+ return {};
53
+ return { dispatcher: insecureTlsDispatcher };
54
+ }
55
+ function isTruthy(value) {
56
+ return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes";
57
+ }
@@ -0,0 +1,108 @@
1
+ const MAX_TEXT_CHARS = 1024;
2
+ const TEXT_EVENT_TYPES = new Set(["agent_message_chunk", "agent_progress_chunk", "agent_thought_chunk", "user_message_chunk"]);
3
+ const SNAPSHOT_EVENT_TYPES = new Set(["usage_update", "session_info_update"]);
4
+ /**
5
+ * Coalesce only adjacent compatible events in the Worker batch. Tool calls,
6
+ * plans, permissions and errors naturally act as barriers because their type
7
+ * is neither text nor a replaceable snapshot.
8
+ */
9
+ export function appendCoalescedAcpEvent(buffer, incoming) {
10
+ const previous = buffer.at(-1);
11
+ if (!previous) {
12
+ buffer.push(incoming);
13
+ return false;
14
+ }
15
+ if (canMergeText(previous, incoming)) {
16
+ const mergedText = `${previous.text ?? ""}${incoming.text ?? ""}`;
17
+ if (mergedText.length <= MAX_TEXT_CHARS) {
18
+ previous.text = mergedText;
19
+ previous.raw = mergeRaw(previous.raw, incoming.raw, mergedText);
20
+ return true;
21
+ }
22
+ }
23
+ if (canReplaceSnapshot(previous, incoming)) {
24
+ const firstSequence = sequenceStart(previous.raw);
25
+ previous.label = incoming.label;
26
+ previous.text = incoming.text;
27
+ previous.status = incoming.status;
28
+ previous.toolCallId = incoming.toolCallId;
29
+ previous.raw = mergeRaw(previous.raw, incoming.raw, incoming.text, firstSequence);
30
+ return true;
31
+ }
32
+ buffer.push(incoming);
33
+ return false;
34
+ }
35
+ function canMergeText(previous, incoming) {
36
+ return TEXT_EVENT_TYPES.has(previous.eventType)
37
+ && previous.eventType === incoming.eventType
38
+ && previous.turnId === incoming.turnId
39
+ && previous.label === incoming.label
40
+ && previous.status === incoming.status
41
+ && !previous.toolCallId
42
+ && !incoming.toolCallId
43
+ && typeof previous.text === "string"
44
+ && typeof incoming.text === "string";
45
+ }
46
+ function canReplaceSnapshot(previous, incoming) {
47
+ if (previous.eventType !== incoming.eventType || previous.turnId !== incoming.turnId)
48
+ return false;
49
+ if (SNAPSHOT_EVENT_TYPES.has(previous.eventType))
50
+ return true;
51
+ if (previous.eventType !== "config_option_update")
52
+ return false;
53
+ const previousKey = configOptionKey(previous.raw);
54
+ const incomingKey = configOptionKey(incoming.raw);
55
+ return Boolean(previousKey) && previousKey === incomingKey;
56
+ }
57
+ function configOptionKey(raw) {
58
+ const update = rawUpdate(raw);
59
+ for (const key of ["configId", "optionId", "id", "key", "name"]) {
60
+ if (typeof update?.[key] === "string" && update[key].trim())
61
+ return update[key].trim();
62
+ }
63
+ return "";
64
+ }
65
+ function mergeRaw(previous, incoming, mergedText, firstSequence = sequenceStart(previous)) {
66
+ const next = asRecord(incoming);
67
+ if (!next)
68
+ return incoming;
69
+ const end = sequenceEnd(incoming);
70
+ const count = sourceCount(previous) + sourceCount(incoming);
71
+ const raw = {
72
+ ...next,
73
+ acaSequence: firstSequence,
74
+ acaSequenceEnd: end,
75
+ sourceEventCount: count
76
+ };
77
+ if (mergedText !== undefined) {
78
+ const update = asRecord(next.update);
79
+ if (update) {
80
+ raw.update = {
81
+ ...update,
82
+ content: { type: "text", text: mergedText }
83
+ };
84
+ }
85
+ }
86
+ return raw;
87
+ }
88
+ function rawUpdate(raw) {
89
+ return asRecord(asRecord(raw)?.update);
90
+ }
91
+ function sequenceStart(raw) {
92
+ const value = asRecord(raw)?.acaSequence;
93
+ return typeof value === "number" ? value : undefined;
94
+ }
95
+ function sequenceEnd(raw) {
96
+ const record = asRecord(raw);
97
+ const end = record?.acaSequenceEnd;
98
+ if (typeof end === "number")
99
+ return end;
100
+ return typeof record?.acaSequence === "number" ? record.acaSequence : undefined;
101
+ }
102
+ function sourceCount(raw) {
103
+ const value = asRecord(raw)?.sourceEventCount;
104
+ return typeof value === "number" && value > 0 ? value : 1;
105
+ }
106
+ function asRecord(value) {
107
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
108
+ }
@@ -0,0 +1,16 @@
1
+ /** Matches the Server-side protocol marker filter before it reaches the batcher.
2
+ * Keeping this marker out of the buffer prevents it from splitting adjacent
3
+ * visible assistant text into separate coalescing groups. */
4
+ export function shouldUploadAcpEvent(event) {
5
+ if (event.eventType !== "agent_message_chunk" || event.text?.trim() !== "text")
6
+ return true;
7
+ const raw = asRecord(event.raw);
8
+ if (!raw)
9
+ return false;
10
+ const update = asRecord(raw.update);
11
+ const content = asRecord(update?.content);
12
+ return !(content?.type === "text" && content.text === "text");
13
+ }
14
+ function asRecord(value) {
15
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
16
+ }
@@ -0,0 +1,91 @@
1
+ import fs from "node:fs";
2
+ import { ACA_ACP_CHILD_ENV, ACA_ACP_OWNER_PID_ENV, ACA_ACP_OWNER_START_TICKS_ENV, ACA_ACP_WORKER_INSTANCE_ENV, ACA_WORKER_INSTANCE_ENV, readProcessStartTicks } from "./process-identity.js";
3
+ export async function cleanupOrphanedAcpProcesses() {
4
+ if (process.platform !== "linux")
5
+ return { inspected: 0, terminated: 0, pids: [] };
6
+ let inspected = 0;
7
+ const pids = [];
8
+ for (const entry of safeReadDir("/proc")) {
9
+ if (!/^\d+$/.test(entry))
10
+ continue;
11
+ const pid = Number(entry);
12
+ if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid)
13
+ continue;
14
+ const environment = readProcessEnvironment(pid);
15
+ if (environmentValue(environment, ACA_ACP_CHILD_ENV) !== "1")
16
+ continue;
17
+ inspected += 1;
18
+ const ownerPid = Number.parseInt(environmentValue(environment, ACA_ACP_OWNER_PID_ENV), 10);
19
+ const ownerStartTicks = environmentValue(environment, ACA_ACP_OWNER_START_TICKS_ENV);
20
+ const workerInstanceId = environmentValue(environment, ACA_ACP_WORKER_INSTANCE_ENV);
21
+ if (isMatchingWorkerProcess(ownerPid, ownerStartTicks, workerInstanceId))
22
+ continue;
23
+ try {
24
+ process.kill(pid, "SIGTERM");
25
+ pids.push(pid);
26
+ }
27
+ catch {
28
+ // Process may have exited between /proc inspection and signal delivery.
29
+ }
30
+ }
31
+ if (pids.length > 0) {
32
+ await delay(2_000);
33
+ for (const pid of pids) {
34
+ if (!isProcessAlive(pid))
35
+ continue;
36
+ try {
37
+ process.kill(pid, "SIGKILL");
38
+ }
39
+ catch {
40
+ // Process may have exited after the liveness check.
41
+ }
42
+ }
43
+ }
44
+ return { inspected, terminated: pids.length, pids };
45
+ }
46
+ function readProcessEnvironment(pid) {
47
+ try {
48
+ return fs.readFileSync(`/proc/${pid}/environ`, "utf8").split("\0").filter(Boolean);
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ }
54
+ function isMatchingWorkerProcess(pid, expectedStartTicks, expectedInstanceId) {
55
+ if (!Number.isInteger(pid) || pid <= 1 || !expectedStartTicks)
56
+ return false;
57
+ try {
58
+ process.kill(pid, 0);
59
+ if (readProcessStartTicks(pid) !== expectedStartTicks)
60
+ return false;
61
+ const ownerInstanceId = environmentValue(readProcessEnvironment(pid), ACA_WORKER_INSTANCE_ENV);
62
+ return !expectedInstanceId || !ownerInstanceId || ownerInstanceId === expectedInstanceId;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ function environmentValue(environment, name) {
69
+ const prefix = `${name}=`;
70
+ return environment.find((value) => value.startsWith(prefix))?.slice(prefix.length) ?? "";
71
+ }
72
+ function isProcessAlive(pid) {
73
+ try {
74
+ process.kill(pid, 0);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ function safeReadDir(directory) {
82
+ try {
83
+ return fs.readdirSync(directory);
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ }
89
+ function delay(ms) {
90
+ return new Promise((resolve) => setTimeout(resolve, ms));
91
+ }