@actagent/acpx 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.
package/src/service.ts ADDED
@@ -0,0 +1,439 @@
1
+ /**
2
+ * ACPX plugin service lifecycle. It resolves config, prepares isolated adapter
3
+ * wrappers, registers the ACP backend, and manages startup/cleanup probes.
4
+ */
5
+ import { randomUUID } from "node:crypto";
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { inspect } from "node:util";
9
+ import { formatErrorMessage } from "actagent/plugin-sdk/error-runtime";
10
+ import { finiteSecondsToTimerSafeMilliseconds } from "actagent/plugin-sdk/number-runtime";
11
+ import type {
12
+ AcpRuntime,
13
+ ACTAgentPluginService,
14
+ ACTAgentPluginServiceContext,
15
+ PluginLogger,
16
+ } from "../runtime-api.js";
17
+ import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js";
18
+ import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
19
+ import { DEFAULT_ACPX_TIMEOUT_SECONDS } from "./config-schema.js";
20
+ import {
21
+ resolveAcpxPluginConfig,
22
+ toAcpMcpServers,
23
+ type ResolvedAcpxPluginConfig,
24
+ } from "./config.js";
25
+ import { createAcpxProcessLeaseStore, type AcpxProcessLeaseStore } from "./process-lease.js";
26
+ import {
27
+ cleanupACTAgentOwnedAcpxProcessTree,
28
+ reapStaleACTAgentOwnedAcpxOrphans,
29
+ type AcpxProcessCleanupDeps,
30
+ } from "./process-reaper.js";
31
+ import { createLazyAcpRuntimeProxy } from "./runtime-proxy.js";
32
+
33
+ type AcpxRuntimeLike = AcpRuntime & {
34
+ probeAvailability(): Promise<void>;
35
+ isHealthy(): boolean;
36
+ doctor?(): Promise<{
37
+ ok: boolean;
38
+ message: string;
39
+ details?: string[];
40
+ }>;
41
+ };
42
+ const ENABLE_STARTUP_PROBE_ENV = "ACTAGENT_ACPX_RUNTIME_STARTUP_PROBE";
43
+ const SKIP_RUNTIME_PROBE_ENV = "ACTAGENT_SKIP_ACPX_RUNTIME_PROBE";
44
+ const ACPX_BACKEND_ID = "acpx";
45
+
46
+ type AcpxRuntimeModule = typeof import("./runtime.js");
47
+ let runtimeModulePromise: Promise<AcpxRuntimeModule> | null = null;
48
+
49
+ type AcpxRuntimeFactoryParams = {
50
+ pluginConfig: ResolvedAcpxPluginConfig;
51
+ gatewayInstanceId: string;
52
+ processLeaseStore: AcpxProcessLeaseStore;
53
+ wrapperRoot: string;
54
+ logger?: PluginLogger;
55
+ };
56
+
57
+ type CreateAcpxRuntimeServiceParams = {
58
+ pluginConfig?: unknown;
59
+ runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike | Promise<AcpxRuntimeLike>;
60
+ processCleanupDeps?: AcpxProcessCleanupDeps;
61
+ };
62
+
63
+ function loadRuntimeModule(): Promise<AcpxRuntimeModule> {
64
+ runtimeModulePromise ??= import("./runtime.js");
65
+ return runtimeModulePromise;
66
+ }
67
+
68
+ /** Convert ACPX timeout seconds into timer-safe milliseconds. */
69
+ export function resolveAcpxTimerTimeoutMs(timeoutSeconds: number | undefined): number | undefined {
70
+ if (timeoutSeconds === undefined) {
71
+ return undefined;
72
+ }
73
+ return finiteSecondsToTimerSafeMilliseconds(timeoutSeconds) ?? 1;
74
+ }
75
+
76
+ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
77
+ let runtime: AcpxRuntimeLike | null = null;
78
+ let runtimePromise: Promise<AcpxRuntimeLike> | null = null;
79
+
80
+ async function resolveRuntime(): Promise<AcpxRuntimeLike> {
81
+ if (runtime) {
82
+ return runtime;
83
+ }
84
+ runtimePromise ??= loadRuntimeModule().then((module) => {
85
+ runtime = new module.AcpxRuntime({
86
+ cwd: params.pluginConfig.cwd,
87
+ actagentGatewayInstanceId: params.gatewayInstanceId,
88
+ actagentProcessLeaseStore: params.processLeaseStore,
89
+ actagentWrapperRoot: params.wrapperRoot,
90
+ sessionStore: module.createFileSessionStore({
91
+ stateDir: params.pluginConfig.stateDir,
92
+ }),
93
+ agentRegistry: module.createAgentRegistry({
94
+ overrides: params.pluginConfig.agents,
95
+ }),
96
+ probeAgent: params.pluginConfig.probeAgent,
97
+ mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
98
+ permissionMode: params.pluginConfig.permissionMode,
99
+ nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
100
+ timeoutMs: resolveAcpxTimerTimeoutMs(params.pluginConfig.timeoutSeconds),
101
+ }) as AcpxRuntimeLike;
102
+ return runtime;
103
+ });
104
+ return await runtimePromise;
105
+ }
106
+
107
+ return {
108
+ ...createLazyAcpRuntimeProxy(resolveRuntime),
109
+ async probeAvailability() {
110
+ await (await resolveRuntime()).probeAvailability();
111
+ },
112
+ isHealthy() {
113
+ return runtime?.isHealthy() ?? false;
114
+ },
115
+ };
116
+ }
117
+
118
+ function warnOnIgnoredLegacyCompatibilityConfig(params: {
119
+ pluginConfig: ResolvedAcpxPluginConfig;
120
+ logger?: PluginLogger;
121
+ }): void {
122
+ const ignoredFields: string[] = [];
123
+ if (params.pluginConfig.legacyCompatibilityConfig.queueOwnerTtlSeconds != null) {
124
+ ignoredFields.push("queueOwnerTtlSeconds");
125
+ }
126
+ if (params.pluginConfig.legacyCompatibilityConfig.strictWindowsCmdWrapper === false) {
127
+ ignoredFields.push("strictWindowsCmdWrapper=false");
128
+ }
129
+ if (ignoredFields.length === 0) {
130
+ return;
131
+ }
132
+ params.logger?.warn(
133
+ `embedded acpx runtime ignores legacy compatibility config: ${ignoredFields.join(", ")}`,
134
+ );
135
+ }
136
+
137
+ function formatDoctorDetail(detail: unknown): string | null {
138
+ if (!detail) {
139
+ return null;
140
+ }
141
+ if (typeof detail === "string") {
142
+ return detail.trim() || null;
143
+ }
144
+ if (detail instanceof Error) {
145
+ return formatErrorMessage(detail);
146
+ }
147
+ if (typeof detail === "object") {
148
+ try {
149
+ return JSON.stringify(detail) ?? inspect(detail, { breakLength: Infinity, depth: 3 });
150
+ } catch {
151
+ return inspect(detail, { breakLength: Infinity, depth: 3 });
152
+ }
153
+ }
154
+ if (
155
+ typeof detail === "number" ||
156
+ typeof detail === "boolean" ||
157
+ typeof detail === "bigint" ||
158
+ typeof detail === "symbol"
159
+ ) {
160
+ return detail.toString();
161
+ }
162
+ return inspect(detail, { breakLength: Infinity, depth: 3 });
163
+ }
164
+
165
+ function formatDoctorFailureMessage(report: { message: string; details?: unknown[] }): string {
166
+ const detailText = report.details?.map(formatDoctorDetail).filter(Boolean).join("; ").trim();
167
+ return detailText ? `${report.message} (${detailText})` : report.message;
168
+ }
169
+
170
+ function normalizeProbeAgent(value: string | undefined): string | undefined {
171
+ const normalized = value?.trim().toLowerCase();
172
+ return normalized ? normalized : undefined;
173
+ }
174
+
175
+ function resolveAllowedAgentsProbeAgent(ctx: ACTAgentPluginServiceContext): string | undefined {
176
+ for (const agent of ctx.config.acp?.allowedAgents ?? []) {
177
+ const normalized = normalizeProbeAgent(agent);
178
+ if (normalized) {
179
+ return normalized;
180
+ }
181
+ }
182
+ return undefined;
183
+ }
184
+
185
+ async function measureAcpxStartup<T>(
186
+ ctx: ACTAgentPluginServiceContext,
187
+ name: string,
188
+ run: () => T | Promise<T>,
189
+ ): Promise<T> {
190
+ return ctx.startupTrace ? await ctx.startupTrace.measure(name, run) : await run();
191
+ }
192
+
193
+ function detailAcpxStartup(
194
+ ctx: ACTAgentPluginServiceContext,
195
+ name: string,
196
+ metrics: ReadonlyArray<readonly [string, number | string]>,
197
+ ): void {
198
+ ctx.startupTrace?.detail?.(name, metrics);
199
+ }
200
+
201
+ function shouldRunStartupProbe(env: NodeJS.ProcessEnv = process.env): boolean {
202
+ return env[ENABLE_STARTUP_PROBE_ENV] !== "0";
203
+ }
204
+
205
+ function shouldProbeRuntimeAtStartup(env: NodeJS.ProcessEnv = process.env): boolean {
206
+ return shouldRunStartupProbe(env) && env[SKIP_RUNTIME_PROBE_ENV] !== "1";
207
+ }
208
+
209
+ async function withStartupProbeTimeout<T>(params: {
210
+ promise: Promise<T>;
211
+ timeoutSeconds: number;
212
+ }): Promise<T> {
213
+ let timeout: ReturnType<typeof setTimeout> | undefined;
214
+ const timeoutMs = resolveAcpxTimerTimeoutMs(params.timeoutSeconds) ?? 1;
215
+ try {
216
+ return await Promise.race([
217
+ params.promise,
218
+ new Promise<never>((_, reject) => {
219
+ timeout = setTimeout(() => {
220
+ reject(
221
+ new Error(
222
+ `embedded acpx runtime backend startup probe timed out after ${params.timeoutSeconds}s`,
223
+ ),
224
+ );
225
+ }, timeoutMs);
226
+ (timeout as { unref?: () => void }).unref?.();
227
+ }),
228
+ ]);
229
+ } finally {
230
+ if (timeout) {
231
+ clearTimeout(timeout);
232
+ }
233
+ }
234
+ }
235
+
236
+ async function resolveGatewayInstanceId(stateDir: string): Promise<string> {
237
+ const filePath = path.join(stateDir, "gateway-instance-id");
238
+ try {
239
+ const existing = (await fs.readFile(filePath, "utf8")).trim();
240
+ if (existing) {
241
+ return existing;
242
+ }
243
+ } catch (error) {
244
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
245
+ throw error;
246
+ }
247
+ }
248
+ const next = randomUUID();
249
+ await fs.mkdir(stateDir, { recursive: true });
250
+ await fs.writeFile(filePath, `${next}\n`, { mode: 0o600 });
251
+ return next;
252
+ }
253
+
254
+ async function reapOpenAcpxProcessLeases(params: {
255
+ gatewayInstanceId: string;
256
+ leaseStore: AcpxProcessLeaseStore;
257
+ deps?: AcpxProcessCleanupDeps;
258
+ }): Promise<{ inspectedPids: number[]; terminatedPids: number[] }> {
259
+ const leases = await params.leaseStore.listOpen(params.gatewayInstanceId);
260
+ const inspectedPids: number[] = [];
261
+ const terminatedPids: number[] = [];
262
+ const pendingLeaseRootResults = new Map<
263
+ string,
264
+ { inspectedPids: number[]; terminatedPids: number[] }
265
+ >();
266
+ for (const lease of leases) {
267
+ if (lease.rootPid <= 0) {
268
+ await params.leaseStore.markState(lease.leaseId, "closing");
269
+ let result = pendingLeaseRootResults.get(lease.wrapperRoot);
270
+ if (!result) {
271
+ result = await reapStaleACTAgentOwnedAcpxOrphans({
272
+ wrapperRoot: lease.wrapperRoot,
273
+ deps: params.deps,
274
+ });
275
+ pendingLeaseRootResults.set(lease.wrapperRoot, result);
276
+ inspectedPids.push(...result.inspectedPids);
277
+ terminatedPids.push(...result.terminatedPids);
278
+ }
279
+ await params.leaseStore.markState(
280
+ lease.leaseId,
281
+ result.terminatedPids.length > 0 ? "closed" : "lost",
282
+ );
283
+ continue;
284
+ }
285
+ await params.leaseStore.markState(lease.leaseId, "closing");
286
+ const result = await cleanupACTAgentOwnedAcpxProcessTree({
287
+ rootPid: lease.rootPid,
288
+ expectedLeaseId: lease.leaseId,
289
+ expectedGatewayInstanceId: lease.gatewayInstanceId,
290
+ wrapperRoot: lease.wrapperRoot,
291
+ deps: params.deps,
292
+ });
293
+ inspectedPids.push(...result.inspectedPids);
294
+ terminatedPids.push(...result.terminatedPids);
295
+ await params.leaseStore.markState(
296
+ lease.leaseId,
297
+ result.terminatedPids.length > 0 ? "closed" : "lost",
298
+ );
299
+ }
300
+ return { inspectedPids, terminatedPids };
301
+ }
302
+
303
+ /** Create the ACPX plugin service that owns runtime registration and cleanup. */
304
+ export function createAcpxRuntimeService(
305
+ params: CreateAcpxRuntimeServiceParams = {},
306
+ ): ACTAgentPluginService {
307
+ let runtime: AcpxRuntimeLike | null = null;
308
+ let lifecycleRevision = 0;
309
+
310
+ return {
311
+ id: "acpx-runtime",
312
+ async start(ctx: ACTAgentPluginServiceContext): Promise<void> {
313
+ if (process.env.ACTAGENT_SKIP_ACPX_RUNTIME === "1") {
314
+ ctx.logger.info("skipping embedded acpx runtime backend (ACTAGENT_SKIP_ACPX_RUNTIME=1)");
315
+ return;
316
+ }
317
+
318
+ const basePluginConfig = await measureAcpxStartup(ctx, "config.resolve", () =>
319
+ resolveAcpxPluginConfig({
320
+ rawConfig: params.pluginConfig,
321
+ workspaceDir: ctx.workspaceDir,
322
+ }),
323
+ );
324
+ const effectiveBasePluginConfig: ResolvedAcpxPluginConfig = {
325
+ ...basePluginConfig,
326
+ probeAgent: basePluginConfig.probeAgent ?? resolveAllowedAgentsProbeAgent(ctx),
327
+ };
328
+ const pluginConfig = await measureAcpxStartup(ctx, "config.prepare-codex-auth", () =>
329
+ prepareAcpxCodexAuthConfig({
330
+ pluginConfig: effectiveBasePluginConfig,
331
+ stateDir: ctx.stateDir,
332
+ logger: ctx.logger,
333
+ }),
334
+ );
335
+ const wrapperRoot = path.join(ctx.stateDir, "acpx");
336
+ await measureAcpxStartup(ctx, "filesystem.prepare", async () => {
337
+ await fs.mkdir(pluginConfig.stateDir, { recursive: true });
338
+ await fs.mkdir(wrapperRoot, { recursive: true });
339
+ });
340
+ const gatewayInstanceId = await measureAcpxStartup(ctx, "gateway-instance-id", () =>
341
+ resolveGatewayInstanceId(ctx.stateDir),
342
+ );
343
+ const processLeaseStore = createAcpxProcessLeaseStore({ stateDir: wrapperRoot });
344
+ const startupReap = await measureAcpxStartup(ctx, "process-leases.reap", () =>
345
+ reapOpenAcpxProcessLeases({
346
+ gatewayInstanceId,
347
+ leaseStore: processLeaseStore,
348
+ deps: params.processCleanupDeps,
349
+ }),
350
+ );
351
+ if (startupReap.terminatedPids.length > 0) {
352
+ ctx.logger.info(
353
+ `reaped ${startupReap.terminatedPids.length} stale ACTAgent-owned ACPX process${startupReap.terminatedPids.length === 1 ? "" : "es"}`,
354
+ );
355
+ }
356
+ warnOnIgnoredLegacyCompatibilityConfig({
357
+ pluginConfig,
358
+ logger: ctx.logger,
359
+ });
360
+
361
+ const startedRuntime = await measureAcpxStartup(ctx, "runtime.create", () =>
362
+ params.runtimeFactory
363
+ ? params.runtimeFactory({
364
+ pluginConfig,
365
+ gatewayInstanceId,
366
+ processLeaseStore,
367
+ wrapperRoot,
368
+ logger: ctx.logger,
369
+ })
370
+ : createLazyDefaultRuntime({
371
+ pluginConfig,
372
+ gatewayInstanceId,
373
+ processLeaseStore,
374
+ wrapperRoot,
375
+ logger: ctx.logger,
376
+ }),
377
+ );
378
+ runtime = startedRuntime;
379
+
380
+ const shouldProbeRuntime = shouldProbeRuntimeAtStartup();
381
+ detailAcpxStartup(ctx, "probe-policy", [
382
+ ["startupProbeEnabledCount", shouldProbeRuntime ? 1 : 0],
383
+ ["probeAgent", pluginConfig.probeAgent ?? "default"],
384
+ ]);
385
+ await measureAcpxStartup(ctx, "backend.register", () => {
386
+ registerAcpRuntimeBackend({
387
+ id: ACPX_BACKEND_ID,
388
+ runtime: startedRuntime,
389
+ ...(shouldProbeRuntime ? { healthy: () => runtime?.isHealthy() ?? false } : {}),
390
+ });
391
+ ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);
392
+ });
393
+
394
+ if (!shouldProbeRuntime) {
395
+ return;
396
+ }
397
+
398
+ lifecycleRevision += 1;
399
+ const currentRevision = lifecycleRevision;
400
+ try {
401
+ await measureAcpxStartup(ctx, "probe.availability", () =>
402
+ withStartupProbeTimeout({
403
+ promise: startedRuntime.probeAvailability(),
404
+ timeoutSeconds: pluginConfig.timeoutSeconds ?? DEFAULT_ACPX_TIMEOUT_SECONDS,
405
+ }),
406
+ );
407
+ if (currentRevision !== lifecycleRevision) {
408
+ return;
409
+ }
410
+ if (startedRuntime.isHealthy()) {
411
+ detailAcpxStartup(ctx, "probe.result", [["healthyCount", 1]]);
412
+ ctx.logger.info("embedded acpx runtime backend ready");
413
+ return;
414
+ }
415
+ const doctorReport = await measureAcpxStartup(ctx, "probe.doctor", () =>
416
+ startedRuntime.doctor?.(),
417
+ );
418
+ if (currentRevision !== lifecycleRevision) {
419
+ return;
420
+ }
421
+ detailAcpxStartup(ctx, "probe.result", [["healthyCount", 0]]);
422
+ ctx.logger.warn(
423
+ `embedded acpx runtime backend probe failed: ${doctorReport ? formatDoctorFailureMessage(doctorReport) : "backend remained unhealthy after probe"}`,
424
+ );
425
+ } catch (err) {
426
+ if (currentRevision !== lifecycleRevision) {
427
+ return;
428
+ }
429
+ detailAcpxStartup(ctx, "probe.result", [["healthyCount", 0]]);
430
+ ctx.logger.warn(`embedded acpx runtime setup failed: ${formatErrorMessage(err)}`);
431
+ }
432
+ },
433
+ async stop(_ctx: ACTAgentPluginServiceContext): Promise<void> {
434
+ lifecycleRevision += 1;
435
+ unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
436
+ runtime = null;
437
+ },
438
+ };
439
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../tsconfig.package-boundary.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "."
5
+ },
6
+ "include": ["./*.ts", "./src/**/*.ts"],
7
+ "exclude": [
8
+ "./**/*.test.ts",
9
+ "./dist/**",
10
+ "./node_modules/**",
11
+ "./src/test-support/**",
12
+ "./src/**/*test-helpers.ts",
13
+ "./src/**/*test-harness.ts",
14
+ "./src/**/*test-support.ts"
15
+ ]
16
+ }