@nathapp/nax 0.40.1 → 0.41.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 (37) hide show
  1. package/dist/nax.js +1072 -268
  2. package/package.json +2 -2
  3. package/src/acceptance/fix-generator.ts +4 -35
  4. package/src/acceptance/generator.ts +4 -27
  5. package/src/agents/acp/adapter.ts +644 -0
  6. package/src/agents/acp/cost.ts +79 -0
  7. package/src/agents/acp/index.ts +9 -0
  8. package/src/agents/acp/interaction-bridge.ts +126 -0
  9. package/src/agents/acp/parser.ts +166 -0
  10. package/src/agents/acp/spawn-client.ts +309 -0
  11. package/src/agents/acp/types.ts +22 -0
  12. package/src/agents/claude-complete.ts +3 -3
  13. package/src/agents/registry.ts +83 -0
  14. package/src/agents/types-extended.ts +23 -0
  15. package/src/agents/types.ts +17 -0
  16. package/src/cli/analyze.ts +6 -2
  17. package/src/cli/plan.ts +23 -0
  18. package/src/config/defaults.ts +1 -0
  19. package/src/config/runtime-types.ts +10 -0
  20. package/src/config/schema.ts +1 -0
  21. package/src/config/schemas.ts +6 -0
  22. package/src/config/types.ts +1 -0
  23. package/src/execution/executor-types.ts +6 -0
  24. package/src/execution/iteration-runner.ts +2 -0
  25. package/src/execution/lifecycle/acceptance-loop.ts +5 -2
  26. package/src/execution/lifecycle/run-initialization.ts +16 -4
  27. package/src/execution/lifecycle/run-setup.ts +4 -0
  28. package/src/execution/runner-completion.ts +11 -1
  29. package/src/execution/runner-execution.ts +8 -0
  30. package/src/execution/runner-setup.ts +4 -0
  31. package/src/execution/runner.ts +10 -0
  32. package/src/pipeline/stages/execution.ts +33 -1
  33. package/src/pipeline/stages/routing.ts +18 -7
  34. package/src/pipeline/types.ts +10 -0
  35. package/src/tdd/orchestrator.ts +7 -0
  36. package/src/tdd/rectification-gate.ts +6 -0
  37. package/src/tdd/session-runner.ts +4 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * ACP cost estimation from token usage.
3
+ *
4
+ * Stub — implementation in ACP-006.
5
+ */
6
+
7
+ /**
8
+ * Token usage data from an ACP session's cumulative_token_usage field.
9
+ */
10
+ export interface SessionTokenUsage {
11
+ input_tokens: number;
12
+ output_tokens: number;
13
+ /** Cache read tokens — billed at a reduced rate */
14
+ cache_read_input_tokens?: number;
15
+ /** Cache creation tokens — billed at a higher creation rate */
16
+ cache_creation_input_tokens?: number;
17
+ }
18
+
19
+ /**
20
+ * Per-model pricing in $/1M tokens: { input, output }
21
+ */
22
+ const MODEL_PRICING: Record<string, { input: number; output: number; cacheRead?: number; cacheCreation?: number }> = {
23
+ // Anthropic Claude models
24
+ "claude-sonnet-4": { input: 3, output: 15 },
25
+ "claude-sonnet-4-5": { input: 3, output: 15 },
26
+ "claude-haiku": { input: 0.8, output: 4.0, cacheRead: 0.1, cacheCreation: 1.0 },
27
+ "claude-haiku-4-5": { input: 0.8, output: 4.0, cacheRead: 0.1, cacheCreation: 1.0 },
28
+ "claude-opus": { input: 15, output: 75 },
29
+ "claude-opus-4": { input: 15, output: 75 },
30
+
31
+ // OpenAI models
32
+ "gpt-4.1": { input: 10, output: 30 },
33
+ "gpt-4": { input: 30, output: 60 },
34
+ "gpt-3.5-turbo": { input: 0.5, output: 1.5 },
35
+
36
+ // Google Gemini
37
+ "gemini-2.5-pro": { input: 0.075, output: 0.3 },
38
+ "gemini-2-pro": { input: 0.075, output: 0.3 },
39
+
40
+ // OpenAI Codex
41
+ codex: { input: 0.02, output: 0.06 },
42
+ "code-davinci-002": { input: 0.02, output: 0.06 },
43
+ };
44
+
45
+ /**
46
+ * Calculate USD cost from ACP session token counts using per-model pricing.
47
+ *
48
+ * @param usage - Token counts from cumulative_token_usage
49
+ * @param model - Model identifier (e.g., 'claude-sonnet-4', 'claude-haiku-4-5')
50
+ * @returns Estimated cost in USD
51
+ */
52
+ export function estimateCostFromTokenUsage(usage: SessionTokenUsage, model: string): number {
53
+ const pricing = MODEL_PRICING[model];
54
+
55
+ if (!pricing) {
56
+ // Fallback: use average rate for unknown models
57
+ // Average of known rates: ~$5/1M tokens combined
58
+ const fallbackInputRate = 3 / 1_000_000;
59
+ const fallbackOutputRate = 15 / 1_000_000;
60
+ const inputCost = (usage.input_tokens ?? 0) * fallbackInputRate;
61
+ const outputCost = (usage.output_tokens ?? 0) * fallbackOutputRate;
62
+ const cacheReadCost = (usage.cache_read_input_tokens ?? 0) * (0.5 / 1_000_000);
63
+ const cacheCreationCost = (usage.cache_creation_input_tokens ?? 0) * (2 / 1_000_000);
64
+ return inputCost + outputCost + cacheReadCost + cacheCreationCost;
65
+ }
66
+
67
+ // Convert $/1M rates to $/token
68
+ const inputRate = pricing.input / 1_000_000;
69
+ const outputRate = pricing.output / 1_000_000;
70
+ const cacheReadRate = (pricing.cacheRead ?? pricing.input * 0.1) / 1_000_000;
71
+ const cacheCreationRate = (pricing.cacheCreation ?? pricing.input * 0.33) / 1_000_000;
72
+
73
+ const inputCost = (usage.input_tokens ?? 0) * inputRate;
74
+ const outputCost = (usage.output_tokens ?? 0) * outputRate;
75
+ const cacheReadCost = (usage.cache_read_input_tokens ?? 0) * cacheReadRate;
76
+ const cacheCreationCost = (usage.cache_creation_input_tokens ?? 0) * cacheCreationRate;
77
+
78
+ return inputCost + outputCost + cacheReadCost + cacheCreationCost;
79
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * ACP Agent Adapter — barrel exports
3
+ */
4
+
5
+ export { AcpAgentAdapter, _acpAdapterDeps } from "./adapter";
6
+ export { createSpawnAcpClient } from "./spawn-client";
7
+ export { estimateCostFromTokenUsage } from "./cost";
8
+ export type { SessionTokenUsage } from "./cost";
9
+ export type { AgentRegistryEntry } from "./types";
@@ -0,0 +1,126 @@
1
+ /**
2
+ * AcpInteractionBridge — connects ACP sessionUpdate notifications to nax interaction chain
3
+ *
4
+ * Detects question patterns in agent messages and routes them to the interaction plugin,
5
+ * enabling mid-session agent ↔ human communication.
6
+ */
7
+
8
+ import type { InteractionPlugin, InteractionRequest, InteractionResponse } from "../../interaction/types";
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Types
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ export interface SessionNotification {
15
+ sessionId: string;
16
+ role: string;
17
+ content: string;
18
+ timestamp: number;
19
+ }
20
+
21
+ export interface BridgeConfig {
22
+ featureName: string;
23
+ storyId: string;
24
+ responseTimeoutMs: number;
25
+ fallbackPrompt: string;
26
+ }
27
+
28
+ type BridgeEvent = "question-detected" | "response-received";
29
+ type BridgeEventHandler = (event: unknown) => void;
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // Question pattern detection
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ const QUESTION_PATTERNS = [/\?/, /\bwhich\b/i, /\bshould i\b/i, /\bunclear\b/i, /\bplease clarify\b/i];
36
+
37
+ function containsQuestionPattern(content: string): boolean {
38
+ return QUESTION_PATTERNS.some((pattern) => pattern.test(content));
39
+ }
40
+
41
+ function generateRequestId(): string {
42
+ return `ix-acp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
43
+ }
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // AcpInteractionBridge
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ export class AcpInteractionBridge {
50
+ private readonly plugin: InteractionPlugin;
51
+ private readonly config: BridgeConfig;
52
+ private destroyed = false;
53
+ private readonly listeners = new Map<BridgeEvent, BridgeEventHandler[]>();
54
+
55
+ constructor(plugin: InteractionPlugin, config: BridgeConfig) {
56
+ this.plugin = plugin;
57
+ this.config = config;
58
+ }
59
+
60
+ isQuestion(notification: SessionNotification): boolean {
61
+ if (notification.role !== "assistant") return false;
62
+ return containsQuestionPattern(notification.content);
63
+ }
64
+
65
+ async onSessionUpdate(notification: SessionNotification): Promise<void> {
66
+ if (this.destroyed) return;
67
+ if (!this.isQuestion(notification)) return;
68
+
69
+ const request: InteractionRequest = {
70
+ id: generateRequestId(),
71
+ type: "input",
72
+ featureName: this.config.featureName,
73
+ storyId: this.config.storyId,
74
+ stage: "execution",
75
+ summary: notification.content,
76
+ fallback: "continue",
77
+ createdAt: Date.now(),
78
+ };
79
+
80
+ this.emit("question-detected", { requestId: request.id, sessionId: notification.sessionId });
81
+
82
+ await this.plugin.send(request);
83
+ }
84
+
85
+ async waitForResponse(requestId: string, timeout: number): Promise<InteractionResponse> {
86
+ try {
87
+ const response = await this.plugin.receive(requestId, timeout);
88
+ this.emit("response-received", { requestId, respondedBy: response.respondedBy });
89
+ return response;
90
+ } catch {
91
+ const fallback: InteractionResponse = {
92
+ requestId,
93
+ action: "input",
94
+ value: "continue",
95
+ respondedBy: "timeout",
96
+ respondedAt: Date.now(),
97
+ };
98
+ this.emit("response-received", { requestId, respondedBy: "timeout" });
99
+ return fallback;
100
+ }
101
+ }
102
+
103
+ getFollowUpPrompt(response: InteractionResponse): string {
104
+ if (!response.value) {
105
+ return this.config.fallbackPrompt;
106
+ }
107
+ return response.value;
108
+ }
109
+
110
+ async destroy(): Promise<void> {
111
+ this.destroyed = true;
112
+ }
113
+
114
+ on(event: BridgeEvent, handler: BridgeEventHandler): void {
115
+ const handlers = this.listeners.get(event) ?? [];
116
+ handlers.push(handler);
117
+ this.listeners.set(event, handlers);
118
+ }
119
+
120
+ private emit(event: BridgeEvent, data: unknown): void {
121
+ const handlers = this.listeners.get(event) ?? [];
122
+ for (const handler of handlers) {
123
+ handler(data);
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * ACP adapter — NDJSON and JSON-RPC output parsing helpers.
3
+ *
4
+ * Extracted from adapter.ts to keep that file within the 800-line limit.
5
+ * Used only by _runOnce() (the spawn-based legacy path).
6
+ */
7
+
8
+ import type { AgentRunOptions } from "../types";
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Types
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ /** Token usage from acpx NDJSON events */
15
+ export interface AcpxTokenUsage {
16
+ input_tokens: number;
17
+ output_tokens: number;
18
+ }
19
+
20
+ /** JSON-RPC message from acpx --format json --json-strict */
21
+ interface JsonRpcMessage {
22
+ jsonrpc: "2.0";
23
+ method?: string;
24
+ params?: {
25
+ sessionId: string;
26
+ update?: {
27
+ sessionUpdate: string;
28
+ content?: { type: string; text?: string };
29
+ used?: number;
30
+ size?: number;
31
+ cost?: { amount: number; currency: string };
32
+ };
33
+ };
34
+ id?: number | string;
35
+ result?: unknown;
36
+ error?: { code: number; message: string };
37
+ }
38
+
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+ // streamJsonRpcEvents
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Stream stdout line-by-line, parse JSON-RPC, detect questions, call bridge.
45
+ */
46
+ export async function streamJsonRpcEvents(
47
+ stdout: ReadableStream<Uint8Array>,
48
+ bridge: AgentRunOptions["interactionBridge"],
49
+ _sessionId: string,
50
+ ): Promise<{ text: string; tokenUsage?: AcpxTokenUsage }> {
51
+ let accumulatedText = "";
52
+ let tokenUsage: AcpxTokenUsage | undefined;
53
+ const decoder = new TextDecoder();
54
+ let buffer = "";
55
+
56
+ const reader = stdout.getReader();
57
+
58
+ try {
59
+ while (true) {
60
+ const { done, value } = await reader.read();
61
+ if (done) break;
62
+
63
+ buffer += decoder.decode(value, { stream: true });
64
+ const lines = buffer.split("\n");
65
+ buffer = lines.pop() ?? "";
66
+
67
+ for (const line of lines) {
68
+ if (!line.trim()) continue;
69
+
70
+ let msg: JsonRpcMessage;
71
+ try {
72
+ msg = JSON.parse(line);
73
+ } catch {
74
+ continue;
75
+ }
76
+
77
+ if (msg.method === "session/update" && msg.params?.update) {
78
+ const update = msg.params.update;
79
+
80
+ if (
81
+ update.sessionUpdate === "agent_message_chunk" &&
82
+ update.content?.type === "text" &&
83
+ update.content.text
84
+ ) {
85
+ accumulatedText += update.content.text;
86
+
87
+ if (bridge?.detectQuestion && bridge.onQuestionDetected) {
88
+ const isQuestion = await bridge.detectQuestion(accumulatedText);
89
+ if (isQuestion) {
90
+ const response = await bridge.onQuestionDetected(accumulatedText);
91
+ accumulatedText += `\n\n[Human response: ${response}]`;
92
+ }
93
+ }
94
+ }
95
+
96
+ if (update.sessionUpdate === "usage_update" && update.used !== undefined) {
97
+ const total = update.used;
98
+ tokenUsage = {
99
+ input_tokens: Math.floor(total * 0.3),
100
+ output_tokens: Math.floor(total * 0.7),
101
+ };
102
+ }
103
+ }
104
+
105
+ if (msg.result) {
106
+ const result = msg.result as Record<string, unknown>;
107
+ if (typeof result === "string") {
108
+ accumulatedText += result;
109
+ }
110
+ }
111
+ }
112
+ }
113
+ } finally {
114
+ reader.releaseLock();
115
+ }
116
+
117
+ return { text: accumulatedText.trim(), tokenUsage };
118
+ }
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ // parseAcpxJsonOutput
122
+ // ─────────────────────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Parse acpx NDJSON output for assistant text and token usage.
126
+ */
127
+ export function parseAcpxJsonOutput(rawOutput: string): {
128
+ text: string;
129
+ tokenUsage?: AcpxTokenUsage;
130
+ stopReason?: string;
131
+ error?: string;
132
+ } {
133
+ const lines = rawOutput.split("\n").filter((l) => l.trim());
134
+ let text = "";
135
+ let tokenUsage: AcpxTokenUsage | undefined;
136
+ let stopReason: string | undefined;
137
+ let error: string | undefined;
138
+
139
+ for (const line of lines) {
140
+ try {
141
+ const event = JSON.parse(line);
142
+
143
+ if (event.content && typeof event.content === "string") text += event.content;
144
+ if (event.text && typeof event.text === "string") text += event.text;
145
+ if (event.result && typeof event.result === "string") text = event.result;
146
+
147
+ if (event.cumulative_token_usage) tokenUsage = event.cumulative_token_usage;
148
+ if (event.usage) {
149
+ tokenUsage = {
150
+ input_tokens: event.usage.input_tokens ?? event.usage.prompt_tokens ?? 0,
151
+ output_tokens: event.usage.output_tokens ?? event.usage.completion_tokens ?? 0,
152
+ };
153
+ }
154
+
155
+ if (event.stopReason) stopReason = event.stopReason;
156
+ if (event.stop_reason) stopReason = event.stop_reason;
157
+ if (event.error) {
158
+ error = typeof event.error === "string" ? event.error : (event.error.message ?? JSON.stringify(event.error));
159
+ }
160
+ } catch {
161
+ if (!text) text = line;
162
+ }
163
+ }
164
+
165
+ return { text: text.trim(), tokenUsage, stopReason, error };
166
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Spawn-based ACP Client — default production implementation.
3
+ *
4
+ * Implements AcpClient/AcpSession interfaces by shelling out to acpx CLI.
5
+ * This is the real transport; createClient injectable defaults to this.
6
+ * Tests override createClient with mock implementations.
7
+ *
8
+ * CLI commands used:
9
+ * acpx <agent> sessions ensure --name <name> → ensureSession
10
+ * acpx --cwd <dir> ... <agent> prompt -s <name> → session.prompt()
11
+ * acpx <agent> sessions close <name> → session.close()
12
+ * acpx <agent> cancel → session.cancelActivePrompt()
13
+ */
14
+
15
+ import { getSafeLogger } from "../../logger";
16
+ import type { AcpClient, AcpSession, AcpSessionResponse } from "./adapter";
17
+ import { parseAcpxJsonOutput } from "./parser";
18
+
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Constants
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ const ACPX_WATCHDOG_BUFFER_MS = 30_000;
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // Spawn helper (injectable for future testing if needed)
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ export const _spawnClientDeps = {
30
+ spawn(
31
+ cmd: string[],
32
+ opts: {
33
+ cwd?: string;
34
+ stdin?: "pipe" | "inherit";
35
+ stdout: "pipe";
36
+ stderr: "pipe";
37
+ env?: Record<string, string | undefined>;
38
+ },
39
+ ): {
40
+ stdout: ReadableStream<Uint8Array>;
41
+ stderr: ReadableStream<Uint8Array>;
42
+ stdin: { write(data: string | Uint8Array): number; end(): void; flush(): void };
43
+ exited: Promise<number>;
44
+ pid: number;
45
+ kill(signal?: number): void;
46
+ } {
47
+ return Bun.spawn(cmd, opts) as unknown as ReturnType<typeof _spawnClientDeps.spawn>;
48
+ },
49
+ };
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+ // Env builder
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Build allowed environment variables for spawned acpx processes.
57
+ * SEC-4: Only pass essential env vars to prevent leaking sensitive data.
58
+ */
59
+ function buildAllowedEnv(extraEnv?: Record<string, string | undefined>): Record<string, string | undefined> {
60
+ const allowed: Record<string, string | undefined> = {};
61
+
62
+ const essentialVars = ["PATH", "HOME", "TMPDIR", "NODE_ENV", "USER", "LOGNAME"];
63
+ for (const varName of essentialVars) {
64
+ if (process.env[varName]) allowed[varName] = process.env[varName];
65
+ }
66
+
67
+ const apiKeyVars = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "CLAUDE_API_KEY"];
68
+ for (const varName of apiKeyVars) {
69
+ if (process.env[varName]) allowed[varName] = process.env[varName];
70
+ }
71
+
72
+ const allowedPrefixes = ["CLAUDE_", "NAX_", "CLAW_", "TURBO_", "ACPX_", "CODEX_", "GEMINI_"];
73
+ for (const [key, value] of Object.entries(process.env)) {
74
+ if (allowedPrefixes.some((prefix) => key.startsWith(prefix))) {
75
+ allowed[key] = value;
76
+ }
77
+ }
78
+
79
+ if (extraEnv) Object.assign(allowed, extraEnv);
80
+ return allowed;
81
+ }
82
+
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+ // SpawnAcpSession
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * An ACP session backed by acpx CLI spawn.
89
+ * Each prompt() call spawns: acpx --cwd ... <agent> prompt -s <name> --file -
90
+ */
91
+ class SpawnAcpSession implements AcpSession {
92
+ private readonly agentName: string;
93
+ private readonly sessionName: string;
94
+ private readonly cwd: string;
95
+ private readonly model: string;
96
+ private readonly timeoutSeconds: number;
97
+ private readonly permissionMode: string;
98
+ private readonly env: Record<string, string | undefined>;
99
+
100
+ constructor(opts: {
101
+ agentName: string;
102
+ sessionName: string;
103
+ cwd: string;
104
+ model: string;
105
+ timeoutSeconds: number;
106
+ permissionMode: string;
107
+ env: Record<string, string | undefined>;
108
+ }) {
109
+ this.agentName = opts.agentName;
110
+ this.sessionName = opts.sessionName;
111
+ this.cwd = opts.cwd;
112
+ this.model = opts.model;
113
+ this.timeoutSeconds = opts.timeoutSeconds;
114
+ this.permissionMode = opts.permissionMode;
115
+ this.env = opts.env;
116
+ }
117
+
118
+ async prompt(text: string): Promise<AcpSessionResponse> {
119
+ const cmd = [
120
+ "acpx",
121
+ "--cwd",
122
+ this.cwd,
123
+ ...(this.permissionMode === "approve-all" ? ["--approve-all"] : []),
124
+ "--format",
125
+ "json",
126
+ "--model",
127
+ this.model,
128
+ "--timeout",
129
+ String(this.timeoutSeconds),
130
+ this.agentName,
131
+ "prompt",
132
+ "-s",
133
+ this.sessionName,
134
+ "--file",
135
+ "-",
136
+ ];
137
+
138
+ getSafeLogger()?.debug("acp-adapter", `Sending prompt to session: ${this.sessionName}`);
139
+
140
+ const proc = _spawnClientDeps.spawn(cmd, {
141
+ cwd: this.cwd,
142
+ stdin: "pipe",
143
+ stdout: "pipe",
144
+ stderr: "pipe",
145
+ env: this.env,
146
+ });
147
+
148
+ proc.stdin.write(text);
149
+ proc.stdin.end();
150
+
151
+ const exitCode = await proc.exited;
152
+ const stdout = await new Response(proc.stdout).text();
153
+ const stderr = await new Response(proc.stderr).text();
154
+
155
+ if (exitCode !== 0) {
156
+ getSafeLogger()?.warn("acp-adapter", `Session prompt exited with code ${exitCode}`, {
157
+ stderr: stderr.slice(0, 200),
158
+ });
159
+ // Return error response so the adapter can handle it
160
+ return {
161
+ messages: [{ role: "assistant", content: stderr || `Exit code ${exitCode}` }],
162
+ stopReason: "error",
163
+ };
164
+ }
165
+
166
+ try {
167
+ const parsed = parseAcpxJsonOutput(stdout);
168
+ return {
169
+ messages: [{ role: "assistant", content: parsed.text || "" }],
170
+ stopReason: "end_turn",
171
+ cumulative_token_usage: parsed.tokenUsage,
172
+ };
173
+ } catch (err) {
174
+ getSafeLogger()?.warn("acp-adapter", "Failed to parse session prompt response", {
175
+ stderr: stderr.slice(0, 200),
176
+ });
177
+ throw err;
178
+ }
179
+ }
180
+
181
+ async close(): Promise<void> {
182
+ const cmd = ["acpx", this.agentName, "sessions", "close", this.sessionName];
183
+ getSafeLogger()?.debug("acp-adapter", `Closing session: ${this.sessionName}`);
184
+
185
+ const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
186
+ const exitCode = await proc.exited;
187
+
188
+ if (exitCode !== 0) {
189
+ const stderr = await new Response(proc.stderr).text();
190
+ getSafeLogger()?.warn("acp-adapter", "Failed to close session", {
191
+ sessionName: this.sessionName,
192
+ stderr: stderr.slice(0, 200),
193
+ });
194
+ }
195
+ }
196
+
197
+ async cancelActivePrompt(): Promise<void> {
198
+ const cmd = ["acpx", this.agentName, "cancel"];
199
+ getSafeLogger()?.debug("acp-adapter", `Cancelling active prompt: ${this.sessionName}`);
200
+
201
+ const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
202
+ await proc.exited;
203
+ }
204
+ }
205
+
206
+ // ─────────────────────────────────────────────────────────────────────────────
207
+ // SpawnAcpClient
208
+ // ─────────────────────────────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * ACP client backed by acpx CLI.
212
+ *
213
+ * The cmdStr is parsed to extract --model and agent name:
214
+ * "acpx --model claude-sonnet-4-5 claude" → model=claude-sonnet-4-5, agent=claude
215
+ *
216
+ * createSession() spawns: acpx <agent> sessions ensure --name <name>
217
+ * loadSession() tries to resume an existing named session.
218
+ */
219
+ export class SpawnAcpClient implements AcpClient {
220
+ private readonly agentName: string;
221
+ private readonly model: string;
222
+ private readonly cwd: string;
223
+ private readonly timeoutSeconds: number;
224
+ private readonly env: Record<string, string | undefined>;
225
+
226
+ constructor(cmdStr: string, cwd?: string, timeoutSeconds?: number) {
227
+ // Parse: "acpx --model <model> <agentName>"
228
+ const parts = cmdStr.split(/\s+/);
229
+ const modelIdx = parts.indexOf("--model");
230
+ this.model = modelIdx >= 0 && parts[modelIdx + 1] ? parts[modelIdx + 1] : "default";
231
+ // Agent name is the last non-flag token
232
+ this.agentName = parts[parts.length - 1] || "claude";
233
+ this.cwd = cwd || process.cwd();
234
+ this.timeoutSeconds = timeoutSeconds || 1800;
235
+ this.env = buildAllowedEnv();
236
+ }
237
+
238
+ async start(): Promise<void> {
239
+ // No-op — spawn-based client doesn't need upfront initialization
240
+ }
241
+
242
+ async createSession(opts: {
243
+ agentName: string;
244
+ permissionMode: string;
245
+ sessionName?: string;
246
+ }): Promise<AcpSession> {
247
+ const sessionName = opts.sessionName || `nax-${Date.now()}`;
248
+
249
+ // Ensure session exists via CLI
250
+ const cmd = ["acpx", opts.agentName, "sessions", "ensure", "--name", sessionName];
251
+ getSafeLogger()?.debug("acp-adapter", `Ensuring session: ${sessionName}`);
252
+
253
+ const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
254
+ const exitCode = await proc.exited;
255
+
256
+ if (exitCode !== 0) {
257
+ const stderr = await new Response(proc.stderr).text();
258
+ throw new Error(`[acp-adapter] Failed to create session: ${stderr || `exit code ${exitCode}`}`);
259
+ }
260
+
261
+ return new SpawnAcpSession({
262
+ agentName: opts.agentName,
263
+ sessionName,
264
+ cwd: this.cwd,
265
+ model: this.model,
266
+ timeoutSeconds: this.timeoutSeconds,
267
+ permissionMode: opts.permissionMode,
268
+ env: this.env,
269
+ });
270
+ }
271
+
272
+ async loadSession(sessionName: string): Promise<AcpSession | null> {
273
+ // Try to ensure session exists — if it does, acpx returns success
274
+ const cmd = ["acpx", this.agentName, "sessions", "ensure", "--name", sessionName];
275
+
276
+ const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
277
+ const exitCode = await proc.exited;
278
+
279
+ if (exitCode !== 0) {
280
+ return null; // Session doesn't exist or can't be resumed
281
+ }
282
+
283
+ return new SpawnAcpSession({
284
+ agentName: this.agentName,
285
+ sessionName,
286
+ cwd: this.cwd,
287
+ model: this.model,
288
+ timeoutSeconds: this.timeoutSeconds,
289
+ permissionMode: "approve-all", // Default for resumed sessions
290
+ env: this.env,
291
+ });
292
+ }
293
+
294
+ async close(): Promise<void> {
295
+ // No-op — spawn-based client has no persistent connection
296
+ }
297
+ }
298
+
299
+ // ─────────────────────────────────────────────────────────────────────────────
300
+ // Factory function
301
+ // ─────────────────────────────────────────────────────────────────────────────
302
+
303
+ /**
304
+ * Create a spawn-based ACP client. This is the default production factory.
305
+ * The cmdStr format is: "acpx --model <model> <agentName>"
306
+ */
307
+ export function createSpawnAcpClient(cmdStr: string, cwd?: string, timeoutSeconds?: number): AcpClient {
308
+ return new SpawnAcpClient(cmdStr, cwd, timeoutSeconds);
309
+ }