Surface concise re-authentication guidance when a provider rejects credentials, and stop dumping raw JSON error payloads.
A new canonical helper in @n-dx/llm-client (authFailureGuidance / authFailureMessage) is the single source of truth for auth-failure wording: it names the provider, states the cause (Invalid or expired credentials), and gives the exact fix — claude logout && claude login, codex logout && codex login, or ndx config llm.google.api_key <KEY>. Every entry point now reads identically:
ndx init / ndx config llm.vendor — the core preflight (packages/core/config.js) replaces the verbose Details: <raw JSON> dump with the concise, ANSI-colored guidance (red headline, yellow remediation). The NDX error code (e.g. NDX_CLAUDE_PREFLIGHT_AUTH_REQUIRED) is demoted to a dim secondary line instead of the headline, and JSON payloads are never printed. A missing Google key gets a distinct "No API key configured" message.ndx work — the runtime LLM providers already throw AuthFailureError; its message is now the canonical, JSON-free line.ndx plan / ndx analyze — rex/sourcevision route auth errors through the shared classifier and (for rex) render AuthFailureError with the shared remediation.
Redact secrets in the logged commandLine, and cover every secret pattern in the twin tests.
cli-log redacted args but wrote commandLine through verbatim. On Windows that is the same data twice: buildWindowsCliCommandLine(binary, args) embeds the argv into the command line, so a key that redactArgs correctly replaced with <redacted> in the args field reappeared in full in commandLine on the same log line of claude_commands.log.
redactArgs cannot be reused for this, and reaching for it makes things worse rather than better. It iterates its argument, so handing it a string walks the individual characters — every pattern is anchored (^sk-ant-…), no single character matches, and the field is emitted as an array of letters with the secret fully intact:
"commandLine":["c","l","a","u","d","e"," ","-","-","a","p","i","-","k","e","y"," ","s","k","-","a","n","t","-","S","E","C","R","E","T"]
So both twins gain a redactCommandLine(line) that tokenises on whitespace, preserves the original spacing, and applies the same SECRET_FLAGS / SECRET_PATTERNS tables through the surrounding quotes that buildWindowsCliCommandLine adds. Returns a string, as the field's own type always claimed.
Two of the four SECRET_PATTERNS were never exercised. The parity block drove five fixed records, and between them they only ever hit gh[pousr]_; the per-twin behaviour block added sk-ant-. Nothing anywhere passed an AIza… (Google AI Studio) or a non-Anthropic sk-… token, in either twin. Either pattern could have been dropped from either copy with the whole suite green — and a dropped pattern means the key it matches is written to disk in plaintext. Both now have cases, plus redactCommandLine coverage per twin and a secret-bearing commandLine in the parity records.
Verified by deleting the AIza pattern from the core twin alone: 4 assertions fail across both the behaviour and parity blocks, where previously that deletion was invisible.
Also corrects both twins' TWIN docblock, which pointed at tests/unit/cli-log-parity.test.js. No such file exists — the guard is the cli-log twin parity block inside tests/unit/cli-log.test.js. A pointer whose only job is telling the next person where the tripwire is should not name a file that was never there.
Close out Codex workflow parity (#122) and fix the skill-tracking asymmetry (#284).
- Body-drift regression test — a new e2e test regenerates the assistant artifacts from the canonical source (
assistant-assets/) and asserts the committed CLAUDE.md, AGENTS.md, and every vendor SKILL.md match the generator. This closes the last acceptance gap of #122 (tests now fail on body drift, not just inventory drift). It immediately caught a real drift: the committed CLAUDE.md carried a ## Changeset Versioning section that was never in the canonical project-guidance.md, so AGENTS.md silently lacked it — that section is now in the shared source and both instruction files carry it. - #284 — commit both: the generated Claude
ndx-* skills were gitignored while the Codex skills were committed, so cloned checkouts lacked the /ndx-* skills for Claude until re-init. .claude/skills/ is removed from .gitignore, the generated skills are committed (and LF-pinned in .gitattributes, matching .agents/skills/), and ndx init now warns via checkSkillTracking() when an enabled assistant's skill directory is gitignored. - Docs sweep: the web package README and the troubleshooting guide no longer describe MCP setup as Claude-only.
Make ndx export work on Windows.
ndx export --deploy=github could not succeed on Windows. Two independent blockers, both now fixed and both verified end-to-end.
1. The dynamic import aborted the command immediately. export.js did await import(resolvePackagePath(...)) with a bare absolute path, and Node's ESM loader rejects that on Windows:
ERR_UNSUPPORTED_ESM_URL_SCHEME … Received protocol 'c:'
The command died before doing any work — before generating the dashboard, let alone deploying. Now wrapped in pathToFileURL(...).href, the same fix tests/e2e/published-package-loadability.test.js already applies for this exact error. This was not in the original bug report; it surfaced only when the flow was actually run on Windows.
2. POSIX-only shell commands in the deploy path.
rm -rf "<path>" per worktree entry. rm is not a cmd.exe command, and although Git for Windows ships usr/bin/rm.exe it is not normally on PATH. With no try/catch this threw and aborted the deploy. Replaced with rmSync(path, { recursive: true, force: true }) — no shell at all, and force: true carries the -f intent.git rm -rf . 2>/dev/null || true. Both 2>/dev/null and || true are POSIX sh constructs. The tolerate-failure intent moved into a JS try/catch, which is also clearer about *why* it is tolerated: a fresh orphan branch legitimately has nothing to remove.
All 16 remaining execSync command strings converted to argv via execFileSyncCli from win-spawn.js. The interpolated ones hand-quoted paths (tmpWorktree, dir) that break on Windows when a project path ends in a backslash — the trailing backslash escapes its own closing quote — or contains &/^. rex also needs the .cmd shim handling that helper provides.
Worth stating precisely: the unquoted ${branch} interpolations were not an injection vector, because branch is the hardcoded constant "n-dx-dashboard". The genuine risk was the project-derived paths.
export.js is no longer exempt from the shell-string architecture guard; that exemption was retired rather than left standing.
Verified against a scratch repository with a local bare origin, from a project directory named e & p (v2) — a space, an &, and parentheses. Both deploy routes pass: the orphan-branch creation path on first run, and the existing-branch worktree add path on the second. 28 files pushed, no .ndx-deploy-tmp left behind. No real deploy target was ever contacted.
Route ndx init's Claude CLI invocations through the Windows-safe spawn helper.
packages/core/claude-integration.js built six execSync command strings by hand — the MCP remove/add registration pair and four --version discovery probes. Every argument involved is a filesystem path (the claude binary, the resolved MCP entrypoint, the project directory), and the surrounding quoting was a bare ":
execSync(`"${claudeCmd}" mcp add ${name} -- node "${bin}" ${descriptor.mcpCommand} "${absDir}"`)
A project directory ending in a backslash — C:\Users\Tom&Jerry\my proj (v2)\ — produces ..."C:\Users\Tom&Jerry\my proj (v2)\", where the trailing backslash escapes its own closing quote. Argument parsing corrupts from that point on, and the command can still exit 0, so ndx init reports "registered" having stored a truncated command. All six now use execFileSyncCli from win-spawn.js, which applies the quoteWindowsToken/ArgvQuote rules and logs each invocation itself.
Verified against that exact path: the argv form emits "...(v2)\\" with the backslash doubled, keeps every & inside a quoted token, and round-trips 9 argv entries to 9 command-line tokens.
The unquoted interpolations in the old strings (${scope}, ${name}, ${descriptor.mcpCommand}) were manifest-derived constants rather than user input, so this was a correctness bug on unusual-but-legal paths, not a user-input injection vector.
The guard that should have caught it is now scan-based. architecture-policy.test.js walked a hardcoded 12-file DEP0190_SCOPE, so it only ratcheted over files someone remembered to enumerate — which is exactly how claude-integration.js and export.js kept hand-built command lines through an entire Windows-hardening epic. It now scans the whole production tree for:
- imports of
exec/execSync from child_process (the string-command APIs — execFile/execFileSync/spawn take argv and are fine) shell: process.platformshell: true with non-empty args
Exemptions moved into a SHELL_STRING_EXEMPT map where each entry states its reason, with the previously-undocumented ci.js and pr-check.js pnpm cases now recorded explicitly and export.js naming the task that will retire it. Demonstrated red-then-green against a newly added unhardened file, with no edit to the guard required to catch it.
claude-integration.js was also dropped from the child_process import allowlists in architecture-policy.test.js and ci.js — it no longer imports any child_process API, so the permission was removed rather than left permitted-but-unused.
Stop printing the child-lifecycle process-group warning on every Windows ndx invocation.
packages/core/cli.js builds its child-process tracker with processGroups: true at module load, and createChildProcessTracker emitted the fallback notice at construction time. Because PLATFORM_SUPPORTS_PROCESS_GROUPS is always false on win32, every command — including ndx --version, ndx --help, and ndx status, none of which spawn a child — prefixed its output with:
[child-lifecycle] process group cleanup is not supported on this platform; falling back to direct child kill
The message read as a degradation, but direct child kill is the intended Windows path — cli.js already omits detached: true on win32 by design, so there was nothing for users to act on.
The notice is now opt-in behind NDX_DEBUG_LIFECYCLE (or the global NDX_DEBUG), matching the existing NDX_DEBUG_LLM / NDX_DEBUG convention in @n-dx/llm-client. Set either to 1, true, or yes to restore it when diagnosing child-cleanup behavior. Termination behavior is unchanged on all platforms; only the logging is gated. The stripKnownRuntimeNoise filter in scripts/cli-smoke-parity.mjs is retained so smoke output stays comparable against older installs and debug-enabled runs.
Route ndx start's port-occupant kill through the Windows-safe spawn helper.
killPortOccupant() in packages/core/web.js arrived with the local-LLM-provider merge and built four command strings by hand:
execSync(`netstat -ano`, …)
execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" })
execSync(`lsof -ti tcp:${port}`, …)
execSync(`kill -9 ${pid}`, { stdio: "ignore" })
Unlike the earlier claude-integration.js and export.js conversions, there is no live defect here: port is parseInt(flags.port, 10) or a typeof number-validated config value, and both pid values are parseInt results, so no interpolation can carry &, ^, (, ), !, or a trailing backslash into a shell command line. This is the blanket exec/execSync policy being applied to new code rather than a bug being fixed — the point of a scan-based guard is that new files do not get to opt out on the argument that their particular interpolations happen to be safe today.
netstat, taskkill, and lsof now go through execFileSyncCli from win-spawn.js with argv. The POSIX kill drops its subprocess entirely in favour of process.kill(pid, "SIGKILL") — spawning /bin/kill to deliver a signal to a pid already in hand added a dependency on the binary being present for nothing. Failure behaviour is unchanged: execFileSyncCli throws on a non-zero exit exactly as execSync did (lsof -ti exits 1 when nothing is listening), and the whole body is already wrapped in a try/catch that returns false.
Caught by architecture-policy.test.js's scan-based shell-string guard as a semantic merge conflict — the two sides never touched the same lines, so the merge produced a tree that typechecked and passed every package test suite while violating a policy one of the branches had just added. Verified red-then-green against the guard (packages/core/web.js — imports \execSync\ from child_process → 56/56 passing), with the full root suite green at 97 files.
Fix a POSIX process-group leak where surviving grandchildren were never force-killed.
terminateProcessGroup sent SIGTERM to the child's process group, then decided whether to escalate to SIGKILL by checking the direct child:
await Promise.race([waitForChildExit(child), delay(forceKillTimeoutMs)]);
if (!isChildRunning(child)) return; // gates on the child
try { killGroup(-child.pid, "SIGKILL"); } // escalation for the group
The leader commonly installs a SIGTERM handler and exits promptly while a grandchild ignores the signal — routine for long-running CLIs, including the claude/codex processes ndx spawns. The child's exit resolved the wait, the early return fired, and SIGKILL never reached the group, stranding every surviving member. That is the precise leak process groups exist to prevent.
Escalation now depends on the group: groupHasMembers() probes with signal 0 (a kernel existence check that delivers nothing) and waitForGroupExit() polls it on a bounded deadline instead of awaiting the child's exit event, so all members get the grace period and SIGKILL lands whenever anyone is left.
This is POSIX signal semantics, so it affected macOS as well as Linux.
PID-reuse safety is documented at the probe: a pgid stays allocated while its group has members, and a pgid is its leader's PID, so that PID cannot be recycled while anyone remains in the group — probing immediately before signalling cannot target an unrelated process. If the group drains in between, the signal fails ESRCH and is swallowed.
The Windows taskkill /T /F path is unchanged.
Terminate the whole process tree on Windows, behind one cross-OS contract.
child-lifecycle.js previously exported PLATFORM_SUPPORTS_PROCESS_GROUPS and picked its termination strategy from it, so Windows silently got direct child kill only — any grandchild spawned by a tracked child was orphaned. Since ndx spawns CLIs that themselves spawn processes (claude/codex), that leak was real rather than theoretical.
A single terminateTree(child, options) now owns the decision: POSIX signals the process group (process.kill(-pgid), SIGTERM then SIGKILL); Windows runs taskkill /PID <pid> /T /F through win-spawn.js. Both fall back to killing the direct child if the tree-wide attempt fails or leaves it running. PLATFORM_SUPPORTS_PROCESS_GROUPS is no longer exported, and the processGroups tracker option is renamed treeKill — it named a POSIX mechanism that does not exist on Windows, where tree-killing nonetheless works.
cli.js no longer branches on process.platform for termination: the detached: true decision moved into an exported treeKillSpawnOptions(), so the platform difference lives in the termination layer that owns it.
Also removes the construction-time stderr notice entirely. Gating it behind NDX_DEBUG_LIFECYCLE (previous release) stopped it appearing on every command, but its text — "falling back to direct child kill" — is now simply false on Windows. Strategy reporting moved to terminateTree, where it names the strategy at the moment one actually runs.
Documented Windows limitations, rather than papered over:
- No graceful phase.
taskkill /T without /F posts WM_CLOSE, which only a process pumping a window-message loop acts on — Node children do not — and process.kill(pid, "SIGTERM") is TerminateProcess anyway. A graceful pass would burn the grace period for nothing, so Windows goes straight to /F. - Job Objects not used. They are the architecturally correct primitive (kill-on-job-close is exactly analogous to a process group) but need a native addon, which would put a compiled dependency in a pure-JS orchestration package.
- Shutdown-time dependency. taskkill is spawned during cleanup; if the
ndx process is itself force-killed, no handler runs and the tree survives unless the host contained it.
The platform, spawnCliImpl, and killGroup seams are injectable so both OS strategies are testable on any host — CI runs the suite on Linux only, so without them the Windows branch would ship unexercised.
Actually restrict API-key file permissions on Windows, instead of only claiming to.
config.js called chmod(path, 0o600) after writing .n-dx.json whenever it held a provider API key, and ndx config --help stated "File permissions set to 0600 (owner-only) for security" unconditionally. On Windows both were false. Measured on Windows 11:
after chmod(path, 0o600):
mode reads back as 0666 (not 0600)
icacls: SYSTEM:(I)(F) BUILTIN\Administrators:(I)(F) <user>:(I)(F)
Every entry is (I) — inherited. fs.chmod cannot express a POSIX mode on Windows; it maps only the read-only attribute and never touches the DACL. So the API key stayed readable by SYSTEM and every administrator while the help text promised owner-only, and the two tests that would have caught it were it.skipIf(win32).
A new file-permissions.js module now attempts and then verifies the restriction, reporting what it actually achieved:
- POSIX —
chmod to 0600, then confirm via stat that the mode landed (FAT/exFAT mounts and some network shares silently drop mode changes). - Windows —
icacls <path> /inheritance:r /grant:r <DOMAIN\user>:F through win-spawn.js, then read the DACL back and require no inherited (I) entries and no principal other than the current user. The exit code is not trusted: icacls reports "Successfully processed 1 files" in cases where the resulting ACL is not what was requested.
When verification fails, the user is warned at the point of writing — naming the file, the cause, and the safer alternative (ANTHROPIC_API_KEY, or Credential Manager on Windows). A false assurance about an API key is worse than a stated limitation. The help text now describes what the running platform actually does.
Verified end-to-end: a real ndx config claude.api_key on Windows now produces a file whose ACL is exactly <user>:(F).
Also resolves the related cli_path executable check. access(value, X_OK) succeeds for a plain JSON file on Windows — Node documents X_OK as having no effect there, so it degrades to F_OK and the check could never reject anything, while advising "Run: chmod +x". It is now explicitly skipped on Windows via executableBitIsMeaningful() with the reasoning recorded. Requiring a PATHEXT extension instead was rejected: it would refuse the extensionless POSIX scripts that pnpm/npm global installs place beside their .CMD shims, and a validation that rejects valid input is worse than none — spawn-time diagnostics already cover the rest.
All four Windows skips in tests/e2e/cli-config.test.js are gone (143 passing, 0 skipped): the permission assertions now check mode on POSIX and the DACL on Windows, and the executable-bit case asserts the documented Windows behaviour rather than being silently skipped.
Make a command timeout actually stop the command, descendants included.
exec delegated its timeout to Node's execFile, which signals only the process it spawned. Anything that process had itself started survived — kept running, kept holding file handles, kept writing to the workspace — while the caller had already been told the command stopped. Measured on Windows with a 400ms timeout: the reported result was Command timed out after 400ms, yet the surviving process went on to write four more times, and a temp directory it held could not be removed for 52 seconds.
That report is what an autonomous agent acts on. It reads files and runs the next command believing the previous one finished, so a build or codemod still writing underneath it can corrupt the state being read.
exec now owns the timeout timer and terminates the whole process tree when it fires: a process-group signal on POSIX (SIGTERM, escalating to SIGKILL, waiting on the *group* rather than the direct child), and taskkill /T /F on Windows. exitCode: null still signals a timeout, and an externally-killed child still reports the same way it always did. Opt out with treeKill: false when a child must stay in the caller's own process group.
Not a Windows-only fix, though Windows is where it was caught: the orphan survived on POSIX too, just invisibly, because unlinking open files is permitted there so no EBUSY drew attention to it. On Windows, libuv's global job object masks the problem for node-spawned node, but not for the cases that matter — sh, cmd, make, and pnpm/npm shims all leave their children behind.
The primitive is exported as terminateProcessTree / treeKillSpawnOptions. It is a deliberate twin of terminateTree in packages/core/child-lifecycle.js, since the orchestration tier must not import from packages; a parity test fails if the two diverge.
Complete the .gitattributes LF-pin coverage (follow-up to #283/#285). Three n-dx-written surfaces were writing LF but had no eol pin, so Windows checkouts (core.autocrlf=true) showed line-ending-only churn on every tool write:
.claude/skills/**/*.md — generated Claude skills (now committed per #284).codex/config.toml — generated Codex MCP config.sourcevision/**/*.txt — sourcevision text output (e.g. llms.txt)
All three are added to both GITATTRIBUTES_EOL_RULES (the list ndx init injects into a project's .gitattributes) and n-dx's own .gitattributes, keeping the two in sync per the stated invariant.
The root cause of the pins shipping incomplete was that these two sources drifted apart — one updated, the other not — and no test caught it. To close that class of bug for good:
- The rules are extracted into a single importable source of truth (
packages/core/gitattributes-pins.js), imported by cli.js. - A sync-guard test (
prd-line-endings.test.js) asserts the injector's pattern set equals n-dx's own .gitattributes eol=lf pattern set — any future divergence fails CI, not just the three patterns fixed today. cli-init.test.js also asserts the new patterns are injected.
Make the hench pre-run commit gate size-aware with configurable thresholds.
The gate now measures change magnitude (dirty file count plus lines changed vs HEAD via git diff --numstat, shared helper measureChangeMagnitude) instead of reacting only to a non-empty dirty list. Two new persisted settings under hench.git.* (.hench/config.json, editable via ndx config):
hench.git.checkpointThreshold (default: 200, 0 disables) — at/above this many changed lines, the interactive prompt warns about the change size and defaults to committing a checkpoint instead of proceeding. Below the threshold, behavior is unchanged.hench.git.requireCleanTree (default: false) — refuse to start against a dirty tree: the interactive prompt drops the "proceed" option and non-interactive runs (--yes, piped) abort.
Autonomous runs (--auto/--loop/--epic-by-epic) keep today's behavior — abort on any dirty tree unless --allow-dirty — but the refusal now reports the measured magnitude. --allow-dirty takes precedence over both config settings for a single run (flag > config > defaults). Documented in hench run --help and ndx config --help.
Clear the losing timer in every bounded termination wait, so a CLI exits when its work is done.
Promise.race([waitForChildExit(child), delay(forceKillTimeoutMs)]) reads as "wait, but not forever". It also leaks: when the child wins the race, the delay timer is still armed, and an armed timer holds the event loop open. Nothing was waiting on it — the process simply could not exit until it fired.
Measured against sh -c "sleep 30" with a 300 ms command timeout, before and after, no other change:
| | exec() resolves | process exits | dead time | |---|---|---|---| | before | 432 ms | 5436 ms | ~5000 ms | | after | 445 ms | 446 ms | ~1 ms |
The 5 s is DEFAULT_FORCE_KILL_TIMEOUT_MS. Any CLI that finished immediately after a command timeout sat idle for the full kill grace period before returning to the shell.
Nine sites across the two twins, all of them replaced with a raceWithTimeout helper that clears its own timer in a finally:
packages/llm-client/src/process-tree.ts — four waitForChildExit races, the taskkill completion race, and captureStdout's bare setTimeout(finish, timeoutMs). That last one is the worst of the set: it is reached on every POSIX non-freeze kill via posixDescendants → readProcessTable, where ps returns in milliseconds but the timer is armed for the whole grace period.packages/core/child-lifecycle.js — the childTarget wait adapter and both Windows tree-kill races. Same defect, and it had to be fixed twice because the orchestration tier cannot import @n-dx/llm-client (spawn-only rule).
The polling delay() calls are deliberately untouched. Those are awaited directly rather than raced, so their timer always fires and never outlives its await — replacing them would add a clearTimeout that can never run.
No behavioural change to the kill sequence itself: the same signals go out in the same order with the same bounds, and every existing termination test passes unmodified. What changes is only how long the process lingers afterwards.
Add ndx auth — on-demand credential verification for the active LLM vendor.
The command re-runs the same provider auth preflight used by ndx init / ndx config llm.vendor and exits 0 when credentials are valid (printing the active vendor, resolved model, and "credentials valid") or 1 on failure (printing the canonical, JSON-free auth-failure guidance). It works without an initialized project — the default vendor (claude) is checked when no config exists.
Every vendor's auth-failure remediation (and the flattened authFailureMessage used by runtime errors) now ends with the canonical verification step Verify credentials: ndx auth, exported from @n-dx/llm-client as VERIFY_CREDENTIALS_STEP, so users always know how to confirm a fix.
Stop ndx pair-programming orphaning processes when a reviewer CLI or test command times out.
All three timeout paths in pair-programming.js signalled only the direct child with a bare child.kill("SIGTERM"). That was four separate defects: no tree kill, so descendants survived; no process group to signal on POSIX; no escalation, so a child that ignores SIGTERM outlived its own timeout indefinitely; and resolve() on the line after kill(), so the caller saw timedOut: true while the tree was still running and still holding the workspace and any port it had bound.
runShellTestCommand was the worst of the three and broken deterministically rather than by race: it spawns with shell: true, so the child being signalled *is* the shell and never the test command beneath it. A timed-out npm test kept building with its output pipe already abandoned.
All three now terminate through child-lifecycle.js's terminateTree — process group on POSIX, taskkill /T on Windows, escalating to SIGKILL — and await it before resolving, so timedOut cannot be observed while the tree is alive. Each spawn passes treeKillSpawnOptions() so the POSIX group-signal path has a group to signal.
Detaching for that group had a catch worth naming: a detached child leaves this process's foreground group, so Ctrl-C would no longer have reached it — trading a timeout orphan for an interrupt orphan. cli.js now registers these children with the tracker whose SIGINT/SIGTERM/SIGHUP handlers already terminate tracked trees, via a new registerChild injection seam.
Covered by real-process tests (tests/e2e/pair-programming-timeout-tree-kill.test.js) that assert the grandchild is dead *without polling* — the promise settling early is precisely the defect — and that a SIGTERM-ignoring command still dies.
Add a BETA option to make the POSIX timeout kill definitive: freeze the process tree, prove it is frozen, then kill it. Off by default.
It ships behind a flag because the sweep it replaces has far more mileage: the freeze path's unit coverage injects its seams, and its behaviour against real POSIX processes is not yet proven in CI. Enable per-project with ndx config experimental.posixFreezeTreeKill true, or for a single run with NDX_POSIX_FREEZE_KILL=1. ndx config --help documents it as BETA and NOT RIGOROUSLY TESTED so nobody turns it on unaware.
The previous approach enumerated descendants and signalled them, which is inference. Its hole is reparenting: a descendant whose parent dies is adopted by init, so the pid→ppid link the enumeration depends on dissolves at exactly the moment the killing starts. The old code collected descendants *before* signalling to work around that; freezing first removes it, because reparenting only happens when a parent exits and nothing exits until enumeration is finished.
On timeout, exec now SIGSTOPs the tree, closes over its descendants to a fixpoint — a pass that discovers nothing, rather than a fixed number of rounds — verifies every member reads as stopped in the process table, and only then SIGKILLs, leaves before parents. It terminates because SIGSTOP cannot be caught, blocked, or ignored and a stopped process cannot fork, so new arrivals can only come from processes that were still running at the previous read, and that set shrinks monotonically. When the child *is* a process-group leader the fast path skips enumeration entirely: group membership is inherited rather than listed, so SIGSTOP then SIGKILL on the group are atomic over the whole tree.
SIGKILL, never SIGTERM: a stopped process does not act on SIGTERM — the signal queues until SIGCONT — so a "graceful" attempt against a frozen tree is a silent no-op. Freezing and graceful termination are therefore mutually exclusive, and this policy is opt-in via freeze on terminateProcessTree, used only for timeouts and runaways. Graceful shutdown keeps its SIGTERM grace period unchanged, and a test pins that the two policies stay distinct.
Windows is unchanged. It has no pure-JS pause — libuv maps the signals it supports onto TerminateProcess, and the real equivalents all need native code — so taskkill /T remains a tree walk. Its failure mode is the mirror image of POSIX's and is now documented where taskkill is invoked: Windows never reparents, so a link survives its parent's death and can dangle onto a recycled pid.
Known limit, recorded in the code: a deliberate double-fork daemon escapes parentage by design and no enumeration finds it. That is a policy question about whether agent-run commands may daemonize, not a detection one.
ndx start stop now terminates the background server's children, not just the recorded PID.
The stop path signalled only the PID from .n-dx-web.pid. On Windows that is doubly insufficient: SIGTERM is TerminateProcess, so the server never ran its cleanup handlers, and nothing walked the tree — so any rex analyze or hench run the server had spawned survived, potentially holding the port or the workspace. The server is also started detached: true, which places it outside libuv's job object, so nothing else would have reaped those children either.
Stop now routes through a shared terminateTreeByPid in child-lifecycle.js: taskkill /T on Windows, a process-group signal on POSIX. Grace periods are unchanged — ndx start stop keeps its 2s default and the N_DX_STOP_GRACE_MS override, deliberately shorter than the 5s used for shutdown, so consolidating the mechanism does not change stop latency.
There were three copies of the SIGTERM → grace → SIGKILL escalation (child-lifecycle.js, web.js, cli.js); there is now one, written against injected signal/liveness/wait capabilities so a live ChildProcess, a bare PID, and a POSIX process group all share the same sequence instead of each drifting. A PID is weaker evidence than a handle — kill(pid, 0) cannot distinguish a live process from a zombie or a recycled PID — so pid-file staleness handling stays with the callers that own the file rather than being assumed away.
The "Update available" notice now suggests an upgrade command that actually works.
Previously it always printed npm i -g @n-dx/core, regardless of how the copy was installed, which failed two ways:
- Wrong package manager. A pnpm-global user following
npm i -g ends up with a second global install under the npm prefix. Both ship an ndx shim, and whichever resolves first on PATH wins — so ndx --version can keep reporting the old version even though the upgrade "succeeded". update-check.js now infers the installing manager from its own path on disk (pnpm's .pnpm virtual store, yarn's data directory, else npm) and prints the matching pnpm add -g / yarn global add / npm i -g form. - Missing
@latest. pnpm records a caret range in its global manifest, and for 0.x versions ^0.3.1 means >=0.3.1 <0.4.0. A bare pnpm add -g @n-dx/core or pnpm update -g re-resolves inside that range and can never cross a minor boundary, leaving users stranded on an old line indefinitely. The suggested command now always pins @n-dx/core@latest.
Adds a docs/guide/troubleshooting.md entry for the ERR_MODULE_NOT_FOUND … assistant-assets/index.js crash that 0.3.x installs hit, since that failure occurs while Node links the module graph — before any ndx code can run and surface an update notice. Documents the upgrade-pinning rule in the README install section.
Record every vendor CLI invocation to an append-only claude_commands.log.
Each claude / codex spawn now appends one JSON line capturing the timestamp, vendor, binary, argv, cwd, platform, the spawning helper, and — on Windows — the fully-built verbatim command line. The log accumulates across sessions, giving a project a consistent history of what was actually run.
Wired at the spawn chokepoints rather than per call site, so a single edit per tier covers everything downstream:
packages/llm-client/src/exec.ts spawnCli — covers cli-provider.ts (claude), codex-cli-provider.ts (codex), and hench's cli-loop.tspackages/core/win-spawn.js spawnCli + execFileSyncCli — covers pair-programming.js reviewer runs and config.js preflight/--version probespackages/core/claude-integration.js — the ndx init MCP registration claude mcp add/remove calls, which use raw execSync and bypass the helpers
Behaviour:
- On by default; opt out with
NDX_CLI_LOG=0 (also false / no). - Path:
<cwd>/claude_commands.log, overridable via NDX_CLI_LOG_PATH. Gitignored, along with its rotated .1 generation. - Secrets redacted before the write — values following
--api-key/--token/--password (and the --flag=value form), plus standalone sk-ant-*, sk-*, gh[pousr]_*, and AIza* tokens become <redacted>. The log is a plain file that outlives the process, so redaction happens at write time rather than read time. - One atomic single-line append per invocation, so concurrent
ndx processes interleave cleanly by line instead of tearing. - Never throws — an unwritable cwd, permission error, or full disk cannot turn a logging failure into a spawn failure.
- Rotates at 1 MB to
claude_commands.log.1, mirroring .rex/execution-log.jsonl.
The implementation is duplicated as packages/llm-client/src/cli-log.ts and packages/core/cli-log.js because the orchestration tier must not import @n-dx/llm-client (spawn-only rule) — the same constraint that already forces the quoteWindowsToken twin. tests/unit/cli-log.test.js runs the full behavioural suite against both copies and asserts they emit byte-identical lines for a shared record table; both twins are imported from source so the parity check cannot fail on a stale dist/.
Also adds cli-log.js to @n-dx/core's files allowlist — without it the published package would import a file it does not ship.
Harden CLI spawning on Windows so launching .cmd shims (claude, codex, rex) no longer fails. Node can't spawn a .cmd directly (post-CVE-2024-27980), and the previous shell: process.platform === "win32" workaround triggered the [DEP0190] deprecation and broke on paths containing spaces.
- New
spawnCli helper (@n-dx/llm-client) routes CLI binaries through cmd.exe /d /s /c with windowsVerbatimArguments and never uses shell:true. Argument quoting follows the Microsoft ArgvQuote / cross-spawn rules (unconditional quoting, backslash-run doubling before quotes, embedded-quote doubling) so paths with spaces and tokens with cmd.exe metacharacters (& | < > ^ ( )) are handled. The orchestration tier (@n-dx/core) carries an equivalent win-spawn.js twin (it cannot import @n-dx/llm-client), kept in lockstep by a cross-package parity test. - All CLI-binary spawn sites are routed through the helper: the claude and codex providers, the hench agent loop and its adapters, the
ndx config CLI-path validator, ndx pair-programming's reviewer, and sourcevision's rex invocations. - Prompts are delivered via stdin for the codex hench adapter and the pair-programming reviewer (previously passed as an argv token), preventing multi-line prompt truncation and command injection through
cmd.exe. diagnoseCliInvocation produces an actionable message when a CLI binary is missing or not invokable — distinguishing a not-found binary, a configured absolute path that doesn't exist, and a binary present on PATH but failing to run — and works from the close/non-zero-exit path on Windows (where a missing .cmd never raises ENOENT). Detection is anchored to the spawned binary so a legitimate run's own error output isn't misclassified.- A regression guard test fails CI if any CLI spawn site reintroduces the
shell:true + args (DEP0190) pattern.
No behavior change on macOS or Linux.