|
#!/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(); |