When AI Coding Agents Fight Over Your CPU (And What I Did About It)
The missing layer in monorepos: coordinating AI agents so validation is fast, serialized, and reliable.
I’m the CTO of diiirect.com — a remote workforce platform and talent recruiting engine. Our product lives and dies by iteration speed: shipping the web app, the API, internal tooling, and shared packages fast, without turning the dev machine into a space heater.
We also lean hard into AI coding agents. I regularly run multiple Claude Code sessions against the same repo: one fixing a UI regression, another refactoring a shared package, another touching backend workflows. It’s insanely productive… until all the agents decide to “verify” at the same time.
If you use Claude Code / Cursor / Copilot / Codex on anything bigger than a toy repo, you’ll run into the same wall.
Why this problem is becoming the new normal
Modern JS/TS teams are converging on a familiar setup:
Monorepo
pnpm
Turborepo (or Nx)
multiple apps + shared packages
This is becoming the default because it’s the cleanest way to:
share code safely (types, UI, utilities)
ship multiple surfaces (web, API, workers, desktop/mobile)
keep CI sane with caching and deterministic builds
But it also creates a new failure mode: the repo is shared, while your dev tooling isn’t coordinated.
Humans coordinate implicitly:
“You run type-check, I’ll wait.”
“Don’t start a heavy build while I’m compiling.”
AI agents don’t have that social layer. They just do the reasonable thing locally:
make changes → verify → repeat
Multiply that by 2–4 agents and “reasonable” turns into resource warfare.
The problem: duplicated heavy work
My stack is a Turborepo + pnpm TypeScript monorepo.
After an agent edits code, it naturally verifies with:
pnpm type-checkwhich runs
tsc --noEmit
That’s correct behavior. The issue is cost: each tsc --noEmit spins up a full TypeScript compiler pass and can easily consume ~800MB+ RAM plus a chunk of CPU.
Now imagine 3 AI sessions finishing around the same time:
Session 1:
pnpm type-check→tsc --noEmit→ ~800MB RAM, CPU pinnedSession 2:
pnpm type-check→tsc --noEmit→ ~800MB RAM, CPU pinnedSession 3:
pnpm type-check→tsc --noEmit→ ~800MB RAM, CPU pinned
Peak: ~2.4GB RAM spike + CPU thrashing
Result: everything slows down, sometimes OOM kills, failed builds, wasted time
And here’s the key:
Running the same type-check 3 times concurrently is completely pointless. They’re checking the same repo state. Runs #2 and #3 produce the same output as run #1.
What you actually want is:
one real run
everyone else waits
then they get a Turborepo cache hit and return instantly
In other words: serialization + caching. Make the second run free.
Solution Part 1: a concurrency guard in front of Turborepo
I wrote a bash script, turbo-guard.sh (~270 lines), that sits between all my pnpm scripts and Turborepo.
It has two layers of defense:
Process-level detection (catches “bypass” scenarios)
Atomic file locking (serializes identical tasks)
Layer 1 — Process-level detection (the hard safety net)
AI agents are creative. Even if you tell them “always run pnpm type-check”, they’ll sometimes run:
npx tsc --noEmitpnpm turbo buildpnpm --filter app lint
So the guard doesn’t just trust who called it — it looks at what’s actually running.
It scans processes scoped to this repo and checks for heavy commands:
tsc --noEmitnext build
If it finds any, it waits for them to finish.
This makes the system robust even when agents bypass the wrapper.
Layer 2 — Atomic file lock (serialize identical work)
For concurrent invocations of the guard itself, it uses a POSIX-portable atomic lock via mkdir.
The lock name is derived from the command arguments:
turbo-guard.sh lintandturbo-guard.sh buildget different locks → they can run in paralleltwo
turbo-guard.sh type-checkcalls get the same lock → serialized
Same task = same lock = no redundant work.
Stale lock recovery (the part that matters in practice)
This script exists because processes get killed unexpectedly:
OOM
SIGKILL
crashes
laptop sleep/wake
etc.
When that happens, you can be left with a stale lock (lock directory exists, but no real owner).
So the guard:
reads the PID from the lock
checks it’s alive and still a relevant process (node/tsc/turbo/pnpm)
cleans up stale locks and safely reacquires (including race handling)
Without PID validation, recycled PIDs can deadlock you waiting on an unrelated process.
Integration: make it invisible to the agents
The trick is to make agents do the right thing without knowing anything.
In package.json, route heavy tasks through the guard:
{
"build": "./scripts/turbo-guard.sh build",
"lint": "./scripts/turbo-guard.sh lint",
"type-check": "./scripts/turbo-guard.sh type-check",
"validate": "./scripts/turbo-guard.sh lint type-check"
}
Then in your agent instructions (e.g. CLAUDE.md), be explicit:
Always use root scripts:
pnpm validate,pnpm type-check,pnpm lint,pnpm buildNever run Turbo directly:
pnpm turbo ...,npx tsc ..., etc.
That’s the “soft” coordination layer (instructions).
The process scan + locks are the “hard” layer (enforcement).
What it looks like in practice
Without the guard (3 sessions type-checking concurrently)
Session 1: ~18s, ~820MB RAM
Session 2: ~22s, ~810MB RAM (slower due to contention)
Session 3: ~25s, ~830MB RAM (even slower, CPU thrashing)
Total wall time: ~25s
Peak RAM: ~2.4GB
CPU: pegged and thrashing
With the guard
Session 1 acquires lock → ~18s, ~820MB RAM
Session 2 waits → then cache hit → <1s
Session 3 waits → then cache hit → <1s
Total wall time: ~19s
Peak RAM: ~820MB
CPU: normal
Same correctness, one-third the resource hit. The second and third checks still happen — they’re just effectively free because the cache is warm.
Solution Part 2: keep TypeScript warm with a watch agent (and make it agent-friendly)
The concurrency guard fixes the resource problem. But there’s a second problem: speed.
A cold tsc --noEmit often takes 15–25 seconds. In an edit → check → fix → check loop, that’s brutal. Over 10 iterations you can burn minutes just waiting.
TypeScript’s --watch solves this. After the initial compile, incremental rechecks often take ~2–3 seconds because tsc keeps program state in memory.
The catch: tsc --watch streams human-readable text to stdout. It’s made for a developer staring at a terminal, not for an AI agent that needs structured results.
So I built tsc-watch-agent.sh (~450 lines):
runs
tsc --watchin the backgroundparses output
writes structured JSON to a status file
exposes commands agents can call (
start,wait,errors,status,stop)
The agent workflow
pnpm tsc:watch:start
pnpm tsc:watch:wait # blocks until current check completes, returns JSON
pnpm tsc:watch:errors # returns just the errors array
pnpm tsc:watch:stop
wait is the key interface:
it blocks until tsc finishes the current cycle
then returns structured JSON
no parsing terminal output, no guessing when compilation is done
Architecture (3 background processes)
The script spawns:
tsc process —
tsc --watch --noEmit --preserveWatchOutput
Usesexecso the tracked PID is the real node/tsc process (important on macOS).parser — reads from a FIFO line-by-line, extracts errors with regex, writes
status.jsonafter each cycle.cleanup watcher — if tsc dies unexpectedly, it unblocks the parser so it doesn’t hang forever on a dead pipe.
This is the difference between “cool demo” and “works for months without wedging.”
How the two scripts coexist cleanly
One easy mistake:
If the guard’s process scan detects the watch process, it will wait forever because watch never exits.
So the guard excludes watch mode explicitly:
it looks for
tsc --noEmitbut ignores anything containing
--watch
The watch agent also uses a separate lock namespace, so it doesn’t collide with guard locks.
The speed difference is the point
Without the watcher (cold checks)
Five iterations:
18s
17s
19s
18s
17s
Total: ~89 seconds spent type-checking
With the watcher
initial compile: ~12s (one-time)
then incremental checks: ~2–3s each
Total (including initial): ~24 seconds
That’s a ~70% reduction in time spent waiting. Over longer sessions, it changes what’s possible.
The full picture
You end up with two “lanes”:
Lane A: one-off validation (serialized + cached + safe)
Use:
pnpm validatepnpm type-checkpnpm build
These go through turbo-guard.sh.
Lane B: iterative debugging (fast incremental feedback)
Use:
pnpm tsc:watch:*
These go through the watch agent and return structured JSON quickly.
Agents don’t need to understand the machinery. They just use the commands they’re told to use, and the infrastructure coordinates the rest.
What I’d do differently
Honestly: not much. A few things that ended up being essential:
mkdirlocks are underrated. Atomic, portable, zero dependencies.PID validation matters. “PID exists” isn’t enough because PIDs get recycled.
The FIFO + cleanup watcher is necessary if you want the watch agent to survive real-world failures.
Instructions matter. Soft coordination (
CLAUDE.md) plus hard enforcement (process scan + locks) is what makes this reliable with AI agents.
If you’re hitting this too
The core insight is simple:
Serialize identical expensive work
Cache results so the second run is free
Keep the compiler warm for iterative loops
Expose results as structured output for agents
It doesn’t have to be Turborepo. Nx, Bazel, plain caching — same idea.
Also: if you publish tooling like this, avoid embedding secrets or internal endpoints in scripts. The approach here is intentionally local-only and dependency-free.
The scripts (sanitized, production-ready)
Below are both scripts in full. They’re dependency-free beyond standard POSIX tooling and are designed to work on macOS and Linux.
Note: Paths under
/tmpare intentionally generic to avoid tying tooling artifacts to a specific company name.
scripts/turbo-guard.sh
#!/usr/bin/env bash
#
# turbo-guard.sh — Serializes concurrent Turborepo tasks across processes.
#
# PROBLEM:
# Multiple AI agent sessions (or terminals) may independently run
# build/lint/type-check at the same time. Heavy tasks like `tsc --noEmit`
# can consume ~800MB+ RAM each — running 3-4 concurrently causes OOM kills
# and wastes CPU time thrashing. Worse, instances may bypass the guard by
# running `npx tsc`, `pnpm turbo build`, or `pnpm --filter app lint` directly.
#
# SOLUTION (two layers):
# Layer 1 — Process-level: Before starting, check for ANY running
# tsc/next-build processes (regardless of how they were started).
# If found, wait for them to finish. This catches bypass scenarios.
#
# Layer 2 — File lock: Uses mkdir(2) as an atomic POSIX lock so that
# concurrent invocations of THIS script are serialized. The second
# caller waits for the first to finish, then runs the same command.
# Turborepo caches results, so the second run is instant (cache hit).
#
# USAGE:
# ./scripts/turbo-guard.sh <turbo-args...>
# ./scripts/turbo-guard.sh lint type-check --filter=app
# ./scripts/turbo-guard.sh build --filter=app
# ./scripts/turbo-guard.sh --force lint # Break stale lock and run
#
# LOCK BEHAVIOR:
# - Lock name derived from args, so different tasks can run in parallel
# - Same task from multiple processes is serialized
# - Stale locks (from killed processes) are auto-detected and cleaned
# - Locks stored in /tmp, cleared on reboot
#
# EXIT CODES:
# Passes through the exit code from `pnpm turbo`.
#
# --------------------------------------------------------------
set -euo pipefail
# -- Parse --force flag (must be first arg) --------------------
FORCE=false
if [ "${1:-}" = "--force" ]; then
FORCE=true
shift
fi
if [ $# -eq 0 ]; then
echo "Usage: turbo-guard.sh [--force] <turbo-args...>"
echo ""
echo "Examples:"
echo " turbo-guard.sh lint type-check --filter=app"
echo " turbo-guard.sh build --filter=app"
echo " turbo-guard.sh --force type-check # Break stale lock"
exit 1
fi
# -- Configuration ---------------------------------------------
MAX_WAIT=600 # Max seconds to wait for another process (10 min)
POLL_INTERVAL=3 # Seconds between liveness checks
LOCK_BASE="/tmp/turbo-guard"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
# -- Derive lock name from turbo args --------------------------
# Normalize args into a filesystem-safe string. Different arg combos get
# different locks, so `lint` and `build` can run in parallel.
LOCK_NAME=$(printf '%s' "$*" | tr ' =/' '-' | tr -cd 'a-zA-Z0-9-')
LOCKDIR="${LOCK_BASE}-${LOCK_NAME}.lock"
PIDFILE="${LOCKDIR}/pid"
# -- Helper functions ------------------------------------------
cleanup() {
rm -f "$PIDFILE" 2>/dev/null
rmdir "$LOCKDIR" 2>/dev/null || true
}
# Check if a PID is alive AND is a node/turbo/pnpm process (not a recycled PID).
is_turbo_alive() {
local pid="$1"
if ! kill -0 "$pid" 2>/dev/null; then
return 1 # Process doesn't exist
fi
local comm
comm=$(ps -p "$pid" -o comm= 2>/dev/null || echo "")
case "$comm" in
*node*|*turbo*|*pnpm*|*npm*|*tsc*) return 0 ;;
*) return 1 ;;
esac
}
# Wait for a specific PID to finish, with timeout.
wait_for_pid() {
local other_pid="$1"
local label="${2:-process}"
local waited=0
while kill -0 "$other_pid" 2>/dev/null; do
sleep "$POLL_INTERVAL"
waited=$((waited + POLL_INTERVAL))
if [ $waited -ge $MAX_WAIT ]; then
echo "turbo-guard: timed out after ${MAX_WAIT}s waiting for $label (PID $other_pid)."
echo "turbo-guard: run with --force to skip waiting, or kill PID $other_pid."
exit 1
fi
done
return "$waited"
}
# =============================================================
# LAYER 1: Process-level guard
# =============================================================
wait_for_heavy_processes() {
if [ "$FORCE" = true ]; then
return 0
fi
local found_any=false
local waited_total=0
while true; do
local heavy_pids=""
heavy_pids=$(
ps -eo pid,args 2>/dev/null \
| grep "$PROJECT_DIR" \
| grep -E '(tsc --noEmit|next build)' \
| grep -v -- '--watch' \
| grep -v "grep" \
| grep -v "turbo-guard" \
| awk '{print $1}' \
| tr '\n' ' '
) || true
heavy_pids=$(echo "$heavy_pids" | xargs 2>/dev/null || echo "")
if [ -z "$heavy_pids" ]; then
if [ "$found_any" = true ]; then
echo "turbo-guard: previous heavy process(es) finished (waited ${waited_total}s)."
fi
return 0
fi
if [ "$found_any" = false ]; then
found_any=true
echo "turbo-guard: heavy process(es) already running: $heavy_pids"
echo "turbo-guard: waiting for them to finish before starting..."
fi
sleep "$POLL_INTERVAL"
waited_total=$((waited_total + POLL_INTERVAL))
if [ $waited_total -ge $MAX_WAIT ]; then
echo "turbo-guard: timed out after ${MAX_WAIT}s waiting for heavy processes."
echo "turbo-guard: still running: $heavy_pids"
echo "turbo-guard: run with --force to skip waiting."
exit 1
fi
done
}
wait_for_heavy_processes
# =============================================================
# LAYER 2: File-lock guard
# =============================================================
if [ "$FORCE" = true ] && [ -d "$LOCKDIR" ]; then
echo "turbo-guard: --force used, removing existing lock."
rm -rf "$LOCKDIR"
fi
if mkdir "$LOCKDIR" 2>/dev/null; then
trap cleanup EXIT INT TERM HUP
echo $$ > "$PIDFILE"
set +e
pnpm turbo "$@"
TURBO_EXIT=$?
set -e
exit $TURBO_EXIT
fi
OTHER_PID=""
if [ -f "$PIDFILE" ]; then
OTHER_PID=$(cat "$PIDFILE" 2>/dev/null || echo "")
fi
if [ -n "$OTHER_PID" ] && is_turbo_alive "$OTHER_PID"; then
echo "turbo-guard: task already running (PID $OTHER_PID). Waiting..."
wait_for_pid "$OTHER_PID" "lock holder"
echo "turbo-guard: previous run finished. Running with turbo cache..."
pnpm turbo "$@"
exit $?
fi
rm -rf "$LOCKDIR"
if mkdir "$LOCKDIR" 2>/dev/null; then
trap cleanup EXIT INT TERM HUP
echo $$ > "$PIDFILE"
set +e
pnpm turbo "$@"
TURBO_EXIT=$?
set -e
exit $TURBO_EXIT
fi
echo "turbo-guard: another process acquired the lock first. Waiting..."
sleep 1
OTHER_PID=""
if [ -f "$PIDFILE" ]; then
OTHER_PID=$(cat "$PIDFILE" 2>/dev/null || echo "")
fi
if [ -n "$OTHER_PID" ] && is_turbo_alive "$OTHER_PID"; then
wait_for_pid "$OTHER_PID" "lock holder"
echo "turbo-guard: previous run finished. Running with turbo cache..."
fi
pnpm turbo "$@"
exit $?
scripts/tsc-watch-agent.sh
#!/usr/bin/env bash
#
# tsc-watch-agent.sh — Structured tsc --watch wrapper for AI agents.
#
# Runs tsc --watch in background and writes structured JSON that agents
# can read instantly after each recompile (~2-3s incremental vs ~15-25s cold).
#
# COMMANDS:
# start [app] - Start watcher (default: app1). Runs in background.
# stop - Stop watcher and clean up.
# status - Print JSON status to stdout.
# errors - Print errors array to stdout.
# wait - Block until next check completes (polls every 0.3s, 120s timeout).
#
# OUTPUT: /tmp/tsc-watch-agent/status.json
#
# AGENT WORKFLOW:
# pnpm tsc:watch:start
# pnpm tsc:watch:wait
# pnpm tsc:watch:errors
# pnpm tsc:watch:stop
#
# PROCESS ARCHITECTURE:
# cmd_start spawns 3 background processes:
# 1. tsc process — `exec` replaces subshell so PID IS the real node/tsc
# 2. parser — reads FIFO line-by-line, writes status.json
# 3. cleanup — waits for tsc to die, then unblocks parser
#
# --------------------------------------------------------------
set -euo pipefail
# -- Configuration ---------------------------------------------
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
OUT_DIR="/tmp/tsc-watch-agent"
STATUS_FILE="${OUT_DIR}/status.json"
RAW_LOG="${OUT_DIR}/raw.log"
TSC_PID_FILE="${OUT_DIR}/tsc.pid"
LOCKDIR="${OUT_DIR}/lock"
FIFO="${OUT_DIR}/tsc.fifo"
WAIT_POLL=0.3
WAIT_TIMEOUT=120
# -- App definitions -------------------------------------------
# Replace these app mappings with your actual monorepo apps if desired.
# The defaults are intentionally generic.
get_app_config() {
local app="${1:-app1}"
case "$app" in
app1)
APP_DIR="${PROJECT_DIR}/apps/app1"
TSC_ARGS="--noEmit --watch --preserveWatchOutput"
NODE_OPTS="--max-old-space-size=8192"
;;
app2)
APP_DIR="${PROJECT_DIR}/apps/app2"
TSC_ARGS="--noEmit --watch --preserveWatchOutput"
NODE_OPTS=""
;;
*)
echo "{\"error\": \"Unknown app: ${app}. Valid: app1, app2\"}" >&2
exit 1
;;
esac
}
# -- JSON helpers ----------------------------------------------
write_status() {
local status="$1" app="$2" error_count="$3" errors_json="$4"
local check_at="$5" duration="$6" pid="$7"
local tmp="${STATUS_FILE}.tmp"
cat > "$tmp" <<ENDJSON
{
"status": "${status}",
"app": "${app}",
"errorCount": ${error_count},
"errors": ${errors_json},
"lastCheckAt": "${check_at}",
"lastCheckDuration": "${duration}",
"pid": ${pid}
}
ENDJSON
mv -f "$tmp" "$STATUS_FILE"
}
# -- Lock management -------------------------------------------
acquire_lock() {
if mkdir "$LOCKDIR" 2>/dev/null; then
echo $$ > "${LOCKDIR}/pid"
return 0
fi
local other_pid=""
if [ -f "$TSC_PID_FILE" ]; then
other_pid=$(cat "$TSC_PID_FILE" 2>/dev/null || echo "")
fi
if [ -z "$other_pid" ] && [ -f "${LOCKDIR}/pid" ]; then
other_pid=$(cat "${LOCKDIR}/pid" 2>/dev/null || echo "")
fi
if [ -n "$other_pid" ] && kill -0 "$other_pid" 2>/dev/null; then
echo "{\"error\": \"Watcher already running (PID ${other_pid}). Use 'stop' first.\"}"
exit 1
fi
rm -rf "$LOCKDIR"
if mkdir "$LOCKDIR" 2>/dev/null; then
echo $$ > "${LOCKDIR}/pid"
return 0
fi
echo "{\"error\": \"Failed to acquire lock.\"}"
exit 1
}
release_lock() {
rm -f "${LOCKDIR}/pid" 2>/dev/null
rmdir "$LOCKDIR" 2>/dev/null || true
}
# -- Process management ----------------------------------------
kill_tree() {
local pid="$1"
if [ -z "$pid" ]; then return; fi
local children
children=$(pgrep -P "$pid" 2>/dev/null || echo "")
kill "$pid" 2>/dev/null || true
local child
for child in $children; do
kill_tree "$child"
done
}
kill_tree_wait() {
local pid="$1"
kill_tree "$pid"
local waited=0
while kill -0 "$pid" 2>/dev/null && [ $waited -lt 5 ]; do
sleep 0.5
waited=$((waited + 1))
done
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
fi
}
# -- Commands ---------------------------------------------------
cmd_start() {
local app="${1:-app1}"
get_app_config "$app"
if [ ! -d "$APP_DIR" ]; then
echo "{\"error\": \"App directory not found: ${APP_DIR}\"}"
exit 1
fi
mkdir -p "$OUT_DIR"
acquire_lock
local tsc_bin="${APP_DIR}/node_modules/.bin/tsc"
if [ ! -f "$tsc_bin" ]; then
tsc_bin="$(command -v tsc 2>/dev/null || echo "")"
if [ -z "$tsc_bin" ]; then
release_lock
echo "{\"error\": \"tsc not found. Run pnpm install first.\"}"
exit 1
fi
fi
> "$RAW_LOG"
rm -f "$FIFO"
mkfifo "$FIFO"
write_status "starting" "$app" 0 "[]" "" "" "0"
# Parser: reads tsc output from FIFO, writes status.json
(
local errors_buf="" error_count=0 tsc_pid="0"
local check_start
check_start=$(date +%s)
while IFS= read -r line; do
if [ "$tsc_pid" = "0" ] && [ -f "$TSC_PID_FILE" ]; then
tsc_pid=$(cat "$TSC_PID_FILE" 2>/dev/null || echo "0")
fi
echo "$line" >> "$RAW_LOG"
if echo "$line" | grep -qE '(Starting compilation|Starting incremental compilation)'; then
check_start=$(date +%s)
errors_buf=""
error_count=0
write_status "checking" "$app" 0 "[]" "" "" "$tsc_pid"
continue
fi
if echo "$line" | grep -qE '^.+\([0-9]+,[0-9]+\): error TS[0-9]+:'; then
local file msg_line msg_col code message
file=$(echo "$line" | sed -E 's/^(.+)\([0-9]+,[0-9]+\): error TS[0-9]+: .+$/\1/')
msg_line=$(echo "$line" | sed -E 's/^.+\(([0-9]+),[0-9]+\): error TS[0-9]+: .+$/\1/')
msg_col=$(echo "$line" | sed -E 's/^.+\([0-9]+,([0-9]+)\): error TS[0-9]+: .+$/\1/')
code=$(echo "$line" | sed -E 's/^.+\([0-9]+,[0-9]+\): error (TS[0-9]+): .+$/\1/')
message=$(echo "$line" | sed -E 's/^.+\([0-9]+,[0-9]+\): error TS[0-9]+: (.+)$/\1/')
file="${file#"${APP_DIR}/"}"
message=$(echo "$message" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g')
local entry="{\"file\": \"${file}\", \"line\": ${msg_line}, \"col\": ${msg_col}, \"code\": \"${code}\", \"message\": \"${message}\"}"
if [ -z "$errors_buf" ]; then
errors_buf="$entry"
else
errors_buf="${errors_buf}, ${entry}"
fi
error_count=$((error_count + 1))
continue
fi
if echo "$line" | grep -qE 'Found [0-9]+ errors?\.'; then
local now duration_s final_status check_at
now=$(date +%s)
duration_s=$((now - check_start))
check_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
if [ "$error_count" -eq 0 ]; then
final_status="ready"
else
final_status="error"
fi
write_status "$final_status" "$app" "$error_count" "[${errors_buf}]" "$check_at" "${duration_s}s" "$tsc_pid"
continue
fi
done < "$FIFO"
write_status "stopped" "$app" 0 "[]" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "" "0"
release_lock
) &
local parser_pid=$!
# tsc process: exec replaces subshell so PID IS the real node/tsc process
(
cd "$APP_DIR"
if [ -n "$NODE_OPTS" ]; then
NODE_OPTIONS="$NODE_OPTS" exec "$tsc_bin" $TSC_ARGS
else
exec "$tsc_bin" $TSC_ARGS
fi
) > "$FIFO" 2>&1 &
local tsc_pid=$!
echo "$tsc_pid" > "$TSC_PID_FILE"
echo "$tsc_pid" > "${LOCKDIR}/pid"
write_status "checking" "$app" 0 "[]" "" "" "$tsc_pid"
# Cleanup watcher: when tsc dies, unblock parser
(
wait "$tsc_pid" 2>/dev/null || true
sleep 1
if kill -0 "$parser_pid" 2>/dev/null; then
echo "" > "$FIFO" 2>/dev/null || true
fi
) &
echo "{\"started\": true, \"app\": \"${app}\", \"pid\": ${tsc_pid}, \"statusFile\": \"${STATUS_FILE}\"}"
}
cmd_stop() {
if [ ! -d "$OUT_DIR" ]; then
echo "{\"stopped\": true, \"wasRunning\": false}"
return 0
fi
local tsc_pid=""
if [ -f "$TSC_PID_FILE" ]; then
tsc_pid=$(cat "$TSC_PID_FILE" 2>/dev/null || echo "")
fi
local was_running=false
if [ -n "$tsc_pid" ] && kill -0 "$tsc_pid" 2>/dev/null; then
was_running=true
kill_tree_wait "$tsc_pid"
fi
# Kill any processes stuck on the FIFO (best-effort)
if [ -p "$FIFO" ]; then
local fifo_pids=""
fifo_pids=$(lsof -t "$FIFO" 2>/dev/null || fuser "$FIFO" 2>/dev/null || echo "")
local p
for p in $fifo_pids; do
kill "$p" 2>/dev/null || true
done
fi
local app="unknown"
if [ -f "$STATUS_FILE" ]; then
app=$(grep -o '"app": "[^"]*"' "$STATUS_FILE" 2>/dev/null | head -1 | sed 's/"app": "//;s/"//' || echo "unknown")
fi
rm -f "$TSC_PID_FILE" "$FIFO" 2>/dev/null
write_status "stopped" "$app" 0 "[]" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "" "0"
release_lock
echo "{\"stopped\": true, \"wasRunning\": ${was_running}}"
}
cmd_status() {
if [ ! -f "$STATUS_FILE" ]; then
echo "{\"status\": \"stopped\", \"app\": \"\", \"errorCount\": 0, \"errors\": [], \"lastCheckAt\": \"\", \"lastCheckDuration\": \"\", \"pid\": 0}"
return 0
fi
local tsc_pid=""
if [ -f "$TSC_PID_FILE" ]; then
tsc_pid=$(cat "$TSC_PID_FILE" 2>/dev/null || echo "")
fi
if [ -n "$tsc_pid" ] && kill -0 "$tsc_pid" 2>/dev/null; then
cat "$STATUS_FILE"
else
echo "{\"status\": \"stopped\", \"app\": \"\", \"errorCount\": 0, \"errors\": [], \"lastCheckAt\": \"\", \"lastCheckDuration\": \"\", \"pid\": 0}"
fi
}
cmd_errors() {
if [ ! -f "$STATUS_FILE" ]; then
echo "[]"
return 0
fi
local content
content=$(cat "$STATUS_FILE")
if command -v python3 &>/dev/null; then
echo "$content" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('errors',[]),indent=2))"
else
echo "$content" | sed -n '/"errors":/,/\]/p' | sed '1s/.*"errors": //'
fi
}
cmd_wait() {
if [ ! -f "$STATUS_FILE" ] && [ ! -f "$TSC_PID_FILE" ]; then
echo "{\"error\": \"No watcher running. Start one with: pnpm tsc:watch:start\"}"
exit 1
fi
local start_check_at=""
if [ -f "$STATUS_FILE" ]; then
start_check_at=$(grep -o '"lastCheckAt": "[^"]*"' "$STATUS_FILE" 2>/dev/null | sed 's/"lastCheckAt": "//;s/"//' || echo "")
fi
local poll_count=0
local max_polls=400 # 120s / 0.3s
while true; do
if [ ! -f "$TSC_PID_FILE" ]; then
echo "{\"error\": \"Watcher process exited unexpectedly.\"}"
exit 1
fi
local tsc_pid
tsc_pid=$(cat "$TSC_PID_FILE" 2>/dev/null || echo "")
if [ -n "$tsc_pid" ] && ! kill -0 "$tsc_pid" 2>/dev/null; then
echo "{\"error\": \"Watcher process (PID ${tsc_pid}) is no longer running.\"}"
exit 1
fi
if [ -f "$STATUS_FILE" ]; then
local current_status current_check_at
current_status=$(grep -o '"status": "[^"]*"' "$STATUS_FILE" 2>/dev/null | head -1 | sed 's/"status": "//;s/"//' || echo "")
current_check_at=$(grep -o '"lastCheckAt": "[^"]*"' "$STATUS_FILE" 2>/dev/null | sed 's/"lastCheckAt": "//;s/"//' || echo "")
if [ "$current_status" = "ready" ] || [ "$current_status" = "error" ]; then
if [ "$current_check_at" != "$start_check_at" ] || [ -z "$start_check_at" ]; then
cat "$STATUS_FILE"
return 0
fi
fi
if [ "$current_status" = "stopped" ]; then
echo "{\"error\": \"Watcher stopped while waiting.\"}"
exit 1
fi
fi
sleep "$WAIT_POLL"
poll_count=$((poll_count + 1))
if [ "$poll_count" -ge "$max_polls" ]; then
echo "{\"error\": \"Timed out after ${WAIT_TIMEOUT}s waiting for type-check to complete.\"}"
exit 1
fi
done
}
# -- Main dispatch ---------------------------------------------
CMD="${1:-}"
shift || true
case "$CMD" in
start) cmd_start "$@" ;;
stop) cmd_stop ;;
status) cmd_status ;;
errors) cmd_errors ;;
wait) cmd_wait ;;
*)
echo "Usage: tsc-watch-agent.sh <command> [args]"
echo ""
echo "Commands:"
echo " start [app] Start tsc --watch (default: app1)"
echo " Apps: app1, app2"
echo " stop Stop the watcher"
echo " status Print JSON status"
echo " errors Print errors array"
echo " wait Block until next check completes"
exit 1
;;
esac
Final note
This isn’t really about TypeScript. It’s about a new reality:
When multiple automated actors work on the same repo, you need coordination primitives. Task runners with caching (Turborepo/Nx) solve the “do less work” problem, but you still need a thin layer to solve the “don’t do the same work at the same time” problem.
Serialize identical work. Cache it. Keep hot compilers alive for loops. And give agents structured outputs so they don’t waste cycles parsing noise.
That’s how you stop your AI assistants from fighting over your CPU—and turn them into an actual multiplier.

