@nathapp/nax 0.45.0 → 0.46.1

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 (48) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bin/nax.ts +7 -6
  3. package/dist/nax.js +340 -202
  4. package/package.json +1 -1
  5. package/src/acceptance/generator.ts +1 -1
  6. package/src/acceptance/types.ts +2 -0
  7. package/src/agents/acp/adapter.ts +34 -6
  8. package/src/agents/acp/cost.ts +5 -75
  9. package/src/agents/acp/index.ts +0 -2
  10. package/src/agents/acp/parser.ts +57 -104
  11. package/src/agents/acp/spawn-client.ts +13 -2
  12. package/src/agents/{claude.ts → claude/adapter.ts} +15 -12
  13. package/src/agents/{claude-complete.ts → claude/complete.ts} +3 -3
  14. package/src/agents/claude/cost.ts +16 -0
  15. package/src/agents/{claude-execution.ts → claude/execution.ts} +17 -6
  16. package/src/agents/claude/index.ts +3 -0
  17. package/src/agents/{claude-interactive.ts → claude/interactive.ts} +4 -4
  18. package/src/agents/{claude-plan.ts → claude/plan.ts} +12 -9
  19. package/src/agents/cost/calculate.ts +154 -0
  20. package/src/agents/cost/index.ts +10 -0
  21. package/src/agents/cost/parse.ts +97 -0
  22. package/src/agents/cost/pricing.ts +59 -0
  23. package/src/agents/cost/types.ts +45 -0
  24. package/src/agents/index.ts +6 -4
  25. package/src/agents/registry.ts +5 -5
  26. package/src/agents/{claude-decompose.ts → shared/decompose.ts} +2 -2
  27. package/src/agents/{model-resolution.ts → shared/model-resolution.ts} +2 -2
  28. package/src/agents/{types-extended.ts → shared/types-extended.ts} +4 -4
  29. package/src/agents/{validation.ts → shared/validation.ts} +2 -2
  30. package/src/agents/{version-detection.ts → shared/version-detection.ts} +3 -3
  31. package/src/agents/types.ts +11 -4
  32. package/src/cli/agents.ts +1 -1
  33. package/src/cli/init.ts +15 -1
  34. package/src/pipeline/stages/acceptance-setup.ts +1 -0
  35. package/src/pipeline/stages/acceptance.ts +5 -8
  36. package/src/pipeline/stages/regression.ts +2 -0
  37. package/src/pipeline/stages/verify.ts +5 -10
  38. package/src/precheck/checks-agents.ts +1 -1
  39. package/src/precheck/checks-git.ts +28 -2
  40. package/src/precheck/checks-warnings.ts +30 -2
  41. package/src/precheck/checks.ts +1 -0
  42. package/src/precheck/index.ts +2 -0
  43. package/src/utils/log-test-output.ts +25 -0
  44. package/src/agents/cost.ts +0 -268
  45. /package/src/agents/{adapters/aider.ts → aider/adapter.ts} +0 -0
  46. /package/src/agents/{adapters/codex.ts → codex/adapter.ts} +0 -0
  47. /package/src/agents/{adapters/gemini.ts → gemini/adapter.ts} +0 -0
  48. /package/src/agents/{adapters/opencode.ts → opencode/adapter.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.45.0",
3
+ "version": "0.46.1",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -124,7 +124,7 @@ Respond with ONLY the TypeScript test code (no markdown code fences, no explanat
124
124
  2,
125
125
  );
126
126
 
127
- await _generatorPRDDeps.writeFile(join(options.workdir, "acceptance-refined.json"), refinedJsonContent);
127
+ await _generatorPRDDeps.writeFile(join(options.featureDir, "acceptance-refined.json"), refinedJsonContent);
128
128
 
129
129
  return { testCode, criteria };
130
130
  }
@@ -80,6 +80,8 @@ export interface GenerateFromPRDOptions {
80
80
  featureName: string;
81
81
  /** Working directory for context scanning */
82
82
  workdir: string;
83
+ /** Feature directory where acceptance-refined.json is written */
84
+ featureDir: string;
83
85
  /** Codebase context (file tree, dependencies, test patterns) */
84
86
  codebaseContext: string;
85
87
  /** Model tier to use for test generation */
@@ -15,7 +15,7 @@ import { createHash } from "node:crypto";
15
15
  import { join } from "node:path";
16
16
  import { resolvePermissions } from "../../config/permissions";
17
17
  import { getSafeLogger } from "../../logger";
18
- import { buildDecomposePrompt, parseDecomposeOutput } from "../claude-decompose";
18
+ import { buildDecomposePrompt, parseDecomposeOutput } from "../shared/decompose";
19
19
  import { createSpawnAcpClient } from "./spawn-client";
20
20
 
21
21
  import type {
@@ -80,7 +80,14 @@ const DEFAULT_ENTRY: AgentRegistryEntry = {
80
80
  export interface AcpSessionResponse {
81
81
  messages: Array<{ role: string; content: string }>;
82
82
  stopReason: string;
83
- cumulative_token_usage?: { input_tokens: number; output_tokens: number };
83
+ cumulative_token_usage?: {
84
+ input_tokens: number;
85
+ output_tokens: number;
86
+ cache_read_input_tokens?: number;
87
+ cache_creation_input_tokens?: number;
88
+ };
89
+ /** Exact cost in USD from acpx usage_update event. Preferred over token-based estimation. */
90
+ exactCostUsd?: number;
84
91
  }
85
92
 
86
93
  export interface AcpSession {
@@ -555,7 +562,13 @@ export class AcpAgentAdapter implements AgentAdapter {
555
562
  // Tracks whether the run completed successfully — used by finally to decide
556
563
  // whether to close the session (success) or keep it open for retry (failure).
557
564
  const runState = { succeeded: false };
558
- const totalTokenUsage = { input_tokens: 0, output_tokens: 0 };
565
+ const totalTokenUsage = {
566
+ input_tokens: 0,
567
+ output_tokens: 0,
568
+ cache_read_input_tokens: 0,
569
+ cache_creation_input_tokens: 0,
570
+ };
571
+ let totalExactCostUsd: number | undefined;
559
572
 
560
573
  try {
561
574
  // 5. Multi-turn loop
@@ -577,10 +590,16 @@ export class AcpAgentAdapter implements AgentAdapter {
577
590
  lastResponse = turnResult.response;
578
591
  if (!lastResponse) break;
579
592
 
580
- // Accumulate token usage
593
+ // Accumulate token usage and exact cost
581
594
  if (lastResponse.cumulative_token_usage) {
582
595
  totalTokenUsage.input_tokens += lastResponse.cumulative_token_usage.input_tokens ?? 0;
583
596
  totalTokenUsage.output_tokens += lastResponse.cumulative_token_usage.output_tokens ?? 0;
597
+ totalTokenUsage.cache_read_input_tokens += lastResponse.cumulative_token_usage.cache_read_input_tokens ?? 0;
598
+ totalTokenUsage.cache_creation_input_tokens +=
599
+ lastResponse.cumulative_token_usage.cache_creation_input_tokens ?? 0;
600
+ }
601
+ if (lastResponse.exactCostUsd !== undefined) {
602
+ totalExactCostUsd = (totalExactCostUsd ?? 0) + lastResponse.exactCostUsd;
584
603
  }
585
604
 
586
605
  // Check for agent question → route to interaction bridge
@@ -643,10 +662,12 @@ export class AcpAgentAdapter implements AgentAdapter {
643
662
  const success = lastResponse?.stopReason === "end_turn";
644
663
  const output = extractOutput(lastResponse);
645
664
 
665
+ // Prefer exact cost from acpx usage_update; fall back to token-based estimation
646
666
  const estimatedCost =
647
- totalTokenUsage.input_tokens > 0 || totalTokenUsage.output_tokens > 0
667
+ totalExactCostUsd ??
668
+ (totalTokenUsage.input_tokens > 0 || totalTokenUsage.output_tokens > 0
648
669
  ? estimateCostFromTokenUsage(totalTokenUsage, options.modelDef.model)
649
- : 0;
670
+ : 0);
650
671
 
651
672
  return {
652
673
  success,
@@ -719,6 +740,13 @@ export class AcpAgentAdapter implements AgentAdapter {
719
740
  throw new CompleteError("complete() returned empty output");
720
741
  }
721
742
 
743
+ if (response.exactCostUsd !== undefined) {
744
+ getSafeLogger()?.info("acp-adapter", "complete() cost", {
745
+ costUsd: response.exactCostUsd,
746
+ model,
747
+ });
748
+ }
749
+
722
750
  return unwrapped;
723
751
  } catch (err) {
724
752
  const error = err instanceof Error ? err : new Error(String(err));
@@ -1,79 +1,9 @@
1
1
  /**
2
- * ACP cost estimation from token usage.
2
+ * ACP cost estimation — re-exports from the shared src/agents/cost/ module.
3
3
  *
4
- * Stub implementation in ACP-006.
4
+ * Kept for zero-breakage backward compatibility.
5
+ * Import directly from src/agents/cost for new code.
5
6
  */
6
7
 
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
- }
8
+ export type { SessionTokenUsage } from "../cost";
9
+ export { estimateCostFromTokenUsage } from "../cost";
@@ -4,6 +4,4 @@
4
4
 
5
5
  export { AcpAgentAdapter, _acpAdapterDeps } from "./adapter";
6
6
  export { createSpawnAcpClient } from "./spawn-client";
7
- export { estimateCostFromTokenUsage } from "./cost";
8
- export type { SessionTokenUsage } from "./cost";
9
7
  export type { AgentRegistryEntry } from "./types";
@@ -2,11 +2,9 @@
2
2
  * ACP adapter — NDJSON and JSON-RPC output parsing helpers.
3
3
  *
4
4
  * Extracted from adapter.ts to keep that file within the 800-line limit.
5
- * Used only by _runOnce() (the spawn-based legacy path).
5
+ * Used by SpawnAcpSession.prompt() to parse acpx stdout.
6
6
  */
7
7
 
8
- import type { AgentRunOptions } from "../types";
9
-
10
8
  // ─────────────────────────────────────────────────────────────────────────────
11
9
  // Types
12
10
  // ─────────────────────────────────────────────────────────────────────────────
@@ -15,131 +13,86 @@ import type { AgentRunOptions } from "../types";
15
13
  export interface AcpxTokenUsage {
16
14
  input_tokens: number;
17
15
  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 };
16
+ cache_read_input_tokens?: number;
17
+ cache_creation_input_tokens?: number;
37
18
  }
38
19
 
39
20
  // ─────────────────────────────────────────────────────────────────────────────
40
- // streamJsonRpcEvents
21
+ // parseAcpxJsonOutput
41
22
  // ─────────────────────────────────────────────────────────────────────────────
42
23
 
43
24
  /**
44
- * Stream stdout line-by-line, parse JSON-RPC, detect questions, call bridge.
25
+ * Parse acpx NDJSON output for assistant text, token usage, and exact cost.
26
+ *
27
+ * Handles the JSON-RPC envelope format emitted by acpx:
28
+ * - session/update agent_message_chunk → text accumulation
29
+ * - session/update usage_update → exact cost (cost.amount) + context size
30
+ * - id/result → token breakdown (inputTokens, outputTokens, cachedWriteTokens, cachedReadTokens)
31
+ *
32
+ * Also handles legacy flat NDJSON format for backward compatibility.
45
33
  */
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 = "";
34
+ export function parseAcpxJsonOutput(rawOutput: string): {
35
+ text: string;
36
+ tokenUsage?: AcpxTokenUsage;
37
+ exactCostUsd?: number;
38
+ stopReason?: string;
39
+ error?: string;
40
+ } {
41
+ const lines = rawOutput.split("\n").filter((l) => l.trim());
42
+ let text = "";
52
43
  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;
44
+ let exactCostUsd: number | undefined;
45
+ let stopReason: string | undefined;
46
+ let error: string | undefined;
69
47
 
70
- let msg: JsonRpcMessage;
71
- try {
72
- msg = JSON.parse(line);
73
- } catch {
74
- continue;
75
- }
48
+ for (const line of lines) {
49
+ try {
50
+ const event = JSON.parse(line);
76
51
 
77
- if (msg.method === "session/update" && msg.params?.update) {
78
- const update = msg.params.update;
52
+ // ── JSON-RPC envelope format (acpx v0.3+) ──────────────────────────────
53
+ if (event.jsonrpc === "2.0") {
54
+ // session/update events
55
+ if (event.method === "session/update" && event.params?.update) {
56
+ const update = event.params.update;
79
57
 
58
+ // Text chunks
80
59
  if (
81
60
  update.sessionUpdate === "agent_message_chunk" &&
82
61
  update.content?.type === "text" &&
83
62
  update.content.text
84
63
  ) {
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
- }
64
+ text += update.content.text;
94
65
  }
95
66
 
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
- };
67
+ // Exact cost from usage_update
68
+ if (update.sessionUpdate === "usage_update" && typeof update.cost?.amount === "number") {
69
+ exactCostUsd = update.cost.amount;
102
70
  }
103
71
  }
104
72
 
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
- }
73
+ // Final result with token breakdown (camelCase from acpx)
74
+ if (event.id !== undefined && event.result && typeof event.result === "object") {
75
+ const result = event.result as Record<string, unknown>;
119
76
 
120
- // ─────────────────────────────────────────────────────────────────────────────
121
- // parseAcpxJsonOutput
122
- // ─────────────────────────────────────────────────────────────────────────────
77
+ if (result.stopReason) stopReason = result.stopReason as string;
78
+ if (result.stop_reason) stopReason = result.stop_reason as string;
123
79
 
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;
80
+ if (result.usage && typeof result.usage === "object") {
81
+ const u = result.usage as Record<string, unknown>;
82
+ tokenUsage = {
83
+ input_tokens: (u.inputTokens as number) ?? (u.input_tokens as number) ?? 0,
84
+ output_tokens: (u.outputTokens as number) ?? (u.output_tokens as number) ?? 0,
85
+ cache_read_input_tokens: (u.cachedReadTokens as number) ?? (u.cache_read_input_tokens as number) ?? 0,
86
+ cache_creation_input_tokens:
87
+ (u.cachedWriteTokens as number) ?? (u.cache_creation_input_tokens as number) ?? 0,
88
+ };
89
+ }
90
+ }
138
91
 
139
- for (const line of lines) {
140
- try {
141
- const event = JSON.parse(line);
92
+ continue;
93
+ }
142
94
 
95
+ // ── Legacy flat NDJSON format ───────────────────────────────────────────
143
96
  if (event.content && typeof event.content === "string") text += event.content;
144
97
  if (event.text && typeof event.text === "string") text += event.text;
145
98
  if (event.result && typeof event.result === "string") text = event.result;
@@ -162,5 +115,5 @@ export function parseAcpxJsonOutput(rawOutput: string): {
162
115
  }
163
116
  }
164
117
 
165
- return { text: text.trim(), tokenUsage, stopReason, error };
118
+ return { text: text.trim(), tokenUsage, exactCostUsd, stopReason, error };
166
119
  }
@@ -12,6 +12,8 @@
12
12
  * acpx <agent> cancel → session.cancelActivePrompt()
13
13
  */
14
14
 
15
+ import { homedir } from "node:os";
16
+ import { isAbsolute } from "node:path";
15
17
  import type { PidRegistry } from "../../execution/pid-registry";
16
18
  import { getSafeLogger } from "../../logger";
17
19
  import type { AcpClient, AcpSession, AcpSessionResponse } from "./adapter";
@@ -60,11 +62,19 @@ export const _spawnClientDeps = {
60
62
  function buildAllowedEnv(extraEnv?: Record<string, string | undefined>): Record<string, string | undefined> {
61
63
  const allowed: Record<string, string | undefined> = {};
62
64
 
63
- const essentialVars = ["PATH", "HOME", "TMPDIR", "NODE_ENV", "USER", "LOGNAME"];
65
+ const essentialVars = ["PATH", "TMPDIR", "NODE_ENV", "USER", "LOGNAME"];
64
66
  for (const varName of essentialVars) {
65
67
  if (process.env[varName]) allowed[varName] = process.env[varName];
66
68
  }
67
69
 
70
+ // Sanitize HOME — must be absolute path. Unexpanded "~" causes literal ~/dir in cwd.
71
+ const rawHome = process.env.HOME ?? "";
72
+ const safeHome = rawHome && isAbsolute(rawHome) ? rawHome : homedir();
73
+ if (rawHome !== safeHome) {
74
+ getSafeLogger()?.warn("env", `HOME env is not absolute ("${rawHome}"), falling back to os.homedir(): ${safeHome}`);
75
+ }
76
+ allowed.HOME = safeHome;
77
+
68
78
  const apiKeyVars = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "CLAUDE_API_KEY"];
69
79
  for (const varName of apiKeyVars) {
70
80
  if (process.env[varName]) allowed[varName] = process.env[varName];
@@ -180,8 +190,9 @@ class SpawnAcpSession implements AcpSession {
180
190
  const parsed = parseAcpxJsonOutput(stdout);
181
191
  return {
182
192
  messages: [{ role: "assistant", content: parsed.text || "" }],
183
- stopReason: "end_turn",
193
+ stopReason: parsed.stopReason ?? "end_turn",
184
194
  cumulative_token_usage: parsed.tokenUsage,
195
+ exactCostUsd: parsed.exactCostUsd,
185
196
  };
186
197
  } catch (err) {
187
198
  getSafeLogger()?.warn("acp-adapter", "Failed to parse session prompt response", {
@@ -4,15 +4,11 @@
4
4
  * Main adapter class coordinating execution, completion, decomposition, and interactive modes.
5
5
  */
6
6
 
7
- import { resolvePermissions } from "../config/permissions";
8
- import { PidRegistry } from "../execution/pid-registry";
9
- import { withProcessTimeout } from "../execution/timeout-handler";
10
- import { getLogger } from "../logger";
11
- import { _completeDeps, executeComplete } from "./claude-complete";
12
- import { buildDecomposePrompt, parseDecomposeOutput } from "./claude-decompose";
13
- import { _runOnceDeps, buildAllowedEnv, buildCommand, executeOnce } from "./claude-execution";
14
- import { runInteractiveMode } from "./claude-interactive";
15
- import { runPlan } from "./claude-plan";
7
+ import { resolvePermissions } from "../../config/permissions";
8
+ import { PidRegistry } from "../../execution/pid-registry";
9
+ import { withProcessTimeout } from "../../execution/timeout-handler";
10
+ import { getLogger } from "../../logger";
11
+ import { buildDecomposePrompt, parseDecomposeOutput } from "../shared/decompose";
16
12
  import type {
17
13
  AgentAdapter,
18
14
  AgentCapabilities,
@@ -25,7 +21,11 @@ import type {
25
21
  PlanOptions,
26
22
  PlanResult,
27
23
  PtyHandle,
28
- } from "./types";
24
+ } from "../types";
25
+ import { _completeDeps, executeComplete } from "./complete";
26
+ import { _runOnceDeps, buildAllowedEnv, buildCommand, executeOnce } from "./execution";
27
+ import { runInteractiveMode } from "./interactive";
28
+ import { runPlan } from "./plan";
29
29
 
30
30
  /**
31
31
  * Injectable dependencies for decompose() — allows tests to intercept
@@ -174,7 +174,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
174
174
  }
175
175
 
176
176
  async decompose(options: DecomposeOptions): Promise<DecomposeResult> {
177
- const { resolveBalancedModelDef } = await import("./model-resolution");
177
+ const { resolveBalancedModelDef } = await import("../shared/model-resolution");
178
178
 
179
179
  const prompt = buildDecomposePrompt(options);
180
180
 
@@ -186,7 +186,10 @@ export class ClaudeCodeAdapter implements AgentAdapter {
186
186
  modelDef = resolveBalancedModelDef(options.config);
187
187
  }
188
188
 
189
- const { skipPermissions } = resolvePermissions(options.config as import("../config").NaxConfig | undefined, "run");
189
+ const { skipPermissions } = resolvePermissions(
190
+ options.config as import("../../config").NaxConfig | undefined,
191
+ "run",
192
+ );
190
193
  const cmd = [this.binary, "--model", modelDef.model, "-p", prompt];
191
194
  if (skipPermissions) {
192
195
  cmd.splice(cmd.length - 2, 0, "--dangerously-skip-permissions");
@@ -4,9 +4,9 @@
4
4
  * Standalone completion endpoint for simple prompts.
5
5
  */
6
6
 
7
- import { resolvePermissions } from "../config/permissions";
8
- import type { CompleteOptions } from "./types";
9
- import { CompleteError } from "./types";
7
+ import { resolvePermissions } from "../../config/permissions";
8
+ import type { CompleteOptions } from "../types";
9
+ import { CompleteError } from "../types";
10
10
 
11
11
  /**
12
12
  * Injectable dependencies for complete() — allows tests to intercept
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Cost Tracking — re-exports from the shared src/agents/cost/ module.
3
+ *
4
+ * Kept for zero-breakage backward compatibility.
5
+ * Import directly from src/agents/cost for new code.
6
+ */
7
+
8
+ export type { ModelCostRates, TokenUsage, CostEstimate, TokenUsageWithConfidence } from "../cost";
9
+ export {
10
+ COST_RATES,
11
+ parseTokenUsage,
12
+ estimateCost,
13
+ estimateCostFromOutput,
14
+ estimateCostByDuration,
15
+ formatCostWithConfidence,
16
+ } from "../cost";
@@ -4,12 +4,14 @@
4
4
  * Handles building commands, preparing environment, and process execution.
5
5
  */
6
6
 
7
- import { resolvePermissions } from "../config/permissions";
8
- import type { PidRegistry } from "../execution/pid-registry";
9
- import { withProcessTimeout } from "../execution/timeout-handler";
10
- import { getLogger } from "../logger";
7
+ import { homedir } from "node:os";
8
+ import { isAbsolute } from "node:path";
9
+ import { resolvePermissions } from "../../config/permissions";
10
+ import type { PidRegistry } from "../../execution/pid-registry";
11
+ import { withProcessTimeout } from "../../execution/timeout-handler";
12
+ import { getLogger } from "../../logger";
13
+ import type { AgentResult, AgentRunOptions } from "../types";
11
14
  import { estimateCostByDuration, estimateCostFromOutput } from "./cost";
12
- import type { AgentResult, AgentRunOptions } from "./types";
13
15
 
14
16
  /**
15
17
  * Maximum characters to capture from agent stdout.
@@ -65,13 +67,22 @@ export function buildCommand(binary: string, options: AgentRunOptions): string[]
65
67
  export function buildAllowedEnv(options: AgentRunOptions): Record<string, string | undefined> {
66
68
  const allowed: Record<string, string | undefined> = {};
67
69
 
68
- const essentialVars = ["PATH", "HOME", "TMPDIR", "NODE_ENV", "USER", "LOGNAME"];
70
+ const essentialVars = ["PATH", "TMPDIR", "NODE_ENV", "USER", "LOGNAME"];
69
71
  for (const varName of essentialVars) {
70
72
  if (process.env[varName]) {
71
73
  allowed[varName] = process.env[varName];
72
74
  }
73
75
  }
74
76
 
77
+ // Sanitize HOME — must be absolute path. Unexpanded "~" causes literal ~/dir in cwd.
78
+ const rawHome = process.env.HOME ?? "";
79
+ const safeHome = rawHome && isAbsolute(rawHome) ? rawHome : homedir();
80
+ if (rawHome !== safeHome) {
81
+ const logger = getLogger();
82
+ logger.warn("env", `HOME env is not absolute ("${rawHome}"), falling back to os.homedir(): ${safeHome}`);
83
+ }
84
+ allowed.HOME = safeHome;
85
+
75
86
  const apiKeyVars = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
76
87
  for (const varName of apiKeyVars) {
77
88
  if (process.env[varName]) {
@@ -0,0 +1,3 @@
1
+ // Re-export everything external callers need from claude/
2
+ export { ClaudeCodeAdapter, _completeDeps, _claudeAdapterDeps } from "./adapter";
3
+ export { _runOnceDeps } from "./execution";
@@ -4,10 +4,10 @@
4
4
  * Handles terminal UI interactions with the Claude agent.
5
5
  */
6
6
 
7
- import type { PidRegistry } from "../execution/pid-registry";
8
- import { getLogger } from "../logger";
9
- import { buildAllowedEnv } from "./claude-execution";
10
- import type { AgentRunOptions, InteractiveRunOptions, PtyHandle } from "./types";
7
+ import type { PidRegistry } from "../../execution/pid-registry";
8
+ import { getLogger } from "../../logger";
9
+ import type { AgentRunOptions, InteractiveRunOptions, PtyHandle } from "../types";
10
+ import { buildAllowedEnv } from "./execution";
11
11
 
12
12
  /**
13
13
  * Run Claude agent in interactive (TTY) mode for TUI output.
@@ -7,13 +7,13 @@ import { join } from "node:path";
7
7
  * Extracted from claude.ts: plan(), buildPlanCommand()
8
8
  */
9
9
 
10
- import { resolvePermissions } from "../config/permissions";
11
- import type { PidRegistry } from "../execution/pid-registry";
12
- import { withProcessTimeout } from "../execution/timeout-handler";
13
- import { getLogger } from "../logger";
14
- import { resolveBalancedModelDef } from "./model-resolution";
15
- import type { AgentRunOptions } from "./types";
16
- import type { PlanOptions, PlanResult } from "./types-extended";
10
+ import { resolvePermissions } from "../../config/permissions";
11
+ import type { PidRegistry } from "../../execution/pid-registry";
12
+ import { withProcessTimeout } from "../../execution/timeout-handler";
13
+ import { getLogger } from "../../logger";
14
+ import { resolveBalancedModelDef } from "../shared/model-resolution";
15
+ import type { PlanOptions, PlanResult } from "../shared/types-extended";
16
+ import type { AgentRunOptions } from "../types";
17
17
 
18
18
  /**
19
19
  * Build the CLI command for plan mode.
@@ -32,7 +32,10 @@ export function buildPlanCommand(binary: string, options: PlanOptions): string[]
32
32
  }
33
33
 
34
34
  // Resolve permission mode from config
35
- const { skipPermissions } = resolvePermissions(options.config as import("../config").NaxConfig | undefined, "plan");
35
+ const { skipPermissions } = resolvePermissions(
36
+ options.config as import("../../config").NaxConfig | undefined,
37
+ "plan",
38
+ );
36
39
  if (skipPermissions) {
37
40
  cmd.push("--dangerously-skip-permissions");
38
41
  }
@@ -75,7 +78,7 @@ export async function runPlan(
75
78
  pidRegistry: PidRegistry,
76
79
  buildAllowedEnv: (options: AgentRunOptions) => Record<string, string | undefined>,
77
80
  ): Promise<PlanResult> {
78
- const { resolveBalancedModelDef } = await import("./model-resolution");
81
+ const { resolveBalancedModelDef } = await import("../shared/model-resolution");
79
82
 
80
83
  const cmd = buildPlanCommand(binary, options);
81
84