@nathapp/nax 0.38.0 → 0.38.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.
Files changed (75) hide show
  1. package/dist/nax.js +3294 -2907
  2. package/package.json +2 -2
  3. package/src/agents/claude-complete.ts +72 -0
  4. package/src/agents/claude-execution.ts +189 -0
  5. package/src/agents/claude-interactive.ts +77 -0
  6. package/src/agents/claude-plan.ts +23 -8
  7. package/src/agents/claude.ts +64 -349
  8. package/src/analyze/classifier.ts +2 -1
  9. package/src/cli/config-descriptions.ts +206 -0
  10. package/src/cli/config-diff.ts +103 -0
  11. package/src/cli/config-display.ts +285 -0
  12. package/src/cli/config-get.ts +55 -0
  13. package/src/cli/config.ts +7 -618
  14. package/src/cli/plugins.ts +15 -4
  15. package/src/cli/prompts-export.ts +58 -0
  16. package/src/cli/prompts-init.ts +200 -0
  17. package/src/cli/prompts-main.ts +237 -0
  18. package/src/cli/prompts-tdd.ts +78 -0
  19. package/src/cli/prompts.ts +10 -541
  20. package/src/commands/logs-formatter.ts +201 -0
  21. package/src/commands/logs-reader.ts +171 -0
  22. package/src/commands/logs.ts +11 -362
  23. package/src/config/loader.ts +4 -15
  24. package/src/config/runtime-types.ts +451 -0
  25. package/src/config/schema-types.ts +53 -0
  26. package/src/config/schemas.ts +2 -0
  27. package/src/config/types.ts +49 -486
  28. package/src/context/auto-detect.ts +2 -1
  29. package/src/context/builder.ts +3 -2
  30. package/src/execution/crash-heartbeat.ts +77 -0
  31. package/src/execution/crash-recovery.ts +23 -365
  32. package/src/execution/crash-signals.ts +149 -0
  33. package/src/execution/crash-writer.ts +154 -0
  34. package/src/execution/lifecycle/run-setup.ts +7 -1
  35. package/src/execution/parallel-coordinator.ts +278 -0
  36. package/src/execution/parallel-executor-rectification-pass.ts +117 -0
  37. package/src/execution/parallel-executor-rectify.ts +135 -0
  38. package/src/execution/parallel-executor.ts +19 -211
  39. package/src/execution/parallel-worker.ts +148 -0
  40. package/src/execution/parallel.ts +5 -404
  41. package/src/execution/pid-registry.ts +3 -8
  42. package/src/execution/runner-completion.ts +160 -0
  43. package/src/execution/runner-execution.ts +221 -0
  44. package/src/execution/runner-setup.ts +82 -0
  45. package/src/execution/runner.ts +53 -202
  46. package/src/execution/timeout-handler.ts +100 -0
  47. package/src/hooks/runner.ts +11 -21
  48. package/src/metrics/tracker.ts +7 -30
  49. package/src/pipeline/runner.ts +2 -1
  50. package/src/pipeline/stages/completion.ts +0 -1
  51. package/src/pipeline/stages/context.ts +2 -1
  52. package/src/plugins/extensions.ts +225 -0
  53. package/src/plugins/loader.ts +40 -4
  54. package/src/plugins/types.ts +18 -221
  55. package/src/prd/index.ts +2 -1
  56. package/src/prd/validate.ts +41 -0
  57. package/src/precheck/checks-blockers.ts +15 -419
  58. package/src/precheck/checks-cli.ts +68 -0
  59. package/src/precheck/checks-config.ts +102 -0
  60. package/src/precheck/checks-git.ts +87 -0
  61. package/src/precheck/checks-system.ts +163 -0
  62. package/src/review/orchestrator.ts +19 -6
  63. package/src/review/runner.ts +17 -5
  64. package/src/routing/chain.ts +2 -1
  65. package/src/routing/loader.ts +2 -5
  66. package/src/tdd/orchestrator.ts +2 -1
  67. package/src/tdd/verdict-reader.ts +266 -0
  68. package/src/tdd/verdict.ts +6 -271
  69. package/src/utils/errors.ts +12 -0
  70. package/src/utils/git.ts +12 -5
  71. package/src/utils/json-file.ts +72 -0
  72. package/src/verification/executor.ts +2 -1
  73. package/src/verification/smart-runner.ts +23 -3
  74. package/src/worktree/manager.ts +9 -3
  75. package/src/worktree/merge.ts +3 -2
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.38.0",
4
- "description": "AI Coding Agent Orchestrator \u2014 loops until done",
3
+ "version": "0.38.2",
4
+ "description": "AI Coding Agent Orchestrator loops until done",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nax": "./dist/nax.js"
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Claude Code Agent - Completion API
3
+ *
4
+ * Standalone completion endpoint for simple prompts.
5
+ */
6
+
7
+ import type { CompleteOptions } from "./types";
8
+ import { CompleteError } from "./types";
9
+
10
+ /**
11
+ * Injectable dependencies for complete() — allows tests to intercept
12
+ * Bun.spawn calls and verify correct CLI args without the claude binary.
13
+ *
14
+ * @internal
15
+ */
16
+ export const _completeDeps = {
17
+ spawn(
18
+ cmd: string[],
19
+ opts: { stdout: "pipe"; stderr: "pipe" | "inherit" },
20
+ ): { stdout: ReadableStream<Uint8Array>; stderr: ReadableStream<Uint8Array>; exited: Promise<number>; pid: number } {
21
+ return Bun.spawn(cmd, opts) as unknown as {
22
+ stdout: ReadableStream<Uint8Array>;
23
+ stderr: ReadableStream<Uint8Array>;
24
+ exited: Promise<number>;
25
+ pid: number;
26
+ };
27
+ },
28
+ };
29
+
30
+ /**
31
+ * Execute a simple completion request without starting a full agent session.
32
+ *
33
+ * @param binary - Path to claude binary
34
+ * @param prompt - Prompt text
35
+ * @param options - Completion options (model, tokens, format)
36
+ * @returns Completion text output
37
+ * @throws CompleteError if execution fails
38
+ */
39
+ export async function executeComplete(binary: string, prompt: string, options?: CompleteOptions): Promise<string> {
40
+ const cmd = [binary, "-p", prompt];
41
+
42
+ if (options?.model) {
43
+ cmd.push("--model", options.model);
44
+ }
45
+
46
+ if (options?.maxTokens !== undefined) {
47
+ cmd.push("--max-tokens", String(options.maxTokens));
48
+ }
49
+
50
+ if (options?.jsonMode) {
51
+ cmd.push("--output-format", "json");
52
+ }
53
+
54
+ const proc = _completeDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
55
+ const exitCode = await proc.exited;
56
+
57
+ const stdout = await new Response(proc.stdout).text();
58
+ const stderr = await new Response(proc.stderr).text();
59
+ const trimmed = stdout.trim();
60
+
61
+ if (exitCode !== 0) {
62
+ const errorDetails = stderr.trim() || trimmed;
63
+ const errorMessage = errorDetails || `complete() failed with exit code ${exitCode}`;
64
+ throw new CompleteError(errorMessage, exitCode);
65
+ }
66
+
67
+ if (!trimmed) {
68
+ throw new CompleteError("complete() returned empty output");
69
+ }
70
+
71
+ return trimmed;
72
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Claude Code Agent - Execution Layer
3
+ *
4
+ * Handles building commands, preparing environment, and process execution.
5
+ */
6
+
7
+ import type { PidRegistry } from "../execution/pid-registry";
8
+ import { withProcessTimeout } from "../execution/timeout-handler";
9
+ import { getLogger } from "../logger";
10
+ import { estimateCostByDuration, estimateCostFromOutput } from "./cost";
11
+ import type { AgentResult, AgentRunOptions } from "./types";
12
+
13
+ /**
14
+ * Maximum characters to capture from agent stdout.
15
+ */
16
+ const MAX_AGENT_OUTPUT_CHARS = 5000;
17
+
18
+ /**
19
+ * Maximum characters to capture from agent stderr.
20
+ */
21
+ const MAX_AGENT_STDERR_CHARS = 1000;
22
+
23
+ /**
24
+ * Grace period in ms between SIGTERM and SIGKILL on timeout.
25
+ */
26
+ const SIGKILL_GRACE_PERIOD_MS = 5000;
27
+
28
+ /**
29
+ * Injectable dependencies for runOnce() — allows tests to verify
30
+ * that PID cleanup (unregister) always runs even if kill() throws.
31
+ *
32
+ * @internal
33
+ */
34
+ export const _runOnceDeps = {
35
+ killProc(proc: { kill(signal?: number | NodeJS.Signals): void }, signal: NodeJS.Signals): void {
36
+ proc.kill(signal);
37
+ },
38
+ buildCmd(binary: string, options: AgentRunOptions): string[] {
39
+ return buildCommand(binary, options);
40
+ },
41
+ };
42
+
43
+ /**
44
+ * Build Claude Code command with model and permissions.
45
+ *
46
+ * @param binary - Path to claude binary
47
+ * @param options - Agent run options
48
+ * @returns Command array for Bun.spawn()
49
+ */
50
+ export function buildCommand(binary: string, options: AgentRunOptions): string[] {
51
+ const model = options.modelDef.model;
52
+ const skipPermissions = options.dangerouslySkipPermissions ?? true;
53
+ const permArgs = skipPermissions ? ["--dangerously-skip-permissions"] : [];
54
+ return [binary, "--model", model, ...permArgs, "-p", options.prompt];
55
+ }
56
+
57
+ /**
58
+ * Build allowed environment variables for spawned agents.
59
+ * SEC-4: Only pass essential env vars to prevent leaking sensitive data.
60
+ *
61
+ * @param options - Agent run options
62
+ * @returns Filtered environment variables
63
+ */
64
+ export function buildAllowedEnv(options: AgentRunOptions): Record<string, string | undefined> {
65
+ const allowed: Record<string, string | undefined> = {};
66
+
67
+ const essentialVars = ["PATH", "HOME", "TMPDIR", "NODE_ENV", "USER", "LOGNAME"];
68
+ for (const varName of essentialVars) {
69
+ if (process.env[varName]) {
70
+ allowed[varName] = process.env[varName];
71
+ }
72
+ }
73
+
74
+ const apiKeyVars = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
75
+ for (const varName of apiKeyVars) {
76
+ if (process.env[varName]) {
77
+ allowed[varName] = process.env[varName];
78
+ }
79
+ }
80
+
81
+ const allowedPrefixes = ["CLAUDE_", "NAX_", "CLAW_", "TURBO_"];
82
+ for (const [key, value] of Object.entries(process.env)) {
83
+ if (allowedPrefixes.some((prefix) => key.startsWith(prefix))) {
84
+ allowed[key] = value;
85
+ }
86
+ }
87
+
88
+ if (options.modelDef.env) {
89
+ Object.assign(allowed, options.modelDef.env);
90
+ }
91
+
92
+ if (options.env) {
93
+ Object.assign(allowed, options.env);
94
+ }
95
+
96
+ return allowed;
97
+ }
98
+
99
+ /**
100
+ * Execute agent process once with timeout and signal handling.
101
+ *
102
+ * @param binary - Path to claude binary
103
+ * @param options - Agent run options
104
+ * @param pidRegistry - PID registry for cleanup
105
+ * @returns Agent execution result
106
+ *
107
+ * @internal
108
+ */
109
+ export async function executeOnce(
110
+ binary: string,
111
+ options: AgentRunOptions,
112
+ pidRegistry: PidRegistry,
113
+ ): Promise<AgentResult> {
114
+ const cmd = _runOnceDeps.buildCmd(binary, options);
115
+ const startTime = Date.now();
116
+
117
+ const proc = Bun.spawn(cmd, {
118
+ cwd: options.workdir,
119
+ stdout: "pipe",
120
+ stderr: "inherit",
121
+ env: buildAllowedEnv(options),
122
+ });
123
+
124
+ const processPid = proc.pid;
125
+ await pidRegistry.register(processPid);
126
+
127
+ let timedOut = false;
128
+ let exitCode: number;
129
+ try {
130
+ const timeoutResult = await withProcessTimeout(proc, options.timeoutSeconds * 1000, {
131
+ graceMs: SIGKILL_GRACE_PERIOD_MS,
132
+ onTimeout: () => {
133
+ timedOut = true;
134
+ },
135
+ killFn: (p, signal) => _runOnceDeps.killProc(p, signal),
136
+ });
137
+ exitCode = timeoutResult.exitCode;
138
+ timedOut = timeoutResult.timedOut;
139
+ } finally {
140
+ await pidRegistry.unregister(processPid);
141
+ }
142
+
143
+ let stdoutTimeoutId: ReturnType<typeof setTimeout> | undefined;
144
+ const stdout = await Promise.race([
145
+ new Response(proc.stdout).text(),
146
+ new Promise<string>((resolve) => {
147
+ stdoutTimeoutId = setTimeout(() => resolve(""), 5000);
148
+ }),
149
+ ]);
150
+ clearTimeout(stdoutTimeoutId); // prevent leaked timer when stdout resolves first
151
+ const stderr = proc.stderr ? await new Response(proc.stderr).text() : "";
152
+ const durationMs = Date.now() - startTime;
153
+
154
+ const fullOutput = stdout + stderr;
155
+ const rateLimited =
156
+ fullOutput.toLowerCase().includes("rate limit") ||
157
+ fullOutput.includes("429") ||
158
+ fullOutput.toLowerCase().includes("too many requests");
159
+
160
+ let costEstimate = estimateCostFromOutput(options.modelTier, fullOutput);
161
+ const logger = getLogger();
162
+ if (!costEstimate) {
163
+ const fallbackEstimate = estimateCostByDuration(options.modelTier, durationMs);
164
+ costEstimate = {
165
+ cost: fallbackEstimate.cost * 1.5,
166
+ confidence: "fallback",
167
+ };
168
+ logger.warn("agent", "Cost estimation fallback (duration-based)", {
169
+ modelTier: options.modelTier,
170
+ cost: costEstimate.cost,
171
+ });
172
+ } else if (costEstimate.confidence === "estimated") {
173
+ logger.warn("agent", "Cost estimation using regex parsing (estimated confidence)", { cost: costEstimate.cost });
174
+ }
175
+ const cost = costEstimate.cost;
176
+
177
+ const actualExitCode = timedOut ? 124 : exitCode;
178
+
179
+ return {
180
+ success: exitCode === 0 && !timedOut,
181
+ exitCode: actualExitCode,
182
+ output: stdout.slice(-MAX_AGENT_OUTPUT_CHARS),
183
+ stderr: stderr.slice(-MAX_AGENT_STDERR_CHARS),
184
+ rateLimited,
185
+ durationMs,
186
+ estimatedCost: cost,
187
+ pid: processPid,
188
+ };
189
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Claude Code Agent - Interactive (TUI) Mode
3
+ *
4
+ * Handles terminal UI interactions with the Claude agent.
5
+ */
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";
11
+
12
+ /**
13
+ * Run Claude agent in interactive (TTY) mode for TUI output.
14
+ *
15
+ * @param binary - Path to claude binary
16
+ * @param options - Interactive run options
17
+ * @param pidRegistry - PID registry for cleanup
18
+ * @returns PTY handle for stdin/stdout/kill control
19
+ */
20
+ export function runInteractiveMode(
21
+ binary: string,
22
+ options: InteractiveRunOptions,
23
+ pidRegistry: PidRegistry,
24
+ ): PtyHandle {
25
+ const model = options.modelDef.model;
26
+ const cmd = [binary, "--model", model, options.prompt];
27
+
28
+ // BUN-001: Replaced node-pty with Bun.spawn (piped stdio).
29
+ // runInteractive() is TUI-only and currently dormant in headless nax runs.
30
+ // TERM + FORCE_COLOR preserve formatting output from Claude Code.
31
+ const allowedEnv = buildAllowedEnv(options as unknown as AgentRunOptions);
32
+ const proc = Bun.spawn(cmd, {
33
+ cwd: options.workdir,
34
+ env: { ...allowedEnv, TERM: "xterm-256color", FORCE_COLOR: "1" },
35
+ stdin: "pipe",
36
+ stdout: "pipe",
37
+ stderr: "inherit",
38
+ });
39
+
40
+ pidRegistry.register(proc.pid).catch(() => {});
41
+
42
+ // Stream stdout to onOutput callback
43
+ (async () => {
44
+ try {
45
+ for await (const chunk of proc.stdout) {
46
+ options.onOutput(Buffer.from(chunk));
47
+ }
48
+ } catch (err) {
49
+ // BUG-21: Handle stream errors to avoid unhandled rejections
50
+ getLogger()?.error("agent", "runInteractive stdout error", { err });
51
+ }
52
+ })();
53
+
54
+ // Fire onExit when process completes
55
+ proc.exited
56
+ .then((code) => {
57
+ pidRegistry.unregister(proc.pid).catch(() => {});
58
+ options.onExit(code ?? 1);
59
+ })
60
+ .catch((err) => {
61
+ // BUG-22: Guard against onExit or unregister throws
62
+ getLogger()?.error("agent", "runInteractive exit error", { err });
63
+ });
64
+
65
+ return {
66
+ write: (data: string) => {
67
+ proc.stdin.write(data);
68
+ },
69
+ resize: (_cols: number, _rows: number) => {
70
+ /* no-op: Bun.spawn has no PTY resize */
71
+ },
72
+ kill: () => {
73
+ proc.kill();
74
+ },
75
+ pid: proc.pid,
76
+ };
77
+ }
@@ -8,6 +8,7 @@ import { join } from "node:path";
8
8
  */
9
9
 
10
10
  import type { PidRegistry } from "../execution/pid-registry";
11
+ import { withProcessTimeout } from "../execution/timeout-handler";
11
12
  import { getLogger } from "../logger";
12
13
  import { resolveBalancedModelDef } from "./model-resolution";
13
14
  import type { AgentRunOptions } from "./types";
@@ -91,6 +92,8 @@ export async function runPlan(
91
92
  timeoutSeconds: 600,
92
93
  };
93
94
 
95
+ const PLAN_TIMEOUT_MS = 600_000; // 10 minutes
96
+
94
97
  if (options.interactive) {
95
98
  // Interactive mode: inherit stdio
96
99
  const proc = Bun.spawn(cmd, {
@@ -104,10 +107,16 @@ export async function runPlan(
104
107
  // Register PID
105
108
  await pidRegistry.register(proc.pid);
106
109
 
107
- const exitCode = await proc.exited;
108
-
109
- // Unregister PID after exit
110
- await pidRegistry.unregister(proc.pid);
110
+ let exitCode: number;
111
+ try {
112
+ const timeoutResult = await withProcessTimeout(proc, PLAN_TIMEOUT_MS, {
113
+ graceMs: 5000,
114
+ });
115
+ exitCode = timeoutResult.exitCode;
116
+ } finally {
117
+ // Unregister PID after exit
118
+ await pidRegistry.unregister(proc.pid);
119
+ }
111
120
 
112
121
  if (exitCode !== 0) {
113
122
  throw new Error(`Plan mode failed with exit code ${exitCode}`);
@@ -133,10 +142,16 @@ export async function runPlan(
133
142
  // Register PID
134
143
  await pidRegistry.register(proc.pid);
135
144
 
136
- const exitCode = await proc.exited;
137
-
138
- // Unregister PID after exit
139
- await pidRegistry.unregister(proc.pid);
145
+ let exitCode: number;
146
+ try {
147
+ const timeoutResult = await withProcessTimeout(proc, PLAN_TIMEOUT_MS, {
148
+ graceMs: 5000,
149
+ });
150
+ exitCode = timeoutResult.exitCode;
151
+ } finally {
152
+ // Unregister PID after exit
153
+ await pidRegistry.unregister(proc.pid);
154
+ }
140
155
 
141
156
  const specContent = await Bun.file(outFile).text();
142
157
  const conversationLog = await Bun.file(errFile).text();