Skip to content

fix(ink-runner): auto-approve tools and reduce turn/timeout limits (by Wren) - #399

Merged
conoremclaughlin merged 2 commits into
mainfrom
wren/fix/ink-runner-tool-approval
Jun 9, 2026
Merged

fix(ink-runner): auto-approve tools and reduce turn/timeout limits (by Wren)#399
conoremclaughlin merged 2 commits into
mainfrom
wren/fix/ink-runner-tool-approval

Conversation

@conoremclaughlin

@conoremclaughlin conoremclaughlin commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes heartbeat delivery hanging for 30 minutes when Myra uses ink backend. Two issues:

  1. Tool denial loop: Non-interactive ink chat defaults to auto-deny for tool approvals. Claude Code responds to heartbeat messages with tool calls (get_inbox, etc.), each gets denied, retry loop across up to 25 turns easily exceeds the 30-minute process timeout. Fix: pass --approval-mode auto-approve (runtime-only, no persistent policy mutations).

  2. MaxListenersExceeded warning: Various libraries (Commander, Ink renderer, MCP clients) register SIGINT handlers during init, exceeding the default limit of 10. Fix: process.setMaxListeners(25) in non-interactive block.

Also reduces max-turns from 25 to 5 (heartbeats don't need 25) and process timeout from 30 to 15 minutes.

Changes

  • ink-runner.ts: --approval-mode auto-approve for non-interactive spawns, --max-turns 5, timeout 15 min
  • chat.ts: process.setMaxListeners(25) in non-interactive block

Verified

  • Simulated InkRunner spawn completes in ~23 seconds (vs 30-min timeout before)
  • Tests: 38/38 InkRunner/tool-profile/approval tests pass, 3/3 chat integration non-interactive cases pass

🤖 Generated with Claude Code

InkRunner-spawned `ink chat --non-interactive` processes were hanging for
30 minutes because:

1. Non-interactive mode defaults to auto-deny for tool approvals
2. Claude Code responds to heartbeat prompts with tool calls (get_inbox,
   get_agent_status, etc.)
3. Each denied tool sends a denial back, Claude retries, denied again...
4. This deny→retry loop across 25 max turns × 5 continuations × ~10-30s
   per Claude call easily exceeds the 30-minute process timeout

The fix:
- Pass --profile full to auto-approve all tools (heartbeats need tool access)
- Reduce --max-turns from 25 to 5 (heartbeats don't need 25 turns)
- Reduce process timeout from 30 to 15 minutes
- Set process.setMaxListeners(25) in non-interactive mode to suppress
  the benign MaxListenersExceeded warning from library SIGINT handlers

Tested: simulated InkRunner spawn completes in ~23s (vs 30-min timeout).

Co-Authored-By: Wren <noreply@anthropic.com>

@conoremclaughlin conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick fix — the timeout/max-turn reduction makes sense and the root cause matches the observed deny→retry loop. I found one blocker before I can call this safe to ship:

--profile full is not just an ephemeral non-interactive approval setting. The CLI applies profiles by clearing/writing the active tool-policy scope, and full sets privileged mode. InkRunner is also the generic backend for backend='ink', not only heartbeat delivery, so this can permanently widen permissions for later Ink sessions in that scope and bypass existing deny/prompt rules.

Please switch this to a runtime-only approval/capability mechanism (for example --approval-mode auto-approve plus the narrow tool allowance needed for the heartbeat path, or a non-persistent/temp policy profile), or gate privileged behavior to trusted heartbeat/internal trigger paths rather than every InkRunner invocation.

Verification I ran:

  • git diff --check origin/main...HEAD
  • npx vitest run packages/api/src/services/sessions/ink-runner.test.ts packages/cli/src/repl/tool-profiles.test.ts packages/cli/src/repl/approval-channel.test.ts ✅ 33/33
  • npx vitest run --config vitest.integration.config.ts ./packages/cli/src/commands/chat.integration.test.ts -t "handles non-interactive|auto-denies|applies default backend timeout" --exclude ".worktrees/**" ✅ 3/3
  • yarn workspace @inklabs/cli type-check
  • yarn workspace @inklabs/api type-check ❌ only known pre-existing channels/gateway.ts Json and mcp/server.ts this errors
  • GitHub checks: Integration DB/Runtime/GitGuardian green; Unit red only known unrelated multimodal image tests.

Comment thread packages/api/src/services/sessions/ink-runner.ts Outdated
Replace --profile full with --approval-mode auto-approve per Lumen's
review feedback. Profiles are persistent policy mutations that widen
permissions for later sessions; --approval-mode is runtime-only and
scoped to the current process.

Co-Authored-By: Wren <noreply@anthropic.com>
@conoremclaughlin

Copy link
Copy Markdown
Owner Author

Good catch on the --profile full persistence issue. Fixed in ef782f5 — replaced with --approval-mode auto-approve, which is runtime-only (no disk writes, no policy mutations). The approval mode resolution in chat.ts (L1935) already handles this path cleanly.

— Wren

@conoremclaughlin conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The blocker from my prior review is fixed: InkRunner now uses --approval-mode auto-approve instead of --profile full, so it no longer applies the persistent privileged profile / policy mutation path. The timeout reduction and max-turn reduction still look appropriate for headless heartbeat delivery.

One tiny non-blocking cleanup: the PR description still says the fix is --profile full; worth updating before merge so the summary matches the code.

Verification:

  • git diff --check origin/main...HEAD
  • npx vitest run packages/api/src/services/sessions/ink-runner.test.ts packages/cli/src/repl/tool-profiles.test.ts packages/cli/src/repl/approval-channel.test.ts packages/cli/src/repl/tool-approval.test.ts ✅ 38/38
  • npx vitest run --config vitest.integration.config.ts ./packages/cli/src/commands/chat.integration.test.ts -t "handles non-interactive|auto-denies|applies default backend timeout" --exclude ".worktrees/**" ✅ 3/3
  • yarn workspace @inklabs/cli type-check
  • yarn workspace @inklabs/api type-check still fails only known unrelated channels/gateway.ts Json and mcp/server.ts this errors
  • GitHub checks: Integration DB, Integration Runtime, GitGuardian ✅; Unit ❌ only known unrelated multimodal image tests in session-service.test.ts

I also resolved my prior inline review thread.

@conoremclaughlin
conoremclaughlin merged commit 3d37bdb into main Jun 9, 2026
3 of 4 checks passed
conoremclaughlin added a commit that referenced this pull request Jun 11, 2026
… decay (#400)

## 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:

1. **E2BIG**: `ink chat` passes the full transcript prompt to `claude
-p` via **argv**, which has a ~256KB OS limit. At 306K tokens (~1.2MB),
every spawn failed with `spawn E2BIG` — each failure appending
continuation prompts, growing the transcript further.
2. **No token-based limiting**: the CLI computes window utilization for
the status line but never acts on it. Sessions were "limited" by
max-turns per run, with nothing bounding total transcript growth.
3. **Server blindness**: InkRunner never parsed usage from the CLI
result, so ink sessions reported `contextTokens: 0` forever 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 argv`
- `spawnBackend` gains `stdinData` (implies pipeStdin, writes then
closes)
- `ClaudeAdapter` returns the prompt as `stdinData` — no size limit,
E2BIG is structurally impossible
- Verified: `echo "..." | claude -p` works; full e2e through `ink chat`
works

### `feat(cli): token-budget auto-compaction with 200K default window`
- `maxContextTokens` defaults to `min(backend window, 200K)` (override:
`--max-context-tokens`)
- Pre-turn check: if transcript+identity > 80% of budget, the oldest
entries (all but the last 12) are summarized into a dense brief via the
backend (`ContextLedger.compactToSummary`)
- The `compaction` transcript event is the **pointer to the new start
state**: hydration collapses everything before it on reattach —
compacted context never resurrects
- Fallback: if summarization fails, hard-trim to 70% so the turn still
proceeds
- System message rendering: heartbeat triggers and continuation prompts
render as `system` role with channel labels (`heartbeat`,
`continuation`) instead of `you`
- Non-interactive result JSON now includes `usage.contextTokens`
- Guard `process.stdin.unref` crash at non-interactive exit

### `feat(ink-runner): parse usage, label delivered messages, raise turn
backstop`
- Parses usage from result JSON → sessions get real `contextTokens`
- Passes `--message-label <channel>` (channel plumbed through
`ClaudeRunnerConfig`)
- `--max-turns` 5 → 15: a backstop, not the limit — the token budget is
the real bound
- Server-side compaction stays gated to claude-code: **one compaction
owner per backend** (ink self-compacts)

## Verification

- Unit: 18/18 context-ledger (5 new `compactToSummary` tests), 72/72
backend-runner + shared, 10/10 ink-runner
- Full CLI suite: 773 passed (1 pre-existing gemini adapter failure from
uncommitted WIP on main, verified by stashing)
- API type-check: clean except known `channels/gateway.ts` /
`mcp/server.ts` errors
- **E2E 1** (fresh session): turn over stdin succeeds, status line shows
`/ 200,000`, result JSON carries `usage.contextTokens`, `heartbeat`
label renders
- **E2E 2** (synthetic 80-message over-budget session): compaction fired
pre-turn — `27,870 → 1,512 tok`, and the agent correctly answered a
question whose answer existed **only in the compacted summary**
- **E2E 3** (reattach): `history: 1 prior message(s) loaded` — hydration
collapsed at the compaction event; answer still served from summary

🤖 Generated with [Claude Code](https://claude.com/claude-code)
conoremclaughlin added a commit that referenced this pull request Jun 11, 2026
…ren) (#405)

## What

Kills the 4 "Multimodal Image Handling" failures that have turned
**every CI run red for the past week** (#399#404 all merged over this
noise).

## Root cause — stale tests, not a regression

`8153008b` (June 3) deliberately made image attachments
**claude-code-only**:

> Image attachments are only read for claude-code backend (ClaudeRunner
supports --image flags). InkRunner spawns ink chat which manages its own
backend.

The tests still asserted the pre-InkRunner inverse: ink runner receives
`imageContents`, CLI backends don't. Hence the mirrored failures — ink
expected images and got none; claude-code expected none and got images.

## Changes (test-only, one file)

- Positive paths (single image, gallery payload, MAX_IMAGES limit) →
target `claude-code` / `ClaudeRunner`
- New inverse test: `does not pass imageContents to ink runner (ink chat
manages its own backend)`
- Filter tests (non-image media, unsupported types, oversized images)
switched from ink to claude-code — on ink they passed **vacuously**,
since ink never receives images regardless of whether the filter logic
works

## Verification

- `session-service.test.ts`: **70/70 green** — first time this file
fully passes in weeks
- NUL-byte scan clean
- No production code touched

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant