1313import io .github .cowwoc .cat .claude .tool .ClaudeTool ;
1414import io .github .cowwoc .cat .claude .hook .ClaudePluginScope ;
1515import io .github .cowwoc .cat .claude .tool .MainClaudeTool ;
16+ import io .github .cowwoc .cat .claude .hook .util .FileUtils ;
1617
1718import io .github .cowwoc .pouch10 .core .WrappedCheckedException ;
1819
2829import java .nio .file .Files ;
2930import java .nio .file .Path ;
3031import java .nio .file .SimpleFileVisitor ;
31- import java .nio .file .StandardCopyOption ;
3232import java .util .Arrays ;
3333import java .util .Objects ;
3434import org .slf4j .Logger ;
4747import tools .jackson .databind .node .ObjectNode ;
4848
4949/**
50- * Launches Claude CLI processes with optional config directory isolation.
50+ * Launches Claude Code CLI processes with optional config directory isolation.
5151 * <p>
52- * Handles building stream-json input, spawning the {@code claude} CLI process, parsing
53- * stream-json output, and optionally creating an isolated config directory with updated
54- * plugin cache.
52+ * Handles building stream-json input, spawning the Node.js process running cli.js,
53+ * parsing stream-json output, and optionally creating an isolated config directory
54+ * with updated plugin cache.
5555 */
5656public final class ClaudeRunner implements AutoCloseable
5757{
5858 /**
59- * Default timeout for the claude CLI process.
59+ * Default timeout for the Node.js process running cli.js .
6060 */
6161 private static final Duration DEFAULT_TIMEOUT = Duration .ofMinutes (3 );
6262 private final ClaudePluginScope scope ;
@@ -105,7 +105,7 @@ public void createIsolatedConfig(Path sourceConfigDir, Path pluginSourceDir,
105105 isolatedConfigDir = Files .createTempDirectory ("claude-isolated-config-" );
106106
107107 // Copy entire config directory
108- copyDirectoryRecursively (sourceConfigDir , isolatedConfigDir );
108+ FileUtils . copyDirectoryRecursively (sourceConfigDir , isolatedConfigDir );
109109
110110 // Update plugin cache with current plugin source files
111111 isolatedPluginRoot = isolatedConfigDir .resolve ("plugins" ).resolve ("cache" ).
@@ -115,14 +115,14 @@ public void createIsolatedConfig(Path sourceConfigDir, Path pluginSourceDir,
115115 deleteDirectoryContents (isolatedPluginRoot );
116116 else
117117 Files .createDirectories (isolatedPluginRoot );
118- copyDirectoryRecursively (pluginSourceDir , isolatedPluginRoot );
118+ FileUtils . copyDirectoryRecursively (pluginSourceDir , isolatedPluginRoot );
119119
120120 // Update jlink binaries in the cache
121121 Path cacheBinDir = isolatedPluginRoot .resolve ("client" ).resolve ("bin" );
122122 if (Files .isDirectory (jlinkBinDir ))
123123 {
124124 Files .createDirectories (cacheBinDir );
125- copyDirectoryRecursively (jlinkBinDir , cacheBinDir );
125+ FileUtils . copyDirectoryRecursively (jlinkBinDir , cacheBinDir );
126126 }
127127 }
128128
@@ -138,8 +138,62 @@ public String getIsolatedConfigDir()
138138 return isolatedConfigDir .toString ();
139139 }
140140
141+ /**
142+ * Checks whether the cache-fix module is detected in the given {@code NODE_OPTIONS} string.
143+ * <p>
144+ * Returns {@code true} when {@code nodeOptions} contains {@code "claude-code-cache-fix"},
145+ * which indicates that the
146+ * <a href="https://github.com/cnighswonger/claude-code-cache-fix">claude-code-cache-fix</a> module
147+ * is loaded. Matches both the short form ({@code --import claude-code-cache-fix}) and the full
148+ * path form ({@code --import /path/to/claude-code-cache-fix/preload.mjs}).
149+ *
150+ * @param nodeOptions the {@code NODE_OPTIONS} environment variable value, or {@code null} if not
151+ * set
152+ * @return {@code true} if the cache-fix module is detected, {@code false} otherwise
153+ */
154+ public static boolean isCacheFixDetected (String nodeOptions )
155+ {
156+ return nodeOptions != null && nodeOptions .contains ("claude-code-cache-fix" );
157+ }
158+
159+ /**
160+ * Resolves the Node.js executable path for launching nested Claude instances.
161+ * <p>
162+ * Checks whether the {@code NODE_OPTIONS} environment variable contains the cache-fix module.
163+ * If not detected, emits a warning to {@code stderr} suggesting how to enable it.
164+ * <p>
165+ * Returns the path to the Node.js executable from {@code CLAUDE_CODE_EXECPATH}, which is used
166+ * to run {@code cli.js} directly.
167+ *
168+ * @param stderr the stream to write the warning to when the cache-fix module is not detected
169+ * @return the path to the Node.js executable
170+ * @throws NullPointerException if {@code stderr} is null
171+ * @throws AssertionError if {@code CLAUDE_CODE_EXECPATH} is not set
172+ */
173+ public static String resolveClaudeBinary (PrintStream stderr )
174+ {
175+ requireThat (stderr , "stderr" ).isNotNull ();
176+ String nodeOptions = System .getenv ("NODE_OPTIONS" );
177+ if (!isCacheFixDetected (nodeOptions ))
178+ {
179+ stderr .println ("WARNING: claude-code-cache-fix not detected in NODE_OPTIONS. " +
180+ "Falling back to unpatched claude. " +
181+ "To enable cache fix, either: " +
182+ "(1) export NODE_OPTIONS=\" ${NODE_OPTIONS} --import claude-code-cache-fix\" , or " +
183+ "(2) use the wrapper script from https://github.com/cnighswonger/claude-code-cache-fix" +
184+ "#option-a-wrapper-script-recommended" );
185+ }
186+ String nodeExec = System .getenv ("CLAUDE_CODE_EXECPATH" );
187+ if (nodeExec == null || nodeExec .isEmpty ())
188+ throw new AssertionError ("CLAUDE_CODE_EXECPATH environment variable is not set" );
189+ return nodeExec ;
190+ }
191+
141192 /**
142193 * Builds the claude CLI command with appropriate flags.
194+ * <p>
195+ * Constructs a command that runs {@code node cli.js} directly with the cache-fix module loaded
196+ * via {@code NODE_OPTIONS}. Emits a warning to stderr when the cache-fix module is not detected.
143197 *
144198 * @param model the model name (haiku, sonnet, or opus)
145199 * @param appendSystemPrompt the text to append to the system prompt via
@@ -150,6 +204,7 @@ public String getIsolatedConfigDir()
150204 * @throws NullPointerException if {@code model}, {@code appendSystemPrompt}, or {@code agent}
151205 * are null
152206 * @throws IllegalArgumentException if {@code model} is not in the allowed set
207+ * @throws AssertionError if {@code NPM_CONFIG_PREFIX} is not set
153208 */
154209 public List <String > buildCommand (String model , String appendSystemPrompt , String agent )
155210 {
@@ -162,7 +217,11 @@ public List<String> buildCommand(String model, String appendSystemPrompt, String
162217 requireThat (appendSystemPrompt , "appendSystemPrompt" ).isNotNull ();
163218 requireThat (agent , "agent" ).isNotNull ();
164219 List <String > command = new ArrayList <>();
165- command .add ("claude" );
220+ command .add (resolveClaudeBinary (System .err ));
221+ String npmPrefix = System .getenv ("NPM_CONFIG_PREFIX" );
222+ if (npmPrefix == null || npmPrefix .isEmpty ())
223+ throw new AssertionError ("NPM_CONFIG_PREFIX environment variable is not set" );
224+ command .add (npmPrefix + "/lib/node_modules/@anthropic-ai/claude-code/cli.js" );
166225 command .add ("-p" );
167226 command .add ("--model" );
168227 command .add (model );
@@ -240,11 +299,14 @@ public String buildInput(List<PrimingMessage> primingMessages, List<String> prom
240299
241300 /**
242301 * Builds a {@link ProcessBuilder} configured with the correct environment for launching the
243- * claude CLI.
302+ * Claude Code CLI.
244303 * <p>
245304 * Removes the {@code CLAUDECODE} env var so the spawned process does not inherit the
246305 * hook-suppression flag. Sets {@code CLAUDE_CONFIG_DIR} and {@code CLAUDE_PLUGIN_ROOT} when
247306 * isolation is active so that all consumers read from the isolated plugin copy.
307+ * <p>
308+ * When {@code claude-code-cache-fix} is detected, sets cache-fix-specific environment variables:
309+ * {@code CACHE_FIX_STRIP_GIT_STATUS=1} and {@code CACHE_FIX_TTL_SUBAGENT=5m}.
248310 *
249311 * @param command the command to execute
250312 * @param cwd the working directory
@@ -256,15 +318,27 @@ public ProcessBuilder buildProcessBuilder(List<String> command, Path cwd)
256318 ProcessBuilder pb = new ProcessBuilder (command );
257319 Map <String , String > env = pb .environment ();
258320 env .remove ("CLAUDECODE" );
259- // Override CLAUDE_PROJECT_DIR so the claude process and its subagents resolve relative file
260- // paths against the runner worktree, not the main workspace.
321+ // Remove env vars that can be used to inject malicious code into the spawned process.
322+ env .remove ("LD_PRELOAD" );
323+ env .remove ("LD_LIBRARY_PATH" );
324+ env .remove ("JAVA_TOOL_OPTIONS" );
325+ // Override CLAUDE_PROJECT_DIR so the Claude Code process and its subagents resolve relative
326+ // file paths against the runner worktree, not the main workspace.
261327 env .put ("CLAUDE_PROJECT_DIR" , cwd .toAbsolutePath ().toString ());
262328
263329 // Inherit ANTHROPIC_BASE_URL if set in parent environment
264- String anthropicBaseUrl = System . getenv ( "ANTHROPIC_BASE_URL" );
265- if (anthropicBaseUrl != null )
330+ String anthropicBaseUrl = scope . getAnthropicBaseUrl ( );
331+ if (! anthropicBaseUrl . isEmpty () )
266332 env .put ("ANTHROPIC_BASE_URL" , anthropicBaseUrl );
267333
334+ // When using claude-code-cache-fix, set cache-fix-specific environment variables
335+ String nodeOptions = System .getenv ("NODE_OPTIONS" );
336+ if (isCacheFixDetected (nodeOptions ))
337+ {
338+ env .put ("CACHE_FIX_STRIP_GIT_STATUS" , "1" );
339+ env .put ("CACHE_FIX_TTL_SUBAGENT" , "5m" );
340+ }
341+
268342 if (isolatedConfigDir != null )
269343 {
270344 env .put ("CLAUDE_CONFIG_DIR" , isolatedConfigDir .toString ());
@@ -276,8 +350,8 @@ public ProcessBuilder buildProcessBuilder(List<String> command, Path cwd)
276350 }
277351
278352 /**
279- * Executes the claude CLI process with the given input, streaming output line-by-line
280- * to avoid buffering the full response in memory.
353+ * Executes the Node.js process running cli.js with the given input, streaming output
354+ * line-by-line to avoid buffering the full response in memory.
281355 * <p>
282356 * If an isolated config directory has been created via {@link #createIsolatedConfig},
283357 * the process will use it via the {@code CLAUDE_CONFIG_DIR} environment variable.
@@ -334,7 +408,7 @@ public ProcessResult executeProcess(List<String> command, String input, Path cwd
334408 /**
335409 * Parses stream-json output to extract assistant text blocks and tool uses.
336410 *
337- * @param output the raw output from claude CLI
411+ * @param output the raw output from Claude Code CLI
338412 * @return the parsed output
339413 * @throws NullPointerException if {@code output} is null
340414 */
@@ -473,7 +547,7 @@ public void close() throws IOException
473547 {
474548 if (isolatedConfigDir != null )
475549 {
476- deleteDirectoryRecursively (isolatedConfigDir );
550+ FileUtils . deleteDirectoryRecursively (isolatedConfigDir );
477551 isolatedConfigDir = null ;
478552 }
479553 }
@@ -548,37 +622,6 @@ private String makeToolResultMessage(String toolUseId, String toolOutput)
548622 return buildMessage ("user" , "user" , content );
549623 }
550624
551- /**
552- * Copies a directory tree recursively.
553- *
554- * @param source the source directory
555- * @param target the target directory
556- * @throws IOException if the copy fails
557- */
558- private static void copyDirectoryRecursively (Path source , Path target ) throws IOException
559- {
560- Files .walkFileTree (source , new SimpleFileVisitor <>()
561- {
562- @ Override
563- public FileVisitResult preVisitDirectory (Path dir , BasicFileAttributes attrs )
564- throws IOException
565- {
566- Path targetDir = target .resolve (source .relativize (dir ));
567- Files .createDirectories (targetDir );
568- return FileVisitResult .CONTINUE ;
569- }
570-
571- @ Override
572- public FileVisitResult visitFile (Path file , BasicFileAttributes attrs )
573- throws IOException
574- {
575- Files .copy (file , target .resolve (source .relativize (file )),
576- StandardCopyOption .REPLACE_EXISTING );
577- return FileVisitResult .CONTINUE ;
578- }
579- });
580- }
581-
582625 /**
583626 * Deletes all contents of a directory without deleting the directory itself.
584627 *
@@ -609,35 +652,7 @@ public FileVisitResult postVisitDirectory(Path directory, IOException exception)
609652 }
610653
611654 /**
612- * Deletes a directory and all its contents recursively.
613- *
614- * @param dir the directory to delete
615- * @throws IOException if the deletion fails
616- */
617- private static void deleteDirectoryRecursively (Path dir ) throws IOException
618- {
619- Files .walkFileTree (dir , new SimpleFileVisitor <>()
620- {
621- @ Override
622- public FileVisitResult visitFile (Path file , BasicFileAttributes attrs ) throws IOException
623- {
624- Files .delete (file );
625- return FileVisitResult .CONTINUE ;
626- }
627-
628- @ Override
629- public FileVisitResult postVisitDirectory (Path directory , IOException exception ) throws IOException
630- {
631- if (exception != null )
632- throw exception ;
633- Files .delete (directory );
634- return FileVisitResult .CONTINUE ;
635- }
636- });
637- }
638-
639- /**
640- * Result of executing the claude CLI process.
655+ * Result of executing the Node.js process running cli.js.
641656 *
642657 * @param parsed the parsed output
643658 * @param elapsed the elapsed time in seconds
@@ -881,7 +896,7 @@ public static int run(ClaudeTool scope, String[] args, PrintStream out) throws I
881896 pluginVersion );
882897 }
883898
884- // When --agent is specified, pass --agent <type> to the nested claude process.
899+ // When --agent is specified, pass --agent <type> to the nested Claude Code process.
885900 // The nested process has CLAUDE_CONFIG_DIR pointing to the isolated config directory
886901 // (when --plugin-source is provided), so --agent resolves from the candidate plugin's
887902 // agents/ directory, honoring all frontmatter settings (tools:, model:, etc.).
0 commit comments