SLOPSHOPPER

AI-DLC Engineering Team

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

newbandrowsguardstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ai-dlc
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ai-dlc: keepalive 1h: off (no 1h cache write seen) keepalive 5m: off (no 5m cache write seen)
README

ai-dlc

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.

Install

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

Start

  1. Run /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.
  2. Talk to the orchestrator: init makes it the session's main agent in Claude Code; in GitHub Copilot, select the orchestrator custom agent.

Commands

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

CommandArgumentsWhat 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/&lt;folder>/ (ai-dlc-&lt;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 -->

License

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.

Source 14 files
hooks/auto-handoff/register.tsx 1080 lines
1import 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}
1080
hooks/auto-handoff/brief.ts 191 lines
1import 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}
191
hooks/auto-handoff/viewer.ts 174 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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}
174
hooks/auto-handoff/server.ts 35 lines
1// 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}
35
hooks/auto-handoff/panel.tsx 46 lines
1// 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}
46
hooks/auto-handoff/config.ts 525 lines
1// 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.'
525
hooks/auto-handoff/run.ts 379 lines
1// 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}
379
hooks/keepalive/keepalive.ts 916 lines
1import 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 })
916
hooks/auto-handoff/io.ts 113 lines
1// 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}
113
hooks/auto-handoff/templates.ts 18 lines
1// 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}
18
hooks/keepalive/duration.ts 82 lines
1// 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}
82
hooks/keepalive/settings.ts 280 lines
1// 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