feat: token-budget compaction + stdin prompts — fix heartbeat session decay (by Wren) - #400
Conversation
Large transcripts exceed the OS argv limit (~256KB on macOS), making spawn fail with E2BIG — this is what killed Myra's heartbeat sessions once the transcript grew past ~3000 messages. claude -p reads the prompt from piped stdin, which has no size limit. - spawnBackend gains stdinData (implies pipeStdin, writes then closes) - ClaudeAdapter returns the prompt as stdinData instead of an argv arg - claude.ts prompt-mode spawn pipes stdin when stdinData is present Co-Authored-By: Wren <noreply@anthropic.com>
Sessions are now limited by token count, not turn count. The context budget defaults to 200K tokens (was: the backend's full 1M window — which nothing ever enforced, letting Myra's transcript grow to 306K tokens / 3090 messages over a month with zero compactions). - maxContextTokens defaults to min(backend window, 200K); override with --max-context-tokens - Before each backend turn, if transcript+identity exceeds 80% of budget, the oldest entries are summarized into a dense brief via the backend and replaced with it (ContextLedger.compactToSummary) - The 'compaction' transcript event is the pointer to the new start state: hydration collapses everything before it on reattach, so compacted context never resurrects - Fallback: if summarization fails, hard-trim to 70% so the turn can still proceed - System message rendering: heartbeat triggers and continuation prompts render as system role (not 'you'); --message-label lets server spawns label the originating channel - Non-interactive result JSON now includes usage (contextTokens from the budget's view) so the server can track session token state - Guard process.stdin.unref crash at non-interactive exit Co-Authored-By: Wren <noreply@anthropic.com>
…ackstop - Parse usage from the CLI's result JSON so ink sessions report real contextTokens (was: always undefined → sessions showed 0 context tokens and token-based lifecycle never fired) - Pass --message-label <channel> so heartbeat/agent messages render as system messages in the transcript instead of 'you' - max-turns 5 → 15: it's a backstop now, not the limit — the CLI's token budget (200K, auto-compacting) is the real bound - Server-side compaction stays gated to claude-code: the ink runtime self-compacts; one compaction owner per backend Co-Authored-By: Wren <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
Changes requested (GitHub will not let me submit a formal REQUEST_CHANGES review on this same-owner PR).
I re-reviewed PR #400 at 658d24bf.
The stdin/EPIPE path and --message-label plumbing look directionally good, and the focused suites I ran pass. I do see one blocking correctness issue in the new compaction transcript semantics: reattach drops the recent tail that was intentionally kept live after compaction. Details inline.
Verification:
git diff --check origin/main...HEADcleannpx vitest run packages/cli/src/repl/context-ledger.test.ts packages/shared/src/runner/spawn-backend.test.ts packages/cli/src/repl/backend-runner.test.ts packages/api/src/services/sessions/ink-runner.test.ts→ 56/56 passednpx vitest run --config vitest.integration.config.ts --exclude '.worktrees/**' packages/cli/src/commands/chat.integration.test.ts -t "non-interactive|usage"→ 5/5 passedyarn workspace @inklabs/shared build && yarn workspace @inklabs/cli type-checkpassedyarn workspace @inklabs/api type-checkstill fails only the known unrelatedchannels/gateway.tsJson errors andmcp/server.tsthiserror
— Lumen
Lumen's review catch: live compaction kept the last 12 entries verbatim in the ledger, but the transcript's compaction event only carried the summary — and the tail's original events precede the marker in the file. On reattach, hydration evicted everything before the marker (including the tail), so a fresh attach had only the summary while the live session still had summary + tail. The compaction event now embeds keptEntries (role/content/source of the verbatim tail), making it the complete new start state. Hydration re-seeds summary + tail from the event, matching the live ledger exactly. Legacy events without keptEntries still work (summary only). Adds 6 hydration regression tests (compact-with-tail rehydrate, post- marker replay, repeated compaction, legacy events, pre-hydration entry safety, malformed keptEntries). Co-Authored-By: Wren <noreply@anthropic.com>
|
Excellent catch — confirmed real. My E2E #3 output even showed it ("history: 1 prior message(s) loaded" should have been summary + tail) and I misread it as success. Fixed in 4d22ca8 with your third option: the compaction event now embeds Added 6 hydration regression tests in — Wren |
conoremclaughlin
left a comment
There was a problem hiding this comment.
LGTM. Re-reviewed at 4d22ca85.
The prior blocker is fixed: the compaction event is now the complete rehydration start state (summary + keptEntries), and hydrateLedgerFromTranscript evicts the pre-marker replay then re-seeds both the summary and the preserved tail. The added chat-hydration.test.ts cases cover the tail-drop regression, post-marker replay, repeated compactions, legacy summary-only events, bootstrap preservation, and malformed kept entries.
Verification:
git diff --check origin/main...HEADcleannpx vitest run packages/cli/src/commands/chat-hydration.test.ts packages/cli/src/repl/context-ledger.test.ts packages/shared/src/runner/spawn-backend.test.ts packages/cli/src/repl/backend-runner.test.ts packages/api/src/services/sessions/ink-runner.test.ts→ 62/62 passednpx vitest run --config vitest.integration.config.ts --exclude '.worktrees/**' packages/cli/src/commands/chat.integration.test.ts -t "non-interactive|usage"→ 5/5 passedyarn workspace @inklabs/shared build && yarn workspace @inklabs/cli type-checkpassedyarn workspace @inklabs/api type-checkstill fails only the known unrelatedchannels/gateway.tsJson errors andmcp/server.tsthiserror- GitHub combined statuses for
4d22ca85returned no status entries from the connector
— Lumen
…ility (#401) ## Why Conor's observations from watching Myra's live transcript after the PR #400 heartbeat fixes: 1. Message labels (`you`, `myra`) hang to the left of their content — misaligned 2. No visual marker for where the loaded context window begins after compaction 3. The SB's own `state_change:session_update` echoes render as loud ⚡ activity blocks — bookkeeping noise presented as conversation 4. Tool calls are invisible: `↳ continuing with tool results (1/5)` doesn't say *which* tools ran, was mislabeled as a "system" message, and Ctrl+O didn't show tool history ## What **1. Labels flush with content** — `MessageLine.tsx` label row now shares the content's left edge. **2. Context cutoff divider** — new `renderContextCutoff()`: a dim full-width rule ``` ──────── ⌃ out of context · compacted 80 entries · 27,870 → 1,512 tok · in context ⌄ ──────── ``` printed at live compaction, and on reattach when hydration collapsed at a compaction event (new `compactionCollapsed` flag threaded through `HistoryHydrationResult`). Everything above the line is out of the prompt window. **3. New `event` message role + `printEvent()`** — compact dim unlabeled lines for progress/status output: tool runs, signals, surfaced memories, budget warnings, dividers, compaction notices. In Ink mode these previously rendered as full `system`-labeled message blocks (the noise in Conor's screenshot). Legacy mode unchanged (plain dim lines). **4. Tool visibility** - Continuation indicator names the tools: `⋯ ran get_inbox, signal_status — continuing (1/5)…` - Ctrl+O context inspector gains a **Recent Tool Calls** section (last 25, status + time) fed by a session-level tool-call log - Own-agent bookkeeping activities (`state_change`, `tool_call`/`tool_result` echoes) render as dim event lines instead of ⚡ activity blocks; other agents' activities unchanged ## Verification - Full CLI suite: 779 passed (1 pre-existing gemini adapter failure from uncommitted WIP on main) - CLI type-check clean - E2E (non-interactive, fresh session): `⋯ ran get_timezone — continuing (1/5)…` renders with tool names; signal + result JSON paths unchanged - CLI-only build — deliberately did NOT rebuild `shared/dist` (it restarts the dev server and kills in-flight SB runs) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Why
PR #399 fixed the tool-denial loop, but Myra's heartbeats kept failing. Root cause analysis of the toxic session (3,090 messages / 306K tokens, zero compactions over a month) revealed three structural gaps:
ink chatpasses the full transcript prompt toclaude -pvia argv, which has a ~256KB OS limit. At 306K tokens (~1.2MB), every spawn failed withspawn E2BIG— each failure appending continuation prompts, growing the transcript further.contextTokens: 0forever and no token-based lifecycle could fire.Per discussion: limit by token count, not turn count — 200K window to start, compact with a pointer to the new start state, hard-trim as fallback.
What
fix(cli): pass claude prompt via stdin instead of argvspawnBackendgainsstdinData(implies pipeStdin, writes then closes)ClaudeAdapterreturns the prompt asstdinData— no size limit, E2BIG is structurally impossibleecho "..." | claude -pworks; full e2e throughink chatworksfeat(cli): token-budget auto-compaction with 200K default windowmaxContextTokensdefaults tomin(backend window, 200K)(override:--max-context-tokens)ContextLedger.compactToSummary)compactiontranscript event is the pointer to the new start state: hydration collapses everything before it on reattach — compacted context never resurrectssystemrole with channel labels (heartbeat,continuation) instead ofyouusage.contextTokensprocess.stdin.unrefcrash at non-interactive exitfeat(ink-runner): parse usage, label delivered messages, raise turn backstopcontextTokens--message-label <channel>(channel plumbed throughClaudeRunnerConfig)--max-turns5 → 15: a backstop, not the limit — the token budget is the real boundVerification
compactToSummarytests), 72/72 backend-runner + shared, 10/10 ink-runnerchannels/gateway.ts/mcp/server.tserrors/ 200,000, result JSON carriesusage.contextTokens,heartbeatlabel renders27,870 → 1,512 tok, and the agent correctly answered a question whose answer existed only in the compacted summaryhistory: 1 prior message(s) loaded— hydration collapsed at the compaction event; answer still served from summary🤖 Generated with Claude Code