An agentic engineering team that runs the AI-DLC: 15 role-scoped agents (orchestrator, product owner, product, requirements, architecture, system design…

An agentic engineering team that runs the AI-DLC: 15 role-scoped agents, the full AI-DLC lifecycle (idea to release) plus eight named lifecycles, artifact handoffs and hard quality gates. Each project can customise the workflow in .ai-dlc/workflow.json. .NET and JS / TS are supported stacks; the lifecycle, agents and gates are language-agnostic.
Claude Code (2.1.275 or later):
/plugin install ai-dlc --marketplace rafsanulhasan/ai-dlc-agent-workflow-plugin
or in two steps: /plugin marketplace add rafsanulhasan/ai-dlc-agent-workflow-plugin, then /plugin install ai-dlc@ai-dlc-agent-workflow.
GitHub Copilot CLI:
copilot plugin marketplace add rafsanulhasan/ai-dlc-agent-workflow-plugin
copilot plugin install ai-dlc@ai-dlc-agent-workflow
/ai-dlc:init once in a repository. It scaffolds AGENTS.md, the per-platform instructions, the team's workflow and the docs/ folders, and installs the documentation rule (docs-status, every repository) and the rules and test gate of each stack it finds.init makes it the session's main agent in Claude Code; in GitHub Copilot, select the orchestrator custom agent.<!-- ai-dlc:commands:begin -->
<!-- Generated by tools/build-copilot.mjs from the skills' frontmatter. Do not edit this list. -->
Every skill of the plugin is a command. In Claude Code, type /ai-dlc:<name>; in GitHub Copilot, /<name> (the commands marked Claude Code only are not in the Copilot plugin).
| Command | Arguments | What it does | ||||||
|---|---|---|---|---|---|---|---|---|
/ai-dlc:agent-invocation | — | Authoritative skill for spawning or invoking another agent with proper context. | ||||||
/ai-dlc:agent-management | — | Creates, updates, and deprecates agent definitions for both Claude Code (.claude/agents/*.md) and GitHub Copilot/VS Code (.github/agents/*.agent.md). | ||||||
/ai-dlc:ai-dlc | — | AI-DLC lifecycle router and operating model. | ||||||
/ai-dlc:architecture-design | — | Structured architectural design skill for the project. | ||||||
/ai-dlc:architecture-narrative | — | Turn an architecture solution into a story stakeholders can follow — business problem first, then constraints and trade-offs, then the proposed design, ADRs and risks. | ||||||
/ai-dlc:architecture-review | — | Structured architectural review skill for the project. | ||||||
/ai-dlc:bunit-blazor-testing | — | Comprehensive guidance for writing Blazor component tests using bUnit with TUnit as the test runner. | ||||||
/ai-dlc:cache-keepalive | `[<n>m\ | <n>h\ | <n>d\ | clear] [--track 1h\ | 5m\ | both] [--session\ | --machine]` | Pings the prompt cache now, or sets how long keep-alive keeps it warm while background tasks run, on the 1h track, the 5m track or both. Claude Code only. |
/ai-dlc:cache-keepalive-configure | [<change in plain words>] | Shows or changes keep-alive's settings in .ai-dlc/cache-keepalive.json. Claude Code only. | ||||||
/ai-dlc:cache-keepalive-install | — | Creates .ai-dlc/cache-keepalive.json with every key at its default, and its schema, on first use; an existing file is left alone (use /ai-dlc:cache-keepalive-merge). Claude Code only. | ||||||
/ai-dlc:cache-keepalive-merge | — | Brings .ai-dlc/cache-keepalive.json up to date after a plugin upgrade, or repairs it. Claude Code only. | ||||||
/ai-dlc:command-management | — | Create, modify, and delete GitHub Copilot prompt files (.github/prompts/*.prompt.md) and Claude command files (.claude/commands/*.md) safely using a discovery-first, non-destructive workflow. | ||||||
/ai-dlc:configure-auto-handoff | — | Shows and changes the automatic session handoff's settings in the repository's .ai-dlc/auto-handoff.json, with the source of each value, and checks whether this Claude Code can rename sessions. Claude Code only. | ||||||
/ai-dlc:csharp-architecture-testing | — | Comprehensive guidance for writing C# architecture tests using NetArchTest.Rules, TUnit, and TUnit.Assertions.Should to enforce layer dependencies, namespace rules, and naming conventions | ||||||
/ai-dlc:csharp-integration-testing | — | Comprehensive guidance for writing C# integration tests using TUnit, TestWebApplicationFactory, Testcontainers, Bogus, and TUnit.Assertions.Should — real infrastructure, no mocks of your own code | ||||||
/ai-dlc:csharp-mutation-testing | — | Runs and interprets Stryker.NET mutation testing for C# projects — installing the tool, configuring stryker-config.json, running full, incremental (--since) and baseline runs, and triaging surviving mutants into real test gaps vs … | ||||||
/ai-dlc:csharp-unit-testing | — | Comprehensive guidance for writing C# unit tests using TUnit, TUnit.Mocks, Bogus, and TUnit.Assertions.Should with project conventions | ||||||
/ai-dlc:design-test-cases | — | Structured test case design skill for software projects. | ||||||
/ai-dlc:docs-rules | — | Installs the AI-DLC documentation rule (docs-status) into the current repository, for every stack. | ||||||
/ai-dlc:dotnet-rules | — | Installs the AI-DLC .NET rules into the current repository: C# coding style, the GlobalUsings convention and the dotnet test / dotnet stryker testing gates, as Claude rules (.claude/rules) with identical GitHub Copilot instruction twins … | ||||||
/ai-dlc:dotnet-test-gate | — | Installs the AI-DLC .NET test gate into the current repository: a Claude Code Stop hook and a GitHub Copilot agentStop hook that block the agent from finishing while dotnet test fails after runtime changes. | ||||||
/ai-dlc:fix-bug | — | Structured bug investigation workflow for software projects. | ||||||
/ai-dlc:github-cd-automation | — | Structured workflow for designing GitHub Actions continuous deployment for .NET and JavaScript/TypeScript projects. | ||||||
/ai-dlc:github-ci-automation | — | Structured workflow for designing and maintaining GitHub Actions continuous integration for .NET and JavaScript/TypeScript projects. | ||||||
/ai-dlc:handoff | — | Records and verifies every hand-off in an AI-DLC run and keeps its run state. | ||||||
/ai-dlc:harness-sync | `[--check] [--prefer claude\ | copilot\ | newer] [--only <kinds>] [--name <n>]` | Runs the two-way Claude Code ↔ GitHub Copilot harness sync, node .claude/scripts/sync-harness.mjs, so every agent, skill, command ↔ prompt, rule ↔ instruction and hook made or edited on one platform exists on the other. | ||||
/ai-dlc:hook-management | — | Create, modify, and delete Claude Code hooks and GitHub Copilot hook integrations safely using a discovery-first, non-destructive workflow. | ||||||
/ai-dlc:implement-feature | — | Structured feature implementation workflow for software projects. | ||||||
/ai-dlc:init | — | Bootstraps a repository for the AI-DLC agent team: scaffolds AGENTS.md (the orchestrator persona with all its skills, plus the project's gates and workspace folders, free of stack-specific commands), CLAUDE.md (an @AGENTS.md import, the … | ||||||
/ai-dlc:k6-docker | — | Invoke this skill when the question is how to run k6 tests via Docker — for either local development or CI automation. | ||||||
/ai-dlc:k6-load-testing | — | Invoke this skill when the task is to validate that the system sustains its SLOs at realistic production throughput over time — including endurance (soak) testing for memory leaks, connection pool exhaustion, and GC pressure. | ||||||
/ai-dlc:k6-performance-testing | — | Invoke this skill when the task is to validate that a system meets its SLOs under normal operating conditions — expected concurrency and throughput levels. | ||||||
/ai-dlc:k6-stress-testing | — | Invoke this skill when the task is to validate system behaviour beyond normal capacity — to observe how it degrades and whether it recovers. | ||||||
/ai-dlc:lens | — | Opens or brings forward the AI-DLC Lens view, a live view of every run: one panel per workflow, its step charts of agents, and the avatar and hand-off panels (needs the ai-dlc-lens plugin). Claude Code only. | ||||||
/ai-dlc:lens-configure | `[<key> [<value>\ | --unset]]` | Shows the lens settings, each with its value, where it comes from and its env name, or sets or unsets one in .ai-dlc/lens.json (needs the ai-dlc-lens plugin). Claude Code only. | |||||
/ai-dlc:lens-gate | `[on\ | off]` | Lens anomaly detection (blocked runs and steps, gates waiting or rejected, loops out of rounds, failed or stuck agents, failed hand-backs): on\ | off turns it on or off; bare, it lists them (needs the ai-dlc-lens plugin). Claude Code only. | ||||
/ai-dlc:lens-init | [--force] | Writes the lens settings file .ai-dlc/lens.json with every default, and its schema .ai-dlc/lens.schema.json (needs the ai-dlc-lens plugin). Claude Code only. | ||||||
/ai-dlc:lens-keepalive | `[on\ | off]` | Shows or hides the lens's Cache keep-alive section (how long keep-alive kept the cache warm per track, what cache reads saved), then reports those figures (needs the ai-dlc-lens plugin; on by default). Claude Code only. | |||||
/ai-dlc:lens-kit | — | Opens the lens kit pane: the manual checks K1 to K5 of the lens (resize across the dock boundary, Svg animation, pane buttons, transcript cards; K5 superseded: see /ai-dlc:lens-gate) (needs the ai-dlc-lens plugin). Claude Code only. | ||||||
/ai-dlc:manage-memory | — | Manages persistent file-based memory for all agents in .claude/agent-memory/<folder>/ (ai-dlc-<agent> for plugin agents), committed with the repository. | ||||||
/ai-dlc:model-metadata | — | Installs the AI-DLC model metadata hook into the current repository: .ai-dlc/model-metadata.json with its schema, holding every Anthropic model's prices (input, output, 5-minute and 1-hour cache writes, cache reads, batch, fast mode, … | ||||||
/ai-dlc:node-rules | — | Installs the AI-DLC JavaScript / TypeScript rules into the current repository: coding style (strict TypeScript, no any, ESM, naming), module and import boundaries, async and error handling, and the test / Stryker testing gates, as Claude … | ||||||
/ai-dlc:node-test-gate | — | Installs the AI-DLC JavaScript / TypeScript test gate into the current repository: a Claude Code Stop hook and a GitHub Copilot agentStop hook that block the agent from finishing while the repository's test command fails after runtime … | ||||||
/ai-dlc:npm-package-deployment | — | Structured workflow for publishing JavaScript/TypeScript packages to the npm registry. | ||||||
/ai-dlc:nuget-package-deployment | — | Structured workflow for publishing NuGet packages to nuget.org. | ||||||
/ai-dlc:playwright-mcp-ui-testing | — | AI-driven browser UI testing using the Playwright MCP server. | ||||||
/ai-dlc:plugin-management | — | Creates and maintains a plugin that ships the same agents and skills to Claude Code and GitHub Copilot (CLI and VS Code) from one source: the Claude plugin is the source of truth and a zero-dependency build projects it into the Copilot … | ||||||
/ai-dlc:presentation-authoring | — | Authors and maintains PowerPoint (.pptx) decks: reading a deck, editing slide text in place without breaking the layout, building a new deck, rendering slides to images for visual QA, and keeping a deck in sync with its source of truth … | ||||||
/ai-dlc:product-planning | — | Product backlog management and release planning skill. | ||||||
/ai-dlc:request-routing | — | The orchestrator's single routing skill. | ||||||
/ai-dlc:requirement-analysis | — | Structured requirement elicitation for software projects. | ||||||
/ai-dlc:research | — | Structured external-knowledge and codebase-exploration research skill for software projects. | ||||||
/ai-dlc:review | — | Structured code review workflow for software projects. | ||||||
/ai-dlc:rules-management | — | Create, modify, delete, and sync Claude rules files (.claude/rules/*.md) and GitHub Copilot instruction files (.github/instructions/*.instructions.md) safely using a discovery-first, non-destructive workflow. | ||||||
/ai-dlc:security-review | — | Security-focused review workflow for software projects. | ||||||
/ai-dlc:skill-management | — | Creates, updates, syncs, and lists skill files (.claude/skills/*/SKILL.md) and (.github/skills/*/SKILL.md) for the AI-DLC multi-agent system, and holds the writing guide and review checklist for any agent-facing document (skills, agent … | ||||||
/ai-dlc:sonarqube-pr-quality-gate | — | Structured workflow for enforcing SonarQube quality gate on pull requests for .NET and JavaScript/TypeScript projects. | ||||||
/ai-dlc:spec-driven-development | — | Manages the full lifecycle of a feature specification — from collaborative drafting (after a requirement-analysis session, or by synthesising a spec from requirements already discussed in the conversation, a product brief or an issue … | ||||||
/ai-dlc:system-design | — | Low-level design skill. | ||||||
/ai-dlc:task-triage | — | The product-manager's task and bug triage skill. | ||||||
/ai-dlc:terse-output | `[lite \ | full \ | ultra \ | off \ | status]` | Terse response register: answer first, keep every technical fact, drop the filler, at three levels (lite, full, ultra). | ||
/ai-dlc:test-doubles | — | Mock, stub, fake, spy or test double policy — the single rule for whether a test may replace part of the system and with what. | ||||||
/ai-dlc:ts-architecture-testing | — | Comprehensive guidance for writing architecture tests in JavaScript/TypeScript projects (Node, TypeScript, plain JavaScript) using dependency-cruiser forbidden and required rules (layer dependencies, no circular dependencies, no orphans, … | ||||||
/ai-dlc:ts-integration-testing | — | Comprehensive guidance for writing integration tests in JavaScript/TypeScript projects (Node, TypeScript, plain JavaScript) using Vitest (or Jest when the repository already uses it), Testcontainers for Node for real databases, caches … | ||||||
/ai-dlc:ts-mutation-testing | — | Runs and interprets StrykerJS mutation testing for JavaScript/TypeScript projects (Node, TypeScript, plain JavaScript): installing @stryker-mutator/core with the vitest-runner or jest-runner, configuring stryker.config.json, running … | ||||||
/ai-dlc:ts-playwright-ui-testing | — | Comprehensive guidance for writing coded end-to-end UI tests for web applications in JavaScript/TypeScript projects (Node, TypeScript, plain JavaScript) using Playwright Test (@playwright/test). | ||||||
/ai-dlc:ts-unit-testing | — | Comprehensive guidance for writing unit tests in JavaScript/TypeScript projects (Node, TypeScript, plain JavaScript) using Vitest (or Jest when the repository already uses it), @faker-js/faker builders, vi.fn / vi.mock for the test … | ||||||
/ai-dlc:tunit-playwright-ui-testing | — | Comprehensive guidance for writing coded end-to-end UI tests in C# using TUnit.Playwright. | ||||||
/ai-dlc:workflow | — | Customise the AI-DLC workflow of a repository: which stages each lifecycle runs, which agent owns each stage and hands off to whom, what runs in parallel, refinement loops and their round and failure limits, gates and who approves them, … | ||||||
/ai-dlc:workspace-folders | — | Sets or changes the repository's worktree root and scratch root: records them in AGENTS.md, CLAUDE.md and .github/copilot-instructions.md, gitignores them, and installs the Claude WorktreeCreate / WorktreeRemove hooks that place … | ||||||
/ai-dlc:write-adr | — | Writes a numbered Architecture Decision Record (ADR) for a significant architectural choice in the project, after first checking the decision deserves one (hard to reverse, surprising without context, a real trade-off). | ||||||
/ai-dlc:write-documentation | — | Structured documentation writing workflow for software projects. | ||||||
/ai-dlc:write-tests | — | Stack-neutral test implementation skill. |
<!-- ai-dlc:commands:end -->
MIT; see LICENSE. Parts of the Claude Code hooks module are adapted from third-party MIT projects, each recorded in its folder's ATTRIBUTION.md.
hooks/auto-handoff/register.tsx 1080 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { assembleBrief, briefPrompt, extractFacts, isValidBrief, markUnverifiedFigures } from './brief.ts'
3import { chainOf, parseBrief, renderPage, viewerLink, withHeader } from './viewer.ts'
4import type { Entry } from './viewer.ts'
5import { SERVER_JS, parseAddress } from './server.ts'
6import { isSpinning, panelTree } from './panel.tsx'
7import type { Line, Panel } from './panel.tsx'
8import { BRIEF_DIR, CAPABILITIES_FILE, DEFAULTS, LAST_RESORT_INSTRUCTIONS, MODULE_DIR, REPO_CONFIG, SEED_PREFIX, SESSION_TEMPLATE, TEMPLATES, TRUST_GATED, contextLine, creationSplit, expand, k, linkify, numbering, numberingNotice, parseTitle, repoConfig, resolveConfig, seedCacheFields, seedCacheLine, sessionName, settingsEnv, short, transcriptTitle, hostFields, windowFields, windowInfo } from './config.ts'
9import type { Config, HostWindow, Level, SeedCache, TemplateKey, WindowInfo } from './config.ts'
10import { ENDED, assembleSessionHandoff, currentRunStateFile, handoffsRoot, inRepo, openRecords, parseRunState, pickRun, runStateNote, scratchRoot, sessionHandoffPath, withNextAction } from './run.ts'
11import type { RunState, RunTarget } from './run.ts'
12import { logAgentUsage, noteUsage, registerKeepalive, showSeedCache, stopForHandoff, transcriptSplit } from '../keepalive/keepalive.ts'
13import { git, homeDir, log, orLog, readRaw, readText, repoRoot, trustGate } from './io.ts'
14
15// At the handoff threshold (thresholdPercent of the context window), Haiku writes a handoff brief, the mod runs /clear, then seeds the
16// fresh session with a pointer to the brief. Interactive terminal sessions only: where a
17// wrapper pipes the session and owns the context limit (DISABLE_AUTO_COMPACT), the mod only logs.
18// Part of the ai-dlc plugin: when the repository has an active AI-DLC run on the current branch,
19// the brief is the `handoff` skill's session handoff, written to the run's scratch folder, and the
20// run's current run state points at it before the /clear (run.ts). Without one, the brief is the mod's own.
21// While the run this chain claimed stays active, the chain goes on past maxConsecutiveHandoffs; it
22// ends when the run is done, blocked or abandoned. Each session of a chain is named
23// "<prefix>: <original name> (Session N)": N is the title's number, else the stored lineage depth,
24// else 1, and the next session is N + 1; only the main session (N = 1 from neither) is renamed.
25// The cache: the fresh session can read only the shared prefix (tools, system prompt, project
26// context) from the prompt cache, never the old conversation. So the handoff keeps that prefix as
27// it was: it changes neither the model nor the effort, and the brief and the seed travel only as
28// messages. Keep-alive (../keepalive/) is registered here too; its timers stop before the /clear, and
29// the seed turn logs what its first request read from the cache.
30
31// The effective settings (config.ts, resolveConfig) and where each came from. Loaded on first use
32// and again at each session start, so a chain picks up edits to the repository file.
33let cfg: Config = DEFAULTS
34let cfgSources: Record<string, string> = {}
35let cfgLoaded = false
36let cfgLoading: Promise<void> | undefined
37// Session titles as the classic SessionStart and UserPromptSubmit inputs carry them, by session id.
38const titles = new Map<string, string>()
39// Whether this process has recorded what the running Claude Code offers (recordCapabilities).
40let capsRecorded = false
41// Origins the engine stamps on a prompt the person sent; the seed arrives as { kind: 'plugin' }.
42const USER_ORIGINS = new Set(['composer', 'bridge'])
43// Rough chars-per-token for tool output, used to project the next request's size.
44const CHARS_PER_TOKEN = 4
45// The panel above the prompt (panel.tsx) is the whole UI. No status entry: the host draws one
46// as "⚠ auto-handoff:", which reads as an error.
47const LOG = `~/${BRIEF_DIR}/auto-handoff.log`
48// problem: why the brief is facts only, when Haiku's summary was unusable.
49// briefPath: the brief in BRIEF_DIR, which keeps the chain, the viewer and the status line working.
50// seedPath: what the fresh session is told to read; the session handoff when a run is active.
51// baseName: the chain's original session name, carried down so names never nest.
52// next: the new session's number (config.ts, numbering), worked out once at the handoff, so the seed
53// reads nothing from the store. notice: the numbering fallback's panel step, when the lineage read failed.
54type Pending = { oldSession: string; briefPath: string; seedPath: string; run?: { id: string; runState: string }; tokens: number; chain: string; link: string; problem?: string; baseName: string; next: number; notice?: Line }
55
56// Module variables survive /clear; $.state does not.
57let pending: Pending | undefined
58let inFlight = false
59let seededSession: string | undefined
60let floor: number | undefined
61let unattended = 0 // handoffs since the user last typed a prompt
62let pausedSession: string | undefined
63// The handoff the fresh session came from, until its first request is measured and shown on the panel.
64let handedFrom: { session: string; tokens: number; link: string; problem?: string; notice?: Line } | undefined
65// The seeded session's place in its chain of handoffs, for its own brief's header. Also kept in
66// the store as lineage:<session>, because a hot reload resets module variables: a session seeded
67// before a reload would otherwise start a new chain when it hands off.
68// baseName: the original session's name, without the chain's "<prefix>: … (Session N)" wrapper.
69type Lineage = { from: string; chain: string; depth?: number; baseName?: string }
70let lineage: Lineage | undefined
71// Tokens added since the last response measured the context: tool results and the
72// response's own output. turn.complete alone missed a turn whose reads jumped from 63k
73// straight past the window, because the request that would have measured it failed.
74let unmeasured = 0
75// The session whose tool call the gate refused. The refusal tells the model a handoff is coming,
76// so one must follow even when the next response measures under the threshold: the gate counts
77// tool output at CHARS_PER_TOKEN, which ran high in a live test (projected 83.7k, measured 72.5k)
78// and left a session that stopped working with no handoff.
79let gated: string | undefined
80// From the latest SessionStart; /clear starts a new transcript file.
81let transcriptPath: string | undefined
82
83// The module's variables in the process environment. Literal names: the host lists the variables
84// a module reads.
85async function processEnv($: EngineInterface): Promise<Record<string, unknown>> {
86 const read: Record<string, string | undefined> = {
87 AI_DLC_AUTO_HANDOFF_ENABLED: await $.env.get('AI_DLC_AUTO_HANDOFF_ENABLED'),
88 AI_DLC_AUTO_HANDOFF_THRESHOLD_PERCENT: await $.env.get('AI_DLC_AUTO_HANDOFF_THRESHOLD_PERCENT'),
89 AI_DLC_AUTO_HANDOFF_THRESHOLD_TOKENS: await $.env.get('AI_DLC_AUTO_HANDOFF_THRESHOLD_TOKENS'),
90 AI_DLC_AUTO_HANDOFF_DEFAULT_CONTEXT_WINDOW: await $.env.get('AI_DLC_AUTO_HANDOFF_DEFAULT_CONTEXT_WINDOW'),
91 AI_DLC_AUTO_HANDOFF_SEED_HEADROOM_TOKENS: await $.env.get('AI_DLC_AUTO_HANDOFF_SEED_HEADROOM_TOKENS'),
92 AI_DLC_AUTO_HANDOFF_MAX_CONSECUTIVE: await $.env.get('AI_DLC_AUTO_HANDOFF_MAX_CONSECUTIVE'),
93 AI_DLC_AUTO_HANDOFF_CHAIN_WHILE_RUN_ACTIVE: await $.env.get('AI_DLC_AUTO_HANDOFF_CHAIN_WHILE_RUN_ACTIVE'),
94 AI_DLC_AUTO_HANDOFF_RENAME_SESSIONS: await $.env.get('AI_DLC_AUTO_HANDOFF_RENAME_SESSIONS'),
95 AI_DLC_AUTO_HANDOFF_SESSION_NAME_PREFIX: await $.env.get('AI_DLC_AUTO_HANDOFF_SESSION_NAME_PREFIX'),
96 AI_DLC_AUTO_HANDOFF_BRIEF_TEMPLATE: await $.env.get('AI_DLC_AUTO_HANDOFF_BRIEF_TEMPLATE'),
97 AI_DLC_AUTO_HANDOFF_INSTRUCTIONS_TEMPLATE: await $.env.get('AI_DLC_AUTO_HANDOFF_INSTRUCTIONS_TEMPLATE'),
98 AI_DLC_AUTO_HANDOFF_IGNORE_FILES: await $.env.get('AI_DLC_AUTO_HANDOFF_IGNORE_FILES'),
99 AI_DLC_AUTO_HANDOFF_VIEWER: await $.env.get('AI_DLC_AUTO_HANDOFF_VIEWER'),
100 }
101 return Object.fromEntries(Object.entries(read).filter(([, v]) => v !== undefined))
102}
103
104// Reads every source and resolves each setting, highest first: `env` of .claude/settings.local.json,
105// `env` of .claude/settings.json (both read from the files, because the merged process environment
106// cannot tell them apart), the process environment, .ai-dlc/auto-handoff.json, the default
107// (config.ts, resolveConfig). The automatic handoff has no /config rows. An unreadable source and
108// every invalid value are logged; resolution falls through. Never throws: on an error the module runs
109// on the defaults.
110async function loadConfig($: EngineInterface): Promise<void> {
111 try {
112 const root = await repoRoot($)
113 const levels: Level[] = []
114 const problems: string[] = []
115 for (const where of ['.claude/settings.local.json', '.claude/settings.json']) {
116 const { env, problem } = settingsEnv(await readRaw($, `${root}/${where}`))
117 if (problem) problems.push(`${where} ${problem}`)
118 if (env) levels.push({ where, byEnv: true, values: env })
119 }
120 levels.push({ where: 'the environment', byEnv: true, values: await processEnv($) })
121 const repo = repoConfig(await readRaw($, `${root}/${REPO_CONFIG}`))
122 if (repo.problem) problems.push(`${REPO_CONFIG} ${repo.problem}`)
123 // viewer, briefTemplate and instructionsTemplate apply from the repository file only in a folder
124 // Claude Code trusts (config.ts, TRUST_GATED; io.ts, trustGate).
125 if (repo.values) levels.push({ where: REPO_CONFIG, byEnv: false, values: await trustGate($, repo.values, TRUST_GATED, REPO_CONFIG, 'set them in the env of .claude/settings.local.json instead', problems) })
126 const r = resolveConfig(levels)
127 cfg = r.cfg
128 cfgSources = r.sources
129 for (const line of [...problems, ...r.warnings]) await log($, `config: ${line}`)
130 } catch (err) {
131 const r = resolveConfig([])
132 cfg = r.cfg
133 cfgSources = r.sources
134 await log($, `config error ${String(err)}; using the defaults`)
135 }
136}
137
138// The settings, loaded once per session start; concurrent callers share one load.
139async function ensureConfig($: EngineInterface): Promise<void> {
140 if (cfgLoaded) return
141 cfgLoading ??= loadConfig($).finally(() => { cfgLoaded = true; cfgLoading = undefined })
142 await cfgLoading
143}
144
145// A fresh load of the settings. A load already in flight read the files before this call, so it is
146// awaited first and a new one started after it; reusing it could keep the old values for the session.
147async function reloadConfig($: EngineInterface): Promise<void> {
148 if (cfgLoading) await cfgLoading
149 cfgLoaded = false
150 await ensureConfig($)
151}
152
153// Records what the running Claude Code offers, for /ai-dlc:init and /ai-dlc:configure-auto-handoff:
154// whether the built-in /rename is in $.command.list(), and the engine version. A model-run skill
155// cannot ask the engine, so it reads this file. Once per process and at each startup or resume.
156async function recordCapabilities($: EngineInterface): Promise<void> {
157 if (capsRecorded) return
158 capsRecorded = true
159 try {
160 const names = (await $.command.list()).map(c => c.name)
161 let version: string | null = null
162 try { version = (await $.session.version()).version } catch {}
163 const path = `${await homeDir($)}/${BRIEF_DIR}/${CAPABILITIES_FILE}`
164 await $.fs.write(path, `${JSON.stringify({ checkedAt: new Date().toISOString(), claudeCodeVersion: version, rename: names.includes('rename') }, null, 2)}\n`)
165 } catch (err) {
166 await log($, `capabilities check failed ${String(err)}`)
167 }
168}
169
170const shippedFile = ($: EngineInterface, file: string) => `${$.plugin.root}/${MODULE_DIR}/templates/${file}`
171const shipped = ($: EngineInterface, key: TemplateKey) => shippedFile($, TEMPLATES.find(t => t[0] === key)![1])
172
173// The template file at its configured path, else the default the mod ships, else ''.
174async function template($: EngineInterface, key: TemplateKey): Promise<string> {
175 return await readText($, expand(cfg[key], await homeDir($) ?? '')) ?? await readText($, shipped($, key)) ?? ''
176}
177
178// A new session writes each template to its path if nothing is there yet, so the files exist
179// to be edited. A file the user wrote is never touched.
180async function writeMissingTemplates($: EngineInterface) {
181 const home = await homeDir($) ?? ''
182 for (const [key] of TEMPLATES) {
183 const path = expand(cfg[key], home)
184 try { await $.fs.read(path); continue } catch {}
185 const text = await readText($, shipped($, key))
186 if (!text) { await log($, `template default unreadable ${shipped($, key)}`); continue }
187 try { await $.fs.write(path, text) } catch (err) { await log($, `template write failed ${path} ${String(err)}`) }
188 }
189}
190
191// The viewer server's address: the Tailscale host resolved to this machine's IPv4, or localhost
192// when Tailscale is missing, logged out or has no address, so the link still opens on this machine.
193async function serveAddress($: EngineInterface): Promise<{ host: string; port: string } | undefined> {
194 const addr = cfg.viewer ? parseAddress(cfg.viewer) : undefined
195 if (!addr || addr.host !== 'tailscale') return addr
196 const local = { host: '127.0.0.1', port: addr.port }
197 try {
198 const { exitCode, stdout } = await $.process.run(['tailscale', 'ip', '-4'])
199 const ip = stdout.trim().split('\n')[0]?.trim()
200 return exitCode === 0 && ip ? { host: ip, port: addr.port } : local
201 } catch {
202 return local
203 }
204}
205
206let lastServeTry = 0
207// Starts the server detached (setsid, else nohup), so the pages stay served after this session
208// exits: a brief's link is opened later, often from a phone, long after the handoff. When a
209// server already holds the port, the new child exits at once, so a launch is safe to repeat.
210// Its output goes to the mod's log. Answers whether the launch ran: it needs `sh`, which Windows
211// lacks, and then the link falls back to the local file.
212async function launchServer($: EngineInterface, pagesDir: string, addr: { host: string; port: string }): Promise<boolean> {
213 try {
214 const home = await homeDir($)
215 const { exitCode } = await $.process.run(['sh', '-c', 'mkdir -p "$1"; if command -v setsid >/dev/null 2>&1; then d=setsid; else d=nohup; fi; $d node -e "$2" "$1" "$3" "$4" >>"$5" 2>&1 </dev/null &',
216 'sh', pagesDir, SERVER_JS, addr.host, addr.port, `${home}/${BRIEF_DIR}/auto-handoff.log`])
217 if (exitCode === 0) return true
218 await log($, `viewer server failed exit=${exitCode}`)
219 } catch (err) {
220 await log($, `viewer server failed ${String(err)}`)
221 }
222 return false
223}
224
225// Brings the server back if it died (a reboot, a crash). Called on startup and after each turn,
226// at most once in five minutes. Sessions with the kill switches set serve too: the switches stop
227// handoffs, and serving old briefs is not one.
228async function keepServing($: EngineInterface) {
229 if (Date.now() - lastServeTry < 300_000) return
230 lastServeTry = Date.now()
231 const addr = await serveAddress($)
232 const home = await homeDir($)
233 if (addr && home) await launchServer($, `${home}/${BRIEF_DIR}/pages`, addr)
234}
235
236/** Writes the page of every brief in sessionId's chain, so each page lists the whole chain. */
237async function writeChainPages($: EngineInterface, briefDir: string, pagesDir: string, sessionId: string): Promise<void> {
238 const own = await $.fs.read(`${briefDir}/${sessionId}.md`)
239 if (typeof own !== 'string') return
240 const all: Entry[] = []
241 for (const f of await $.fs.list(briefDir)) {
242 if (f.kind !== 'file' || !f.name.endsWith('.md')) continue
243 const id = f.name.slice(0, -3)
244 try {
245 const text = await $.fs.read(`${briefDir}/${f.name}`)
246 if (typeof text !== 'string') continue
247 const { header, body } = parseBrief(text)
248 all.push({ id, header, body })
249 } catch {}
250 }
251 const chain = chainOf(all, sessionId)
252 for (const e of chain) await $.fs.write(`${pagesDir}/${e.id}.html`, renderPage(e, chain))
253}
254
255// Writes the pages for sessionId's chain and makes sure the server is up. Returns the page's
256// link, or '' when the pages could not be written. Never throws: the viewer is not the handoff.
257async function viewer($: EngineInterface, briefDir: string, pagesDir: string, sessionId: string): Promise<string> {
258 try {
259 // $.fs.write creates pagesDir, so no `mkdir`: a subprocess Windows can't run.
260 await writeChainPages($, briefDir, pagesDir, sessionId)
261 const addr = await serveAddress($)
262 const served = addr && await launchServer($, pagesDir, addr)
263 return viewerLink(served ? addr : undefined, pagesDir, sessionId)
264 } catch (err) {
265 await log($, `viewer error session=${sessionId} ${String(err)}`)
266 return ''
267 }
268}
269
270// What the run lookup found for this session:
271// run: the AI-DLC run it hands off (run.ts, pickRun), with where its files go;
272// owned: whether that run belongs to this session's chain (its `session` is this session or an
273// earlier one of the chain, or the chain claimed it), so the chain may go on past the loop guard;
274// ended: the run this chain claimed, when its status is now done, blocked or abandoned.
275type Lookup = { run?: RunTarget; owned: boolean; ended?: RunState }
276
277// The tool gate asks canHandOff on every call past the threshold, and parallel calls arrive together.
278// One lookup (git, then every run state) serves them for LOOKUP_TTL_MS, shared while it is still running;
279// the cache is also dropped at each turn end, typed prompt and session start, so a changed run state
280// is seen at the next turn. runLookup never rejects, so a cached promise cannot hold an error.
281const LOOKUP_TTL_MS = 2_000
282let lookupCache: { sessionId: string; at: number; look: Promise<Lookup> } | undefined
283function cachedLookup($: EngineInterface, sessionId: string): Promise<Lookup> {
284 const now = Date.now()
285 if (lookupCache?.sessionId === sessionId && now - lookupCache.at < LOOKUP_TTL_MS) return lookupCache.look
286 const look = runLookup($, sessionId, new Date(now))
287 lookupCache = { sessionId, at: now, look }
288 return look
289}
290
291// Run folders whose odd run-state names (run.ts, runStateNote) were already logged: once per folder
292// and note for the life of the module, not at every lookup.
293const notedFolders = new Set<string>()
294
295// The AI-DLC runs on the current branch, as they matter to this session. Never throws: with no run
296// the handoff goes on as the mod's own brief, under the loop guard.
297async function runLookup($: EngineInterface, sessionId: string, at: Date): Promise<Lookup> {
298 try {
299 const cwd = (await $.session.cwd()).replace(/\\/g, '/')
300 const root = await git($, ['-C', cwd, 'rev-parse', '--show-toplevel'])
301 if (!root) return { owned: false }
302 const branchName = await git($, ['-C', root, 'rev-parse', '--abbrev-ref', 'HEAD'])
303 if (!branchName || branchName === 'HEAD') return { owned: false }
304 const handoffs = handoffsRoot(await readText($, `${root}/.ai-dlc/workflow.json`))
305 let entries: { name: string; kind: string }[]
306 try { entries = await $.fs.list(inRepo(root, handoffs)) } catch { return { owned: false } }
307 // Each run folder's current run state (run.ts, currentRunStateFile: the highest
308 // run.v<major>[.<minor>].md, else a run.md from before versions) is read in parallel: the status
309 // that rules a run out is only in its front matter.
310 const files = new Map<string, string>()
311 // Run folders are directories and run states files; a link (kind `other`) is neither, at both levels.
312 const states = await Promise.all(entries.filter(e => e.kind === 'dir').map(async e => {
313 const folder = `${handoffs}/${e.name}`
314 let names: string[]
315 try { names = (await $.fs.list(inRepo(root, folder))).filter(f => f.kind === 'file').map(f => f.name) } catch { return undefined }
316 const note = runStateNote(names)
317 const line = note && `run state names ${folder}: ${note}`
318 if (line && !notedFolders.has(line)) {
319 notedFolders.add(line)
320 await log($, line)
321 }
322 const file = currentRunStateFile(names)
323 if (!file) return undefined
324 const text = await readText($, `${inRepo(root, folder)}/${file}`)
325 const state = text ? parseRunState(text, folder) : undefined
326 if (state) files.set(folder, `${folder}/${file}`)
327 return state
328 }))
329 const runs = states.filter((s): s is RunState => s !== undefined)
330 const lineageIds = await ancestors($, sessionId)
331 const claimed = await claimedRun($, sessionId)
332 const claimedState = claimed === undefined ? undefined : runs.find(r => r.id === claimed)
333 const ended = claimedState?.status && ENDED.has(claimedState.status) ? claimedState : undefined
334 const run = pickRun(runs, branchName, sessionId, lineageIds, claimed)
335 if (!run) return { owned: false, ended }
336 const chain = new Set([sessionId, ...lineageIds])
337 const session = run.session?.trim() ?? ''
338 const owned = (session !== '' && chain.has(session)) || run.id === claimed
339 const scratch = scratchRoot(await readText($, `${root}/AGENTS.md`))
340 const commit = await git($, ['-C', root, 'rev-parse', '--short', 'HEAD'])
341 return { run: { ...run, root, runState: files.get(run.folder)!, sessionHandoff: sessionHandoffPath(scratch, run, at), branchName, commit }, owned, ended }
342 } catch (err) {
343 await log($, `run lookup error session=${sessionId} ${String(err)}`)
344 return { owned: false }
345 }
346}
347
348// A session's stored lineage record: found, absent (nothing, or not a lineage), or failed (the store
349// read itself rejected). The numbering path tells absent from failed (config.ts, numberingNotice).
350type LineageRead = { state: 'found'; lineage: Lineage } | { state: 'absent' } | { state: 'failed'; error: string }
351
352async function readLineage($: EngineInterface, sessionId: string): Promise<LineageRead> {
353 let v: Partial<Lineage> | undefined
354 try {
355 v = await $.store.get(`lineage:${sessionId}`) as Partial<Lineage> | undefined
356 } catch (err) {
357 return { state: 'failed', error: String(err) }
358 }
359 return typeof v?.from === 'string' && typeof v.chain === 'string'
360 ? { state: 'found', lineage: { from: v.from, chain: v.chain, depth: typeof v.depth === 'number' ? v.depth : undefined, baseName: typeof v.baseName === 'string' ? v.baseName : undefined } }
361 : { state: 'absent' }
362}
363
364// The stored lineage for the chain readers (ancestors, the run lookup): a failed read counts as absent.
365async function storedLineage($: EngineInterface, sessionId: string): Promise<Lineage | undefined> {
366 const read = await readLineage($, sessionId)
367 return read.state === 'found' ? read.lineage : undefined
368}
369
370// This session's lineage: in memory for the seeded session, else from the store (a hot reload).
371// handoff() does not use it: its numbering reads the store alone, so a failing store shows (D3).
372async function lineageOf($: EngineInterface, sessionId: string): Promise<Lineage | undefined> {
373 return sessionId === seededSession && lineage ? lineage : await storedLineage($, sessionId)
374}
375
376// The earlier sessions of this session's chain, nearest first, from the lineage records.
377async function ancestors($: EngineInterface, sessionId: string): Promise<string[]> {
378 const out: string[] = []
379 let cur = await lineageOf($, sessionId)
380 for (let i = 0; cur && i < 100 && !out.includes(cur.from); i++) {
381 out.push(cur.from)
382 cur = await storedLineage($, cur.from)
383 }
384 return out
385}
386
387// The run id this session's chain handed off with, recorded by the seed handler as chainRun:<session>.
388async function claimedRun($: EngineInterface, sessionId: string): Promise<string | undefined> {
389 try {
390 const v = await $.store.get(`chainRun:${sessionId}`)
391 return typeof v === 'string' && v ? v : undefined
392 } catch {
393 return undefined
394 }
395}
396
397// The session's current title: what the classic hook inputs said, else the transcript's last
398// custom-title (a name the person or /rename gave), else its ai-title.
399async function currentTitle($: EngineInterface, sessionId: string, transcript: string): Promise<string | undefined> {
400 const known = titles.get(sessionId)
401 if (known) return known
402 const text = await readRaw($, expand(transcript, await homeDir($) ?? ''))
403 return text ? transcriptTitle(text) : undefined
404}
405
406// Renames the session about to be cleared, before the /clear: a hook on the new session cannot
407// rename the old one. Only when the running Claude Code has /rename (checked each time, since the
408// types do not promise it). $.command.run queues a command and resolves once it has run, when the
409// session is idle; the rename is awaited, so the /clear is queued only after it ran, rather than
410// trusting the queue to keep two commands in order. A rename that fails, or has not run within
411// RENAME_WAIT_MS, is logged and never stops the handoff.
412const RENAME_WAIT_MS = 10_000
413async function renameOld($: EngineInterface, sessionId: string, name: string, title: string | undefined): Promise<void> {
414 if (title === name) return
415 try {
416 const names = (await $.command.list()).map(c => c.name)
417 if (!names.includes('rename')) {
418 await log($, `rename unavailable: /rename is not in this Claude Code's commands; session=${sessionId} keeps its name`)
419 return
420 }
421 titles.set(sessionId, name)
422 await log($, `renaming session=${sessionId} to "${name}"`)
423 let timer: Timer | undefined
424 const late = new Promise<'late'>(resolve => { timer = $.clock.after(RENAME_WAIT_MS, () => resolve('late')) })
425 const ran = $.command.run({ command: 'rename', args: name }).then(
426 () => 'ran' as const,
427 async (err: unknown) => { await log($, `rename failed session=${sessionId} ${String(err)}`); return 'failed' as const },
428 )
429 const outcome = await Promise.race([ran, late])
430 timer?.cancel()
431 if (outcome === 'late') await log($, `rename not run within ${RENAME_WAIT_MS / 1000}s session=${sessionId}; clearing anyway`)
432 } catch (err) {
433 await log($, `rename failed session=${sessionId} ${String(err)}`)
434 }
435}
436
437// Writes the brief, the run's session handoff when there is a run, and the viewer pages, then
438// renames the session and queues the /clear. `found`: the run lookup chainGuard already made for this
439// attempt, so it is not done twice.
440async function handoff($: EngineInterface, sessionId: string, tokens: number, threshold: number, found?: Lookup) {
441 try {
442 // The numbering reads this session's lineage from the store, never the in-memory copy, so a store
443 // that fails shows (the seed wrote the same record there before this session could hand off).
444 const read = await readLineage($, sessionId)
445 const own = read.state === 'found' ? read.lineage : undefined
446 // The title is needed before the brief: its number is the session's number.
447 const { transcript, cwd } = await transcriptFor($, sessionId)
448 const title = await currentTitle($, sessionId, transcript)
449 const parts = parseTitle(title, cfg.sessionNamePrefix)
450 // The title's number wins, then the lineage depth, then 1 (the main session).
451 const num = numbering(parts.number, own?.depth, read.state === 'failed')
452 // The chain's original name: carried in the lineage, else this session's own title (with any
453 // chain wrapper taken off), else its short id.
454 const name = own?.baseName ?? parts.base ?? short(sessionId)
455 // One notice per handoff whose lineage read failed: logged here, once; its step rides along.
456 const notice = read.state === 'failed' ? numberingNotice(sessionId, read.error, num) : undefined
457 if (notice) await log($, notice.log)
458 const at = new Date()
459 const { run } = found ?? await runLookup($, sessionId, at)
460 const { brief, problem } = await composeBrief($, sessionId, tokens, threshold, num.current, run, { transcript, cwd })
461 const briefDir = `${await homeDir($)}/${BRIEF_DIR}`
462 const briefPath = `${briefDir}/${sessionId}.md`
463 // A failed read restarts the chain id here, as an absent one does; the notice says so.
464 const chain = own?.chain ?? sessionId
465 const header = { from: own?.from, chain, depth: String(num.current), tokens: String(tokens), at: new Date().toISOString(), cwd }
466 await $.fs.write(briefPath, withHeader(header, brief))
467 const linked = run ? await writeRunHandoff($, run, brief, sessionId, tokens, at) : undefined
468 const link = await viewer($, briefDir, `${briefDir}/pages`, sessionId)
469 pending = { oldSession: sessionId, briefPath, seedPath: linked?.seedPath ?? briefPath, run: linked?.run, tokens, chain, link, problem, baseName: name, next: num.next, notice: notice?.step }
470 // The notice's toast goes out here, once; later step lists carry the step without one.
471 steps($, [...(notice ? [notice.step] : []), briefStep(problem), { mark: 'spin', text: 'clearing' }], notice?.toast)
472 await $.store.set(`fired:${sessionId}`, problem ? `clearing-facts-only:${problem}` : 'clearing')
473 await log($, `brief written ${briefPath} (${brief.length} chars); queueing /clear`)
474 // Only the main session is renamed, to Session 1: a numbered one keeps its title (D4).
475 if (cfg.renameSessions && num.isMain) await renameOld($, sessionId, sessionName(cfg.sessionNamePrefix, name, 1), title)
476 // Module timers survive /clear: keep-alive's must not ping the old conversation into the new session.
477 if (stopForHandoff($)) await log($, `keepalive timers stopped before /clear session=${sessionId}`)
478 queueClear($, briefPath)
479 } catch (err) {
480 pending = undefined
481 // fired stays 'briefing', which tryHandoff treats as an orphan: the next turn tries again.
482 failed($, 'no brief written', 'this session keeps going and tries again after the next turn')
483 await log($, `handoff error session=${sessionId} ${String(err)}; no clear`)
484 }
485}
486
487// The session's working folder and its transcript path, read once per handoff: the title comes from
488// the transcript before the brief is written, and the brief's header names the folder.
489async function transcriptFor($: EngineInterface, sessionId: string): Promise<{ transcript: string; cwd: string }> {
490 const cwd = await $.session.cwd()
491 // Claude Code keeps transcripts under the cwd with every non-alphanumeric character as '-'.
492 const transcript = transcriptPath ?? `~/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${sessionId}.jsonl`
493 return { transcript, cwd }
494}
495
496// The brief's text: Haiku's summary in the template's sections, around the facts the code gathered,
497// or the facts alone when the summary is unusable (`problem` says why). With a run, Haiku writes the
498// handoff skill's session handoff sections instead of the brief's.
499// depth: the session's number in its chain (config.ts, numbering), always known.
500async function composeBrief($: EngineInterface, sessionId: string, tokens: number, threshold: number, depth: number, run: RunTarget | undefined, where: { transcript: string; cwd: string }) {
501 const messages = await $.session.messages()
502 const facts = extractFacts(messages, cfg.ignoreFiles)
503 const { base, source } = await configured($)
504 facts.handoffTokens = tokens
505 facts.threshold = threshold
506 facts.thresholdSource = threshold > base ? `${source} (${k(base)}), raised to leave ${k(cfg.seedHeadroomTokens)} above the starting size` : source
507 if (sessionId === seededSession && floor !== undefined) facts.seededSessionStartSize = floor
508 facts.unattendedCount = unattended
509 facts.depth = depth
510 const briefTemplate = (run && await readText($, shippedFile($, SESSION_TEMPLATE))) || await template($, 'briefTemplate')
511 const result = await $.model.complete({
512 model: 'haiku',
513 system: 'You summarize coding sessions into precise handoff briefs.',
514 prompt: briefPrompt(messages, facts, briefTemplate),
515 maxTokens: 4_000,
516 timeoutMs: 60_000,
517 })
518 // A failed, empty or sectionless reply falls back to the facts.
519 const text = result.isAnswered ? result.text : ''
520 const problem = !result.isAnswered ? result.reason : !text.trim() ? 'empty' : !isValidBrief(text, briefTemplate) ? 'no-sections' : undefined
521 if (problem) await log($, `haiku brief unusable session=${sessionId} reason=${problem}; using facts-only brief`)
522 const checked = markUnverifiedFigures(text, facts)
523 if (!problem && checked.flagged.length) await log($, `brief figures not in Handoff Numbers session=${sessionId}: ${checked.flagged.join(', ')}`)
524 const { transcript } = where
525 const instructions = await template($, 'instructionsTemplate') || LAST_RESORT_INSTRUCTIONS
526 const summary = problem ? undefined : checked.text
527 const brief = run
528 ? assembleSessionHandoff({ run, sessionId, transcript, instructions, tokens: k(tokens), records: await recordsOf($, run) }, facts, summary)
529 : assembleBrief({ sessionId, transcript, instructions }, facts, summary)
530 return { brief, problem }
531}
532
533// The run's session handoff, then the run's current run state pointing at it, both before the
534// /clear: a session that dies between here and the seed still leaves the run resumable from its run
535// state. The *Next action* is written into the version the lookup read, never a new one: a handoff
536// chain is one session (handoff skill, *Run state*, Versions). The session handoff goes first so the
537// run state never points at a file that was not written; when a later step fails, the
538// log names the step and the session handoff left behind (gitignored scratch, harmless), and the
539// answer is undefined, so the seed names the brief instead.
540async function writeRunHandoff($: EngineInterface, run: RunTarget, brief: string, sessionId: string, tokens: number, at: Date): Promise<{ seedPath: string; run: NonNullable<Pending['run']> } | undefined> {
541 const handoffPath = inRepo(run.root, run.sessionHandoff)
542 const statePath = inRepo(run.root, run.runState)
543 const file = run.runState.slice(run.runState.lastIndexOf('/') + 1)
544 let step = 'write session handoff'
545 try {
546 await $.fs.write(handoffPath, brief)
547 step = `read ${file}`
548 const state = await $.fs.read(statePath)
549 if (typeof state !== 'string') throw new Error(`${run.runState} unreadable`)
550 step = `write ${file}`
551 await $.fs.write(statePath, withNextAction(state, { stage: run.stage, sessionHandoff: run.sessionHandoff, oldSession: sessionId, tokens: k(tokens), at }))
552 await log($, `session handoff written ${handoffPath}; ${run.runState} next action updated`)
553 return { seedPath: handoffPath, run: { id: run.id, runState: run.runState } }
554 } catch (err) {
555 const left = step === 'write session handoff' ? '' : `; ${handoffPath} written but not linked from ${run.runState}`
556 await log($, `session handoff failed run=${run.id} step="${step}" ${String(err)}${left}; seeding the brief instead`)
557 return undefined
558 }
559}
560
561// Queues the /clear that ends this session; the seed follows at the next SessionStart.
562function queueClear($: EngineInterface, briefPath: string) {
563 $.command.run({ command: 'clear' }).catch(async (err: unknown) => {
564 pending = undefined
565 // fired stays 'clearing', so this session does not try again; it carries on as it is.
566 failed($, '/clear was rejected', `this session keeps going; the brief is at ${briefPath}`)
567 await log($, `clear rejected ${String(err)}`)
568 })
569}
570
571// The open records of the run, each checked on disk (run.ts, openRecords). The session handoff is
572// the file being written, so it counts as there.
573async function recordsOf($: EngineInterface, run: RunTarget) {
574 const state = await readRaw($, inRepo(run.root, run.runState))
575 if (state === undefined) return undefined
576 return Promise.all(openRecords(state, run, run.sessionHandoff).map(async r => ({
577 ...r,
578 exists: r.path === run.sessionHandoff || await readRaw($, inRepo(run.root, r.path)) !== undefined,
579 })))
580}
581
582// Whether the chain may hand off once more without a typed prompt. A session the person prompted
583// (unattended 0) always may. Otherwise, with chainWhileRunActive, the run state decides: the run
584// this chain claimed being done, blocked or abandoned ends the chain, and an active run of the
585// chain lets it go on past maxConsecutiveHandoffs. With no such run, the loop guard applies.
586// `find` makes the run lookup; a guard that goes ahead returns it, so the handoff does not redo it.
587type Guard = { stop: false; look?: Lookup } | { stop: true; log: string; header: string; toast: string }
588async function chainGuard(sessionId: string, find: () => Promise<Lookup>): Promise<Guard> {
589 if (unattended === 0) return { stop: false }
590 if (!cfg.chainWhileRunActive) return unattended < cfg.maxUnattended ? { stop: false } : pause(sessionId)
591 const look = await find()
592 if (look.ended) {
593 const why = `run ${look.ended.id} is ${look.ended.status}: chain ends`
594 return { stop: true, log: `${why} session=${sessionId} after ${unattended} handoffs with no user prompt`, header: `auto-handoff stopped: ${why}`, toast: `${why}: send a message to resume` }
595 }
596 if (unattended < cfg.maxUnattended) return { stop: false, look }
597 if (look.owned && look.run) return { stop: false, look }
598 return pause(sessionId)
599}
600
601const pause = (sessionId: string): Guard => ({
602 stop: true,
603 log: `loop guard session=${sessionId}: ${unattended} handoffs with no user prompt; paused until one`,
604 header: `auto-handoff paused after ${unattended} handoffs in a row`,
605 toast: `paused after ${unattended} handoffs in a row: send a message to resume`,
606})
607
608// Shared by turn.complete and turn.step: the fired, kill-switch and loop-guard checks, then
609// the handoff itself. Returns true when a handoff started.
610async function tryHandoff($: EngineInterface, sessionId: string, tokens: number, threshold: number, via: string): Promise<boolean> {
611 const fired = await $.store.get(`fired:${sessionId}`)
612 // Every caller checks inFlight first, so a 'briefing' marker seen here is an orphan: the
613 // module reloaded mid-handoff and the brief never landed. Retry instead of going quiet.
614 if (fired === 'briefing') await log($, `stale briefing marker session=${sessionId} (mod reloaded mid-handoff); retrying`)
615 else if (fired) return false
616
617 const pane = await paneVar($)
618 if (pane) {
619 await $.store.set(`fired:${sessionId}`, 'skipped-pane')
620 await log($, `skip session=${sessionId} tokens=${tokens} reason=${pane}`)
621 return false
622 }
623
624 // Not marked fired: once the user types, the next turn can hand off.
625 const guard = await chainGuard(sessionId, () => runLookup($, sessionId, new Date()))
626 if (guard.stop) {
627 if (pausedSession !== sessionId) {
628 pausedSession = sessionId
629 await log($, guard.log)
630 showPanel($, { header: { mark: 'warn', text: guard.header }, steps: [{ mark: 'warn', text: 'send a message to resume' }], sticky: true }, guard.toast)
631 }
632 return false
633 }
634 // Counted with an active run too, so the brief, the panel and the log show the chain's length.
635 unattended++
636
637 inFlight = true
638 gated = undefined
639 // No keep-alive ping while the brief is written: it would only re-cache a conversation about to go.
640 if (stopForHandoff($)) await log($, `keepalive timers stopped for the handoff session=${sessionId}`)
641 await $.store.set(`fired:${sessionId}`, 'briefing')
642 // via goes last: it can hold spaces ("turn.step measured=123 gated").
643 await log($, `threshold session=${sessionId} tokens=${tokens} threshold=${threshold} ${windowFields(lastWindow, cfg)} ${hostFields(await hostWindow($))} via=${via}`)
644 showPanel($, { header: { mark: 'spin', text: `auto-handoff · ${k(tokens)} / ${k(threshold)}` }, steps: [{ mark: 'spin', text: 'writing brief' }] },
645 `context ${k(tokens)} is past ${k(threshold)}: handing off`)
646 // Not awaited: the brief can take a while and /clear only runs once the session is idle.
647 handoff($, sessionId, tokens, threshold, guard.look).finally(() => { inFlight = false })
648 return true
649}
650
651// Kill switches. `enabled` false (from any source: AI_DLC_AUTO_HANDOFF_ENABLED=false in a shell
652// turns it off for the sessions started there) turns the mod off. DISABLE_AUTO_COMPACT, Claude Code's
653// own variable, means something else owns the context limit (a wrapper that pipes the session, where
654// /clear would break the pipe), so the mod stays out of its way too.
655async function paneVar($: EngineInterface): Promise<string | undefined> {
656 if (!cfg.enabled) return `enabled=false (${cfgSources.enabled ?? 'default'})`
657 // Literal name: the host lists the variables a module reads.
658 if (await $.env.get('DISABLE_AUTO_COMPACT')) return 'DISABLE_AUTO_COMPACT set'
659 return undefined
660}
661
662// Whether tryHandoff would go ahead for this session. The tool gate refuses calls only then,
663// so a session the mod will not hand off (pane, loop guard, chain ended, already fired) is never blocked.
664async function canHandOff($: EngineInterface, sessionId: string): Promise<boolean> {
665 if (await paneVar($) || (await chainGuard(sessionId, () => cachedLookup($, sessionId))).stop) return false
666 const fired = await $.store.get(`fired:${sessionId}`)
667 return !fired || fired === 'briefing'
668}
669
670// The panel above the prompt. A module variable rather than $.state: $.state does not survive
671// /clear, and the panel carries the handoff across it.
672const FRAME_MS = 100 // the host redraws the band ten times a second at most
673const DONE_MS = 10_000
674let shown: Panel | undefined
675let frame = 0
676let spin: Timer | undefined
677let collapse: Timer | undefined
678
679// The band above the prompt is drawn on the terminal and desktop only. On the mobile app or in
680// VS Code nothing shows it, so the moments that matter go out as a toast there too: the
681// threshold tripping, the result, and anything that stays up until dismissed. Never per step.
682const BAND_SURFACES = new Set(['terminal', 'desktop'])
683async function toastOffBand($: EngineInterface, text: string) {
684 try {
685 if ((await $.session.surfaces()).some((s) => !BAND_SURFACES.has(s))) $.ui.toast(text, { timeoutMs: 30_000 })
686 } catch (err) {
687 await log($, `surfaces error ${String(err)}`)
688 }
689}
690
691function showPanel($: EngineInterface, p: Panel, toast?: string) {
692 if (toast) void toastOffBand($, toast)
693 shown = p
694 collapse?.cancel()
695 collapse = undefined
696 if (isSpinning(p)) {
697 spin ??= $.clock.every(FRAME_MS, () => {
698 frame++
699 $.ui.invalidate('ui.render')
700 })
701 } else {
702 spin?.cancel()
703 spin = undefined
704 if (!p.sticky) collapse = $.clock.after(DONE_MS, () => hidePanel($))
705 }
706 $.ui.invalidate('ui.render')
707}
708
709function hidePanel($: EngineInterface) {
710 shown = undefined
711 spin?.cancel()
712 collapse?.cancel()
713 spin = collapse = undefined
714 $.ui.invalidate('ui.render')
715}
716
717// The steps under the panel's current header; a panel lost to a reload gets a plain one. `toast`:
718// shown off the band (toastOffBand) with this call; a step list replaces the last, so a step that
719// must stay up is passed again in each later list, without its toast.
720function steps($: EngineInterface, lines: Line[], toast?: string) {
721 showPanel($, { header: shown?.header ?? { mark: 'spin', text: 'auto-handoff' }, steps: lines }, toast)
722}
723
724// Adds a step to the panel on screen, or opens one under `header` when nothing is showing.
725function addStep($: EngineInterface, step: Line, header: Line, sticky = false) {
726 const p = shown ?? { header, steps: [] }
727 showPanel($, { ...p, steps: [...p.steps, step], sticky: p.sticky || sticky }, step.mark === 'spin' || step.mark === 'done' ? undefined : step.text)
728}
729
730// A facts-only brief is the one quiet failure: the handoff works, but the brief is thin.
731const briefStep = (problem?: string): Line => problem
732 ? { mark: 'warn', text: `brief is facts only: the summary failed (${problem})` }
733 : { mark: 'done', text: 'brief written' }
734
735function failed($: EngineInterface, what: string, next: string) {
736 showPanel($, { header: { mark: 'fail', text: `handoff failed: ${what}` }, steps: [{ mark: 'fail', text: next }, { mark: 'fail', text: `log: ${LOG}` }], sticky: true },
737 `handoff failed: ${what}. ${next}`)
738}
739
740// The fresh session's first measurement: the panel's last step, which says the handoff worked.
741// It collapses on its own unless the brief was facts only.
742// With `cache`, a step says whether the fresh session's first request read the shared prefix from the cache.
743function showHandedOff($: EngineInterface, fresh: number, cache?: SeedCache) {
744 if (!handedFrom) return
745 const { tokens, link, problem, notice } = handedFrom
746 const lines: Line[] = [...(notice ? [notice] : []), ...(problem ? [briefStep(problem)] : []), ...(cache ? [{ mark: cache.read > 0 ? 'done' : 'warn', text: seedCacheLine(cache) } as Line] : [])]
747 showPanel($, { header: { mark: 'done', text: `handed off · ${k(tokens)} → ${k(fresh)}` }, steps: lines, link: link || undefined, sticky: !!problem },
748 `↪ handed off · ${k(tokens)} → ${k(fresh)}${problem ? ' · the brief is facts only' : ''}`)
749 handedFrom = undefined
750}
751
752// The context window the threshold is a share of: what $.session.usage() reports (windowInfo), else
753// defaultContextWindow. The fallback is logged once per process, so a session on a larger window that
754// hands off early can be traced to it, and one on a smaller window, where the line lies past the
755// window and the handoff waits for Claude Code's auto-compact (session.compact below), is told to set thresholdTokens.
756let windowFallbackLogged = false
757// The window the last threshold was computed from, and its source, for the per-turn threshold log
758// line. configured() clears it first, so with thresholdTokens (no window read) it stays undefined.
759// Every caller of tryHandoff computes the threshold just before, so the line shows that window.
760let lastWindow: WindowInfo | undefined
761// Claude Code's own view of the window, for the threshold log line only: asked for when that line is
762// written, never on every turn. `summary` estimates locally; `full` would send token-count requests,
763// as /context does. A missing breakdown or a failed call gives undefined, which logs `none`.
764async function hostWindow($: EngineInterface): Promise<HostWindow | undefined> {
765 try {
766 return (await $.session.usage({ breakdown: 'summary' })).context.breakdown
767 } catch {
768 return undefined
769 }
770}
771
772async function contextWindow($: EngineInterface): Promise<number> {
773 const w = windowInfo((await $.session.usage()).context)
774 lastWindow = w ?? { window: cfg.defaultContextWindow, source: 'default' }
775 if (w !== undefined) return w.window
776 const fallback = cfg.defaultContextWindow
777 if (!windowFallbackLogged) {
778 windowFallbackLogged = true
779 const line = contextLine(cfg.thresholdPercent, fallback)
780 await log($, `no context window reported: thresholdPercent applies to ${fallback}, a line of ${line} tokens; if the real window is smaller than ${line}, the handoff waits for auto-compact: set thresholdTokens (AI_DLC_AUTO_HANDOFF_THRESHOLD_TOKENS) to an absolute token count`)
781 }
782 return fallback
783}
784
785// The configured threshold in tokens and where it came from: thresholdTokens, an absolute token
786// count, when it is set anywhere; else thresholdPercent of the window. The source names the variable
787// or file, so the headroom warning tells the person what to change.
788// Main or lead session only: every caller skips events that carry an agentId.
789async function configured($: EngineInterface): Promise<{ base: number; source: string }> {
790 lastWindow = undefined
791 if (cfg.thresholdTokens) return { base: cfg.thresholdTokens, source: cfgSources.thresholdTokens ?? 'thresholdTokens' }
792 const window = await contextWindow($)
793 const from = cfgSources.thresholdPercent
794 return { base: contextLine(cfg.thresholdPercent, window), source: `${cfg.thresholdPercent}% of the ${k(window)} window, ${!from || from === 'default' ? 'thresholdPercent default' : from}` }
795}
796
797// A seeded session hands off no sooner than seedHeadroomTokens past its floor, whatever the threshold says.
798async function thresholdFor($: EngineInterface, sessionId: string): Promise<number> {
799 const { base } = await configured($)
800 return sessionId === seededSession ? Math.max(base, (floor ?? 0) + cfg.seedHeadroomTokens) : base
801}
802
803// Once per seeded session, as its floor lands: when the configured threshold leaves less than
804// seedHeadroomTokens above the floor, say so, with the number, the source, and where the line moved to.
805// Without this the 2026-10-04 chain looked like a guard bug: a token threshold left in a shell.
806let warnedSession: string | undefined
807async function warnTightThreshold($: EngineInterface, sessionId: string) {
808 if (floor === undefined || warnedSession === sessionId) return
809 warnedSession = sessionId
810 const { base, source } = await configured($)
811 const headroom = base - floor
812 const min = cfg.seedHeadroomTokens
813 if (headroom >= min) return
814 await log($, `tight threshold session=${sessionId} threshold=${base} source=${source} floor=${floor} headroom=${headroom} effective=${floor + min}`)
815 const left = headroom > 0 ? `leaves ${k(headroom)}` : 'is below'
816 addStep($, { mark: 'warn', text: `threshold ${k(base)} (${source}) ${left} this session's ${k(floor)} start: handing off at ${k(floor + min)} instead` }, { mark: 'warn', text: 'auto-handoff' }, true)
817}
818
819export const register: Register = (on) => {
820 // The defaults until the first hook reads the settings (ensureConfig).
821 cfg = resolveConfig([]).cfg
822 cfgLoaded = false
823 // Keep-alive (../keepalive/keepalive.ts): its own events and the /ai-dlc:cache-keepalive command. It arms no
824 // pings while a handoff is under way.
825 registerKeepalive(on, { handingOff: () => inFlight || pending !== undefined })
826 on('turn.complete', async ($, e, next) => {
827 const r = await next(e)
828 try {
829 if (!e.agentId) {
830 lookupCache = undefined
831 await ensureConfig($)
832 await recordCapabilities($)
833 await keepServing($)
834 }
835 if (e.agentId || inFlight || pending) return r // subagent turns fire turn.complete too
836 const tokens = (await $.session.usage()).context.tokens
837 if (tokens === undefined) return r
838 const sessionId = await $.session.id()
839 if (sessionId === seededSession && floor === undefined) {
840 // Fallback only: turn.step sets the floor from the seed turn's first response. Reached
841 // when that response carried no usage.
842 floor = tokens
843 await log($, `floor set at turn end session=${sessionId} tokens=${tokens} (first response carried no usage)`)
844 showHandedOff($, tokens)
845 await warnTightThreshold($, sessionId)
846 return r
847 }
848 const threshold = await thresholdFor($, sessionId)
849 if (tokens < threshold && gated !== sessionId) return r
850 await tryHandoff($, sessionId, tokens, threshold, gated === sessionId ? 'turn.complete gated' : 'turn.complete')
851 } catch (err) {
852 await log($, `turn.complete error ${String(err)}`)
853 }
854 return r
855 })
856
857 // Tool output lands in the next request; count it before that request is sent. Past the
858 // threshold, refuse the call instead: one step of parallel reads took a session from 67k to
859 // 437k with no request in between for turn.step to stop. A refused call never runs; the
860 // next request trips turn.step and the handoff goes through the normal path.
861 on('tool.call', async ($, e, next) => {
862 if (!e.agentId) {
863 try {
864 await ensureConfig($)
865 const sessionId = await $.session.id()
866 const tokens = (await $.session.usage()).context.tokens
867 const projected = (tokens ?? 0) + unmeasured
868 const threshold = await thresholdFor($, sessionId)
869 if (inFlight || pending || (tokens !== undefined && projected >= threshold && await canHandOff($, sessionId))) {
870 if (!inFlight && !pending) gated = sessionId
871 await log($, `tool refused session=${sessionId} tool=${e.tool} projected=${projected} threshold=${threshold}`)
872 return { deny: `[auto-handoff] Not run: the context is past the handoff threshold (${k(projected)} ≥ ${k(threshold)}). This session is handing off to a fresh one, which will redo this call. Make no more tool calls.` }
873 }
874 } catch (err) {
875 await log($, `tool.call gate error ${String(err)}`)
876 }
877 }
878 const r = await next(e)
879 if (!e.agentId && typeof r.text === 'string') unmeasured += Math.ceil(r.text.length / CHARS_PER_TOKEN)
880 return r
881 })
882
883 // Before each main-loop request: if the last measured size plus what has landed since
884 // crosses the threshold, end the turn here and hand off instead of sending a request
885 // that may overflow the window.
886 on('turn.step', async function* ($, e, next) {
887 if (!e.agentId && !inFlight && !pending && e.index > 0) {
888 try {
889 await ensureConfig($)
890 const tokens = (await $.session.usage()).context.tokens
891 const sessionId = await $.session.id()
892 const isSeedTurn = sessionId === seededSession && floor === undefined
893 if (tokens !== undefined && !isSeedTurn) {
894 const projected = tokens + unmeasured
895 const threshold = await thresholdFor($, sessionId)
896 const isGated = gated === sessionId
897 if ((projected >= threshold || isGated) && await tryHandoff($, sessionId, projected, threshold, `turn.step measured=${tokens}${isGated ? ' gated' : ''}`)) {
898 unmeasured = 0
899 // A gated session can measure under the threshold here; "would carry" a number below it reads as a bug.
900 const why = projected >= threshold
901 ? `The next request would carry about ${Math.round(projected / 1000)}k tokens (threshold ${Math.round(threshold / 1000)}k).`
902 : `A tool call was refused at the handoff threshold (${Math.round(threshold / 1000)}k).`
903 yield { kind: 'text', index: 0, text: `[auto-handoff] ${why} Stopping this turn to hand off to a fresh session.` }
904 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
905 return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null }
906 }
907 }
908 } catch (err) {
909 await log($, `turn.step error ${String(err)}`)
910 }
911 }
912 const before = unmeasured
913 const seeding = !e.agentId && seededSession !== undefined && floor === undefined
914 const r = yield* next(e)
915 // The response measured everything up to its request; its own output is new, and so is
916 // any tool output that landed while it streamed (core runs tools before the stream ends).
917 if (!e.agentId && r.usage) {
918 unmeasured = unmeasured - before + r.usage.output_tokens
919 // Keep-alive's cache ledger: until when each cache is warm. A failure is logged, never thrown.
920 const usage = r.usage
921 await orLog($, 'keepalive usage', () => noteUsage($, usage))
922 }
923 // ITEM-034.c: a subagent response's usage goes to the debug log only, never to the cache ledger
924 // (T17.3 forwards it to noteUsage, which then logs it itself)
925 if (e.agentId && r.usage) logAgentUsage($, r.usage, e.agentId)
926 // The seed turn's first request is the fresh session's true starting size. Measuring at
927 // the end of that turn instead let a busy first turn (47k to 129k of reads) set
928 // the floor at 129k, with the pre-request check off the whole way.
929 // It also proves what the handoff kept of the cache: only the shared prefix can be read.
930 if (seeding && r.usage) {
931 floor = r.usage.input_tokens + r.usage.cache_read_input_tokens + r.usage.cache_creation_input_tokens
932 const cache: SeedCache = {
933 input: r.usage.input_tokens,
934 read: r.usage.cache_read_input_tokens,
935 written: r.usage.cache_creation_input_tokens,
936 split: creationSplit(r.usage) ?? (await transcriptSplit($, transcriptPath ? expand(transcriptPath, await homeDir($) ?? '') : undefined))?.split,
937 }
938 await log($, `floor session=${seededSession} tokens=${floor} (seed turn's first request) ${seedCacheFields(cache)}`)
939 showHandedOff($, floor, cache)
940 // Also on keep-alive's status line, until the next turn starts. A failure there is logged,
941 // never thrown: the floor is already set, and the tight-threshold warning below must still run.
942 await orLog($, 'keepalive seed line', () => showSeedCache($, cache))
943 await warnTightThreshold($, seededSession!)
944 }
945 return r
946 })
947
948 // The engine's own auto-compact runs ahead of the turn.step check (live tests 2026-10-03:
949 // two compactions, no turn.step line). Catch it here and hand off in its place.
950 on('session.compact', async ($, e, next) => {
951 if (e.trigger !== 'auto' || e.agentId) return next(e)
952 if (inFlight || pending) return { skip: 'auto-handoff in progress' }
953 try {
954 await ensureConfig($)
955 const sessionId = await $.session.id()
956 const tokens = ((await $.session.usage()).context.tokens ?? 0) + unmeasured
957 const threshold = await thresholdFor($, sessionId)
958 await log($, `auto-compact session=${sessionId} projected=${tokens}`)
959 if (await tryHandoff($, sessionId, tokens, threshold, 'session.compact')) {
960 unmeasured = 0
961 return { skip: 'auto-handoff: handing off to a fresh session instead of compacting' }
962 }
963 } catch (err) {
964 await log($, `session.compact error ${String(err)}`)
965 }
966 return next(e)
967 })
968
969 // The seed row in the transcript: the brief path and the viewer URL drawn as links, so a
970 // click opens them. The stored message stays as submitted; only the drawing changes. A
971 // Markdown element linkifies http:, https: and file: (the Link element refuses the
972 // Tailscale IP), and the panel draws its brief link the same way.
973 // Yields the band to a survey, and passes when there is nothing to show.
974 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
975 if (!shown || e.props.hasSurvey) return next(e)
976 return panelTree($.ui.resolve(e), shown, frame, () => hidePanel($))
977 })
978
979 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'plugin' } } }, async ($, e, next) => {
980 const origin = e.props.origin
981 if (origin.kind !== 'plugin' || origin.name !== $.plugin.name || !e.props.text.startsWith(SEED_PREFIX)) return next(e)
982 const { Markdown } = $.ui.resolve(e)
983 return <Markdown text={linkify(e.props.text)} />
984 })
985
986 on('prompt.submit', async ($, e, next) => {
987 if (e.origin && USER_ORIGINS.has(e.origin.kind)) {
988 unattended = 0
989 lookupCache = undefined
990 if (pausedSession) hidePanel($) // the pause panel's "send a message to resume" is done
991 pausedSession = undefined
992 }
993 return next(e)
994 })
995
996 // The session's title as the engine knows it, kept for naming the chain (currentTitle).
997 on('classic.UserPromptSubmit', async ($, e, next) => {
998 const r = await next(e)
999 try {
1000 if (e.session_title?.trim()) titles.set(await $.session.id(), e.session_title.trim())
1001 } catch {}
1002 return r
1003 })
1004
1005 on('classic.SessionStart', async ($, e, next) => {
1006 const r = await next(e)
1007 if (e.transcript_path) transcriptPath = e.transcript_path
1008 // Each session start reloads the settings, so a chain picks up edits to the repository file.
1009 lookupCache = undefined
1010 await reloadConfig($)
1011 try {
1012 if (e.session_title?.trim()) titles.set(await $.session.id(), e.session_title.trim())
1013 } catch {}
1014 if (e.source === 'startup' || e.source === 'resume') {
1015 capsRecorded = false
1016 await recordCapabilities($)
1017 }
1018 if (e.source === 'startup') {
1019 await writeMissingTemplates($)
1020 await keepServing($)
1021 }
1022 // A /clear of the person's own leaves no handoff to report; the panel from the last one goes too.
1023 if (e.source === 'clear' && !pending && !inFlight && shown) hidePanel($)
1024 if (e.source !== 'clear' || !pending) return r
1025 const p = pending
1026 pending = undefined
1027 let sessionTitle: string | undefined
1028 try {
1029 const newSession = await $.session.id()
1030 seededSession = newSession
1031 floor = undefined
1032 unmeasured = 0
1033 await $.store.set(`fired:${p.oldSession}`, `seeded:${newSession}`)
1034 await log($, `seeding new=${newSession} from=${p.oldSession}`)
1035 handedFrom = { session: p.oldSession, tokens: p.tokens, link: p.link, problem: p.problem, notice: p.notice }
1036 steps($, [...(p.notice ? [p.notice] : []), briefStep(p.problem), { mark: 'done', text: 'cleared' }, { mark: 'spin', text: 'starting the fresh session' }])
1037 // The number worked out at the handoff (Pending.next); the old session's lineage is not read again,
1038 // so one store failure cannot give two numbers. A stored lineage always has a depth from now on.
1039 lineage = { from: p.oldSession, chain: p.chain, depth: p.next, baseName: p.baseName }
1040 await $.store.set(`lineage:${newSession}`, lineage)
1041 // The run the chain hands off: the new session may carry it on before the resumed
1042 // orchestrator writes its own id into the run state (run.ts, pickRun).
1043 if (p.run) await $.store.set(`chainRun:${newSession}`, p.run.id)
1044 // The new session's name, set through the SessionStart answer: no session.start fires after /clear.
1045 if (cfg.renameSessions) {
1046 sessionTitle = sessionName(cfg.sessionNamePrefix, p.baseName, p.next)
1047 titles.set(newSession, sessionTitle)
1048 await log($, `naming session=${newSession} "${sessionTitle}"`)
1049 }
1050 // The old brief learns where it went, and its chain's pages link forward.
1051 try {
1052 const old = parseBrief(await $.fs.read(p.briefPath) as string)
1053 // viewer: the page link, read by the status line script for the session it handed off to.
1054 await $.fs.write(p.briefPath, withHeader({ ...old.header, to: newSession, ...(p.link ? { viewer: p.link } : {}) }, old.body))
1055 const briefDir = p.briefPath.replace(/\/[^/]+$/, '')
1056 await viewer($, briefDir, `${briefDir}/pages`, p.oldSession)
1057 } catch (err) {
1058 await log($, `viewer forward link failed ${String(err)}`)
1059 }
1060 // One line on screen; the model reads the brief from disk. A full brief as the
1061 // seed showed up as a wall of text the person never wrote.
1062 // With a run, the seed names the session handoff, the run state the orchestrator resumes from,
1063 // and the session handoff's list of the open records.
1064 const copy = p.link ? ` (readable copy: ${p.link})` : ''
1065 const read = p.run
1066 ? `Read the session handoff at ${p.seedPath} before doing anything else${copy} and follow its Instructions section. It continues AI-DLC run ${p.run.id}: resume the run from its run state ${p.run.runState}, whose Next action points to the session handoff, and read the open records its Handoff records section lists.`
1067 : `Read the brief at ${p.briefPath} before doing anything else${copy} and follow its Instructions section.`
1068 const text = `${SEED_PREFIX} ${short(p.oldSession)}. The previous session hit its context limit and was cleared. ${read} Open your first reply with the line "↪ Handoff from session ${short(p.oldSession)}".`
1069 $.prompt.submit({ text }).catch((err: unknown) => {
1070 handedFrom = undefined
1071 failed($, 'the seed prompt was rejected', `paste the brief path to carry on: ${p.briefPath}`)
1072 return log($, `seed rejected ${String(err)}`)
1073 })
1074 } catch (err) {
1075 await log($, `seed error ${String(err)}`)
1076 }
1077 return sessionTitle ? { ...r, sessionTitle } : r
1078 })
1079}
1080hooks/auto-handoff/brief.ts 191 lines1import type { SessionMessage } from 'claude-code'
2import { renderTemplate, sectionHeadings } from './templates.ts'
3
4// Brief building.
5// Files, commits, issues and the last real request come from the transcript in code;
6// Haiku only writes the judgment sections. Its reply is checked, and the facts alone
7// stand in when it fails.
8
9const MAX_MESSAGES = 120
10const MAX_MSG_CHARS = 2_000
11const MAX_TRANSCRIPT_CHARS = 150_000
12
13// Harness signals that arrive as user messages but are never the user's words.
14const META_PREFIXES = ['Stop hook feedback', '[Automatic handoff]', '[auto-handoff]', '[Image', '<system-reminder>', '<command-name>', '<local-command']
15
16const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
17const GIT_COMMIT_OUTPUT = /^\[[\w./-]+(?: \(root-commit\))? ([0-9a-f]{7,})\] (.+)$/m
18
19export type Facts = {
20 filesModified: string[]
21 commits: string[]
22 issues: string[]
23 lastUserMessage?: string
24 handoffTokens?: number
25 threshold?: number
26 /** Where the base threshold came from: an env override, the repository file or the default. */
27 thresholdSource?: string
28 seededSessionStartSize?: number
29 /** Handoffs since the user last typed, this one included. */
30 unattendedCount?: number
31 /** Which handoff this is in its chain (1 for the first, 2 for the second, etc.). */
32 depth?: number
33}
34
35/** The user's own words, or undefined for a harness signal or a tool-result-only message. */
36export function userText(m: SessionMessage): string | undefined {
37 if (m.role !== 'user') return undefined
38 const raw = m.text.trim()
39 if (!raw) return undefined
40 return isMeta(raw) ? undefined : raw
41}
42
43function isMeta(text: string): boolean {
44 return META_PREFIXES.some(p => text.startsWith(p))
45}
46
47function commitSubject(command: string): string | undefined {
48 if (!/\bgit\b[^\n|;&]*\bcommit\b/.test(command)) return undefined
49 const heredoc = command.match(/<<\s*'?(\w+)'?\n([\s\S]*?)\n\1/)
50 if (heredoc) return heredoc[2]?.trim().split('\n')[0]
51 return command.match(/-m\s+(["'])(.+?)\1/)?.[2]
52}
53
54/** ignoreFiles: paths that never count as edits (auto-synced state, caches); the ignoreFiles setting. */
55export function extractFacts(messages: readonly SessionMessage[], ignoreFiles?: RegExp): Facts {
56 const files = new Set<string>()
57 const commits: string[] = []
58 const issues = new Set<string>()
59 let lastUserMessage: string | undefined
60 for (const m of messages) {
61 const said = userText(m)
62 if (said) lastUserMessage = said
63 for (const n of `${said ?? (m.role === 'assistant' ? m.text : '')}`.matchAll(/(?<![\w&/])#(\d{2,5})\b/g)) issues.add(`#${n[1]}`)
64 for (const t of m.toolUses) {
65 const path = t.input.file_path ?? t.input.notebook_path
66 if (EDIT_TOOLS.has(t.tool) && typeof path === 'string' && !t.isError && !ignoreFiles?.test(path)) files.add(path)
67 if (t.tool !== 'Bash' || typeof t.input.command !== 'string' || t.isError) continue
68 // git's own "[main abc1234] subject" line is the proof a commit landed; the command is the fallback
69 const out = t.text?.match(GIT_COMMIT_OUTPUT)
70 const subject = out ? `${out[2]} (${out[1]})` : t.text === undefined ? commitSubject(t.input.command) : undefined
71 if (subject && !commits.includes(subject)) commits.push(subject)
72 }
73 }
74 return { filesModified: [...files].slice(-20), commits: commits.slice(-10), issues: [...issues].slice(-15), lastUserMessage }
75}
76
77/** The transcript as Haiku reads it. Harness signals are labelled so they are not taken for the user. */
78export function renderTranscript(messages: readonly SessionMessage[]): string {
79 const lines = messages.slice(-MAX_MESSAGES).map(m => {
80 const role = m.role === 'user' && m.text.trim() && !userText(m) ? 'system signal (not the user)' : m.role
81 const text = m.text.length > MAX_MSG_CHARS ? m.text.slice(0, MAX_MSG_CHARS) + ' [truncated]' : m.text
82 const tools = m.toolUses.map(t => ` [tool ${t.tool}${t.isError ? ' ERROR' : ''}] ${JSON.stringify(t.input).slice(0, 300)}`)
83 return [`### ${role}`, text, ...tools].filter(Boolean).join('\n')
84 })
85 const joined = lines.join('\n\n')
86 return joined.length > MAX_TRANSCRIPT_CHARS ? joined.slice(-MAX_TRANSCRIPT_CHARS) : joined
87}
88
89function list(items: string[], empty: string): string {
90 return items.length ? items.map(i => `- ${i}`).join('\n') : empty
91}
92
93// The only token figures the brief may use. Haiku once wrote "burned its 200k budget" for a
94// session at 93k because the prompt held no numbers at all.
95function handoffNumbersBlock(f: Facts): string {
96 const lines = []
97 if (f.depth !== undefined) lines.push(`- **Handoff depth:** ${f.depth}`)
98 if (f.handoffTokens !== undefined) lines.push(`- **Tokens at handoff:** ${f.handoffTokens} (${k(f.handoffTokens)})`)
99 if (f.threshold !== undefined) lines.push(`- **Threshold:** ${f.threshold} (${k(f.threshold)})${f.thresholdSource ? `, from ${f.thresholdSource}` : ''}`)
100 if (f.seededSessionStartSize !== undefined) lines.push(`- **This session's starting size (seeded from a handoff):** ${f.seededSessionStartSize} (${k(f.seededSessionStartSize)})`)
101 if (f.unattendedCount !== undefined) lines.push(`- **Handoffs in a row with no user message:** ${f.unattendedCount}`)
102 return lines.length ? `## Handoff Numbers\n${lines.join('\n')}\n` : ''
103}
104
105const k = (n: number) => `${Math.round(n / 1000)}k`
106
107export function factsBlock(f: Facts, withLastMessage = true): string {
108 const numbers = handoffNumbersBlock(f)
109 const files = `## Files Modified (from Edit/Write calls)
110${list(f.filesModified, 'None.')}
111
112## Commits This Session
113${list(f.commits, 'None.')}
114
115## GitHub Issues Mentioned
116${f.issues.length ? f.issues.join(', ') : 'None.'}`
117 const all = [numbers, files].filter(Boolean).join('\n')
118 return withLastMessage ? `${all}\n\n## Last Real User Message (verbatim)\n${f.lastUserMessage ?? 'None found.'}` : all
119}
120
121export function briefPrompt(messages: readonly SessionMessage[], facts: Facts, template: string): string {
122 // Data first, instructions last.
123 return `## Extracted Facts\n${factsBlock(facts)}\n\n## Conversation\n${renderTranscript(messages)}\n\n---\n\n${template.trim()}`
124}
125
126/** A reply that holds none of the template's sections is a dialogue fragment, not a brief. */
127export function isValidBrief(text: string, template: string): boolean {
128 return sectionHeadings(template).some(h => text.includes(h))
129}
130
131// Whether the brief's last-request section
132// says the request is not (fully) answered. A chain with no user message is not a real request.
133export function hasUnansweredLastRequest(text: string): boolean {
134 const heading = /##\s*Last Request from the User/i.exec(text)
135 if (!heading) return false
136 const rest = text.slice(heading.index + heading[0].length)
137 const next = /^#{1,6}\s/m.exec(rest)
138 const section = next ? rest.slice(0, next.index) : rest
139 if (/No user message found|^\s*\[auto-handoff\]/i.test(section)) return false
140 return /\bStatus[\s*_`]*:[\s*_`"]*(Not (?:yet |fully )?answered|Partially answered|Unanswered)/i.test(section)
141}
142
143export type BriefContext = {
144 sessionId: string
145 /** The session's transcript file. */
146 transcript: string
147 /** The instructions template, rendered at the top of the brief. */
148 instructions: string
149}
150
151export function assembleBrief(ctx: BriefContext, facts: Facts, haiku: string | undefined): string {
152 const header = `${renderTemplate(ctx.instructions, { priority: haiku ? hasUnansweredLastRequest(haiku) : false })}
153
154## Session Handoff Brief
155
156- **Previous Session:** ${ctx.sessionId}
157- **Transcript:** \`${ctx.transcript}\`
158
159## How to Use This Brief
160${haiku ? 'Haiku wrote the judgment sections from conversation text with tool output abbreviated. The facts sections came from tool calls in code.' : 'Haiku did not return a usable brief, so this holds only facts extracted in code. Read the transcript for the rest.'} Treat every line as a starting point, not a fact. Before acting on anything here, spawn a subagent to verify: run git status, gh pr view, or Read the file directly. If a fact is missing, grep the transcript before asking the user.`
161 // A valid Haiku brief already quotes the last request in its own section.
162 return [header, haiku?.trim(), factsBlock(facts, !haiku)].filter(Boolean).join('\n\n')
163}
164
165// A token figure: "93k", "93.1k", "93,105 tokens", "93105 tokens".
166const TOKEN_FIGURE = /\b(\d{1,4}(?:\.\d+)?)k\b|\b(\d{1,3}(?:,\d{3})+|\d{4,7})(?= tokens\b)/gi
167
168const figureValue = (k?: string, whole?: string) => k !== undefined ? Number(k) * 1000 : Number(whole!.replace(/,/g, ''))
169
170/**
171 * Marks every token figure in Haiku's text that the Handoff Numbers block does not hold, within
172 * rounding to the nearest thousand. The template asks Haiku to copy those numbers; this makes it a
173 * rule. Figures are marked, not removed, so the next session sees what was claimed and that it is
174 * unchecked. With no numbers block, every figure is marked.
175 */
176export function markUnverifiedFigures(text: string, f: Facts): { text: string; flagged: string[] } {
177 const block = handoffNumbersBlock(f)
178 const allowed = [
179 ...[...block.matchAll(/\b(\d{1,4}(?:\.\d+)?)k\b/g)].map(m => figureValue(m[1])),
180 ...[...block.matchAll(/\b\d{4,7}\b/g)].map(m => Number(m[0])),
181 ]
182 const flagged: string[] = []
183 const marked = text.replace(TOKEN_FIGURE, (match: string, k?: string, whole?: string) => {
184 const value = figureValue(k, whole)
185 if (allowed.some(a => Math.abs(a - value) < 1000)) return match
186 flagged.push(match)
187 return `${match} [unverified: not in Handoff Numbers]`
188 })
189 return { text: marked, flagged }
190}
191hooks/auto-handoff/viewer.ts 174 lines1// The handoff viewer: one self-contained HTML page per brief, written to a pages/ folder beside
2// the briefs. Each brief starts with a small header (from, to, chain, tokens, at, cwd); briefs that
3// share a chain id or a from/to link are one run of handoffs, and every page in a run lists all of them.
4// The page renders the brief's markdown in the browser from a CDN, so the mod ships no packages.
5
6export type Header = { from?: string; to?: string; chain?: string; depth?: string; tokens?: string; at?: string; cwd?: string; viewer?: string }
7export type Entry = { id: string; header: Header; body: string }
8
9const HEADER_KEYS = ['from', 'to', 'chain', 'depth', 'tokens', 'at', 'cwd', 'viewer'] as const
10
11/** Splits a brief into its header and body. A brief with no header is a chain of one. */
12export function parseBrief(text: string): { header: Header; body: string } {
13 const m = /^---\n([\s\S]*?)\n---\n?/.exec(text)
14 if (!m) return { header: {}, body: text }
15 const header: Header = {}
16 for (const line of m[1]!.split('\n')) {
17 const kv = /^(\w+):\s*(.*)$/.exec(line)
18 if (kv && (HEADER_KEYS as readonly string[]).includes(kv[1]!)) header[kv[1] as keyof Header] = kv[2]!.trim()
19 }
20 return { header, body: text.slice(m[0].length) }
21}
22
23export function withHeader(header: Header, body: string): string {
24 const lines = HEADER_KEYS.filter(k => header[k] !== undefined).map(k => `${k}: ${header[k]}`)
25 return `---\n${lines.join('\n')}\n---\n${body}`
26}
27
28/** Every brief linked to id, oldest first. Briefs join through a shared chain id or a from/to
29 * link, so a run whose chain id broke partway (a hot reload once dropped it) still reads as one. */
30export function chainOf(entries: readonly Entry[], id: string): Entry[] {
31 const parent = new Map<string, string>()
32 const find = (x: string): string => {
33 let r = x
34 while (parent.has(r) && parent.get(r) !== r) r = parent.get(r)!
35 parent.set(x, r)
36 return r
37 }
38 const join = (a: string, b: string) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb) }
39 for (const e of entries) {
40 join(e.id, `chain:${e.header.chain || e.id}`)
41 if (e.header.from) join(e.id, e.header.from)
42 if (e.header.to) join(e.id, e.header.to)
43 }
44 const root = find(id)
45 return entries.filter(e => find(e.id) === root).sort((a, b) => (a.header.at ?? '').localeCompare(b.header.at ?? ''))
46}
47
48/** The page's address: served when the mod runs a server, else the local file. The served link
49 * uses the session id's first 8 characters, short enough to stay on one line on a phone. */
50export function viewerLink(serve: { host: string; port: string } | undefined, pagesDir: string, sessionId: string): string {
51 if (serve) return `http://${serve.host}:${serve.port}/${sessionId.slice(0, 8)}`
52 // A Windows path (C:\Users\x) becomes file:///C:/Users/x; a POSIX path passes through.
53 const dir = pagesDir.replace(/\\/g, '/').replace(/^(?=[A-Za-z]:)/, '/')
54 return `file://${dir}/${sessionId}.html`
55}
56
57// Sections meant for the fresh session, not for a person reading the page.
58const HIDDEN = new Set(['Instructions', 'How to Use This Brief'])
59
60/** The brief's "## " sections, minus the ones written for the model. */
61export function sections(body: string): string[] {
62 return body.split(/^(?=## )/m).map(s => s.trim()).filter(s => s && !HIDDEN.has(/^## (.+)$/m.exec(s)?.[1]?.trim() ?? ''))
63}
64
65function oneLiner(body: string): string {
66 const m = /## Work in Progress\s*\n+([\s\S]+?)(?:\n\n|\n##|$)/.exec(body)
67 if (!m) return ''
68 const s = m[1]!.replace(/\s+/g, ' ').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^\d+\.\s*|^-\s*/, '').trim()
69 return s.length > 120 ? `${s.slice(0, 117)}…` : s
70}
71
72const esc = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
73const short = (id: string) => id.slice(0, 8)
74const when = (iso?: string) => {
75 if (!iso) return ''
76 const d = new Date(iso)
77 return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' ') + ' UTC'
78}
79const tokens = (t?: string) => (t && Number(t) ? ` · ${Math.round(Number(t) / 1000)}k` : '')
80const chip = (id: string, current: boolean) => `<span class="chip${current ? ' on' : ''}" title="${esc(id)}">${esc(short(id))}</span>`
81
82/** The page for one brief. chain is every brief in its chain, oldest first (the brief itself included). */
83export function renderPage(entry: Entry, chain: readonly Entry[]): string {
84 const { header } = entry
85 // Previous and next are the neighbours in the chain, not the header's from/to: a header can
86 // miss a link the chain recovered. The last brief's session has no page until it hands off.
87 const at = chain.findIndex(e => e.id === entry.id)
88 const prev = chain[at - 1]
89 const next = at >= 0 ? chain[at + 1] : undefined
90 const meta = [
91 chip(entry.id, false),
92 `<span>${esc(when(header.at))}${esc(tokens(header.tokens))}</span>`,
93 header.cwd ? `<span>${esc(header.cwd)}</span>` : '',
94 prev ? `<a href="${esc(prev.id)}.html">← previous</a>` : '',
95 next ? `<a href="${esc(next.id)}.html">next →</a>`
96 : header.to ? `<span title="${esc(header.to)}">next: ${esc(short(header.to))}, still running</span>` : '',
97 ].filter(Boolean).join('')
98 const rows = chain.length > 1 ? chain.map((e, i) => {
99 const current = e.id === entry.id
100 const c = current ? chip(e.id, true) : `<a href="${esc(e.id)}.html">${chip(e.id, false)}</a>`
101 const latest = i === chain.length - 1 ? '<span class="latest">latest</span>' : ''
102 const wip = oneLiner(e.body)
103 return `<div class="row"><div>${c}</div><div class="rowtext"><div><span class="when">${i + 1}. ${esc(when(e.header.at))}${esc(tokens(e.header.tokens))}</span>${latest}</div>${wip ? `<div class="wip">${esc(wip)}</div>` : ''}</div></div>`
104 }).join('') : ''
105 const history = rows ? `<div class="section"><h2>Handoff Chain</h2>${rows}</div>` : ''
106 // JSON in a script tag: escape "<" so a brief can never close the tag.
107 const data = JSON.stringify(sections(entry.body)).replace(/</g, '\\u003c')
108 return `<!DOCTYPE html>
109<html lang="en">
110<head>
111<meta charset="utf-8">
112<meta name="viewport" content="width=device-width,initial-scale=1">
113<title>Session Context ${esc(short(entry.id))}</title>
114<link rel="preconnect" href="https://fonts.googleapis.com">
115<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
116<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;700&family=Fraunces:opsz,wght@9..144,400;9..144,700;9..144,900&display=swap" rel="stylesheet">
117<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
118<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"></script>
119<style>
120 :root { color-scheme: light; --paper: #F7F3ED; --ink: #1C1C1C; --red: #E63946; --blue: #457B9D; --green: #2A9D8F;
121 --font-display: 'Fraunces', serif; --font-body: 'DM Sans', sans-serif; --font-mono: 'DM Mono', monospace; }
122 * { margin: 0; padding: 0; box-sizing: border-box; }
123 body { font-family: var(--font-body); background: var(--ink); color: var(--ink); font-size: 16px; line-height: 1.7; -webkit-font-smoothing: antialiased; min-height: 100vh; }
124 .card { max-width: 760px; margin: 0 auto; padding-bottom: 48px; }
125 .header { background: var(--ink); padding: 28px 16px 24px; }
126 .header h1 { font-family: var(--font-display); font-size: 1.5rem; font-weight: 900; color: var(--paper); margin: 0 0 8px; }
127 .meta { font-family: var(--font-mono); font-size: 12px; color: rgba(247,243,237,0.5); display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; }
128 .meta a { color: rgba(247,243,237,0.75); text-decoration: none; }
129 .meta a:hover { color: var(--paper); }
130 .meta .chip { background: rgba(247,243,237,0.1); color: rgba(247,243,237,0.7); }
131 .chip { font-family: var(--font-mono); font-size: 11px; background: rgba(28,28,28,0.08); color: var(--ink); padding: 2px 7px; border-radius: 6px; white-space: nowrap; }
132 .chip.on { background: var(--ink); color: var(--paper); }
133 .section { background: var(--paper); padding: 24px 16px; border-bottom: 1px solid rgba(28,28,28,0.1); overflow-wrap: break-word; }
134 @media (min-width: 600px) { .header, .section { padding-left: 32px; padding-right: 32px; } }
135 .row { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid rgba(28,28,28,0.08); }
136 .row a { text-decoration: none; }
137 .rowtext { min-width: 0; flex: 1; }
138 .when { font-size: 12px; color: #777; font-family: var(--font-mono); margin-right: 6px; }
139 .latest { font-size: 10px; font-family: var(--font-mono); background: var(--green); color: #fff; padding: 1px 6px; border-radius: 4px; }
140 .wip { font-size: 13px; color: #555; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
141 h2 { font-family: var(--font-display); font-weight: 700; font-size: 1.35rem; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--ink); line-height: 1.2; }
142 h3 { font-family: var(--font-display); font-weight: 700; font-size: 1.05rem; color: var(--blue); margin: 16px 0 6px; }
143 p { margin: 0 0 12px; } p:last-child { margin-bottom: 0; }
144 code { font-family: var(--font-mono); background: rgba(28,28,28,0.07); padding: 1px 5px; border-radius: 3px; font-size: 0.85em; word-break: break-all; }
145 pre { background: var(--ink); color: var(--paper); padding: 14px 18px; margin: 12px 0; font-size: 0.85em; white-space: pre-wrap; word-break: break-all; box-shadow: 4px 4px 0 var(--blue); }
146 pre code { background: none; padding: 0; color: inherit; }
147 ul, ol { padding-left: 20px; margin: 0 0 12px; } li { margin-bottom: 5px; line-height: 1.55; }
148 li::marker { color: var(--red); } ol li::marker { color: var(--ink); }
149 a { color: var(--blue); text-underline-offset: 2px; } a:hover { color: var(--red); }
150 strong { font-weight: 600; }
151</style>
152</head>
153<body>
154<div class="card">
155 <div class="header"><h1>↕ Session Context</h1><div class="meta">${meta}</div></div>
156 ${history}
157 <div id="brief"></div>
158</div>
159<script id="data" type="application/json">${data}</script>
160<script>
161 var parts = JSON.parse(document.getElementById('data').textContent), out = document.getElementById('brief');
162 parts.forEach(function (md) {
163 var div = document.createElement('div');
164 div.className = 'section';
165 if (window.marked && window.DOMPurify) div.innerHTML = DOMPurify.sanitize(marked.parse(md));
166 else { var pre = document.createElement('pre'); pre.textContent = md; div.appendChild(pre); }
167 out.appendChild(div);
168 });
169</script>
170</body>
171</html>
172`
173}
174hooks/auto-handoff/server.ts 35 lines1// The static server for the viewer pages, run as `node -e SERVER_JS <dir> <host> <port>`. It
2// serves only <name>.html files from <dir>, and /<first 8 characters of a session id> as that
3// session's page, so the link fits one line on a phone. register.tsx starts it (the loader keeps $ in that file).
4
5export const SERVER_JS = `
6const http = require('http'), fs = require('fs'), path = require('path')
7const [dir, host, port] = process.argv.slice(1)
8http.createServer((req, res) => {
9 let name = decodeURIComponent(new URL(req.url, 'http://x').pathname.slice(1))
10 if (req.method === 'GET' && /^[0-9a-f]{8}$/.test(name)) {
11 let files = []
12 try { files = fs.readdirSync(dir) } catch {}
13 name = files.filter(f => f.startsWith(name + '-') && f.endsWith('.html')).sort()[0] || ''
14 }
15 if (req.method !== 'GET' || !/^[\\w-]+\\.html$/.test(name)) { res.writeHead(404); return res.end('Not found') }
16 fs.readFile(path.join(dir, name), (err, data) => {
17 if (err) { res.writeHead(404); return res.end('Not found') }
18 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' })
19 res.end(data)
20 })
21}).on('error', (err) => {
22 // Another session already serves the folder on this port: nothing to do, and no stack trace.
23 if (err.code === 'EADDRINUSE') { console.log('viewer port ' + port + ' already served'); process.exit(0) }
24 throw err
25}).listen(Number(port), host, () => console.log('viewer serving http://' + host + ':' + port))
26`
27
28/** "host:port", or undefined when the value is not one: a host of letters, digits, `_`, `.` and `-`,
29 * and a port from 10 to 65535 with no leading zero. The host "tailscale" is resolved by the caller.
30 * The CLI of /ai-dlc:configure-auto-handoff applies the same rule (its `host-port` format). */
31export function parseAddress(v: string): { host: string; port: string } | undefined {
32 const m = /^([\w.-]+):([1-9]\d{1,4})$/.exec(v.trim())
33 return m && Number(m[2]) <= 65535 ? { host: m[1]!, port: m[2]! } : undefined
34}
35hooks/auto-handoff/panel.tsx 46 lines1// The band above the prompt is the mod's whole UI: a header line and the steps under it, with
2// a braille spinner on whatever is still running. A toast cannot animate or carry a link, so the
3// panel replaced them. This file draws; register.tsx holds the panel and its timers, since the
4// host never follows $ across an import.
5
6export type Mark = 'spin' | 'done' | 'warn' | 'fail'
7export type Line = { mark: Mark; text: string }
8// sticky: stays until dismissed (a failure, the pause, a warning); otherwise a panel with nothing
9// spinning collapses on its own.
10export type Panel = { header: Line; steps: Line[]; link?: string; sticky?: boolean }
11
12export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
13const GLYPH: Record<Exclude<Mark, 'spin'>, string> = { done: '✓', warn: '⚠', fail: '✗' }
14const COLOR: Record<Mark, string> = { spin: 'yellow', done: 'green', warn: 'yellow', fail: 'red' }
15
16export const isSpinning = (p: Panel) => p.header.mark === 'spin' || p.steps.some((l) => l.mark === 'spin')
17
18// The plain text of the panel, one line per row, for logs and tests.
19export const panelText = (p: Panel, frame = 0) =>
20 [p.header, ...p.steps].map((l, i) => `${i ? ' ' : ''}${glyph(l.mark, frame)} ${l.text}`).join('\n')
21
22const glyph = (m: Mark, frame: number) => m === 'spin' ? SPINNER[frame % SPINNER.length] : GLYPH[m]
23
24// The elements come from $.ui.resolve in the render hook.
25type Elements = { Box: any; Text: any; Markdown: any; Button: any }
26
27export function panelTree({ Box, Text, Markdown, Button }: Elements, p: Panel, frame: number, onDismiss: () => void) {
28 return (
29 <Box flexDirection="column">
30 <Box>
31 <Text color={COLOR[p.header.mark]}>{glyph(p.header.mark, frame)} </Text>
32 <Text bold>{p.header.text}</Text>
33 </Box>
34 {p.steps.map((l, i) => (
35 <Box key={`step-${i}`}>
36 <Text> </Text>
37 <Text color={COLOR[l.mark]}>{glyph(l.mark, frame)} </Text>
38 <Text dimColor={l.mark === 'done'}>{l.text}</Text>
39 </Box>
40 ))}
41 {p.link ? <Markdown text={` [open brief](${p.link})`} /> : null}
42 {p.sticky ? <Button key="dismiss" label="Dismiss" onPress={onDismiss} /> : null}
43 </Box>
44 )
45}
46hooks/auto-handoff/config.ts 525 lines1// Settings and the constants built on them. Engine-free: the host follows $ only into functions
2// declared in register.tsx, so anything taking $ (reading the files, the environment) stays there.
3import { parseAddress } from './server.ts'
4
5export const BRIEF_DIR = '.claude/state/auto-handoff'
6// Where this module sits inside the ai-dlc plugin; the shipped templates are under it.
7export const MODULE_DIR = 'hooks/auto-handoff'
8// The repository file that configures the module, relative to the repository root.
9export const REPO_CONFIG = '.ai-dlc/auto-handoff.json'
10// What the module learns about the running Claude Code at each session start: whether the built-in
11// /rename exists and the engine version. /ai-dlc:init and /ai-dlc:configure-auto-handoff read it,
12// because a model-run skill cannot call $.command.list() itself.
13export const CAPABILITIES_FILE = 'capabilities.json'
14
15// thresholdPercent: the share of the context window that triggers a handoff, 1 to 100. The line in
16// tokens is thresholdPercent of the window $.session.usage() reports (contextLine below), or of
17// defaultContextWindow when it reports none. thresholdTokens, when set, is an absolute line instead.
18// seedHeadroomTokens and maxUnattended are loop guards. A seeded session must grow seedHeadroomTokens
19// past its first-turn size (its floor) before it can hand off again, and, while no AI-DLC run is
20// active, at most maxUnattended handoffs may run before the user types a prompt. With an active run
21// that this session's chain claimed, the run state decides instead (chainWhileRunActive): the chain
22// goes on while the run is active and ends when it is done, blocked or abandoned.
23// 40k of headroom is the empirical line where a seeded session can read its brief and still do real
24// work. The 2026-10-03 live run (threshold 20k, seeded sessions start at ~31k) chained six times
25// with no guard; the 2026-10-04 run (a token threshold of 80000 left in a shell, floor ~45k) chained
26// eight times with a guard of a quarter of the threshold, because max(80k, 45k + 20k) is still 80k,
27// and 35k of room goes in reading the brief.
28// The rest shape the brief: two template files and a pattern for files that never count as edits.
29// viewer: where the mod serves the brief pages, "host:port"; "tailscale" as the host means this
30// machine's Tailscale IP, or 127.0.0.1 without Tailscale. Blank: no server, and the link is the local file.
31// viewer and the two templates apply from .ai-dlc/auto-handoff.json only in a trusted folder (TRUST_GATED).
32export type Config = {
33 enabled: boolean
34 thresholdPercent: number
35 thresholdTokens?: number
36 defaultContextWindow: number
37 seedHeadroomTokens: number
38 maxUnattended: number
39 chainWhileRunActive: boolean
40 renameSessions: boolean
41 sessionNamePrefix: string
42 briefTemplate: string
43 instructionsTemplate: string
44 ignoreFiles?: RegExp
45 viewer: string
46}
47export const MIN_HEADROOM = 40_000
48// The window used when the host reports neither a window nor a percentage (human decision, 2026-10-21).
49export const DEFAULT_WINDOW = 500_000
50export const DEFAULTS: Config = {
51 enabled: true,
52 thresholdPercent: 80,
53 defaultContextWindow: DEFAULT_WINDOW,
54 seedHeadroomTokens: MIN_HEADROOM,
55 maxUnattended: 2,
56 chainWhileRunActive: true,
57 renameSessions: true,
58 sessionNamePrefix: '[ai-dlc:auto-handoff]',
59 briefTemplate: '~/.claude/auto-handoff/brief.md',
60 instructionsTemplate: '~/.claude/auto-handoff/instructions.md',
61 viewer: '',
62}
63
64/** What a setting's value must be. */
65type Kind = 'boolean' | 'percent' | 'positive' | 'whole' | 'text' | 'path' | 'pattern' | 'address'
66/** A setting: its key in .ai-dlc/auto-handoff.json and in Config, its environment variable and its
67 * kind. One line each: tests/auto-handoff/ in the repository checks this table against the schema the
68 * configure-auto-handoff skill ships. The automatic handoff has no /config rows (human decision,
69 * 2026-10-23, run AAW-20261022-cache-keepalive-init). */
70export type Setting = { key: string; field: keyof Config; env: string; kind: Kind }
71export const SETTINGS: readonly Setting[] = [
72 { key: 'enabled', field: 'enabled', env: 'AI_DLC_AUTO_HANDOFF_ENABLED', kind: 'boolean' },
73 { key: 'thresholdPercent', field: 'thresholdPercent', env: 'AI_DLC_AUTO_HANDOFF_THRESHOLD_PERCENT', kind: 'percent' },
74 { key: 'thresholdTokens', field: 'thresholdTokens', env: 'AI_DLC_AUTO_HANDOFF_THRESHOLD_TOKENS', kind: 'positive' },
75 { key: 'defaultContextWindow', field: 'defaultContextWindow', env: 'AI_DLC_AUTO_HANDOFF_DEFAULT_CONTEXT_WINDOW', kind: 'positive' },
76 { key: 'seedHeadroomTokens', field: 'seedHeadroomTokens', env: 'AI_DLC_AUTO_HANDOFF_SEED_HEADROOM_TOKENS', kind: 'whole' },
77 { key: 'maxConsecutiveHandoffs', field: 'maxUnattended', env: 'AI_DLC_AUTO_HANDOFF_MAX_CONSECUTIVE', kind: 'positive' },
78 { key: 'chainWhileRunActive', field: 'chainWhileRunActive', env: 'AI_DLC_AUTO_HANDOFF_CHAIN_WHILE_RUN_ACTIVE', kind: 'boolean' },
79 { key: 'renameSessions', field: 'renameSessions', env: 'AI_DLC_AUTO_HANDOFF_RENAME_SESSIONS', kind: 'boolean' },
80 { key: 'sessionNamePrefix', field: 'sessionNamePrefix', env: 'AI_DLC_AUTO_HANDOFF_SESSION_NAME_PREFIX', kind: 'text' },
81 { key: 'briefTemplate', field: 'briefTemplate', env: 'AI_DLC_AUTO_HANDOFF_BRIEF_TEMPLATE', kind: 'path' },
82 { key: 'instructionsTemplate', field: 'instructionsTemplate', env: 'AI_DLC_AUTO_HANDOFF_INSTRUCTIONS_TEMPLATE', kind: 'path' },
83 { key: 'ignoreFiles', field: 'ignoreFiles', env: 'AI_DLC_AUTO_HANDOFF_IGNORE_FILES', kind: 'pattern' },
84 { key: 'viewer', field: 'viewer', env: 'AI_DLC_AUTO_HANDOFF_VIEWER', kind: 'address' },
85]
86
87/** One place values are read from, highest first. `byEnv`: keyed by environment variable, not by key. */
88export type Level = { where: string; byEnv: boolean; values: Record<string, unknown> }
89/** The effective settings, each key's source, and one warning per value that was set but invalid. */
90export type Resolved = { cfg: Config; sources: Record<string, string>; warnings: string[] }
91
92const WHOLE = /^\s*(0|[1-9][0-9]*)\s*$/
93const BOOLS: Record<string, boolean> = { true: true, false: false, '1': true, '0': false, yes: true, no: false, on: true, off: false }
94
95/** A boolean, or a word for one (true/false, 1/0, yes/no, on/off; any case, spaces around ignored).
96 * Undefined for anything else. Shared with ../keepalive/settings.ts, so both parse a boolean alike. */
97export function parseBool(raw: unknown): boolean | undefined {
98 if (typeof raw === 'boolean') return raw
99 if (typeof raw !== 'string') return undefined
100 // Own keys only: a plain lookup would let `constructor` or `__proto__` reach Object.prototype.
101 const word = raw.trim().toLowerCase()
102 return Object.hasOwn(BOOLS, word) ? BOOLS[word] : undefined
103}
104
105// Numbers are not shared with ../keepalive/settings.ts: this module takes whole numbers only, written
106// without leading zeros, and keep-alive takes fractions ("0.9", "1.5"); one parser would change one of them.
107const whole = (v: unknown): number | undefined => {
108 const n = typeof v === 'number' ? v : typeof v === 'string' && WHOLE.test(v) ? Number(v) : NaN
109 return Number.isSafeInteger(n) && n >= 0 ? n : undefined
110}
111
112/**
113 * A raw value checked against its kind: the value, `null` when it is not set (missing, null, or a
114 * blank environment value), or `undefined` when it is set but invalid. An environment variable is
115 * always text in the process, and may be text, a number or a boolean in a settings file's `env`.
116 */
117export function check(kind: Kind, raw: unknown, byEnv: boolean): unknown {
118 if (raw === undefined || raw === null) return null
119 if (byEnv && typeof raw === 'string' && !raw.trim()) return null
120 switch (kind) {
121 case 'boolean': return parseBool(raw)
122 case 'percent': {
123 const n = whole(raw)
124 return n !== undefined && n >= 1 && n <= 100 ? n : undefined
125 }
126 case 'positive': {
127 const n = whole(raw)
128 return n !== undefined && n >= 1 ? n : undefined
129 }
130 case 'whole': return whole(raw)
131 case 'text':
132 case 'path':
133 return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined
134 case 'pattern': {
135 if (typeof raw !== 'string') return undefined
136 if (!raw.trim()) return '' // set to nothing: no file is ignored
137 try { new RegExp(raw); return raw } catch { return undefined }
138 }
139 case 'address': {
140 // Blank means no server; anything else must be host:port, or the server would never start.
141 if (typeof raw !== 'string') return undefined
142 const t = raw.trim()
143 return t === '' || parseAddress(t) ? t : undefined
144 }
145 }
146}
147
148const show = (v: unknown) => typeof v === 'string' ? JSON.stringify(v) : String(JSON.stringify(v))
149
150/**
151 * Each setting from the first level that holds a valid value, highest first: the levels given (the
152 * `env` of .claude/settings.local.json, the `env` of .claude/settings.json, the process environment,
153 * then .ai-dlc/auto-handoff.json), then the default. A value that is set but invalid is reported in
154 * `warnings` and resolution falls through to the next level.
155 */
156export function resolveConfig(levels: readonly Level[]): Resolved {
157 const cfg: Config = { ...DEFAULTS }
158 const sources: Record<string, string> = {}
159 const warnings: string[] = []
160 for (const s of SETTINGS) {
161 let found = false
162 for (const level of levels) {
163 const name = level.byEnv ? s.env : s.key
164 if (!Object.prototype.hasOwnProperty.call(level.values, name)) continue
165 const raw = level.values[name]
166 const v = check(s.kind, raw, level.byEnv)
167 if (v === null) continue
168 if (v === undefined) {
169 warnings.push(`${name}=${show(raw)} in ${level.where} ignored: not a valid ${s.kind}; falling back to the next source`)
170 continue
171 }
172 assign(cfg, s, v)
173 sources[s.key] = `${name} in ${level.where}`
174 found = true
175 break
176 }
177 if (!found) sources[s.key] = 'default'
178 }
179 return { cfg, sources, warnings }
180}
181
182function assign(cfg: Config, s: Setting, v: unknown) {
183 if (s.kind === 'pattern') cfg.ignoreFiles = v ? new RegExp(v as string) : undefined
184 else (cfg as Record<string, unknown>)[s.field] = v
185}
186
187export const k = (n: number) => `${Math.round(n / 1000)}k`
188export const short = (sessionId: string) => sessionId.slice(0, 8)
189export const expand = (path: string, home: string) => path.replace(/^~(?=\/|$)/, home)
190
191// Strip // and /* */ comments outside strings: Claude Code settings files may be JSONC.
192export function stripComments(text: string): string {
193 let out = ''
194 let i = 0
195 let inStr = false
196 while (i < text.length) {
197 const c = text[i]!
198 const d = text[i + 1]
199 if (inStr) {
200 out += c
201 if (c === '\\') { out += d ?? ''; i += 2; continue }
202 if (c === '"') inStr = false
203 i++
204 } else if (c === '"') { inStr = true; out += c; i++ }
205 else if (c === '/' && d === '/') { while (i < text.length && text[i] !== '\n') i++ }
206 else if (c === '/' && d === '*') { const e = text.indexOf('*/', i + 2); i = e === -1 ? text.length : e + 2 }
207 else { out += c; i++ }
208 }
209 return out
210}
211
212/** A settings file's `env` object, or the problem with it. Missing text or a file with no `env` is
213 * not a problem. */
214export function settingsEnv(text: string | undefined): { env?: Record<string, unknown>; problem?: string } {
215 if (text === undefined) return {}
216 let doc: unknown
217 try { doc = JSON.parse(stripComments(text.replace(/^/, ''))) } catch { return { problem: 'is not valid JSON (comments allowed); its env is skipped' } }
218 if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { problem: 'top level is not an object; its env is skipped' }
219 const env = (doc as Record<string, unknown>).env
220 if (env === undefined) return {}
221 if (!env || typeof env !== 'object' || Array.isArray(env)) return { problem: 'env is not an object; skipped' }
222 return { env: env as Record<string, unknown> }
223}
224
225/** .ai-dlc/auto-handoff.json's settings, or the problem with it. A missing file is not a problem. */
226export function repoConfig(text: string | undefined): { values?: Record<string, unknown>; problem?: string } {
227 if (text === undefined) return {}
228 let doc: unknown
229 try { doc = JSON.parse(text.replace(/^/, '')) } catch { return { problem: 'is not valid JSON; it is skipped' } }
230 if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { problem: 'top level is not an object; it is skipped' }
231 return { values: doc as Record<string, unknown> }
232}
233
234// Workspace trust for the repository file. .ai-dlc/auto-handoff.json is committed, so a cloned
235// repository could point the viewer server at the network (0.0.0.0:port serves briefs and transcript
236// summaries to the LAN), make the module write a template file at a path it picks, or feed a file of
237// its choosing into the Haiku prompt. Claude Code asks before trusting a folder's settings files, but
238// it knows nothing of this file, so the module checks Claude Code's own record instead: the keys
239// below apply from the repository file only when the folder is trusted. Every other key always does.
240// Settings `env` and the process environment are the person's own, and are never gated.
241export const TRUST_GATED = ['briefTemplate', 'instructionsTemplate', 'viewer'] as const
242
243/** Where Claude Code records trust: `projects["<path>"].hasTrustDialogAccepted` in ~/.claude.json.
244 * In a repository, the key is the repository root (in a worktree, the main checkout's root), and a
245 * nested repository has its own. Outside a repository, it is the start folder or a folder above it. */
246export type TrustTarget = { root: string } | { start: string }
247
248/** A path as trust keys are compared: `\` as `/`, no trailing slash, lower case. Windows writes keys
249 * with forward slashes and a lowercase drive letter on some machines, and backslashes on others. */
250export const normPath = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
251
252/** The repository root trust is keyed on: the main checkout's root when `commonDir` (git's
253 * --git-common-dir, absolute) is a `.git` folder outside `top` (a linked worktree), else `top`. */
254export function trustRoot(top: string, commonDir: string | undefined): string {
255 const common = commonDir?.replace(/\\/g, '/').replace(/\/+$/, '')
256 if (!common || !common.endsWith('/.git')) return top
257 const main = common.slice(0, -'/.git'.length)
258 return main && normPath(main) !== normPath(top) ? main : top
259}
260
261/** The folders whose entry may hold trust for the target, nearest first. */
262function trustKeys(target: TrustTarget): string[] {
263 if ('root' in target) return [normPath(target.root)]
264 const parts = normPath(target.start).split('/')
265 return parts.map((_, i) => parts.slice(0, parts.length - i).join('/'))
266}
267
268/**
269 * Whether Claude Code trusts the target, from the text of ~/.claude.json, and why not. Fails closed:
270 * a missing or unreadable file, bad JSON, no matching entry, or a value that is not exactly `true`
271 * means untrusted. `why` names the reason only, never the file's content.
272 */
273export function trustOf(claudeJson: string | undefined, target: TrustTarget): { trusted: boolean; why: string } {
274 if (claudeJson === undefined) return { trusted: false, why: '~/.claude.json was not readable' }
275 let doc: unknown
276 try { doc = JSON.parse(claudeJson.replace(/^/, '')) } catch { return { trusted: false, why: '~/.claude.json is not valid JSON' } }
277 const projects = doc && typeof doc === 'object' ? (doc as Record<string, unknown>).projects : undefined
278 if (!projects || typeof projects !== 'object' || Array.isArray(projects)) return { trusted: false, why: '~/.claude.json has no projects' }
279 const wanted = new Set(trustKeys(target))
280 let matched = false
281 for (const [key, entry] of Object.entries(projects as Record<string, unknown>)) {
282 if (!wanted.has(normPath(key))) continue
283 matched = true
284 if (entry && typeof entry === 'object' && (entry as Record<string, unknown>).hasTrustDialogAccepted === true) return { trusted: true, why: '' }
285 }
286 return { trusted: false, why: matched ? 'the folder is not marked trusted' : 'the folder has no entry' }
287}
288
289/** The repository file's values without the trust-gated keys that are set, and the keys taken out. */
290export const withoutGated = (values: Record<string, unknown>) => withoutKeys(values, TRUST_GATED)
291
292/** The value at a key; a dot in the key is one level of nesting (`oneHour.keepWarmFor`). */
293function valueAt(values: Record<string, unknown>, key: string): unknown {
294 let cur: unknown = values
295 for (const part of key.split('.')) {
296 if (!cur || typeof cur !== 'object' || Array.isArray(cur) || !Object.hasOwn(cur, part)) return undefined
297 cur = (cur as Record<string, unknown>)[part]
298 }
299 return cur
300}
301
302/** The longest text, in characters (code points), a skipped setting's value is logged with before it is cut. */
303export const PREVIEW_CHARS = 40
304
305/**
306 * A value as the trust gate logs it: a text in double quotes, anything else as JSON. Runs of
307 * white space become one space, and a value longer than PREVIEW_CHARS is cut there, with `…` and
308 * `(N chars)` after it, so a long pingPrompt or compactInstructions stays one short line. Lengths
309 * count characters (code points, so an emoji is one and is never split in half) of the folded
310 * text, the text that was cut. Nothing is redacted (asked for, so a person can see what was
311 * skipped): the first PREVIEW_CHARS characters are logged as they are, which is why the README
312 * says never to put a secret in these settings.
313 */
314export function previewValue(v: unknown): string {
315 const text = typeof v === 'string' ? v : JSON.stringify(v) ?? String(v)
316 const chars = Array.from(text.replace(/\s+/g, ' '))
317 const cut = chars.length > PREVIEW_CHARS ? `${chars.slice(0, PREVIEW_CHARS).join('')}…` : chars.join('')
318 const shown = typeof v === 'string' ? `"${cut}"` : cut
319 return chars.length > PREVIEW_CHARS ? `${shown} (${chars.length} chars)` : shown
320}
321
322/** The given keys with the values they have in `values`, `key=value, …`, as the trust gate logs them. */
323export const keysWithValues = (values: Record<string, unknown>, keys: readonly string[]): string =>
324 keys.map(key => `${key}=${previewValue(valueAt(values, key))}`).join(', ')
325
326/**
327 * The values without those of the given keys that are set (not missing or null), and the keys taken
328 * out. A dot in a key is one level of nesting. The values passed in are never changed: what is taken
329 * out is taken out of copies. Shared by the automatic handoff's and keep-alive's trust gates.
330 */
331export function withoutKeys(values: Record<string, unknown>, keys: readonly string[]): { values: Record<string, unknown>; ignored: string[] } {
332 const ignored = keys.filter(key => { const v = valueAt(values, key); return v !== undefined && v !== null })
333 if (!ignored.length) return { values, ignored }
334 const kept = { ...values }
335 for (const key of ignored) {
336 const parts = key.split('.')
337 let cur = kept
338 for (const part of parts.slice(0, -1)) cur = cur[part] = { ...(cur[part] as Record<string, unknown>) }
339 delete cur[parts.at(-1)!]
340 }
341 return { values: kept, ignored }
342}
343
344/** Where the window a threshold was computed from came from: `host`, the `window` Claude Code
345 * reported; `host-percent`, derived from its `percent` and `tokens`; `default`, defaultContextWindow. */
346export type WindowSource = 'host' | 'host-percent' | 'default'
347export type WindowInfo = { window: number; source: WindowSource }
348
349/** The context window in tokens and its source, from $.session.usage().context: its `window`, else
350 * derived from its `percent` (0 to 100) and `tokens`, else undefined, and the caller uses defaultContextWindow. */
351export function windowInfo(context: { tokens?: number; window?: number; percent?: number }): WindowInfo | undefined {
352 if (typeof context.window === 'number' && context.window > 0) return { window: context.window, source: 'host' }
353 const { tokens, percent: pct } = context
354 if (typeof tokens === 'number' && tokens > 0 && typeof pct === 'number' && pct > 0) return { window: Math.round(tokens * 100 / pct), source: 'host-percent' }
355 return undefined
356}
357
358/** The context window in tokens (windowInfo without its source). */
359export const windowOf = (context: { tokens?: number; window?: number; percent?: number }): number | undefined => windowInfo(context)?.window
360
361/** The window fields of the per-turn `threshold` log line: the window the threshold was computed
362 * from, where it came from, and the basis of the threshold. With thresholdTokens no window is read,
363 * so `window=none windowSource=none`. One space-separated run of key=value pairs, no spaces in a value. */
364export function windowFields(w: WindowInfo | undefined, cfg: Pick<Config, 'thresholdPercent' | 'thresholdTokens'>): string {
365 const basis = cfg.thresholdTokens ? 'thresholdTokens' : `thresholdPercent:${cfg.thresholdPercent}`
366 return `window=${w?.window ?? 'none'} windowSource=${w?.source ?? 'none'} basis=${basis}`
367}
368
369/** The part of $.session.usage({ breakdown }).context.breakdown the threshold line reads: how Claude
370 * Code settled its window (`autocompactSource`, a ContextWindowSource such as `model-default` or
371 * `env`) and the token count its auto-compact runs at (absent when auto-compact is off). */
372export type HostWindow = { autocompactSource?: unknown; autoCompactThreshold?: unknown }
373
374/** Claude Code's own window fields of the threshold log line, next to the module's windowFields:
375 * `hostWindowSource=<source|none> autoCompactThreshold=<n|none>`. A value that is missing, of the
376 * wrong type or would put a space in the line reads `none`. */
377export function hostFields(b: HostWindow | undefined): string {
378 const src = typeof b?.autocompactSource === 'string' && /^\S+$/.test(b.autocompactSource) ? b.autocompactSource : 'none'
379 const at = typeof b?.autoCompactThreshold === 'number' && Number.isFinite(b.autoCompactThreshold) ? b.autoCompactThreshold : 'none'
380 return `hostWindowSource=${src} autoCompactThreshold=${at}`
381}
382
383/** A response usage's `cache_creation` split, 1-hour and 5-minute tokens, when it carries one (zeros
384 * included). The mods API's usage type does not declare it; the engine may still pass it on. */
385export function creationSplit(usage: unknown): { '1h': number; '5m': number } | undefined {
386 const c = usage && typeof usage === 'object' ? (usage as Record<string, unknown>).cache_creation : undefined
387 if (!c || typeof c !== 'object') return undefined
388 const h = (c as Record<string, unknown>).ephemeral_1h_input_tokens
389 const m = (c as Record<string, unknown>).ephemeral_5m_input_tokens
390 return typeof h === 'number' && typeof m === 'number' ? { '1h': h, '5m': m } : undefined
391}
392
393/** What the seeded session's first request read from and wrote to the prompt cache. */
394export type SeedCache = { input: number; read: number; written: number; split?: { '1h': number; '5m': number } }
395
396/** The cache fields of the seed turn's `floor` log line. `cache=hit` when anything was read from the
397 * cache: only the shared prefix (tools, system prompt, project context) can be, never the old conversation. */
398export function seedCacheFields(c: SeedCache): string {
399 return `input_tokens=${c.input} cache_read_input_tokens=${c.read} cache_creation_input_tokens=${c.written} cache_creation_1h=${c.split?.['1h'] ?? 'none'} cache_creation_5m=${c.split?.['5m'] ?? 'none'} cache=${c.read > 0 ? 'hit' : 'miss'}`
400}
401
402/** The panel's line for the same: "cache hit: 30k read, 2k written (1h 2k, 5m 0)". */
403export function seedCacheLine(c: SeedCache): string {
404 const split = c.split ? `1h ${k(c.split['1h'])}, 5m ${k(c.split['5m'])}` : '1h/5m split not reported'
405 return `${c.read > 0 ? `cache hit: ${k(c.read)} read` : 'cache miss: nothing read'}, ${k(c.written)} written (${split})`
406}
407
408/** The handoff line in tokens: thresholdPercent of the window, rounded down. */
409export const contextLine = (thresholdPercent: number, window: number) => Math.floor(window * thresholdPercent / 100)
410
411/** The chain's session suffix, "(Session <n>)": written by sessionSuffix, read by parseTitle through
412 * the one pattern below (its group captures n). Change the two together. */
413export const sessionSuffix = (n: number) => `(Session ${n})`
414const SESSION_SUFFIX = String.raw`\(Session (\d+)\)`
415const TRAILING_NUMBER = new RegExp(String.raw`${SESSION_SUFFIX}\s*$`)
416const TRAILING_SUFFIX = new RegExp(String.raw`\s*${SESSION_SUFFIX}$`)
417
418/** A session name in a handoff chain: "<prefix>: <base> (Session <n>)". */
419export const sessionName = (prefix: string, base: string, n: number) => `${prefix}: ${base} ${sessionSuffix(n)}`
420
421const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
422
423/** The default session-name prefix: always stripped from a title, besides the prefix in force, so a
424 * chain started under the default keeps its base name after the prefix changes (Q16-2). */
425export const DEFAULT_SESSION_PREFIX: string = DEFAULTS.sessionNamePrefix
426
427/** A title read into its base name (no prefix, no "(Session n)") and its session number. */
428export type TitleParts = { base?: string; number?: number }
429
430/**
431 * Reads a session title into its base name and its number. Pure and total.
432 * The number is that of the last trailing "(Session n)", n a whole number from 1
433 * (`P: B (Session 1) (Session 2)` is 2). The base is the title with every trailing "(Session n)" and
434 * every leading "<prefix>:" taken off, again and again, so names never nest. Only two prefixes are
435 * taken off: the default and the one in force; any other `X:` stays in the base (AC-16.3.3).
436 * A blank title, or one that is only wrappers, has no base.
437 */
438export function parseTitle(title: string | undefined, prefix: string): TitleParts {
439 let t = title?.trim()
440 if (!t) return {}
441 const parts: TitleParts = {}
442 const n = Number(TRAILING_NUMBER.exec(t)?.[1])
443 if (Number.isSafeInteger(n) && n >= 1) parts.number = n
444 const lead = new RegExp(`^(?:${escapeRe(DEFAULT_SESSION_PREFIX)}|${escapeRe(prefix)}):\\s*`)
445 for (let before = ''; before !== t;) {
446 before = t
447 t = t.replace(TRAILING_SUFFIX, '').replace(lead, '').trim()
448 }
449 if (t) parts.base = t
450 return parts
451}
452
453/** The base name of a session title (parseTitle), undefined when it has none. */
454export function baseName(title: string | undefined, prefix: string): string | undefined {
455 return parseTitle(title, prefix).base
456}
457
458/** A session's number in its chain, the next session's, whether it is the main session (the one
459 * renamed Session 1), and where the number came from. */
460export type Numbering = { current: number; next: number; isMain: boolean; source: 'title' | 'lineage' | 'default' }
461
462/**
463 * The current and the next number of a session handing off: the title's number, else the lineage
464 * depth (`depth`: from a lineage read that found a record, else undefined), else 1. Only a session
465 * with neither, whose lineage read did not fail, is the main session: a read that failed is never
466 * trusted to rename (`readFailed`).
467 */
468export function numbering(titleNumber: number | undefined, depth: number | undefined, readFailed = false): Numbering {
469 const current = titleNumber ?? depth ?? 1
470 const source = titleNumber !== undefined ? 'title' : depth !== undefined ? 'lineage' : 'default'
471 return { current, next: current + 1, isMain: source === 'default' && !readFailed, source }
472}
473
474/** The one notice of a handoff whose lineage read failed: its log line (marked `numbering fallback:`),
475 * its panel step and the toast shown where the band is not drawn. */
476export function numberingNotice(session: string, error: string, n: Numbering): { log: string; step: { mark: 'warn'; text: string }; toast: string } {
477 const fromTitle = n.source === 'title'
478 // One set of fragments for both forms: the log adds the details (the number, why it defaulted).
479 const outcome = (detail: boolean) => fromTitle
480 ? `number${detail ? ` ${n.current}` : ''} taken from the title${detail ? ` ${sessionSuffix(n.current)}` : ''}`
481 : `number defaulted to 1${detail ? ', the title has none' : ''}`
482 const tail = `, chain id restarted${fromTitle ? '' : ', session not renamed'}`
483 const text = `numbering: store unreadable, ${outcome(false)}${tail}`
484 return {
485 log: `numbering fallback: lineage read failed session=${session} (${error}); ${outcome(true)}${tail}`,
486 step: { mark: 'warn', text },
487 toast: text,
488 }
489}
490
491/** The session's title from its transcript: the last `custom-title` line (a name the person or
492 * /rename gave), else the last `ai-title` line. Undefined when the transcript names none. */
493export function transcriptTitle(jsonl: string): string | undefined {
494 let custom: string | undefined
495 let ai: string | undefined
496 for (const line of jsonl.split('\n')) {
497 if (!line.includes('-title"')) continue
498 try {
499 const o = JSON.parse(line) as { type?: string; customTitle?: unknown; aiTitle?: unknown }
500 if (o.type === 'custom-title' && typeof o.customTitle === 'string' && o.customTitle.trim()) custom = o.customTitle.trim()
501 else if (o.type === 'ai-title' && typeof o.aiTitle === 'string' && o.aiTitle.trim()) ai = o.aiTitle.trim()
502 } catch {}
503 }
504 return custom ?? ai
505}
506
507// The seed prompt's first characters; the render hook knows the seed row by them.
508export const SEED_PREFIX = '[auto-handoff] ↪ Handoff from session'
509// The seed text with its brief path and viewer URL as markdown links. The path becomes a
510// file: link labelled by its file name; the URL links to itself. Exported for the test.
511export function linkify(text: string): string {
512 return text
513 .replace(/(?<=\bat )(\/\S+\.md)(?=[\s)]|$)/, (p) => `[${p.slice(p.lastIndexOf('/') + 1)}](file://${p})`)
514 .replace(/(https?:\/\/[^\s)]+)/, (u) => `[${u}](${u})`)
515}
516
517// Each template's default, a file in the mod's templates/ folder.
518export const TEMPLATES = [['briefTemplate', 'brief.md'], ['instructionsTemplate', 'instructions.md']] as const
519export type TemplateKey = typeof TEMPLATES[number][0]
520// The sections Haiku writes when an AI-DLC run is active: the handoff skill's session handoff
521// template. Shipped only, not a setting, so it stays the skill's template.
522export const SESSION_TEMPLATE = 'session-handoff.md'
523// Used only when the shipped default is unreadable too, so the fresh session still knows what to do.
524export const LAST_RESORT_INSTRUCTIONS = '## Instructions\n\nThis turn was triggered by the system, not by a user. Read this brief and continue the work it describes.'
525hooks/auto-handoff/run.ts 379 lines1// The ai-dlc side of a handoff: when the repository has an active AI-DLC run on the current branch,
2// the automatic handoff writes the `handoff` skill's session handoff (skills/handoff/SKILL.md,
3// "Session handoff") into the scratch folder and points the run's current run state at it (the
4// highest run.v<major>[.<minor>].md, else a run.md from before versions; currentRunStateFile), so
5// the fresh session resumes the run from it. Engine-free, like config.ts: the host follows $ only into
6// functions declared in register.tsx, so the file reads and git calls stay there.
7
8import type { Facts } from './brief.ts'
9import { factsBlock, hasUnansweredLastRequest } from './brief.ts'
10import { renderTemplate } from './templates.ts'
11
12/** One run state (run.v<major>[.<minor>].md, or a run.md from before versions), as the handoff
13 * skill's "Run state" template writes it. */
14export type RunState = {
15 /** The run id: the run folder's name. */
16 id: string
17 /** The run folder, relative to the repository root, with forward slashes. */
18 folder: string
19 status?: string
20 branch?: string
21 session?: string
22 updated?: string
23 lifecycle?: string
24 stage?: string
25 owner?: string
26 lastRecord?: string
27 /** The one-line summary after "# Run: <id> — ". */
28 summary?: string
29}
30
31/** Where the session handoff goes and what the fresh session reads. */
32export type RunTarget = RunState & {
33 /** The repository root, absolute, with forward slashes. */
34 root: string
35 /** The run's current run-state file (currentRunStateFile), relative to the repository root. */
36 runState: string
37 /** The session handoff, relative to the repository root unless the scratch root is absolute. */
38 sessionHandoff: string
39 branchName: string
40 commit?: string
41}
42
43const DEFAULT_HANDOFFS = 'docs/handoffs'
44const DEFAULT_SCRATCH = '.scratch'
45/** Marks a Next action this module wrote, so a second handoff in a row does not nest the quote. */
46export const AUTO_MARK = 'automatic context-limit handoff'
47/** The leading sentence of a Next action withNextAction wrote. Matching the whole prefix, not just
48 * AUTO_MARK, keeps an action that merely mentions the phrase from being taken for one. */
49const WRITTEN_HERE = new RegExp(`^Continue (?:stage \`[^\`\\n]*\`|the current stage) from the session handoff \`[^\`\\n]*\` \\(${AUTO_MARK} from session `)
50
51const lf = (text: string) => text.replace(/\r\n/g, '\n')
52const trimSlashes = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
53
54/** The folder that holds the run folders: artifacts.handoffs of .ai-dlc/workflow.json up to its
55 * first placeholder segment, else docs/handoffs. A file that does not parse falls back too. */
56export function handoffsRoot(workflowJson?: string): string {
57 if (!workflowJson) return DEFAULT_HANDOFFS
58 try {
59 const value = (JSON.parse(workflowJson) as { artifacts?: { handoffs?: unknown } }).artifacts?.handoffs
60 if (typeof value !== 'string' || !value.trim()) return DEFAULT_HANDOFFS
61 const kept: string[] = []
62 for (const seg of trimSlashes(value.trim()).split('/')) {
63 if (seg.includes('{')) break
64 // A `..` segment would let the run lookup leave the repository: use the default instead.
65 if (seg === '..') return DEFAULT_HANDOFFS
66 kept.push(seg)
67 }
68 const root = kept.join('/').replace(/^\.\//, '')
69 return root || DEFAULT_HANDOFFS
70 } catch {
71 return DEFAULT_HANDOFFS
72 }
73}
74
75/** The scratch root from the "Workspace folders" table of AGENTS.md (the `workspace-folders`
76 * skill records it there), else .scratch. */
77export function scratchRoot(agentsMd?: string): string {
78 const m = agentsMd ? /^\|\s*Scratch\s*\|\s*`([^`]+)`/m.exec(lf(agentsMd)) : null
79 return m?.[1]?.trim() ? trimSlashes(m[1].trim()) : DEFAULT_SCRATCH
80}
81
82export const isAbsolute = (p: string) => /^(\/|[A-Za-z]:[\\/])/.test(p)
83
84/** Reads a run state's front matter and title. Undefined when it has no front matter. */
85export function parseRunState(text: string, folder: string): RunState | undefined {
86 const m = /^---\n([\s\S]*?)\n---\n?/.exec(lf(text))
87 if (!m) return undefined
88 const fm: Record<string, string> = {}
89 for (const line of m[1]!.split('\n')) {
90 const kv = /^([\w-]+):\s*(.*)$/.exec(line)
91 // A value may carry a trailing "# comment", as in the template; drop it.
92 if (kv) fm[kv[1]!] = kv[2]!.replace(/\s+#.*$/, '').trim()
93 }
94 const title = /^# Run: \S+ — (.+)$/m.exec(lf(text).slice(m[0].length))
95 const id = folder.split('/').pop() ?? folder
96 return {
97 id: fm.run || id,
98 folder,
99 status: fm.status,
100 branch: fm.branch,
101 session: fm.session,
102 updated: fm.updated,
103 lifecycle: fm.lifecycle,
104 stage: fm['current-stage'],
105 owner: fm.owner,
106 lastRecord: fm['last-record'],
107 summary: title?.[1]?.trim(),
108 }
109}
110
111/** A run-state version file name: `run.v<major>.md` or `run.v<major>.<minor>.md`. Versions start
112 * at 1, so a part is a whole number from 1 with no leading zero: `run.v0.md`, `run.v01.md` and
113 * `run.v1.01.md` are not run states, and no two names can mean the same version. */
114// Keep this pattern the same as RUN_VERSION in plugins/claude/ai-dlc-lens/hooks/lens.ts: the lens reads
115// the run state this module writes, so both must take the same file names as run-state versions.
116const VERSION_NAME = /^run\.v([1-9]\d*)(?:\.([1-9]\d*))?\.md$/
117
118/**
119 * The current run-state file of a run folder, from the file names in it (the handoff skill's *Run
120 * state*, Versions): the highest `run.v<major>.md` or `run.v<major>.<minor>.md`, compared by major
121 * then minor, each as a number, a bare major counting as minor 0 (run.v2.md over run.v1.2.md over
122 * run.v1.1.md over run.v1.md; run.v1.10.md over run.v1.9.md; run.v10.md over run.v9.md), else a
123 * `run.md` from before versions; undefined when the folder has neither. The module writes into this
124 * file and never starts a new version: a handoff chain is one session.
125 */
126export function currentRunStateFile(names: readonly string[]): string | undefined {
127 let best: { major: number; minor: number; name: string } | undefined
128 for (const name of names) {
129 const m = VERSION_NAME.exec(name)
130 if (!m) continue
131 const major = Number(m[1])
132 const minor = m[2] === undefined ? 0 : Number(m[2])
133 if (!best || major > best.major || (major === best.major && minor > best.minor)) best = { major, minor, name }
134 }
135 return best?.name ?? (names.includes('run.md') ? 'run.md' : undefined)
136}
137
138/** What is odd about a run folder's run-state names, for the log; undefined when nothing is:
139 * names that look like versions (`run.v….md`) but none is a valid one (`VERSION_NAME`), or a `run.md`
140 * beside versions, where the highest version is read and `run.md` is not. */
141export function runStateNote(names: readonly string[]): string | undefined {
142 const looks = names.filter(n => /^run\.v.*\.md$/.test(n))
143 if (!looks.length) return undefined
144 const file = currentRunStateFile(names)
145 if (!looks.some(n => VERSION_NAME.test(n))) return `no valid run-state version among ${looks.join(', ')}; ${file ? `${file} read` : 'folder ignored'}`
146 return names.includes('run.md') ? `run.md beside run-state versions; ${file} read` : undefined
147}
148
149/** The statuses that end a run's handoff chain. */
150export const ENDED = new Set(['done', 'blocked', 'abandoned'])
151
152/** The run this session hands off, among the `active` runs on the current branch:
153 * 1. the run whose `session` is this session's id or one of its chain's earlier sessions (`lineage`,
154 * nearest first), or whose id is the run this chain claimed (`claimed`) while no session outside
155 * the chain has written it; the newest `updated` when there are several;
156 * 2. else the only active run on the branch, when its `session` is empty (no session claimed it);
157 * 3. else none, so the module seeds its plain brief. A run a session outside this chain wrote is
158 * never taken, and neither is one of several unclaimed runs: two sessions never drive one run. A
159 * `blocked` run waits for the human, so it is never picked.
160 * A session seeded by a handoff matches through `lineage`: until the resumed orchestrator rewrites
161 * the run state, its `session` still names an earlier session of the chain. */
162export function pickRun(runs: readonly RunState[], branch: string, sessionId: string, lineage: readonly string[] = [], claimed?: string): RunState | undefined {
163 const open = runs.filter(r => r.status === 'active' && r.branch === branch)
164 const chain = new Set([sessionId, ...lineage].filter(Boolean))
165 const own = open.filter(r => {
166 const s = r.session?.trim() ?? ''
167 return (s && chain.has(s)) || (claimed !== undefined && r.id === claimed && !s)
168 })
169 if (own.length) return [...own].sort((a, b) => (b.updated ?? '').localeCompare(a.updated ?? ''))[0]
170 return open.length === 1 && !open[0]!.session?.trim() ? open[0] : undefined
171}
172
173/** One row of a run state's *Stages* table: its step code, its status and the record files it names. */
174export type StageRow = { step: string; status: string; records: string[] }
175
176const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
177const norm = (h: string) => h.toLowerCase().replace(/[^a-z]/g, '')
178const base = (p: string) => p.slice(p.lastIndexOf('/') + 1)
179// A record file name in a cell: backticked or bare, with or without a folder.
180const RECORD = /[^\s`|]*\.(?:handoff|handback)\.md/g
181
182/** The rows of a run state's *Stages* table. Columns are found by their header (Step, Status, Handoff,
183 * Handback / Hand-back), so a table with more or fewer columns still parses; no table, no rows. */
184export function parseStages(state: string): StageRow[] {
185 const text = lf(state)
186 const heading = /^## Stages[ \t]*$/m.exec(text)
187 if (!heading) return []
188 const rest = text.slice(heading.index + heading[0].length)
189 const end = /^## /m.exec(rest)
190 const lines = (end ? rest.slice(0, end.index) : rest).split('\n').filter(l => l.trim().startsWith('|'))
191 if (lines.length < 2) return []
192 const head = cells(lines[0]!).map(norm)
193 const at = (name: string) => head.indexOf(name)
194 const step = at('step')
195 const status = at('status')
196 const recordCols = head.map((h, i) => h === 'handoff' || h === 'handback' ? i : -1).filter(i => i >= 0)
197 if (status < 0) return []
198 return lines.slice(2).map(line => {
199 const c = cells(line)
200 const records = recordCols.flatMap(i => (c[i] ?? '').match(RECORD) ?? [])
201 return { step: step >= 0 ? (c[step] ?? '').replace(/`/g, '') : '', status: (c[status] ?? '').toLowerCase(), records }
202 })
203}
204
205/** Whether a stage row is still open work: any status but done or skipped. */
206export const isOpen = (row: StageRow) => !/^(done|skipped)\b/.test(row.status.trim())
207
208/** A record's step code, as the `handoff` skill's *Naming* allows: numbers, each loop `.l<k>` followed by
209 * the numbers after it (its round, then any parallel or nested sub-number), loops more than once:
210 * `3`, `5.0.1`, `1.3.l1.2`, `1.4.l1.1.2`, `8.1.6.1.l2.1`; and the codes of earlier rules (`3.1.l2`). */
211const CODE = String.raw`\d+(?:\.\d+)*(?:\.l\d+(?:\.\d+)*)*`
212
213/** The record a record answers or is answered by: same step code, agents swapped, handoff ↔ handback.
214 * "3-orchestrator-to-x.handoff.md" → "3-x-to-orchestrator.handback.md". Undefined for another name. */
215export function pairOf(record: string): string | undefined {
216 const slash = record.lastIndexOf('/')
217 const dir = record.slice(0, slash + 1)
218 const m = new RegExp(String.raw`^(${CODE})-(.+?)-to-(.+)\.(handoff|handback)\.md$`).exec(record.slice(slash + 1))
219 if (!m) return undefined
220 return `${dir}${m[1]}-${m[3]}-to-${m[2]}.${m[4] === 'handoff' ? 'handback' : 'handoff'}.md`
221}
222
223/** A file the fresh session is told to read, relative to the repository root unless absolute. */
224export type OpenRecord = { path: string; note: string }
225
226/**
227 * The records of a *Stages* row that are still open work. A row is a sub-root and lists all its records,
228 * its loops' rounds included, oldest first in each of its Handoff and Handback columns, so only these
229 * are kept:
230 * - every record of the row's latest pass (`passOf`), the pass of the last handoff in its Handoff column
231 * (of its last record when it names no handoff): the round in progress, or the first pass;
232 * - every handoff of an earlier pass whose hand-back the row does not name: work never answered. A
233 * handoff to the human (`-to-human.`) is not: it never gets a hand-back record, the human answers it.
234 * A record whose name has no step code is kept, since its pass cannot be told.
235 */
236export function openInRow(row: StageRow): string[] {
237 const handoffs = row.records.filter(p => p.endsWith('.handoff.md'))
238 const newest = handoffs.at(-1) ?? row.records.at(-1)
239 const code = stepOf(newest)
240 if (!code) return row.records
241 const latest = passOf(code)
242 const named = new Set(row.records.map(base))
243 return row.records.filter(p => {
244 const c = stepOf(p)
245 if (!c || passOf(c) === latest) return true
246 const pair = pairOf(p)
247 return p.endsWith('.handoff.md') && !/-to-human\.handoff\.md$/.test(p) && !!pair && !named.has(base(pair))
248 })
249}
250
251/**
252 * The files of the work that is not done, in reading order: the run state, the session handoff, then the
253 * open records (`openInRow`) of every *Stages* row whose status is not done or skipped. `last-record` and
254 * its pair come first among the records when their stage is open. A record named by its file name alone
255 * lives in the run folder. Done and skipped stages are left out: the fresh session needs only open work.
256 */
257export function openRecords(state: string, run: { folder: string; runState: string; lastRecord?: string }, sessionHandoff: string): OpenRecord[] {
258 const inFolder = (name: string) => name.includes('/') ? name : `${run.folder}/${name}`
259 const out: OpenRecord[] = [{ path: run.runState, note: 'run state' }, { path: sessionHandoff, note: 'this session handoff' }]
260 const seen = new Set(out.map(r => r.path))
261 const add = (name: string, note: string) => {
262 const path = inFolder(name)
263 if (seen.has(path)) return
264 seen.add(path)
265 out.push({ path, note })
266 }
267 // The record's own code, then the row's step when it differs: "step 1.3.l1.1 in 1.3".
268 const where = (row: StageRow, p: string) => {
269 const code = stepOf(p) || row.step || '?'
270 return `step ${code}${row.step && row.step !== code ? ` in ${row.step}` : ''}, ${row.status || 'open'}`
271 }
272 const rows = parseStages(state)
273 const last = run.lastRecord?.trim()
274 if (last) {
275 const pair = pairOf(last)
276 const names = new Set([base(last), ...(pair ? [base(pair)] : [])])
277 const row = rows.find(r => r.records.some(p => names.has(base(p))))
278 if (row && isOpen(row)) {
279 add(last, `last record, ${where(row, last)}`)
280 if (pair) add(pair, 'its pair')
281 }
282 }
283 for (const row of rows) if (isOpen(row)) for (const p of openInRow(row)) add(p, where(row, p))
284 return out
285}
286
287/** The "Handoff records" section: each open file, `(missing)` after one that is not on disk.
288 * `runState` is the run-state file the list was read from, named in the text. */
289export function recordsSection(records: readonly (OpenRecord & { exists: boolean })[], runState: string): string {
290 const lines = records.map(r => `- \`${r.path}\`${r.exists ? '' : ' (missing)'} — ${r.note}`)
291 return `## Handoff records
292The files of the work that is not done yet, listed in code from the *Stages* table of \`${runState}\`: read them before its *Next action*. Stages that are done or skipped are left out, and an open stage lists only its latest pass (the round in progress, or its first pass) and any handoff still waiting for its hand-back.
293
294${lines.join('\n')}`
295}
296
297const pad = (n: number) => String(n).padStart(2, '0')
298/** YYYY-MM-DD HH:MM, local time, as the run state writes `updated`. */
299export const stamp = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
300/** session-YYYYMMDD-HHMM.md, the skill's file name. */
301export const sessionHandoffName = (d: Date) => `session-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}.md`
302
303/** The step code of a record file name, with or without a folder:
304 * "1.4.l1.1.2-orchestrator-to-x.handoff.md" → "1.4.l1.1.2"; "3.l1-…" → "3.l1". */
305export const stepOf = (record?: string) => record ? new RegExp(String.raw`^(${CODE})-`).exec(base(record))?.[1] : undefined
306
307/** The pass a step code belongs to: in a loop, the code up to its last loop's round
308 * ("1.4.l1.1.2" → "1.4.l1.1", "8.1.6.1.l2.1" → itself, an earlier rule's "3.1.l2" → itself); outside a
309 * loop, the first pass of its sub-root ("1.4.2" → "1.4", "1.3.1" → "1.3", "4" → "4"). */
310export function passOf(code: string): string {
311 const loop = /^(.*\.l\d+(?:\.\d+)?)/.exec(code)
312 return loop ? loop[1]! : code.split('.').slice(0, 2).join('.')
313}
314
315/** The session handoff's path: <scratch>/<run folder name>/session-<YYYYMMDD-HHMM>.md. */
316export function sessionHandoffPath(scratch: string, run: RunState, at: Date): string {
317 return `${trimSlashes(scratch)}/${run.folder.split('/').pop()}/${sessionHandoffName(at)}`
318}
319
320/** A path relative to the repository root made absolute; an absolute one is left alone. */
321export const inRepo = (root: string, p: string) => isAbsolute(p) ? p : `${trimSlashes(root)}/${p}`
322
323/**
324 * The run state with its *Next action* pointing at the session handoff, and `updated` set. The action that
325 * was there is quoted under the new one, so nothing the orchestrator planned is lost; when that
326 * action was itself written here, its quote is carried over instead of nesting a second one.
327 */
328export function withNextAction(state: string, ctx: { stage?: string; sessionHandoff: string; oldSession: string; tokens: string; at: Date }): string {
329 const crlf = state.includes('\r\n')
330 let text = lf(state)
331 text = text.replace(/^(updated:).*$/m, `$1 ${stamp(ctx.at)}`)
332 const heading = /^## Next action[ \t]*\n/m.exec(text)
333 const start = heading ? heading.index + heading[0].length : text.length
334 const rest = text.slice(start)
335 const next = /^## /m.exec(rest)
336 const old = (next ? rest.slice(0, next.index) : rest).trim()
337 const prior = WRITTEN_HERE.test(old)
338 ? old.split('\n').filter(l => l.startsWith('>')).map(l => l.replace(/^> ?/, '')).join('\n').trim()
339 : old
340 const stage = ctx.stage ? `stage \`${ctx.stage}\`` : 'the current stage'
341 const action = [
342 `Continue ${stage} from the session handoff \`${ctx.sessionHandoff}\` (${AUTO_MARK} from session ${ctx.oldSession.slice(0, 8)} at ${ctx.tokens}, ${stamp(ctx.at)}). Then carry on with the next action recorded before the handoff:`,
343 '',
344 ...(prior ? prior.split('\n').map(l => l ? `> ${l}` : '>') : ['> (none recorded)']),
345 ].join('\n')
346 const body = `${action}\n${next ? '\n' : ''}`
347 const out = heading
348 ? text.slice(0, start) + body + (next ? rest.slice(next.index) : '')
349 : `${text.replace(/\n*$/, '\n')}\n## Next action\n${body}`
350 return crlf ? out.replace(/\n/g, '\r\n') : out
351}
352
353/**
354 * The handoff skill's session handoff, filled by the automatic path: the header lines from the run
355 * state and git, the fresh session's instructions, then the sections Haiku wrote from the session
356 * handoff template (or, when Haiku failed, the facts alone), then the facts extracted in code.
357 */
358export function assembleSessionHandoff(ctx: { run: RunTarget; sessionId: string; transcript: string; instructions: string; tokens: string; records?: readonly (OpenRecord & { exists: boolean })[] }, facts: Facts, haiku: string | undefined): string {
359 const { run } = ctx
360 const instructions = renderTemplate(ctx.instructions, { priority: haiku ? hasUnansweredLastRequest(haiku) : false })
361 const step = stepOf(run.lastRecord)
362 const header = `# Session handoff: ${run.id} — ${run.summary ?? 'continue the run'}
363
364- Run state: \`${run.runState}\`
365- Lifecycle / stage / step: ${run.lifecycle ?? 'unknown'} / ${run.stage ?? 'unknown'} / ${step ?? 'unknown'}, agent \`${run.owner ?? 'orchestrator'}\`
366- Branch and last commit: ${run.branchName} @ ${run.commit ?? 'unknown'}
367- Next session focus: resume the run where the previous session stopped (${AUTO_MARK} at ${ctx.tokens})
368- Previous session: ${ctx.sessionId} · transcript \`${ctx.transcript}\`
369
370${instructions}
371
372## Resume the run
373This session continues run \`${run.id}\`. Resume it from its run state \`${run.runState}\` as the \`handoff\` skill describes (*Run state*, Resuming): re-verify its \`last-record\`, and for a stage left \`in-progress\` check whether that agent's output landed before handing off again. Its *Next action* points to this file and quotes the action planned before the handoff.
374${ctx.records?.length ? `\n${recordsSection(ctx.records, run.runState)}\n` : ''}
375## How to Use This Brief
376${haiku ? 'Haiku wrote the sections below from the conversation, with tool output abbreviated. The facts sections came from tool calls in code.' : 'Haiku did not return a usable summary, so this holds only facts extracted in code. Read the transcript for the rest.'} Treat every line as a starting point, not a fact: check it against the run state, the records and \`git status\` before acting on it.`
377 return [header, haiku?.trim(), factsBlock(facts, !haiku)].filter(Boolean).join('\n\n')
378}
379hooks/keepalive/keepalive.ts 916 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { TRACK_NAMES, formatDuration, parseCommandArgs } from './duration.ts'
3import type { Scope, TrackName } from './duration.ts'
4import { defaults, gatedDiffering, readMult, resolveSettings } from './settings.ts'
5import type { Level, Settings, TrackKey, TrackSettings } from './settings.ts'
6import { creationSplit, k, repoConfig, settingsEnv, trustOf } from '../auto-handoff/config.ts'
7import type { TrustTarget } from '../auto-handoff/config.ts'
8import { homeDir, log, readRaw, repoRoot, trustGate, trustTarget } from '../auto-handoff/io.ts'
9import type { TrustCache } from '../auto-handoff/io.ts'
10import type { KeepaliveState, KeepaliveTrackState } from '../../types/index.d.ts'
11import { armUntil, cachePart, cacheView, dropPending, emptyLedger, hhmm, isEmpty, lineOf, needsMainSeed, observe, observePing, reasonText, seedMain } from './cache-state.ts'
12import type { Ledger, Lifetimes } from './cache-state.ts'
13
14// Keep-alive, folded into the ai-dlc hooks module from the vendored cache-keepalive mod (see
15// ../ATTRIBUTION.md, "cache-keepalive"). When Claude ends a turn while background tasks still run, it
16// pings the prompt cache so the cache is still warm when the task's notification wakes the session.
17// Two tracks, one per cache lifetime: the 1-hour track and the 5-minute track. Which ones run comes
18// from the cache ledger (./cache-state.ts): per source and lifetime, until when each cache is warm, as
19// the session's responses and pings show it, never from an assumption.
20// ../auto-handoff/register.tsx calls registerKeepalive; the files, git, trust and the log come from
21// ../auto-handoff/io.ts. This file reads no environment variable: its settings come from settings
22// files, never the process environment.
23
24// The command. A hooks module can only register a bare name (letters, digits, _ and -), so the name
25// comes from the plugin skill skills/cache-keepalive, which Claude Code lists as /ai-dlc:cache-keepalive,
26// and the command.run hook below answers its runs in the skill's place.
27export const COMMAND = 'ai-dlc:cache-keepalive'
28export const REPO_FILE = '.ai-dlc/cache-keepalive.json'
29// The /ai-dlc:cache-keepalive overrides, one pair of store keys per track: this session's (a list of
30// [session id, ms]) and the machine's (ms).
31const sessionKey = (n: TrackName) => `keepalive:keepWarmFor:sessions:${n}`
32const machineKey = (n: TrackName) => `keepalive:keepWarmFor:machine:${n}`
33const COST_KEY = 'keepalive:compactCost'
34const RATIO_KEY = 'keepalive:summaryRatio'
35// How much of a transcript's end is read for the cache split when the file is too big to read whole.
36const TAIL_BYTES = 2_000_000
37
38export type { TrackName }
39const TRACKS: readonly (readonly [TrackName, TrackKey])[] = [['1h', 'oneHour'], ['5m', 'fiveMinute']]
40
41/** What register.tsx tells keep-alive: whether an automatic handoff is under way, when no pings are armed. */
42export type KeepaliveDeps = { handingOff: () => boolean }
43
44/** Tokens written to the cache for the 1-hour and the 5-minute lifetime. */
45export type Split = Record<TrackName, number>
46
47// nextPingAt: when the armed timer fires (null when none is armed); keep: the keep-warm time in force
48// for the track, worked out at arming and at each ping, and re-read while the track is not running
49// (refreshKeep); undefined means the track's setting. Both are published in the track's state
50// (publish), not used to decide. cfg: the track's settings as the armed timer took them (arm), the
51// one source of a running track's published pingEvery and ping limit, so they match what its tick
52// uses even when a full read changes the settings meanwhile; a track not running publishes the
53// settings now read, the ones its next arming takes. part: the keep-alive part of the track's line,
54// the text after the cache part (or after `keepalive <track>: ` when there is none); the line itself
55// is built when it is shown or published (lineFor), with that moment's cache part. A track that is not
56// armed always says why (reasonText), never a bare `off`.
57// pingWarmUntil (0 = none): the last landed ping's time plus the track's ttl. Read only when a Stop
58// arms the track (LLD 3.4), never by the cache part, which stays the ledger's: a landed 1h ping that
59// wrote no 1h tokens leaves the ledger's 1h entry, yet proves the cache warm. Cleared when the cache
60// is gone or unknown (expired, missed, an error), at session.end and before a handoff.
61type Track = { name: TrackName; key: TrackKey; timer?: Timer; generation: number; armedTime: number; warmUntil: number; pingWarmUntil: number; pings: number; part: string; nextPingAt: number | null; keep?: Keep; cfg?: TrackSettings }
62const newTrack = (name: TrackName, key: TrackKey): Track => ({ name, key, generation: 0, armedTime: 0, warmUntil: 0, pingWarmUntil: 0, pings: 0, part: reasonText({ noWrite: name }), nextPingAt: null })
63
64// Module variables survive /clear, which is why every timer is stopped before one (stopForHandoff)
65// and at session.end.
66let settings: Settings = defaults()
67let sources: Record<string, string> = {}
68const warned = new Set<string>()
69const tracks: Record<TrackName, Track> = { '1h': newTrack('1h', 'oneHour'), '5m': newTrack('5m', 'fiveMinute') }
70let compacting = false
71let spent = 0
72let budget = 0
73let context = 0
74// Until when each cache of this session is warm, per source and lifetime (./cache-state.ts), from the
75// responses' usage, the pings and, when no usage was seen, the transcript. Cleared at session.end and
76// before a handoff; a new turn keeps it.
77let ledger: Ledger = emptyLedger()
78const life = (): Lifetimes => ({ '5m': settings.fiveMinute.ttl, '1h': settings.oneHour.ttl })
79// After an automatic handoff, what the seeded session's first request read from the cache, shown
80// under the track lines until the next turn starts (showSeedCache).
81let seedLine: string | undefined
82
83/** The 1h/5m split of a usage object, when it carries one with a write: `cache_creation`'s
84 * `ephemeral_1h_input_tokens` and `ephemeral_5m_input_tokens`. Undefined otherwise. */
85export function splitOf(usage: unknown): Split | undefined {
86 const split = creationSplit(usage)
87 return split && split['1h'] + split['5m'] > 0 ? split : undefined
88}
89
90/** A transcript's last cache write: its split and, when the row has a timestamp, when it was written. */
91export type TranscriptSplit = { split: Split; at?: number }
92
93// The last row of a transcript (JSON lines) that wrote to the cache with a split, with the time of
94// that row (`at`, from its `timestamp`) when it has one. `sidechain`: whether subagent rows count.
95function lastSplit(jsonl: string, sidechain: boolean): TranscriptSplit | undefined {
96 const lines = jsonl.split('\n')
97 for (let i = lines.length - 1; i >= 0; i--) {
98 const line = lines[i]!
99 if (!line.includes('"cache_creation"')) continue
100 try {
101 const o = JSON.parse(line) as { isSidechain?: boolean; timestamp?: unknown; message?: { usage?: unknown } }
102 if (!sidechain && o.isSidechain === true) continue
103 const split = splitOf(o.message?.usage)
104 if (!split) continue
105 const at = typeof o.timestamp === 'string' ? Date.parse(o.timestamp) : NaN
106 return Number.isFinite(at) ? { split, at } : { split }
107 } catch {}
108 }
109 return undefined
110}
111
112/** The split of the last main-conversation response in a transcript (JSON lines) that wrote to the
113 * cache, with the time of that row (`at`, from its `timestamp`) when it has one. */
114export const splitFromTranscript = (jsonl: string): TranscriptSplit | undefined => lastSplit(jsonl, false)
115
116/** splitFromTranscript for a subagent's own transcript, whose rows are all sidechain rows: they count
117 * (LLD 3.7, Fallback and 034.c). */
118export const splitFromAgentTranscript = (jsonl: string): TranscriptSplit | undefined => lastSplit(jsonl, true)
119
120/** Records one main-conversation response in the cache ledger (./cache-state.ts, observe). */
121export async function noteUsage($: EngineInterface, usage: unknown): Promise<void> {
122 observe(ledger, { at: await $.clock.now(), usage }, life())
123}
124
125// ITEM-034.c, the observation check (LLD 3.7, rev 6): two debug lines that show whether a subagent's
126// usage and its transcript carry the 1h/5m split. Log only: they never touch the ledger and never throw.
127
128/** Logs one subagent response's usage to the debug log: `keepalive: agent usage agent=<id>
129 * split=present|absent writes=<n>`, `writes` being its cache_creation_input_tokens and `split`
130 * whether its cache_creation carries the 1h/5m fields. register.tsx calls it for each response that
131 * has an agent id (until T17.3 moves the call into noteUsage). */
132export function logAgentUsage($: EngineInterface, usage: unknown, agentId: string): void {
133 try {
134 const raw = usage && typeof usage === 'object' ? (usage as Record<string, unknown>).cache_creation_input_tokens : undefined
135 const writes = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0
136 $.ui.log(`keepalive: agent usage agent=${agentId} split=${creationSplit(usage) ? 'present' : 'absent'} writes=${writes}`, { to: 'debug' })
137 } catch {}
138}
139
140/** Logs a finished subagent's transcript to the debug log: `keepalive: subagent transcript
141 * agent=<id> rows=<n> split=present|absent`, `rows` being its non-empty lines and `split` whether a
142 * row wrote to the cache with the 1h/5m split (splitFromAgentTranscript). A missing path or a read
143 * that fails logs `rows=0 split=absent`. Never throws. */
144export async function logSubagentTranscript($: EngineInterface, e: { agent_id?: string; agent_transcript_path?: string }): Promise<void> {
145 try {
146 const text = e.agent_transcript_path ? await readTranscript($, e.agent_transcript_path) : undefined
147 const rows = text === undefined ? 0 : text.split('\n').filter(l => l.trim() !== '').length
148 const split = text !== undefined && splitFromAgentTranscript(text) !== undefined
149 $.ui.log(`keepalive: subagent transcript agent=${e.agent_id ?? '?'} rows=${rows} split=${split ? 'present' : 'absent'}`, { to: 'debug' })
150 } catch {}
151}
152
153// A transcript file: read whole, else its last TAIL_BYTES through `tail`; undefined when neither works.
154async function readTranscript($: EngineInterface, path: string): Promise<string | undefined> {
155 const whole = await readRaw($, path)
156 if (whole !== undefined) return whole
157 try {
158 const { exitCode, stdout } = await $.process.run(['tail', '-c', String(TAIL_BYTES), path])
159 return exitCode === 0 ? stdout : undefined
160 } catch {
161 return undefined
162 }
163}
164
165/** The split from the transcript file: read whole, else its last TAIL_BYTES through `tail`. */
166export async function transcriptSplit($: EngineInterface, path: string | undefined): Promise<TranscriptSplit | undefined> {
167 if (!path) return undefined
168 const text = await readTranscript($, path)
169 return text === undefined ? undefined : splitFromTranscript(text)
170}
171
172// No main usage seen yet, or a main write with no split is pending (LLD 3.4, rev 5): the transcript's
173// last write seeds main, from the time of its row, else the pending write's time, else now (seedMain,
174// which never lowers a live entry). The guard skips the read otherwise, so the transcript is read once
175// per Stop with a pending write and never per response: a response's own row may not be written yet
176// when its usage arrives, while at the Stop it is. A transcript with no split forgets the pending
177// write, so the next Stop does not read again.
178async function seedFromTranscript($: EngineInterface, path: string | undefined, now: number) {
179 if (!needsMainSeed(ledger)) return
180 const r = await transcriptSplit($, path)
181 if (r) return seedMain(ledger, r.split, r.at ?? ledger.pendingMain?.at ?? now, now, life())
182 if (ledger.pendingMain) $.ui.log('keepalive: main write without split, transcript has none', { to: 'debug' })
183 dropPending(ledger)
184}
185
186const anyRunning = () => TRACKS.some(([n]) => tracks[n].timer !== undefined)
187
188// The settings a track's state and status line speak for (see Track, cfg).
189const cfgOf = (t: Track): TrackSettings => (t.timer !== undefined && t.cfg) || settings[t.key]
190
191function stopTrack(t: Track) {
192 // Bumping the generation also stops a ping already in flight from rearming
193 t.generation++
194 t.timer?.cancel()
195 t.timer = undefined
196 t.nextPingAt = null
197}
198
199function stopAll(line?: string) {
200 for (const [n] of TRACKS) {
201 const t = tracks[n]
202 const was = t.timer !== undefined
203 stopTrack(t)
204 if (line && was) t.part = line
205 }
206}
207
208// The state other plugins read (ai-dlc-lens, read-only): $.state { plugin: 'ai-dlc', key: 'keepalive' },
209// its contract in ../../types/index.d.ts (plugin.json `types`). Written each time the status lines
210// change, whether or not the status line is shown, so a reader sees what the lines say.
211const STATE = { plugin: 'ai-dlc', key: 'keepalive' } as const
212// Writes go out one after another, in the order the snapshots were taken.
213let publishing: Promise<unknown> = Promise.resolve()
214
215// The line a track shows at `now`: the ledger's cache part of the track's lifetime (main, then
216// subagents), then the keep-alive part (LLD 2.2). Built at each render and publish, never stored, so
217// it never carries a cache part older than the event that shows it.
218const lineFor = (t: Track, now: number) => lineOf(t.name, cachePart(cacheView(ledger, t.name, now)), t.part)
219
220// The published cache entries of a track's lifetime, one per source that has one (D8); omitted when none.
221function cachesOf(n: TrackName, now: number): KeepaliveTrackState['caches'] {
222 const v = cacheView(ledger, n, now)
223 const caches = [
224 ...(v.main ? [{ source: 'main' as const, ...v.main }] : []),
225 ...(v.subagents ? [{ source: 'subagents' as const, ...v.subagents }] : []),
226 ]
227 return caches.length ? caches : undefined
228}
229
230/** The tracks as published at `now`: their status, with the next ping, the keep-warm time, the ping
231 * limit, the line as shown and, when the ledger has any, the caches of the track's lifetime. */
232function trackStates(now: number): KeepaliveTrackState[] {
233 return TRACKS.map(([n]) => {
234 const t = tracks[n]
235 const cfg = cfgOf(t)
236 const keep = t.keep ?? { ms: cfg.keepWarmFor }
237 const caches = cachesOf(n, now)
238 return {
239 name: n,
240 running: t.timer !== undefined,
241 armedAt: t.armedTime,
242 warmUntil: t.warmUntil,
243 nextPingAt: t.timer !== undefined ? t.nextPingAt : null,
244 pingEvery: cfg.pingEvery,
245 keepWarmMs: keep.ms,
246 pings: t.pings,
247 cap: pingCap(cfg.maxPings, cfg.pingEvery, keep),
248 line: lineFor(t, now),
249 ...(caches ? { caches } : {}),
250 }
251 })
252}
253
254// Never throws and never waits: a failed write only goes to the debug log. The last catch keeps the
255// chain resolved when that log call fails too, so one failure never stops every later write.
256// `now` is the caller's clock reading, the time the snapshot's cache parts speak for.
257function publish($: EngineInterface, now: number) {
258 const snapshot = { enabled: settings.enabled, tracks: trackStates(now) }
259 publishing = publishing
260 .then(async () => {
261 const value: KeepaliveState = { ...snapshot, updatedAt: await $.clock.now(), sessionId: await $.session.id() }
262 await $.state.set(STATE, value)
263 })
264 .catch(err => $.ui.log(`keepalive: state not published (${err instanceof Error ? err.message : String(err)})`, { to: 'debug' }))
265 .catch(() => {})
266}
267
268function render($: EngineInterface, now: number) {
269 publish($, now)
270 if (!settings.enabled || !settings.status) return $.ui.status(undefined)
271 $.ui.status([...TRACKS.map(([n]) => lineFor(tracks[n], now)), ...(seedLine ? [seedLine] : [])].join('\n'))
272}
273
274async function warn($: EngineInterface, line: string) {
275 if (warned.has(line)) return
276 warned.add(line)
277 $.ui.log(`keepalive: ${line}`)
278 await log($, `keepalive: ${line}`)
279}
280
281// Where a person sets a trust-gated setting instead: the sources a cloned repository cannot write.
282const GATED_INSTEAD = `set them in the env of .claude/settings.local.json instead (a keep-warm time also with /${COMMAND})`
283
284// The turn cache: what the read at a new turn (load with atTurn) keeps between turns, so a turn reads
285// only what changed. A Stop and the command never use it.
286// - files: each settings file's text with its size and modified time ('missing' when it does not
287// stat); a file is read again only when that changes. Two edits in one modified-time tick that
288// leave the size the same go unseen until the next full read.
289// - cwd, root, target: git's answers (the repository root, and what trust is keyed on) for the
290// session's working directory. Dropped when that directory changes, at session.end and at every
291// full read (forgetGit), so a turn never runs git while the directory stays, and is never behind
292// the last full read, which runs git itself. Not watched otherwise: a git init or a moved worktree
293// has no cheaper signal than the rev-parse it would replace.
294// - trust: Claude Code's trust answer, kept with the size and modified time of ~/.claude.json, which
295// is stat-ed at every turn that needs the answer and read again when it changed. Trust fails
296// closed and gates the committed sources, so a trust withdrawn in that file must count at the next
297// turn. Still asked only when a trust-gated setting differs from its default (trustGate).
298type Seen = { sig: string; text: string | undefined }
299type TrustAnswer = { trusted: boolean; why: string }
300let turnCache: { files: Map<string, Seen>; cwd?: string; root?: string; target?: TrustTarget; trust?: { sig: string; answer: TrustAnswer } } = { files: new Map() }
301
302const forgetGit = () => { turnCache = { files: turnCache.files } }
303
304// A file's size and modified time, 'missing' when it does not stat (missing, or unreadable).
305async function sigOf($: EngineInterface, path: string): Promise<string> {
306 try {
307 const s = await $.fs.stat(path)
308 return `${s.kind}:${s.size}:${s.mtimeMs}`
309 } catch {
310 return 'missing'
311 }
312}
313
314// A file that stats as a file but whose read fails (a lock or a transient error while an editor saves
315// it) is not absent: the read at a turn counts it as a failure, so the turn keeps the settings last read.
316const unreadable = (path: string) => new Error(`${path} exists but could not be read`)
317
318// readRaw, but only when the file's size or modified time changed since the turn cache last read it.
319// Throws, caching nothing, when a file that stats as a file cannot be read: its next turn reads it
320// again. A path that stats as something else (a folder) reads as absent, as at a full read.
321async function cachedRead($: EngineInterface, path: string): Promise<string | undefined> {
322 const sig = await sigOf($, path)
323 const was = turnCache.files.get(path)
324 if (was && was.sig === sig) return was.text
325 const text = await readRaw($, path)
326 if (text === undefined && sig.startsWith('file:')) {
327 turnCache.files.delete(path)
328 throw unreadable(path)
329 }
330 // A file that does not stat but reads is not kept: next turn reads it again
331 if (sig === 'missing' && text !== undefined) turnCache.files.delete(path)
332 else turnCache.files.set(path, { sig, text })
333 return text
334}
335
336// The repository root for the turn read: git runs again only when the working directory changed.
337// The answer is kept only in the cache it was asked for: a full read (forgetGit) or a new working
338// directory that replaced the cache while git ran must make the next turn ask again.
339async function turnRoot($: EngineInterface): Promise<string> {
340 const cwd = (await $.session.cwd()).replace(/\\/g, '/')
341 if (turnCache.cwd !== cwd) turnCache = { files: turnCache.files, cwd }
342 if (turnCache.root !== undefined) return turnCache.root
343 const cache = turnCache
344 const root = await repoRoot($)
345 if (turnCache === cache) cache.root = root
346 return root
347}
348
349// The trust answer for the turn read, as a TrustCache whose answer trustGate asks for only when a
350// gated setting differs from its default (io.ts): reused while the trust target and ~/.claude.json
351// are unchanged, else worked out again as folderTrust does. A ~/.claude.json that stats as a file but
352// cannot be read rejects the answer, caching nothing: the turn counts it as a failure and keeps the
353// settings last read (load), and the next turn reads the file again.
354function turnTrust($: EngineInterface): TrustCache {
355 let result: Promise<TrustAnswer> | undefined
356 const answer = async (): Promise<TrustAnswer> => {
357 const home = await homeDir($)
358 const path = home ? `${home}/.claude.json` : undefined
359 let target = turnCache.target
360 if (target === undefined) {
361 const cache = turnCache
362 target = await trustTarget($)
363 // As in turnRoot: kept only in the cache it was asked for
364 if (turnCache === cache) cache.target = target
365 }
366 const sig = path ? await sigOf($, path) : 'missing'
367 if (turnCache.trust?.sig === sig) return turnCache.trust.answer
368 const text = path ? await readRaw($, path) : undefined
369 if (path && text === undefined && sig.startsWith('file:')) {
370 turnCache.trust = undefined
371 throw unreadable(path)
372 }
373 const fresh = trustOf(text, target)
374 // Kept only when the file stat-ed: a file that cannot be stat-ed is asked again at every turn
375 turnCache.trust = sig === 'missing' ? undefined : { sig, answer: fresh }
376 return fresh
377 }
378 // trustGate (io.ts) asks `cache.result ??= folderTrust($)`. This getter always answers a promise,
379 // so at a turn folderTrust is never called and the cached answer above is the one used; the test
380 // "trustGate asks the turn cache" fails if trustGate stops going through cache.result.
381 return {
382 get result() { return result ??= answer() },
383 set result(v) { result = v },
384 }
385}
386
387// Reads every level and resolves each setting, highest first: `env` of .claude/settings.local.json,
388// `env` of .claude/settings.json, .ai-dlc/cache-keepalive.json, the default (settings.ts). Keep-alive
389// has no /config rows, and the process environment is not read. The two committed sources, the env
390// of .claude/settings.json and the repository file, set the trust-gated settings (settings.ts,
391// TRUST_GATED) only in a folder Claude Code trusts; elsewhere each one whose value differs from its
392// default is skipped with a warning and the next level applies (one equal to its default changes
393// nothing, so it applies quietly: settings.ts, gatedDiffering). Never throws: on an error, the
394// defaults apply.
395// `atTurn`: the read at a new turn (turn.start), so a fresh session publishes its real settings from
396// its first turn. Same levels, precedence and trust, but a settings file that cannot be parsed, or a
397// read that fails, keeps the settings last read instead (the defaults when none was read yet) and
398// says so in the debug log. Nothing is warned in the transcript: the next Stop or command reads and
399// warns as above. It reads through the turn cache below: only what changed since the last turn.
400async function load($: EngineInterface, { atTurn = false } = {}) {
401 const problems: string[] = []
402 // The problems that mean a file was not read: its JSON, its shape, or the read itself
403 const failures: string[] = []
404 const fail = (line: string) => { problems.push(line); failures.push(line) }
405 const levels: Level[] = []
406 // A full read (a Stop, the command) runs git and reads every file itself, and drops the cached git
407 // and trust answers, so the next turn asks git again: a turn's answer is never older than the last
408 // full read. The cached files need no dropping: their size and modified time say when they changed.
409 if (!atTurn) forgetGit()
410 const trust: TrustCache = atTurn ? turnTrust($) : {}
411 const read = atTurn ? (path: string) => cachedRead($, path) : (path: string) => readRaw($, path)
412 try {
413 const root = atTurn ? await turnRoot($) : await repoRoot($)
414 for (const where of ['.claude/settings.local.json', '.claude/settings.json']) {
415 const { env, problem } = settingsEnv(await read(`${root}/${where}`))
416 if (problem) fail(`${where} ${problem}`)
417 if (!env) continue
418 const values = where === '.claude/settings.json' ? await trustGate($, env, gatedDiffering(env, 'env'), `the env of ${where}`, GATED_INSTEAD, problems, trust) : env
419 levels.push({ where, by: 'env', values })
420 }
421 const repo = repoConfig(await read(`${root}/${REPO_FILE}`))
422 if (repo.problem) fail(`${REPO_FILE} ${repo.problem}`)
423 if (repo.values) levels.push({ where: REPO_FILE, by: 'json', values: await trustGate($, repo.values, gatedDiffering(repo.values, 'json'), REPO_FILE, GATED_INSTEAD, problems, trust) })
424 } catch (err) {
425 fail(atTurn ? `settings files unreadable (${String(err)})` : `settings files unreadable (${String(err)}); using the defaults`)
426 }
427 if (atTurn && failures.length) {
428 for (const line of failures) $.ui.log(`keepalive: settings not re-read at the new turn: ${line}; keeping the settings last read`, { to: 'debug' })
429 return
430 }
431 const r = resolveSettings(levels)
432 settings = r.settings
433 sources = r.sources
434 // At a turn, the other problems (an invalid value, a setting skipped in an untrusted folder) are left
435 // to the next Stop or command, which warns about each once a session
436 if (atTurn) return
437 for (const line of [...problems, ...r.warnings]) await warn($, line)
438}
439
440// The keep-warm time in force and where it comes from; `override` when /ai-dlc:cache-keepalive set it.
441type Keep = { ms: number; from: string; override?: boolean }
442
443async function sessionOverrides($: EngineInterface, n: TrackName): Promise<[string, number][]> {
444 const v = await $.store.get(sessionKey(n))
445 return Array.isArray(v) ? v.filter((x): x is [string, number] => Array.isArray(x) && typeof x[0] === 'string' && typeof x[1] === 'number') : []
446}
447
448// The keep-warm time of a track: this session's override, the machine's, then the track's setting.
449// An override shorter than minDuration (set before minDuration was raised) is ignored with a warning.
450// `session: false` leaves this session's override out and warns about nothing: the time a session
451// that has ended leaves in force (session.end), which its own override no longer reaches.
452async function keepWarm($: EngineInterface, key: TrackKey, { session = true } = {}): Promise<Keep> {
453 const n = TRACKS.find(([, k]) => k === key)![0]
454 if (session) {
455 const id = await $.session.id()
456 const own = (await sessionOverrides($, n)).find(([s]) => s === id)
457 if (own) {
458 if (own[1] >= settings.minDuration) return { ms: own[1], from: `this session, /${COMMAND}`, override: true }
459 await warn($, `the /${COMMAND} override of the ${n} track for this session (${formatDuration(own[1])}) is shorter than minDuration (${formatDuration(settings.minDuration)}); ignored`)
460 }
461 }
462 const machine = await $.store.get(machineKey(n))
463 if (typeof machine === 'number' && machine > 0) {
464 if (machine >= settings.minDuration) return { ms: machine, from: `this machine, /${COMMAND}`, override: true }
465 if (session) await warn($, `the /${COMMAND} override of the ${n} track for this machine (${formatDuration(machine)}) is shorter than minDuration (${formatDuration(settings.minDuration)}); ignored`)
466 }
467 return { ms: settings[key].keepWarmFor, from: sources[`${key}.keepWarmFor`] ?? 'default' }
468}
469
470// Re-reads the keep-warm time of every track that is not running, so the state it publishes says the
471// time now in force: the one the track's next arming would take. Called where a track is reset (a
472// new turn, the session ending), where the overrides change (/ai-dlc:cache-keepalive), after every
473// full read of the settings (a Stop, the command, the seed line), and wherever a track stops, on its
474// own or not (end, the spend limit, work done, overage, a handoff), so an override set or cleared
475// while it ran counts the moment it stops. Never here for a running track: its tick reads the time
476// again before each ping, and a change made while it runs takes effect at that ping (README), so
477// until then the time it armed with is the one in force.
478// Not in stopTrack: arm() calls it before every arming. A track armed while this waits on the store
479// is left alone (its generation moved on). Never throws: a store that cannot be read leaves the
480// track's setting in force, as keepWarm's last level, and says so in the debug log.
481async function refreshKeep($: EngineInterface, opts: { session?: boolean } = {}) {
482 for (const [n, key] of TRACKS) {
483 const t = tracks[n]
484 if (t.timer !== undefined) continue
485 const g = t.generation
486 let keep: Keep | undefined
487 try {
488 keep = await keepWarm($, key, opts)
489 } catch (err) {
490 $.ui.log(`keepalive ${n}: keep-warm time not re-read (${err instanceof Error ? err.message : String(err)}); publishing the setting`, { to: 'debug' })
491 }
492 if (t.generation === g && t.timer === undefined) t.keep = keep
493 }
494}
495
496// For a stop that cannot wait on the store (stopForHandoff answers at once): re-reads in the
497// background, then publishes, unless a track was stopped or armed meanwhile (a new turn or the
498// session ending), since that path re-reads and publishes for itself.
499async function republishKeep($: EngineInterface) {
500 const gens = TRACKS.map(([n]) => tracks[n].generation)
501 await refreshKeep($)
502 if (TRACKS.every(([n], i) => tracks[n].generation === gens[i])) publish($, await $.clock.now())
503}
504
505// Sets (ms) or clears (undefined) the override of each track named, in the scope given.
506async function setKeepWarm($: EngineInterface, scope: Scope, ms: number | undefined, which: readonly TrackName[]) {
507 for (const n of which) {
508 if (scope === 'machine') {
509 await (ms === undefined ? $.store.delete(machineKey(n)) : $.store.set(machineKey(n), ms))
510 continue
511 }
512 const id = await $.session.id()
513 const rest = (await sessionOverrides($, n)).filter(([s]) => s !== id)
514 await $.store.set(sessionKey(n), ms === undefined ? rest : [...rest, [id, ms]].slice(-settings.sessionOverridesMax))
515 }
516}
517
518// "keep warm 2h on both tracks (this session, ...)" when one override sets both, else each track's own.
519async function keepLines($: EngineInterface) {
520 const [a, b] = [await keepWarm($, 'oneHour'), await keepWarm($, 'fiveMinute')]
521 if (a.ms === b.ms && a.from === b.from) return `keep warm ${formatDuration(a.ms)} on both tracks (${a.from})`
522 return `keep warm: 1h track ${formatDuration(a.ms)} (${a.from}), 5m track ${formatDuration(b.ms)} (${b.from})`
523}
524
525const inOverage = (limits: { percentUsed: number }[]) => limits.some(l => l.percentUsed >= settings.overagePercent)
526
527// Input-token equivalents a call cost, reads priced for the session's model
528type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
529const cost = (u: Usage, r: number) => r * u.cache_read_input_tokens + u.input_tokens + settings.writeMult * u.cache_creation_input_tokens + settings.outputMult * u.output_tokens
530
531// A ping of the main conversation at `at`. Its usage goes into the ledger: as a landed ping of the
532// track `ttl` when it hit the cache (observePing), else as any response (observe), which is also how
533// the command's ping, which keeps no track, is recorded.
534// `at` is the ping's start, taken before the fork, so a warm-until recorded from it errs early.
535async function ping($: EngineInterface, at: number, ttl?: TrackName) {
536 const r = await $.model.fork({ prompt: settings.pingPrompt })
537 if (!('usage' in r)) return { ok: false, line: `no ping: ${r.reason}` }
538 const u = r.usage
539 context = u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens
540 const hit = u.cache_read_input_tokens / Math.max(context, 1)
541 spent += cost(u, readMult(settings.readPrice, await $.session.model()))
542 const of = Number.isFinite(budget) ? `of ${Math.round(budget)}` : '(no spend limit)'
543 const ok = hit > settings.hitThreshold
544 if (ok && ttl) observePing(ledger, { at, ttl, usage: u }, life())
545 else observe(ledger, { at, usage: u }, life())
546 return { ok, line: `cache read ${u.cache_read_input_tokens}/${context} (${Math.round(hit * 100)}%), spent ${Math.round(spent)} ${of} token-equivalents` }
547}
548
549// Compacting pays when its own cost plus re-caching the summary is below re-caching the whole context
550async function compactPays($: EngineInterface) {
551 const perToken = (await $.store.get(COST_KEY) as number | undefined) ?? settings.compactCostGuess
552 const ratio = (await $.store.get(RATIO_KEY) as number | undefined) ?? settings.summaryRatioGuess
553 return perToken + settings.writeMult * ratio < settings.writeMult
554}
555
556// Compaction only appends to the transcript, and its boundary row names the last message before it.
557// The mod API exposes no message UUIDs, so the command reads that row, picked by when this compaction began.
558// --resume-session-at is a hidden CLI flag; --fork-session leaves the compacted session as it is.
559async function restoreCommand($: EngineInterface, startedAt: string) {
560 const id = await $.session.id()
561 const quote = (s: string) => `'${s.replaceAll("'", `'\\''`)}'`
562 const at = `jq -r 'select(.subtype == "compact_boundary" and .timestamp >= "${startedAt}") | .logicalParentUuid' `
563 + `"\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/projects/*/${id}.jsonl | head -1`
564 return `cd ${quote(await $.session.cwd())} && claude --resume ${id} --fork-session --resume-session-at "$(${at})"`
565}
566
567async function compact($: EngineInterface) {
568 compacting = true
569 try {
570 const startedAt = new Date(await $.clock.now()).toISOString()
571 const r = await $.session.compact({ instructions: settings.compactInstructions })
572 if (r.skip !== undefined) return `compaction skipped: ${r.skip}`
573 $.ui.log(`keepalive: compacted. To reopen the conversation as it was before, in a new session, run: ${await restoreCommand($, startedAt)}`)
574 const before = r.tokensBefore ?? context
575 if (r.usage && before) {
576 await $.store.set(COST_KEY, cost(r.usage, readMult(settings.readPrice, await $.session.model())) / before)
577 if (r.tokensAfter) await $.store.set(RATIO_KEY, r.tokensAfter / before)
578 }
579 const read = r.usage ? `${r.usage.cache_read_input_tokens} read from cache, ${r.usage.output_tokens} output` : 'no usage reported'
580 return `compacted ${before} → ${r.tokensAfter ?? '?'} tokens (${read})`
581 } catch (err) {
582 return `compaction refused: ${err instanceof Error ? err.message : String(err)}`
583 } finally {
584 compacting = false
585 }
586}
587
588/** The pings a keep-warm time allows on a track: the n-th ping is due n × pingEvery after the turn
589 * ended and goes out when that is within the keep-warm time, the last one included (see arm). */
590export const pingsWithin = (keepMs: number, pingEvery: number) => Math.floor(keepMs / pingEvery)
591
592/** A track's ping limit: maxPings, or, when /ai-dlc:cache-keepalive set the keep-warm time, at least the
593 * pings that time allows, so the command always keeps warm for the time it says. */
594export const pingCap = (maxPings: number, pingEvery: number, keep: { ms: number; override?: boolean }) =>
595 keep.override ? Math.max(maxPings, pingsWithin(keep.ms, pingEvery)) : maxPings
596
597// Written right after arm(), so cfgOf gives the settings the armed timer took, and nextPingAt is the
598// first tick: until a ping lands, the counter says when the first one is due, so 0 reads "not yet"
599// (spec D6, LLD 3.5)
600const pingsOf = (t: Track, keep: Keep) => {
601 const cfg = cfgOf(t)
602 const first = t.pings === 0 && t.nextPingAt !== null ? `, first ping at ${hhmm(t.nextPingAt)}` : ''
603 return `ping ${t.pings}/${pingCap(cfg.maxPings, cfg.pingEvery, keep)}${first}`
604}
605const runningText = (t: Track, keep: Keep, lead = '') =>
606 `${lead}warm until ${hhmm(t.warmUntil)}, ${pingsOf(t, keep)}, keep warm ${formatDuration(keep.ms)}, stop by ${hhmm(t.armedTime + keep.ms)}`
607
608// A track stopping on its own (expired, kept warm, missed, its ping limit, an error): its keep-warm
609// time is re-read before the state is published (refreshKeep). Never throws.
610async function end($: EngineInterface, t: Track, text: string) {
611 stopTrack(t)
612 t.part = text
613 await refreshKeep($)
614 render($, await $.clock.now())
615}
616
617// One track's pings: each tick checks, in order, a timer that fired after the cache expired, the
618// track's keep-warm time, then pings; a miss stops the track, a hit moves "warm until" on, and the
619// track's ping limit or the shared spend limit stops it after that ping.
620// `from` is the time the timer is set, so the published state can say when the next ping is due.
621function arm($: EngineInterface, t: Track, from: number) {
622 stopTrack(t)
623 const armedAt = t.generation
624 const cfg = settings[t.key]
625 t.cfg = cfg
626 t.nextPingAt = from + cfg.pingEvery
627 t.timer = $.clock.after(cfg.pingEvery, async () => {
628 // A timer stopped since it was set must not touch the track: another may be armed by now
629 if (t.generation !== armedAt) return
630 t.timer = undefined
631 try {
632 // A timer that fires late (the machine slept) finds the cache gone, and a ping would only re-write it
633 const now = await $.clock.now()
634 if (t.generation !== armedAt) return
635 if (now >= t.warmUntil) { t.pingWarmUntil = 0; return await end($, t, 'expired, stopped') }
636 const keep = await keepWarm($, t.key)
637 if (t.generation !== armedAt) return
638 t.keep = keep
639 // The n-th ping is due n × pingEvery after the turn ended, and goes out when that is within the
640 // keep-warm time, the last one included (20 on the 5m track's hour; human decision D1). Counted
641 // on that schedule, not the clock, so the seconds each ping takes never cost the last one.
642 // A duration spent before the first ping needed none: the cache's own lifetime covered it.
643 if ((t.pings + 1) * cfg.pingEvery > keep.ms) return await end($, t, `${t.pings ? 'kept warm' : 'no ping needed for'} ${formatDuration(keep.ms)}, stopped, warm until ${hhmm(t.warmUntil)}`)
644
645 const { ok, line } = await ping($, now, t.name)
646 if (t.generation !== armedAt) return
647 $.ui.log(`keepalive ${t.name}: ${line}`, { to: 'debug' })
648 if (!ok) { t.pingWarmUntil = 0; return await end($, t, 'missed, stopped') }
649 t.warmUntil = now + cfg.ttl
650 t.pingWarmUntil = t.warmUntil
651 t.pings++
652 // Awaited, so a failure in it lands in the catch below
653 if (spent >= budget) return await spendLimit($, t)
654 // The ping limit stops the track only when the keep-warm time would allow another ping; when it
655 // does not, the next tick ends the track as "kept warm", as without a limit.
656 const cap = pingCap(cfg.maxPings, cfg.pingEvery, keep)
657 if (t.pings >= cap && (t.pings + 1) * cfg.pingEvery <= keep.ms) return await end($, t, `ping limit ${cap} reached, stopped, warm until ${hhmm(t.warmUntil)}`)
658 const at = await $.clock.now()
659 if (t.generation !== armedAt) return
660 // Armed before the line and the render, so both speak for the settings the new timer took
661 // (cfgOf) and the state shows the next ping
662 arm($, t, at)
663 t.part = runningText(t, keep)
664 render($, at)
665 } catch (err) {
666 // Logged even when the track was stopped meanwhile (the spend limit stops it before it fails)
667 $.ui.log(`keepalive ${t.name}: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
668 if (t.generation !== armedAt) return
669 t.pingWarmUntil = 0
670 await end($, t, 'error, stopped')
671 }
672 })
673}
674
675// The spend limit is shared: reaching it stops every track. compactOnCap compacts instead, when it pays.
676async function spendLimit($: EngineInterface, t: Track) {
677 for (const [n] of TRACKS) {
678 const other = tracks[n]
679 if (other !== t && other.timer === undefined) continue // off, or already stopped for its own reason
680 stopTrack(other)
681 other.part = `spend limit reached, stopped, warm until ${hhmm(other.warmUntil)}`
682 }
683 await refreshKeep($)
684 // The tracks are already stopped, so a store that cannot be read stops here too, and says so
685 let pays = false
686 try {
687 pays = settings.compactOnCap && await compactPays($)
688 } catch (err) {
689 $.ui.log(`keepalive: could not tell whether compacting pays (${err instanceof Error ? err.message : String(err)}); not compacting`, { to: 'debug' })
690 }
691 if (!pays) return render($, await $.clock.now())
692 t.part = 'compacting'
693 render($, await $.clock.now())
694 const result = await compact($)
695 $.ui.log(`keepalive: ${result}`, { to: 'debug' })
696 t.part = result.split(' (')[0]!
697 render($, await $.clock.now())
698}
699
700/** Stops every keep-alive timer before an automatic handoff's /clear, so no ping of the old session
701 * runs into the new one, and says so on the status line. Answers whether a timer was running. */
702export function stopForHandoff($: EngineInterface): boolean {
703 const was = anyRunning()
704 stopAll('handed off, stopped')
705 ledger = emptyLedger()
706 for (const [n] of TRACKS) tracks[n].pingWarmUntil = 0
707 if (was) {
708 // Synchronous, as the caller's /clear follows at once: the ledger was just emptied, so no line has
709 // a cache part and the time render is given changes nothing in what it shows
710 render($, 0)
711 void republishKeep($)
712 }
713 return was
714}
715
716/** The seed line's text for the tokens the seeded session's first request read from the cache. */
717export const seedLineOf = (read: number) => read > 0 ? `keepalive seed: cache hit, ${k(read)} read` : 'keepalive seed: cache miss'
718
719/** After an automatic handoff, shows on the status line whether the seeded session's first request
720 * read from the prompt cache, `keepalive seed: cache hit, 30k read` or `keepalive seed: cache miss`,
721 * under the track lines until the next turn starts. Hidden, like the tracks, when keep-alive or its
722 * status is off. register.tsx calls it from the seed turn, beside its panel line and log line. */
723export async function showSeedCache($: EngineInterface, cache: { read: number }): Promise<void> {
724 seedLine = seedLineOf(cache.read)
725 await load($)
726 // A full read: publish the keep-warm time it leaves in force (no track runs during a turn)
727 await refreshKeep($)
728 publish($, await $.clock.now())
729 if (!settings.enabled || !settings.status) return
730 // No track runs during a turn (turn.start stops them), so the line stands alone until the turn's
731 // Stop arms one; render() then shows it under the track lines.
732 $.ui.status(seedLine)
733}
734
735export function registerKeepalive(on: Parameters<Register>[0], deps: KeepaliveDeps) {
736
737 on('turn.start', async ($, e, next) => {
738 stopAll()
739 seedLine = undefined
740 // The turn resets the keep-alive part only: the ledger, and so the cache part, is kept (AC-17.2.3)
741 for (const [n] of TRACKS) tracks[n].part = reasonText('turnRunning')
742 // The settings and the overrides are read again at every new turn, and only then: a fresh session
743 // publishes its real settings from its first turn, and a machine override another session changed
744 // is published at this session's next turn. Nothing is read while the session is idle. A file is
745 // read again only when it changed, and git only when the working directory did (the turn cache).
746 try {
747 await load($, { atTurn: true })
748 } catch (err) {
749 $.ui.log(`keepalive: settings not re-read at the new turn (${err instanceof Error ? err.message : String(err)}); keeping the settings last read`, { to: 'debug' })
750 }
751 // A session's override outlives its turns (it is kept per session in the store), so the time
752 // re-read here is still that override until it is cleared or the session ends
753 await refreshKeep($)
754 publish($, await $.clock.now())
755 if (!compacting) $.ui.status(undefined)
756 return next(e)
757 })
758
759 // Arm only when background work will wake the session later. A Stop with none is the work being
760 // done: nothing arms, and each track shows its cache and why it is off (AC-17.2.2).
761 on('classic.Stop', async ($, e, next) => {
762 try {
763 if (compacting || deps.handingOff()) return next(e)
764 const tasks = e.background_tasks?.length ?? 0
765 const now = await $.clock.now()
766 if (!tasks) {
767 await seedFromTranscript($, e.transcript_path, now)
768 const running = new Set(TRACKS.filter(([n]) => tracks[n].timer !== undefined).map(([n]) => n))
769 if (running.size) stopAll('work done, stopped')
770 for (const [n, key] of TRACKS) {
771 if (running.has(n)) continue
772 tracks[n].part = !settings.enabled || !settings[key].enabled ? reasonText('disabled')
773 : isEmpty(ledger, n) ? reasonText({ noWrite: n })
774 : reasonText('noTasks')
775 }
776 await refreshKeep($)
777 render($, now)
778 return next(e)
779 }
780 await load($)
781 // After the full read, so a seeded entry lasts the cache lifetime now in force; before every
782 // branch, so each line below has its cache part
783 await seedFromTranscript($, e.transcript_path, now)
784 // A full read can change the keep-warm time of a track that will not arm below: each branch
785 // re-reads it once the tracks are stopped (refreshKeep skips a running track)
786 if (!settings.enabled) {
787 stopAll()
788 for (const [n] of TRACKS) tracks[n].part = reasonText('disabled')
789 await refreshKeep($)
790 publish($, now)
791 $.ui.status(undefined)
792 return next(e)
793 }
794 const { context: ctx, rateLimits } = await $.session.usage()
795 if (inOverage(rateLimits)) {
796 stopAll()
797 for (const [n] of TRACKS) tracks[n].part = reasonText('overage')
798 await refreshKeep($)
799 render($, now)
800 return next(e)
801 }
802 spent = 0
803 budget = settings.budgetMultiplier > 0 ? settings.budgetMultiplier * (ctx.tokens ?? 0) : Infinity
804 for (const [n, key] of TRACKS) {
805 const t = tracks[n]
806 stopTrack(t)
807 if (!settings[key].enabled) { t.part = reasonText('disabled'); continue }
808 if (isEmpty(ledger)) { t.part = reasonText('noSplit'); continue }
809 // A track arms from a warm main cache of its lifetime, warm until that cache's own time
810 // (spec D5), or from its own last landed ping, warm until that ping's time plus the ttl, when
811 // that is later and still ahead (LLD 3.4); the cache part stays the ledger's.
812 const fromPing = t.pingWarmUntil > now ? t.pingWarmUntil : undefined
813 const fromLedger = armUntil(ledger, n, now)
814 const warm = fromLedger === undefined ? fromPing : fromPing === undefined ? fromLedger : Math.max(fromLedger, fromPing)
815 if (warm === undefined) {
816 // Not armed: no write of this lifetime by any source, or every one of them has expired
817 t.part = reasonText(isEmpty(ledger, n) ? { noWrite: n } : 'expired')
818 t.warmUntil = 0
819 continue
820 }
821 t.armedTime = now
822 t.pings = 0
823 t.warmUntil = warm
824 t.keep = await keepWarm($, key)
825 arm($, t, now)
826 t.part = runningText(t, t.keep, `armed, ${tasks} task(s), `)
827 }
828 // The tracks that did not arm (off, disabled, no split): the time the full read left in force
829 await refreshKeep($)
830 render($, now)
831 } catch (err) {
832 await log($, `keepalive: Stop error ${String(err)}`)
833 }
834 return next(e)
835 })
836
837 // ITEM-034.c (LLD 3.7, rev 6): log only, so the live check can see whether a finished subagent's
838 // transcript carries the split. No ledger change; T17.3 adds markAgent and the fallback here.
839 on('classic.SubagentStop', async ($, e, next) => {
840 try {
841 await logSubagentTranscript($, e)
842 } catch {}
843 return next(e)
844 })
845
846 // Other sessions can push the account into overage during the wait
847 on('session.measure', async ($, e, next) => {
848 if (anyRunning() && e.changed.includes('rateLimits') && inOverage(e.rateLimits)) {
849 stopAll('overage, stopped')
850 await refreshKeep($)
851 render($, await $.clock.now())
852 }
853 return next(e)
854 })
855
856 // The session ended (exit, /clear, resume elsewhere): its timers must not ping the next one, and
857 // nothing of its spend, its context or its warnings carries over. Module variables survive /clear.
858 on('session.end', async ($, e, next) => {
859 stopAll()
860 ledger = emptyLedger()
861 seedLine = undefined
862 spent = 0
863 budget = 0
864 context = 0
865 compacting = false
866 // A new session reads every settings file, and asks git and trust, again at its first turn
867 turnCache = { files: new Map() }
868 // Each session warns about a setting problem once
869 for (const [n] of TRACKS) { tracks[n].part = reasonText('sessionEnded'); tracks[n].pingWarmUntil = 0 }
870 // The ended session's own override reaches no other session: the machine's override or the
871 // setting is what stays in force
872 await refreshKeep($, { session: false })
873 warned.clear()
874 publish($, await $.clock.now())
875 $.ui.status(undefined)
876 return next(e)
877 })
878
879 // /ai-dlc:cache-keepalive pings now (upstream). With a duration it sets the keep-warm time of both
880 // tracks, or of the one --track names, and with clear it removes that override, in the default scope
881 // or the one --session / --machine names.
882 on('command.run', { command: COMMAND }, async ($, e) => {
883 await load($)
884 // A full read: publish the keep-warm time, pingEvery and limit it leaves in force
885 await refreshKeep($)
886 publish($, await $.clock.now())
887 const args = parseCommandArgs(e.args ?? '', settings.scope, settings.minDuration)
888 if (args.kind === 'usage') {
889 const every = TRACKS.map(([n, key]) => `the ${n} track every ${formatDuration(settings[key].pingEvery)}`).join(' and ')
890 return { text: `keepalive: ${args.problem}. Usage: /${COMMAND} [<n>m|<n>h|<n>d|clear] [--track 1h|5m|both] [--session|--machine]. A duration sets the keep-warm time of both tracks, or of the one --track names. Pings come on ${every}, so a duration no longer than a track's interval sends no ping on it: the cache's own lifetime covers it.` }
891 }
892 if (args.kind !== 'ping') {
893 await setKeepWarm($, args.scope, args.kind === 'set' ? args.ms : undefined, args.tracks)
894 await refreshKeep($)
895 publish($, await $.clock.now())
896 const where = args.scope === 'machine' ? 'every session on this machine' : 'this session'
897 const which = args.tracks.length === TRACK_NAMES.length ? 'both tracks' : `the ${args.tracks[0]} track`
898 const what = args.kind === 'set' ? `set to ${formatDuration(args.ms)} on ${which} for ${where}` : `override cleared on ${which} for ${where}`
899 // A running track keeps the time it armed with until its next ping, which reads it again
900 const running = args.tracks.filter(n => tracks[n].timer !== undefined)
901 const later = running.length === 0 ? '' : running.length === 1
902 ? `; the running ${running[0]} track takes it at its next ping`
903 : '; the running tracks take it at their next ping'
904 return { text: `keepalive: keep-warm ${what}; now ${await keepLines($)}${later}` }
905 }
906 if (!settings.enabled) return { text: `keepalive: off (enabled=false, ${sources.enabled ?? 'default'}); nothing pinged` }
907 const { context: ctx } = await $.session.usage()
908 if (!budget) budget = settings.budgetMultiplier > 0 ? settings.budgetMultiplier * (ctx.tokens ?? 0) : Infinity
909 const { ok, line } = await ping($, await $.clock.now())
910 return { text: `${ok ? 'HIT' : 'MISS'}: ${line}; ${await keepLines($)}` }
911 })
912}
913
914/** The settings in force and where each came from. */
915export const current = () => ({ settings, sources })
916hooks/auto-handoff/io.ts 113 lines1// The module's file, git, trust and log helpers, shared by register.tsx and ../keepalive/keepalive.ts.
2// The engine is passed in as `$` and only ever called as $.noun.event(...), which is what `claude
3// plugin validate` follows. HOME and USERPROFILE are the only environment variables read here.
4import type { EngineInterface } from 'claude-code'
5import { BRIEF_DIR, keysWithValues, trustOf, trustRoot, withoutKeys } from './config.ts'
6import type { TrustTarget } from './config.ts'
7
8// Windows sets USERPROFILE, not HOME. With HOME unset, every path built on it began with
9// "undefined/", which $.fs resolves under the session's working directory: briefs, templates
10// and pages landed inside the user's project.
11export async function homeDir($: EngineInterface): Promise<string | undefined> {
12 return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
13}
14
15export async function log($: EngineInterface, line: string) {
16 try {
17 const path = `${await homeDir($)}/${BRIEF_DIR}/auto-handoff.log`
18 const stamped = `${new Date().toISOString()} ${line}`
19 // Where there is a `sh`, append: an append is atomic, so concurrent sessions and the viewer
20 // server (which appends its own output here) never drop each other's lines.
21 try {
22 const { exitCode } = await $.process.run(['sh', '-c', 'mkdir -p "$(dirname "$2")" && printf "%s\\n" "$1" >> "$2"', 'sh', stamped, path])
23 if (exitCode === 0) return
24 } catch {}
25 // Windows has no `sh`. $.fs has no append, so the log is read and rewritten: two lines logged
26 // at the same instant can lose one. It keeps the last ~500 KB, cut at a line, so a read never
27 // hits $.fs's 4 MiB cap.
28 const old = await $.fs.read(path).catch(() => '')
29 const kept = typeof old !== 'string' ? '' : old.length > 1_000_000 ? old.slice(old.indexOf('\n', old.length - 500_000) + 1) : old
30 await $.fs.write(path, `${kept}${stamped}\n`)
31 } catch {}
32}
33
34/** Runs `fn` and logs a throw from it as `<what> error <the error>` instead of passing it on: for a
35 * step whose failure must not stop the hook that runs it, such as keep-alive's seed line. */
36export async function orLog($: EngineInterface, what: string, fn: () => Promise<unknown>): Promise<void> {
37 try {
38 await fn()
39 } catch (err) {
40 await log($, `${what} error ${String(err)}`)
41 }
42}
43
44export async function readText($: EngineInterface, path: string): Promise<string | undefined> {
45 try {
46 const text = await $.fs.read(path)
47 if (typeof text === 'string' && text.trim()) return text
48 } catch {}
49 return undefined
50}
51// A file's text as it is, blank included; undefined when it cannot be read.
52export async function readRaw($: EngineInterface, path: string): Promise<string | undefined> {
53 try {
54 const text = await $.fs.read(path)
55 return typeof text === 'string' ? text : undefined
56 } catch {
57 return undefined
58 }
59}
60
61// The repository root (git), else the session's working directory.
62export async function repoRoot($: EngineInterface): Promise<string> {
63 const cwd = (await $.session.cwd()).replace(/\\/g, '/')
64 return await git($, ['-C', cwd, 'rev-parse', '--show-toplevel']) ?? cwd
65}
66
67// Whether Claude Code trusts this folder (config.ts, trustOf). Also keep-alive's check for its own
68// repository file (../keepalive/keepalive.ts).
69export async function folderTrust($: EngineInterface): Promise<{ trusted: boolean; why: string }> {
70 const home = await homeDir($)
71 return trustOf(home ? await readRaw($, `${home}/.claude.json`) : undefined, await trustTarget($))
72}
73
74/** One load's answer from folderTrust, kept so a load that gates several sources reads ~/.claude.json once. */
75export type TrustCache = { result?: Promise<{ trusted: boolean; why: string }> }
76
77// A level read from a file a cloned repository controls (a repository file, the env of the committed
78// .claude/settings.json), without the given keys unless Claude Code trusts this folder. ~/.claude.json
79// is read only when one of the keys is set, and only its trust flag is used: nothing else from it is
80// logged or kept. The skipped keys are named, each with its value (config.ts, previewValue: long
81// text cut short), in one line added to `problems`, with `where` and `instead` (where to set them
82// instead). Fails closed. Shared by the automatic handoff and keep-alive.
83export async function trustGate($: EngineInterface, values: Record<string, unknown>, keys: readonly string[], where: string, instead: string, problems: string[], cache: TrustCache = {}): Promise<Record<string, unknown>> {
84 const gated = withoutKeys(values, keys)
85 if (!gated.ignored.length) return values
86 cache.result ??= folderTrust($)
87 const { trusted, why } = await cache.result
88 if (trusted) return values
89 problems.push(`${keysWithValues(values, gated.ignored)} in ${where} ignored: Claude Code does not trust this folder (${why}); ${instead}`)
90 return gated.values
91}
92
93// What trust is keyed on (config.ts, TrustTarget): the repository root, or in a linked worktree the
94// main checkout's root; outside a repository, the session's working directory.
95export async function trustTarget($: EngineInterface): Promise<TrustTarget> {
96 const cwd = (await $.session.cwd()).replace(/\\/g, '/')
97 const top = await git($, ['-C', cwd, 'rev-parse', '--show-toplevel'])
98 if (!top) return { start: cwd }
99 return { root: trustRoot(top, await git($, ['-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'])) }
100}
101
102// git, run through -C in the given directory. Undefined when git is missing, the directory is not
103// a repository, or the command prints nothing.
104export async function git($: EngineInterface, args: string[]): Promise<string | undefined> {
105 try {
106 const { exitCode, stdout } = await $.process.run(['git', ...args])
107 const out = stdout.trim()
108 return exitCode === 0 && out ? out : undefined
109 } catch {
110 return undefined
111 }
112}
113hooks/auto-handoff/templates.ts 18 lines1// Helpers for the two templates that shape a handoff. The templates themselves are markdown
2// files: the defaults ship in the mod's templates/ folder, and on a session's start the mod
3// copies each one to its configured path if no file is there yet. Every handoff reads the
4// copy, so editing it changes the brief. Delete it to get the current default back.
5
6/** Fills {{#name}}...{{/name}} (shown when set) and {{^name}}...{{/name}} (shown when not). */
7export function renderTemplate(template: string, flags: Record<string, boolean>): string {
8 return template
9 .replace(/\{\{([#^])(\w+)\}\}([\s\S]*?)\{\{\/\2\}\}/g, (_m, kind: string, name: string, body: string) => (kind === '#') === (Object.hasOwn(flags, name) && Boolean(flags[name])) ? body : '')
10 .replace(/\n{3,}/g, '\n\n')
11 .trim()
12}
13
14/** The "## " headings a template asks for, as Haiku should write them. */
15export function sectionHeadings(template: string): string[] {
16 return [...template.matchAll(/^## (.+)$/gm)].map(m => `## ${m[1]!.trim()}`)
17}
18hooks/keepalive/duration.ts 82 lines1// Durations and the command's arguments. Added to the cache-keepalive mod's code while it was vendored (see
2// ../ATTRIBUTION.md, "cache-keepalive"). Engine-free, so the tests and settings.ts read it directly.
3// The defaults live in settings.ts alone (SETTINGS): no duration default is repeated here.
4
5export const MINUTE = 60_000
6const UNIT_MS: Record<string, number> = { m: MINUTE, h: 60 * MINUTE, d: 24 * 60 * MINUTE }
7// The shortest duration that parses at all. minDuration (settings.ts) sets the shortest one accepted.
8export const ABSOLUTE_MIN = 1_000
9
10export type Scope = 'session' | 'machine'
11
12/** `<n>m`, `<n>h` or `<n>d` (n may have a fraction: `1.5h`) in milliseconds; undefined for anything
13 * else or for less than `min` (1 minute unless given). */
14export function parseDuration(text: unknown, min = MINUTE): number | undefined {
15 if (typeof text !== 'string') return undefined
16 const m = /^\s*(\d+(?:\.\d+)?)\s*([mhd])\s*$/i.exec(text)
17 if (!m) return undefined
18 const ms = Math.round(Number(m[1]) * UNIT_MS[m[2]!.toLowerCase()]!)
19 return Number.isFinite(ms) && ms >= Math.max(min, ABSOLUTE_MIN) ? ms : undefined
20}
21
22/** A duration in the largest unit that holds it whole: 8h, 2d, 90m; a part of a minute as 0.5m. */
23export function formatDuration(ms: number): string {
24 for (const unit of ['d', 'h'] as const) if (ms > 0 && ms % UNIT_MS[unit]! === 0) return `${ms / UNIT_MS[unit]!}${unit}`
25 return ms % MINUTE === 0 ? `${ms / MINUTE}m` : `${Number((ms / MINUTE).toFixed(2))}m`
26}
27
28/** A cache lifetime, and the keep-alive track that keeps it warm. */
29export type TrackName = '1h' | '5m'
30export const TRACK_NAMES: readonly TrackName[] = ['1h', '5m']
31
32export type CommandArgs =
33 | { kind: 'ping' }
34 | { kind: 'set'; ms: number; scope: Scope; tracks: TrackName[] }
35 | { kind: 'clear'; scope: Scope; tracks: TrackName[] }
36 | { kind: 'usage'; problem: string }
37
38/** `1h`, `5m` or `both` (any case) as the tracks it names; undefined for anything else. */
39function tracksOf(word: string | undefined): TrackName[] | undefined {
40 const w = word?.toLowerCase()
41 if (w === 'both') return [...TRACK_NAMES]
42 return w === '1h' || w === '5m' ? [w] : undefined
43}
44
45/** The words after `/ai-dlc:cache-keepalive`: a duration or `clear`; `--session` / `--machine` to pick
46 * the scope for this call; and `--track 1h`, `--track 5m` or `--track both` (also `--track=5m`) to pick
47 * the track whose keep-warm time it sets or clears, both when not given. `min`: the shortest duration
48 * accepted (minDuration). A flag alone changes nothing: the call pings. */
49export function parseCommandArgs(args: string, defaultScope: Scope, min = MINUTE): CommandArgs {
50 let scope = defaultScope
51 let ms: number | undefined
52 let clear = false
53 let tracks: TrackName[] | undefined
54 const words = args.trim().split(/\s+/).filter(Boolean)
55 const badTrack = (v: string | undefined) => ({ kind: 'usage' as const, problem: v === undefined ? '--track needs 1h, 5m or both' : `"${v}" is not a track: --track takes 1h, 5m or both` })
56 for (let i = 0; i < words.length; i++) {
57 const word = words[i]!
58 const w = word.toLowerCase()
59 if (w === '--track' || w.startsWith('--track=')) {
60 const value = w === '--track' ? words[++i] : word.slice('--track='.length)
61 if (tracks !== undefined) return { kind: 'usage', problem: 'give --track once' }
62 tracks = tracksOf(value)
63 if (!tracks) return badTrack(value)
64 continue
65 }
66 const d = parseDuration(w, min)
67 if (w === '--machine') scope = 'machine'
68 else if (w === '--session') scope = 'session'
69 else if (w === 'clear' || w === '--clear') clear = true
70 else if (d !== undefined) {
71 if (ms !== undefined) return { kind: 'usage', problem: 'give one duration' }
72 ms = d
73 }
74 else return { kind: 'usage', problem: `"${word}" is not a duration (<n>m, <n>h, <n>d, at least ${formatDuration(Math.max(min, ABSOLUTE_MIN))}), clear, --session, --machine or --track` }
75 }
76 if (clear && ms !== undefined) return { kind: 'usage', problem: 'give a duration or clear, not both' }
77 const which = tracks ?? [...TRACK_NAMES]
78 if (clear) return { kind: 'clear', scope, tracks: which }
79 if (ms !== undefined) return { kind: 'set', ms, scope, tracks: which }
80 return { kind: 'ping' }
81}
82hooks/keepalive/settings.ts 280 lines1// Keep-alive's settings: one table (SETTINGS) holds every key, its environment variable and its default;
2// nothing else repeats a default. Engine-free: keepalive.ts reads the files and hands the levels in, and
3// tests/ in the repository checks the table against the README and the cache-keepalive-configure skill's schema.
4// Keep-alive has no /config rows (human decision, 2026-10-22, run AAW-20261022-cache-keepalive-init).
5import { ABSOLUTE_MIN, formatDuration, parseDuration } from './duration.ts'
6import type { Scope } from './duration.ts'
7import { parseBool } from '../auto-handoff/config.ts'
8
9export type TrackKey = 'oneHour' | 'fiveMinute'
10export type TrackSettings = { enabled: boolean; ttl: number; pingEvery: number; keepWarmFor: number; maxPings: number }
11export type PricePattern = { pattern: string; mult: number }
12export type Settings = {
13 enabled: boolean
14 scope: Scope
15 status: boolean
16 pingPrompt: string
17 hitThreshold: number
18 overagePercent: number
19 minDuration: number
20 sessionOverridesMax: number
21 oneHour: TrackSettings
22 fiveMinute: TrackSettings
23 budgetMultiplier: number
24 compactOnCap: boolean
25 compactInstructions: string
26 writeMult: number
27 outputMult: number
28 readPrice: PricePattern[]
29 compactCostGuess: number
30 summaryRatioGuess: number
31}
32
33/** What a value must be. */
34export type Kind = 'boolean' | 'scope' | 'text' | 'threshold' | 'percent' | 'minDuration' | 'duration' | 'whole' | 'positiveWhole' | 'nonNegative' | 'positive' | 'ratio' | 'prices'
35
36/** A setting: its key in .ai-dlc/cache-keepalive.json (a dot is one nesting level), its environment
37 * variable, its kind and its default, written as a person would write it. */
38export type Setting = { key: string; env: string; kind: Kind; default: unknown }
39
40export const ENV_PREFIX = 'AI_DLC_KEEPALIVE_'
41const PING_PROMPT = 'Keep-alive ping. Reply with only: ok'
42const COMPACT_INSTRUCTIONS = "Also keep the background tasks' IDs, output paths, and what to do when they finish."
43
44// Order matters: minDuration comes before every duration, and each track's ttl before its pingEvery,
45// because their rules read those values.
46export const SETTINGS: readonly Setting[] = [
47 { key: 'enabled', env: 'AI_DLC_KEEPALIVE_ENABLED', kind: 'boolean', default: true },
48 { key: 'scope', env: 'AI_DLC_KEEPALIVE_SCOPE', kind: 'scope', default: 'session' },
49 { key: 'status', env: 'AI_DLC_KEEPALIVE_STATUS', kind: 'boolean', default: true },
50 { key: 'pingPrompt', env: 'AI_DLC_KEEPALIVE_PING_PROMPT', kind: 'text', default: PING_PROMPT },
51 { key: 'hitThreshold', env: 'AI_DLC_KEEPALIVE_HIT_THRESHOLD', kind: 'threshold', default: 0.9 },
52 { key: 'overagePercent', env: 'AI_DLC_KEEPALIVE_OVERAGE_PERCENT', kind: 'percent', default: 100 },
53 { key: 'minDuration', env: 'AI_DLC_KEEPALIVE_MIN_DURATION', kind: 'minDuration', default: '1m' },
54 { key: 'sessionOverridesMax', env: 'AI_DLC_KEEPALIVE_SESSION_OVERRIDES_MAX', kind: 'positiveWhole', default: 20 },
55 { key: 'oneHour.enabled', env: 'AI_DLC_KEEPALIVE_1H_ENABLED', kind: 'boolean', default: true },
56 { key: 'oneHour.ttl', env: 'AI_DLC_KEEPALIVE_1H_TTL', kind: 'duration', default: '60m' },
57 { key: 'oneHour.pingEvery', env: 'AI_DLC_KEEPALIVE_1H_PING_EVERY', kind: 'duration', default: '50m' },
58 { key: 'oneHour.keepWarmFor', env: 'AI_DLC_KEEPALIVE_1H_KEEP_WARM_FOR', kind: 'duration', default: '8h' },
59 // The two ping limits are the pings each track's default keepWarmFor allows at its default pingEvery,
60 // counted as keepalive.ts counts them (the one on the limit included): 8h / 50m = 9, 1h / 3m = 20.
61 // tests/keepalive.test.ts pins them to those defaults. 0 ("no limit") is no longer valid (human, 2026-10-23).
62 { key: 'oneHour.maxPings', env: 'AI_DLC_KEEPALIVE_1H_MAX_PINGS', kind: 'positiveWhole', default: 9 },
63 { key: 'fiveMinute.enabled', env: 'AI_DLC_KEEPALIVE_5M_ENABLED', kind: 'boolean', default: true },
64 { key: 'fiveMinute.ttl', env: 'AI_DLC_KEEPALIVE_5M_TTL', kind: 'duration', default: '5m' },
65 { key: 'fiveMinute.pingEvery', env: 'AI_DLC_KEEPALIVE_5M_PING_EVERY', kind: 'duration', default: '3m' },
66 { key: 'fiveMinute.keepWarmFor', env: 'AI_DLC_KEEPALIVE_5M_KEEP_WARM_FOR', kind: 'duration', default: '1h' },
67 { key: 'fiveMinute.maxPings', env: 'AI_DLC_KEEPALIVE_5M_MAX_PINGS', kind: 'positiveWhole', default: 20 },
68 { key: 'budgetMultiplier', env: 'AI_DLC_KEEPALIVE_BUDGET_MULTIPLIER', kind: 'nonNegative', default: 2 },
69 { key: 'compactOnCap', env: 'AI_DLC_KEEPALIVE_COMPACT_ON_CAP', kind: 'boolean', default: false },
70 { key: 'compactInstructions', env: 'AI_DLC_KEEPALIVE_COMPACT_INSTRUCTIONS', kind: 'text', default: COMPACT_INSTRUCTIONS },
71 { key: 'writeMult', env: 'AI_DLC_KEEPALIVE_WRITE_MULT', kind: 'positive', default: 2 },
72 { key: 'outputMult', env: 'AI_DLC_KEEPALIVE_OUTPUT_MULT', kind: 'positive', default: 5 },
73 { key: 'readPrice', env: 'AI_DLC_KEEPALIVE_READ_PRICE', kind: 'prices', default: 'fable|mythos=0.025,opus[- ]?5[-. ]5=0.05,*=0.1' },
74 { key: 'compactCostGuess', env: 'AI_DLC_KEEPALIVE_COMPACT_COST_GUESS', kind: 'positive', default: 0.6 },
75 { key: 'summaryRatioGuess', env: 'AI_DLC_KEEPALIVE_SUMMARY_RATIO_GUESS', kind: 'ratio', default: 0.15 },
76]
77
78// The settings a cloned repository may set only in a folder Claude Code trusts, from either source it
79// commits: .ai-dlc/cache-keepalive.json and the `env` of .claude/settings.json. They decide whether
80// and how long the module spends the person's tokens and rate limit (enabled, the intervals, the
81// keep-warm times, the ping limits, the spend limit and the prices it is counted in, compactOnCap),
82// or are text it sends to the model (pingPrompt, compactInstructions). Human decision D2, 2026-10-22;
83// writeMult, outputMult and readPrice added 2026-10-23, since a low price lifts the spend limit as
84// surely as a high budgetMultiplier does.
85export const TRUST_GATED: readonly string[] = [
86 'enabled', 'pingPrompt',
87 'oneHour.enabled', 'oneHour.pingEvery', 'oneHour.keepWarmFor', 'oneHour.maxPings',
88 'fiveMinute.enabled', 'fiveMinute.pingEvery', 'fiveMinute.keepWarmFor', 'fiveMinute.maxPings',
89 'budgetMultiplier', 'compactOnCap', 'compactInstructions',
90 'writeMult', 'outputMult', 'readPrice',
91]
92
93/** The names the trust-gated settings go by in a level: their variables in an `env`, their keys in the repository file. */
94export const gatedNames = (by: 'env' | 'json'): string[] =>
95 SETTINGS.filter(s => TRUST_GATED.includes(s.key)).map(s => by === 'env' ? s.env : s.key)
96
97/**
98 * Whether a value differs from its setting's default, both read as the module reads them (a duration
99 * in milliseconds, prices as their pairs), so `60m` equals `1h`. An invalid or unset value differs.
100 */
101export function differsFromDefault(s: Setting, raw: unknown, by: Level['by']): boolean {
102 const mine = check(s.kind, raw, by, 0)
103 if (mine === null || isInvalid(mine)) return true
104 return JSON.stringify(mine) !== JSON.stringify(check(s.kind, s.default, 'json', 0))
105}
106
107/**
108 * The trust-gated names in a level whose value differs from its default: the ones the trust gate skips
109 * in a folder Claude Code does not trust. A gated value equal to its default changes nothing, so it is
110 * neither skipped nor warned about; .ai-dlc/cache-keepalive.json holds every default since init writes
111 * them (run AAW-20261023-keepalive-explicit-defaults).
112 */
113export function gatedDiffering(values: Record<string, unknown>, by: 'env' | 'json'): string[] {
114 return SETTINGS.filter(s => TRUST_GATED.includes(s.key)).filter(s => {
115 const r = rawOf({ where: '', by, values }, s)
116 return r.has && differsFromDefault(s, r.raw, by)
117 }).map(s => by === 'env' ? s.env : s.key)
118}
119
120/** One place values are read from, highest first. `by`: how a setting is named there. */
121export type Level = { where: string; by: 'env' | 'json'; values: Record<string, unknown> }
122export type Resolved = { settings: Settings; sources: Record<string, string>; warnings: string[] }
123
124// Booleans parse as in the automatic handoff's settings (../auto-handoff/config.ts, parseBool).
125// Numbers do not: these take fractions ("0.9"), which the handoff's whole-number settings refuse.
126const NUMBER = /^\s*[0-9]+(\.[0-9]+)?\s*$/
127const num = (v: unknown): number | undefined => {
128 const n = typeof v === 'number' ? v : typeof v === 'string' && NUMBER.test(v) ? Number(v) : NaN
129 return Number.isFinite(n) ? n : undefined
130}
131
132/** `pattern=multiplier` pairs separated by commas, one of them `*` (any other model); each pattern a
133 * case-insensitive regular expression. Undefined when anything is off. */
134export function parsePrices(text: unknown): PricePattern[] | undefined {
135 if (typeof text !== 'string' || !text.trim()) return undefined
136 const out: PricePattern[] = []
137 for (const part of text.split(',')) {
138 const at = part.lastIndexOf('=')
139 if (at <= 0) return undefined
140 const pattern = part.slice(0, at).trim()
141 const mult = num(part.slice(at + 1))
142 if (!pattern || mult === undefined) return undefined
143 if (pattern !== '*') { try { new RegExp(pattern, 'i') } catch { return undefined } }
144 out.push({ pattern, mult })
145 }
146 return out.filter(p => p.pattern === '*').length === 1 ? out : undefined
147}
148
149/** The cache-read price over the base input price for a model id: the first pattern that matches, else `*`. */
150export function readMult(prices: readonly PricePattern[], model: string): number {
151 for (const p of prices) if (p.pattern !== '*' && new RegExp(p.pattern, 'i').test(model)) return p.mult
152 return prices.find(p => p.pattern === '*')?.mult ?? 1
153}
154
155/**
156 * A raw value checked against its kind: the value, `null` when it is not set (missing; in an `env`, also
157 * null or blank text), or `{ invalid }` with the reason (in the repository file, null is invalid). `minDuration`:
158 * the shortest duration a `duration` may be.
159 */
160export function check(kind: Kind, raw: unknown, by: Level['by'], minDuration: number): unknown {
161 const bad = (why: string) => ({ invalid: why })
162 if (raw === undefined) return null
163 // .ai-dlc/cache-keepalive.json holds a value for every key (run AAW-20261023-keepalive-explicit-
164 // defaults): a null left there by hand is invalid, warned about once, and the next level applies.
165 if (raw === null) return by === 'json' ? bad(NULL_IN_FILE) : null
166 if (by === 'env' && typeof raw === 'string' && !raw.trim()) return null
167 switch (kind) {
168 case 'boolean': return parseBool(raw) ?? bad('not true or false')
169 case 'scope': {
170 const word = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
171 return word === 'session' || word === 'machine' ? word : bad('not session or machine')
172 }
173 case 'text': return typeof raw === 'string' && raw.trim() ? raw.trim() : bad('blank or not text')
174 case 'threshold': { const n = num(raw); return n !== undefined && n >= 0 && n < 1 ? n : bad('not a number from 0 up to, not including, 1') }
175 case 'ratio': { const n = num(raw); return n !== undefined && n > 0 && n < 1 ? n : bad('not a number between 0 and 1') }
176 case 'percent': { const n = num(raw); return n !== undefined && n >= 1 && n <= 100 ? n : bad('not a number from 1 to 100') }
177 case 'nonNegative': { const n = num(raw); return n !== undefined && n >= 0 ? n : bad('not a number of 0 or more') }
178 case 'positive': { const n = num(raw); return n !== undefined && n > 0 ? n : bad('not a number above 0') }
179 case 'whole': { const n = num(raw); return n !== undefined && Number.isSafeInteger(n) && n >= 0 ? n : bad('not a whole number of 0 or more') }
180 case 'positiveWhole': { const n = num(raw); return n !== undefined && Number.isSafeInteger(n) && n >= 1 ? n : bad('not a whole number of 1 or more') }
181 case 'minDuration': { const ms = parseDuration(raw, ABSOLUTE_MIN); return ms ?? bad('not a duration (<n>m, <n>h or <n>d, at least 1 second)') }
182 case 'duration': {
183 const ms = parseDuration(raw, ABSOLUTE_MIN)
184 if (ms === undefined) return bad('not a duration (<n>m, <n>h or <n>d)')
185 return ms >= minDuration ? ms : bad(`shorter than minDuration (${formatDuration(minDuration)})`)
186 }
187 case 'prices': return parsePrices(raw) ?? bad('not pattern=multiplier pairs separated by commas, with exactly one *')
188 }
189}
190
191/** Why a null in the repository file is ignored. */
192export const NULL_IN_FILE = 'null: every key holds a value (run /ai-dlc:cache-keepalive-merge to write its default)'
193
194const isInvalid = (v: unknown): v is { invalid: string } => !!v && typeof v === 'object' && !Array.isArray(v) && 'invalid' in v
195const show = (v: unknown) => typeof v === 'string' ? JSON.stringify(v) : String(JSON.stringify(v))
196
197/** A setting's raw value in a level: by its variable, or by its nested JSON key. */
198function rawOf(level: Level, s: Setting): { has: boolean; raw: unknown; name: string } {
199 if (level.by === 'env') return { has: Object.hasOwn(level.values, s.env), raw: level.values[s.env], name: s.env }
200 let cur: unknown = level.values
201 for (const part of s.key.split('.')) {
202 if (!cur || typeof cur !== 'object' || Array.isArray(cur) || !Object.hasOwn(cur, part)) return { has: false, raw: undefined, name: s.key }
203 cur = (cur as Record<string, unknown>)[part]
204 }
205 return { has: true, raw: cur, name: s.key }
206}
207
208function setIn(target: Record<string, unknown>, key: string, v: unknown) {
209 const [head, tail] = key.split('.') as [string, string | undefined]
210 if (tail === undefined) target[head] = v
211 else (target[head] as Record<string, unknown>)[tail] = v
212}
213function getIn(target: Record<string, unknown>, key: string): unknown {
214 const [head, tail] = key.split('.') as [string, string | undefined]
215 return tail === undefined ? target[head] : (target[head] as Record<string, unknown>)[tail]
216}
217
218/** The rule a value must keep beyond its kind, read against the values resolved before it. */
219function rule(key: string, v: unknown, soFar: Record<string, unknown>): string | undefined {
220 const m = /^(oneHour|fiveMinute)\.pingEvery$/.exec(key)
221 if (!m) return undefined
222 const ttl = getIn(soFar, `${m[1]}.ttl`) as number
223 return (v as number) < ttl ? undefined : `not shorter than ${m[1]}.ttl (${formatDuration(ttl)})`
224}
225
226const blank = (): Record<string, unknown> => ({ oneHour: {}, fiveMinute: {} })
227
228/** The built-in defaults, from SETTINGS: the one defaults table. */
229export function defaults(): Settings {
230 const out = blank()
231 for (const s of SETTINGS) setIn(out, s.key, check(s.kind, s.default, 'json', 0))
232 return out as unknown as Settings
233}
234
235/**
236 * Each setting from the first level that holds a valid value, highest first: the levels given (the
237 * `env` of .claude/settings.local.json, the `env` of .claude/settings.json, then
238 * .ai-dlc/cache-keepalive.json), then the default. A value that is set but invalid, or that breaks a rule (a duration
239 * shorter than minDuration, a pingEvery not shorter than its track's ttl), is reported in `warnings`
240 * and resolution falls through to the next level. Defaults are exempt from minDuration. When even the
241 * default pingEvery is not shorter than a ttl set at some level, that ttl is dropped for its default too.
242 */
243export function resolveSettings(levels: readonly Level[]): Resolved {
244 const out = blank()
245 const sources: Record<string, string> = {}
246 const warnings: string[] = []
247 for (const s of SETTINGS) {
248 const min = s.key === 'minDuration' ? 0 : (out.minDuration as number | undefined) ?? 0
249 let found = false
250 for (const level of levels) {
251 const { has, raw, name } = rawOf(level, s)
252 if (!has) continue
253 const v = check(s.kind, raw, level.by, min)
254 if (v === null) continue
255 const why = isInvalid(v) ? v.invalid : rule(s.key, v, out)
256 if (why !== undefined) {
257 warnings.push(`${name}=${show(raw)} in ${level.where} ignored: ${why}; falling back to the next source`)
258 continue
259 }
260 setIn(out, s.key, v)
261 sources[s.key] = `${name} in ${level.where}`
262 found = true
263 break
264 }
265 if (found) continue
266 const d = check(s.kind, s.default, 'json', 0)
267 const why = rule(s.key, d, out)
268 if (why !== undefined) {
269 const ttlKey = s.key.replace('pingEvery', 'ttl')
270 const ttlSetting = SETTINGS.find(x => x.key === ttlKey)!
271 warnings.push(`${ttlKey} from ${sources[ttlKey]} ignored: no level sets a ${s.key} shorter than it, and the default (${formatDuration(d as number)}) is not; set ${s.key} too. Using the defaults for both`)
272 setIn(out, ttlKey, check(ttlSetting.kind, ttlSetting.default, 'json', 0))
273 sources[ttlKey] = 'default'
274 }
275 setIn(out, s.key, d)
276 sources[s.key] = 'default'
277 }
278 return { settings: out as unknown as Settings, sources, warnings }
279}
280