SLOPSHOPPER

handoff-trigger

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…

newtoastprompttimer
v0.1.0no licenseupdated 2026-10-08F0rty-Tw0/.claude/mods/handoff-trigger
A shopper browsing a rack in a slop shop
README

Claude Code Configuration

Complete setup guide to reproduce this Claude Code environment on Windows 11.

Prerequisites

ToolVersionInstall
NVM for Windows1.2.2winget install CoreyButler.NVMforWindows
Node.js24.13.0nvm install 24.13.0 && nvm use 24.13.0
pnpmlatestcorepack enable && corepack prepare pnpm@latest --activate
Python3.14+winget install Python.Python.3.14
uv0.10+pip install uv or winget install astral-sh.uv
GitHub CLIlatestwinget install GitHub.cli
Gitlatestwinget install Git.Git

Step 1: Install Claude Code

irm https://claude.ai/install.ps1 | iex

Verify: claude --version (should show 2.1.x)

Step 2: Install RTK (Rust Token Killer)

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 gain fails, you may have reachingforthejack/rtk (Rust Type Kit) installed instead. Uninstall it first.

Step 3: Install mcp-proxy

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)

Step 4: Install GitHub MCP Server

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

Step 5: Login

/login

Configuration Files

settings.json

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",
          },
        ],
      },
    ],
  },
}

mcp-proxy-servers.json

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:

ServerPackage / BinaryRequires
github~/bin/github-mcp-server.exeGITHUB_PERSONAL_ACCESS_TOKEN
codex-clipnpm dlx @cexll/codex-mcp-serverOPENAI_API_KEY (env)
gemini-clipnpm dlx gemini-mcp-toolGEMINI_API_KEY (env)
angular-clipnpm dlx @angular/cli mcp-
context7pnpm dlx @upstash/context7-mcpCONTEXT7_API_KEY
exapnpm dlx exa-mcp-serverEXA_API_KEY
filesystempnpm dlx @modelcontextprotocol/server-filesystem D:\-
greb-mcpgreb-mcp-js (from cheetah-greb)GREB_API_KEY
taigaApiuv run src/server.py (from D:/pytaiga-mcp)-

Installing Each MCP Server

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.

GitHub MCP Server (binary)

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).

Codex CLI (OpenAI)

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.

Gemini CLI (Google)

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.

Angular CLI

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
Context7 (Upstash)

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-..." }
Exa (Web Search)

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.

Filesystem (MCP Reference Server)

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.

Greb MCP (Code Search)

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_..." }
Taiga API (Project Management)

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.json (Client-Side MCP Config)

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).

Starting the MCP Proxy

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 (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/null
  • 1>nul -> 1>/dev/null
  • 2>>nul -> 2>>/dev/null

Uses word-boundary matching (\bnul\b) so it won't affect null, nulcheck.log, etc.

RTK Auto-Rewrite (hooks/rtk/rtk-rewrite.js)

A PreToolUse:Bash hook that transparently rewrites commands to RTK equivalents before execution. Covers:

  • Git: git status/diff/log/add/commit/push/pull/branch/fetch/stash/show
  • GitHub CLI: gh pr/issue/run
  • JS/TS: vitest, tsc, eslint, prettier, playwright, prisma
  • Cargo: cargo test/build/clippy
  • File ops: cat -> rtk read, rg/grep -> rtk grep, ls -> rtk ls
  • Containers: docker ps/images/logs, kubectl get/logs
  • Python: pytest, ruff, pip
  • Go: go test/build/vet, golangci-lint

RTK Suggest (hooks/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.


Commands (Slash Commands)

CommandDescription
/clean-claudeCleanup ~/.claude (debug logs, transcripts, cache). Runs scripts/clean-claude.mjs
/delete-nulDelete Windows reserved nul files via PowerShell \\?\ path prefix
/start-mcp-proxyStart/verify the mcp-proxy aggregation server on port 8808

Agents

agents/code-reviewer.md

Custom agent triggered after major project steps are completed. Reviews implementation against the original plan for:

  • Plan alignment and deviation analysis
  • Code quality (error handling, type safety, naming)
  • Architecture (SOLID, separation of concerns)
  • Issue categorization: Critical / Important / Suggestion
  • When dispatched by the code-review skill: blast-radius classification, proof audit, and that skill's output format and verdicts

Skills (19 installed)

Custom Skills

SkillDescription
dotnet.NET 10 development with clean architecture, Minimal APIs, SignalR
systematic-debuggingRoot-cause tracing, defense-in-depth, condition-based waiting
test-driven-developmentTDD workflow with anti-pattern detection

Community Skills (from skill packs)

SkillDescription
brainstormingCreative exploration before implementation
code-review-receivingTechnical rigor when receiving review feedback
dispatching-parallel-agentsParallelization of independent tasks
finishing-a-development-branchMerge/PR/cleanup decision guide
frontend-designProduction-grade web component design
nextjs-best-practicesNext.js App Router patterns
prompt-engineerLLM prompt design and evaluation
skills-creatingCreate and edit skills
subagent-driven-developmentMulti-agent implementation with spec review
using-git-worktreesIsolated feature work via git worktrees

Scripts

scripts/clean-claude.mjs

Node.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).


Profiles (Shell Configuration)

The cross-shell problem on Windows

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:

  1. 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.
  1. 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.

PowerShell (profiles/Microsoft.PowerShell_profile.ps1)

Copy to your PowerShell profile location:

Copy-Item "$HOME\.claude\profiles\Microsoft.PowerShell_profile.ps1" $PROFILE -Force

Features:

  • Default working directory D:\ (skipped in VS Code and Claude Code via $env:TERM_PROGRAM / $env:CLAUDECODE guards)
  • Lazy-loaded Terminal-Icons (deferred import on first ls/dir for fast startup)
  • Claude Code alias: cc -> claude
  • Git aliases: gcm (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 lockfiles

Terminal Defaults (profiles/terminal-defaults.md)

All terminals open in D:\ by default:

  • Command Prompt: shortcut WorkingDirectory set via profiles/set-shortcut.ps1
  • cmd.exe (Win+R): Registry HKCU\Software\Microsoft\Command Processor\Autorun = cd /d D:\
  • PowerShell: Set-Location D:\ guarded for non-VS-Code/Claude Code

HUD (Status Line)

The 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

How it works

The statusLine.command in settings.json resolves the latest installed claude-hud version under `~/.claude

Source 2 files
hooks/register.ts 50 lines
1import 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}
50
hooks/idle.ts 276 lines
1import 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