From 5558ea7d868604e87fd7f6611ea48d59b0046058 Mon Sep 17 00:00:00 2001 From: shahinyanm Date: Sun, 26 Jul 2026 12:57:10 +0400 Subject: [PATCH] fix: revive the tp-* response-budget watchdog (0.49.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every tp-* agent declares a "Response budget: ~N tokens" and a watchdog was supposed to flag replies that overran it. It never ran once. The check lived in PostToolUse:Task, which does not fire for the dispatch tool on current Claude Code. This repo already knew that — installer.ts has said so since v0.39.3 — but nobody connected it to the budget path. The evidence was quiet and total: .token-pilot/over-budget.log did not exist, and every recorded task event carried a null budget. Moved the check to SubagentStop, where completions actually arrive, and corrected what it measures. It now sizes the agent's FINAL reply instead of everything the agent emitted along the way. That distinction matters: a response budget is about the answer handed back, and summing all output would have flagged essentially every agent (55k of output against a 600 token budget). The source is the transcript, not the payload's last_assistant_message. That field is present in the 2.1.220 bundle, but nothing here has ever read it, so there is no evidence it is populated — while the transcript is a path we already depend on and verified against real data. over-budget.log is written unconditionally. Surfacing the warning in the transcript rides the existing TOKEN_PILOT_SUBAGENT_FEEDBACK gate, since additionalContext on SubagentStop needs CC 2.1.163+. Verified end to end on a built dist: an overrun writes agent=tp-debugger budget=100 actual=2000, and a reply within budget writes nothing while still recording its task event. --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 55 ++++++++ agents/tp-api-surface-tracker.md | 2 +- agents/tp-audit-scanner.md | 2 +- agents/tp-commit-writer.md | 2 +- agents/tp-context-engineer.md | 2 +- agents/tp-dead-code-finder.md | 2 +- agents/tp-debugger.md | 2 +- agents/tp-dep-health.md | 2 +- agents/tp-doc-writer.md | 2 +- agents/tp-history-explorer.md | 2 +- agents/tp-impact-analyzer.md | 2 +- agents/tp-incident-timeline.md | 2 +- agents/tp-incremental-builder.md | 2 +- agents/tp-migration-scout.md | 2 +- agents/tp-onboard.md | 2 +- agents/tp-performance-profiler.md | 2 +- agents/tp-pr-reviewer.md | 2 +- agents/tp-refactor-planner.md | 2 +- agents/tp-review-impact.md | 2 +- agents/tp-run.md | 2 +- agents/tp-session-restorer.md | 2 +- agents/tp-ship-coordinator.md | 2 +- agents/tp-spec-writer.md | 2 +- agents/tp-test-coverage-gapper.md | 2 +- agents/tp-test-triage.md | 2 +- agents/tp-test-writer.md | 2 +- package-lock.json | 4 +- package.json | 2 +- server.json | 4 +- src/hooks/subagent-stop.ts | 111 ++++++++++++++++ src/index.ts | 21 ++- tests/hooks/subagent-budget.test.ts | 198 ++++++++++++++++++++++++++++ 34 files changed, 416 insertions(+), 35 deletions(-) create mode 100644 tests/hooks/subagent-budget.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a48213c..1df40e3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Token Pilot — save 60-90% tokens when AI reads code", - "version": "0.48.0" + "version": "0.49.0" }, "plugins": [ { "name": "token-pilot", "source": "./", "description": "Reduces token consumption by 60-90% via AST-aware lazy file reading, structural symbol navigation, and cross-session tool-usage analytics. 25 MCP tools + 25 subagents + budget watchdog hooks.", - "version": "0.48.0", + "version": "0.49.0", "author": { "name": "Digital-Threads" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a917d6c..4f063ee 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "token-pilot", - "version": "0.48.0", + "version": "0.49.0", "description": "Saves 60-90% tokens on AI code reading. AST-aware lazy reads, symbol navigation, find_usages, structural git diff/log, edit-safety guard, Task-routing matcher, cross-session telemetry (errors + diagnostics), 25 tp-* subagents tiered to haiku/sonnet/opus with budget watchdog.", "author": { "name": "Digital-Threads", diff --git a/CHANGELOG.md b/CHANGELOG.md index 8237b8c..39d3592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,61 @@ All notable changes to Token Pilot will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.49.0] - 2026-07-26 + +### Fixed — the tp-* response-budget watchdog was dead + +Every tp-* agent declares a `Response budget: ~N tokens`, and a watchdog +was supposed to flag replies that overran it. It never ran. The check +lived in `PostToolUse:Task`, which does not fire for the dispatch tool on +current Claude Code — something this codebase already knew and noted in a +comment back in v0.39.3, without connecting it to the budget path. + +The symptoms were quiet and complete: `.token-pilot/over-budget.log` did +not exist on any machine, and every recorded task event carried a null +budget. Months of nothing. + +Moved the check to `SubagentStop`, where subagent completions actually +arrive, and corrected what it measures. It now sizes the agent's **final +reply** rather than everything the agent emitted along the way — a +response budget is about the answer handed back, not about the thinking +and tool calls on the way there. Summing all output would have flagged +essentially every agent. + +The log write is unconditional. Surfacing the warning in the transcript +rides the existing `TOKEN_PILOT_SUBAGENT_FEEDBACK=1` gate, because +`additionalContext` on `SubagentStop` needs Claude Code 2.1.163+. + +### Security + +Cleared all 48 open dependency advisories, every one transitive through +`@modelcontextprotocol/sdk` (27 were `hono` alone). The SDK moves 1.27.1 → +1.29.0, which carries current `hono`, `qs`, `path-to-regexp`, +`ip-address`, `vite` and `vitest`. Two packages needed a major the SDK +does not request and are pinned through `overrides`: `fast-uri` 4.1.1 — +which is on our path, since the SDK's server imports the ajv validator +under stdio — and `@hono/node-server` 2.0.12. + +### Documentation + +Corrected the advertised tool count, which had drifted in three places +(README said 23, marketplace 24, docs 23; there are 25), and documented +`call_tree`, which was missing from the tools reference. + +Corrected `tokensFromTranscript`'s docstring: it sums every line carrying +a usage object, not "across assistant messages" as claimed. Claude Code +writes one record per content block and repeats the usage on each, so a +per-line sum double-counts within a request — ~1.4% on output tokens, a +clean ~2x on any input-side field. + +### CI + +npm is retiring the audit endpoint the pipeline used; it now answers 400 +for the npm shipped with Node 20 and 22, which failed every build +regardless of the code. The step now reads the JSON report itself: a +missing or malformed report is a warning, real advisories at moderate or +above still fail the build. + ## [0.48.0] - 2026-07-26 Catches up with Claude Code 2.1.188 → 2.1.220. Every breaking change in diff --git a/agents/tp-api-surface-tracker.md b/agents/tp-api-surface-tracker.md index 999f561..c2de79b 100644 --- a/agents/tp-api-surface-tracker.md +++ b/agents/tp-api-surface-tracker.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__read_symbol - Bash model: haiku -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: dd184501203fa7f3c73f419c4ffbe33c4be75400cb64a7a51733a3fe23f6e085 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-audit-scanner.md b/agents/tp-audit-scanner.md index e5f2b68..c171b07 100644 --- a/agents/tp-audit-scanner.md +++ b/agents/tp-audit-scanner.md @@ -11,7 +11,7 @@ tools: - Grep - Read model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: d172f600bf32277ea6eb4cbbee4542ddd698a986dcd96997d33930561964569b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-commit-writer.md b/agents/tp-commit-writer.md index 9d2a9f6..57dca65 100644 --- a/agents/tp-commit-writer.md +++ b/agents/tp-commit-writer.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__test_summary - mcp__token-pilot__outline - Bash -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: de64a406b5176de19f7422619c7de7949b1f28865f225402c9cea9255f377428 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-context-engineer.md b/agents/tp-context-engineer.md index 910778c..baa87b6 100644 --- a/agents/tp-context-engineer.md +++ b/agents/tp-context-engineer.md @@ -13,7 +13,7 @@ tools: - Edit - Glob model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 68b32af2dacd82ebe52c4eec93edb903d452688274c3065218270627c564d8b0 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-dead-code-finder.md b/agents/tp-dead-code-finder.md index 17cf447..9fa9fec 100644 --- a/agents/tp-dead-code-finder.md +++ b/agents/tp-dead-code-finder.md @@ -11,7 +11,7 @@ tools: - Grep - Read model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: d9b7f5b7ae6f4ae21305c775361bcab097cc774370a6d976c093571d46d55021 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-debugger.md b/agents/tp-debugger.md index 83d3206..b6524a2 100644 --- a/agents/tp-debugger.md +++ b/agents/tp-debugger.md @@ -12,7 +12,7 @@ tools: - Read - Bash model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 052413de8d92377edcde6ae5c823f5378db304baccfa29e8866467f42553a500 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-dep-health.md b/agents/tp-dep-health.md index edf7fde..e20dc15 100644 --- a/agents/tp-dep-health.md +++ b/agents/tp-dep-health.md @@ -9,7 +9,7 @@ tools: - Bash - Read model: haiku -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: e14dc57493d816f8c2e017963e2ef5f66bea50fd0b805a80e8a0d97c968427e7 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-doc-writer.md b/agents/tp-doc-writer.md index 1a75996..123b44a 100644 --- a/agents/tp-doc-writer.md +++ b/agents/tp-doc-writer.md @@ -13,7 +13,7 @@ tools: - Edit - Glob model: haiku -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 57d741794ab40e31a7ac49c68ea39a9088f5827cdef866ce81bfca1b7c9180cf requiredMcpServers: - "token-pilot" diff --git a/agents/tp-history-explorer.md b/agents/tp-history-explorer.md index a68c2c6..9711f19 100644 --- a/agents/tp-history-explorer.md +++ b/agents/tp-history-explorer.md @@ -10,7 +10,7 @@ tools: - Bash - Read model: haiku -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 7b70fa76a60e3c58a1de4f56c32c0f166424137e203a0cf1c8654e7c9235d904 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-impact-analyzer.md b/agents/tp-impact-analyzer.md index eae5c59..c2b4da2 100644 --- a/agents/tp-impact-analyzer.md +++ b/agents/tp-impact-analyzer.md @@ -12,7 +12,7 @@ tools: - mcp__token-pilot__read_symbols - Read model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 351a987e11eba63852f5431a16d8eb53104f4f689f82fdcc5a2bf4db948ba92f requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incident-timeline.md b/agents/tp-incident-timeline.md index bd58f85..6977ed5 100644 --- a/agents/tp-incident-timeline.md +++ b/agents/tp-incident-timeline.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__read_symbol - Bash model: inherit -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: de5722bfea374eaab096c1ae635c37879e7a91370ee3cd0532f4240be03c91eb requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incremental-builder.md b/agents/tp-incremental-builder.md index 8a3a890..0e75416 100644 --- a/agents/tp-incremental-builder.md +++ b/agents/tp-incremental-builder.md @@ -13,7 +13,7 @@ tools: - Edit - Bash model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 375a824d0d847bb5453ec594c7a62ad566ee7e4d92717b0473f771f1a0477c60 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-migration-scout.md b/agents/tp-migration-scout.md index d18499a..6a2f062 100644 --- a/agents/tp-migration-scout.md +++ b/agents/tp-migration-scout.md @@ -11,7 +11,7 @@ tools: - Grep - Glob model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 0334de1bf99b431b65359637d125cda7c44c6f780eb92c57cc538715b1939536 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-onboard.md b/agents/tp-onboard.md index a4b7e6f..e68dec3 100644 --- a/agents/tp-onboard.md +++ b/agents/tp-onboard.md @@ -10,7 +10,7 @@ tools: - mcp__token-pilot__smart_read - mcp__token-pilot__smart_read_many - mcp__token-pilot__read_section -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 832e95633fbc8e9b0c10f3e540a327d4be062fb4b3f17a6cce6be13f414e2927 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-performance-profiler.md b/agents/tp-performance-profiler.md index 1bba70e..03073f8 100644 --- a/agents/tp-performance-profiler.md +++ b/agents/tp-performance-profiler.md @@ -11,7 +11,7 @@ tools: - Bash - Read model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: b61f06380d80798fa2e49d37bcba0653495bee04dd6bdbc1feff9a75607b0508 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-pr-reviewer.md b/agents/tp-pr-reviewer.md index 1d9da30..7a5d8dd 100644 --- a/agents/tp-pr-reviewer.md +++ b/agents/tp-pr-reviewer.md @@ -11,7 +11,7 @@ tools: - mcp__token-pilot__read_for_edit - Read model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: f83f50d05b4f70285ae7afed2b1a406fc436df56e61a0aedbfb31edc7f2b6e66 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-refactor-planner.md b/agents/tp-refactor-planner.md index de04c67..06057b7 100644 --- a/agents/tp-refactor-planner.md +++ b/agents/tp-refactor-planner.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__outline - mcp__token-pilot__read_symbol model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: c5f6fc122c89e16e5cf774045f92169ee3468555320b898171ba13eca5323550 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-review-impact.md b/agents/tp-review-impact.md index 2eb3d13..f78f25f 100644 --- a/agents/tp-review-impact.md +++ b/agents/tp-review-impact.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__module_info - Bash model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 8ef3c3341cbfed4eb8dd130126a9683edc57e378c92ff0ca764d584fd941c55c requiredMcpServers: - "token-pilot" diff --git a/agents/tp-run.md b/agents/tp-run.md index 08204cd..5f17a2e 100644 --- a/agents/tp-run.md +++ b/agents/tp-run.md @@ -16,7 +16,7 @@ tools: - Glob - Bash model: haiku -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 2b08618d34a61f00aafccbda9fed6d83243296dedb83440edbd2d5c28bb6dbc4 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-session-restorer.md b/agents/tp-session-restorer.md index c1d1f46..72660d4 100644 --- a/agents/tp-session-restorer.md +++ b/agents/tp-session-restorer.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__session_budget - Bash - Read -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 529374ed728f5eed5b758b3be3da65624783c0bf0c1a253d7d661a843eb5f767 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-ship-coordinator.md b/agents/tp-ship-coordinator.md index 1c27aa4..21cfd2c 100644 --- a/agents/tp-ship-coordinator.md +++ b/agents/tp-ship-coordinator.md @@ -11,7 +11,7 @@ tools: - Read - Grep model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: a60f6ae110eb3138064bce074e8ba26fa0ce5f4659df1624a9d9d3646803391b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-spec-writer.md b/agents/tp-spec-writer.md index f2670d3..f79a804 100644 --- a/agents/tp-spec-writer.md +++ b/agents/tp-spec-writer.md @@ -9,7 +9,7 @@ tools: - Read - Write model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: c7a4e8b39228fd5158528f389c924c5ff2d98c4b9b05ee0106d54a26c5dc1350 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-coverage-gapper.md b/agents/tp-test-coverage-gapper.md index 46207f6..2e28b12 100644 --- a/agents/tp-test-coverage-gapper.md +++ b/agents/tp-test-coverage-gapper.md @@ -10,7 +10,7 @@ tools: - mcp__token-pilot__test_summary - Glob - Grep -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: be81eed53a3720d146cf89e4a14a7a56577633f7c84c234c412ab70d64c05b11 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-triage.md b/agents/tp-test-triage.md index 86d9150..75dc6c7 100644 --- a/agents/tp-test-triage.md +++ b/agents/tp-test-triage.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__find_usages - mcp__token-pilot__read_symbol model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 362ecf4cb03b059421ea26933473700900073dc38b3a7fe271208dfb1ae14f90 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-writer.md b/agents/tp-test-writer.md index 9cd5bc8..5ecb028 100644 --- a/agents/tp-test-writer.md +++ b/agents/tp-test-writer.md @@ -13,7 +13,7 @@ tools: - Edit - Bash model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 269f2fe22ff4517c277d3f56ca67d8a5527b93290ab21079a83ba7af22c1b5a9 requiredMcpServers: - "token-pilot" diff --git a/package-lock.json b/package-lock.json index da8a136..931d546 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "token-pilot", - "version": "0.48.0", + "version": "0.49.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "token-pilot", - "version": "0.48.0", + "version": "0.49.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 351671a..d0f7e00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "token-pilot", - "version": "0.48.0", + "version": "0.49.0", "description": "Save up to 80% tokens when AI reads code \u2014 MCP server for token-efficient code navigation, AST-aware structural reading instead of dumping full files into context window", "type": "module", "main": "dist/index.js", diff --git a/server.json b/server.json index d4ad605..38816d4 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/Digital-Threads/token-pilot", "source": "github" }, - "version": "0.48.0", + "version": "0.49.0", "packages": [ { "registryType": "npm", "identifier": "token-pilot", - "version": "0.48.0", + "version": "0.49.0", "transport": { "type": "stdio" }, diff --git a/src/hooks/subagent-stop.ts b/src/hooks/subagent-stop.ts index e001ae9..eda4cfc 100644 --- a/src/hooks/subagent-stop.ts +++ b/src/hooks/subagent-stop.ts @@ -32,6 +32,13 @@ import { readFileSync } from "node:fs"; import type { HookEvent } from "../core/event-log.js"; +import { estimateTokens } from "../core/token-estimator.js"; +import { + parseAgentBudget, + decideBudgetAdvice, + appendOverBudgetLog, + loadAgentBody, +} from "./post-task.js"; export interface SubagentStopInput { hook_event_name?: string; @@ -103,6 +110,110 @@ export function tokensFromTranscript(path: string | undefined): number { return out > 0 ? out : lastTotal; } +/** + * Size of the subagent's FINAL reply, in tokens. + * + * Distinct from `tokensFromTranscript`, which sums everything the agent + * ever emitted. The tp-* agents declare "Response budget: ~N tokens", + * and that budget is about the answer handed back to the caller — not + * about the thinking and tool calls along the way. So we take the last + * assistant turn carrying text and measure only that. + * + * The SubagentStop payload also has a `last_assistant_message` field + * (present in the 2.1.220 bundle), but nothing in this codebase has ever + * read it, so we have no evidence it is populated. The transcript is the + * source we already rely on, so it stays the single source here. + * + * Returns 0 when the path is missing, unreadable, or the agent finished + * without a text reply — never throws. + */ +export function finalResponseTokens(path: string | undefined): number { + if (!path || typeof path !== "string") return 0; + let raw: string; + try { + raw = readFileSync(path, "utf-8"); + } catch { + return 0; + } + let lastText = ""; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + let rec: unknown; + try { + rec = JSON.parse(line); + } catch { + continue; + } + const msg = ( + rec as { + message?: { role?: string; content?: unknown }; + } + ).message; + if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue; + const text = msg.content + .filter( + (b): b is { type: string; text: string } => + typeof b === "object" && + b !== null && + (b as { type?: unknown }).type === "text" && + typeof (b as { text?: unknown }).text === "string", + ) + .map((b) => b.text) + .join(""); + if (text.trim()) lastText = text; + } + return lastText ? estimateTokens(lastText) : 0; +} + +/** + * Response-budget watchdog for tp-* subagents. + * + * Lived in PostToolUse:Task since v0.37.0, but that hook does not fire + * for the dispatch tool on current Claude Code — so the check never ran, + * `over-budget.log` never appeared, and every recorded task event carried + * a null budget. SubagentStop is where completions actually arrive. + * + * Returns an advisory string when the reply overran the declared budget, + * or null in every other case (non-tp agent, no budget declared, agent + * file missing, transcript unreadable). Never throws: a watchdog must not + * be able to break subagent completion. + */ +export async function checkSubagentBudget( + projectRoot: string, + homeDir: string, + input: Pick, +): Promise { + try { + const agent = input.agent_type; + if (typeof agent !== "string" || !agent.startsWith("tp-")) return null; + + const actualTokens = finalResponseTokens(input.agent_transcript_path); + if (actualTokens <= 0) return null; + + const body = await loadAgentBody(projectRoot, homeDir, agent); + const budget = body ? parseAgentBudget(body) : null; + if (budget == null) return null; + + const decision = decideBudgetAdvice({ + agentName: agent, + budget, + actualTokens, + }); + if (!decision.overBudget) return null; + + await appendOverBudgetLog(projectRoot, { + ts: Date.now(), + agent, + budget, + actualTokens, + overByRatio: decision.overByRatio, + }); + return decision.message; + } catch { + return null; + } +} + /** * Build the `event:"task"` record from a SubagentStop payload. Pure — * no I/O except the optional transcript token read, which the caller diff --git a/src/index.ts b/src/index.ts index 9e772d2..7c5d6ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -415,6 +415,7 @@ export async function main(cliArgs = process.argv.slice(2)): Promise { buildSubagentTaskEvent, decideSubagentFeedback, renderSubagentFeedback, + checkSubagentBudget, } = await import("./hooks/subagent-stop.js"); const ev = buildSubagentTaskEvent(input, Date.now()); if (ev) { @@ -422,6 +423,18 @@ export async function main(cliArgs = process.argv.slice(2)): Promise { await appendEvent(process.cwd(), ev); } + // v0.49.0 — the tp-* response-budget watchdog. It used to hang off + // PostToolUse:Task, which never fires here, so it has been silently + // dead: no over-budget.log, every task event with a null budget. + // The log write is unconditional (that is the record); surfacing it + // in the transcript rides the same feature gate as the feedback + // below, since SubagentStop additionalContext needs CC 2.1.163+. + const budgetMessage = await checkSubagentBudget( + process.cwd(), + homedir(), + input, + ); + // v0.41.0 — optional SubagentStop feedback. Returning // hookSpecificOutput.additionalContext from SubagentStop is a // Claude Code 2.1.163+ feature; on older Claude Code it is @@ -445,9 +458,13 @@ export async function main(cliArgs = process.argv.slice(2)): Promise { }; } } - const rendered = renderSubagentFeedback( + const combined = [ + budgetMessage, decideSubagentFeedback(input, { workflow: wf }), - ); + ] + .filter(Boolean) + .join("\n"); + const rendered = renderSubagentFeedback(combined || null); if (rendered) process.stdout.write(rendered); } }); diff --git a/tests/hooks/subagent-budget.test.ts b/tests/hooks/subagent-budget.test.ts new file mode 100644 index 0000000..cca0d41 --- /dev/null +++ b/tests/hooks/subagent-budget.test.ts @@ -0,0 +1,198 @@ +/** + * v0.49.0 — the tp-* response-budget watchdog, revived. + * + * The budget check has lived in PostToolUse:Task since v0.37.0, but that + * hook does not fire for the dispatch tool on current Claude Code (noted + * in installer.ts since v0.39.3). Consequence: `.token-pilot/over-budget.log` + * never appeared and every recorded task event carried a null budget — the + * watchdog has been dead for months without anyone noticing. + * + * SubagentStop is where subagent completions actually arrive, so the check + * belongs here. The measured value is the size of the agent's FINAL reply, + * not the sum of everything it emitted along the way: the agents declare + * "Response budget: ~N tokens", which is about the answer they hand back. + */ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, writeFile, mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + finalResponseTokens, + checkSubagentBudget, +} from "../../src/hooks/subagent-stop.ts"; + +/** One JSONL line in the shape Claude Code writes for a subagent turn. */ +function assistantLine(text: string): string { + return JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text }], + usage: { output_tokens: 10 }, + }, + }); +} + +function toolUseLine(): string { + return JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "Read", input: {} }], + usage: { output_tokens: 40 }, + }, + }); +} + +describe("finalResponseTokens", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "tp-budget-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + async function transcript(lines: string[]): Promise { + const p = join(dir, "agent.jsonl"); + await writeFile(p, lines.join("\n") + "\n"); + return p; + } + + it("returns 0 for a missing or unreadable path", async () => { + expect(finalResponseTokens(undefined)).toBe(0); + expect(finalResponseTokens(join(dir, "nope.jsonl"))).toBe(0); + }); + + it("measures the last assistant text, not the whole transcript", async () => { + const p = await transcript([ + assistantLine("x".repeat(4000)), + assistantLine("short answer"), + ]); + // Only the final reply counts, so this stays tiny despite the 4000-char + // turn before it. + expect(finalResponseTokens(p)).toBeLessThan(10); + expect(finalResponseTokens(p)).toBeGreaterThan(0); + }); + + it("ignores tool_use blocks that follow the last text block", async () => { + const p = await transcript([ + assistantLine("the real answer"), + toolUseLine(), + ]); + expect(finalResponseTokens(p)).toBeGreaterThan(0); + expect(finalResponseTokens(p)).toBeLessThan(10); + }); + + it("survives malformed lines", async () => { + const p = await transcript([ + "not json{{{", + assistantLine("still readable"), + "", + ]); + expect(finalResponseTokens(p)).toBeGreaterThan(0); + }); + + it("returns 0 when the agent produced no text reply", async () => { + const p = await transcript([toolUseLine()]); + expect(finalResponseTokens(p)).toBe(0); + }); +}); + +describe("checkSubagentBudget", () => { + let projectRoot: string; + let homeDir: string; + + /** Write an agent definition carrying a response budget. */ + async function writeAgent(name: string, budget: number | null) { + const agentsDir = join(projectRoot, ".claude", "agents"); + await mkdir(agentsDir, { recursive: true }); + const body = + budget === null + ? `---\nname: ${name}\n---\n\nNo budget declared.\n` + : `---\nname: ${name}\n---\n\nResponse budget: ~${budget} tokens\n`; + await writeFile(join(agentsDir, `${name}.md`), body); + } + + async function writeTranscript(text: string): Promise { + const p = join(projectRoot, "agent.jsonl"); + await writeFile(p, assistantLine(text) + "\n"); + return p; + } + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "tp-budget-proj-")); + homeDir = await mkdtemp(join(tmpdir(), "tp-budget-home-")); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true }); + }); + + it("ignores agents that are not tp-*", async () => { + const transcriptPath = await writeTranscript("x".repeat(8000)); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "general-purpose", + agent_transcript_path: transcriptPath, + }); + expect(msg).toBeNull(); + }); + + it("stays silent when the reply fits the budget", async () => { + await writeAgent("tp-debugger", 600); + const transcriptPath = await writeTranscript("a short answer"); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "tp-debugger", + agent_transcript_path: transcriptPath, + }); + expect(msg).toBeNull(); + }); + + it("flags a reply that overruns the budget and logs it", async () => { + await writeAgent("tp-debugger", 100); + const transcriptPath = await writeTranscript("x".repeat(8000)); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "tp-debugger", + agent_transcript_path: transcriptPath, + }); + expect(msg).toContain("tp-debugger"); + expect(msg).toContain("100"); + + const log = await readFile( + join(projectRoot, ".token-pilot", "over-budget.log"), + "utf-8", + ); + expect(log).toContain("tp-debugger"); + }); + + it("stays silent when the agent declares no budget", async () => { + await writeAgent("tp-debugger", null); + const transcriptPath = await writeTranscript("x".repeat(8000)); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "tp-debugger", + agent_transcript_path: transcriptPath, + }); + expect(msg).toBeNull(); + }); + + it("stays silent when the agent file cannot be found", async () => { + const transcriptPath = await writeTranscript("x".repeat(8000)); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "tp-nonexistent", + agent_transcript_path: transcriptPath, + }); + expect(msg).toBeNull(); + }); + + it("never throws when the transcript is missing", async () => { + await writeAgent("tp-debugger", 100); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "tp-debugger", + agent_transcript_path: join(projectRoot, "gone.jsonl"), + }); + expect(msg).toBeNull(); + }); +});