Skip to content

Instantly share code, notes, and snippets.

@genesiscz
Last active February 19, 2026 18:52
Show Gist options
  • Select an option

  • Save genesiscz/80c8a1b3b436090f603544546a4e813c to your computer and use it in GitHub Desktop.

Select an option

Save genesiscz/80c8a1b3b436090f603544546a4e813c to your computer and use it in GitHub Desktop.
fix-agents.ts — Claude Code hook that fixes subagent TaskOutput flooding the parent context with raw JSONL

fix-agents.ts — Claude Code Subagent Output Fix

Fixes the bug where TaskOutput dumps raw JSONL transcripts (50k–200k+ tokens) into the parent agent's context window instead of a clean summary.

Tracked issues:

  • #17591 — regression since v2.0.77
  • #16789 — feature request
  • #17208 — proposes output_mode parameter
  • #19892 — verbose API metadata

Prerequisites

  • Bun runtime installed

Setup

Paste this into Claude Code (or run manually):

# 1. Download the hook script
curl -fsSL "https://gist.githubusercontent.com/genesiscz/80c8a1b3b436090f603544546a4e813c/raw/fix-agents.ts" \
  -o ~/.claude/fix-agents.ts
chmod +x ~/.claude/fix-agents.ts

# 2. Merge hooks into ~/.claude/settings.json
# If you don't have a settings.json yet, create one first:
# echo '{}' > ~/.claude/settings.json

bun -e '
const path = `${process.env.HOME}/.claude/settings.json`;
const settings = JSON.parse(await Bun.file(path).text());

if (!settings.hooks) settings.hooks = {};

const cmd = "bun run ~/.claude/fix-agents.ts --mode cleanup-output,no-poll";
const hook = { type: "command", command: cmd };

const taskEntry = { matcher: "Task", hooks: [hook] };
const taskOutputEntry = { matcher: "TaskOutput", hooks: [hook] };

// Add PreToolUse hooks (prepend to preserve existing hooks)
const pre = settings.hooks.PreToolUse ?? [];
const hasPreTask = pre.some(e => e.matcher === "Task" && e.hooks?.some(h => h.command?.includes("fix-agents")));
const hasPreOutput = pre.some(e => e.matcher === "TaskOutput" && e.hooks?.some(h => h.command?.includes("fix-agents")));
if (!hasPreTask) pre.unshift(taskEntry);
if (!hasPreOutput) pre.unshift(taskOutputEntry);
settings.hooks.PreToolUse = pre;

// Add PostToolUse hook
const post = settings.hooks.PostToolUse ?? [];
const hasPostTask = post.some(e => e.matcher === "Task" && e.hooks?.some(h => h.command?.includes("fix-agents")));
if (!hasPostTask) post.unshift(taskEntry);
settings.hooks.PostToolUse = post;

await Bun.write(path, JSON.stringify(settings, null, 2) + "\n");
console.log("Done — hooks added to settings.json");
'

What it does

When the parent agent calls TaskOutput, this hook fires before the read and:

  1. Finds the .output symlink in /private/tmp/claude-*/
  2. Reads the raw JSONL transcript from the symlink target
  3. Extracts a clean summary (tool calls with trimmed args + final result)
  4. Replaces the symlink with the clean text file (original JSONL preserved)

Before (raw JSONL dumped into context):

{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I'll read...
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01...
... (hundreds of lines of JSON)

After (clean summary):

[Tool: Read] {"file_path":"/src/foo.ts"}
[Tool: Grep] {"pattern":"handleAuth","path":"/src/"}

--- RESULT ---
The authentication system uses a three-layer approach: ...

Modes

Modes are composable — combine with commas: --mode cleanup-output,no-poll

Mode What it does
cleanup-output Replaces raw JSONL symlink with clean summary before TaskOutput reads it
no-poll Injects "don't poll" reminder after Task calls — prevents the double-call pattern
force-background Forces run_in_background: true on Task calls (most aggressive)
none No-op. Disable without removing hooks

Recommended combo: --mode cleanup-output,no-poll

Manual hook config

If you prefer to edit ~/.claude/settings.json manually, add these under "hooks":

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Task",
        "hooks": [{ "type": "command", "command": "bun run ~/.claude/fix-agents.ts --mode cleanup-output,no-poll" }]
      },
      {
        "matcher": "TaskOutput",
        "hooks": [{ "type": "command", "command": "bun run ~/.claude/fix-agents.ts --mode cleanup-output,no-poll" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Task",
        "hooks": [{ "type": "command", "command": "bun run ~/.claude/fix-agents.ts --mode cleanup-output,no-poll" }]
      }
    ]
  }
}

Uninstall

rm ~/.claude/fix-agents.ts
# Then remove the fix-agents hook entries from ~/.claude/settings.json
#!/usr/bin/env bun
/*
================================================================================
fix-agents.ts — Claude Code Subagent Output Fix
================================================================================
PROBLEM
-------
Claude Code's Task tool spawns subagents (background agents) that produce JSONL
transcript files. When you call TaskOutput to read an agent's result, it follows
a symlink at /private/tmp/claude-<id>/<workspace>/tasks/<task-id>.output that
points directly to the raw JSONL transcript. This dumps the ENTIRE transcript
— every tool call, every intermediate reasoning step, all API metadata — into
the parent agent's context window.
For a typical subagent run this can be 50k–200k+ tokens of noise, which:
- Floods the parent's context window, triggering early autocompact
- Buries the actual result under pages of tool call JSON
- Wastes tokens on redundant intermediate "I'll read the file" filler
- Makes the parent agent confused about what the actual answer was
This is a known regression since Claude Code v2.0.77. Tracked across multiple
GitHub issues:
https://github.com/anthropics/claude-code/issues/17591 (regression, 10+)
https://github.com/anthropics/claude-code/issues/16789 (feature request, 8+)
https://github.com/anthropics/claude-code/issues/17208 (proposes output_mode)
https://github.com/anthropics/claude-code/issues/19892 (verbose API metadata)
SOLUTION
--------
This script is a Claude Code hook that intercepts Task/TaskOutput tool calls
and cleans up the output before the parent agent sees it.
HOW IT WORKS (cleanup-output mode)
-----------------------------------
1. Registered as a PreToolUse hook on "Task" and "TaskOutput" matchers
2. When TaskOutput is about to be called, the hook fires BEFORE the read
3. It finds the .output symlink in /private/tmp/claude-* /
4. Reads the raw JSONL transcript from the symlink target
5. Extracts a clean summary:
- Tool calls with trimmed arguments (max 100 chars per value)
- Only substantive intermediate text (3+ lines, filters filler)
- The final assistant message (the actual result)
6. Saves the original JSONL path in a .source companion file
7. Replaces the symlink with a clean text file
8. TaskOutput then reads the clean file instead of raw JSONL
The output format looks like:
[Tool: Read] {"file_path":"/src/foo.ts"}
[Tool: Grep] {"pattern":"handleAuth","path":"/src/"}
> Here's what I found analyzing the authentication flow:
> The auth module uses JWT tokens stored in httpOnly cookies...
> (multi-line substantive findings are preserved)
--- RESULT ---
The authentication system uses a three-layer approach: ...
MODES
-----
--mode cleanup-output (RECOMMENDED)
Intercepts TaskOutput PreToolUse calls. Replaces the .output symlink with a
clean text file containing a summarized transcript. The original JSONL is
preserved at the symlink target. Subsequent reads re-extract from the
original via the .source companion file (handles agents that are still
running when first read).
Also injects a PostToolUse reminder after each Task call telling the parent
to wait for the completion notification instead of polling with TaskOutput.
This prevents the double-call pattern (poll while running + read after done).
Hooks needed:
PreToolUse -> matcher: "Task" (cleanup on Task calls too)
PreToolUse -> matcher: "TaskOutput" (cleanup before reading output)
PostToolUse -> matcher: "Task" (inject "don't poll" reminder)
--mode reminders
Forces run_in_background: true on all Task calls (except SYNC_AGENTS like
"code-reviewer") so the parent waits for the completion notification instead
of polling. PostToolUse injects a reminder telling the parent not to poll
with TaskOutput or Bash tail commands.
This mode is more aggressive — it changes agent behavior rather than just
cleaning output. Useful if you find the parent agent is compulsively polling
TaskOutput before agents finish.
Hooks needed:
PreToolUse -> matcher: "Task" (force background + allow)
PostToolUse -> matcher: "Task" (inject reminder)
--mode none
No-op. Useful for temporarily disabling without removing hooks.
SETUP
-----
Requires: Bun runtime (used via shebang)
Location: ~/.claude/fix-agents.ts
Add to ~/.claude/settings.json under "hooks":
"PreToolUse": [
{
"matcher": "Task",
"hooks": [{ "type": "command", "command": "bun run ~/.claude/fix-agents.ts --mode cleanup-output" }]
},
{
"matcher": "TaskOutput",
"hooks": [{ "type": "command", "command": "bun run ~/.claude/fix-agents.ts --mode cleanup-output" }]
}
],
"PostToolUse": [
{
"matcher": "Task",
"hooks": [{ "type": "command", "command": "bun run ~/.claude/fix-agents.ts --mode cleanup-output" }]
}
]
TUNING
------
SYNC_AGENTS Set of subagent_type values that must NOT be forced to
background (only relevant in "reminders" mode).
Default: ["code-reviewer"]
MAX_ARG_LENGTH Max characters per string value in tool call args before
truncation. Default: 100
MIN_INTERMEDIATE_LINES Minimum line count for intermediate assistant text to be
kept in the summary. Shorter texts (1-2 lines) like
"Let me read that file" are filtered as filler.
Default: 3
================================================================================
*/
import { createInterface } from "readline";
import {
readdirSync,
lstatSync,
readFileSync,
readlinkSync,
unlinkSync,
writeFileSync,
existsSync,
} from "fs";
import { join } from "path";
type Mode = "reminders" | "cleanup-output" | "none";
// Agents that must remain synchronous (blocking) — add subagent_type values here
const SYNC_AGENTS = new Set(["code-reviewer"]);
const POST_TASK_REMINDER = [
"[Task Tool Post-Reminder]",
"1. Wait for the completion notification — DO NOT poll.",
" FORBIDDEN before notification: Bash(tail/cat /tmp/.../tasks/...), TaskOutput.",
"2. After notification arrives:",
" - IF <result> tag contains the complete answer → use it directly.",
" - ELSE extract final answer with: tail -1 {output_file} | jq -r '.message.content[0].text'",
"3. Use resume: + agentId only when you need to continue the same agent session.",
].join(" ");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseMode(): Mode {
const idx = process.argv.indexOf("--mode");
const value = idx !== -1 ? process.argv[idx + 1] : undefined;
if (value === "cleanup-output") return "cleanup-output";
if (value === "none") return "none";
return "reminders"; // default
}
async function readStdin(): Promise<string> {
const rl = createInterface({ input: process.stdin });
const lines: string[] = [];
for await (const line of rl) {
lines.push(line);
}
return lines.join("\n");
}
/** Search /private/tmp/claude-* for the .output file matching a task id. */
function findOutputFile(taskId: string): string | null {
const tmpDir = "/private/tmp";
try {
const claudeDirs = readdirSync(tmpDir).filter((d: string) =>
d.startsWith("claude-")
);
for (const claudeDir of claudeDirs) {
const claudePath = join(tmpDir, claudeDir);
let entries: string[];
try {
entries = readdirSync(claudePath);
} catch {
continue;
}
for (const wsDir of entries) {
const candidate = join(claudePath, wsDir, "tasks", `${taskId}.output`);
try {
lstatSync(candidate);
return candidate;
} catch {
// not found in this workspace dir
}
}
}
} catch {
// /private/tmp not readable or similar
}
return null;
}
interface JSONLContentBlock {
type?: string;
text?: string;
name?: string;
input?: Record<string, unknown>;
}
interface JSONLMessage {
role?: string;
content?: string | JSONLContentBlock[];
}
interface JSONLEntry {
type?: string;
message?: JSONLMessage;
}
const MAX_ARG_LENGTH = 100;
/** Recursively trim long string values in tool input params. */
function trimValue(value: unknown): unknown {
if (typeof value === "string") {
if (value.length > MAX_ARG_LENGTH) {
const remaining = value.length - MAX_ARG_LENGTH;
return value.slice(0, MAX_ARG_LENGTH) + `...(${remaining} bytes more, read the file if you need all data)`;
}
return value;
}
if (Array.isArray(value)) {
return value.map(trimValue);
}
if (value !== null && typeof value === "object") {
const trimmed: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
trimmed[k] = trimValue(v);
}
return trimmed;
}
return value; // numbers, booleans, null — pass through
}
/** Minimum line count for an intermediate text to be kept (filters filler). */
const MIN_INTERMEDIATE_LINES = 3;
/**
* Extract a clean summary from a JSONL transcript.
*
* [Tool: Name] {trimmed args}
* > substantive intermediate text (3+ lines only)
* [Tool: Name] {trimmed args}
*
* --- RESULT ---
* <final assistant text>
*
* Short intermediate texts (≤2 lines) like "I'll read the file now" are
* dropped as filler. Multi-line texts (findings, analysis) are kept.
*/
function extractCleanTranscript(jsonl: string): string | null {
const lines = jsonl.trim().split("\n");
// First pass: collect raw entries in order
const raw: Array<{ kind: "tool" | "text"; value: string }> = [];
let lastText: string | null = null;
for (const line of lines) {
try {
const entry: JSONLEntry = JSON.parse(line);
const msg = entry.message;
if (!msg || !Array.isArray(msg.content)) continue;
if (msg.role === "assistant") {
for (const block of msg.content) {
if (block.type === "tool_use" && block.name && block.input) {
const args = JSON.stringify(trimValue(block.input));
raw.push({ kind: "tool", value: `[Tool: ${block.name}] ${args}` });
}
if (block.type === "text" && block.text) {
raw.push({ kind: "text", value: block.text });
lastText = block.text;
}
}
}
} catch {
// skip unparseable lines
}
}
if (!lastText) return null;
// Remove the last text entry — it goes after the --- RESULT --- separator
for (let i = raw.length - 1; i >= 0; i--) {
if (raw[i].kind === "text" && raw[i].value === lastText) {
raw.splice(i, 1);
break;
}
}
// Second pass: keep tool calls + only substantive intermediate texts
const entries: string[] = [];
for (const item of raw) {
if (item.kind === "tool") {
entries.push(item.value);
} else {
const lineCount = item.value.split("\n").length;
if (lineCount >= MIN_INTERMEDIATE_LINES) {
entries.push(`> ${item.value}`);
}
}
}
const parts: string[] = [];
if (entries.length > 0) {
parts.push(entries.join("\n"));
parts.push("\n--- RESULT ---\n");
}
parts.push(lastText);
return parts.join("\n");
}
/**
* Clean the .output file for a task.
*
* First call: .output is a symlink → save the JSONL target path in a
* companion .source file, then replace the symlink with clean text.
* Later calls: .output is a regular file → re-read the original JSONL via
* .source, re-extract, and overwrite (handles mid-run → completed).
*/
function cleanOutputFile(taskId: string): void {
const filePath = findOutputFile(taskId);
if (!filePath) return;
const sourcePath = filePath.replace(/\.output$/, ".source");
let jsonlPath: string | null = null;
try {
const stat = lstatSync(filePath);
if (stat.isSymbolicLink()) {
// First time: save the symlink target for future re-reads
jsonlPath = readlinkSync(filePath);
writeFileSync(sourcePath, jsonlPath);
} else if (existsSync(sourcePath)) {
// Already cleaned before — re-read from the saved JSONL path
jsonlPath = readFileSync(sourcePath, "utf8").trim();
}
} catch {
return;
}
if (!jsonlPath || !existsSync(jsonlPath)) return;
try {
const content = readFileSync(jsonlPath, "utf8");
const cleanText = extractCleanTranscript(content);
if (!cleanText) return;
// Replace symlink (first time) or overwrite regular file (subsequent)
try { unlinkSync(filePath); } catch {}
writeFileSync(filePath, cleanText);
} catch {
// best effort — don't break the tool call if cleaning fails
}
}
// ---------------------------------------------------------------------------
// Output helpers
// ---------------------------------------------------------------------------
function respond(output: Record<string, unknown>): void {
console.log(JSON.stringify(output));
}
function allow(extra?: Record<string, unknown>): void {
respond({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
...extra,
},
});
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const mode = parseMode();
const raw = await readStdin();
let data: Record<string, unknown>;
try {
data = JSON.parse(raw);
} catch {
process.exit(0);
}
const hookEvent = (data.hook_event_name as string) ?? "";
const toolName = (data.tool_name as string) ?? "";
// ── Mode: reminders ────────────────────────────────────────────────────
if (mode === "reminders") {
if (toolName === "Task" && hookEvent === "PreToolUse") {
const toolInput = (data.tool_input as Record<string, unknown>) ?? {};
const subagentType = (toolInput.subagent_type as string) ?? "";
if (SYNC_AGENTS.has(subagentType)) {
allow();
} else {
allow({ updatedInput: { ...toolInput, run_in_background: true } });
}
return;
}
if (toolName === "Task" && hookEvent === "PostToolUse") {
respond({
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: POST_TASK_REMINDER,
},
});
return;
}
}
// ── Mode: cleanup-output ───────────────────────────────────────────────
if (mode === "cleanup-output") {
// After a Task call, remind the parent to wait for the notification
// instead of immediately polling with TaskOutput
if (toolName === "Task" && hookEvent === "PostToolUse") {
respond({
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: POST_TASK_REMINDER,
},
});
return;
}
// Before TaskOutput reads, replace the raw JSONL with a clean summary
if (toolName === "TaskOutput" && hookEvent === "PreToolUse") {
const toolInput = (data.tool_input as Record<string, unknown>) ?? {};
const taskId = (toolInput.task_id as string) ?? "";
if (taskId) {
cleanOutputFile(taskId);
}
allow();
return;
}
}
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment