{"slug": "i-stopped-running-tsc-on-every-edit-batching-a-whole-claude-code-session-into", "title": "I Stopped Running tsc on Every Edit — Batching a Whole Claude Code Session Into One Run", "summary": "A developer who built an autonomous Claude Code setup reports that batching TypeScript checks into a single run per session dramatically improved AI coding throughput. By accumulating edited file paths in a PostToolUse hook and running tsc only on Stop, the startup cost drops to 1/N, reducing stalls and boosting daily output. The developer attributes about half of their revenue increase and code quality improvement to such environment optimizations.", "body_md": "Back in college, 100,000 yen a month was my entire budget. Stacking side jobs, I got that up to 600,000 a month — then I was laid off and it went straight back to zero. Six months later, after building out an autonomous Claude Code setup, I'm at 1.2 million yen a month in revenue. Once the environment is right, the hours where \"the system is running\" outnumber the hours where you're moving your hands. This post takes apart one piece of that system.\n\nAfter a few days of touching dozens of files a day with Claude Code, it hits you: \"the TypeScript check is running every single time, and the response keeps stalling.\"\n\nIf you wire up the default hooks in the obvious way, you end up calling Prettier on PostToolUse every time a file is saved, and the TypeScript compiler on Stop. It looks conscientious. In practice it's a pile of waste.\n\n**tsc takes 2–4 seconds just to start up.**\n\nBooting the Node.js process, parsing `tsconfig.json`\n\n, loading type definition files — all of that repeats every time you touch a single file. Edit 10 files, that's 10 times. In a monorepo, 10 times per package. Days where you touch 100 files during a Claude Code session are not unusual, and launching tsc each time pushes the cumulative cost into minutes.\n\nWorse, this directly slows down \"the response to the edit.\" The moment Claude Code finishes writing a file, the hook fires, and the next operation is blocked until tsc finishes. An AI that writes code stopping for several seconds every time it writes fundamentally undermines throughput.\n\nThe problem isn't \"checking.\" It's \"checking every time.\"\n\nPlenty of people work in text editors with an IDE that runs a whole-project check on every save, and it doesn't stress them out — because the UI response and the background process are decoupled. Claude Code hooks are called synchronously. Claude stops until the hook finishes. Continuing to \"check every time\" under that constraint is like a sparring partner who freezes for five seconds after every return.\n\nThe fix is **\"check once per session, all at once.\"**\n\nJust accumulate paths on every edit, and only run the check at the moment Claude finishes all the work for that turn (Stop). Process multiple files in one launch and the startup cost drops to 1/N. Even if you touched 50 files during a session, tsc launches (per tsconfig) 1–3 times.\n\n**My rule is \"set up the environment before doing the work.\"**\n\nWhen my revenue hit zero, the first thing I did was re-read the Claude Code hook docs. I decided that zeroing out the loss per write mattered more than increasing the number of writes I could get Claude to do. In the end, two days spent on hooks determined the efficiency of the several hundred hours that followed.\n\nWire up your hooks correctly and you get an environment where the AI doesn't stall. The difference is dramatic in practice. When the cycle from firing off a request to getting the next result tightens up, you get more attempts, and the density of what you can ship in a day goes up. The increase in side-job work I could take on, and the improvement in code quality, are about half attributable to this kind of accumulated environment work.\n\nThe implementation splits across two files: `post-edit-accumulator.js`\n\n(the PostToolUse hook) and `stop-format-typecheck.js`\n\n(the Stop hook). The responsibilities are cleanly separated. The former only \"stacks paths\"; the latter only \"processes them in bulk.\"\n\n```\n[Claude がファイルを編集（Edit / Write / MultiEdit）]\n        │\n        ▼\n PostToolUse フック\n post-edit-accumulator.js\n   ├─ .ts / .tsx / .js / .jsx か判定\n   └─ appendFileSync でパスを1行追記（並行安全）\n        │\n        ▼\n /tmp/ecc-edited-{sessionId}.txt\n   （1行1パス・重複あり・セッションスコープ）\n        │\n        ▼ ── Claude がターンを終了（Stop イベント発火）──\n        │\n Stop フック\n stop-format-typecheck.js\n   ├─ ファイル読み込み → 即 unlink（2重処理防止）\n   ├─ [...new Set(...)] で重複排除\n   ├─ プロジェクトルート別にグループ化\n   │     └─ formatter 1回（biome check --write / prettier --write）\n   └─ tsconfig.json 起点でグループ化\n         └─ npx tsc --noEmit 1回（per tsconfig）\n               └─ エラーは編集ファイル関連行のみ最大10行 stderr へ\n```\n\n`post-edit-accumulator.js`\n\nhas a simple job. It gets called every time Claude Code edits a file, and just appends that path as one line to a tmp file.\n\n``` js\n// post-edit-accumulator.js L38-44\nconst JS_TS_EXT = /\\.(ts|tsx|js|jsx)$/;\n\nfunction appendPath(filePath) {\n  if (filePath && JS_TS_EXT.test(filePath)) {\n    fs.appendFileSync(getAccumFile(), filePath + '\\n', 'utf8');\n  }\n}\n```\n\nThe reason for using `appendFileSync`\n\nis **concurrency safety**. Claude Code sometimes fires multiple tool calls in parallel, which means several PostToolUse hook processes can run at the same time. `appendFileSync`\n\nis an atomic append-to-end at the OS level, so even with processes running side by side, their writes don't overwrite each other. No lock files, no mutual exclusion needed.\n\nThere's also some care in how the tmp file is named (L24-32).\n\n``` js\nfunction getAccumFile() {\n  const raw =\n    process.env.CLAUDE_SESSION_ID ||\n    crypto.createHash('sha1').update(process.cwd()).digest('hex').slice(0, 12);\n  const sessionId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);\n  return path.join(os.tmpdir(), `ecc-edited-${sessionId}.txt`);\n}\n```\n\nIf `CLAUDE_SESSION_ID`\n\nis available it uses that; otherwise it identifies the session by the first 12 characters of the SHA1 hash of `process.cwd()`\n\n. Making the filename session-specific means multiple Claude Code sessions running at once don't interfere. Even if you're working on `/Users/xxx/project-a`\n\nand `/Users/xxx/project-b`\n\nin parallel sessions, each stacks paths into its own independent tmp file.\n\nPath separators and traversal characters (`..`\n\n, `/`\n\n, etc.) are replaced with underscores via the regex `/[^a-zA-Z0-9_-]/g`\n\nbefore use. Whatever `CLAUDE_SESSION_ID`\n\nhappens to contain, it can be safely embedded in a filename.\n\nIt also absorbs the differences between tool types (L53-59).\n\n```\n// Edit / Write: single file_path\nappendPath(input.tool_input?.file_path);\n// MultiEdit: array of edits, each with its own file_path\nconst edits = input.tool_input?.edits;\nif (Array.isArray(edits)) {\n  for (const edit of edits) appendPath(edit?.file_path);\n}\n```\n\nThe Edit tool, the Write tool, and the MultiEdit tool all stack into the same tmp file. The accumulator doesn't care which tool Claude Code used to touch the file. By absorbing the tool-type differences upstream, the Stop hook only has to look at an array of paths.\n\nNot deduplicating here is deliberate. `appendFileSync`\n\nappends, so editing the same file 10 times lines up the same path 10 times. That's fine. Deduplication is the reader's job (the Stop hook). Keeping the write simple means the PostToolUse hook itself finishes in a few milliseconds. The only thing that stalls Claude's edit operation is \"the time it takes to write one line.\"\n\nWhen Claude finishes a turn, `stop-format-typecheck.js`\n\nis called. This is the main engine that actually does the work.\n\n**The first thing it does is read the tmp file and immediately delete it (L139-148).**\n\n``` js\nlet raw;\ntry {\n  raw = fs.readFileSync(accumFile, 'utf8');\n} catch {\n  return; // No accumulator — nothing edited this response\n}\ntry { fs.unlinkSync(accumFile); } catch { /* best-effort */ }\n\nconst files = parseAccumulator(raw);\n```\n\nBy calling `unlink`\n\nright after reading, the same file won't be double-processed even if the Stop hook is called twice. If the file doesn't exist at read time (a turn where no JS/TS files were touched), it just `return`\n\ns. The cost of the hook running is zero.\n\nDeduplication is a one-liner (L36-38).\n\n``` js\nfunction parseAccumulator(raw) {\n  return [...new Set(raw.split('\\n').map(l => l.trim()).filter(Boolean))];\n}\n```\n\nJust run it through a `Set`\n\n. However many times the same path was stacked, the result is one entry. A file edited 100 times is handed to `tsc`\n\nexactly once.\n\n**Budget distribution is the core of this design (L173-175).**\n\n``` js\nconst totalBatches = byProjectRoot.size + byTsConfigDir.size;\nconst perBatchMs = totalBatches > 0\n  ? Math.floor(TOTAL_BUDGET_MS / totalBatches)\n  : 60_000;\n```\n\n`TOTAL_BUDGET_MS`\n\nis `270_000`\n\n(270 seconds) (L29). Claude Code's Stop hook has a 300-second timeout, so this leaves 90 seconds for overhead and allocates 270 seconds to batch processing. With just one project, you get the whole 270 seconds. In a monorepo with 3 tsconfigs and 2 project roots, that's 5 batches, so 54 seconds per batch. Time is distributed automatically according to the batch count. More tsconfigs means less time per batch, but the total never exceeds 300 seconds.\n\n**Let's look at how grouping works (L161-169).**\n\n``` js\nconst byTsConfigDir = new Map();\nfor (const filePath of files) {\n  if (!/\\.(ts|tsx)$/.test(filePath)) continue;\n  const resolved = path.resolve(filePath);\n  if (!fs.existsSync(resolved)) continue;\n  const tsDir = findTsConfigDir(resolved);\n  if (!tsDir) continue;\n  if (!byTsConfigDir.has(tsDir)) byTsConfigDir.set(tsDir, []);\n  byTsConfigDir.get(tsDir).push(resolved);\n}\n```\n\nThe `findTsConfigDir`\n\nfunction walks up to 20 levels of parent directories from a file path looking for `tsconfig.json`\n\n(L83-88). So when you edit `packages/api/src/handlers/user.ts`\n\n, `tsc --noEmit`\n\nruns rooted at `packages/api/tsconfig.json`\n\n. The `tsconfig.json`\n\nunder `packages/web/`\n\nis handled independently in a separate batch. Even within the same monorepo, unrelated packages don't get checked.\n\nTypeScript error output is narrowed too (L122-133). Instead of piping the entire output of `tsc --noEmit`\n\nthrough, it filters to **only lines containing an edited file's path, up to 10 lines**, and writes them to `stderr`\n\n.\n\n``` js\nconst relevantLines = lines\n  .filter(line => {\n    for (const c of candidates) { if (line.includes(c)) return true; }\n    return false;\n  })\n  .slice(0, 10);\nif (relevantLines.length > 0) {\n  process.stderr.write(`[Hook] TypeScript errors in ${path.basename(filePath)}:\\n`);\n  relevantLines.forEach(line => process.stderr.write(line + '\\n'));\n}\n```\n\nRather than dumping 1000 lines of tsc errors into the terminal, it narrows down to \"only the lines related to the files just touched.\" Since the filtered errors are all Claude Code reads on the next turn, the context stays clean.\n\nAfter the \"grouping by project root\" mentioned earlier, the function that actually calls the formatter is `formatBatch`\n\n(L48-77). This part is genuinely complex, and I rewrote it several times before it worked.\n\n``` js\nfunction formatBatch(projectRoot, files, timeoutMs) {\n  const formatter = detectFormatter(projectRoot);\n  if (!formatter) return;\n\n  const resolved = resolveFormatterBin(projectRoot, formatter);\n  if (!resolved) return;\n\n  const existingFiles = files.filter(f => fs.existsSync(f));\n  if (existingFiles.length === 0) return;\n\n  const fileArgs =\n    formatter === 'biome'\n      ? [...resolved.prefix, 'check', '--write', ...existingFiles]\n      : [...resolved.prefix, '--write', ...existingFiles];\n```\n\n`detectFormatter`\n\nis a lib-side function: if `biome.json`\n\nor `biome.jsonc`\n\nexists it returns `'biome'`\n\n, and if `package.json`\n\nmentions `prettier`\n\nit returns `'prettier'`\n\n. If no formatter is found it returns `null`\n\n, and `formatBatch`\n\nimmediately `return`\n\ns. The design is such that **using this hook on a project with no formatter configured does nothing at all**.\n\nThe important part is how `existingFiles`\n\nis built (L55).\n\n``` js\nconst existingFiles = files.filter(f => fs.existsSync(f));\nif (existingFiles.length === 0) return;\n```\n\nWhen Claude does something like \"write a file and delete it right after\" (creating a temp file then cleaning up, moving a file via rename, etc.), the path may be stacked in the accumulator while the file no longer exists by the time the Stop hook runs. Passing it to the formatter without an existence check crashes with \"file not found.\" `existsSync`\n\nis mandatory here. I'll go into this problem in more detail in the section on where I got stuck.\n\nThe difference in command argument structure between biome and prettier is also worth noting. biome does formatting and static analysis together with `check --write`\n\n, while prettier works with just `--write`\n\n. That flag difference is absorbed in a single ternary expression.\n\n**The Windows .cmd problem** (L64-76) is remote from anyone developing on macOS, but it's an interesting piece of implementation.\n\n```\nif (process.platform === 'win32' && resolved.bin.endsWith('.cmd')) {\n  if (existingFiles.some(f => UNSAFE_PATH_CHARS.test(f))) {\n    process.stderr.write('[Hook] stop-format-typecheck: skipping batch — unsafe path chars\\n');\n    return;\n  }\n  const result = spawnSync(resolved.bin, fileArgs, { cwd: projectRoot, shell: true, stdio: 'pipe', timeout: timeoutMs });\n```\n\nOn Windows, executing `.cmd`\n\nfiles like `npx.cmd`\n\nor `biome.cmd`\n\nrequires the `shell: true`\n\noption. But with `shell: true`\n\n, paths get interpreted through the shell, and paths containing spaces or `&`\n\nget mangled. So `UNSAFE_PATH_CHARS`\n\nat L33 checks in advance, and if a dangerous path is present the whole batch is skipped with a warning to `stderr`\n\n.\n\nOn macOS/Linux it uses `execFileSync`\n\nand calls the binary directly without a shell in between. Arguments are passed as an array, so paths containing spaces are safe.\n\n`typecheckBatch`\n\n(L91-133), which handles the TypeScript check, has a similar branch.\n\n``` js\nfunction typecheckBatch(tsConfigDir, editedFiles, timeoutMs) {\n  const isWin = process.platform === 'win32';\n  const npxBin = isWin ? 'npx.cmd' : 'npx';\n  const args = ['tsc', '--noEmit', '--pretty', 'false'];\n  const opts = { cwd: tsConfigDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: timeoutMs };\n```\n\n`cwd: tsConfigDir`\n\nis the trick. `tsc --noEmit`\n\nautomatically looks for `tsconfig.json`\n\nin the current directory. By setting `cwd`\n\nto the directory where tsconfig.json lives, you get the correct config loaded without specifying a `--project`\n\nflag.\n\n`--pretty false`\n\nis for ease of parsing. Having it output plain text without color codes or terminal escapes ensures the downstream error filtering (line-based matching) works reliably. If you try to search for file paths with `includes()`\n\nwhile ANSI escapes are mixed in, characters wrapped in escape sequences can break the match.\n\n`stdio: ['pipe', 'pipe', 'pipe']`\n\nis deliberate too. Piping all of `stdin`\n\n, `stdout`\n\n, and `stderr`\n\nkeeps tsc's output from flowing straight to the terminal and puts it into Node's buffer. Only on failure do you join `err.stdout`\n\nand `err.stderr`\n\nfor post-processing.\n\nThe structure of the `run`\n\nfunction (L188-195) looks odd at first glance.\n\n```\nfunction run(rawInput) {\n  try {\n    main();\n  } catch (err) {\n    process.stderr.write(`[Hook] stop-format-typecheck error: ${err.message}\\n`);\n  }\n  return rawInput;\n}\n```\n\nIt doesn't use the result of `main()`\n\n; it returns the `rawInput`\n\nargument as-is. This follows Claude Code's hook spec. A Stop hook is expected to receive Claude's event data on stdin and write the processing result to stdout. This hook doesn't need to transform the data, so it just passes the received JSON straight through.\n\nThe important part is that it's wrapped in `try-catch`\n\n. Whatever error occurs inside the hook, the Claude Code session itself isn't broken. In the worst case, formatting and typechecking are both skipped, but Claude's work doesn't stop. It just emits a warning to `stderr`\n\n. This reflects the design philosophy that **a hook is an aid and must never obstruct the main work**.\n\n`MAX_STDIN = 1024 * 1024`\n\n(L22) comes from the same thinking. In theory stdin will never get huge, but a 1MB cap prevents reading forever if something goes wrong.\n\nThe logic looks clean once you organize it after the fact, but it didn't start out in this shape. Here are three things that broke while actually running it.\n\nIn the first version, ID sanitization inside `getAccumFile`\n\nwas too loose. I wasn't thinking about what characters `CLAUDE_SESSION_ID`\n\nmight contain.\n\nDepending on the environment, `CLAUDE_SESSION_ID`\n\ncan come in a form containing slashes, like `session/abc123`\n\n(it depends on version and configuration). Build `path.join(os.tmpdir(), 'ecc-edited-session/abc123.txt')`\n\nfrom that and `appendFileSync`\n\ncrashes with `ENOENT`\n\n, because the `os.tmpdir()/ecc-edited-session/`\n\ndirectory doesn't exist.\n\nThe symptom is simple: the PostToolUse hook errors every time. But **the error only goes to stderr, and Claude's work doesn't stop** (thanks to the pass-through design). So my only clue was a vague \"I feel like the hook isn't working.\" I didn't know the cause until I actually checked stderr.\n\n``` js\n// 修正前\nreturn path.join(os.tmpdir(), `ecc-edited-${raw}.txt`);\n\n// 修正後\nconst sessionId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);\nreturn path.join(os.tmpdir(), `ecc-edited-${sessionId}.txt`);\n```\n\n`/[^a-zA-Z0-9_-]/g`\n\nreplaces everything except alphanumerics, hyphens, and underscores with `_`\n\n. Slashes, dots, and whitespace all get flattened. It also slices to 64 characters to control filename length. After the fix, `session/abc123`\n\nbecomes `session_abc123`\n\nand gets created in tmp without a problem.\n\n**The lesson is \"don't trust the contents of environment variables.\"** Values that aren't in the spec can show up. Anything coming from outside must be sanitized before you use it on the filesystem.\n\nAt some point during a Claude Code session, I noticed tsc was running twice against the same file. Checking the logs, the Stop event had fired twice.\n\nInvestigating, I found that in some operation patterns (specific flows involving tools or subagents), the Stop hook can be triggered multiple times. That's behavior on the Claude Code side, so the hook has to handle it.\n\nThe original implementation read the tmp file each time the Stop hook ran. When a second Stop came in, if the tmp file was still there, the same paths got processed again.\n\nThe fix is to delete it at the same time you read it.\n\n``` js\nlet raw;\ntry {\n  raw = fs.readFileSync(accumFile, 'utf8');\n} catch {\n  return; // ファイルがない = 処理済みか、このターンにJS/TSを触っていない\n}\ntry { fs.unlinkSync(accumFile); } catch { /* best-effort */ }\n```\n\nRight after `readFileSync`\n\n, once the contents are in memory, call `unlinkSync`\n\n. When the second Stop arrives, `readFileSync`\n\nthrows `ENOENT`\n\nand the `catch`\n\nblock returns. However many Stops pile up, processing happens exactly once.\n\n`unlinkSync`\n\nis wrapped in `try-catch`\n\nso a failed deletion doesn't stop anything. If another process already deleted it, you get `ENOENT`\n\n, and that's not a problem. The pragmatic stance is: \"I don't need proof that the delete succeeded — the contents are already in memory.\"\n\n**What I learned from this failure is the principle 'treat external events as idempotent.'** Design so the same trigger arriving any number of times produces the same result, and uncertain behavior stops being a problem.\n\nDuring real Claude work, this pattern comes up: write a temp file named `tmp.ts`\n\n, check its contents, then delete it. Or rename a file from `old.ts`\n\nto `new.ts`\n\n(which is really delete + create).\n\nAt that point, the accumulator has `tmp.ts`\n\nor `old.ts`\n\nstacked in it. But by the time the Stop hook runs, those don't exist. What happens if you hand them to `tsc`\n\nwithout an existence check?\n\n```\nerror TS2307: Cannot find module '/path/to/tmp.ts' or its corresponding type declarations.\n```\n\nThis error goes out to the Stop hook's stderr. And since the error filter correctly picks it up as \"a line related to an edited file,\" it lands in Claude Code's context. Claude sees the \"tmp.ts not found\" error on the next turn and starts attempting unnecessary fixes.\n\nThe fix is in two places. I added an `fs.existsSync`\n\ncheck to both the `byProjectRoot`\n\nand `byTsConfigDir`\n\nconstruction loops.\n\n``` js\n// byProjectRoot の構築\nconst resolved = path.resolve(filePath);\nif (!fs.existsSync(resolved)) continue;  // ← これ\n\n// byTsConfigDir の構築\nconst resolved = path.resolve(filePath);\nif (!fs.existsSync(resolved)) continue;  // ← これも\n```\n\nThe same check exists on the `formatBatch`\n\nside too (`existingFiles`\n\nat L55). It's spread across three places because grouping and execution happen at different times. Filtering during grouping also saves the cost of searching for `tsconfig.json`\n\n. Filtering again right before execution handles the (very rare) race where a file disappears after grouping.\n\n**What I learned from this failure is that 'the accumulator stacks facts, not intentions.'** What's stacked isn't \"files that were edited\" but \"paths as of the moment an edit operation happened.\" The Stop hook takes no responsibility for a path's past; it only looks at whether it exists now.\n\n`findTsConfigDir`\n\n(L79-88) walks up to 20 levels of parent directories from a file looking for `tsconfig.json`\n\n.\n\n``` js\nfunction findTsConfigDir(filePath) {\n  let dir = path.dirname(filePath);\n  const fsRoot = path.parse(dir).root;\n  let depth = 0;\n  while (dir !== fsRoot && depth < 20) {\n    if (fs.existsSync(path.join(dir, 'tsconfig.json'))) return dir;\n    dir = path.dirname(dir);\n    depth++;\n  }\n  return null;\n}\n```\n\nAt first there was no depth cap, and control was only `dir !== fsRoot`\n\n. That was fine in a normal development environment, but one day when I tested it on a monorepo setup that used symlinks heavily, the loop went around nearly 50 times before returning `null`\n\n. Whether macOS symlink resolution was affecting the `fsRoot`\n\ncheck, I couldn't fully trace — but it was definitely walking deeper than expected.\n\nAfter adding the `depth < 20`\n\ncap, the problem stopped reproducing. The number \"20\" is a rule of thumb: in a normal development project, it's not realistically possible to walk deeper than that without hitting a `tsconfig.json`\n\n. Even in a typical monorepo structure, it's 6–7 levels from `packages/module-name/src/utils/helper.ts`\n\nto the root. 20 is triple that as a safety margin.\n\n**The root of this problem is 'don't have only one termination condition.'** With only the natural termination condition of \"stop when you reach the root,\" an unexpected environment produces near-infinite-loop behavior. `depth < 20`\n\nisn't a logical termination condition — it's an **insurance termination condition**. It never fires in the normal case, but it always stops things when something is wrong. There's no downside to writing this kind of insurance.\n\nThe last failure is about the initial budget design. In the first version, I applied a fixed 60-second timeout to tsc.\n\n``` js\n// 最初の実装（問題あり）\nconst TYPECHECK_TIMEOUT_MS = 60_000;\nfor (const [tsDir, batch] of byTsConfigDir) {\n  typecheckBatch(tsDir, batch, TYPECHECK_TIMEOUT_MS);\n}\n```\n\nRunning that on a large monorepo with 5 tsconfigs, the total could exceed 300 seconds (5 × 60 seconds = 300 seconds, plus formatting on top). Claude Code's Stop hook times out at 300 seconds, so the last few tsc runs would get cancelled.\n\nThe current implementation uses dynamic budget distribution.\n\n``` js\nconst totalBatches = byProjectRoot.size + byTsConfigDir.size;\nconst perBatchMs = totalBatches > 0\n  ? Math.floor(TOTAL_BUDGET_MS / totalBatches)\n  : 60_000;\n```\n\n`TOTAL_BUDGET_MS = 270_000`\n\n(270 seconds) divided by the total batch count. With 5 tsconfigs and 2 project roots (for the formatter), that's 7 batches total, so `270_000 / 7 ≈ 38_571`\n\nmilliseconds (about 38 seconds) as the cap per batch. 7 batches × 38 seconds = 266 seconds, which fits inside 270. And since 270 seconds is Claude Code's 300-second limit minus 30, it won't exceed 300 seconds even including hook startup overhead.\n\nWith only one project, you get the full `270_000 / 1 = 270_000`\n\n(270 seconds). The bigger the monorepo gets, the shorter each batch's time, but the total is always within 270 seconds. It's a simple story: the pie is fixed, and more batches means thinner slices.\n\nAfter this design change, the Stop hook stopped terminating early due to timeouts on projects of any size.\n\nSumming up the things I got stuck on: nearly every sticking point in this implementation is a **boundary condition**. Values coming from outside for the session ID, Stop arriving multiple times, files disappearing mid-flight, tsconfig not being found, the budget overflowing. These are hard to reproduce in unit tests — the kind of problem you only notice by stepping on it while actually running the thing.\n\nBecause a hook is an \"auxiliary tool,\" the reassurance that work doesn't stop when it breaks carries a risk of lax testing. What I consciously did was \"go look at stderr precisely because the hook's errors are hard to see.\" Even when Claude's responses look perfectly natural, the hook can be quietly slacking off.\n\nIn addition to the five troubles dug into above (session ID path contamination, double Stop invocation, deleted files, the findTsConfigDir deep dive, and the static budget), here's a comprehensive list of the other gotchas I actually hit.\n\n**nvm's Node.js isn't on PATH and the hook doesn't start**\n\nThe shell that launches Claude Code hooks doesn't read `~/.zshrc`\n\n. Node.js managed by nvm doesn't get onto PATH automatically, so the `node`\n\ncommand isn't found. When registering a hook in `hooks.json`\n\n, either write the absolute path to Node.js directly in the command, or put a wrapper shell script with an explicit PATH in between. A `#!/usr/bin/env node`\n\nshebang alone isn't enough.\n\n**No execute permission, and it fails silently**\n\nForget `chmod +x`\n\nand the hook fails to start with `Permission denied`\n\n. But thanks to the pass-through design, Claude Code's work itself doesn't stop, so you get that vague \"I feel like it isn't working\" realization. The fastest way to confirm is Claude Code's `--debug`\n\nmode log, or grepping the Stop hook's stderr log after the fact.\n\n**Debugging with console.log() pollutes stdout and Claude Code throws a parse error**\n\n`console.log('debug')`\n\nand the receiving side fails to parse the JSON. Always use `process.stderr.write()`\n\nor `console.error()`\n\nfor debug output. Develop without knowing this and it shows up as \"Claude started behaving strangely after I added a hook.\"**An early version with the hooks.json matcher left at * calling tsc directly inside**\n\n`post-edit-accumulator.js`\n\n(L38: `/\\.(ts|tsx|js|jsx)$/`\n\n) is designed to narrow things down **Not looking at MultiEdit's edits array, so some paths never get stacked**\n\n`tool_input.file_path`\n\n. MultiEdit, on the other hand, has a `file_path`\n\nin each element of the `tool_input.edits`\n\narray. Handle only one of the two and a MultiEdit that edits several files at once only stacks the first one. `post-edit-accumulator.js`\n\nhandles both explicitly at L56-58.**Trying to eliminate duplicates because I thought stacking the same file repeatedly would make the Stop hook heavy**\n\nAttempt an implementation on the PostToolUse side that \"checks whether it's already stacked before appending,\" and you get a read → compare → write window that races with concurrent processes and requires mutual exclusion. Duplicates are eliminated in bulk on the Stop hook side with `[...new Set()]`\n\n(L37), so the correct answer on the PostToolUse side is to stack with `appendFileSync`\n\nwithout thinking about it.\n\n**Omitting --pretty false lets ANSI escapes destroy the error filter**\n\n`tsc --noEmit`\n\noutputs in color by default. When error lines contain escape sequences like `\\x1b[91m`\n\n, the filter that searches for file paths with `includes()`\n\n(L127) can't match the path string correctly. TypeScript errors are actually occurring, but they never reach Claude's context, and the turn moves on without them being fixed. `--pretty false`\n\n(L94) is not optional.**Setting stdio: 'inherit' sends tsc output straight to the terminal**\n\n`execFileSync`\n\nand it defaults to `'inherit'`\n\n, which sends tsc's output straight into the Stop hook's stdout. The pass-through design breaks, and Claude Code receives event data and tsc output mixed together. Explicitly specifying all three with `stdio: ['pipe', 'pipe', 'pipe']`\n\n(L95) is mandatory.**Omitting cwd makes tsc read the wrong tsconfig**\n\n`typecheckBatch`\n\nruns tsc with `cwd: tsConfigDir`\n\n(L95). Omit that and tsc searches for `tsconfig.json`\n\nfrom the current process's directory, leading to a situation where files in `packages/api/`\n\nget checked with `packages/web/`\n\n's configuration. In a monorepo that's fatal. There's also the `--project`\n\nflag approach, but matching `cwd`\n\nis simpler.**Looking at only stdout or only stderr makes errors disappear**\n\nWhether `tsc`\n\nemits errors to stdout or stderr changes depending on version and configuration. `typecheckBatch`\n\n(L122) joins both with `(stdout + stderr).split('\\n')`\n\nbefore filtering. Look only at `stderr`\n\nand errors that went to stdout vanish, producing the confusion of \"there shouldn't be any errors but it doesn't work.\"\n\n**Confusing biome and prettier arguments and crashing**\n\nbiome is `check --write`\n\n; prettier is `--write`\n\n(L58-61). Pass `check --write`\n\nto prettier and it tries to write a file named `check`\n\nand fails. Pass only `--write`\n\nto biome and it errors for lack of a subcommand. The formatter-detection → command-generation branch is hidden away on the lib side, and not calling it directly is the safe move.\n\n**Writing the formatter assuming a global install, then it didn't work in other environments**\n\nBecause `resolveFormatterBin`\n\nis designed to search the project's `node_modules/.bin/`\n\n, it doesn't depend on globally installed `biome`\n\nor `prettier`\n\n. But run it with no `node_modules`\n\npresent and the behavior is \"formatter not found → do nothing.\" That's a safe design in itself, but the gotcha is that it's hard to notice \"formatting isn't running.\"\n\n**Passing jsx files to tsc**\n\nThe `byTsConfigDir`\n\nconstruction loop (L162) has a `!/\\.(ts|tsx)$/`\n\nfilter. `.js`\n\nand `.jsx`\n\nare formatter-only targets and aren't passed to tsc. Remove that filter and `.jsx`\n\nfiles become targets of the `findTsConfigDir`\n\nsearch, producing unexpected errors depending on the tsconfig settings.\n\n**Using only absolute paths as candidates in the error filter and missing relative-path errors**\n\n`typecheckBatch`\n\n's error filter (L124-125) puts both the file's absolute path and its path relative to `tsConfigDir`\n\ninto the candidate set. Which form tsc writes in an error line depends on the tsconfig `rootDir`\n\nsetting. Search with only absolute paths, or only relative ones, and some errors slip straight through.\n\nGuidelines derived from the implementation and the failures. Ordered by how easily someone building hooks with this structure for the first time will overlook them.\n\n**1. PostToolUse only stacks; decisions happen in bulk at Stop**\n\nBuild PostToolUse to \"stack while deduplicating\" or \"process in parallel too,\" and you need to manage contention with concurrent processes. Keeping the write simple and concentrating decisions and processing in Stop is the simplest and most robust design.\n\n**2. Use appendFileSync for appends to guarantee concurrency safety**\n\n`appendFileSync`\n\nis an atomic append-to-end at the OS level, so no lock files or mutual exclusion are needed. When you need concurrent writes to a file, `appendFileSync`\n\nis the first option.**3. Reduce environment variables to alphanumerics before using them in filenames**\n\nThe format of `CLAUDE_SESSION_ID`\n\nvaries by version and environment. Replace everything except alphanumerics, hyphens, and underscores with `/[^a-zA-Z0-9_-]/g`\n\n, and limit the length with `.slice(0, 64)`\n\n(L30-31). Trust that a value from outside \"will surely come in the documented format\" and file creation will fail in rare cases.\n\n**4. Achieve idempotency with \"read and immediately delete\"**\n\nDesign on the assumption that the Stop hook will be called multiple times. Calling `unlinkSync`\n\nright after `readFileSync`\n\non the tmp file (L141-146) means the second and later calls catch `ENOENT`\n\nand return immediately. It's more reliable than managing a lock file, and the code is shorter.\n\n**5. Put existsSync in two stages: before grouping and before execution**\n\n`existsSync`\n\ncheck in each of the `byProjectRoot`\n\nand `byTsConfigDir`\n\nconstruction loops (L155, L165), and filter again as `existingFiles`\n\nright before `formatBatch`\n\nexecutes (L55). Defense in depth completely prevents the \"pass a deleted file and crash tsc\" problem.**6. Derive timeouts from external constraints and distribute them dynamically**\n\nClaude Code's Stop hook has a 300-second limit. `TOTAL_BUDGET_MS = 270_000`\n\n(L29) is that limit minus 30 seconds of overhead. Dividing it evenly by batch count (L175) keeps the whole thing inside 300 seconds no matter how many tsconfigs a monorepo has. Dynamic distribution — \"external constraint ÷ batch count\" — is the right answer, not a fixed value.\n\n**7. Give loops both a logical termination condition and an insurance one**\n\n`findTsConfigDir`\n\n's `while`\n\nloop combines `dir !== fsRoot`\n\n(the logical termination condition) with `depth < 20`\n\n(the insurance one) (L82-83). It's a cap based on the rule of thumb that \"there's no tsconfig deeper than 20 levels,\" and it stops the loop when symlinks or environment-dependent behavior take it deeper than expected. Always put insurance in loops that traverse an external filesystem.\n\n**8. Control tsc with --pretty false and stdio: pipe**\n\n`tsc --noEmit --pretty false`\n\n(L94) strips ANSI escapes, and `stdio: ['pipe', 'pipe', 'pipe']`\n\n(L95) receives output into Node's buffer. Without both together, either the error filtering breaks or Claude's stdout gets polluted. Memorize this as the standard configuration for calling tsc in a subprocess.**9. Narrow tsc errors to \"related to edited files, max 10 lines\"**\n\nPipe all of tsc's output straight into Claude's context and unrelated errors become noise, leading Claude to attempt wrong fixes. Filter with both absolute and relative paths as candidates (L124-128), and cap it with `.slice(0, 10)`\n\n. The principle is that errors a hook outputs should stay at \"the minimum information needed for Claude's next action.\"\n\n**10. Wrap the entire hook in try-catch so it doesn't obstruct Claude's work**\n\n`run()`\n\nin `try-catch`\n\n(L188-194) and only write errors to stderr. Even if formatting or typechecking fails, Claude can move on to the next turn. Designing so that breakage isn't a problem takes priority over making the hook work perfectly.**11. stdout is for pass-through only; write debugging to stderr**\n\nA hook's stdout is the data returned to Claude Code. Mix in debug strings with `console.log()`\n\nand Claude Code throws a parse error. Use `process.stderr.write()`\n\nor `console.error()`\n\nfor all debugging during development. Making that a habit completely prevents the \"Claude started behaving strangely after I added a hook\" situation.\n\n**12. Auto-detect the formatter; skip silently if none is configured**\n\n`detectFormatter`\n\nchecks `biome.json`\n\nand `package.json`\n\nto pick a formatter, and returns `null`\n\nif it finds none. At `null`\n\n, `formatBatch`\n\nreturns immediately. It never \"breaks because no formatter is installed,\" so you can safely bring it into any project. When setting up hooks in a new environment, you're less likely to worry about \"why is formatting running?\" than to debug \"why isn't it formatting?\"\n\n**13. Give tsc the right context with cwd: tsConfigDir**\n\n`tsconfig.json`\n\nin the current directory. Setting `cwd`\n\nto the directory where `tsconfig.json`\n\nlives gets the correct config loaded without a `--project`\n\nflag. In a monorepo, just setting `cwd`\n\ngives you independent checks across multiple packages.**14. Put a cap on reading stdout**\n\n`MAX_STDIN = 1024 * 1024`\n\n(L22) caps stdin reading at 1MB. It's irrelevant for normal hook events, but it prevents the process from eating memory forever if a bug or unexpected state sends an endless stream on stdin. Always cap streams coming from outside.\n\n**15. Know that hook errors are \"hard to see\"**\n\nBecause of the pass-through design, even a completely non-functional hook doesn't affect Claude Code's visible behavior. Often the only signal is a vague \"somehow it isn't being formatted\" or \"I don't think tsc is running.\" Checking the stderr log periodically, or adding a lightweight stderr message that indicates the hook ran, speeds up diagnosis considerably.\n\nTwo files and roughly 200 lines of code achieve the goal of \"launching tsc once per session.\" The design axis is simple: stack in PostToolUse, process in bulk at Stop. That separation is what lets Claude move on to the next action without stopping every time it finishes an edit operation.\n\nBut inside those 200 lines are a stack of specific judgment calls.\n\nSanitizing the session ID comes from the premise that \"the format of values coming from outside isn't guaranteed.\" Idempotency via immediate unlink comes from the premise that \"assume the same trigger can arrive multiple times.\" The two-stage existsSync check comes from the premise that \"the accumulator is a record of past operations, not a guarantee of current file state.\" Dynamic budget distribution comes from the premise that \"check the external constraint (300 seconds) first, and design backward from it.\"\n\nAll of these came to light *after* actually running things and breaking them. Neither static analysis nor unit tests would have found them in advance. They only appeared by continuing to use the hooks in production Claude sessions.\n\nWhen you start using Claude Code, you focus at first on \"what code should I have it write.\" But once you're touching 100 files a day, the questions that create the productivity gap are \"is tsc running every single time?\", \"are errors polluting the context?\", and \"can I cut the time Claude spends stopped?\" During those six months when my revenue was zero, I still think spending the first two days on hooks was the right call. Getting the environment in place first changed the quality of the several hundred hours that followed.\n\nI've put the full picture of the system, the breakdown of the 1.2 million yen a month, and a 30-day walkthrough into a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/i-stopped-running-tsc-on-every-edit-batching-a-whole-claude-code-session-into", "canonical_source": "https://dev.to/bokuwalily/i-stopped-running-tsc-on-every-edit-batching-a-whole-claude-code-session-into-one-run-36e8", "published_at": "2026-08-20 00:00:11+00:00", "updated_at": "2026-08-20 00:14:20.983679+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-agents", "mlops"], "entities": ["Claude Code", "TypeScript", "Prettier", "Biome"], "alternates": {"html": "https://wpnews.pro/news/i-stopped-running-tsc-on-every-edit-batching-a-whole-claude-code-session-into", "markdown": "https://wpnews.pro/news/i-stopped-running-tsc-on-every-edit-batching-a-whole-claude-code-session-into.md", "text": "https://wpnews.pro/news/i-stopped-running-tsc-on-every-edit-batching-a-whole-claude-code-session-into.txt", "jsonld": "https://wpnews.pro/news/i-stopped-running-tsc-on-every-edit-batching-a-whole-claude-code-session-into.jsonld"}}