Symptom
The gjc process dies outright in the middle of a long session. The in-flight turn is lost, and recovery depends entirely on an external supervisor bringing it back with --resume.
It happened twice in my session today (~/.gjc/agent/gjc-crash.log):
2026-08-04T04:33:19.171Z pid=73527 [Uncaught Exception] Error: EIO: i/o error, write
2026-08-04T04:34:02.123Z pid=75845 [Uncaught Exception] Error: EIO: i/o error, write
Error: EIO: i/o error, write
at write (unknown)
at writeFast (internal:fs/streams:345:38)
at N (/$bunfs/root/gjc:101:382)
at h_9 (/$bunfs/root/gjc:25788:89)
...
at processTicksAndRejections (native:7:39)
Right after the second crash the supervisor restarted the session as gjc-patched --resume 019faef8-bc59-....
It is not OOM
I suspected memory first. It is not.
vm.swapusage: used = 0.00M — no swap in use at all
- zero jetsam (macOS memory-pressure kill) events
- no crash report in
~/Library/Logs/DiagnosticReports/ for today, which means the process was not killed by a signal — it exited through the normal fatal-error path
So this is an unhandled exception, not resource exhaustion.
Root cause
EIO on a write is a transient, environmental failure: the terminal being written to went away. These processes run under SCREEN -dmS inside login -pflq, reached over SSH. When that pty is torn down, the next stdout write fails with EIO.
The problem is that nothing catches it, so it reaches the fatal handler:
packages/utils/src/postmortem.ts:370
.on("uncaughtException", async error => {
await handleFatalError("Uncaught Exception", error, Reason.UNCAUGHT_EXCEPTION);
})
handleFatalError terminates the process.
The repo already knows the answer — it is just wired to stderr only
There is a helper in this codebase for exactly this condition:
packages/utils/src/safe-stderr.ts
const CLOSED_STDERR_ERROR_CODES = new Set(["EIO", "EPIPE", "EBADF"]);
export function safeStderrWrite(message: string): void {
if (!process.stderr.writable) return;
try {
fs.writeSync(process.stderr.fd, message);
} catch (error) {
if (isClosedStderrWriteError(error)) return; // ← EIO treated as benign
throw error;
}
}
An EIO on a stderr write is correctly read as "the stream is closed" and swallowed. The identical condition on stdout kills the process. The judgement already exists; it is applied on one side only.
Suggested fix
- Apply the same guard to the stdout write path — a
safeStdoutWrite, or a shared helper that takes the stream.
- At minimum, stop
handleFatalError from treating closed-stream error codes (EIO / EPIPE / EBADF) as fatal. A terminal going away and the agent being broken are different events, and only the second one should end the process.
Why this is worse than it looks
The trigger is routine. I reach this host over SSH on a Tailscale tailnet, and the link itself is healthy — a direct path (no DERP relay), 13 ms, 0% packet loss over a sampled ping. Sessions still drop occasionally, as SSH sessions do.
A dropped terminal should cost a reconnect. Right now it costs the whole agent process and whatever work was in flight.
Wider picture from the same log
gjc-crash.log has accumulated 81 Uncaught Exception entries. Two of today's are the EIO above, and a number of the others also look like things that should not be fatal:
2 Error: fixture: closed fd write EPIPE ← same family
2 Error: EIO: i/o error, write
2 Error: Anthropic credential is unavailable
1 Error: timeout
1 AgentBusyError: Agent is already processing...
1 Error: Settings not initialized...
By date they cluster — 22 on 07-25 and 40 on 07-29 — which suggests a condition that repeats rather than a one-off.
The closed fd write EPIPE entries share the same root and should be covered by fix (2) as well.
Symptom
The
gjcprocess dies outright in the middle of a long session. The in-flight turn is lost, and recovery depends entirely on an external supervisor bringing it back with--resume.It happened twice in my session today (
~/.gjc/agent/gjc-crash.log):Right after the second crash the supervisor restarted the session as
gjc-patched --resume 019faef8-bc59-....It is not OOM
I suspected memory first. It is not.
vm.swapusage: used = 0.00M— no swap in use at all~/Library/Logs/DiagnosticReports/for today, which means the process was not killed by a signal — it exited through the normal fatal-error pathSo this is an unhandled exception, not resource exhaustion.
Root cause
EIOon a write is a transient, environmental failure: the terminal being written to went away. These processes run underSCREEN -dmSinsidelogin -pflq, reached over SSH. When that pty is torn down, the next stdout write fails withEIO.The problem is that nothing catches it, so it reaches the fatal handler:
packages/utils/src/postmortem.ts:370handleFatalErrorterminates the process.The repo already knows the answer — it is just wired to stderr only
There is a helper in this codebase for exactly this condition:
packages/utils/src/safe-stderr.tsAn
EIOon a stderr write is correctly read as "the stream is closed" and swallowed. The identical condition on stdout kills the process. The judgement already exists; it is applied on one side only.Suggested fix
safeStdoutWrite, or a shared helper that takes the stream.handleFatalErrorfrom treating closed-stream error codes (EIO/EPIPE/EBADF) as fatal. A terminal going away and the agent being broken are different events, and only the second one should end the process.Why this is worse than it looks
The trigger is routine. I reach this host over SSH on a Tailscale tailnet, and the link itself is healthy — a direct path (no DERP relay), 13 ms, 0% packet loss over a sampled ping. Sessions still drop occasionally, as SSH sessions do.
A dropped terminal should cost a reconnect. Right now it costs the whole agent process and whatever work was in flight.
Wider picture from the same log
gjc-crash.loghas accumulated 81Uncaught Exceptionentries. Two of today's are theEIOabove, and a number of the others also look like things that should not be fatal:By date they cluster — 22 on 07-25 and 40 on 07-29 — which suggests a condition that repeats rather than a one-off.
The
closed fd write EPIPEentries share the same root and should be covered by fix (2) as well.