@kodelyth/acpx 2026.5.42 → 2026.6.2

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.
@@ -1,434 +0,0 @@
1
- import { execFile } from "node:child_process";
2
- import { createRequire } from "node:module";
3
- import path from "node:path";
4
- import { promisify } from "node:util";
5
- import { KLAW_ACPX_LEASE_ID_ARG, KLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
6
-
7
- const execFileAsync = promisify(execFile);
8
- const requireFromHere = createRequire(import.meta.url);
9
- const GENERATED_WRAPPER_BASENAMES = new Set([
10
- "codex-acp-wrapper.mjs",
11
- "claude-agent-acp-wrapper.mjs",
12
- ]);
13
- const KLAW_PLUGIN_DEPS_MARKER = "/plugin-runtime-deps/";
14
- const OWNED_ACP_PACKAGE_NAMES = [
15
- "@zed-industries/codex-acp",
16
- "@zed-industries/codex-acp-darwin-arm64",
17
- "@zed-industries/codex-acp-darwin-x64",
18
- "@zed-industries/codex-acp-linux-arm64",
19
- "@zed-industries/codex-acp-linux-x64",
20
- "@zed-industries/codex-acp-win32-arm64",
21
- "@zed-industries/codex-acp-win32-x64",
22
- "@agentclientprotocol/claude-agent-acp",
23
- "acpx",
24
- ];
25
- const ACP_PACKAGE_MARKERS = [
26
- "/@zed-industries/codex-acp/",
27
- "/@agentclientprotocol/claude-agent-acp/",
28
- "/acpx/dist/",
29
- ];
30
-
31
- export type AcpxProcessInfo = {
32
- pid: number;
33
- ppid: number;
34
- command: string;
35
- };
36
-
37
- export type AcpxProcessCleanupDeps = {
38
- listProcesses?: () => Promise<AcpxProcessInfo[]>;
39
- killProcess?: (pid: number, signal: NodeJS.Signals) => void;
40
- sleep?: (ms: number) => Promise<void>;
41
- };
42
-
43
- export type AcpxProcessCleanupResult = {
44
- inspectedPids: number[];
45
- terminatedPids: number[];
46
- skippedReason?: "missing-root" | "not-klaw-owned" | "unverified-root";
47
- };
48
-
49
- export type AcpxStartupReapResult = {
50
- inspectedPids: number[];
51
- terminatedPids: number[];
52
- skippedReason?: "unsupported-platform" | "process-list-unavailable";
53
- };
54
-
55
- function normalizePathLike(value: string): string {
56
- return value.replaceAll("\\", "/");
57
- }
58
-
59
- function resolvePackageRoot(packageName: string): string | undefined {
60
- try {
61
- return normalizePathLike(path.dirname(requireFromHere.resolve(`${packageName}/package.json`)));
62
- } catch {
63
- return undefined;
64
- }
65
- }
66
-
67
- const OWNED_ACP_PACKAGE_ROOTS = OWNED_ACP_PACKAGE_NAMES.map(resolvePackageRoot).filter(
68
- (root): root is string => Boolean(root),
69
- );
70
-
71
- function commandBelongsToResolvedAcpPackage(command: string): boolean {
72
- return OWNED_ACP_PACKAGE_ROOTS.some((root) => command.includes(`${root}/`));
73
- }
74
-
75
- function commandMentionsGeneratedWrapper(command: string): boolean {
76
- return Array.from(GENERATED_WRAPPER_BASENAMES).some((basename) => command.includes(basename));
77
- }
78
-
79
- function commandWrapperBelongsToRoot(command: string, wrapperRoot: string | undefined): boolean {
80
- if (!wrapperRoot) {
81
- return true;
82
- }
83
- const normalizedCommand = normalizePathLike(command);
84
- const normalizedRoot = normalizePathLike(wrapperRoot).replace(/\/+$/, "");
85
- return Array.from(GENERATED_WRAPPER_BASENAMES).some((basename) =>
86
- normalizedCommand.includes(`${normalizedRoot}/${basename}`),
87
- );
88
- }
89
-
90
- export function isKlawLeaseAwareAcpxProcessCommand(params: {
91
- command: string | undefined;
92
- wrapperRoot?: string;
93
- }): boolean {
94
- const command = params.command?.trim();
95
- if (!command) {
96
- return false;
97
- }
98
- const normalized = normalizePathLike(command);
99
- return (
100
- commandMentionsGeneratedWrapper(normalized) &&
101
- commandWrapperBelongsToRoot(normalized, params.wrapperRoot)
102
- );
103
- }
104
-
105
- function commandsReferToSameRootCommand(liveCommand: string, storedCommand: string | undefined) {
106
- if (!storedCommand?.trim()) {
107
- return true;
108
- }
109
- return normalizePathLike(liveCommand).trim() === normalizePathLike(storedCommand).trim();
110
- }
111
-
112
- function splitCommandParts(value: string): string[] {
113
- const parts: string[] = [];
114
- let current = "";
115
- let quote: "'" | '"' | null = null;
116
- let escaping = false;
117
-
118
- for (const ch of value) {
119
- if (escaping) {
120
- current += ch;
121
- escaping = false;
122
- continue;
123
- }
124
- if (ch === "\\" && quote !== "'") {
125
- escaping = true;
126
- continue;
127
- }
128
- if (quote) {
129
- if (ch === quote) {
130
- quote = null;
131
- } else {
132
- current += ch;
133
- }
134
- continue;
135
- }
136
- if (ch === "'" || ch === '"') {
137
- quote = ch;
138
- continue;
139
- }
140
- if (/\s/.test(ch)) {
141
- if (current) {
142
- parts.push(current);
143
- current = "";
144
- }
145
- continue;
146
- }
147
- current += ch;
148
- }
149
-
150
- if (escaping) {
151
- current += "\\";
152
- }
153
- if (current) {
154
- parts.push(current);
155
- }
156
- return parts;
157
- }
158
-
159
- function commandOptionEquals(
160
- parts: string[],
161
- option: string,
162
- expected: string | undefined,
163
- ): boolean {
164
- if (!expected) {
165
- return true;
166
- }
167
- const index = parts.indexOf(option);
168
- return index >= 0 && parts[index + 1] === expected;
169
- }
170
-
171
- function liveCommandMatchesLeaseIdentity(params: {
172
- command: string | undefined;
173
- expectedLeaseId?: string;
174
- expectedGatewayInstanceId?: string;
175
- }): boolean {
176
- if (!params.expectedLeaseId && !params.expectedGatewayInstanceId) {
177
- return true;
178
- }
179
- const parts = splitCommandParts(params.command ?? "");
180
- return (
181
- commandOptionEquals(parts, KLAW_ACPX_LEASE_ID_ARG, params.expectedLeaseId) &&
182
- commandOptionEquals(parts, KLAW_GATEWAY_INSTANCE_ID_ARG, params.expectedGatewayInstanceId)
183
- );
184
- }
185
-
186
- export function isKlawOwnedAcpxProcessCommand(params: {
187
- command: string | undefined;
188
- wrapperRoot?: string;
189
- }): boolean {
190
- const command = params.command?.trim();
191
- if (!command) {
192
- return false;
193
- }
194
- const normalized = normalizePathLike(command);
195
- if (
196
- isKlawLeaseAwareAcpxProcessCommand({
197
- command: normalized,
198
- wrapperRoot: params.wrapperRoot,
199
- })
200
- ) {
201
- return true;
202
- }
203
- if (commandBelongsToResolvedAcpPackage(normalized)) {
204
- return true;
205
- }
206
- if (!normalized.includes(KLAW_PLUGIN_DEPS_MARKER)) {
207
- return false;
208
- }
209
- return ACP_PACKAGE_MARKERS.some((marker) => normalized.includes(marker));
210
- }
211
-
212
- function parseProcessList(stdout: string): AcpxProcessInfo[] {
213
- const processes: AcpxProcessInfo[] = [];
214
- for (const line of stdout.split(/\r?\n/)) {
215
- const match = /^\s*(?<pid>\d+)\s+(?<ppid>\d+)\s+(?<command>.+?)\s*$/.exec(line);
216
- if (!match?.groups) {
217
- continue;
218
- }
219
- processes.push({
220
- pid: Number.parseInt(match.groups.pid, 10),
221
- ppid: Number.parseInt(match.groups.ppid, 10),
222
- command: match.groups.command,
223
- });
224
- }
225
- return processes;
226
- }
227
-
228
- export async function listPlatformProcesses(): Promise<AcpxProcessInfo[]> {
229
- if (process.platform === "win32") {
230
- return [];
231
- }
232
- const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="], {
233
- maxBuffer: 8 * 1024 * 1024,
234
- });
235
- return parseProcessList(stdout);
236
- }
237
-
238
- function collectProcessTree(processes: AcpxProcessInfo[], rootPid: number): AcpxProcessInfo[] {
239
- const childrenByParent = new Map<number, AcpxProcessInfo[]>();
240
- for (const processInfo of processes) {
241
- const children = childrenByParent.get(processInfo.ppid) ?? [];
242
- children.push(processInfo);
243
- childrenByParent.set(processInfo.ppid, children);
244
- }
245
-
246
- const byPid = new Map(processes.map((processInfo) => [processInfo.pid, processInfo]));
247
- const root = byPid.get(rootPid);
248
- const collected: AcpxProcessInfo[] = [];
249
- if (root) {
250
- collected.push(root);
251
- }
252
-
253
- const queue = [...(childrenByParent.get(rootPid) ?? [])];
254
- while (queue.length > 0) {
255
- const next = queue.shift();
256
- if (!next || collected.some((processInfo) => processInfo.pid === next.pid)) {
257
- continue;
258
- }
259
- collected.push(next);
260
- queue.push(...(childrenByParent.get(next.pid) ?? []));
261
- }
262
-
263
- return collected;
264
- }
265
-
266
- function uniquePids(processes: AcpxProcessInfo[]): number[] {
267
- return Array.from(
268
- new Set(
269
- processes
270
- .map((processInfo) => processInfo.pid)
271
- .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid),
272
- ),
273
- );
274
- }
275
-
276
- function isProcessAlive(pid: number): boolean {
277
- try {
278
- process.kill(pid, 0);
279
- return true;
280
- } catch {
281
- return false;
282
- }
283
- }
284
-
285
- async function terminatePids(
286
- pids: number[],
287
- deps: AcpxProcessCleanupDeps | undefined,
288
- ): Promise<number[]> {
289
- const killProcess = deps?.killProcess ?? ((pid, signal) => process.kill(pid, signal));
290
- const sleep = deps?.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
291
- const terminated: number[] = [];
292
-
293
- for (const pid of pids) {
294
- try {
295
- killProcess(pid, "SIGTERM");
296
- terminated.push(pid);
297
- } catch {
298
- // The process may already be gone.
299
- }
300
- }
301
- if (terminated.length === 0) {
302
- return terminated;
303
- }
304
- await sleep(750);
305
- for (const pid of terminated) {
306
- if (deps?.killProcess || isProcessAlive(pid)) {
307
- try {
308
- killProcess(pid, "SIGKILL");
309
- } catch {
310
- // Best-effort cleanup only.
311
- }
312
- }
313
- }
314
- return terminated;
315
- }
316
-
317
- export async function cleanupKlawOwnedAcpxProcessTree(params: {
318
- rootPid?: number;
319
- rootCommand?: string;
320
- expectedLeaseId?: string;
321
- expectedGatewayInstanceId?: string;
322
- wrapperRoot?: string;
323
- deps?: AcpxProcessCleanupDeps;
324
- }): Promise<AcpxProcessCleanupResult> {
325
- const rootPid = params.rootPid;
326
- if (!rootPid || rootPid <= 0 || rootPid === process.pid) {
327
- return { inspectedPids: [], terminatedPids: [], skippedReason: "missing-root" };
328
- }
329
-
330
- let processes: AcpxProcessInfo[] = [];
331
- try {
332
- processes = await (params.deps?.listProcesses ?? listPlatformProcesses)();
333
- } catch {
334
- processes = [];
335
- }
336
-
337
- const listedTree = collectProcessTree(processes, rootPid);
338
- // Session-store PIDs are stale data. If the live process table cannot prove
339
- // that this PID still belongs to an Klaw-owned wrapper, fail closed to
340
- // avoid killing an unrelated process after PID reuse.
341
- if (listedTree.length === 0) {
342
- return { inspectedPids: [], terminatedPids: [], skippedReason: "unverified-root" };
343
- }
344
- const rootCommand = listedTree[0]?.command ?? params.rootCommand;
345
- const liveCommandWasGeneratedWrapper = commandMentionsGeneratedWrapper(
346
- normalizePathLike(rootCommand ?? ""),
347
- );
348
- const storedCommandWasGeneratedWrapper = commandMentionsGeneratedWrapper(
349
- normalizePathLike(params.rootCommand ?? ""),
350
- );
351
- if (!liveCommandWasGeneratedWrapper && storedCommandWasGeneratedWrapper) {
352
- return {
353
- inspectedPids: listedTree.map((processInfo) => processInfo.pid),
354
- terminatedPids: [],
355
- skippedReason: "not-klaw-owned",
356
- };
357
- }
358
- if (
359
- !liveCommandWasGeneratedWrapper &&
360
- !commandsReferToSameRootCommand(rootCommand ?? "", params.rootCommand)
361
- ) {
362
- return {
363
- inspectedPids: listedTree.map((processInfo) => processInfo.pid),
364
- terminatedPids: [],
365
- skippedReason: "not-klaw-owned",
366
- };
367
- }
368
- if (
369
- !isKlawOwnedAcpxProcessCommand({
370
- command: rootCommand,
371
- wrapperRoot: params.wrapperRoot,
372
- })
373
- ) {
374
- return {
375
- inspectedPids: listedTree.map((processInfo) => processInfo.pid),
376
- terminatedPids: [],
377
- skippedReason: "not-klaw-owned",
378
- };
379
- }
380
- if (
381
- !liveCommandMatchesLeaseIdentity({
382
- command: rootCommand,
383
- expectedLeaseId: params.expectedLeaseId,
384
- expectedGatewayInstanceId: params.expectedGatewayInstanceId,
385
- })
386
- ) {
387
- return {
388
- inspectedPids: listedTree.map((processInfo) => processInfo.pid),
389
- terminatedPids: [],
390
- skippedReason: "not-klaw-owned",
391
- };
392
- }
393
-
394
- const pids = uniquePids(listedTree.toReversed());
395
- return {
396
- inspectedPids: uniquePids(listedTree),
397
- terminatedPids: await terminatePids(pids, params.deps),
398
- };
399
- }
400
-
401
- export async function reapStaleKlawOwnedAcpxOrphans(params: {
402
- wrapperRoot: string;
403
- deps?: AcpxProcessCleanupDeps;
404
- }): Promise<AcpxStartupReapResult> {
405
- if (process.platform === "win32") {
406
- return { inspectedPids: [], terminatedPids: [], skippedReason: "unsupported-platform" };
407
- }
408
-
409
- let processes: AcpxProcessInfo[];
410
- try {
411
- processes = await (params.deps?.listProcesses ?? listPlatformProcesses)();
412
- } catch {
413
- return { inspectedPids: [], terminatedPids: [], skippedReason: "process-list-unavailable" };
414
- }
415
-
416
- const orphans = processes.filter(
417
- (processInfo) =>
418
- processInfo.ppid === 1 &&
419
- isKlawOwnedAcpxProcessCommand({
420
- command: processInfo.command,
421
- wrapperRoot: params.wrapperRoot,
422
- }),
423
- );
424
- // Startup reaping starts from currently visible orphan roots and then expands
425
- // each tree, so adapter grandchildren do not survive as fresh orphans after
426
- // the wrapper root exits.
427
- const orphanTrees = orphans.map((orphan) => collectProcessTree(processes, orphan.pid));
428
- const inspectedPids = uniquePids(orphanTrees.flat());
429
- const pids = uniquePids(orphanTrees.flatMap((tree) => tree.toReversed()));
430
- return {
431
- inspectedPids,
432
- terminatedPids: await terminatePids(pids, params.deps),
433
- };
434
- }
@@ -1,6 +0,0 @@
1
- export function formatErrorMessage(error) {
2
- if (error instanceof Error) {
3
- return error.message || error.name || "Error";
4
- }
5
- return String(error);
6
- }
@@ -1,123 +0,0 @@
1
- const WINDOWS_DIRECT_EXECUTABLE_PATH_RE =
2
- /^(?<command>(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:exe|com))(?=\s|$)(?:\s+(?<rest>.*))?$/i;
3
-
4
- // Windows wrapper scripts need their host shell or interpreter (`cmd.exe`,
5
- // `powershell.exe`, or `node`) instead of direct spawning.
6
- const WINDOWS_WRAPPER_PATH_RE =
7
- /^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:bat|cmd|cjs|js|mjs|ps1)$/i;
8
-
9
- function splitCommandParts(value, platform = process.platform) {
10
- const parts = [];
11
- let current = "";
12
- let quote = null;
13
- let escaping = false;
14
-
15
- for (let index = 0; index < value.length; index += 1) {
16
- const ch = value[index];
17
- const next = value[index + 1];
18
- if (escaping) {
19
- current += ch;
20
- escaping = false;
21
- continue;
22
- }
23
- if (ch === "\\") {
24
- if (quote === "'") {
25
- current += ch;
26
- continue;
27
- }
28
- if (platform === "win32") {
29
- if (quote === '"') {
30
- if (next === '"' || next === "\\") {
31
- escaping = true;
32
- continue;
33
- }
34
- current += ch;
35
- continue;
36
- }
37
- if (!quote) {
38
- current += ch;
39
- continue;
40
- }
41
- }
42
- escaping = true;
43
- continue;
44
- }
45
- if (quote) {
46
- if (ch === quote) {
47
- quote = null;
48
- } else {
49
- current += ch;
50
- }
51
- continue;
52
- }
53
- if (ch === "'" || ch === '"') {
54
- quote = ch;
55
- continue;
56
- }
57
- if (/\s/.test(ch)) {
58
- if (current.length > 0) {
59
- parts.push(current);
60
- current = "";
61
- }
62
- continue;
63
- }
64
- current += ch;
65
- }
66
-
67
- if (escaping) {
68
- current += "\\";
69
- }
70
- if (quote) {
71
- throw new Error("Invalid agent command: unterminated quote");
72
- }
73
- if (current.length > 0) {
74
- parts.push(current);
75
- }
76
- return parts;
77
- }
78
-
79
- function splitWindowsExecutableCommand(value, platform = process.platform) {
80
- if (platform !== "win32") {
81
- return null;
82
- }
83
- const trimmed = value.trim();
84
- if (!trimmed || trimmed.startsWith('"') || trimmed.startsWith("'")) {
85
- return null;
86
- }
87
- const match = trimmed.match(WINDOWS_DIRECT_EXECUTABLE_PATH_RE);
88
- if (!match?.groups?.command) {
89
- return null;
90
- }
91
- const rest = match.groups.rest?.trim() ?? "";
92
- return {
93
- command: match.groups.command,
94
- args: rest ? splitCommandParts(rest, platform) : [],
95
- };
96
- }
97
-
98
- function assertSupportedWindowsCommand(command, platform = process.platform) {
99
- if (platform !== "win32" || !WINDOWS_WRAPPER_PATH_RE.test(command)) {
100
- return;
101
- }
102
- throw new Error(
103
- `Unsupported Windows agent command wrapper: ${command}. ` +
104
- "Invoke wrapper scripts through their shell or interpreter instead " +
105
- "(for example `cmd.exe /c`, `powershell.exe -File`, or `node <script>`).",
106
- );
107
- }
108
-
109
- export function splitCommandLine(value, platform = process.platform) {
110
- const windowsCommand = splitWindowsExecutableCommand(value, platform);
111
- const parts = windowsCommand ?? splitCommandParts(value, platform);
112
- if (parts.length === 0) {
113
- throw new Error("Invalid agent command: empty command");
114
- }
115
- const parsed = Array.isArray(parts)
116
- ? {
117
- command: parts[0],
118
- args: parts.slice(1),
119
- }
120
- : parts;
121
- assertSupportedWindowsCommand(parsed.command, platform);
122
- return parsed;
123
- }
@@ -1,59 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- type SplitCommandLine = (
4
- value: string,
5
- platform?: string,
6
- ) => {
7
- command: string;
8
- args: string[];
9
- };
10
-
11
- async function loadSplitCommandLine(): Promise<SplitCommandLine> {
12
- const moduleUrl = new URL("./mcp-command-line.mjs", import.meta.url);
13
- return (await import(moduleUrl.href)).splitCommandLine as SplitCommandLine;
14
- }
15
-
16
- describe("mcp-command-line", () => {
17
- it("parses quoted Windows executable paths without dropping backslashes", async () => {
18
- const splitCommandLine = await loadSplitCommandLine();
19
- const parsed = splitCommandLine(
20
- '"C:\\Program Files\\Claude\\claude.exe" --stdio --flag "two words"',
21
- "win32",
22
- );
23
-
24
- expect(parsed).toEqual({
25
- command: "C:\\Program Files\\Claude\\claude.exe",
26
- args: ["--stdio", "--flag", "two words"],
27
- });
28
- });
29
-
30
- it("parses unquoted Windows executable paths without mangling backslashes", async () => {
31
- const splitCommandLine = await loadSplitCommandLine();
32
- const parsed = splitCommandLine("C:\\Users\\alerl\\.local\\bin\\claude.exe --version", "win32");
33
-
34
- expect(parsed).toEqual({
35
- command: "C:\\Users\\alerl\\.local\\bin\\claude.exe",
36
- args: ["--version"],
37
- });
38
- });
39
-
40
- it("preserves unquoted Windows path arguments after the executable", async () => {
41
- const splitCommandLine = await loadSplitCommandLine();
42
- const parsed = splitCommandLine(
43
- '"C:\\Program Files\\Claude\\claude.exe" --config C:\\Users\\me\\cfg.json',
44
- "win32",
45
- );
46
-
47
- expect(parsed).toEqual({
48
- command: "C:\\Program Files\\Claude\\claude.exe",
49
- args: ["--config", "C:\\Users\\me\\cfg.json"],
50
- });
51
- });
52
-
53
- it("rejects direct Windows wrapper-script commands with a helpful error", async () => {
54
- const splitCommandLine = await loadSplitCommandLine();
55
- expect(() =>
56
- splitCommandLine('"C:\\Users\\me\\bin\\claude-wrapper.cmd" --stdio', "win32"),
57
- ).toThrow(/Invoke wrapper scripts through their shell or interpreter instead/);
58
- });
59
- });