@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.
- package/dist/nax.js +1072 -268
- package/package.json +2 -2
- package/src/acceptance/fix-generator.ts +4 -35
- package/src/acceptance/generator.ts +4 -27
- package/src/agents/acp/adapter.ts +644 -0
- package/src/agents/acp/cost.ts +79 -0
- package/src/agents/acp/index.ts +9 -0
- package/src/agents/acp/interaction-bridge.ts +126 -0
- package/src/agents/acp/parser.ts +166 -0
- package/src/agents/acp/spawn-client.ts +309 -0
- package/src/agents/acp/types.ts +22 -0
- package/src/agents/claude-complete.ts +3 -3
- package/src/agents/registry.ts +83 -0
- package/src/agents/types-extended.ts +23 -0
- package/src/agents/types.ts +17 -0
- package/src/cli/analyze.ts +6 -2
- package/src/cli/plan.ts +23 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/runtime-types.ts +10 -0
- package/src/config/schema.ts +1 -0
- package/src/config/schemas.ts +6 -0
- package/src/config/types.ts +1 -0
- package/src/execution/executor-types.ts +6 -0
- package/src/execution/iteration-runner.ts +2 -0
- package/src/execution/lifecycle/acceptance-loop.ts +5 -2
- package/src/execution/lifecycle/run-initialization.ts +16 -4
- package/src/execution/lifecycle/run-setup.ts +4 -0
- package/src/execution/runner-completion.ts +11 -1
- package/src/execution/runner-execution.ts +8 -0
- package/src/execution/runner-setup.ts +4 -0
- package/src/execution/runner.ts +10 -0
- package/src/pipeline/stages/execution.ts +33 -1
- package/src/pipeline/stages/routing.ts +18 -7
- package/src/pipeline/types.ts +10 -0
- package/src/tdd/orchestrator.ts +7 -0
- package/src/tdd/rectification-gate.ts +6 -0
- 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
|
+
}
|