@nathapp/nax 0.42.5 → 0.42.7

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 CHANGED
@@ -18963,6 +18963,8 @@ class SpawnAcpSession {
18963
18963
  timeoutSeconds;
18964
18964
  permissionMode;
18965
18965
  env;
18966
+ pidRegistry;
18967
+ activeProc = null;
18966
18968
  constructor(opts) {
18967
18969
  this.agentName = opts.agentName;
18968
18970
  this.sessionName = opts.sessionName;
@@ -18971,6 +18973,7 @@ class SpawnAcpSession {
18971
18973
  this.timeoutSeconds = opts.timeoutSeconds;
18972
18974
  this.permissionMode = opts.permissionMode;
18973
18975
  this.env = opts.env;
18976
+ this.pidRegistry = opts.pidRegistry;
18974
18977
  }
18975
18978
  async prompt(text) {
18976
18979
  const cmd = [
@@ -18997,35 +19000,50 @@ class SpawnAcpSession {
18997
19000
  stderr: "pipe",
18998
19001
  env: this.env
18999
19002
  });
19000
- proc.stdin.write(text);
19001
- proc.stdin.end();
19002
- const exitCode = await proc.exited;
19003
- const stdout = await new Response(proc.stdout).text();
19004
- const stderr = await new Response(proc.stderr).text();
19005
- if (exitCode !== 0) {
19006
- getSafeLogger()?.warn("acp-adapter", `Session prompt exited with code ${exitCode}`, {
19007
- stderr: stderr.slice(0, 200)
19008
- });
19009
- return {
19010
- messages: [{ role: "assistant", content: stderr || `Exit code ${exitCode}` }],
19011
- stopReason: "error"
19012
- };
19013
- }
19003
+ this.activeProc = proc;
19004
+ const processPid = proc.pid;
19005
+ await this.pidRegistry?.register(processPid);
19014
19006
  try {
19015
- const parsed = parseAcpxJsonOutput(stdout);
19016
- return {
19017
- messages: [{ role: "assistant", content: parsed.text || "" }],
19018
- stopReason: "end_turn",
19019
- cumulative_token_usage: parsed.tokenUsage
19020
- };
19021
- } catch (err) {
19022
- getSafeLogger()?.warn("acp-adapter", "Failed to parse session prompt response", {
19023
- stderr: stderr.slice(0, 200)
19024
- });
19025
- throw err;
19007
+ proc.stdin.write(text);
19008
+ proc.stdin.end();
19009
+ const exitCode = await proc.exited;
19010
+ const stdout = await new Response(proc.stdout).text();
19011
+ const stderr = await new Response(proc.stderr).text();
19012
+ if (exitCode !== 0) {
19013
+ getSafeLogger()?.warn("acp-adapter", `Session prompt exited with code ${exitCode}`, {
19014
+ stderr: stderr.slice(0, 200)
19015
+ });
19016
+ return {
19017
+ messages: [{ role: "assistant", content: stderr || `Exit code ${exitCode}` }],
19018
+ stopReason: "error"
19019
+ };
19020
+ }
19021
+ try {
19022
+ const parsed = parseAcpxJsonOutput(stdout);
19023
+ return {
19024
+ messages: [{ role: "assistant", content: parsed.text || "" }],
19025
+ stopReason: "end_turn",
19026
+ cumulative_token_usage: parsed.tokenUsage
19027
+ };
19028
+ } catch (err) {
19029
+ getSafeLogger()?.warn("acp-adapter", "Failed to parse session prompt response", {
19030
+ stderr: stderr.slice(0, 200)
19031
+ });
19032
+ throw err;
19033
+ }
19034
+ } finally {
19035
+ this.activeProc = null;
19036
+ await this.pidRegistry?.unregister(processPid);
19026
19037
  }
19027
19038
  }
19028
19039
  async close() {
19040
+ if (this.activeProc) {
19041
+ try {
19042
+ this.activeProc.kill(15);
19043
+ getSafeLogger()?.debug("acp-adapter", `Killed active prompt process PID ${this.activeProc.pid}`);
19044
+ } catch {}
19045
+ this.activeProc = null;
19046
+ }
19029
19047
  const cmd = ["acpx", this.agentName, "sessions", "close", this.sessionName];
19030
19048
  getSafeLogger()?.debug("acp-adapter", `Closing session: ${this.sessionName}`);
19031
19049
  const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
@@ -19039,6 +19057,12 @@ class SpawnAcpSession {
19039
19057
  }
19040
19058
  }
19041
19059
  async cancelActivePrompt() {
19060
+ if (this.activeProc) {
19061
+ try {
19062
+ this.activeProc.kill(15);
19063
+ getSafeLogger()?.debug("acp-adapter", `Killed active prompt process PID ${this.activeProc.pid}`);
19064
+ } catch {}
19065
+ }
19042
19066
  const cmd = ["acpx", this.agentName, "cancel"];
19043
19067
  getSafeLogger()?.debug("acp-adapter", `Cancelling active prompt: ${this.sessionName}`);
19044
19068
  const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
@@ -19051,8 +19075,10 @@ class SpawnAcpClient {
19051
19075
  model;
19052
19076
  cwd;
19053
19077
  timeoutSeconds;
19078
+ permissionMode;
19054
19079
  env;
19055
- constructor(cmdStr, cwd, timeoutSeconds) {
19080
+ pidRegistry;
19081
+ constructor(cmdStr, cwd, timeoutSeconds, pidRegistry) {
19056
19082
  const parts = cmdStr.split(/\s+/);
19057
19083
  const modelIdx = parts.indexOf("--model");
19058
19084
  this.model = modelIdx >= 0 && parts[modelIdx + 1] ? parts[modelIdx + 1] : "default";
@@ -19063,7 +19089,9 @@ class SpawnAcpClient {
19063
19089
  this.agentName = lastToken;
19064
19090
  this.cwd = cwd || process.cwd();
19065
19091
  this.timeoutSeconds = timeoutSeconds || 1800;
19092
+ this.permissionMode = "approve-reads";
19066
19093
  this.env = buildAllowedEnv2();
19094
+ this.pidRegistry = pidRegistry;
19067
19095
  }
19068
19096
  async start() {}
19069
19097
  async createSession(opts) {
@@ -19083,7 +19111,8 @@ class SpawnAcpClient {
19083
19111
  model: this.model,
19084
19112
  timeoutSeconds: this.timeoutSeconds,
19085
19113
  permissionMode: opts.permissionMode,
19086
- env: this.env
19114
+ env: this.env,
19115
+ pidRegistry: this.pidRegistry
19087
19116
  });
19088
19117
  }
19089
19118
  async loadSession(sessionName, agentName) {
@@ -19099,14 +19128,15 @@ class SpawnAcpClient {
19099
19128
  cwd: this.cwd,
19100
19129
  model: this.model,
19101
19130
  timeoutSeconds: this.timeoutSeconds,
19102
- permissionMode: "approve-all",
19103
- env: this.env
19131
+ permissionMode: this.permissionMode,
19132
+ env: this.env,
19133
+ pidRegistry: this.pidRegistry
19104
19134
  });
19105
19135
  }
19106
19136
  async close() {}
19107
19137
  }
19108
- function createSpawnAcpClient(cmdStr, cwd, timeoutSeconds) {
19109
- return new SpawnAcpClient(cmdStr, cwd, timeoutSeconds);
19138
+ function createSpawnAcpClient(cmdStr, cwd, timeoutSeconds, pidRegistry) {
19139
+ return new SpawnAcpClient(cmdStr, cwd, timeoutSeconds, pidRegistry);
19110
19140
  }
19111
19141
  var _spawnClientDeps;
19112
19142
  var init_spawn_client = __esm(() => {
@@ -19367,7 +19397,7 @@ class AcpAgentAdapter {
19367
19397
  }
19368
19398
  async _runWithClient(options, startTime) {
19369
19399
  const cmdStr = `acpx --model ${options.modelDef.model} ${this.name}`;
19370
- const client = _acpAdapterDeps.createClient(cmdStr, options.workdir, options.timeoutSeconds);
19400
+ const client = _acpAdapterDeps.createClient(cmdStr, options.workdir, options.timeoutSeconds, options.pidRegistry);
19371
19401
  await client.start();
19372
19402
  let sessionName = options.acpSessionName;
19373
19403
  if (!sessionName && options.featureName && options.storyId) {
@@ -19539,7 +19569,8 @@ class AcpAgentAdapter {
19539
19569
  maxInteractionTurns: options.maxInteractionTurns,
19540
19570
  featureName: options.featureName,
19541
19571
  storyId: options.storyId,
19542
- sessionRole: options.sessionRole
19572
+ sessionRole: options.sessionRole,
19573
+ pidRegistry: options.pidRegistry
19543
19574
  });
19544
19575
  if (!result.success) {
19545
19576
  throw new Error(`[acp-adapter] plan() failed: ${result.output}`);
@@ -19609,8 +19640,8 @@ var init_adapter = __esm(() => {
19609
19640
  async sleep(ms) {
19610
19641
  await Bun.sleep(ms);
19611
19642
  },
19612
- createClient(cmdStr, cwd, timeoutSeconds) {
19613
- return createSpawnAcpClient(cmdStr, cwd, timeoutSeconds);
19643
+ createClient(cmdStr, cwd, timeoutSeconds, pidRegistry) {
19644
+ return createSpawnAcpClient(cmdStr, cwd, timeoutSeconds, pidRegistry);
19614
19645
  }
19615
19646
  };
19616
19647
  });
@@ -20171,14 +20202,27 @@ var init_chain = __esm(() => {
20171
20202
  });
20172
20203
 
20173
20204
  // src/utils/path-security.ts
20174
- import { isAbsolute, join as join5, normalize, resolve } from "path";
20205
+ import { realpathSync } from "fs";
20206
+ import { dirname, isAbsolute, join as join5, normalize, resolve } from "path";
20207
+ function safeRealpath(p) {
20208
+ try {
20209
+ return realpathSync(p);
20210
+ } catch {
20211
+ try {
20212
+ const parent = realpathSync(dirname(p));
20213
+ return join5(parent, p.split("/").pop() ?? "");
20214
+ } catch {
20215
+ return p;
20216
+ }
20217
+ }
20218
+ }
20175
20219
  function validateModulePath(modulePath, allowedRoots) {
20176
20220
  if (!modulePath) {
20177
20221
  return { valid: false, error: "Module path is empty" };
20178
20222
  }
20179
- const normalizedRoots = allowedRoots.map((r) => resolve(r));
20223
+ const normalizedRoots = allowedRoots.map((r) => safeRealpath(resolve(r)));
20180
20224
  if (isAbsolute(modulePath)) {
20181
- const absoluteTarget = normalize(modulePath);
20225
+ const absoluteTarget = safeRealpath(normalize(modulePath));
20182
20226
  const isWithin = normalizedRoots.some((root) => {
20183
20227
  return absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root;
20184
20228
  });
@@ -20187,7 +20231,7 @@ function validateModulePath(modulePath, allowedRoots) {
20187
20231
  }
20188
20232
  } else {
20189
20233
  for (const root of normalizedRoots) {
20190
- const absoluteTarget = resolve(join5(root, modulePath));
20234
+ const absoluteTarget = safeRealpath(resolve(join5(root, modulePath)));
20191
20235
  if (absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root) {
20192
20236
  return { valid: true, absolutePath: absoluteTarget };
20193
20237
  }
@@ -20467,7 +20511,7 @@ function isPlainObject2(value) {
20467
20511
  }
20468
20512
 
20469
20513
  // src/config/path-security.ts
20470
- import { existsSync as existsSync4, lstatSync, realpathSync } from "fs";
20514
+ import { existsSync as existsSync4, lstatSync, realpathSync as realpathSync2 } from "fs";
20471
20515
  import { isAbsolute as isAbsolute2, normalize as normalize2, resolve as resolve3 } from "path";
20472
20516
  function validateDirectory(dirPath, baseDir) {
20473
20517
  const resolved = resolve3(dirPath);
@@ -20476,7 +20520,7 @@ function validateDirectory(dirPath, baseDir) {
20476
20520
  }
20477
20521
  let realPath;
20478
20522
  try {
20479
- realPath = realpathSync(resolved);
20523
+ realPath = realpathSync2(resolved);
20480
20524
  } catch (error48) {
20481
20525
  throw new Error(`Failed to resolve path: ${dirPath} (${error48.message})`);
20482
20526
  }
@@ -20490,7 +20534,7 @@ function validateDirectory(dirPath, baseDir) {
20490
20534
  }
20491
20535
  if (baseDir) {
20492
20536
  const resolvedBase = resolve3(baseDir);
20493
- const realBase = existsSync4(resolvedBase) ? realpathSync(resolvedBase) : resolvedBase;
20537
+ const realBase = existsSync4(resolvedBase) ? realpathSync2(resolvedBase) : resolvedBase;
20494
20538
  if (!isWithinDirectory(realPath, realBase)) {
20495
20539
  throw new Error(`Path is outside allowed directory: ${dirPath} (resolved to ${realPath}, base: ${realBase})`);
20496
20540
  }
@@ -20514,19 +20558,19 @@ function validateFilePath(filePath, baseDir) {
20514
20558
  if (!existsSync4(resolved)) {
20515
20559
  const parent = resolve3(resolved, "..");
20516
20560
  if (existsSync4(parent)) {
20517
- const realParent = realpathSync(parent);
20561
+ const realParent = realpathSync2(parent);
20518
20562
  realPath = resolve3(realParent, filePath.split("/").pop() || "");
20519
20563
  } else {
20520
20564
  realPath = resolved;
20521
20565
  }
20522
20566
  } else {
20523
- realPath = realpathSync(resolved);
20567
+ realPath = realpathSync2(resolved);
20524
20568
  }
20525
20569
  } catch (error48) {
20526
20570
  throw new Error(`Failed to resolve path: ${filePath} (${error48.message})`);
20527
20571
  }
20528
20572
  const resolvedBase = resolve3(baseDir);
20529
- const realBase = existsSync4(resolvedBase) ? realpathSync(resolvedBase) : resolvedBase;
20573
+ const realBase = existsSync4(resolvedBase) ? realpathSync2(resolvedBase) : resolvedBase;
20530
20574
  if (!isWithinDirectory(realPath, realBase)) {
20531
20575
  throw new Error(`Path is outside allowed directory: ${filePath} (resolved to ${realPath}, base: ${realBase})`);
20532
20576
  }
@@ -21870,7 +21914,7 @@ var package_default;
21870
21914
  var init_package = __esm(() => {
21871
21915
  package_default = {
21872
21916
  name: "@nathapp/nax",
21873
- version: "0.42.5",
21917
+ version: "0.42.7",
21874
21918
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
21875
21919
  type: "module",
21876
21920
  bin: {
@@ -21943,8 +21987,8 @@ var init_version = __esm(() => {
21943
21987
  NAX_VERSION = package_default.version;
21944
21988
  NAX_COMMIT = (() => {
21945
21989
  try {
21946
- if (/^[0-9a-f]{6,10}$/.test("7b603fa"))
21947
- return "7b603fa";
21990
+ if (/^[0-9a-f]{6,10}$/.test("e8a2a25"))
21991
+ return "e8a2a25";
21948
21992
  } catch {}
21949
21993
  try {
21950
21994
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -24423,7 +24467,7 @@ var init_constitution = __esm(() => {
24423
24467
  });
24424
24468
 
24425
24469
  // src/pipeline/stages/constitution.ts
24426
- import { dirname } from "path";
24470
+ import { dirname as dirname2 } from "path";
24427
24471
  var constitutionStage;
24428
24472
  var init_constitution2 = __esm(() => {
24429
24473
  init_constitution();
@@ -24433,7 +24477,7 @@ var init_constitution2 = __esm(() => {
24433
24477
  enabled: (ctx) => ctx.config.constitution.enabled,
24434
24478
  async execute(ctx) {
24435
24479
  const logger = getLogger();
24436
- const ngentDir = ctx.featureDir ? dirname(dirname(ctx.featureDir)) : `${ctx.workdir}/nax`;
24480
+ const ngentDir = ctx.featureDir ? dirname2(dirname2(ctx.featureDir)) : `${ctx.workdir}/nax`;
24437
24481
  const result = await loadConstitution(ngentDir, ctx.config.constitution);
24438
24482
  if (result) {
24439
24483
  ctx.constitution = result;
@@ -25230,6 +25274,7 @@ var init_story_context = __esm(() => {
25230
25274
  });
25231
25275
 
25232
25276
  // src/execution/lock.ts
25277
+ import { unlink } from "fs/promises";
25233
25278
  import path8 from "path";
25234
25279
  function getSafeLogger3() {
25235
25280
  try {
@@ -25263,7 +25308,7 @@ async function acquireLock(workdir) {
25263
25308
  });
25264
25309
  const fs2 = await import("fs/promises");
25265
25310
  await fs2.unlink(lockPath).catch(() => {});
25266
- lockData2 = undefined;
25311
+ lockData2 = null;
25267
25312
  }
25268
25313
  if (lockData2) {
25269
25314
  const lockPid = lockData2.pid;
@@ -25301,18 +25346,14 @@ async function acquireLock(workdir) {
25301
25346
  async function releaseLock(workdir) {
25302
25347
  const lockPath = path8.join(workdir, "nax.lock");
25303
25348
  try {
25304
- const file2 = Bun.file(lockPath);
25305
- const exists = await file2.exists();
25306
- if (exists) {
25307
- const proc = Bun.spawn(["rm", lockPath], { stdout: "pipe" });
25308
- await proc.exited;
25309
- await Bun.sleep(10);
25310
- }
25349
+ await unlink(lockPath);
25311
25350
  } catch (error48) {
25312
- const logger = getSafeLogger3();
25313
- logger?.warn("execution", "Failed to release lock", {
25314
- error: error48.message
25315
- });
25351
+ if (error48.code !== "ENOENT") {
25352
+ const logger = getSafeLogger3();
25353
+ logger?.warn("execution", "Failed to release lock", {
25354
+ error: error48.message
25355
+ });
25356
+ }
25316
25357
  }
25317
25358
  }
25318
25359
  var init_lock = __esm(() => {
@@ -25655,17 +25696,17 @@ async function autoCommitIfDirty(workdir, stage, role, storyId) {
25655
25696
  });
25656
25697
  const gitRoot = (await new Response(topLevelProc.stdout).text()).trim();
25657
25698
  await topLevelProc.exited;
25658
- const { realpathSync: realpathSync3 } = await import("fs");
25699
+ const { realpathSync: realpathSync4 } = await import("fs");
25659
25700
  const realWorkdir = (() => {
25660
25701
  try {
25661
- return realpathSync3(workdir);
25702
+ return realpathSync4(workdir);
25662
25703
  } catch {
25663
25704
  return workdir;
25664
25705
  }
25665
25706
  })();
25666
25707
  const realGitRoot = (() => {
25667
25708
  try {
25668
- return realpathSync3(gitRoot);
25709
+ return realpathSync4(gitRoot);
25669
25710
  } catch {
25670
25711
  return gitRoot;
25671
25712
  }
@@ -26957,7 +26998,7 @@ var init_session_runner = __esm(() => {
26957
26998
  });
26958
26999
 
26959
27000
  // src/tdd/verdict-reader.ts
26960
- import { unlink } from "fs/promises";
27001
+ import { unlink as unlink2 } from "fs/promises";
26961
27002
  import path9 from "path";
26962
27003
  function isValidVerdict(obj) {
26963
27004
  if (!obj || typeof obj !== "object")
@@ -27157,7 +27198,7 @@ async function readVerdict(workdir) {
27157
27198
  async function cleanupVerdict(workdir) {
27158
27199
  const verdictPath = path9.join(workdir, VERDICT_FILE);
27159
27200
  try {
27160
- await unlink(verdictPath);
27201
+ await unlink2(verdictPath);
27161
27202
  } catch {}
27162
27203
  }
27163
27204
  var VERDICT_FILE = ".nax-verifier-verdict.json";
@@ -30883,14 +30924,15 @@ function installSignalHandlers(ctx) {
30883
30924
  process.on("SIGINT", sigintHandler);
30884
30925
  process.on("SIGHUP", sighupHandler);
30885
30926
  process.on("uncaughtException", uncaughtExceptionHandler);
30886
- process.on("unhandledRejection", (reason) => unhandledRejectionHandler(reason));
30927
+ const rejectionWrapper = (reason) => unhandledRejectionHandler(reason);
30928
+ process.on("unhandledRejection", rejectionWrapper);
30887
30929
  logger?.debug("crash-recovery", "Signal handlers installed");
30888
30930
  return () => {
30889
30931
  process.removeListener("SIGTERM", sigtermHandler);
30890
30932
  process.removeListener("SIGINT", sigintHandler);
30891
30933
  process.removeListener("SIGHUP", sighupHandler);
30892
30934
  process.removeListener("uncaughtException", uncaughtExceptionHandler);
30893
- process.removeListener("unhandledRejection", (reason) => unhandledRejectionHandler(reason));
30935
+ process.removeListener("unhandledRejection", rejectionWrapper);
30894
30936
  logger?.debug("crash-recovery", "Signal handlers unregistered");
30895
30937
  };
30896
30938
  }
@@ -33666,7 +33708,7 @@ var init_sequential_executor = __esm(() => {
33666
33708
  });
33667
33709
 
33668
33710
  // src/execution/status-file.ts
33669
- import { rename, unlink as unlink2 } from "fs/promises";
33711
+ import { rename, unlink as unlink3 } from "fs/promises";
33670
33712
  import { resolve as resolve8 } from "path";
33671
33713
  function countProgress(prd) {
33672
33714
  const stories = prd.userStories;
@@ -33715,7 +33757,7 @@ async function writeStatusFile(filePath, status) {
33715
33757
  }
33716
33758
  const tmpPath = `${resolvedPath}.tmp`;
33717
33759
  try {
33718
- await unlink2(tmpPath);
33760
+ await unlink3(tmpPath);
33719
33761
  } catch {}
33720
33762
  await Bun.write(tmpPath, JSON.stringify(status, null, 2));
33721
33763
  await rename(tmpPath, resolvedPath);
@@ -65602,6 +65644,7 @@ init_registry();
65602
65644
  import { existsSync as existsSync9 } from "fs";
65603
65645
  import { join as join10 } from "path";
65604
65646
  import { createInterface } from "readline";
65647
+ init_pid_registry();
65605
65648
  init_logger2();
65606
65649
 
65607
65650
  // src/prd/schema.ts
@@ -65805,6 +65848,7 @@ async function planCommand(workdir, config2, options) {
65805
65848
  if (!adapter)
65806
65849
  throw new Error(`[plan] No agent adapter found for '${agentName}'`);
65807
65850
  const interactionBridge = createCliInteractionBridge();
65851
+ const pidRegistry = new PidRegistry(workdir);
65808
65852
  logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
65809
65853
  try {
65810
65854
  await adapter.plan({
@@ -65817,9 +65861,11 @@ async function planCommand(workdir, config2, options) {
65817
65861
  modelTier: config2?.plan?.model ?? "balanced",
65818
65862
  dangerouslySkipPermissions: config2?.execution?.dangerouslySkipPermissions ?? false,
65819
65863
  maxInteractionTurns: config2?.agent?.maxInteractionTurns,
65820
- featureName: options.feature
65864
+ featureName: options.feature,
65865
+ pidRegistry
65821
65866
  });
65822
65867
  } finally {
65868
+ await pidRegistry.killAll().catch(() => {});
65823
65869
  logger?.info("plan", "Interactive session ended");
65824
65870
  }
65825
65871
  if (!_deps2.existsSync(outputPath)) {
@@ -66107,7 +66153,7 @@ import { join as join13 } from "path";
66107
66153
  // src/commands/common.ts
66108
66154
  init_path_security2();
66109
66155
  init_errors3();
66110
- import { existsSync as existsSync10, readdirSync as readdirSync2, realpathSync as realpathSync2 } from "fs";
66156
+ import { existsSync as existsSync10, readdirSync as readdirSync2, realpathSync as realpathSync3 } from "fs";
66111
66157
  import { join as join11, resolve as resolve6 } from "path";
66112
66158
  function resolveProject(options = {}) {
66113
66159
  const { dir, feature } = options;
@@ -66115,7 +66161,7 @@ function resolveProject(options = {}) {
66115
66161
  let naxDir;
66116
66162
  let configPath;
66117
66163
  if (dir) {
66118
- projectRoot = realpathSync2(resolve6(dir));
66164
+ projectRoot = realpathSync3(resolve6(dir));
66119
66165
  naxDir = join11(projectRoot, "nax");
66120
66166
  if (!existsSync10(naxDir)) {
66121
66167
  throw new NaxError(`Directory does not contain a nax project: ${projectRoot}
@@ -66174,7 +66220,7 @@ function findProjectRoot(startDir) {
66174
66220
  const naxDir = join11(current, "nax");
66175
66221
  const configPath = join11(naxDir, "config.json");
66176
66222
  if (existsSync10(configPath)) {
66177
- return realpathSync2(current);
66223
+ return realpathSync3(current);
66178
66224
  }
66179
66225
  const parent = join11(current, "..");
66180
66226
  if (parent === current) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.42.5",
3
+ "version": "0.42.7",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -114,8 +114,13 @@ export const _acpAdapterDeps = {
114
114
  * Default: spawn-based client (shells out to acpx CLI).
115
115
  * Override in tests via: _acpAdapterDeps.createClient = mock(...)
116
116
  */
117
- createClient(cmdStr: string, cwd?: string, timeoutSeconds?: number): AcpClient {
118
- return createSpawnAcpClient(cmdStr, cwd, timeoutSeconds);
117
+ createClient(
118
+ cmdStr: string,
119
+ cwd?: string,
120
+ timeoutSeconds?: number,
121
+ pidRegistry?: import("../../execution/pid-registry").PidRegistry,
122
+ ): AcpClient {
123
+ return createSpawnAcpClient(cmdStr, cwd, timeoutSeconds, pidRegistry);
119
124
  },
120
125
  };
121
126
 
@@ -436,7 +441,7 @@ export class AcpAgentAdapter implements AgentAdapter {
436
441
 
437
442
  private async _runWithClient(options: AgentRunOptions, startTime: number): Promise<AgentResult> {
438
443
  const cmdStr = `acpx --model ${options.modelDef.model} ${this.name}`;
439
- const client = _acpAdapterDeps.createClient(cmdStr, options.workdir, options.timeoutSeconds);
444
+ const client = _acpAdapterDeps.createClient(cmdStr, options.workdir, options.timeoutSeconds, options.pidRegistry);
440
445
  await client.start();
441
446
 
442
447
  // 1. Resolve session name: explicit > sidecar > derived
@@ -673,6 +678,7 @@ export class AcpAgentAdapter implements AgentAdapter {
673
678
  featureName: options.featureName,
674
679
  storyId: options.storyId,
675
680
  sessionRole: options.sessionRole,
681
+ pidRegistry: options.pidRegistry,
676
682
  });
677
683
 
678
684
  if (!result.success) {
@@ -12,6 +12,7 @@
12
12
  * acpx <agent> cancel → session.cancelActivePrompt()
13
13
  */
14
14
 
15
+ import type { PidRegistry } from "../../execution/pid-registry";
15
16
  import { getSafeLogger } from "../../logger";
16
17
  import type { AcpClient, AcpSession, AcpSessionResponse } from "./adapter";
17
18
  import { parseAcpxJsonOutput } from "./parser";
@@ -96,6 +97,8 @@ class SpawnAcpSession implements AcpSession {
96
97
  private readonly timeoutSeconds: number;
97
98
  private readonly permissionMode: string;
98
99
  private readonly env: Record<string, string | undefined>;
100
+ private readonly pidRegistry?: PidRegistry;
101
+ private activeProc: { pid: number; kill(signal?: number): void } | null = null;
99
102
 
100
103
  constructor(opts: {
101
104
  agentName: string;
@@ -105,6 +108,7 @@ class SpawnAcpSession implements AcpSession {
105
108
  timeoutSeconds: number;
106
109
  permissionMode: string;
107
110
  env: Record<string, string | undefined>;
111
+ pidRegistry?: PidRegistry;
108
112
  }) {
109
113
  this.agentName = opts.agentName;
110
114
  this.sessionName = opts.sessionName;
@@ -113,6 +117,7 @@ class SpawnAcpSession implements AcpSession {
113
117
  this.timeoutSeconds = opts.timeoutSeconds;
114
118
  this.permissionMode = opts.permissionMode;
115
119
  this.env = opts.env;
120
+ this.pidRegistry = opts.pidRegistry;
116
121
  }
117
122
 
118
123
  async prompt(text: string): Promise<AcpSessionResponse> {
@@ -143,40 +148,60 @@ class SpawnAcpSession implements AcpSession {
143
148
  env: this.env,
144
149
  });
145
150
 
146
- proc.stdin.write(text);
147
- proc.stdin.end();
151
+ this.activeProc = proc;
152
+ const processPid = proc.pid;
153
+ await this.pidRegistry?.register(processPid);
148
154
 
149
- const exitCode = await proc.exited;
150
- const stdout = await new Response(proc.stdout).text();
151
- const stderr = await new Response(proc.stderr).text();
155
+ try {
156
+ proc.stdin.write(text);
157
+ proc.stdin.end();
152
158
 
153
- if (exitCode !== 0) {
154
- getSafeLogger()?.warn("acp-adapter", `Session prompt exited with code ${exitCode}`, {
155
- stderr: stderr.slice(0, 200),
156
- });
157
- // Return error response so the adapter can handle it
158
- return {
159
- messages: [{ role: "assistant", content: stderr || `Exit code ${exitCode}` }],
160
- stopReason: "error",
161
- };
162
- }
159
+ const exitCode = await proc.exited;
160
+ const stdout = await new Response(proc.stdout).text();
161
+ const stderr = await new Response(proc.stderr).text();
163
162
 
164
- try {
165
- const parsed = parseAcpxJsonOutput(stdout);
166
- return {
167
- messages: [{ role: "assistant", content: parsed.text || "" }],
168
- stopReason: "end_turn",
169
- cumulative_token_usage: parsed.tokenUsage,
170
- };
171
- } catch (err) {
172
- getSafeLogger()?.warn("acp-adapter", "Failed to parse session prompt response", {
173
- stderr: stderr.slice(0, 200),
174
- });
175
- throw err;
163
+ if (exitCode !== 0) {
164
+ getSafeLogger()?.warn("acp-adapter", `Session prompt exited with code ${exitCode}`, {
165
+ stderr: stderr.slice(0, 200),
166
+ });
167
+ // Return error response so the adapter can handle it
168
+ return {
169
+ messages: [{ role: "assistant", content: stderr || `Exit code ${exitCode}` }],
170
+ stopReason: "error",
171
+ };
172
+ }
173
+
174
+ try {
175
+ const parsed = parseAcpxJsonOutput(stdout);
176
+ return {
177
+ messages: [{ role: "assistant", content: parsed.text || "" }],
178
+ stopReason: "end_turn",
179
+ cumulative_token_usage: parsed.tokenUsage,
180
+ };
181
+ } catch (err) {
182
+ getSafeLogger()?.warn("acp-adapter", "Failed to parse session prompt response", {
183
+ stderr: stderr.slice(0, 200),
184
+ });
185
+ throw err;
186
+ }
187
+ } finally {
188
+ this.activeProc = null;
189
+ await this.pidRegistry?.unregister(processPid);
176
190
  }
177
191
  }
178
192
 
179
193
  async close(): Promise<void> {
194
+ // Kill in-flight prompt process first (if any)
195
+ if (this.activeProc) {
196
+ try {
197
+ this.activeProc.kill(15); // SIGTERM
198
+ getSafeLogger()?.debug("acp-adapter", `Killed active prompt process PID ${this.activeProc.pid}`);
199
+ } catch {
200
+ // Process may have already exited
201
+ }
202
+ this.activeProc = null;
203
+ }
204
+
180
205
  const cmd = ["acpx", this.agentName, "sessions", "close", this.sessionName];
181
206
  getSafeLogger()?.debug("acp-adapter", `Closing session: ${this.sessionName}`);
182
207
 
@@ -193,6 +218,16 @@ class SpawnAcpSession implements AcpSession {
193
218
  }
194
219
 
195
220
  async cancelActivePrompt(): Promise<void> {
221
+ // Kill in-flight prompt process directly (faster than acpx cancel)
222
+ if (this.activeProc) {
223
+ try {
224
+ this.activeProc.kill(15); // SIGTERM
225
+ getSafeLogger()?.debug("acp-adapter", `Killed active prompt process PID ${this.activeProc.pid}`);
226
+ } catch {
227
+ // Process may have already exited
228
+ }
229
+ }
230
+
196
231
  const cmd = ["acpx", this.agentName, "cancel"];
197
232
  getSafeLogger()?.debug("acp-adapter", `Cancelling active prompt: ${this.sessionName}`);
198
233
 
@@ -219,9 +254,11 @@ export class SpawnAcpClient implements AcpClient {
219
254
  private readonly model: string;
220
255
  private readonly cwd: string;
221
256
  private readonly timeoutSeconds: number;
257
+ private readonly permissionMode: string;
222
258
  private readonly env: Record<string, string | undefined>;
259
+ private readonly pidRegistry?: PidRegistry;
223
260
 
224
- constructor(cmdStr: string, cwd?: string, timeoutSeconds?: number) {
261
+ constructor(cmdStr: string, cwd?: string, timeoutSeconds?: number, pidRegistry?: PidRegistry) {
225
262
  // Parse: "acpx --model <model> <agentName>"
226
263
  const parts = cmdStr.split(/\s+/);
227
264
  const modelIdx = parts.indexOf("--model");
@@ -234,7 +271,9 @@ export class SpawnAcpClient implements AcpClient {
234
271
  this.agentName = lastToken;
235
272
  this.cwd = cwd || process.cwd();
236
273
  this.timeoutSeconds = timeoutSeconds || 1800;
274
+ this.permissionMode = "approve-reads";
237
275
  this.env = buildAllowedEnv();
276
+ this.pidRegistry = pidRegistry;
238
277
  }
239
278
 
240
279
  async start(): Promise<void> {
@@ -268,6 +307,7 @@ export class SpawnAcpClient implements AcpClient {
268
307
  timeoutSeconds: this.timeoutSeconds,
269
308
  permissionMode: opts.permissionMode,
270
309
  env: this.env,
310
+ pidRegistry: this.pidRegistry,
271
311
  });
272
312
  }
273
313
 
@@ -288,8 +328,9 @@ export class SpawnAcpClient implements AcpClient {
288
328
  cwd: this.cwd,
289
329
  model: this.model,
290
330
  timeoutSeconds: this.timeoutSeconds,
291
- permissionMode: "approve-all", // Default for resumed sessions
331
+ permissionMode: this.permissionMode,
292
332
  env: this.env,
333
+ pidRegistry: this.pidRegistry,
293
334
  });
294
335
  }
295
336
 
@@ -306,6 +347,11 @@ export class SpawnAcpClient implements AcpClient {
306
347
  * Create a spawn-based ACP client. This is the default production factory.
307
348
  * The cmdStr format is: "acpx --model <model> <agentName>"
308
349
  */
309
- export function createSpawnAcpClient(cmdStr: string, cwd?: string, timeoutSeconds?: number): AcpClient {
310
- return new SpawnAcpClient(cmdStr, cwd, timeoutSeconds);
350
+ export function createSpawnAcpClient(
351
+ cmdStr: string,
352
+ cwd?: string,
353
+ timeoutSeconds?: number,
354
+ pidRegistry?: PidRegistry,
355
+ ): AcpClient {
356
+ return new SpawnAcpClient(cmdStr, cwd, timeoutSeconds, pidRegistry);
311
357
  }
@@ -55,6 +55,8 @@ export interface PlanOptions {
55
55
  * Used to persist the name to status.json for plan→run session continuity.
56
56
  */
57
57
  onAcpSessionCreated?: (sessionName: string) => Promise<void> | void;
58
+ /** PID registry for tracking spawned agent processes — cleanup on crash/SIGTERM */
59
+ pidRegistry?: import("../execution/pid-registry").PidRegistry;
58
60
  }
59
61
 
60
62
  /**
package/src/cli/plan.ts CHANGED
@@ -15,6 +15,7 @@ import type { AgentAdapter } from "../agents/types";
15
15
  import { scanCodebase } from "../analyze/scanner";
16
16
  import type { CodebaseScan } from "../analyze/types";
17
17
  import type { NaxConfig } from "../config";
18
+ import { PidRegistry } from "../execution/pid-registry";
18
19
  import { getLogger } from "../logger";
19
20
  import { validatePlanOutput } from "../prd/schema";
20
21
 
@@ -123,6 +124,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
123
124
  const adapter = _deps.getAgent(agentName, config);
124
125
  if (!adapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
125
126
  const interactionBridge = createCliInteractionBridge();
127
+ const pidRegistry = new PidRegistry(workdir);
126
128
  logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
127
129
  try {
128
130
  await adapter.plan({
@@ -136,8 +138,10 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
136
138
  dangerouslySkipPermissions: config?.execution?.dangerouslySkipPermissions ?? false,
137
139
  maxInteractionTurns: config?.agent?.maxInteractionTurns,
138
140
  featureName: options.feature,
141
+ pidRegistry,
139
142
  });
140
143
  } finally {
144
+ await pidRegistry.killAll().catch(() => {});
141
145
  logger?.info("plan", "Interactive session ended");
142
146
  }
143
147
  // Read back from file written by agent
@@ -134,7 +134,8 @@ export function installSignalHandlers(ctx: SignalHandlerContext): () => void {
134
134
  process.on("SIGINT", sigintHandler);
135
135
  process.on("SIGHUP", sighupHandler);
136
136
  process.on("uncaughtException", uncaughtExceptionHandler);
137
- process.on("unhandledRejection", (reason) => unhandledRejectionHandler(reason));
137
+ const rejectionWrapper = (reason: unknown) => unhandledRejectionHandler(reason);
138
+ process.on("unhandledRejection", rejectionWrapper);
138
139
 
139
140
  logger?.debug("crash-recovery", "Signal handlers installed");
140
141
 
@@ -143,7 +144,7 @@ export function installSignalHandlers(ctx: SignalHandlerContext): () => void {
143
144
  process.removeListener("SIGINT", sigintHandler);
144
145
  process.removeListener("SIGHUP", sighupHandler);
145
146
  process.removeListener("uncaughtException", uncaughtExceptionHandler);
146
- process.removeListener("unhandledRejection", (reason) => unhandledRejectionHandler(reason));
147
+ process.removeListener("unhandledRejection", rejectionWrapper);
147
148
  logger?.debug("crash-recovery", "Signal handlers unregistered");
148
149
  };
149
150
  }
@@ -5,6 +5,7 @@
5
5
  * Prevents concurrent runs in the same directory.
6
6
  */
7
7
 
8
+ import { unlink } from "node:fs/promises";
8
9
  import path from "node:path";
9
10
  import { getLogger } from "../logger";
10
11
 
@@ -49,7 +50,7 @@ export async function acquireLock(workdir: string): Promise<boolean> {
49
50
  if (exists) {
50
51
  // Read lock data
51
52
  const lockContent = await lockFile.text();
52
- let lockData: { pid: number };
53
+ let lockData: { pid: number } | null;
53
54
  try {
54
55
  lockData = JSON.parse(lockContent);
55
56
  } catch {
@@ -61,7 +62,7 @@ export async function acquireLock(workdir: string): Promise<boolean> {
61
62
  const fs = await import("node:fs/promises");
62
63
  await fs.unlink(lockPath).catch(() => {});
63
64
  // Fall through to create a new lock
64
- lockData = undefined as unknown as { pid: number };
65
+ lockData = null;
65
66
  }
66
67
 
67
68
  if (lockData) {
@@ -88,6 +89,7 @@ export async function acquireLock(workdir: string): Promise<boolean> {
88
89
  pid: process.pid,
89
90
  timestamp: Date.now(),
90
91
  };
92
+ // NOTE: Node.js fs used intentionally — Bun.file()/Bun.write() lacks O_CREAT|O_EXCL atomic exclusive create
91
93
  const fs = await import("node:fs");
92
94
  const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o644);
93
95
  fs.writeSync(fd, JSON.stringify(lockData));
@@ -114,18 +116,14 @@ export async function acquireLock(workdir: string): Promise<boolean> {
114
116
  export async function releaseLock(workdir: string): Promise<void> {
115
117
  const lockPath = path.join(workdir, "nax.lock");
116
118
  try {
117
- const file = Bun.file(lockPath);
118
- const exists = await file.exists();
119
- if (exists) {
120
- const proc = Bun.spawn(["rm", lockPath], { stdout: "pipe" });
121
- await proc.exited;
122
- // Wait a bit for filesystem to sync (prevents race in tests)
123
- await Bun.sleep(10);
124
- }
119
+ await unlink(lockPath);
125
120
  } catch (error) {
126
- const logger = getSafeLogger();
127
- logger?.warn("execution", "Failed to release lock", {
128
- error: (error as Error).message,
129
- });
121
+ // Ignore ENOENT (already gone), log others
122
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
123
+ const logger = getSafeLogger();
124
+ logger?.warn("execution", "Failed to release lock", {
125
+ error: (error as Error).message,
126
+ });
127
+ }
130
128
  }
131
129
  }
@@ -2,7 +2,8 @@
2
2
  * Path security utilities for nax (SEC-1, SEC-2).
3
3
  */
4
4
 
5
- import { isAbsolute, join, normalize, resolve } from "node:path";
5
+ import { realpathSync } from "node:fs";
6
+ import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
6
7
 
7
8
  /**
8
9
  * Result of a path validation.
@@ -23,16 +24,32 @@ export interface PathValidationResult {
23
24
  * @param allowedRoots - Array of absolute paths that are allowed as roots
24
25
  * @returns Validation result
25
26
  */
27
+ /** Resolve symlinks for a path that may not exist yet (fall back to parent dir). */
28
+ function safeRealpath(p: string): string {
29
+ try {
30
+ return realpathSync(p);
31
+ } catch {
32
+ // Path doesn't exist — resolve the parent directory instead
33
+ try {
34
+ const parent = realpathSync(dirname(p));
35
+ return join(parent, p.split("/").pop() ?? "");
36
+ } catch {
37
+ return p;
38
+ }
39
+ }
40
+ }
41
+
26
42
  export function validateModulePath(modulePath: string, allowedRoots: string[]): PathValidationResult {
27
43
  if (!modulePath) {
28
44
  return { valid: false, error: "Module path is empty" };
29
45
  }
30
46
 
31
- const normalizedRoots = allowedRoots.map((r) => resolve(r));
47
+ // Resolve symlinks in each root
48
+ const normalizedRoots = allowedRoots.map((r) => safeRealpath(resolve(r)));
32
49
 
33
50
  // If absolute, just check against roots
34
51
  if (isAbsolute(modulePath)) {
35
- const absoluteTarget = normalize(modulePath);
52
+ const absoluteTarget = safeRealpath(normalize(modulePath));
36
53
  const isWithin = normalizedRoots.some((root) => {
37
54
  return absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root;
38
55
  });
@@ -42,7 +59,7 @@ export function validateModulePath(modulePath: string, allowedRoots: string[]):
42
59
  } else {
43
60
  // If relative, check if it's within any root when resolved relative to that root
44
61
  for (const root of normalizedRoots) {
45
- const absoluteTarget = resolve(join(root, modulePath));
62
+ const absoluteTarget = safeRealpath(resolve(join(root, modulePath)));
46
63
  if (absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root) {
47
64
  return { valid: true, absolutePath: absoluteTarget };
48
65
  }