Nudges /handoff at 70% of the compaction limit and runs it at 90%; keeps an idle session's cache warm with 2 pings, then hands off, and continues from the…

Complete setup guide to reproduce this Claude Code environment on Windows 11.
| Tool | Version | Install |
|---|---|---|
| NVM for Windows | 1.2.2 | winget install CoreyButler.NVMforWindows |
| Node.js | 24.13.0 | nvm install 24.13.0 && nvm use 24.13.0 |
| pnpm | latest | corepack enable && corepack prepare pnpm@latest --activate |
| Python | 3.14+ | winget install Python.Python.3.14 |
| uv | 0.10+ | pip install uv or winget install astral-sh.uv |
| GitHub CLI | latest | winget install GitHub.cli |
| Git | latest | winget install Git.Git |
irm https://claude.ai/install.ps1 | iex
Verify: claude --version (should show 2.1.x)
Repo: rtk-ai/rtk | Website: rtk-ai.app
RTK is a CLI proxy written in Rust that filters and compresses command outputs before they reach your LLM context, saving 60-90% of tokens on common dev commands (git, cargo, vitest, tsc, eslint, etc.).
Option A: Pre-built binary (recommended for Windows)
# 1. Download the Windows binary from GitHub Releases
# https://github.com/rtk-ai/rtk/releases
# Get: rtk-x86_64-pc-windows-msvc.zip
# 2. Extract and place in ~/bin
mkdir "$HOME\bin" -Force
# Extract rtk.exe to ~/bin/
# 3. Add ~/bin to PATH (one-time)
[Environment]::SetEnvironmentVariable("PATH", "$env:PATH;$HOME\bin", "User")
Option B: Cargo install (requires Rust toolchain)
cargo install --git https://github.com/rtk-ai/rtk
Option C: Quick install script (Linux/macOS)
curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh
Post-install: Initialize for Claude Code
rtk init --global
This installs the hook and creates RTK.md with instructions for settings.json.
Verify: rtk --version (should show rtk 0.15.0+) and rtk gain (must show token savings stats)
Name collision warning: If
rtk gainfails, you may havereachingforthejack/rtk(Rust Type Kit) installed instead. Uninstall it first.
Repo: sparfenyuk/mcp-proxy | PyPI: mcp-proxy
mcp-proxy bridges between stdio and SSE/Streamable HTTP transports for MCP servers. In aggregation mode it exposes multiple MCP servers behind a single HTTP endpoint.
Option A: uv (recommended)
uv tool install mcp-proxy
Option B: Docker (from v0.3.2+)
docker run --rm -t ghcr.io/sparfenyuk/mcp-proxy:latest --help
Update an existing install:
uv tool upgrade --reinstall mcp-proxy
Verify: where mcp-proxy (should show ~/.local/bin/mcp-proxy.exe)
Repo: github/github-mcp-server
# Download the latest release for Windows from:
# https://github.com/github/github-mcp-server/releases
# Or build from source (requires Go): go build -o github-mcp-server.exe ./cmd/github-mcp-server
# Place github-mcp-server.exe in ~/bin/
Authenticate with GitHub CLI first: gh auth login
Create ~/bin/github-mcp-wrapper.cmd for dynamic auth via gh:
@echo off
for /f "tokens=*" %%a in ('gh auth token 2^>nul') do set GITHUB_PERSONAL_ACCESS_TOKEN=%%a
"%~dp0github-mcp-server.exe" stdio
Create ~/bin/github-mcp-wrapper.sh for Git Bash:
#!/bin/bash
export GITHUB_PERSONAL_ACCESS_TOKEN=$("/c/Program Files/GitHub CLI/gh" auth token 2>/dev/null)
exec "/c/Users/$USERNAME/bin/github-mcp-server.exe" stdio
/login
The main Claude Code settings file. Key sections:
{
// Git Bash doesn't obey .gitignore — disable for correct file access
"respectGitignore": false,
// Environment variables
"env": {
"BASH_MAX_TIMEOUT_MS": "1800000", // 30-minute bash timeout
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", // Enable native agent teams
},
// Disable co-authored-by in commits
"includeCoAuthoredBy": false,
// Permission allow/deny lists
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(ls:*)",
"Bash(mkdir:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:www.typescriptlang.org)",
"Bash(git log:*)",
"Bash(gh issue list:*)",
"Bash(gh issue view:*)",
"Bash(npx prettier:*)",
"Bash(nx prepush:*)",
"Bash(pnpm commit:*)",
"Bash(rg:*)",
"mcp__nx__nx_docs",
"mcp__nx__nx_workspace",
"mcp__nx__nx_project_details",
"Bash(nx show projects:*)",
"Bash(nx run-many:*)",
"Bash(nx run:*)",
"Bash(nx affected:*)",
"Bash(nx lint:*)",
"Bash(nx test:*)",
"Bash(nx build:*)",
"Bash(nx documentation:*)",
],
"deny": [
"Bash(git push origin main:*)",
"Bash(git push origin master:*)",
"Bash(rm -rf:*)",
"Bash(curl:*)",
"Bash(wget:*)",
],
},
// Auto-discover .mcp.json in projects
"enableAllProjectMcpServers": true,
// Status line — provided by the claude-hud plugin (see HUD section)
"statusLine": {
"type": "command",
"command": "node <resolved claude-hud plugin>/dist/index.js",
},
// Enabled plugins
"enabledPlugins": {
"typescript-lsp@claude-plugins-official": true,
"marksman-lsp": true,
"lua-lsp@claude-plugins-official": true,
"rust-analyzer-lsp@claude-plugins-official": true,
"claude-hud@claude-hud": true,
},
// Hooks — nul-guard + RTK auto-rewrite and suggest
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node C:/Users/<YOUR_USERNAME>/.claude/hooks/nul-guard.js",
},
{
"type": "command",
"command": "node C:/Users/<YOUR_USERNAME>/.claude/hooks/rtk/rtk-rewrite.js",
},
{
"type": "command",
"command": "node C:/Users/<YOUR_USERNAME>/.claude/hooks/rtk/rtk-suggest.js",
},
],
},
],
},
}
Copy from mcp-proxy-servers.example.json and fill in your API keys:
Copy-Item mcp-proxy-servers.example.json mcp-proxy-servers.json
# Then edit mcp-proxy-servers.json with your actual keys
The proxy config defines these MCP servers:
| Server | Package / Binary | Requires |
|---|---|---|
| github | ~/bin/github-mcp-server.exe | GITHUB_PERSONAL_ACCESS_TOKEN |
| codex-cli | pnpm dlx @cexll/codex-mcp-server | OPENAI_API_KEY (env) |
| gemini-cli | pnpm dlx gemini-mcp-tool | GEMINI_API_KEY (env) |
| angular-cli | pnpm dlx @angular/cli mcp | - |
| context7 | pnpm dlx @upstash/context7-mcp | CONTEXT7_API_KEY |
| exa | pnpm dlx exa-mcp-server | EXA_API_KEY |
| filesystem | pnpm dlx @modelcontextprotocol/server-filesystem D:\ | - |
| greb-mcp | greb-mcp-js (from cheetah-greb) | GREB_API_KEY |
| taigaApi | uv run src/server.py (from D:/pytaiga-mcp) | - |
All pnpm dlx servers run on-demand (no global install needed) as long as pnpm is on PATH. The proxy launches them automatically. You only need to install the standalone binaries and obtain API keys.
Repo: github/github-mcp-server
# 1. Download the latest release for Windows
# https://github.com/github/github-mcp-server/releases
# 2. Place in ~/bin
Copy-Item github-mcp-server.exe "$HOME\bin\"
# 3. Authenticate with GitHub CLI (the wrapper uses `gh auth token`)
gh auth login
Alternative: use the remote server at https://api.githubcopilot.com/mcp/ (requires OAuth or PAT).
Repo: cexll/codex-mcp-server | npm: @cexll/codex-mcp-server
MCP server that connects to OpenAI's Codex CLI for code analysis, refactoring, and automation. Runs on-demand via pnpm dlx (no global install needed).
# Quick add to Claude Code:
claude mcp add codex-cli -- npx -y @cexll/codex-mcp-server
# Or global install:
npm install -g @cexll/codex-mcp-server
Prerequisite: OpenAI Codex CLI installed and authenticated.
Repo: jamubc/gemini-mcp-tool | npm: gemini-mcp-tool
MCP server that connects to Google's Gemini CLI, leveraging Gemini's 1M token window for large file analysis and codebase understanding. Runs on-demand via pnpm dlx.
# Quick add to Claude Code:
claude mcp add gemini-cli -- npx -y gemini-mcp-tool
# Or global install:
npm install -g gemini-mcp-tool
Prerequisite: Google Gemini CLI installed and configured.
Repo: angular/angular-cli | npm: @angular/cli
Built-in MCP server in Angular CLI for project introspection, documentation, and best practices. No API key required.
# Verify it works standalone:
pnpm dlx @angular/cli mcp --help
Repo: upstash/context7-mcp | npm: @upstash/context7-mcp
Provides up-to-date code documentation for LLMs, avoiding hallucinated APIs and outdated examples. Works without a key (rate-limited) or with a free API key for higher limits.
# Quick add to Claude Code:
claude mcp add --scope user context7 -- npx -y @upstash/context7-mcp --api-key YOUR_API_KEY
Get a free API key at: https://context7.com/dashboard
Add to mcp-proxy-servers.json:
"env": { "CONTEXT7_API_KEY": "ctx7sk-..." }
Repo: exa-labs/exa-mcp-server | npm: exa-mcp-server
Web search and crawling MCP server powered by Exa's neural search. Runs on-demand via pnpm dlx.
# Quick add to Claude Code:
claude mcp add --transport http exa https://mcp.exa.ai/mcp
Get an API key at: https://dashboard.exa.ai/api-keys
Add to mcp-proxy-servers.json:
"env": { "EXA_API_KEY": "..." }
The config uses --tools=web_search_advanced_exa,crawling_exa to expose only search and crawling tools.
Repo: modelcontextprotocol/servers | npm: @modelcontextprotocol/server-filesystem
Node.js filesystem operations server with directory access controls. No API key required. Runs on-demand via pnpm dlx. The D:\ argument grants read/write access to the D: drive.
# Verify it works standalone:
pnpm dlx @modelcontextprotocol/server-filesystem D:\
Security note: This grants the MCP server access to the entire D: drive. Adjust the path argument to limit scope if needed.
Repo: VaibhavRaina/greb | npm: cheetah-greb | Website: grebmcp.com
Semantic code search via MCP using natural language queries. Searches your codebase with AI-powered ranking — no indexing required. Works with Claude Code, Cursor, Windsurf, and other MCP clients.
pnpm install -g cheetah-greb
This installs the greb-mcp-js binary globally.
Get an API key at: https://grebmcp.com/dashboard/api-keys
Add to mcp-proxy-servers.json:
"env": { "GREB_API_KEY": "grb_..." }
Repo: talhaorak/pytaiga-mcp
MCP server for Taiga project management. Provides access to projects, user stories, tasks, issues, sprints, and more. Runs locally via uv.
# 1. Clone the repo to D:\
git clone https://github.com/talhaorak/pytaiga-mcp.git D:\pytaiga-mcp
# 2. Install dependencies
uv --directory D:\pytaiga-mcp sync
# 3. Configure your Taiga instance — create D:\pytaiga-mcp\.env
# TAIGA_URL=https://your-taiga-instance.com
# TAIGA_USERNAME=your-username
# TAIGA_PASSWORD=your-password
The proxy launches the server automatically via uv --directory D:/pytaiga-mcp run src/server.py. No API key needed in the proxy config — authentication is handled by the .env file in the repo.
Claude Code connects to the MCP servers through the proxy. Add this mcpServers block to ~/.claude.json:
{
"mcpServers": {
"github": {
"type": "stdio",
"command": "mcp-proxy",
"args": ["http://127.0.0.1:8808/servers/github/sse"]
},
"codex-cli": {
"type": "stdio",
"command": "mcp-proxy",
"args": ["http://127.0.0.1:8808/servers/codex-cli/sse"]
},
"gemini-cli": {
"type": "stdio",
"command": "mcp-proxy",
"args": ["http://127.0.0.1:8808/servers/gemini-cli/sse"]
},
"angular-cli": {
"type": "stdio",
"command": "mcp-proxy",
"args": ["http://127.0.0.1:8808/servers/angular-cli/sse"]
},
"context7": {
"type": "stdio",
"command": "mcp-proxy",
"args": ["http://127.0.0.1:8808/servers/context7/sse"]
},
"exa": {
"type": "stdio",
"command": "mcp-proxy",
"args": ["http://127.0.0.1:8808/servers/exa/sse"]
},
"filesystem": {
"type": "stdio",
"command": "mcp-proxy",
"args": ["http://127.0.0.1:8808/servers/filesystem/sse"]
},
"greb-mcp": {
"type": "stdio",
"command": "mcp-proxy",
"args": ["http://127.0.0.1:8808/servers/greb-mcp/sse"]
},
"taigaApi": {
"type": "stdio",
"command": "mcp-proxy",
"args": ["http://127.0.0.1:8808/servers/taigaApi/sse"]
}
}
}
Each entry uses mcp-proxy as a stdio-to-SSE bridge, pointing at the aggregation server. The proxy must be running first (see below).
Use the slash command inside Claude Code:
/start-mcp-proxy
Or manually via PowerShell:
mcp-proxy --port 8808 --pass-environment --named-server-config "$HOME\.claude\mcp-proxy-servers.json"
Status: http://127.0.0.1:8808/status Servers: http://127.0.0.1:8808/servers/<name>/sse
hooks/nul-guard.js)A PreToolUse:Bash hook that prevents creation of literal nul files on Windows. The model sometimes emits 2>nul (Windows CMD syntax) in bash, which creates a file named "nul" instead of discarding output.
Rewrites:
2>nul -> 2>/dev/null>nul -> >/dev/null1>nul -> 1>/dev/null2>>nul -> 2>>/dev/nullUses word-boundary matching (\bnul\b) so it won't affect null, nulcheck.log, etc.
hooks/rtk/rtk-rewrite.js)A PreToolUse:Bash hook that transparently rewrites commands to RTK equivalents before execution. Covers:
git status/diff/log/add/commit/push/pull/branch/fetch/stash/showgh pr/issue/runvitest, tsc, eslint, prettier, playwright, prismacargo test/build/clippycat -> rtk read, rg/grep -> rtk grep, ls -> rtk lsdocker ps/images/logs, kubectl get/logspytest, ruff, pipgo test/build/vet, golangci-linthooks/rtk/rtk-suggest.js)Same pattern matching as rewrite, but emits a systemMessage suggestion instead of modifying the command. Acts as a fallback awareness layer.
| Command | Description |
|---|---|
/clean-claude | Cleanup ~/.claude (debug logs, transcripts, cache). Runs scripts/clean-claude.mjs |
/delete-nul | Delete Windows reserved nul files via PowerShell \\?\ path prefix |
/start-mcp-proxy | Start/verify the mcp-proxy aggregation server on port 8808 |
agents/code-reviewer.mdCustom agent triggered after major project steps are completed. Reviews implementation against the original plan for:
code-review skill: blast-radius classification, proof audit, and that skill's output format and verdicts| Skill | Description |
|---|---|
dotnet | .NET 10 development with clean architecture, Minimal APIs, SignalR |
systematic-debugging | Root-cause tracing, defense-in-depth, condition-based waiting |
test-driven-development | TDD workflow with anti-pattern detection |
| Skill | Description |
|---|---|
brainstorming | Creative exploration before implementation |
code-review-receiving | Technical rigor when receiving review feedback |
dispatching-parallel-agents | Parallelization of independent tasks |
finishing-a-development-branch | Merge/PR/cleanup decision guide |
frontend-design | Production-grade web component design |
nextjs-best-practices | Next.js App Router patterns |
prompt-engineer | LLM prompt design and evaluation |
skills-creating | Create and edit skills |
subagent-driven-development | Multi-agent implementation with spec review |
using-git-worktrees | Isolated feature work via git worktrees |
scripts/clean-claude.mjsNode.js cleanup utility targeting bloated ~/.claude directories:
node ~/.claude/scripts/clean-claude.mjs # dry-run report
node ~/.claude/scripts/clean-claude.mjs --apply # execute cleanup
node ~/.claude/scripts/clean-claude.mjs --deep # also prune JSONL content
node ~/.claude/scripts/clean-claude.mjs --days 7 # override max age
Targets: debug/, transcripts/, projects/, file-history/, shell-snapshots/, todos/, cache/, paste-cache/. Deep mode truncates bloated JSONL fields (normalizedMessages, agent_progress, bash_progress, toolUseResult, thinking blocks).
Claude Code uses Git Bash internally for all Bash() tool calls, but most Windows tools (pnpm globals, Python, LSP servers, etc.) are installed into Windows-style PATH entries that Git Bash doesn't inherit by default. This causes "command not found" errors for tools that work fine in PowerShell.
The solution has two parts:
profiles/bash_profile — syncs the full Windows PATH (Machine + User) into Git Bash at startup using cygpath to convert paths. This ensures pnpm, rtk, mcp-proxy, python, uv, and all global npm packages are findable.settings.json: "respectGitignore": false — Git Bash doesn't handle .gitignore the same way, so this prevents file access issues.To install the bash profile:
# Copy to Git Bash's profile location
Copy-Item "$HOME\.claude\profiles\bash_profile" "$HOME\.bash_profile" -Force
How it works:
# Reads Windows PATH via powershell.exe, splits on ";", converts each
# entry to Unix-style path via cygpath, and appends missing entries
_sync_win_path() {
local win_path
win_path=$(powershell.exe -NoProfile -NonInteractive -Command \
'([Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + [Environment]::GetEnvironmentVariable("PATH","User")).TrimEnd(";")' \
2>/dev/null | tr -d '\r')
# ... converts and appends each missing entry to $PATH
}
Without this, Claude Code's bash shell won't find globally installed tools like typescript-language-server, pnpm, rtk, etc.
profiles/Microsoft.PowerShell_profile.ps1)Copy to your PowerShell profile location:
Copy-Item "$HOME\.claude\profiles\Microsoft.PowerShell_profile.ps1" $PROFILE -Force
Features:
D:\ (skipped in VS Code and Claude Code via $env:TERM_PROGRAM / $env:CLAUDECODE guards)ls/dir for fast startup)cc -> claudegcm (checkout master), gcb (checkout -b), gc (checkout), gp (pull), gpm (pull master), gfm (fetch --prune), gs (status -sb)rmnm - recursively remove node_modules, .nx, .angular, dist, tmp, coverage, and lockfilesprofiles/terminal-defaults.md)All terminals open in D:\ by default:
profiles/set-shortcut.ps1HKCU\Software\Microsoft\Command Processor\Autorun = cd /d D:\Set-Location D:\ guarded for non-VS-Code/Claude CodeThe status line is provided by the claude-hud plugin — it shows real-time info in Claude Code's status bar (model, context usage, cost, git branch, etc.).
Install it from its marketplace:
/plugin marketplace add jarrodwatts/claude-hud
/plugin install claude-hud
The statusLine.command in settings.json resolves the latest installed claude-hud version under `~/.claude
hooks/register.ts 50 lines1import type { Register } from 'claude-code'
2
3import { idle } from './idle'
4
5// Shares of autoCompactThreshold (autoCompactWindow minus the compaction buffer),
6// not of the model's window. With auto-compaction off it falls back to the window.
7// ponytail: constants, not userConfig; promote them if they need tuning per machine.
8const NUDGE_AT = 70
9const RUN_AT = 90
10
11export const register: Register = on => {
12 idle(on)
13
14 // One nudge and one run per fill cycle; a cycle ends when the context drops
15 // below NUDGE_AT again (after /clear or a compaction).
16 let isNudged = false
17 let hasRun = false
18
19 // turn.complete, not session.measure: measure also fires mid-turn, where a
20 // command can't run. A subagent's turn, an interrupt or an error is not the
21 // end of the person's turn. The matcher also lets idle.ts hook every turn:
22 // the engine takes one unmatched hook per event per plugin.
23 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
24 const result = await next(e)
25 if (e.agentId) {
26 return result
27 }
28
29 const { context } = await $.session.usage({ breakdown: 'summary' })
30 const limit = context.breakdown?.autoCompactThreshold ?? context.window
31 const percent = Math.round((100 * (context.tokens ?? 0)) / limit)
32
33 if (percent < NUDGE_AT) {
34 isNudged = false
35 hasRun = false
36 } else if (percent >= RUN_AT && !hasRun) {
37 hasRun = true
38 isNudged = true
39 $.ui.toast(`Context at ${percent}% of the compaction limit: running /handoff`)
40 // A command can't run inside the hook the turn is waiting on.
41 $.clock.after(0, () => $.command.run({ command: 'handoff' }))
42 } else if (!isNudged) {
43 isNudged = true
44 $.ui.toast(`Context at ${percent}% of the compaction limit: consider /handoff (runs itself at ${RUN_AT}%)`)
45 }
46
47 return result
48 })
49}
50hooks/idle.ts 276 lines1import type { EngineInterface, On, TurnCompleteReason } from 'claude-code'
2
3// ponytail: the prompt-cache TTL is assumed 1h, not detected. A mod can't read
4// it ($.fs.read stops at 4 MiB, $.session.usage has no TTL breakdown); on a 5m
5// TTL session the first ping spends cold-cache's one stop (see the spec).
6const TTL_MS = 60 * 60_000
7const LEAD_MS = 5 * 60_000
8const MIN_TOKENS = 50_000
9const DUP_MS = 5 * 60_000
10const PINGS = 2
11
12// A reply is needed: the harness rejects a turn with no visible output.
13export const PING_TEXT = 'Prompt-cache keepalive ping from the handoff-trigger mod. Reply with one line of at most five words saying the cache is kept warm, and do nothing else: no tools, no work.'
14// Without it the handoff skill copies the last user prompts, pings included,
15// and lets the latest one win.
16export const HANDOFF_ARGS = "The handoff-trigger mod's keepalive pings are not user prompts: leave them out of the last user prompts, and they do not change the task."
17
18// A handoff path as the handoff skill replies with it (`~/…` or `C:\…`); the
19// lookbehind keeps leading markdown (`**`, `(`, `[`) out of the match.
20const HANDOFF_PATH = /(?<=^|[\s`'"(\[*])(?:[A-Za-z]:|~)?[^\s`'"()\[\]*]*[\\/]\.claude[\\/]handoffs[\\/][^\s`'"()\[\]*]+\.md/g
21
22// A cold prompt between its drop and continueFresh's submit; re-sends append.
23type Pending = {
24 text: string
25 readonly handoffPath: string
26 readonly sessionId: string
27}
28
29// The last resubmitted text, so a re-send within DUP_MS doesn't run twice.
30type Resent = {
31 readonly text: string
32 readonly until: number
33}
34
35// Module state, one per Claude Code process; /clear and /resume keep it.
36let lastResponseAt: number | undefined
37let handoffPath: string | undefined
38let cancelTimer: (() => void) | undefined
39let turnStartedAt: number | undefined
40let isPromptInTurn = false
41let hasFired = false
42let pings = 0
43let spell = 0
44let pending: Pending | undefined
45let resent: Resent | undefined
46
47export function idle(on: On): void {
48 lastResponseAt = undefined
49 handoffPath = undefined
50 cancelTimer = undefined
51 turnStartedAt = undefined
52 isPromptInTurn = false
53 hasFired = false
54 pings = 0
55 spell = 0
56 pending = undefined
57 resent = undefined
58
59 // Keeps pending: the mod's own /clear ends the session mid-flight.
60 on('session.end', async ($, e, next) => {
61 stopTimer()
62 lastResponseAt = undefined
63 handoffPath = undefined
64 isPromptInTurn = false
65 hasFired = false
66 pings = 0
67 spell += 1
68
69 return next(e)
70 })
71
72 on('turn.start', async ($, e, next) => {
73 stopTimer()
74 spell += 1
75 turnStartedAt = await $.clock.now()
76
77 return next(e)
78 })
79
80 on('turn.complete', async ($, e, next) => {
81 // Before any await: a turn.start landing in one moves spell, and this
82 // turn's wake must not arm inside the next turn.
83 const turnSpell = spell
84 const result = await next(e)
85 if (e.agentId) return result
86
87 // Any end of a turn closes check 3's window: a later same text is a new
88 // prompt, also a retry after the resubmitted turn failed.
89 resent = undefined
90
91 // A person prompt resets pings, so pings > 0 means this is the ping's own
92 // turn. It refreshed nothing: lastResponseAt stays, the handoff runs now.
93 const isFailed = e.reason === 'error' || e.reason === 'refusal'
94 const isFailedPing = isFailed && pings > 0 && !hasFired
95 if (isFailedPing) {
96 pings = PINGS
97 arm($, 0)
98
99 return result
100 }
101
102 await settleTurn($, e.answer, e.reason, turnSpell)
103
104 return result
105 })
106
107 on('prompt.submit', async ($, e, next) => {
108 const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
109 if (!isPerson) return next(e)
110
111 // Both reads before check 2; no await from there to setting pending, so
112 // two overlapping person prompts can't both reach check 5.
113 const now = await $.clock.now()
114 const sessionId = await $.session.id()
115
116 if (pending) {
117 if (e.text !== pending.text) pending.text += `\n\n${e.text}`
118 const appended = { drop: 'Added to your prompt that is being continued from the handoff.' }
119
120 return appended
121 }
122
123 const duplicate = { drop: 'Already sent after clearing.' }
124 const isResent = resent !== undefined && now < resent.until && e.text.trim() === resent.text.trim()
125 if (isResent) return duplicate
126
127 const path = handoffPath
128 const isCommand = e.text.startsWith('/')
129 const hasAttachments = (e.attachments?.length ?? 0) > 0
130 // A prompt typed into a running turn: that turn keeps the cache warm.
131 const isInTurn = e.turnId !== undefined
132 const isWarm = isInTurn || lastResponseAt === undefined || now - lastResponseAt < TTL_MS
133 const isPassing = isCommand || hasAttachments || path === undefined || isWarm
134 if (isPassing) {
135 hasFired = false
136 pings = 0
137 if (!isCommand) spell += 1
138 if (e.turnId) isPromptInTurn = true
139
140 return next(e)
141 }
142
143 stopTimer()
144 hasFired = false
145 pings = 0
146 spell += 1
147 pending = { text: e.text, handoffPath: path, sessionId }
148 handoffPath = undefined
149 // A command can't run inside a hook the turn is waiting on.
150 $.clock.after(0, () => continueFresh($))
151 const cleared = { drop: 'Cache cold: clearing and continuing from the handoff with your prompt.' }
152
153 return cleared
154 })
155}
156
157// The cold return: /clear, then the dropped prompt resubmitted as the user's
158// own with the handoff to read first. Any failure shows the prompt in a toast.
159async function continueFresh($: EngineInterface): Promise<void> {
160 // The same object check 2 appends to, until pending is cleared below.
161 const sent = pending
162 if (!sent) return
163
164 try {
165 await $.command.run({ command: 'clear' })
166 const sessionId = await $.session.id()
167 // Submitting now would land in the cold session.
168 if (sessionId === sent.sessionId) throw new Error('/clear kept the session')
169
170 const now = await $.clock.now()
171 // Before the submit: a re-send while it is in flight meets check 3.
172 pending = undefined
173 resent = { text: sent.text, until: now + DUP_MS }
174 const request = `Continue from the handoff in ${sent.handoffPath}: read it and run its Verify command, then\n`
175 + `act on my new request below. It overrides the handoff's Next step.\n\n${sent.text}`
176 const { drop } = await $.prompt.submit({ asUser: true, text: request })
177 if (drop) throw new Error(drop)
178 } catch {
179 $.ui.toast(`Couldn't continue from the handoff. Your prompt: ${sent.text}`)
180 resent = undefined
181 } finally {
182 pending = undefined
183 }
184}
185
186// turn.complete steps 1-4: record a handoff written this turn, or arm the wake.
187async function settleTurn($: EngineInterface, answer: string, reason: TurnCompleteReason, turnSpell: number): Promise<void> {
188 const path = await freshHandoff($, answer)
189 handoffPath = path
190 lastResponseAt = await $.clock.now()
191 isPromptInTurn = false
192 stopTimer()
193
194 const isArmable = reason === 'answer' && !hasFired && path === undefined
195 if (!isArmable) return
196
197 const { context } = await $.session.usage()
198 const tokens = context.tokens ?? 0
199 const hasMovedOn = spell !== turnSpell
200 if (tokens < MIN_TOKENS || hasMovedOn) return
201
202 arm($, TTL_MS - LEAD_MS)
203}
204
205// The first handoff path in the reply whose file was written during this turn;
206// a reply that only quotes an older handoff, or answered a prompt typed into
207// the turn, has none.
208async function freshHandoff($: EngineInterface, answer: string): Promise<string | undefined> {
209 const startedAt = turnStartedAt
210 if (startedAt === undefined || isPromptInTurn) return undefined
211
212 for (const [spelled] of answer.matchAll(HANDOFF_PATH)) {
213 const path = await expandHome($, spelled)
214 const stat = await $.fs.stat(path).catch(() => undefined)
215 if (stat && stat.mtimeMs >= startedAt) return path
216 }
217
218 return undefined
219}
220
221async function expandHome($: EngineInterface, path: string): Promise<string> {
222 if (!path.startsWith('~')) return path
223
224 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
225
226 if (home === undefined) return path
227
228 return home + path.slice(1)
229}
230
231function arm($: EngineInterface, delayMs: number): void {
232 stopTimer()
233 const armedSpell = spell
234 const timer = $.clock.after(delayMs, () => fire($, armedSpell))
235 cancelTimer = () => timer.cancel()
236}
237
238function stopTimer(): void {
239 cancelTimer?.()
240 cancelTimer = undefined
241}
242
243// The idle wake: a refresh ping for the first PINGS wakes, then /handoff while
244// the cache is still warm. A spell that moved on since arming sends nothing.
245async function fire($: EngineInterface, armedSpell: number): Promise<void> {
246 const now = await $.clock.now()
247 if (spell !== armedSpell) return
248
249 // Sleep guard: a late timer (the machine slept) finds the cache cold already.
250 const isLate = lastResponseAt === undefined || now - lastResponseAt >= TTL_MS
251 if (isLate) return
252
253 if (pings < PINGS) {
254 pings += 1
255 $.ui.toast(`Keeping the prompt cache warm (${pings}/${PINGS})`)
256 const { drop } = await $.prompt.submit({ text: PING_TEXT }).catch(() => ({ drop: 'rejected' }))
257 // A person prompt during the submit owns the spell now.
258 if (spell !== armedSpell) return
259 if (!drop) return
260
261 // cold-cache's block reason, maybe behind an engine prefix: the cache is
262 // cold, so a handoff would pay the re-cache with nobody there.
263 if (drop.includes('Prompt cache expired:')) {
264 hasFired = true
265
266 return
267 }
268 }
269
270 hasFired = true
271 $.ui.toast('Idle: writing a handoff while the cache is warm')
272 await $.command.run({ command: 'handoff', args: HANDOFF_ARGS }).catch(() => {
273 $.ui.toast('Idle: /handoff could not run')
274 })
275}
276