@koreanhole/opencodex 2.95.10 → 2.97.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.
@@ -0,0 +1,545 @@
1
+ import { execFileSync, spawn as nodeSpawn } from "node:child_process";
2
+ import { Readable, Writable } from "node:stream";
3
+ import * as acp from "@agentclientprotocol/sdk";
4
+ import { commandInvocation } from "../../lib/win-exec";
5
+ import { redactSecretString } from "../../lib/redact";
6
+ import type { CliAgentTurnDeps } from "./types";
7
+
8
+ const DEFAULT_TIMEOUT_MS = 300_000;
9
+ const DEFAULT_KILL_GRACE_MS = 2_000;
10
+ const MAX_STDERR_BYTES = 8 * 1024;
11
+ const MAX_MODEL_ID_CHARS = 512;
12
+ const MAX_ACP_MODELS = 512;
13
+ const MAX_ACP_CONFIG_OPTIONS = 128;
14
+ const MAX_ACP_OPTION_VALUES = 512;
15
+ const MAX_MCP_SERVERS = 32;
16
+
17
+ export interface AcpModel {
18
+ modelId: string;
19
+ name?: string;
20
+ description?: string;
21
+ }
22
+
23
+ export interface AcpConfigOptionValue {
24
+ value: string;
25
+ name?: string;
26
+ description?: string;
27
+ }
28
+
29
+ export interface AcpConfigOption {
30
+ type: "select" | "boolean";
31
+ id: string;
32
+ name: string;
33
+ category?: string;
34
+ currentValue?: string | boolean;
35
+ options?: readonly AcpConfigOptionValue[];
36
+ }
37
+
38
+ export interface AcpSessionInfo {
39
+ sessionId: string;
40
+ currentModelId?: string;
41
+ availableModels: readonly AcpModel[];
42
+ configOptions: readonly AcpConfigOption[];
43
+ modelConfigOption?: AcpConfigOption;
44
+ }
45
+
46
+ export interface AcpAuthMethod {
47
+ id: string;
48
+ name: string;
49
+ description?: string;
50
+ }
51
+
52
+ export interface AcpInitialize {
53
+ protocolVersion: number;
54
+ authMethods: readonly AcpAuthMethod[];
55
+ agentVersion?: string;
56
+ }
57
+
58
+ export class AcpError extends Error {
59
+ constructor(
60
+ message: string,
61
+ readonly status: number,
62
+ readonly errorType: string,
63
+ readonly code: string,
64
+ readonly retryable: boolean,
65
+ readonly fallbackAllowed: boolean,
66
+ readonly authRequired = false,
67
+ ) {
68
+ super(message);
69
+ this.name = "AcpError";
70
+ }
71
+ }
72
+
73
+ export interface AcpDeps extends CliAgentTurnDeps {
74
+ env?: NodeJS.ProcessEnv;
75
+ }
76
+
77
+ export interface AcpHooks {
78
+ onSessionUpdate?: (notification: acp.SessionNotification) => void;
79
+ onPermission?: (params: acp.RequestPermissionRequest) => void;
80
+ onStderr?: (text: string) => void;
81
+ }
82
+
83
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
84
+ return value && typeof value === "object" && !Array.isArray(value)
85
+ ? value as Record<string, unknown>
86
+ : undefined;
87
+ }
88
+
89
+ function nonEmpty(value: unknown, max = MAX_MODEL_ID_CHARS): string | undefined {
90
+ if (typeof value !== "string") return undefined;
91
+ const trimmed = value.trim();
92
+ return trimmed && trimmed.length <= max ? trimmed : undefined;
93
+ }
94
+
95
+ export function safeAcpMessage(value: unknown, fallback = "ACP failed."): string {
96
+ const raw = value instanceof Error
97
+ ? value.message
98
+ : typeof value === "string"
99
+ ? value
100
+ : value === undefined
101
+ ? fallback
102
+ : JSON.stringify(value) ?? String(value);
103
+ return redactSecretString(raw).slice(0, 2_048);
104
+ }
105
+
106
+ export function isAcpAuthError(error: unknown): boolean {
107
+ return /auth(?:entication)?\s*(?:required|failed|missing)|not\s+(?:logged|signed)\s+in|sign[ -]?in|login required|unauthorized|api key (?:is )?missing|invalid (?:api )?key/i.test(safeAcpMessage(error));
108
+ }
109
+
110
+ function modelFrom(value: unknown): AcpModel | undefined {
111
+ const record = asRecord(value);
112
+ const modelId = nonEmpty(record?.modelId);
113
+ if (!modelId) return undefined;
114
+ const name = nonEmpty(record?.name, 512);
115
+ const description = nonEmpty(record?.description, 2_048);
116
+ return {
117
+ modelId,
118
+ ...(name ? { name } : {}),
119
+ ...(description ? { description } : {}),
120
+ };
121
+ }
122
+
123
+ function configOptionFrom(value: unknown): AcpConfigOption | undefined {
124
+ const record = asRecord(value);
125
+ if (!record) return undefined;
126
+ const id = nonEmpty(record.id, 512);
127
+ const name = nonEmpty(record.name, 512);
128
+ if (!id || !name) return undefined;
129
+ const type = record.type === "boolean" ? "boolean" : record.type === "select" ? "select" : undefined;
130
+ if (!type) return undefined;
131
+ const category = typeof record.category === "string" ? record.category : undefined;
132
+ const currentValue = typeof record.currentValue === "string" || typeof record.currentValue === "boolean"
133
+ ? record.currentValue
134
+ : undefined;
135
+ const options = Array.isArray(record.options)
136
+ ? record.options.slice(0, MAX_ACP_OPTION_VALUES).flatMap(item => {
137
+ const option = asRecord(item);
138
+ const optionValue = nonEmpty(option?.value, 512);
139
+ if (!optionValue) return [];
140
+ const optionName = nonEmpty(option?.name, 512);
141
+ const optionDescription = nonEmpty(option?.description, 2_048);
142
+ return [{
143
+ value: optionValue,
144
+ ...(optionName ? { name: optionName } : {}),
145
+ ...(optionDescription ? { description: optionDescription } : {}),
146
+ }];
147
+ })
148
+ : undefined;
149
+ return {
150
+ type,
151
+ id,
152
+ name,
153
+ ...(category ? { category } : {}),
154
+ ...(currentValue !== undefined ? { currentValue } : {}),
155
+ ...(options ? { options } : {}),
156
+ };
157
+ }
158
+
159
+ /** Parse the ACP session extension without requiring provider-specific fields. */
160
+ export function parseAcpSession(
161
+ response: unknown,
162
+ label = "ACP",
163
+ fallbackAllowed = true,
164
+ ): AcpSessionInfo {
165
+ const result = asRecord(response);
166
+ const sessionId = nonEmpty(result?.sessionId, 1_024);
167
+ if (!sessionId) throw new AcpError(`${label} returned a session without an id.`, 502, "upstream_error", "acp_protocol_error", false, fallbackAllowed);
168
+ const models = asRecord(result?.models);
169
+ const seen = new Set<string>();
170
+ const availableModels: AcpModel[] = [];
171
+ if (Array.isArray(models?.availableModels)) {
172
+ for (const item of models.availableModels.slice(0, MAX_ACP_MODELS)) {
173
+ const model = modelFrom(item);
174
+ if (!model || seen.has(model.modelId)) continue;
175
+ seen.add(model.modelId);
176
+ availableModels.push(model);
177
+ }
178
+ }
179
+ const configOptions = Array.isArray(result?.configOptions)
180
+ ? result.configOptions.slice(0, MAX_ACP_CONFIG_OPTIONS).flatMap(item => {
181
+ const option = configOptionFrom(item);
182
+ return option ? [option] : [];
183
+ })
184
+ : [];
185
+ const currentModelId = nonEmpty(models?.currentModelId);
186
+ const modelConfigOption = configOptions.find(option => option.type === "select" && option.category === "model");
187
+ return {
188
+ sessionId,
189
+ ...(currentModelId ? { currentModelId } : {}),
190
+ availableModels,
191
+ configOptions,
192
+ ...(modelConfigOption ? { modelConfigOption } : {}),
193
+ };
194
+ }
195
+
196
+ export function parseAcpInitialize(
197
+ value: unknown,
198
+ label = "ACP",
199
+ fallbackAllowed = true,
200
+ ): AcpInitialize {
201
+ const result = asRecord(value);
202
+ if (!result) throw new AcpError(`${label} initialize returned an invalid response.`, 502, "upstream_error", "acp_protocol_error", false, fallbackAllowed);
203
+ const version = typeof result.protocolVersion === "number" && Number.isInteger(result.protocolVersion)
204
+ ? result.protocolVersion
205
+ : undefined;
206
+ if (version !== acp.PROTOCOL_VERSION) {
207
+ throw new AcpError(
208
+ `${label} negotiated protocol ${String(version ?? "unknown")}; only v${acp.PROTOCOL_VERSION} is supported.`,
209
+ 502,
210
+ "upstream_error",
211
+ "acp_protocol_error",
212
+ false,
213
+ fallbackAllowed,
214
+ );
215
+ }
216
+ const authMethods = Array.isArray(result.authMethods)
217
+ ? result.authMethods.flatMap(item => {
218
+ const record = asRecord(item);
219
+ const id = nonEmpty(record?.id, 256);
220
+ const name = nonEmpty(record?.name, 512);
221
+ const description = nonEmpty(record?.description, 2_048);
222
+ return id && name ? [{ id, name, ...(description ? { description } : {}) }] : [];
223
+ })
224
+ : [];
225
+ const agentInfo = asRecord(result.agentInfo);
226
+ const agentVersion = nonEmpty(agentInfo?.version, 256);
227
+ return { protocolVersion: version, authMethods, ...(agentVersion ? { agentVersion } : {}) };
228
+ }
229
+
230
+ function acpError(error: unknown, fallbackAllowed: boolean, label: string): AcpError {
231
+ const authRequired = isAcpAuthError(error);
232
+ return new AcpError(
233
+ safeAcpMessage(error, `${label} failed.`),
234
+ authRequired ? 401 : 502,
235
+ authRequired ? "authentication_error" : "upstream_error",
236
+ authRequired ? "acp_auth_required" : "acp_transport_error",
237
+ false,
238
+ fallbackAllowed,
239
+ authRequired,
240
+ );
241
+ }
242
+
243
+ function killWindowsProcessTree(pid: number): void {
244
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
245
+ execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe", windowsHide: true });
246
+ }
247
+
248
+ export interface AcpClientOptions {
249
+ binary: string;
250
+ env: Record<string, string>;
251
+ cwd: string;
252
+ args?: readonly string[];
253
+ hooks?: AcpHooks;
254
+ label?: string;
255
+ fallbackAllowed?: boolean;
256
+ }
257
+
258
+ /** One request-scoped ACP child. Cleanup is idempotent and always reaps the process. */
259
+ export class AcpClient {
260
+ private readonly child: ReturnType<typeof nodeSpawn>;
261
+ private readonly connection: acp.ClientSideConnection;
262
+ private readonly processLifecycle: Promise<void>;
263
+ private readonly stderrChunks: string[] = [];
264
+ private stderrBytes = 0;
265
+ private readonly terminateOnce: () => Promise<void>;
266
+ private terminated = false;
267
+ private exited = false;
268
+ private readonly label: string;
269
+ private readonly fallbackAllowed: boolean;
270
+ sessionId?: string;
271
+ initializeResult?: AcpInitialize;
272
+
273
+ private constructor(
274
+ child: ReturnType<typeof nodeSpawn>,
275
+ connection: acp.ClientSideConnection,
276
+ processLifecycle: Promise<void>,
277
+ private readonly hooks: AcpHooks,
278
+ private readonly deps: AcpDeps,
279
+ label: string,
280
+ fallbackAllowed: boolean,
281
+ ) {
282
+ this.child = child;
283
+ this.connection = connection;
284
+ this.processLifecycle = processLifecycle;
285
+ this.label = label;
286
+ this.fallbackAllowed = fallbackAllowed;
287
+ this.terminateOnce = async () => {
288
+ if (this.terminated) return;
289
+ this.terminated = true;
290
+ try { this.child.stdin?.end(); } catch { /* already closed */ }
291
+ if (this.deps.platform === "win32" && this.child.pid !== undefined) {
292
+ try { (this.deps.killWindowsProcessTree ?? killWindowsProcessTree)(this.child.pid); } catch { /* direct fallback */ }
293
+ }
294
+ this.signalTree("SIGTERM");
295
+ await Promise.race([
296
+ this.processLifecycle,
297
+ new Promise<void>(resolve => setTimeout(resolve, this.deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS)),
298
+ ]);
299
+ if (this.child.exitCode === null || this.treeAlive()) {
300
+ this.signalTree("SIGKILL");
301
+ await Promise.race([
302
+ this.processLifecycle,
303
+ new Promise<void>(resolve => setTimeout(resolve, (this.deps.reapTimeoutMs ?? (this.deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS) * 2 + 250))),
304
+ ]);
305
+ }
306
+ };
307
+ }
308
+
309
+ private signalTree(signal: NodeJS.Signals): void {
310
+ const pid = this.child.pid;
311
+ if ((this.deps.platform ?? process.platform) !== "win32" && pid !== undefined) {
312
+ try {
313
+ process.kill(-pid, signal);
314
+ return;
315
+ } catch {
316
+ // Fall through when the child did not establish a process group.
317
+ }
318
+ }
319
+ try { this.child.kill(signal); } catch { /* already gone */ }
320
+ }
321
+
322
+ private treeAlive(): boolean {
323
+ const pid = this.child.pid;
324
+ if ((this.deps.platform ?? process.platform) === "win32" || pid === undefined) return false;
325
+ try {
326
+ process.kill(-pid, 0);
327
+ return true;
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+
333
+ static async connect(
334
+ options: AcpClientOptions,
335
+ deps: AcpDeps = {},
336
+ ): Promise<AcpClient> {
337
+ const hooks = options.hooks ?? {};
338
+ const label = options.label ?? "ACP";
339
+ const fallbackAllowed = options.fallbackAllowed ?? true;
340
+ const spawnFn = deps.spawn ?? (nodeSpawn as unknown as NonNullable<CliAgentTurnDeps["spawn"]>);
341
+ const invocation = commandInvocation(options.binary, [...(options.args ?? ["--acp"])], deps.platform ?? process.platform, { env: options.env });
342
+ let child: ReturnType<typeof nodeSpawn>;
343
+ try {
344
+ child = spawnFn(invocation.file, invocation.args, {
345
+ ...invocation.options,
346
+ cwd: options.cwd,
347
+ env: options.env,
348
+ stdio: ["pipe", "pipe", "pipe"],
349
+ windowsHide: true,
350
+ detached: (deps.platform ?? process.platform) !== "win32",
351
+ });
352
+ } catch (error) {
353
+ throw acpError(error, fallbackAllowed, label);
354
+ }
355
+ if (!child.stdin || !child.stdout) {
356
+ try { child.kill("SIGKILL"); } catch { /* ignore */ }
357
+ throw new AcpError(`${label} process did not expose stdio.`, 500, "upstream_error", "acp_spawn_failed", false, fallbackAllowed);
358
+ }
359
+ const processLifecycle = new Promise<void>(resolvePromise => {
360
+ let settled = false;
361
+ const settle = (): void => {
362
+ if (settled) return;
363
+ settled = true;
364
+ resolvePromise();
365
+ };
366
+ child.once("error", settle);
367
+ child.once("close", settle);
368
+ if (child.exitCode !== null) settle();
369
+ });
370
+ let instance: AcpClient;
371
+ const client: acp.Client = {
372
+ requestPermission: async params => {
373
+ hooks.onPermission?.(params);
374
+ if (instance?.sessionId) void instance.cancel(instance.sessionId).catch(() => undefined);
375
+ return { outcome: { outcome: "cancelled" } };
376
+ },
377
+ sessionUpdate: async notification => {
378
+ if (notification.sessionId === instance?.sessionId) hooks.onSessionUpdate?.(notification);
379
+ },
380
+ };
381
+ const stream = acp.ndJsonStream(
382
+ Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
383
+ Readable.toWeb(child.stdout) as unknown as ReadableStream<Uint8Array>,
384
+ );
385
+ const connection = new acp.ClientSideConnection(() => client, stream);
386
+ instance = new AcpClient(child, connection, processLifecycle, hooks, deps, label, fallbackAllowed);
387
+ child.stderr?.setEncoding("utf8");
388
+ child.stderr?.on("data", (chunk: string) => {
389
+ if (instance.stderrBytes >= MAX_STDERR_BYTES) return;
390
+ const remaining = MAX_STDERR_BYTES - instance.stderrBytes;
391
+ const admitted = chunk.slice(0, remaining);
392
+ instance.stderrChunks.push(admitted);
393
+ instance.stderrBytes += admitted.length;
394
+ hooks.onStderr?.(admitted);
395
+ });
396
+ child.once("close", () => { instance.exited = true; });
397
+ try {
398
+ const initialize = await deadline(
399
+ connection.initialize({
400
+ protocolVersion: acp.PROTOCOL_VERSION,
401
+ clientCapabilities: {},
402
+ clientInfo: { name: "opencodex", version: "1.0.0" },
403
+ }),
404
+ deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
405
+ () => instance.terminate(),
406
+ label,
407
+ fallbackAllowed,
408
+ );
409
+ instance.initializeResult = parseAcpInitialize(initialize, label, fallbackAllowed);
410
+ return instance;
411
+ } catch (error) {
412
+ await instance.terminate();
413
+ if (error instanceof AcpError) throw error;
414
+ throw acpError(error, fallbackAllowed, label);
415
+ }
416
+ }
417
+
418
+ stderrText(): string {
419
+ return this.stderrChunks.join("");
420
+ }
421
+
422
+ async newSession(params: {
423
+ cwd: string;
424
+ mcpServers: readonly Record<string, unknown>[];
425
+ }): Promise<AcpSessionInfo> {
426
+ try {
427
+ const response = await deadline(
428
+ this.connection.request<unknown>(acp.AGENT_METHODS.session_new, {
429
+ cwd: params.cwd,
430
+ mcpServers: params.mcpServers.slice(0, MAX_MCP_SERVERS),
431
+ }),
432
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
433
+ () => this.terminate(),
434
+ this.label,
435
+ this.fallbackAllowed,
436
+ );
437
+ const session = parseAcpSession(response, this.label, this.fallbackAllowed);
438
+ this.sessionId = session.sessionId;
439
+ return session;
440
+ } catch (error) {
441
+ if (error instanceof AcpError) throw error;
442
+ throw acpError(error, this.fallbackAllowed, this.label);
443
+ }
444
+ }
445
+
446
+ async setModel(sessionId: string, modelId: string): Promise<void> {
447
+ await deadline(
448
+ this.connection.request("session/set_model", { sessionId, modelId }),
449
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
450
+ () => this.terminate(),
451
+ this.label,
452
+ this.fallbackAllowed,
453
+ ).catch(error => {
454
+ if (error instanceof AcpError) throw error;
455
+ throw new AcpError(`${this.label} rejected session/set_model.`, 502, "upstream_error", "acp_transport_error", false, this.fallbackAllowed);
456
+ });
457
+ }
458
+
459
+ async setConfigOption(sessionId: string, configId: string, value: string): Promise<void> {
460
+ await deadline(
461
+ this.connection.request(acp.AGENT_METHODS.session_set_config_option, {
462
+ sessionId,
463
+ configId,
464
+ value,
465
+ }),
466
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
467
+ () => this.terminate(),
468
+ this.label,
469
+ this.fallbackAllowed,
470
+ ).catch(error => {
471
+ if (error instanceof AcpError) throw error;
472
+ throw new AcpError(`${this.label} rejected session/set_config_option.`, 502, "upstream_error", "acp_transport_error", false, this.fallbackAllowed);
473
+ });
474
+ }
475
+
476
+ async setMode(sessionId: string, modeId: string): Promise<void> {
477
+ await deadline(
478
+ this.connection.request(acp.AGENT_METHODS.session_set_mode, { sessionId, modeId }),
479
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
480
+ () => this.terminate(),
481
+ this.label,
482
+ this.fallbackAllowed,
483
+ ).catch(error => {
484
+ if (error instanceof AcpError) throw error;
485
+ throw new AcpError(`${this.label} rejected session/set_mode.`, 502, "upstream_error", "acp_transport_error", false, this.fallbackAllowed);
486
+ });
487
+ }
488
+
489
+ async authenticate(methodId: string): Promise<void> {
490
+ await deadline(
491
+ this.connection.authenticate({ methodId }),
492
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
493
+ () => this.terminate(),
494
+ this.label,
495
+ this.fallbackAllowed,
496
+ ).catch(error => {
497
+ if (error instanceof AcpError) throw error;
498
+ throw acpError(error, this.fallbackAllowed, this.label);
499
+ });
500
+ }
501
+
502
+ async prompt(sessionId: string, text: string, cancellationSignal?: AbortSignal): Promise<acp.PromptResponse> {
503
+ return await deadline(
504
+ this.connection.request(
505
+ acp.AGENT_METHODS.session_prompt,
506
+ { sessionId, prompt: [{ type: "text" as const, text }] },
507
+ { ...(cancellationSignal ? { cancellationSignal } : {}) },
508
+ ),
509
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
510
+ () => this.terminate(),
511
+ this.label,
512
+ this.fallbackAllowed,
513
+ );
514
+ }
515
+
516
+ async cancel(sessionId: string): Promise<void> {
517
+ if (this.exited) return;
518
+ await this.connection.cancel({ sessionId });
519
+ }
520
+
521
+ async terminate(): Promise<void> {
522
+ await this.terminateOnce();
523
+ }
524
+ }
525
+
526
+ async function deadline<T>(
527
+ promise: Promise<T>,
528
+ ms: number,
529
+ onTimeout: () => Promise<void>,
530
+ label: string,
531
+ fallbackAllowed: boolean,
532
+ ): Promise<T> {
533
+ let timer: ReturnType<typeof setTimeout> | undefined;
534
+ const timeout = new Promise<never>((_, reject) => {
535
+ timer = setTimeout(() => {
536
+ void onTimeout().catch(() => undefined);
537
+ reject(new AcpError(`${label} request timed out.`, 504, "upstream_error", "timeout", true, fallbackAllowed));
538
+ }, ms);
539
+ });
540
+ try {
541
+ return await Promise.race([promise, timeout]);
542
+ } finally {
543
+ if (timer) clearTimeout(timer);
544
+ }
545
+ }