Lesson 57 of 73
The Shell Tool
A coding agent becomes useful when it can run commands. With a shell it can compile code, run the tests, read the error messages, change the code, and try again. This lesson builds that ability as a tool, which is a function that the model can ask the agent to call. The shell tool takes one argument, a command line such as javac Hello.java, and returns what the command printed together with its exit code.
Starting a command from Java looks like one line of code. In practice five problems show up quickly. A command can print so much that it freezes while Java waits. A command can run forever. A command can start other programs that keep running after the agent stops it. A command can read secrets such as API keys from the environment and print them into the model's context. A command can ask a question that nobody can answer, such as Overwrite? [y/N], and wait. Each example in this lesson solves one of these problems, and Example 14 combines the solutions into one class.
After this lesson you can run commands from Java without hangs, leaked processes, or leaked secrets. You can also turn what a command printed into text that a model can act on.
The picture below shows where the shell tool sits in the agent loop. The model never touches the operating system. It writes a tool call, and the tool does the work.
model --tool call: {"command": "javac Hello.java"}--> agent loop
|
v
ShellTool
| starts
v
bash -c "javac Hello.java"
| prints, exits
v
model <--text: "Exit code: 1 ... error ..."------- ShellToolFour words appear throughout the lesson, so here are their meanings. A process is a running program. Every process has three standard streams: standard input (stdin, where it reads), standard output (stdout, where it writes normal results), and standard error (stderr, where it writes error messages). An environment variable is a named piece of text, such as PATH or HOME, that a process receives from the process that started it. A shell is a program that reads a command line, understands its syntax (pipes, quotes, &&), and starts the programs it names. bash is the most common shell on Linux and macOS.
| Topic | Java or operating system feature | Example |
|---|---|---|
| Starting a shell | ProcessBuilder, bash -c, bash -lc |
1 |
| Output and exit code | getInputStream, getErrorStream, waitFor |
2 |
| Full pipes | reader threads, redirectErrorStream |
3 |
| Timeouts | waitFor(timeout), destroy, destroyForcibly |
4 |
| Process trees | ProcessHandle.descendants |
5 |
| Process groups | setsid, kill -- -pgid |
6 |
| Truncation | head and tail with a marker | 7 |
| Working directory | an EXIT trap that records $PWD |
8 |
| Environment | removing secrets, non-interactive variables | 9 |
| Input | /dev/null as standard input, /dev/tty |
10 |
| Background jobs | log files, status, stop | 11 |
| Streaming | a line callback, ANSI and \r cleanup |
12 |
| The result text | exit code, output, isError |
13 |
| Project 23 | ShellTool in a scripted agent session |
14 |
The tool plugs into the agent from The Agent Loop and a Tool Framework (Project 21). That lesson defines a Tool interface with four parts: a name, a description, a JSON Schema that describes the arguments, and a run method. run takes the parsed arguments as a Jackson JsonNode and returns text. When run throws an exception, the agent turns it into an error result, which is a ToolMessage with isError set. Example 14 restates a reduced version of these types so that the program stays complete.
The file tools from File Tools (Project 22) keep every path inside a workspace, the directory the agent is allowed to work in. The shell tool uses the same idea for its working directory.
Every example here starts real processes (bash, sleep, javac) on Linux or macOS, uses short timeouts, and stops every process it starts. Examples 6, 10, 11, 13, and 14 use the setsid command from util-linux. Linux distributions ship it and macOS does not, and the text says what to use instead on macOS.
Example 1: ProcessBuilder with bash -c and bash -lc
A model writes commands the way a person types them into a terminal, with pipes, &&, quotes, and wildcards (globs such as *.java). Java's ProcessBuilder understands none of that. It starts one program and gives it a list of arguments, with no interpretation of the text.
The fix is to start bash itself and pass the whole command line as a single argument after -c, which tells bash to run that string as a command. Bash then does the parsing. The program below runs the same command under bash -c and under bash -lc, sets a working directory and an environment variable, runs a pipe, and runs a command that does not exist. Read each output line as an answer to the question "how was the child started?"
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public class FirstShellCall {
static String run(List<String> shell, String command, Path cwd) throws Exception {
List<String> argv = new ArrayList<>(shell);
argv.add(command); // the whole command line is one argument
ProcessBuilder pb = new ProcessBuilder(argv);
pb.directory(cwd.toFile()); // working directory of the child
pb.environment().put("GREETING", "hello"); // added to a copy of the JVM's environment
pb.redirectErrorStream(true);
Process p = pb.start();
String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
p.waitFor();
return out.strip();
}
public static void main(String[] args) throws Exception {
Path ws = Files.createTempDirectory("ws").resolve("project");
Files.createDirectories(ws);
String cmd = "echo \"cwd=$(basename \"$PWD\") GREETING=$GREETING login=$(shopt -q login_shell && echo yes || echo no) args=$#\"";
System.out.println("bash -c : " + run(List.of("bash", "-c"), cmd, ws));
System.out.println("bash -lc: " + run(List.of("bash", "-lc"), cmd, ws));
System.out.println("pipes : " + run(List.of("bash", "-c"), "printf 'b\\na\\nc\\n' | sort | head -n 2 | tr '\\n' ' '", ws));
System.out.println("missing : " + run(List.of("bash", "-c"), "no-such-command-xyz", ws));
Files.delete(ws);
Files.delete(ws.getParent());
}
}bash -c : cwd=project GREETING=hello login=no args=0
bash -lc: cwd=project GREETING=hello login=yes args=0
pipes : a b
missing : bash: line 1: no-such-command-xyz: command not foundEach output line answers one question about how the child process was started.
cwd=projectshows thatpb.directory(...)set the child's working directory, the directory that relative paths are resolved against. The command prints only the last path component withbasename, because the temporary directory's full name is random and would change the output on every run. Withoutdirectory(...), the child inherits the JVM's working directory, which for an agent is wherever the user launched it.GREETING=helloshows thatpb.environment()is a mutable copy of the JVM's environment. Changes to the copy affect only this child.System.getenv()in the JVM is unchanged.login=noandlogin=yesshow the difference between the two flags.-cruns the string in a non-interactive, non-login shell.-l(so-lcmeans-lplus-c) makes bash a login shell, the kind that starts when a person logs in. A login shell reads/etc/profileand then the first of~/.bash_profile,~/.bash_login, or~/.profilebefore it runs the command.args=0shows that the command string is not an argument list. Withbash -c 'cmd' a b, bash sets$0toaand$1tob. The shell tool passes nothing after the command, so$#(the number of arguments) is 0.pipes : a bshows that bash, not Java, runs the pipelineprintf | sort | head | tr. If the same text went toProcessBuilderdirectly, it would try to start a program namedprintfwith'b\na\nc\n'and|as literal arguments.missing :shows what the model sees after a typo. Bash printscommand not foundon standard error, and the line number refers to a line of the command string.
The choice between -c and -lc has consequences on both sides.
A login shell picks up the user's PATH additions from their profile. PATH is the environment variable that lists the directories bash searches when it looks for a program. Version managers such as SDKMAN!, nvm, or pyenv often add themselves to it in a profile file, so mvn, node, or a specific java resolve in a login shell the way they do in the user's own terminal. The cost is that every call runs the profile scripts. These can take from a few milliseconds to hundreds of milliseconds, can print text that ends up in the tool output, and can export variables again that the agent removed (Example 9).
A non-login shell starts fast and runs with exactly the environment the agent builds. The cost is that a tool installed only through a profile script is not on its PATH.
The rest of this lesson uses bash -c and a PATH taken from the JVM, and Example 9 returns to this trade-off. For comparison, Pi's bash tool (version 0.73.1, dist/core/tools/bash.js and dist/utils/shell.js) runs /bin/bash -c <command> with the process environment plus its own bin directory on PATH, and does not start a login shell.
Example 2: capturing output and the exit code
When a command finishes, the agent needs three things from it. The first is what it wrote to standard output. The second is what it wrote to standard error. The third is its exit code, an integer from 0 to 255 that the process returns when it ends. By convention, 0 means success and anything else means some kind of failure, and the meaning of each non-zero value depends on the program.
The program below reads all three. It first keeps the two streams separate, and then merges standard error into standard output. After that it runs commands whose exit codes have specific meanings, so you can see the values a shell tool meets every day.
import java.nio.charset.StandardCharsets;
import java.util.List;
public class OutputAndExitCode {
record Result(int exitCode, String stdout, String stderr) {}
static Result run(String command, boolean merge) throws Exception {
ProcessBuilder pb = new ProcessBuilder(List.of("bash", "-c", command));
pb.redirectErrorStream(merge);
Process p = pb.start();
p.getOutputStream().close(); // no input for the child
String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
int code = p.waitFor();
return new Result(code, out, err);
}
static void show(String label, Result r) {
System.out.printf("%s exit=%d stdout=%s stderr=%s%n", label, r.exitCode(),
r.stdout().replace("\n", "\\n"), r.stderr().replace("\n", "\\n"));
}
public static void main(String[] args) throws Exception {
String cmd = "echo one; echo two >&2; echo three; exit 3";
show("separate:", run(cmd, false));
show("merged: ", run(cmd, true));
show("true: ", run("true", false));
show("false: ", run("false", false));
show("grep: ", run("printf 'a\\nb\\n' | grep -c z", false));
show("missing: ", run("no-such-command-xyz", false));
show("syntax: ", run("if then", false));
show("killed: ", run("kill -TERM $$", false));
}
}separate: exit=3 stdout=one\nthree\n stderr=two\n
merged: exit=3 stdout=one\ntwo\nthree\n stderr=
true: exit=0 stdout= stderr=
false: exit=1 stdout= stderr=
grep: exit=1 stdout=0\n stderr=
missing: exit=127 stdout= stderr=bash: line 1: no-such-command-xyz: command not found\n
syntax: exit=2 stdout= stderr=bash: -c: line 1: syntax error near unexpected token `then'\nbash: -c: line 1: `if then'\n
killed: exit=143 stdout= stderr=The first two lines run the same command, echo one; echo two >&2; echo three; exit 3. The part >&2 sends the output of echo two to standard error.
separate:putsoneandthreein stdout andtwoin stderr. Separate streams keep the source of each line but lose the order between them. Nothing in the two strings says thattwowas printed betweenoneandthree.merged:usesredirectErrorStream(true), which makes the child's standard error the same pipe as its standard output. The order is kept (one,two,three) and the error stream is empty. For a model, the order usually matters more than the source, because a compiler error is easier to read next to the line that came before it. Most coding agents send merged output.exit=3in both lines is the value ofexit 3.waitFor()returns it once the process ends.
The remaining lines are exit codes that a shell tool sees every day.
| Line | Exit code | Meaning |
|---|---|---|
true |
0 | success |
false |
1 | generic failure |
grep |
1 | grep -c printed 0: no line matched. For grep, 1 means "no match" and 2 means an error, so 1 is not a failure of the command |
missing |
127 | bash could not find the command |
syntax |
2 | bash could not parse the command line; the message quotes the bad text |
killed |
143 | the process ended by signal 15 (SIGTERM); Java reports a death by signal N as 128 + N |
A signal is a small message that the operating system delivers to a process, for example "please stop". Example 4 explains signals in detail. For now, the rule to remember is that a process killed by signal N reports the exit code 128 + N, so 15 gives 143.
The syntax line says bash: -c: line 1:, which tells the model that the error is in its own command string and not in a script file. The killed line uses kill -TERM $$, where $$ is the shell's own process id, so the shell kills itself. Example 4 shows the same code when Java sends the signal.
The program closes p.getOutputStream() right after start(). That stream is the child's standard input seen from Java, so closing it means the child gets no input (Example 10). The program also reads stdout completely before it reads stderr. That order is safe only because the outputs here are a few bytes. Example 3 shows what happens with large output.
Example 3: reading output while the process runs
Between Java and the child, each stream is a pipe, a buffer inside the operating system kernel that has a fixed capacity. One side writes into it and the other side reads from it. On Linux, a pipe holds 16 pages by default, which is 65,536 bytes with 4 KiB pages (pipe(7)).
When the buffer is full, the child's next write blocks, which means the child stops and waits until the reader takes some data out. Now suppose the Java side waits for the process to end before it reads. If the child writes more than the buffer holds, the child waits for Java to read, and Java waits for the child to end. Both wait forever. This is a deadlock.
child writes ----> [ pipe: 65,536 bytes max ] ----> Java reads
full: child's write blocks
Java calls waitFor() and does not read => nobody empties the pipe => hangThe program below writes 200,000 bytes to stderr, which is more than three pipe buffers. It shows the hang and then two fixes. Pay attention to the first line of output, because the command needs only a few milliseconds of real work.
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class PipeDeadlock {
// 200,000 bytes to stderr, then one short line to stdout
static final String CMD = "head -c 200000 /dev/zero | tr '\\0' 'x' >&2; echo done";
static Thread drain(InputStream in, ByteArrayOutputStream sink) {
return Thread.ofVirtual().start(() -> {
try (in) { in.transferTo(sink); } catch (Exception e) { throw new RuntimeException(e); }
});
}
public static void main(String[] args) throws Exception {
// 1. Wait first, read later: the child blocks once the stderr pipe is full.
Process p = new ProcessBuilder(List.of("bash", "-c", CMD)).start();
boolean finished = p.waitFor(500, TimeUnit.MILLISECONDS);
System.out.println("wait first: finished within 500 ms = " + finished);
p.descendants().forEach(ProcessHandle::destroyForcibly);
p.destroyForcibly().waitFor();
// 2. Drain both pipes on their own threads while the child runs.
Process q = new ProcessBuilder(List.of("bash", "-c", CMD)).start();
ByteArrayOutputStream out = new ByteArrayOutputStream(), err = new ByteArrayOutputStream();
Thread t1 = drain(q.getInputStream(), out), t2 = drain(q.getErrorStream(), err);
boolean done = q.waitFor(10, TimeUnit.SECONDS);
t1.join(); t2.join();
System.out.println("drain both: finished = " + done + ", exit = " + q.exitValue()
+ ", stdout = " + out.size() + " bytes, stderr = " + err.size() + " bytes");
// 3. Merge stderr into stdout and read one stream.
Process r = new ProcessBuilder(List.of("bash", "-c", CMD)).redirectErrorStream(true).start();
long merged = r.getInputStream().readAllBytes().length;
System.out.println("merged: exit = " + r.waitFor() + ", " + merged + " bytes on one stream");
}
}wait first: finished within 500 ms = false
drain both: finished = true, exit = 0, stdout = 5 bytes, stderr = 200000 bytes
merged: exit = 0, 200005 bytes on one streamHere is what each line shows.
wait first: finished within 500 ms = false. The command needs a few milliseconds of work, but it did not finish in 500 ms. Thetrprocess wrote the first 65,536 bytes into the stderr pipe and then blocked, because nobody reads that pipe. Bash in turn waits fortr. Without the timeout,waitFor()would never return. The program kills the children and the shell before it continues, so no process is left behind.drain both: finished = true, exit = 0, stdout = 5 bytes, stderr = 200000 bytes. Two virtual threads (lightweight threads managed by the JVM, see Virtual Threads) copy each stream into a buffer while the main thread waits. The pipes never fill, so the process runs to the end. The 5 bytes of stdout are the worddoneplus a newline. The 200,000 bytes of stderr are thexcharacters.merged: exit = 0, 200005 bytes on one stream. WithredirectErrorStream(true)there is only one pipe. Reading it to the end on the main thread beforewaitFor()is enough.readAllBytes()returns when the child closes its end of the pipe, which happens when the child exits. The total of 200,005 is the 200,000xbytes plus the 5 bytes ofdoneand a newline.
The rule for the shell tool follows from these three lines: every pipe the child writes to must have a reader from the moment the process starts. Two streams need two readers, one per stream, each on its own thread. One merged stream needs one reader.
Another option is to send output to a file with redirectOutput(File). The kernel then writes the file, and no Java reader is needed. Example 11 uses that for background jobs. The same rule applies in the other direction to the child's standard input, which Example 10 covers.
Example 4: timeouts, destroy, and destroyForcibly
A command can run forever. A test can deadlock, a server can be started in the foreground, or tail -f can wait for new lines that never come. The shell tool therefore gives every command a timeout, a maximum running time after which the tool stops the process.
Process.waitFor(long, TimeUnit) waits up to the timeout and returns false if the process is still running. Stopping the process then has two levels. The first level is polite: ask the process to stop with a signal that it may handle. The second level is forceful: end the process with a signal that it cannot refuse.
A signal is a small message from the operating system to a process. SIGTERM (signal 15) means "please terminate". A program can catch it to clean up first, or ignore it. SIGKILL (signal 9) means "terminate now". The kernel enforces it, and the program never sees it. The program below shows both levels, including a command that ignores the polite one.
import java.util.List;
import java.util.concurrent.TimeUnit;
public class Timeouts {
static Process start(String command) throws Exception {
return new ProcessBuilder(List.of("bash", "-c", command))
.redirectErrorStream(true)
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.start();
}
static List<ProcessHandle> awaitChildren(Process p, int n) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
List<ProcessHandle> c = p.descendants().toList();
if (c.size() >= n) return c;
Thread.sleep(10);
}
throw new IllegalStateException("children did not start");
}
public static void main(String[] args) throws Exception {
// 1. A command that finishes in time.
Process quick = start("sleep 0.1");
System.out.println("sleep 0.1: finished=" + quick.waitFor(2, TimeUnit.SECONDS) + " exit=" + quick.exitValue());
// 2. A command that does not: destroy() sends SIGTERM.
Process slow = start("sleep 30");
boolean finished = slow.waitFor(300, TimeUnit.MILLISECONDS);
slow.destroy();
slow.waitFor();
System.out.println("sleep 30: finished=" + finished + " after destroy() exit=" + slow.exitValue());
// 3. A command that ignores SIGTERM: destroy() is not enough.
Process stubborn = new ProcessBuilder(List.of("bash", "-c", "trap '' TERM; sleep 30; echo late")).start();
List<ProcessHandle> children = awaitChildren(stubborn, 1); // bash has set the trap and started sleep
stubborn.destroy();
boolean gone = stubborn.waitFor(300, TimeUnit.MILLISECONDS);
System.out.println("trap '' TERM: gone after destroy()=" + gone);
stubborn.destroyForcibly(); // SIGKILL cannot be ignored
stubborn.waitFor();
System.out.println("after destroyForcibly() exit=" + stubborn.exitValue());
System.out.println("child sleep still alive=" + children.stream().anyMatch(ProcessHandle::isAlive));
children.forEach(ProcessHandle::destroyForcibly); // clean up what example 5 explains
}
}sleep 0.1: finished=true exit=0
sleep 30: finished=false after destroy() exit=143
trap '' TERM: gone after destroy()=false
after destroyForcibly() exit=137
child sleep still alive=trueHere is what each line shows.
sleep 0.1: finished=true exit=0. The command ends well inside its 2-second limit, sowaitForreturnstrueand the exit code is 0.sleep 30: finished=false after destroy() exit=143. After 300 ms the process is still running.destroy()asks it to stop. On Linux and macOS the JDK sends SIGTERM. Thesleepprogram neither catches nor ignores it, so it dies from the signal, and Java reports 128 + 15 = 143. Bash runs a single simple command such assleep 30by replacing itself with that program (anexec), which is why the signal reachedsleepdirectly.trap '' TERM: gone after destroy()=false.trap '' TERMmakes bash ignore SIGTERM, and a program started by bash inherits ignored signals, sosleepignores it too.destroy()sends the signal, nothing happens, and the process is still alive 300 ms later. In this case bash does not replace itself withsleep, because it still hasecho lateto run after it.after destroyForcibly() exit=137.destroyForcibly()sends SIGKILL, which a process cannot catch or ignore. Bash dies, and Java reports 128 + 9 = 137.child sleep still alive=true. SIGKILL went to bash, the process Java started, and not to its childsleep. The child lost its parent and kept running, and now belongs to nobody. The program captured the child's handle before the kill so that it can stop the child in the last line. Example 5 turns this cleanup into a method.
The program waits for the child to appear with awaitChildren instead of sleeping for a fixed time. Once sleep exists, bash has already run trap, so the test does not depend on how busy the machine is.
The usual sequence in agents has four steps: wait for the timeout, send SIGTERM, wait a short grace period (a second or two), then send SIGKILL. The grace period lets a test runner write its report or a build tool remove its lock file. The sequence is still incomplete while it signals only one process, and the next two examples fix that.
Example 5: killing process trees with ProcessHandle.descendants
A command line often starts several processes. mvn test starts a JVM that starts more JVMs for the tests. npm run dev starts a shell that starts node. The starting process is the parent and each process it starts is a child. The set of a process, its children, their children, and so on is a process tree.
bash (the process Java started)
`-- subshell
|-- sleep 30
`-- sleep 30ProcessHandle (Java 9 and later) represents any process by its id. It can list a process's children() (direct children only) and descendants() (all levels), and each handle can be signalled on its own. The program below builds a three-level tree like the one above. It kills only the root, looks at what survives, and then kills the whole tree.
import java.util.List;
public class ProcessTree {
static List<ProcessHandle> awaitDescendants(Process p, int n) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
List<ProcessHandle> d = p.descendants().toList();
if (d.size() >= n) return d;
Thread.sleep(10);
}
throw new IllegalStateException("descendants did not start");
}
static long alive(List<ProcessHandle> hs) { return hs.stream().filter(ProcessHandle::isAlive).count(); }
// Snapshot the tree, kill every process in it, and wait until each one has exited.
static void killTree(ProcessHandle root) {
List<ProcessHandle> all = root.descendants().toList();
root.destroyForcibly();
all.forEach(ProcessHandle::destroyForcibly);
root.onExit().join();
all.forEach(h -> h.onExit().join());
}
public static void main(String[] args) throws Exception {
// bash starts a subshell, which starts two sleeps; bash then waits.
String cmd = "(sleep 30 & sleep 30 & wait) & wait";
Process p1 = new ProcessBuilder(List.of("bash", "-c", cmd)).start();
List<ProcessHandle> tree1 = awaitDescendants(p1, 3);
System.out.println("tree: root bash, children=" + p1.children().count() + ", descendants=" + tree1.size());
p1.destroyForcibly().waitFor(); // kills bash only
System.out.println("after killing the root only: root alive=" + p1.isAlive()
+ ", descendants alive=" + alive(tree1));
tree1.forEach(ProcessHandle::destroyForcibly); // clean up the orphans
tree1.forEach(h -> h.onExit().join());
Process p2 = new ProcessBuilder(List.of("bash", "-c", cmd)).start();
List<ProcessHandle> tree2 = awaitDescendants(p2, 3);
killTree(p2.toHandle());
System.out.println("after killTree: root alive=" + p2.isAlive() + ", descendants alive=" + alive(tree2));
}
}tree: root bash, children=1, descendants=3
after killing the root only: root alive=false, descendants alive=3
after killTree: root alive=false, descendants alive=0The command (sleep 30 & sleep 30 & wait) & wait makes bash start a subshell in the background. A subshell is a child copy of the shell that runs the commands inside the parentheses. The subshell starts two sleep processes in the background and waits for both, and the outer bash waits for the subshell.
tree: root bash, children=1, descendants=3. Bash has one direct child, the subshell.descendants()walks the whole tree and finds three processes: the subshell and the two sleeps.after killing the root only: root alive=false, descendants alive=3.destroyForcibly()on theProcesskills bash and nothing else. All three descendants are still running. A process whose parent has died is an orphan. On Linux, the kernel gives an orphan a new parent, usually PID 1 or the nearest subreaper (a process that asked the kernel to adopt orphans below it). From then on, the orphans are no longer descendants of anything Java knows about, so a laterdescendants()call cannot find them. The program kept the list from before the kill and uses it to clean up.after killTree: root alive=false, descendants alive=0.killTreetakes the list first, kills the root and every listed descendant with SIGKILL, and then waits ononExit()for each one. The method returns only after all of them are gone.
killTree has a gap in time. A process can start a new child after the list is taken and before the kill happens. For a shell tool that kills a tree after a timeout, this window is small. A tool such as make -j, however, can start new processes at a high rate. A second gap is a process that leaves the tree on purpose. Example 6 closes both problems with a process group.
The loop serve supervisor in Workshop: loop serve, run, and chat uses the same descendants-then-root kill for the engine process.
Example 6: process groups for processes that leave the tree
Some programs daemonize. This means they start a child and let the parent exit at once, so the child is reparented and keeps running after the command that started it has returned. Shell code does the same with ( cmd & ). descendants() cannot see such a process, because its parent is no longer in the tree.
The operating system has a second way to relate processes. A process group is a set of processes that the kernel can signal together. A child joins its parent's group by default and stays in it after reparenting, so the group still contains the escaped process. The setsid command starts a program as the leader of a new session and a new process group, and the group's id equals the program's process id.
without setsid: escaped sleep belongs to the same group as the JVM's other children (hard to target)
with setsid: group id = pid of bash; bash, subshell, escaped sleep are all members
kill -KILL -- -<that pid> signals every memberThe program below compares the two approaches on a command that leaves one sleep behind.
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.List;
public class ProcessGroup {
// The subshell starts sleep in the background, prints its pid, and exits, so that sleep
// loses its parent and the kernel reparents it. "next" is printed after the subshell is gone.
static final String CMD = "( sleep 30 & echo $! ); echo next; sleep 30";
static long escapedPid(Process p) throws Exception {
BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));
long pid = Long.parseLong(r.readLine().trim());
if (!"next".equals(r.readLine())) throw new IllegalStateException();
return pid;
}
public static void main(String[] args) throws Exception {
// 1. Without a group: descendants() does not see the escaped sleep.
Process p = new ProcessBuilder(List.of("bash", "-c", CMD)).start();
ProcessHandle escaped = ProcessHandle.of(escapedPid(p)).orElseThrow();
System.out.println("escaped is a descendant: " + p.descendants().anyMatch(h -> h.pid() == escaped.pid()));
p.descendants().forEach(ProcessHandle::destroyForcibly);
p.destroyForcibly().waitFor();
System.out.println("after killing the tree, escaped alive: " + escaped.isAlive());
escaped.destroyForcibly();
escaped.onExit().join();
// 2. With setsid: bash leads a new session and process group whose id is its pid.
Process q = new ProcessBuilder(List.of("setsid", "bash", "-c", CMD)).start();
ProcessHandle escaped2 = ProcessHandle.of(escapedPid(q)).orElseThrow();
System.out.println("setsid: escaped is a descendant: " + q.descendants().anyMatch(h -> h.pid() == escaped2.pid()));
Process kill = new ProcessBuilder("kill", "-KILL", "--", "-" + q.pid()).start(); // negative pid = whole group
System.out.println("kill -KILL -- -<pgid> exit=" + kill.waitFor());
q.waitFor();
escaped2.onExit().join();
System.out.println("after killing the group, escaped alive: " + escaped2.isAlive());
}
}escaped is a descendant: false
after killing the tree, escaped alive: true
setsid: escaped is a descendant: false
kill -KILL -- -<pgid> exit=0
after killing the group, escaped alive: falseThe command prints the pid of the escaped sleep, prints next once the subshell has exited, and then runs a second sleep in the foreground. The program reads the line next before it checks anything, which guarantees that the reparenting has already happened.
escaped is a descendant: false. The subshell exited, so the escapedsleepis not below bash any more.after killing the tree, escaped alive: true. Killing every descendant plus the root (the approach of Example 5) leaves the escaped process running. A test server started this way would hold its port open after the agent moved on.setsid: escaped is a descendant: false. This is the same command undersetsid. The escaped process is still outside the tree, but it is in the group thatsetsidcreated.kill -KILL -- -<pgid> exit=0. Thekillcommand with a negative number signals every process in the group with that id. Java'sProcessHandlehas no method to signal a group, so the program runs thekillprogram for it. The--ends kill's option parsing, so that the negative number is read as a pid and not as an option.after killing the group, escaped alive: false. The group kill reached the escaped process.
setsid creates the new session in the same process when the caller is not already a process group leader, and forks first when it is. A child started by Java is not a group leader, so q.pid() is the pid of bash itself and the group id equals it.
Two limits remain. A process that calls setsid() itself leaves the group. And macOS has no setsid command; a small wrapper that calls the setsid() system call, or a container, fills the gap there.
Pi spawns its shell as a detached process, which on Unix makes it a group leader. On timeout or abort it sends SIGKILL to the negative pid, which is the same group kill (killProcessTree in dist/utils/shell.js). The only complete way to stop everything a command started is an operating system container or cgroup (a Linux feature that groups processes and limits what they can do), which Permissions, Sandboxing, and Prompt Injection covers.
Example 7: truncating output for the model
A test run, a build log, or cat on a large file can print megabytes. All of it would go into the model's context window, the fixed number of tokens (small pieces of text) that the model can attend to at once. A large output would push the rest of the conversation out and cost money. The shell tool therefore sends a bounded excerpt.
Which part to keep depends on how commands behave. The beginning of the output usually shows what ran. The end usually shows the error and the summary. The common policy keeps a head (the first lines) and a tail (the last lines) and replaces the middle with a marker, a line that says how much was removed.
The program below applies two limits, a line limit and a character limit, to 5,000 lines of output and to one very long line. The character limit is needed because a single line can be huge, for example minified JavaScript.
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class TruncateOutput {
record Truncated(String text, int totalLines, int totalChars, boolean truncated) {}
// Keep the first headLines and last tailLines; also cap the total characters.
static Truncated headTail(String output, int headLines, int tailLines, int maxChars) {
List<String> lines = output.lines().toList();
int n = lines.size();
String text;
boolean cut = n > headLines + tailLines;
if (cut) {
String head = String.join("\n", lines.subList(0, headLines));
String tail = String.join("\n", lines.subList(n - tailLines, n));
text = head + "\n[... " + (n - headLines - tailLines) + " lines omitted ...]\n" + tail;
} else {
text = String.join("\n", lines);
}
if (text.length() > maxChars) { // very long lines: cut by characters too
int half = maxChars / 2;
text = text.substring(0, half) + "\n[... " + (text.length() - 2 * half) + " characters omitted ...]\n"
+ text.substring(text.length() - half);
cut = true;
}
return new Truncated(text, n, output.length(), cut);
}
static String run(String command) throws Exception {
Process p = new ProcessBuilder(List.of("bash", "-c", command)).redirectErrorStream(true).start();
String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
p.waitFor();
return out;
}
public static void main(String[] args) throws Exception {
String big = run("for i in $(seq 1 5000); do echo \"line $i\"; done; echo 'ERROR: test failed at line 4999' >&2");
Truncated t = headTail(big, 3, 3, 2_000);
System.out.println(t.text());
System.out.println("total lines=" + t.totalLines() + " chars=" + t.totalChars() + " sent chars=" + t.text().length());
Path spill = Files.createTempFile("shell-output", ".log"); // full output for a later read
Files.writeString(spill, big);
System.out.println("full output saved: " + Files.size(spill) + " bytes");
Files.delete(spill);
Truncated wide = headTail(run("head -c 5000 /dev/zero | tr '\\0' 'a'; echo"), 3, 3, 60);
System.out.println(wide.text());
System.out.println("one line of " + wide.totalChars() + " chars -> " + wide.text().length() + " chars");
}
}line 1
line 2
line 3
[... 4995 lines omitted ...]
line 4999
line 5000
ERROR: test failed at line 4999
total lines=5001 chars=48925 sent chars=101
full output saved: 48925 bytes
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
[... 4940 characters omitted ...]
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
one line of 5001 chars -> 95 charsHere is how to read the output.
- The first seven lines are the excerpt sent to the model.
line 1toline 3are the head,[... 4995 lines omitted ...]is the marker, andline 4999,line 5000, and theERRORline are the tail. The error line was written to stderr after everything else, and because the streams are merged it arrives last, inside the tail. A head-only policy would have dropped it. total lines=5001 chars=48925 sent chars=101. The full output has 5,001 lines (5,000 numbered lines plus the error line) and 48,925 characters. The model receives 101 characters: 3 lines of head, the marker, and 3 lines of tail. The marker states the number of omitted lines so that the model knows the output was cut and can ask for a part of it, for example withsed -n '2000,2100p'on a saved log.full output saved: 48925 bytes. The full text goes to a temporary file. A real tool puts the file's path in the marker, so the model can read the omitted middle with a file tool instead of rerunning the command.- The last four lines show the second limit. One line of 5,000
acharacters passes the line limit, because it is a single line. But the character limit of 60 keeps 30 characters from each end and replaces 4,940 with a marker (5,000 minus 60). Without a character limit, minified JavaScript or a long JSON line would bypass the line limit.
Lines bound how much must be scanned, and characters bound how many tokens the output costs. The reference harnesses make the same choices with different numbers.
- Crush (v0.97.1,
internal/agent/tools/bash.go) keeps the first and last 15,000 characters of a 30,000-character limit, counts the dropped lines in the marker, and writes the full output to a spill file whose path it puts in the marker. - Pi keeps the last 2,000 lines or 50 KB, whichever limit is reached first, and saves the full output to a temporary file.
Context Engineering for Coding Agents compares truncation policies across all tools.
Example 8: keeping the working directory between calls
Each tool call starts a new bash process, so a cd in one call is gone in the next. A model that runs cd src in one call and javac Main.java in the next call expects the second command to run in src. If the tool forgets, the second command fails with "file not found", and the model gets confused.
The shell tool can keep the directory by asking bash for its final working directory ($PWD) when the command ends, and by starting the next call there. The program below does this with an EXIT trap. A trap is a command that bash runs when some event happens, and an EXIT trap runs when the shell exits for any reason, including an explicit exit. The trap writes the directory to a file, and Java reads the file after the process ends.
The program also checks that the new directory is still inside the workspace. If a command moves outside, the session refuses the new directory.
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
public class KeepCwd {
static final class Session {
final Path workspace;
final Path cwdFile;
Path cwd;
Session(Path workspace) throws Exception {
this.workspace = workspace.toRealPath();
this.cwd = this.workspace;
this.cwdFile = Files.createTempFile("cwd", ".txt");
}
String run(String command) throws Exception {
// The EXIT trap runs after the command, even when it calls `exit`, and records $PWD.
String wrapped = "trap 'pwd -P > \"$LOOP_CWD_FILE\"' EXIT\n" + command;
ProcessBuilder pb = new ProcessBuilder(List.of("bash", "-c", wrapped))
.directory(cwd.toFile()).redirectErrorStream(true);
pb.environment().put("LOOP_CWD_FILE", cwdFile.toString());
Process p = pb.start();
String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip();
int code = p.waitFor();
Path next = Path.of(Files.readString(cwdFile).strip());
String note = "";
if (next.startsWith(workspace) && Files.isDirectory(next)) cwd = next;
else note = " (left the workspace; cwd not changed)";
return "exit=" + code + " out=" + (out.isEmpty() ? "-" : out) + " cwd=" + show(cwd) + note;
}
String show(Path p) { return "/" + workspace.relativize(p).toString().replace('\\', '/'); }
}
public static void main(String[] args) throws Exception {
Path ws = Files.createTempDirectory("ws");
Files.createDirectories(ws.resolve("src/main/java"));
Session s = new Session(ws);
System.out.println("1 " + s.run("cd src"));
System.out.println("2 " + s.run("ls"));
System.out.println("3 " + s.run("cd main/java && pwd | sed 's|.*/src|<ws>/src|'"));
System.out.println("4 " + s.run("cd ../..; exit 4"));
System.out.println("5 " + s.run("cd /tmp"));
System.out.println("6 " + s.run("cd no-such-dir"));
try (Stream<Path> w = Files.walk(ws)) { w.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); }
Files.delete(s.cwdFile);
}
}1 exit=0 out=- cwd=/src
2 exit=0 out=main cwd=/src
3 exit=0 out=<ws>/src/main/java cwd=/src/main/java
4 exit=4 out=- cwd=/src
5 exit=0 out=- cwd=/src (left the workspace; cwd not changed)
6 exit=1 out=bash: line 2: cd: no-such-dir: No such file or directory cwd=/srcThe workspace contains src/main/java. Each numbered line is one call, and cwd is shown relative to the workspace root.
cd srcprints nothing, exits 0, and the session's directory becomes/src.lsruns in/srcwithout acd, and printsmain. The directory carried over from the first call.cd main/java && pwdmoves two levels down. Thesedreplaces the random temporary prefix with<ws>so that the line is the same on every run.cd ../..; exit 4goes back up and exits with code 4. The EXIT trap still ran, so the directory is recorded as/src, and the exit code is the command's 4. The trap does not change the exit status.cd /tmpleaves the workspace. The command succeeds, but the session refuses the new directory and keeps/src, with a note that the model will see. The check uses the real path, becausepwd -Presolves symbolic links. A link inside the workspace that points outside it is therefore caught too.cd no-such-dirfails with exit code 1. Bash's message saysline 2because the trap is line 1 of the string bash runs and the command is line 2. The directory stays/src.
There are two other designs for the same problem.
- A persistent shell keeps one bash process for the whole session and writes each command to its standard input, followed by a marker such as
echo __DONE_$?__that shows where the output ends. It keeps all state: directory, variables, functions, and activated virtual environments. It also keeps damage. A command that changesset -eor hangs affects every later call, and timing out one command without killing the shell is harder. - An interpreter in the agent runs shell syntax without starting bash at all. Crush does this with the
mvdan.cc/shinterpreter (internal/shell/shell.go), and copies the interpreter's directory and exported variables back into its session after each command.
Pi runs every command in the same fixed directory and does not carry cd between calls. The trap approach in this example keeps only the directory, which is the state models most often rely on, and leaves each call otherwise independent.
Example 9: environment hygiene
A child process inherits the agent's environment unless the agent changes it. The environment of a developer's terminal often holds credentials: API keys, cloud profiles, a database password, the socket of an SSH agent. A command the model runs can read and print all of them. So can a test, a build script, or a dependency's post-install script. The printed text goes to the model, and possibly to a remote API.
The second problem is interactivity. Some programs open a pager (a program such as less that shows text one screen at a time and waits for keys) or ask for a password when they think a person is present.
The program below builds a child environment that removes credentials by name and sets variables that turn interactive behavior off. The parent environment in the program is made up, so the output is the same on every machine.
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Pattern;
public class CleanEnvironment {
static final Pattern SECRET = Pattern.compile(
"(?i).*(TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIALS).*|AWS_.*|SSH_AUTH_SOCK");
static final Map<String, String> NON_INTERACTIVE = Map.of(
"GIT_TERMINAL_PROMPT", "0", // git fails instead of asking for a username
"GIT_PAGER", "cat", // git log/diff print instead of opening less
"PAGER", "cat",
"TERM", "dumb", // no cursor movement or full-screen programs
"NO_COLOR", "1", // programs that follow no-color.org print no colors
"DEBIAN_FRONTEND", "noninteractive");
static Map<String, String> cleanEnv(Map<String, String> parent, List<String> removed) {
Map<String, String> env = new TreeMap<>();
parent.forEach((k, v) -> { if (SECRET.matcher(k).matches()) removed.add(k); else env.put(k, v); });
env.putAll(NON_INTERACTIVE);
return env;
}
public static void main(String[] args) throws Exception {
Map<String, String> parent = new TreeMap<>(Map.of(
"PATH", System.getenv("PATH"), "HOME", "/home/dev", "LANG", "C.UTF-8",
"OPENAI_API_KEY", "sk-test-123", "GITHUB_TOKEN", "ghp_test", "AWS_PROFILE", "prod",
"DB_PASSWORD", "hunter2", "SSH_AUTH_SOCK", "/tmp/ssh-agent.sock", "TERM", "xterm-256color"));
List<String> removed = new java.util.ArrayList<>();
Map<String, String> env = cleanEnv(parent, removed);
System.out.println("removed: " + removed.stream().sorted().toList());
System.out.println("kept: " + env.keySet());
ProcessBuilder pb = new ProcessBuilder(List.of("bash", "-c",
"echo \"key=${OPENAI_API_KEY:-unset} term=$TERM pager=$GIT_PAGER home=$HOME\""));
pb.environment().clear(); // start from nothing, not from the JVM's environment
pb.environment().putAll(env);
Process p = pb.redirectErrorStream(true).start();
System.out.println("child: " + new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip());
p.waitFor();
}
}removed: [AWS_PROFILE, DB_PASSWORD, GITHUB_TOKEN, OPENAI_API_KEY, SSH_AUTH_SOCK]
kept: [DEBIAN_FRONTEND, GIT_PAGER, GIT_TERMINAL_PROMPT, HOME, LANG, NO_COLOR, PAGER, PATH, TERM]
child: key=unset term=dumb pager=cat home=/home/devThe parent map stands in for a developer's environment. Its values are fake, which keeps the output the same on every machine.
removed:lists the five names that matched the pattern.OPENAI_API_KEYandGITHUB_TOKENmatch by words inside their names (API_KEY,TOKEN).DB_PASSWORDmatches by the wordPASSWORD.AWS_PROFILEmatches theAWS_prefix, because the AWS command-line tools read credentials and profiles fromAWS_*variables.SSH_AUTH_SOCKmatches by exact name. That last one holds no secret text, but it lets any process use the keys loaded into the user's SSH agent, for example to push to a repository.kept:is sorted because the map is aTreeMap.PATH,HOME, andLANGcame from the parent. The six non-interactive settings were added, andTERMwas replaced bydumb.child:is what the child process sees. The key isunset,TERMisdumb, and the pager iscat. The program callspb.environment().clear()beforeputAll, so the child gets exactly this map and nothing from the JVM's real environment.
Each added variable turns one behavior off.
| Variable | Effect |
|---|---|
GIT_TERMINAL_PROMPT=0 |
git fails instead of asking for a username or password on the terminal (git documentation) |
GIT_PAGER=cat, PAGER=cat |
git log, git diff, and man print their output instead of opening a pager that waits for keys |
TERM=dumb |
programs that read TERM skip cursor movement and full-screen modes |
NO_COLOR=1 |
programs that follow the no-color.org convention print no color codes |
DEBIAN_FRONTEND=noninteractive |
Debian's package tools use default answers instead of dialogs |
A pattern on names is a denylist. It removes what it recognizes, so a variable named MY_SERVICE_CREDS passes through. An allowlist is stricter: start from an empty map and copy only PATH, HOME, LANG, USER, TMPDIR, and whatever the project's build needs. An allowlist can break tools that need a variable you forgot, but the error message usually names that variable, so the fix is quick.
The login shell from Example 1 matters here. bash -lc runs the user's profile, which may export the same secrets again after the agent removed them. Removing variables also gives no protection against a command that reads ~/.aws/credentials or ~/.ssh/id_ed25519 from disk. File access is a sandboxing question for Permissions, Sandboxing, and Prompt Injection.
Example 10: commands that wait for input
The model cannot type. A command that asks a question (Overwrite? [y/N], a password prompt, or git commit opening an editor) waits for an answer that never comes, and the tool call hangs until its timeout.
The shell tool prevents most of these hangs by giving the child no input at all. It does this by connecting standard input to /dev/null, a special file that is always empty. Any program that tries to read from it receives end-of-input at once, the signal that no more data will arrive, so it stops waiting.
The program below runs the same prompt twice, first with an open pipe that nobody writes to and then with /dev/null. It then runs cat, and finally a program that reads the terminal directly instead of standard input.
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class WaitsForInput {
enum Stdin { OPEN_PIPE, DEV_NULL }
static String run(String command, Stdin stdin, boolean newSession) throws Exception {
List<String> argv = new ArrayList<>(newSession ? List.of("setsid") : List.of());
argv.addAll(List.of("bash", "-c", command));
ProcessBuilder pb = new ProcessBuilder(argv).redirectErrorStream(true);
if (stdin == Stdin.DEV_NULL) pb.redirectInput(new File("/dev/null"));
Process p = pb.start(); // OPEN_PIPE: we hold the pipe and never write
if (!p.waitFor(2, TimeUnit.SECONDS)) {
p.descendants().forEach(ProcessHandle::destroyForcibly);
p.destroyForcibly().waitFor();
return "TIMED OUT (waiting for input)";
}
return new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip().replace("\n", " | ");
}
public static void main(String[] args) throws Exception {
String ask = "printf 'Overwrite? [y/N] '; read -r answer; echo \"answer=[$answer] read-status=$?\"";
System.out.println("open pipe: " + run(ask, Stdin.OPEN_PIPE, false));
System.out.println("/dev/null: " + run(ask, Stdin.DEV_NULL, false));
System.out.println("cat: " + run("cat; echo cat-exit=$?", Stdin.DEV_NULL, false));
System.out.println("/dev/tty: " + run("echo 'Password:' > /dev/tty; read -r pw < /dev/tty", Stdin.DEV_NULL, true));
}
}open pipe: TIMED OUT (waiting for input)
/dev/null: Overwrite? [y/N] answer=[] read-status=1
cat: cat-exit=0
/dev/tty: bash: line 1: /dev/tty: No such device or address | bash: line 1: /dev/tty: No such device or addressHere is what each line shows.
open pipe: TIMED OUT (waiting for input).ProcessBuilderconnects the child's standard input to a pipe by default. Java never writes to it and never closes it, soreadwaits for a line or for end-of-input, and neither comes. The two-second timeout ends the wait./dev/null: Overwrite? [y/N] answer=[] read-status=1.redirectInput(new File("/dev/null"))makes standard input the null device, which returns end-of-input on the first read.readgives up at once with status 1, the answer is empty, and the script continues. A script that defaults to "no" behaves correctly here. Closing Java's end of the pipe right afterstart()(Example 2) has the same effect.cat: cat-exit=0.catwith no file arguments copies standard input to standard output. A model sometimes runs it by mistake, for examplecatfollowed by a heredoc (a block of text fed to a command) that got lost. With/dev/null,catends at once with nothing to copy, instead of hanging./dev/tty: ... No such device or address, twice./dev/ttyis the controlling terminal, the terminal a process belongs to. Programs that must talk to a person, such assudoandsshasking for a password, open/dev/ttyinstead of reading standard input, so redirecting stdin does not stop them. Undersetsidthe process has no controlling terminal, so opening/dev/ttyfails right away. The two messages come from theechoand theread, each failing to open it.
Without setsid, a child of a JVM that runs in a terminal can open that terminal, write a password prompt onto the user's screen, and wait for keystrokes. Several defenses therefore work together:
/dev/nullfor standard input.setsidto remove the terminal.- The non-interactive variables from Example 9 for pagers and git.
- A timeout for everything else.
The tool description should also tell the model that no input is available. The model then adds flags such as -y, --yes, or --no-edit itself.
Example 11: background processes: start, log, check, stop
Some commands are meant to keep running: a development server that the tests talk to, a file watcher, a database. With a normal call the tool would wait until the command ends, which never happens. The model needs to start one, continue with other calls, read its output later, and stop it.
A background job is a process the tool starts without waiting for it, with an id the model uses in later calls. The program below keeps jobs in a map and sends each job's output to a log file. It offers four operations: start, status, tail (the last lines of the log), and stop.
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class BackgroundJobs {
record Job(String id, String command, Process process, Path log) {}
static final class Jobs {
private final Map<String, Job> jobs = new TreeMap<>();
private final Path logDir;
private int next = 1;
Jobs(Path logDir) { this.logDir = logDir; }
String start(String command) throws Exception {
String id = "job" + next++;
Path log = logDir.resolve(id + ".log");
Process p = new ProcessBuilder(List.of("setsid", "bash", "-c", command))
.redirectErrorStream(true)
.redirectOutput(log.toFile()) // the OS writes the log; no reader thread needed
.redirectInput(ProcessBuilder.Redirect.from(Path.of("/dev/null").toFile()))
.start();
jobs.put(id, new Job(id, command, p, log));
return id;
}
String status(String id) {
Process p = jobs.get(id).process();
return p.isAlive() ? "running" : "exited " + p.exitValue();
}
List<String> tail(String id, int n) throws Exception {
List<String> lines = Files.readAllLines(jobs.get(id).log());
return lines.subList(Math.max(0, lines.size() - n), lines.size());
}
String stop(String id) throws Exception {
Process p = jobs.get(id).process();
if (!p.isAlive()) return "already " + status(id);
new ProcessBuilder("kill", "-TERM", "--", "-" + p.pid()).start().waitFor(); // whole group
p.waitFor();
return "stopped, " + status(id);
}
void stopAll() throws Exception { for (String id : jobs.keySet()) stop(id); }
}
static void awaitLine(Path log, String text) throws Exception {
for (int i = 0; i < 1000 && !Files.readString(log).contains(text); i++) Thread.sleep(10);
}
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("jobs");
Jobs jobs = new Jobs(dir);
String server = jobs.start("echo 'server: starting'; echo 'server: listening'; while true; do sleep 1; done");
String build = jobs.start("echo 'build: compiling'; echo 'build: 1 error' >&2; exit 2");
awaitLine(dir.resolve(server + ".log"), "listening");
jobs.jobs.get(build).process().waitFor();
System.out.println(server + ": " + jobs.status(server) + " " + jobs.tail(server, 1));
System.out.println(build + ": " + jobs.status(build) + " " + jobs.tail(build, 2));
System.out.println(server + ": " + jobs.stop(server));
System.out.println(build + ": " + jobs.stop(build));
jobs.stopAll();
for (String f : new String[] {"job1.log", "job2.log"}) Files.delete(dir.resolve(f));
Files.delete(dir);
}
}job1: running [server: listening]
job2: exited 2 [build: compiling, build: 1 error]
job1: stopped, exited 143
job2: already exited 2Here is what each line shows.
job1: running [server: listening]. The server job printed two lines and is still in its loop.tail(server, 1)returns the last line of its log. The program waited for the wordlisteningin the log before it checked, which is how an agent knows that a server is ready.job2: exited 2 [build: compiling, build: 1 error]. The build job printed one line to stdout and one to stderr and exited with code 2. Both lines are in the same log because ofredirectErrorStream(true), andstatusreports the exit code once the process has ended.job1: stopped, exited 143.stopsends SIGTERM to the job's process group (negative pid, as in Example 6), so thesleep 1inside the loop dies as well as bash. Exit code 143 is SIGTERM.job2: already exited 2. Stopping a finished job is not an error. The tool reports the state.
Each job writes to a file through redirectOutput(log.toFile()), so the kernel writes the log, and no Java thread has to drain a pipe for the life of the job (Example 3). Each job also runs under setsid with /dev/null as input. Stopping it therefore reaches every process it started, and it never waits for input. The agent stops all jobs when the session ends. Otherwise a development server keeps its port after the agent exits.
The two reference harnesses differ here.
- Crush's bash tool has a
run_in_backgroundparameter, moves a command that runs longer thanauto_background_afterseconds (default 60) to the background automatically, and offers separatejob_outputandjob_killtools. - Pi has no background mode. Its README says "No background bash. Use tmux." and leaves long-running processes to a terminal multiplexer that the user can see.
Example 12: streaming output to the user while capturing it
A test suite can run for minutes. The person watching the agent wants to see progress while it runs, and the model needs the final text. The tool therefore does two things with each line. It hands the line to a listener, a function that the terminal UI provides, as soon as the line arrives. It also appends a cleaned copy to the text that the model will receive.
Cleaning is needed because terminal programs write things that make sense on a screen but not in a transcript.
- ANSI escape codes are byte sequences that start with the escape character (code 27) and change colors or move the cursor. A red
FAILis really the bytes for "switch to red", the textFAIL, and the bytes for "reset". - Carriage returns (
\r) move the cursor to the start of the line. A progress bar uses them to redraw itself in place, so one printed line can hold many versions of the bar.
The program below reads one character at a time, splits only on \n, shows each line live, and builds a cleaned copy for the model.
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.function.Consumer;
import java.util.regex.Pattern;
public class StreamAndCapture {
static final Pattern ANSI = Pattern.compile("\u001B\\[[0-9;?]*[A-Za-z]");
// For the model: drop color codes and keep only the last redraw of a \r progress line.
static String forModel(String line) {
String plain = ANSI.matcher(line).replaceAll("");
int cr = plain.lastIndexOf('\r');
return cr >= 0 ? plain.substring(cr + 1) : plain;
}
static String runStreaming(String command, Consumer<String> onLine) throws Exception {
Process p = new ProcessBuilder(List.of("bash", "-c", command)).redirectErrorStream(true).start();
StringBuilder captured = new StringBuilder();
try (Reader r = new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8)) {
StringBuilder line = new StringBuilder();
for (int c; (c = r.read()) != -1; ) { // split on \n only; readLine() would also split on \r
if (c != '\n') { line.append((char) c); continue; }
onLine.accept(line.toString()); // the user sees each line as it arrives
captured.append(forModel(line.toString())).append('\n');
line.setLength(0);
}
if (!line.isEmpty()) { onLine.accept(line.toString()); captured.append(forModel(line.toString())).append('\n'); }
}
captured.append("[exit code ").append(p.waitFor()).append(']');
return captured.toString();
}
public static void main(String[] args) throws Exception {
String cmd = "echo 'Compiling 3 files'; "
+ "printf 'download 10%%\\rdownload 60%%\\rdownload 100%%\\n'; "
+ "printf '\\033[32mPASS\\033[0m test_add\\n'; "
+ "printf '\\033[31mFAIL\\033[0m test_div\\n' >&2; exit 1";
int[] count = {0};
String forModel = runStreaming(cmd, line -> {
count[0]++;
String visible = line.replace("\u001B", "ESC").replace("\r", "\\r");
System.out.println("live " + count[0] + ": " + visible);
});
System.out.println("--- captured for the model ---");
System.out.println(forModel);
}
}live 1: Compiling 3 files
live 2: download 10%\rdownload 60%\rdownload 100%
live 3: ESC[32mPASSESC[0m test_add
live 4: ESC[31mFAILESC[0m test_div
--- captured for the model ---
Compiling 3 files
download 100%
PASS test_add
FAIL test_div
[exit code 1]Here is how the two views differ.
live 1tolive 4are what the listener received, one call per line, as the process produced them. The program prints the escape character asESCand the carriage return as\rso that they are visible.live 2is one line that contains three redraws of a progress indicator:download 10%, then\r, thendownload 60%, and so on. A terminal shows only the last one. The reader splits only on\n.BufferedReader.readLine()would also split at\rand turn one progress line into three.live 3andlive 4contain color codes:ESC[32m(green),ESC[31m(red), andESC[0m(reset).- The block after
--- captured for the model ---is the cleaned text. The progress line becamedownload 100%, the text after the last\r. The color codes are gone, soPASS test_addandFAIL test_divcost a few tokens each instead of carrying escape sequences. The last line,[exit code 1], comes from theexit 1at the end of the command.
Many programs buffer standard output in blocks of several kilobytes when it is a pipe rather than a terminal, so lines can arrive in bursts. The C library's stdio does this. Python does it unless it runs with -u or PYTHONUNBUFFERED=1. GNU coreutils' stdbuf -oL asks a stdio program for line buffering. The captured text is complete either way. Only the timing of the live view changes.
Example 13: reporting exit codes and output to the model
The last step turns a finished command into the tool result, the text that the model reads in the next turn, plus a flag that says whether the call failed. The format should leave no room for guessing. The model must be able to tell whether the command succeeded, what its exit code was, whether the output was cut, and what the command printed.
The program below formats four outcomes the same way: a success with no output, a failure with an error message, a command that exits with 1 and prints nothing, and a command that times out. The status goes first, because the model reads the top of the text before the output.
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class ReportToModel {
record ShellResult(String command, int exitCode, String output, boolean timedOut, long timeoutMs) {}
record ToolResult(String content, boolean isError) {}
static ShellResult run(String command, long timeoutMs) throws Exception {
Process p = new ProcessBuilder(List.of("setsid", "bash", "-c", command))
.redirectErrorStream(true)
.redirectInput(ProcessBuilder.Redirect.from(new java.io.File("/dev/null"))).start();
ByteArrayOutputStream out = new ByteArrayOutputStream();
Thread reader = Thread.ofVirtual().start(() -> { try { p.getInputStream().transferTo(out); } catch (Exception e) { } });
boolean done = p.waitFor(timeoutMs, TimeUnit.MILLISECONDS);
if (!done) new ProcessBuilder("kill", "-KILL", "--", "-" + p.pid()).start().waitFor();
int code = p.waitFor();
reader.join();
return new ShellResult(command, code, out.toString().strip(), !done, timeoutMs);
}
// One text block the model can read without guessing: status line first, then the output.
static ToolResult report(ShellResult r) {
StringBuilder sb = new StringBuilder();
if (r.timedOut()) sb.append("Command timed out after ").append(r.timeoutMs()).append(" ms and was killed. Partial output:\n");
else sb.append("Exit code: ").append(r.exitCode()).append('\n');
sb.append(r.output().isEmpty() ? "(no output)" : r.output());
return new ToolResult(sb.toString(), r.timedOut() || r.exitCode() != 0);
}
public static void main(String[] args) throws Exception {
String[] commands = {
"mkdir -p build && rmdir build",
"echo 'Main.java:3: error: cannot find symbol' >&2; exit 1",
"printf 'a\\nb\\n' | grep -n zzz",
"echo 'starting tests'; sleep 30",
};
for (String c : commands) {
ToolResult t = report(run(c, 3000));
System.out.println("$ " + c + "\nisError=" + t.isError() + "\n" + t.content() + "\n");
}
}
}$ mkdir -p build && rmdir build
isError=false
Exit code: 0
(no output)
$ echo 'Main.java:3: error: cannot find symbol' >&2; exit 1
isError=true
Exit code: 1
Main.java:3: error: cannot find symbol
$ printf 'a\nb\n' | grep -n zzz
isError=true
Exit code: 1
(no output)
$ echo 'starting tests'; sleep 30
isError=true
Command timed out after 3000 ms and was killed. Partial output:
starting testsEach block shows the command, the isError flag, and the result text.
mkdir -p build && rmdir buildsucceeded and printed nothing. The result saysExit code: 0and(no output). An empty string would also be correct, but some models answer an empty tool result by running the command again, and the explicit text avoids that.- The second command printed a compiler-style error to stderr and exited 1. Because the output is merged, the error text follows the status line, and
isError=true. grep -n zzzfound no match. The result isExit code: 1and(no output), andisError=true. This is the ambiguity from Example 2: forgrep, 1 means "no lines matched", which is an answer and not a malfunction. The tool cannot know every program's conventions, so it reports the exit code exactly and lets the model interpret it. Marking non-zero codes as errors is a choice. It helps for compilers and test runners, and it is slightly misleading forgrep,diff, andtest.- The last command printed
starting testsand then slept. After 3,000 ms the tool killed the process group and reportedCommand timed out after 3000 ms and was killed. Partial output:followed by what had been printed. Partial output tells the model how far the command got, which decides whether it should raise the timeout or change the command.
isError maps to the error flag of the tool-result message in the protocol the agent uses, for example is_error in some providers' tool-result blocks or isError in an MCP tool result. With the flag, the model can tell a failed call from a successful one without parsing text.
The two reference harnesses make different choices here.
- Crush appends
Exit code Nafter the output, adds the working directory as<cwd>...</cwd>, and returns the text as a normal (not error) result. - Pi throws from the tool's
executewhen the exit code is non-zero, which its runtime reports to the model as a result withisError: true(Pi's extension documentation). It appendsCommand exited with code Nand returnsCommand timed out after N secondsfor timeouts.
Example 14: Project 23, the shell tool in a scripted session
Project 23 puts Examples 1 to 13 into one class, ShellTool, that implements the Tool interface. The program runs it in a small agent loop against a scripted model, a list of canned assistant messages that stands in for a real model, so that the run is the same every time.
The task for the session is: write a Java file, compile it, read the compiler error, fix the file, compile again, and run it. The first message contains a deliberate bug, a missing semicolon. The scripted model's second message is written to fit the compiler error that the first call produces. In a real run, the model would read the error and write that fix itself.
Look for three things in the output: the error result of the first call, the working directory that carries over to the second call, and the final history size.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
public class ShellToolProject {
static final ObjectMapper JSON = new ObjectMapper();
// Restated from Project 21 (The Agent Loop and a Tool Framework), reduced to what this program needs.
interface Tool { String name(); String description(); JsonNode schema(); String run(JsonNode args) throws Exception; }
sealed interface Message permits UserMessage, AssistantMessage, ToolMessage {}
record UserMessage(String text) implements Message {}
record ToolCall(String id, String name, String arguments) {}
record AssistantMessage(String text, List<ToolCall> toolCalls) implements Message {}
record ToolMessage(String callId, String name, String content, boolean isError) implements Message {}
static ToolMessage execute(Map<String, Tool> tools, ToolCall call) { // Project 21: exceptions become error results
try {
return new ToolMessage(call.id(), call.name(), tools.get(call.name()).run(JSON.readTree(call.arguments())), false);
} catch (Exception e) {
return new ToolMessage(call.id(), call.name(), e.getClass().getSimpleName() + ": " + e.getMessage(), true);
}
}
static final class CommandFailed extends Exception { CommandFailed(String text) { super(text); } }
static final class ShellTool implements Tool {
private final Path workspace;
private final Path cwdFile;
private Path cwd;
private final long defaultTimeoutMs;
private final int maxChars;
ShellTool(Path workspace, long defaultTimeoutMs, int maxChars) throws Exception {
this.workspace = workspace.toRealPath();
this.cwd = this.workspace;
this.cwdFile = Files.createTempFile("loop-cwd", ".txt");
this.defaultTimeoutMs = defaultTimeoutMs;
this.maxChars = maxChars;
}
public String name() { return "shell"; }
public String description() {
return "Run a bash command in the workspace. The working directory persists between calls. "
+ "No input is available; commands that prompt fail. Output is truncated to " + maxChars + " characters.";
}
public JsonNode schema() {
ObjectNode s = JSON.createObjectNode().put("type", "object");
ObjectNode props = s.putObject("properties");
props.putObject("command").put("type", "string").put("description", "bash command line");
props.putObject("timeout_ms").put("type", "integer").put("minimum", 1);
s.putArray("required").add("command");
return s;
}
public String run(JsonNode args) throws Exception {
String command = args.path("command").asText("");
if (command.isBlank()) throw new IllegalArgumentException("missing required argument 'command'");
long timeoutMs = args.path("timeout_ms").asLong(defaultTimeoutMs);
ProcessBuilder pb = new ProcessBuilder(List.of("setsid", "bash", "-c",
"trap 'pwd -P > \"$LOOP_CWD_FILE\"' EXIT\n" + command))
.directory(cwd.toFile()).redirectErrorStream(true)
.redirectInput(ProcessBuilder.Redirect.from(new File("/dev/null")));
Map<String, String> env = pb.environment();
env.keySet().removeIf(k -> k.matches("(?i).*(TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY).*|AWS_.*"));
env.keySet().removeAll(List.of("JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS")); // agent's JVM flags
env.putAll(Map.of("LOOP_CWD_FILE", cwdFile.toString(), "GIT_TERMINAL_PROMPT", "0",
"PAGER", "cat", "GIT_PAGER", "cat", "TERM", "dumb", "NO_COLOR", "1"));
Process p = pb.start();
ByteArrayOutputStream out = new ByteArrayOutputStream();
Thread reader = Thread.ofVirtual().start(() -> { try { p.getInputStream().transferTo(out); } catch (Exception e) { } });
boolean done = p.waitFor(timeoutMs, TimeUnit.MILLISECONDS);
if (!done) new ProcessBuilder("kill", "-KILL", "--", "-" + p.pid()).start().waitFor();
int code = p.waitFor();
reader.join();
Path next = Path.of(Files.readString(cwdFile).strip());
if (done && next.startsWith(workspace) && Files.isDirectory(next)) cwd = next;
String text = truncate(out.toString(StandardCharsets.UTF_8).strip());
String status = done ? "Exit code: " + code : "Timed out after " + timeoutMs + " ms; process group killed.";
String where = "cwd: /" + workspace.relativize(cwd).toString().replace('\\', '/');
String result = status + "\n" + where + "\n" + (text.isEmpty() ? "(no output)" : text);
if (!done || code != 0) throw new CommandFailed(result); // reported as an error result
return result;
}
private String truncate(String s) {
if (s.length() <= maxChars) return s;
int half = maxChars / 2;
return s.substring(0, half) + "\n[... " + (s.length() - 2 * half) + " characters omitted ...]\n" + s.substring(s.length() - half);
}
}
// The scripted model: one canned assistant message per step.
static AssistantMessage call(String id, String command) {
return new AssistantMessage(null, List.of(new ToolCall(id, "shell", JSON.createObjectNode().put("command", command).toString())));
}
public static void main(String[] args) throws Exception {
Path ws = Files.createTempDirectory("loop-ws");
ShellTool shell = new ShellTool(ws, 20_000, 2_000);
Map<String, Tool> tools = Map.of(shell.name(), shell);
List<AssistantMessage> script = List.of(
call("call_1", """
mkdir -p src && cat > src/Hello.java <<'EOF'
public class Hello {
public static void main(String[] args) {
System.out.println("sum = " + add(2, 3))
}
static int add(int a, int b) { return a + b; }
}
EOF
cd src && javac Hello.java"""),
call("call_2", """
cat > Hello.java <<'EOF'
public class Hello {
public static void main(String[] args) {
System.out.println("sum = " + add(2, 3));
}
static int add(int a, int b) { return a + b; }
}
EOF
javac Hello.java && java Hello"""),
new AssistantMessage("Hello.java compiles and prints sum = 5.", List.of()));
List<Message> history = new ArrayList<>(List.of(new UserMessage("Write Hello.java in src that prints 2 + 3, compile it, and run it.")));
for (AssistantMessage a : script) { // a real agent sends `history` to the model here
history.add(a);
if (a.toolCalls().isEmpty()) { System.out.println("assistant: " + a.text()); break; }
for (ToolCall c : a.toolCalls()) {
String cmd = JSON.readTree(c.arguments()).get("command").asText();
System.out.println("tool call " + c.id() + ": shell " + cmd.lines().reduce((x, y) -> y).orElse(""));
ToolMessage r = execute(tools, c);
history.add(r);
System.out.println("result (isError=" + r.isError() + "):\n" + r.content().indent(2).stripTrailing());
}
}
System.out.println("messages in history: " + history.size());
try (Stream<Path> w = Files.walk(ws)) { w.sorted(Comparator.reverseOrder()).forEach(f -> f.toFile().delete()); }
Files.delete(shell.cwdFile);
}
}tool call call_1: shell cd src && javac Hello.java
result (isError=true):
CommandFailed: Exit code: 1
cwd: /src
Hello.java:3: error: ';' expected
System.out.println("sum = " + add(2, 3))
^
1 error
tool call call_2: shell javac Hello.java && java Hello
result (isError=false):
Exit code: 0
cwd: /src
sum = 5
assistant: Hello.java compiles and prints sum = 5.
messages in history: 6The session has three model turns.
tool call call_1shows the last line of the first command,cd src && javac Hello.java. The full command createssrc, writesHello.javawith a heredoc (<<'EOF'; the quotes stop bash from expanding$inside the file), changes intosrc, and compiles.- The result is an error result.
runthrewCommandFailedbecause the exit code was not 0, and the restated Project 21executemethod turned the exception into aToolMessagewithisError=trueand the contentCommandFailed:followed by the exception's message. The message is the full result text:Exit code: 1, the working directory/src(thecdsucceeded beforejavacfailed, and the EXIT trap recorded it), and javac's messageHello.java:3: error: ';' expected. javac also prints the source line and a caret under the position where the semicolon is missing, and1 erroris javac's count. This is the text a real model would read to find the fix. tool call call_2rewritesHello.javawith the semicolon, then runsjavac Hello.java && java Hello. It uses a relative path because the working directory carried over from the first call.- The second result is
Exit code: 0, still in/src, with the program's outputsum = 5, andisError=false. assistant:is the scripted final message with no tool calls, which ends the loop.messages in history: 6counts the user message, three assistant messages, and two tool messages. A real agent sends this history to the model before each assistant turn.
ShellTool.run applies the earlier examples in order. The list below follows the code from top to bottom.
- It throws
IllegalArgumentExceptionfor a missing command, which the agent reports as an error result. - It starts
setsid bash -cwith the EXIT trap (Examples 1, 6, 8) in the session's directory, with/dev/nullas input (Example 10). - It removes secret-looking variables and adds non-interactive ones (Example 9).
- It also removes
JAVA_TOOL_OPTIONS,_JAVA_OPTIONS, andJDK_JAVA_OPTIONS. These carry JVM flags meant for the agent's own JVM. Everyjavaandjavacthe model runs would pick them up, and the JVM prints aPicked up ...line on standard error each time it does, which would end up in the tool output. - It drains the merged output on a virtual thread (Example 3), and on timeout it kills the whole process group (Examples 4 to 6).
- It updates the directory only after a normal exit inside the workspace (Example 8). After a SIGKILL the trap never ran, so the file holds the previous value.
- It truncates the output (Example 7) and builds the status-first result (Example 13).
- A non-zero exit code or a timeout makes it throw
CommandFailedwith that text. This is how Project 21's interface, whoserunreturns only a string, marks a result as an error (theisErrorchoice of Example 13).
The schema() method gives the model two parameters, a required command string and an optional timeout_ms integer. The description() method tells the model the three facts it needs to use the tool well: the directory persists, there is no input, and output is truncated.
The version in loop adds streaming through an event listener (Example 12), background jobs as a second tool (Example 11), a spill file named in the truncation marker, and SIGTERM before SIGKILL with a grace period. The check that decides whether a command may run at all is the subject of Permissions, Sandboxing, and Prompt Injection.
Interview points
- A shell tool starts
bash -c <command>because pipes, redirection,&&, and globs are shell syntax, whileProcessBuilderstarts one program with literal arguments. bash -lcloads the user's profile and itsPATH, costs startup time on every call, and can export removed secrets again.bash -cstarts with exactly the environment the agent builds.- Every pipe the child writes to needs a reader from the start. A full 64 KiB pipe blocks the child, and then
waitFor()never returns. Merging stderr into stdout keeps the order of lines and needs only one reader. destroy()sends SIGTERM, which a program can catch or ignore, anddestroyForcibly()sends SIGKILL, which it cannot. Java reports death by signal N as exit code 128 + N, for example 143 for SIGTERM and 137 for SIGKILL.- Killing the
Processkills one process.ProcessHandle.descendants()finds the tree, but orphaned and daemonized processes leave it. A process group fromsetsidpluskill -- -pgidreaches them. Only containers or cgroups catch processes that callsetsid()themselves. - Truncation keeps a head and a tail because errors and summaries are at the end. It needs both a line limit and a character limit, a marker with the omitted count, and ideally a saved copy of the full log.
- Working-directory persistence can use an EXIT trap that records
pwd -P, a persistent shell with end markers, or an in-process interpreter (Crush). Each approach keeps a different amount of state. - Standard input should be
/dev/nulland the process should have no controlling terminal (setsid), so prompts fail fast instead of hanging.GIT_TERMINAL_PROMPT=0andPAGER=catstop the common interactive cases. - Background jobs write to log files, so no thread has to drain a pipe for their whole life. They run in their own process group and are stopped when the session ends.
- The tool result states the exit code, the timeout, and truncation explicitly, and sets an error flag. Exit code 1 from
grepshows why the exact code matters more than a word like "success".
Summary
ProcessBuilder(List.of("bash", "-c", command))runs a command line.directory(...)sets the working directory, andenvironment()is a per-child copy of the JVM's environment.redirectErrorStream(true)merges stderr into stdout in the order the child wrote the lines.- Exit code 127 means command not found, 2 from bash means a syntax error, and 128 + N means death by signal N.
- A Linux pipe holds 16 pages (65,536 bytes with 4 KiB pages) by default. A child that fills an unread pipe blocks until someone reads.
waitFor(timeout, unit)returnsfalseon timeout.destroy()sends SIGTERM anddestroyForcibly()sends SIGKILL.- A process that ignores SIGTERM survives
destroy(), and ignored signals are inherited by the programs a shell starts. - Killing a parent does not kill its children. The orphans are reparented and leave
descendants(). setsidputs a command in a new process group whose id is its pid, andkill -KILL -- -<pgid>signals every member, including processes that left the tree.- Truncation in Crush keeps 15,000 characters from each end of a 30,000-character limit. Pi keeps the last 2,000 lines or 50 KB.
- An EXIT trap runs even after
exit Nand does not change the exit status, so it can record the final directory for the next call. - Removing secrets by name is a denylist. An allowlist of variables is stricter, and it breaks tools that need a variable that was forgotten.
/dev/nullas standard input makesreadreturn status 1 at once. Programs such assudoread/dev/tty, which fails undersetsid.- Background jobs log to files through
redirectOutput(File)and are stopped by process group. - Project 23's
ShellToolcombines all of these behind theToolinterface. A scripted session exercises it by compiling a Java file, failing, fixing the file, and running it.
