SLOPSHOPPER

sweet-spot

Hands a long session over, to a fresh one or by compacting, before the context gets too big

newbandguardcommandpromptmodel
v0.2.0MITupdated 2026-10-15muellerei/sweet-spot
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sweet-spot
› 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 › /sweet-spot ⎿ sweet-spot: use `/sweet-spot status` to see where the context stands, `/sweet-spot later [tokens]` to move the next handov ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="docs/images/banner-light.svg" width="900" alt="sweet-spot: hands a long Claude Code session over, to a fresh one or by compacting">

<b>One job: keep your context in the sweet spot.</b><br> Not so long that answers get less reliable and every call re-reads a mountain.

<a href="https://github.com/muellerei/sweet-spot/actions/workflows/check.yml"><img src="https://github.com/muellerei/sweet-spot/actions/workflows/check.yml/badge.svg" alt="CI"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/muellerei/sweet-spot" alt="MIT license"></a>

<a href="#install">Install</a> · <a href="#why">Why</a> · <a href="#what-it-does">What it does</a> · <a href="#handoff-or-compact">Handoff or compact</a> · <a href="#what-it-can-reach">What it can reach</a> · <a href="#settings">Settings</a> · <a href="#limits">Limits</a> · <a href="CHANGELOG.md">Changelog</a> · <a href="CONTRIBUTING.md">Contributing</a>

sweet-spot never acts in the middle of a turn. It waits for a pause, counts down with a "Not now", and postpones while a subagent or background task is running. Only then does it hand a long Claude Code session over before the context gets too big: to a fresh session that starts from a handoff, or by Claude Code's own /compact. "Handover" is the word for all of these. It is a Claude Code mod (a plugin of function hooks).

The context bar fills past the threshold, the mod waits for a subagent, hands the session over and continues in a fresh session

Drawn, not recorded: an illustration of the sequence, looping.

Built to be safe

  • Only at a pause. An answer that ended in an error, a refusal or Esc is no pause, and neither is the end of a subagent.
  • Counts down first. A line above the prompt counts down 10 seconds, with "Not now" to move the handover back.
  • Waits for running work. A subagent, a background task or a timer holds it back, until the emergency brake: 150k past the threshold, never beyond 90 percent of the window.
  • Typing is not lost. What you type during a handover is held and goes in when it is done.
  • Writes nothing. No files, no network, and one process only: tail, to read the end of a long transcript.

Install

Developed against Claude Code 2.1.296; older versions are not tested, and versions before 2.1.271 cannot load the mod at all (see Settings). Run inside Claude Code:

/plugin marketplace add muellerei/sweet-spot
/plugin install sweet-spot@muellerei-sweet-spot
/reload-plugins

That is all the setup there is. Work until the context passes 200k tokens, and at the end of an answer a line above the prompt counts down:

Handing over to a fresh session in 10 s · context 205000 of 200000 · 1: Not now, +25000

Press 1 to move the handover back, or start typing to keep working. Otherwise, when it runs out, you see:

handing over now, context 205000 tokens
handed over, the fresh session carries on from the handoff

By default the mod writes the handoff itself with a built-in prompt, clears the session and starts the fresh one with it; if that fails, it compacts the session instead. ~/.claude/sweet-spot/handoff-prompt.md replaces that prompt. handoff_method and handoff_fallback choose the ways: Claude Code's own /compact, the mod's handoff, or a handoff command of your own. To watch first, turn dry_run on in /config: then it only says when it would hand over. See Settings.

Ideas, questions and a quick "works on my setup" are all welcome: CONTRIBUTING.md.

Why

The sweet spot is the range where the context is big enough to hold the work and still small enough to be cheap and reliable.

The sweet spot on a scale of context size, the threshold, and where a fresh session starts

  • Cost. Every call re-reads the whole context. Measured over real sessions, most of the cost came from calls on more than 200k tokens.
  • Reliability. "As token count grows, accuracy and recall degrade" (Anthropic). An independent test of 18 models found the same (Chroma).

A fresh session starts at 62k to 98k tokens in real handovers. Nobody publishes where the line lies, and the picture is a sketch. The default of 200000 comes from the cost measurement. The mod does not claim that a fresh session answers better, only that a long one costs more.

What it does

At the end of an answer it looks at the context size. Past the threshold, and when nothing else is going on, it counts down and then hands over. By default that means it asks for a handoff, clears the session and submits the text of the handoff file, not its path, as the first message of the new one. The old session stays as it was: /resume takes you back to it. With /compact as the way, the session stays and only gets smaller; nothing is cleared or submitted.

  • Only at a pause. It acts when an answer is finished, never in the middle of a turn and never from a subagent. A turn that ended with an error, a refusal or Esc is no pause.
  • A countdown before it acts. A line above the prompt says what is coming and counts down countdown_seconds (10 by default). "Not now" (key 1 in an empty prompt) moves the handover back, as /sweet-spot later with no number does. Typing or sending a message stops the countdown without moving anything; the next pause counts down again. A draft in the prompt holds the countdown back until the next pause. After two pauses that did not hand over, the countdown comes without "Not now", typing no longer stops it, and your draft stays in the prompt. When the countdown runs out the mod looks again and hands over only if it still should.
  • Not now, if you say so. In the middle of something? /sweet-spot later 50k moves this one handover back: the mod hands over once the context has grown 50k tokens past where it is now. The next handover uses your threshold again. It never goes past the emergency brake and never brings a handover forward. With no number it takes later_tokens.
  • At the next pause, if you say so. /sweet-spot asap hands over at the next pause, wherever the context stands: it lifts the threshold and the minimum gap and has no countdown, because you asked for it. Typed while no answer runs, it hands over at once, a moment after it answers. It still waits for running work, as any handover does, and it is spent by the handover it starts. A compact or /clear of your own, the end of the session and a context that has shrunk below where you asked end it; later after it takes it back.
  • Ask where it stands. /sweet-spot status shows the context against the window, where the next handover comes (with a later that holds) and the emergency brake. It only reads, and a countdown goes on while you ask.
  • It waits for running work. While a subagent, a background task or a one-shot timer is running, it postpones, because a handoff written now could not hold that work's result, and the result would come into a fresh session that does not know what it was for. It says so once ("handoff postponed, a subagent is running, context 201293 tokens"); it does not repeat the line at every stop. It acts at the first pause after the last one has finished (measured: the answer after the last subagent ended handed over). Past an emergency brake (150k above the threshold, never beyond 90 percent of the window) it goes ahead anyway and says so, with no countdown; if you moved the handover back, a countdown without "Not now" announces it once.
  • You choose the way. handoff_method is the main way, handoff_fallback the one tried when the main way fails before it changed the session, and the change is said with its reason. The ways: the mod writes the handoff itself with a built-in prompt (internal-handoff, the default); Claude Code's own /compact (builtin-compact, the default fallback); or your own command, run as a turn of its own, whose file the mod reads (external-command, needs handoff_command). If Claude Code refuses the compact (it does in a headless -p or SDK session) or a hook vetoes it, the fallback takes over; if you press Esc during it, nothing else hands over.
  • A compact that is not enough is not repeated. If the context is still above the threshold right after a compact (at the first tool call of the next turn, or its pause), the next time the fallback takes over; with no fallback, the mod says so once and leaves the session alone until a pause is below the threshold again.
  • Typing is held, not lost. What you type while a handover runs is held and goes in when it is done: into the fresh session with the handoff, or into the compacted session. If submitting the message fails, /sweet-spot redeliver is the way out: it submits it again.
  • After a long pause, a fresh session. When you leave a session alone for more than an hour, the prompt cache expires, and your next message would send the whole conversation again at the price of writing the cache. With an hour of cache and a context well past what a fresh session starts with, the mod writes a handoff at minute 55, while the cache is still warm, and leaves the session as it is. Come back while the cache is warm and nothing changes; come back after it has expired, and your message starts a fresh session with the handoff, and a line says how long the session sat idle and that the old one is in /resume. The band above the prompt says so once the cache has expired, and /sweet-spot later keeps you in the session. Running work or an attachment keeps your message in the session too. Claude Code 2.1.299 and later can compact a large idle session itself instead; how the two differ and how to choose: docs/idle-handoff.md. The details, the costs and the limits: docs/idle-handoff.md.
  • Report only, if you want. With dry_run on it only says "would hand over now" and does nothing.
  • Bad settings are refused, not repaired. A threshold so low that every fresh session would trigger the next handover is rejected with the reason, and the mod stays off.

What happens to what you type while a handoff runs, and what redeliver is for:

What you type during a handover is held and goes into the fresh session; if the submit fails, redeliver puts it in

Drawn, not recorded: an illustration of the sequence, looping.

And the questions the mod asks at every pause, in this order. The first one that says stop ends the check:

The five questions at every pause: a normal end of the answer, the threshold, running work, the minimum gap, dry run

Drawn, not recorded: an illustration of the sequence, looping.

Handoff or compact

Both ways bring the context down. They differ in what the session holds afterwards.

Past the threshold the session goes one of two ways: a handoff clears it and starts a fresh session that holds only the handoff; a compact keeps the same session with Claude Code's summary in place of the history

Handoff (default)Compact
AfterwardsA fresh session that holds only the handoffThe same session, with Claude Code's summary in place of the history
What it keepsWhat the handoff prompt asks for; ~/.claude/sweet-spot/handoff-prompt.md replaces itWhat Claude Code's /compact keeps; the mod has no say in it
Can you read itYes, it is the first message of the fresh sessionNo file; the summary goes straight into the session
Time, measuredFrom the end of the answer to the fresh session's first message: 20 to 34 s with your own command at 202k to 224k tokens. With the built-in prompt 15 to 21 s, measured when it stood in for a missing commandThe compact itself: about 15 s at 65k tokens, 56.5 s at 408k; it grows with the context
Context after, measuredAt the start of the fresh session: 62k to 98k tokens with your own command, 31k once with the built-in prompt130k at the first pause after a compact at 408k; that pause already holds the next turn

handoff_method picks the way and handoff_fallback the one tried when it fails. A handoff command of your own (external-command) goes the handoff way: its file starts the fresh session. See Settings.

What it can reach

  • Reads: the transcript of the session, to learn how long its prompt cache lives (the whole file up to 4 MiB, above that its last 256 KiB), your handoff file, ~/.claude/sweet-spot/handoff-prompt.md if you made one, and a listing of the temp directory to find a handoff the answer did not name, or of the folder of the file it does name, and the environment variables HOME and TMPDIR, to find those two places.
  • Writes: nothing. The message waiting to be delivered, and the baseline of each project folder for the idle handoff, live in the mod's store in Claude Code, which /clear does not touch.
  • Network: none. The model call that writes the handoff runs through Claude Code, and so does the compact.
  • Own processes: one. A transcript above 4 MiB is read at its end with tail -c 262144 <transcript>, with no shell; Claude Code's file read stops at 4 MiB. Without tail (Windows without the Git tools on the path) the idle handoff stays off for that session and a warn line says so.

To check this against the code yourself:

claude plugin validate .

It lists every call the hooks make and every environment variable they read.

How it differs

There are several context mods. They fall into a few approaches, and this table compares features, not projects. It reflects the code of the context mods read in October 2026 and is meant as a map, not a ranking. Only features this version has are listed; corrections are welcome.

Compacts in placeHands over (typical)sweet-spot
New session instead of a summary in the same onenoyesyes, or a compact if you choose
Acts only when an answer is finishedmostlymostlyyes
Waits for subagents and background worksomesomeyes
Report-only mode before it actsone, as its defaultonly a manual dry runyes, a setting
Falls back to a second way if the first failsrarerareyes
Writes into your projectrarelyoften, or by asking Claude tonever

What it does not do

No memory, no rules, no panel or status line; the line above the prompt shows only while a countdown runs, or once an idle handoff waits for a cache that has expired. It has no summarizer of its own: when it compacts, it starts Claude Code's own /compact. Those belong in other tools. The first version also has no cache keep-warm and no log file; the CHANGELOG lists them under "Not in this version".

Settings

In the /config menu under sweet-spot.

SettingDefaultWhat it does
threshold200000Context size in tokens at which it hands over. Values below 130000 are refused. On a small window it is lowered to 80 percent of the window.
dry_runoffOnly say that a handoff would happen, and do nothing. Turn on to watch what the mod would do before it acts.
min_gap_minutes10Least time between two attempts. 0 turns the whole gap off.
min_gap_turns5Least number of turns between two attempts. 0 turns the whole gap off.
later_tokens25000How far /sweet-spot later moves the next handover back when you give it no number, in tokens.
countdown_seconds10Seconds the line above the prompt counts down before a handover, with "Not now". A whole number from 0 to 60; 0 hands over at once. Without a terminal or desktop window to show it (VS Code, mobile), it hands over at once too.
handoff_methodinternal-handoffHow the session is handed over. internal-handoff: the mod writes a handoff and starts a fresh session with it. builtin-compact: Claude Code's own /compact, the session stays. external-command: your own command.
handoff_fallbackbuiltin-compactUsed when the main method fails before it changed the session: any of the three, or none for no second try (the mod says why). The same value as handoff_method means no fallback.
idle_handoffonAfter a long pause, write a handoff shortly before the prompt cache expires, and start a fresh session with it when you come back after it has. Only with an hour of cache. See docs/idle-handoff.md.
idle_handoff_min_tokens0Context size from which the idle handoff is written. 0: the session's baseline, what a fresh session sends with its first message, plus 30000.
handoff_commandemptyOnly used with external-command (advanced). Name of your own handoff command, without the slash. external-command with no command here switches the mod off and says why.

The two ways are choices in /config, so they cannot be mistyped; an unknown value is refused. Does not load before Claude Code 2.1.271: the host rejects a mod with fixed choices in an older version ("If you declare options on any field, users on Claude Code versions before v2.1.271 can't load the plugin", Claude Code plugin reference, section "Limit a field to fixed options"). Examples:

  • Compact only: handoff_method builtin-compact, handoff_fallback none.
  • Compact when it works, otherwise a handoff: builtin-compact and internal-handoff.
  • Only your own command: external-command and none, with handoff_command set.

The gap ends after the minutes or the turns, whichever comes first. Both are starting points; raise them if attempts come too close together.

A change in /config takes effect at once: the host reloads the mod, which starts from a clean state, so the minimum gap, the redeliver pointer, a later, an asap and an idle handoff written are forgotten. A reload while a handoff is running (the seconds after the mod decides to hand over, see Handoff or compact) ends that handoff without a message, so leave the settings alone during that time.

Commands

The mod has one command, /sweet-spot, and the word after it says what it does. It runs at once, also while an answer is still running.

CommandWhat it does
/sweet-spot statusSays where the context stands: the tokens against the window, where the next handover comes (with a later that holds) and the emergency brake, then a standing asap and where the idle handoff stands. It only reads; a countdown keeps running.
/sweet-spot asapHands over at the next pause, below the threshold and inside the minimum gap too, with no countdown. It still waits for running work. It says the context, or why it did not take the request. With an idle handoff written and the cache expired, it hands that one over.
/sweet-spot later [tokens]Moves the next handover back until the context has grown that many tokens past where it is now (50000 or 50k; with no number, later_tokens). It says the value it hands over at, or why not. With the idle handoff written after a long pause, it drops that too, so your next message stays in this session.
/sweet-spot redeliverSubmits again the first message of a handoff that did not reach the fresh session.

Limits

  • Where an organisation manages Claude Code, the mod does next to nothing. There Claude Code passes over a plugin you installed yourself for some of the events the mod relies on, among them the one that says an answer has ended and the one that says the session was compacted; only a plugin the organisation lists in its managed settings gets them. Without them the mod never judges a pause: no handover at the threshold and no idle handoff. If that is your setup, ask whoever manages Claude Code to list sweet-spot there. This comes from claude plugin validate; I have no managed installation to try it on.
  • The idle handoff gives way to Claude Code's own idle compaction. From 200,000 tokens Claude Code may compact an idle session itself at minute 54, a minute before the mod would write the handoff; the session then carries on compacted, and no handoff is written. To keep the idle handoff for large sessions, set idleCompaction to false in your settings.json. How the two differ: docs/idle-handoff.md.
  • It waits only for what the host reports as a subagent, a background task or a one-shot timer. Work the host does not report, it cannot see.
  • A reason for postponing is said once, not at every stop. When the reason changes, or the wait ends and returns, it is said again.
  • A dry run counts as an attempt and starts the minimum gap, so you do not get a new "would hand over" at every turn.
  • redeliver submits only into the fresh session that /clear made, and says so if you are in another one (after a /resume, say). Only a handoff that failed at the submit can be redelivered, and only until the mod is reloaded; after a reload it says "nothing to redeliver". It does not run while an answer is still running: it says so, and you try again when the answer is done.
  • later and asap hold only for the next handover in this session; anything that shrinks the context (a compact, yours too, or /clear), a reload or a change in /config forge
Source 17 files
hooks/register.tsx 1269 lines
1import type { EngineInterface as Engine, Register } from 'claude-code'
2
3import { configOf, type Config, type ConfigResult } from './config'
4import { ASAP_OVER, RUNNING_UNKNOWN, asapFor } from './asap'
5import {
6  activeTasks,
7  brakeFor,
8  brakeOf,
9  busyReason,
10  decide,
11  effectiveThreshold,
12  laterAt,
13  oneShots,
14  shrunk,
15  thresholdInForce,
16  type Decision,
17  type Snapshot,
18} from './decide'
19import { claim, hold, HELD_MOMENT } from './hold'
20import { LATER_OVER, laterFor, tokensOf } from './later'
21import { idlePart, statusFor, statusTail } from './status'
22import { checkOf, firstMethod, judgedLine } from './compact-check'
23import { bandOf, kindAt, moveBack, NOT_NOW_KEY, typed, type Countdown, type Start } from './countdown'
24import { begin, end, endedLine, press, surveyed, type Answer, type CountdownIo, type EndReason, type Expiry } from './countdown-flow'
25import { logger, refresh, shows } from './log'
26import {
27  carryOut,
28  deliverReturn,
29  endOfTurn,
30  noteToolResult,
31  prepare,
32  redeliver,
33  release,
34  startOfTurn,
35  throughMethods,
36  why,
37  type Failure,
38  type Io,
39  type Outcome,
40} from './relay'
41import {
42  ANSWER_RUNNING,
43  COMPACTED,
44  HANDED_OVER,
45  HANDOFF_UNDER_WAY,
46  NOTHING_TO_REDELIVER,
47  REDELIVER_UNDER_WAY,
48  REDELIVERING,
49  REDELIVERED,
50  usageFor,
51  CANCELLED,
52  notPreparedLine,
53  switchedLine,
54  COLD_BAND,
55  IDLE_DROPPED,
56  ATTACHMENTS_STAY,
57  DRY_RUN_RETURN,
58  WORK_STAYS,
59  CONTEXT_UNKNOWN,
60  contextUnreadLine,
61  decidedOf,
62  failedLine,
63  offLine,
64  reportOf,
65  unreadLine,
66  unregisteredLine,
67} from './report'
68import { endSeen, judgeable, stopSeen, turnStarted, type Stop } from './pairing'
69import { freshIdle, freshState, underWay, type IdlePrepared, type State } from './state'
70import {
71  HOUR_MS,
72  baselineOfUsage,
73  fireVerdict,
74  firstBaselineOf,
75  floorOf,
76  idleFor,
77  isHit,
78  lifetimeOf,
79  planVerdict,
80  prepareDueAt,
81  readPlan,
82  returnVerdict,
83  TAIL_BYTES,
84  type InputUsage,
85} from './idle-handoff'
86
87// The host follows `$` only into functions declared at the top of this file, so what takes `$` lives here and not inside `register`.
88
89const say = ($: Engine, text: string) => {
90  $.ui.toast(text)
91  $.ui.log(text)
92}
93
94// One line of the diagnostic output, for finding out why a handoff did or did not happen. It goes to the transcript, where the host stamps
95// the time. The level is looked up at each line, so a line from a timer goes by the latest read.
96const toLog = ($: Engine) => (text: string) => $.ui.log(text)
97
98const logTo = ($: Engine, state: State) => logger(() => state.logLevel, toLog($))
99
100// An empty name is no command of the user's own: shown as `none`, not as an empty quote.
101const commandShown = (name: string) => (name === '' ? 'none' : `"${name}"`)
102
103// The command's name is of interest only where a way uses it.
104const usesCommand = (c: Config) => c.handoffMethod === 'external-command' || c.handoffFallback === 'external-command'
105
106// The log level, read afresh at the start of every handler (see `refresh`). The host wants the variable's name as a literal at the call.
107// Each change to a level that shows `info` is marked with the settings in force, so that a log turned on halfway through a session says
108// them too, and shows from where on its finer lines start. Settings the mod rejects have their own line, said where the person looks.
109const readLevel = async ($: Engine, settings: ConfigResult, state: State) => {
110  const before = await refresh(() => $.env.get('SWEET_SPOT_LOG_LEVEL'), toLog($), state)
111  if (!settings.ok || state.logLevel === before || !shows(state.logLevel, 'info')) return
112  const c = settings.config
113  logTo($, state)(
114    'info',
115    `settings: threshold ${c.threshold}, dry_run ${c.dryRun ? 'on' : 'off'}, min gap ${c.minGapMinutes} minutes and ${c.minGapTurns} turns, later_tokens ${c.laterTokens}, countdown_seconds ${c.countdownSeconds}, idle_handoff ${c.idleHandoff ? 'on' : 'off'}, idle_handoff_min_tokens ${c.idleHandoffMinTokens}, method ${c.handoffMethod}, fallback ${c.handoffFallback}${usesCommand(c) ? `, handoff command ${commandShown(c.handoffCommand)}` : ''}, log level ${state.logLevel}`,
116  )
117}
118
119const announce = ($: Engine, settings: ConfigResult, state: State) => {
120  if (settings.ok) return
121  say($, offLine(settings.errors))
122  const n = settings.errors.length
123  logTo($, state)('warn', `settings rejected, ${n} ${n === 1 ? 'error' : 'errors'}`)
124}
125
126const sessionIo = ($: Engine, state: State): Io => ({
127  commands: async () => (await $.command.list()).map(c => c.name),
128  now: () => $.clock.now(),
129  run: (command, args) => $.command.run(args === undefined ? { command } : { command, args }),
130  after: (ms, fn) => $.clock.after(ms, fn),
131  readFile: path => $.fs.read(path),
132  listDir: path => $.fs.list(path),
133  tmpdir: () => $.env.get('TMPDIR'),
134  home: () => $.env.get('HOME'),
135  modelFork: async prompt => {
136    const r = await $.model.fork({ prompt })
137    return r.isAnswered
138      ? { isAnswered: true, text: r.text, usage: r.usage }
139      : { isAnswered: false, reason: r.reason, ...('status' in r ? { status: r.status } : {}) }
140  },
141  compact: async () => {
142    const r = await $.session.compact()
143    return r.skip === undefined ? {} : { skip: r.skip }
144  },
145  tell: text => say($, text),
146  log: logTo($, state),
147  submit: text => $.prompt.submit({ text }),
148  sessionId: () => $.session.id(),
149  keep: (key, value) => $.store.set(key, value),
150  recall: key => $.store.get(key),
151  forget: key => $.store.delete(key),
152})
153
154const countdownIo = ($: Engine, state: State): CountdownIo => ({
155  now: () => $.clock.now(),
156  after: (ms, fn) => $.clock.after(ms, fn),
157  every: (ms, fn) => $.clock.every(ms, fn),
158  surfaces: () => $.session.surfaces(),
159  prompt: async () => (await $.prompt.read()).text,
160  redraw: () => $.ui.invalidate('ui.render'),
161  toast: text => $.ui.toast(text),
162  log: logTo($, state),
163})
164
165// The message about invalid settings and the command, once per load: at the session start, or at the first turn of a session that was already running.
166const introduce = async ($: Engine, settings: ConfigResult, state: State) => {
167  if (state.introduced) return
168  state.introduced = true
169  announce($, settings, state)
170  await registerCommand($, state)
171}
172
173const NOT_NOW_BUTTON = 'not-now'
174
175// The one command of the mod; what it does is the word after it.
176const COMMAND = 'sweet-spot'
177
178const registerCommand = async ($: Engine, state: State) => {
179  try {
180    // Immediate: a command typed while an answer runs would otherwise wait until the pause that ends the answer has been judged
181    // (docs/host-behaviour.md, Commands).
182    await $.command.register({
183      name: COMMAND,
184      description:
185        'Show where the context stands, move the next handover back or drop the idle handoff, hand over at the next pause, or submit again the handoff that did not reach the fresh session',
186      argumentHint: 'status | later [tokens] | asap | redeliver',
187      immediate: true,
188    })
189  } catch (err) {
190    say($, unregisteredLine(why(err)))
191    logTo($, state)('warn', 'could not register the command')
192  }
193}
194
195// Says how a run ended. Whatever goes wrong ends here as a line, never as an error into the host. The log names the stage, not the
196// reason: that can carry paths, the host's words and what was typed.
197const settle = async (
198  $: Engine,
199  state: State,
200  run: { name: 'handover' | 'redelivery'; done: (outcome: Outcome) => string; sayFailure?: (failure: Failure) => boolean },
201  carry: () => Promise<Outcome>,
202) => {
203  const log = logTo($, state)
204  try {
205    const outcome = await carry()
206    const failure = outcome.failure
207    if (failure === undefined) say($, run.done(outcome))
208    else if (failure.stage === 'cancelled') {
209      say($, CANCELLED)
210      log('info', `${run.name} cancelled`)
211    } else {
212      if (run.sayFailure?.(failure) ?? true) say($, failedLine(failure.reason))
213      log('warn', `${run.name} failed (stage ${failure.stage})`)
214    }
215  } catch (err) {
216    say($, failedLine(why(err)))
217    log('warn', `${run.name} failed (unexpected error)`)
218  }
219}
220
221// The submit waits for the session to be idle, so it is not waited for in the command: the command answers at once.
222const runRedelivery = async ($: Engine, state: State) => {
223  try {
224    await settle($, state, { name: 'redelivery', done: () => REDELIVERED }, async () => ({ failure: await redeliver(sessionIo($, state), state) }))
225  } finally {
226    state.redelivering = false
227  }
228}
229
230// Without usable settings (`config` undefined) the mod never handed anything over, so there is nothing to redeliver.
231const redeliverText = ($: Engine, config: Config | undefined, args: string, state: State): string => {
232  if (args.trim() !== 'redeliver') return usageFor(args)
233  if (underWay(state)) return HANDOFF_UNDER_WAY
234  if (state.answerRunning) return ANSWER_RUNNING
235  if (state.redelivering) return REDELIVER_UNDER_WAY
236  if (state.pending === undefined || config === undefined) return NOTHING_TO_REDELIVER
237  state.redelivering = true
238  $.clock.after(0, () => void runRedelivery($, state))
239  return REDELIVERING
240}
241
242const turnedDown = (text: string, why: string, verb = 'later'): Answer => ({ text, lines: [['debug', `${verb}: turned down, ${why}`]] })
243
244// What a command that acts on the context needs before it does: settings it can trust, no handover under way (nor, for `asap`, a step of
245// its own still to come), and the context size, read at that moment. Otherwise the answer that turns it down. The command answers whatever
246// goes wrong with the read; the hook's `.catch` would only pass the error to the host.
247const contextFor = async (
248  $: Engine,
249  settings: ConfigResult,
250  state: State,
251  verb: 'later' | 'asap',
252): Promise<{ config: Config; tokens: number; window: number } | Answer> => {
253  if (!settings.ok) return turnedDown(offLine(settings.errors), 'the settings are rejected', verb)
254  const busy = () => underWay(state) || (verb === 'asap' && state.asapScheduled)
255  if (busy()) return turnedDown(HANDOFF_UNDER_WAY, 'a handover is in progress', verb)
256  let context: { tokens?: number; window: number }
257  try {
258    context = (await $.session.usage()).context
259  } catch (err) {
260    return { text: contextUnreadLine(why(err)), lines: [['warn', `${verb}: could not read the context size`]] }
261  }
262  // A pause may have started a handover during the read: a later handover or a mark set beside it would come too late for it and hold on in
263  // the fresh session.
264  if (underWay(state)) return turnedDown(HANDOFF_UNDER_WAY, 'a handover is in progress', verb)
265  if (context.tokens === undefined) return turnedDown(CONTEXT_UNKNOWN, 'the context size is unknown', verb)
266  return { config: settings.config, tokens: context.tokens, window: context.window }
267}
268
269// Moves the next handover back. It needs the context at the time it is typed, so its answer waits for that read.
270const laterText = async ($: Engine, settings: ConfigResult, rest: string, state: State): Promise<Answer> => {
271  const asked = tokensOf(rest)
272  if ('error' in asked) return turnedDown(asked.error, 'not a number of tokens')
273  // Staying in this session after a long pause: the handoff written goes, and the rest is `later` as ever.
274  if (state.idle.prepared !== undefined) {
275    const over = endPause($, state, 'later')
276    const answer = await laterText($, settings, rest, state)
277    const lines: Answer['lines'] = over === undefined ? [] : [['debug', over]]
278    return { text: IDLE_DROPPED + answer.text, lines: [...lines, ...(answer.lines ?? [])] }
279  }
280  const ready = await contextFor($, settings, state, 'later')
281  if ('text' in ready) return ready
282  const { config, tokens, window } = ready
283  const before = laterAt(state.later, tokens)
284  const result = laterFor(config, window, tokens, state.later, asked.tokens === 'setting' ? config.laterTokens : asked.tokens)
285  state.later = result.later
286  // The last word counts: `later` after `asap` takes the mark back, whether `later` itself was turned down or not.
287  const withdrawn = state.asap !== undefined
288  state.asap = undefined
289  // A step of `asap` still to come finds no mark and does nothing; a new `asap` before it must not be turned down for it.
290  state.asapScheduled = false
291  // Moving the handover back is the person's answer, not a pause that dodged it: the deferred pauses start over. A later turned down moves
292  // nothing and leaves them.
293  if (result.later !== before) state.deferred = undefined
294  const lines: Answer['lines'] = result.lines.map(line => ['debug', line])
295  if (withdrawn) lines.unshift(['debug', 'asap: withdrawn by later'])
296  return { text: withdrawn ? `asap withdrawn, ${result.text}` : result.text, lines }
297}
298
299const verbOf = (args: string) => args.trim().split(/\s/, 1)[0]
300
301// Reads the context and says where it stands; nothing in the state changes. It answers during a handover too, and says so.
302const statusText = async ($: Engine, settings: ConfigResult, state: State): Promise<Answer> => {
303  if (!settings.ok) return { text: offLine(settings.errors) }
304  let context: { tokens?: number; window: number }
305  try {
306    context = (await $.session.usage()).context
307  } catch (err) {
308    return { text: contextUnreadLine(why(err)), lines: [['warn', 'status: could not read the context size']] }
309  }
310  const { tokens, window } = context
311  const text =
312    statusFor(settings.config, window, tokens, laterAt(state.later, tokens)) + statusTail(state.asap !== undefined, idlePart(state.idle, await $.clock.now()))
313  return {
314    text: state.handingOver ? `${text}, a handover is in progress` : text,
315    lines: [
316      [
317        'debug',
318        tokens === undefined ? `status: answered, context size unknown, window ${window} tokens` : `status: answered, context ${tokens} of ${window} tokens`,
319      ],
320    ],
321  }
322}
323
324// Hands over at the next pause, wherever the context stands. It needs the context at the time it is typed, so its answer waits for that read.
325const asapText = async ($: Engine, settings: ConfigResult, state: State): Promise<Answer> => {
326  const ready = await contextFor($, settings, state, 'asap')
327  if ('text' in ready) return ready
328  const { config, tokens, window } = ready
329  const result = asapFor(config, tokens)
330  state.asap = result.asap
331  // The last word counts: `asap` after `later` drops the later handover.
332  const dropped = state.later !== undefined
333  state.later = undefined
334  const lines: NonNullable<Answer['lines']> = result.lines.map(line => ['debug', line])
335  if (dropped) lines.unshift(['debug', 'later: dropped by asap'])
336  const prefix = dropped ? 'later dropped, ' : ''
337  // An answer that runs ends in a pause, and that pause judges. At rest no pause comes.
338  if (state.answerRunning) return { text: prefix + result.text, lines }
339  const rest = await asapAtRest($, config, state, tokens, window, result.text)
340  if (rest === undefined) return { text: prefix + result.text, lines }
341  return { text: prefix + rest, lines: [...lines, ['debug', 'asap: judged at rest']] }
342}
343
344// What the last stop said plus what runs now, as a pause would read them, for `asap` typed at rest.
345const restSnapshot = async ($: Engine, state: State, tokens: number, window: number): Promise<Snapshot | undefined> => {
346  const stop = state.lastStop
347  if (stop === undefined) return undefined
348  const [agents, turns, now] = await Promise.all([$.agent.list(), $.session.turns(), $.clock.now()])
349  // `later` is gone: `asapText` dropped it before this reads.
350  return { ...snapshotOf(stop, { tokens, window }, agents, turns, state), now, later: undefined }
351}
352
353// The step that starts the handover of `asap` at rest comes this long after the command's answer: the host refuses a compact, a /clear and a
354// user command inside the command itself (docs/host-behaviour.md, Commands).
355const ASAP_STEP_MS = 300
356
357// `asap` typed with no answer running: no stop and no pause will judge, so the command does. It judges as a pause does, and the handover that
358// follows begins in a step of its own. Not known what runs (no stop seen since the last turn started): the mark stays for the next pause.
359// The text it answers with, or `undefined` for the answer of the mark alone.
360const asapAtRest = async ($: Engine, config: Config, state: State, tokens: number, window: number, text: string): Promise<string | undefined> => {
361  let snapshot: Snapshot | undefined
362  try {
363    snapshot = await restSnapshot($, state, tokens, window)
364  } catch (err) {
365    // Not known what runs, as with no stop seen: the mark stays for the next pause, which reads it again.
366    logTo($, state)('warn', 'asap: could not read what runs')
367    return `${text}, ${RUNNING_UNKNOWN}`
368  }
369  // A turn or a handover may have started during the reads: the mark waits for its pause.
370  if (state.answerRunning || underWay(state)) return undefined
371  if (snapshot === undefined) return `${text}, ${RUNNING_UNKNOWN}`
372  const decision = decide(snapshot, config)
373  if (decision.kind === 'none') return undefined
374  if (decision.kind === 'defer') return reported(decision, config, state)
375  // The answer says it; a toast would say it twice.
376  if (decision.kind === 'dry-run') {
377    spendMarks($, state, snapshot.now, snapshot.turns)
378    return reported(decision, config, state)
379  }
380  state.asapScheduled = true
381  $.clock.after(ASAP_STEP_MS, () => void asapStep($, config, state))
382  return reported(decision, config, state)
383}
384
385// The step: it judges again with a fresh snapshot, and begins only if the judgment is still to hand over. Otherwise the mark stays and the
386// next pause judges.
387const asapStep = async ($: Engine, config: Config, state: State) => {
388  state.asapScheduled = false
389  try {
390    if (state.asap === undefined) return
391    const { tokens, window } = (await $.session.usage()).context
392    if (tokens === undefined) return
393    const snapshot = await restSnapshot($, state, tokens, window)
394    if (snapshot === undefined || state.asap === undefined || state.answerRunning || underWay(state)) return
395    const decision = decide(snapshot, config)
396    if (decision.kind === 'defer') sayOnce($, state, decision.reason, reported(decision, config, state))
397    else if (decision.kind === 'act' && handOverPrepared($, state, snapshot.now, snapshot.turns)) return
398    // The command's answer has said it is handing over; saying it again would double the line.
399    else if (decision.kind !== 'none') beginHandover($, decision, config, state, snapshot.now, snapshot.turns, false)
400  } catch (err) {
401    sayUnread($, state, err)
402  }
403}
404
405// The handoff written in a long pause, when `asap` is about to hand over: cold, it is the one handed over, since writing it anew on a cold
406// cache would read the whole context again; warm, it goes, and a fresh one carries everything up to now for little. True when it is handed over.
407const handOverPrepared = ($: Engine, state: State, now: number, turns: number): boolean => {
408  const prepared = state.idle.prepared
409  if (prepared?.kind !== 'real') return false
410  const log = logTo($, state)
411  if (now - (state.idle.touch ?? now) < HOUR_MS) {
412    const over = endPause($, state, 'asap with the cache warm')
413    if (over !== undefined) log('debug', over)
414    return false
415  }
416  // In one step with no wait, as a handover begins: the band and the timers go with the handoff.
417  const over = endPause($, state, 'asap takes the handoff written')
418  spendMarks($, state, now, turns)
419  state.handingOver = true
420  state.held = []
421  if (over !== undefined) log('debug', over)
422  log('info', 'idle: asap hands over the handoff written')
423  void returnInto($, state, prepared, undefined, () => HANDED_OVER)
424  return true
425}
426
427// A failed read is said once; the log carries the warning the first time only.
428const sayUnread = ($: Engine, state: State, err: unknown) => {
429  const line = unreadLine(why(err))
430  if (sayOnce($, state, line, line)) logTo($, state)('warn', 'could not check for a handoff')
431}
432
433// What the command does is the word after it. `later` takes an argument, so the rest is its argument; `status` and `redeliver` take none.
434const commandText = ($: Engine, settings: ConfigResult, args: string, state: State): Promise<Answer> | Answer => {
435  const words = args.trim()
436  const verb = verbOf(words)
437  if (verb === 'later') return laterText($, settings, words.slice(verb.length), state)
438  if (words === 'status') return statusText($, settings, state)
439  if (words === 'asap') return asapText($, settings, state)
440  return { text: redeliverText($, settings.ok ? settings.config : undefined, args, state) }
441}
442
443// Runs the handoff to its end and says how it went.
444const handoff = async ($: Engine, config: Config, state: State, tokens: number) => {
445  const io = sessionIo($, state)
446  io.log('info', `handoff starts, method ${config.handoffMethod}`)
447  try {
448    const done = (outcome: Outcome) => (outcome.method === 'builtin-compact' ? COMPACTED : HANDED_OVER)
449    // The failure that the mark alone causes (a compact as the only way, not asked because the last one was not enough) is told once for each mark.
450    const sayFailure = (failure: Failure) => {
451      const check = state.compactCheck
452      const byMarkAlone = failure.stage === 'compact' && config.handoffMethod === 'builtin-compact' && config.handoffFallback === 'none'
453      if (!byMarkAlone || check?.kind !== 'insufficient') return true
454      if (check.said) return false
455      check.said = true
456      return true
457    }
458    await settle($, state, { name: 'handover', done, sayFailure }, async () => {
459      const outcome = await carryOut(io, state, () => throughMethods(io, state, config))
460      if (outcome.failure === undefined) {
461        state.afterHandoff = { before: tokens, pauses: 0 }
462        // A compact is checked at the first tool call or pause after it; a handoff into a fresh session starts over.
463        state.compactCheck = outcome.method === 'builtin-compact' ? { kind: 'pending' } : undefined
464      }
465      return outcome
466    })
467  } finally {
468    // `carryOut` ends the holding itself, in one step with its last look at what was held; this covers a throw before that.
469    state.handingOver = false
470  }
471}
472
473// A reason for postponing, or a failure, is said once: `key` is what makes two lines the same one.
474const sayOnce = ($: Engine, state: State, key: string, text: string): boolean => {
475  if (state.said === key) return false
476  state.said = key
477  say($, text)
478  return true
479}
480
481// `act` comes only with the dry run off. It starts the handoff after the stop has returned: the stop is a hook the turn waits on, and the
482// command is a turn of its own that would wait on this one.
483const handOver = ($: Engine, decision: Extract<Decision, { kind: 'act' }>, config: Config, state: State, announce = true) => {
484  // Without a first way (a compact that was not enough, and no fallback) there is nothing to announce: the run fails at the mark.
485  const first = firstMethod(config, state)
486  if (announce && first !== undefined) say($, reportOf(decision, first))
487  state.handingOver = true
488  state.held = []
489  $.clock.after(0, () => void handoff($, config, state, decision.tokens))
490}
491
492// The first call of a tool in the main loop after a compact says whether it was enough. The size counts the model call that asked for the
493// tool, not the tool's result, and reads the same before and after `next(e)`: it is what the compact left and the prompt (measured,
494// host-behaviour). The pause would already hold the whole turn, and a turn that reads a lot would look like a compact that did too little.
495// Only the first reading judges. One with no size leaves it to the next call, whose size holds at most the results before it: still nearer
496// than the pause.
497const checkAtToolCall = async ($: Engine, settings: ConfigResult, config: Config, state: State) => {
498  if (state.compactCheck?.kind !== 'pending') return
499  const { tokens, window } = (await $.session.usage()).context
500  const before = state.compactCheck
501  if (before?.kind !== 'pending') return
502  state.compactCheck = checkOf(before, tokens, effectiveThreshold(config.threshold, window))
503  const line = judgedLine(before, state.compactCheck, tokens)
504  if (line === undefined) return
505  await readLevel($, settings, state)
506  logTo($, state)('debug', line)
507}
508
509// The pauses after a handoff that are logged against the context it left: enough to see where the fresh session starts and how it grows.
510const AFTER_HANDOFF_PAUSES = 3
511
512// The account's windows as the status line shows them; none off a subscription, and then the line has nothing of it.
513const limitsOf = (limits: readonly { kind: string; percentUsed: number }[]): string =>
514  limits.length === 0 ? '' : `, limits ${limits.map(l => `${l.kind} ${l.percentUsed}%`).join(', ')}`
515
516const outstandingOf = (e: Stop): NonNullable<State['lastStop']> => ({
517  backgroundTasks: activeTasks(e.background_tasks),
518  oneShotTimers: oneShots(e.session_crons),
519})
520
521// What a pause judges, but the clock, built the same way for the pause and for the end of a countdown. `later` is checked against the
522// context: the pause has already ended it in the state, the end of a countdown only reads it.
523const snapshotOf = (
524  seen: NonNullable<State['lastStop']>,
525  context: { tokens?: number; window: number },
526  agents: readonly { status: string }[],
527  turns: number,
528  state: State,
529): Omit<Snapshot, 'now'> => ({
530  tokens: context.tokens,
531  window: context.window,
532  ...seen,
533  subagents: agents.filter(a => a.status === 'running').length,
534  turns,
535  lastAttempt: state.lastAttempt,
536  later: laterAt(state.later, context.tokens),
537  asap: state.asap,
538})
539
540// The line for a decision names the way tried first; with none known (a compact that was not enough, and no fallback), the setting's way.
541const reported = (decision: Exclude<Decision, { kind: 'none' }>, config: Config, state: State): string =>
542  reportOf(decision, firstMethod(config, state) ?? config.handoffMethod)
543
544// Carried out at the pause, when the countdown runs out with a judgment of its own, or by `asap` at rest: only then is it an attempt and are
545// `later` and the mark spent. A countdown that ends without a handover leaves both as they were.
546const spendMarks = ($: Engine, state: State, at: number, atTurns: number) => {
547  const log = logTo($, state)
548  // A dry run counts as an attempt too: it shows the pace the real run would keep.
549  state.said = undefined
550  state.lastAttempt = { at, turns: atTurns }
551  // `later` held for this handover, whether it goes through or not: the next one comes at the threshold.
552  if (state.later !== undefined) log('debug', 'later: spent by this handover')
553  state.later = undefined
554  // `asap` is spent by the same handover.
555  if (state.asap !== undefined) log('debug', 'asap: spent by this handover')
556  state.asap = undefined
557  // A handover that began, whether it goes through or not, ends the deferred pauses.
558  state.deferred = undefined
559}
560
561const beginHandover = ($: Engine, judged: Countdown['decision'], config: Config, state: State, at: number, atTurns: number, announce = true) => {
562  // A second source of a handover (a countdown that was still being set up when `asap` began one at rest) finds it under way.
563  if (underWay(state)) return logTo($, state)('debug', 'handover: already under way')
564  spendMarks($, state, at, atTurns)
565  if (judged.kind === 'act') handOver($, judged, config, state, announce)
566  else say($, reported(judged, config, state))
567}
568
569// What a pause reads of the host, in one go. Planning the idle handoff after it goes by the same context.
570const pauseRead = ($: Engine) => Promise.all([$.session.usage(), $.agent.list(), $.session.turns(), $.clock.now()])
571type PauseRead = Awaited<ReturnType<typeof pauseRead>>
572
573const atPause = async ($: Engine, e: Stop, config: Config, state: State, [{ context, rateLimits }, agents, turns, now]: PauseRead) => {
574  // A context below where `later` was asked has shrunk (a compact, /clear): the later handover is over before `decide` judges.
575  const kept = laterAt(state.later, context.tokens)
576  const over = state.later !== undefined && kept === undefined
577  state.later = kept
578  // The mark ends the same way.
579  const asapKept = shrunk(state.asap, context.tokens)
580  const asapOver = state.asap !== undefined && asapKept === undefined
581  state.asap = asapKept
582  // The deferred pauses end the same way.
583  const deferred = shrunk(state.deferred, context.tokens)
584  const deferredOver = state.deferred !== undefined && deferred === undefined
585  state.deferred = deferred
586  const snapshot = { ...snapshotOf(outstandingOf(e), context, agents, turns, state), now }
587  const log = logTo($, state)
588  if (over) log('debug', LATER_OVER)
589  if (asapOver) log('debug', ASAP_OVER)
590  if (deferredOver) log('debug', 'countdown: deferred pauses over, the context shrank')
591  const fresh = state.afterHandoff
592  if (fresh !== undefined && fresh.pauses < AFTER_HANDOFF_PAUSES && snapshot.tokens !== undefined && snapshot.tokens < fresh.before) {
593    fresh.pauses += 1
594    log('debug', `after handoff: context ${snapshot.tokens} tokens (${fresh.before} before the handoff), pause ${fresh.pauses}`)
595  }
596  // Before `decide` judges and `firstMethod` names the first way: at the first pause after a compact that was not enough the context is above
597  // the threshold, so `decide` says `act`, and a mark set only after it would let that run compact once more to no effect.
598  const threshold = effectiveThreshold(config.threshold, snapshot.window)
599  const before = state.compactCheck
600  state.compactCheck = checkOf(before, snapshot.tokens, threshold)
601  const judged = judgedLine(before, state.compactCheck, snapshot.tokens)
602  if (judged !== undefined) log('debug', judged)
603  const decision = decide(snapshot, config)
604  log(
605    'info',
606    `pause: context ${snapshot.tokens} of ${snapshot.window} tokens (threshold ${threshold}${state.later === undefined ? '' : `, later ${thresholdInForce(config.threshold, snapshot.window, state.later)}`}${state.asap === undefined ? '' : ', asap'}, brake ${brakeOf(threshold, snapshot.window)}), ${snapshot.backgroundTasks} background tasks, ${snapshot.oneShotTimers} timers, ${snapshot.subagents} subagents, turn ${turns}, ${state.lastAttempt === undefined ? 'no attempt before' : `last attempt at turn ${state.lastAttempt.turns}`}${limitsOf(rateLimits)}, ${decidedOf(decision)}${'forced' in decision && decision.forced !== undefined ? `, forced past the brake by ${decision.forced}` : ''}`,
607  )
608  if (decision.kind === 'none') {
609    state.said = undefined
610    return
611  }
612  if (decision.kind === 'defer') {
613    sayOnce($, state, decision.reason, reported(decision, config, state))
614    return
615  }
616  // Carried out at the pause, or when the countdown runs out with a judgment of its own: only then is it an attempt and is `later` spent. A
617  // countdown that ends without a handover leaves both as they were.
618  const carry = (judged: Countdown['decision'], at: number, atTurns: number) => beginHandover($, judged, config, state, at, atTurns)
619  const postpone = (judged: Extract<Decision, { kind: 'defer' }>) => void sayOnce($, state, judged.reason, reported(judged, config, state))
620  const expiry: Expiry = { look: () => lookAgain($, e, state), judge: s => decide(s, config), carry, postpone }
621  // `asap` was asked for by the person: there is no countdown to say it again.
622  if (snapshot.asap === undefined && (await countdownBegun($, decision, config, state, snapshot, expiry))) return
623  carry(decision, now, turns)
624}
625
626// What the end of a countdown reads to judge again, as a pause does. Background tasks and one-shot timers are the stop's: after it they
627// cannot be read, and they change only in a turn, which ends the countdown.
628const lookAgain = async ($: Engine, e: Stop, state: State): Promise<Omit<Snapshot, 'now'>> => {
629  const [{ context }, agents, turns] = await Promise.all([$.session.usage(), $.agent.list(), $.session.turns()])
630  return snapshotOf(outstandingOf(e), context, agents, turns, state)
631}
632
633// A countdown before the handover, where one is set and the first way is known; without a first way the pause goes on as before and
634// the run says why it fails.
635const countdownBegun = async ($: Engine, decision: Countdown['decision'], config: Config, state: State, snapshot: Snapshot, expiry: Expiry) => {
636  if (config.countdownSeconds === 0) return false
637  const method = firstMethod(config, state)
638  if (method === undefined) return false
639  const { window } = snapshot
640  const kind = kindAt(snapshot, config, state.deferred?.pauses ?? 0)
641  if (kind === 'immediate') {
642    logTo($, state)('debug', 'countdown: none, at the emergency brake')
643    return false
644  }
645  // The countdown at the brake counts down to the brake, which is where it hands over.
646  const threshold = kind === 'brake' ? brakeFor(config, window) : thresholdInForce(config.threshold, window, state.later)
647  const start: Start =
648    kind === 'button' ? { kind, method, decision, threshold, moveBack: moveBack(config, window, decision.tokens) } : { kind, method, decision, threshold }
649  return begin(countdownIo($, state), state, config.countdownSeconds, start, expiry)
650}
651
652// Ends a running countdown for what a person did, at once and before any wait of the handler; the line comes after its read of the log
653// level. Nothing is moved back: the next pause counts down again.
654//
655// A hook that only watches for it passes the event on whatever goes wrong here (the `.catch` at its call): the event is the host's, and
656// a `.catch` on the hook itself would pass it on a second time where `next(e)` was what failed.
657const endCountdown = async ($: Engine, settings: ConfigResult, state: State, reason: EndReason) => {
658  if (!end(countdownIo($, state), state)) return
659  await readLevel($, settings, state)
660  logTo($, state)('debug', endedLine(reason))
661}
662
663// Ends the mark for what happened to the session, at once; the line comes after its read of the log level. Like the countdown it only watches.
664const endAsap = async ($: Engine, settings: ConfigResult, state: State, why: string) => {
665  if (state.asap === undefined) return
666  state.asap = undefined
667  await readLevel($, settings, state)
668  logTo($, state)('debug', `asap: ended, ${why}`)
669}
670
671// "Not now" moves the handover back as the command `later` with no number does, and answers the same way.
672const notNow = async ($: Engine, settings: ConfigResult, state: State) => {
673  const lines = await press(countdownIo($, state), state, () => laterText($, settings, '', state))
674  await readLevel($, settings, state)
675  const log = logTo($, state)
676  for (const [level, line] of lines) log(level, line)
677}
678
679// The stop is a hook the turn waits on and can only be asked to go on: an error here is said once and the stop ends as if the mod were absent.
680// The answer is what was read, or nothing where the read itself failed.
681const atPauseOrSay = async ($: Engine, e: Stop, config: Config, state: State): Promise<PauseRead | undefined> => {
682  let read: PauseRead | undefined
683  try {
684    read = await pauseRead($)
685    await atPause($, e, config, state, read)
686  } catch (err) {
687    sayUnread($, state, err)
688  }
689  return read
690}
691
692// This is the one place that judges, once the stop and the end of its turn are both in; a stop whose turn ended otherwise is dropped.
693// What it judges is taken by the caller before its read of the log level, together with whether a handoff runs: see `register`.
694// Only a turn that ended in an answer is a pause: Esc, an error or a refusal never starts a handoff.
695const judge = async ($: Engine, config: Config, state: State, paired: ReturnType<typeof judgeable>, handingOver: boolean) => {
696  const log = logTo($, state)
697  if ('waitingFor' in paired) {
698    log('debug', `judge: waiting for ${paired.waitingFor}`)
699    return
700  }
701  if (!handingOver && paired.end === 'answer') {
702    log('debug', 'judge: the stop and the end of the turn are both in, the turn ended in answer')
703    const read = await atPauseOrSay($, paired.stop, config, state)
704    // A handover begun at this pause replaces the session: there is no pause of it to plan for.
705    if (underWay(state)) return
706    // The plan goes by the context the pause judged; where the pause could not read, the plan reads the context itself.
707    try {
708      const tokens = read === undefined ? (await $.session.usage()).context.tokens : read[0].context.tokens
709      await planIdle($, config, state, paired.stop, tokens)
710    } catch {
711      log('warn', 'idle: could not plan')
712    }
713  } else log('debug', `judge: the turn ended in ${paired.end}, no pause`)
714}
715
716// The baseline belongs to the project's setup (system prompt, tools, MCP servers, CLAUDE.md), not to one session: kept per project folder.
717const baselineKey = (cwd: string) => `idle-baseline:${cwd}`
718
719// The first request of a fresh session is the project's baseline, unless the session is one a handoff of the mod began: its first message
720// is the handoff. Whatever fails here leaves the baseline as it was; the next fresh session tries again.
721const keepBaseline = async ($: Engine, state: State, usage: InputUsage) => {
722  try {
723    const [id, cwd, at] = await Promise.all([$.session.id(), $.session.cwd(), $.clock.now()])
724    if (id === state.deliveredInto) return
725    const tokens = baselineOfUsage(usage)
726    state.idle.baseline = tokens
727    await $.store.set(baselineKey(cwd), { tokens, at })
728  } catch {
729    // Not kept: the transcript of a resumed session is the fallback.
730  }
731}
732
733// Reads the transcript for the cache lifetime, and finds the baseline where the first request did not give it: kept for the project, or for
734// a resumed session with none kept, the first request in its transcript. A transcript above the limit is read at its end through `tail`, the
735// one process the mod starts; it holds the latest writes, not the first request. The path is never logged.
736const readIdle = async ($: Engine, state: State) => {
737  const idle = state.idle
738  idle.read = false
739  const log = logTo($, state)
740  let text: string | undefined
741  let whole = false
742  const path = idle.transcript
743  if (path !== undefined) {
744    try {
745      const plan = readPlan((await $.fs.stat(path)).size)
746      if (plan === 'read') text = String(await $.fs.read(path))
747      else {
748        const end = await $.process.run(['tail', '-c', String(TAIL_BYTES), path])
749        if (end.exitCode !== 0) throw new Error(`tail ended with ${end.exitCode}`)
750        text = end.stdout
751      }
752      whole = plan === 'read'
753    } catch {
754      if (!idle.unreadSaid) log('warn', 'idle: the transcript could not be read')
755      idle.unreadSaid = true
756    }
757  }
758  // An end read with no write in it leaves the lifetime read before.
759  const lifetime = text === undefined ? undefined : lifetimeOf(text)
760  if (lifetime !== undefined) idle.lifetime = lifetime
761  if (idle.baseline === undefined) {
762    try {
763      const kept = (await $.store.get(baselineKey(await $.session.cwd()))) as { tokens?: unknown } | undefined
764      if (typeof kept?.tokens === 'number') idle.baseline = kept.tokens
765    } catch {
766      // Unknown, as with none kept.
767    }
768  }
769  if (idle.baseline === undefined && !idle.fresh && whole && text !== undefined) idle.baseline = firstBaselineOf(text)
770  log('debug', `idle: cache lifetime ${idle.lifetime ?? 'unknown'}, baseline ${idle.baseline === undefined ? 'unknown' : `${idle.baseline} tokens`}`)
771}
772
773// The line of a pause that is over, and whether a handoff written went with it.
774const pauseOverLine = (why: string, dropped: boolean) => `idle: pause over, ${why}${dropped ? ', the handoff written is dropped' : ''}`
775
776// The pause is over: the planned step and a handoff written for it go. Only the state changes here; the line it gives back, for the log,
777// is the caller's to write once it has read the log level. `why` is for that line only.
778const endPause = ($: Engine, state: State, why: string): string | undefined => {
779  const idle = state.idle
780  // The step under way has no timer and nothing written yet: it is told, and drops what it writes.
781  if (state.preparing && idle.prepared === undefined) idle.endedWhilePreparing = why
782  if (idle.timer === undefined && idle.prepared === undefined) return undefined
783  idle.timer?.cancel()
784  idle.timer = undefined
785  idle.coldTimer?.cancel()
786  idle.coldTimer = undefined
787  if (idle.coldShown) void $.ui.invalidate('ui.render')
788  idle.coldShown = false
789  const dropped = idle.prepared !== undefined
790  idle.prepared = undefined
791  return pauseOverLine(why, dropped)
792}
793
794// Writes the line `endPause` gave back, after the read of the log level, for a handler that has nothing else to log.
795const logPauseOver = async ($: Engine, settings: ConfigResult, state: State, over: string | undefined) => {
796  if (over === undefined) return
797  await readLevel($, settings, state)
798  logTo($, state)('debug', over)
799}
800
801// At the end of each answer, the step that writes the handoff is planned anew, 55 minutes after the answer's last request.
802const planIdle = async ($: Engine, config: Config, state: State, stop: Stop, tokens: number | undefined) => {
803  const idle = state.idle
804  idle.timer?.cancel()
805  idle.timer = undefined
806  const log = logTo($, state)
807  const verdict = planVerdict({
808    on: config.idleHandoff,
809    lifetime: idle.lifetime,
810    tokens,
811    floor: floorOf(config.idleHandoffMinTokens, idle.baseline),
812    handingOver: underWay(state),
813    timers: stop.session_crons?.length ?? 0,
814  })
815  const touch = idle.touch
816  if (!verdict.plan || touch === undefined) return log('debug', `idle: not planned, ${verdict.plan ? 'no request seen' : verdict.why}`)
817  const now = await $.clock.now()
818  const wait = Math.max(0, prepareDueAt(touch) - now)
819  idle.timer = $.clock.after(wait, () => void prepareIdle($, config, state, now))
820  log('debug', `idle: planned in ${Math.round(wait / 60_000)} min`)
821}
822
823// The step itself: the reads first, then every check and the mark in one go with no wait between, so that a turn of the person's that starts
824// meanwhile is seen and not taken for the mod's own.
825const prepareIdle = async ($: Engine, config: Config, state: State, since: number) => {
826  const idle = state.idle
827  idle.timer = undefined
828  idle.endedWhilePreparing = undefined
829  const log = logTo($, state)
830  try {
831    const [agents, now, usage] = await Promise.all([
832      $.agent.list(),
833      $.clock.now(),
834      $.session.usage(),
835      idle.transcript === undefined ? undefined : readIdle($, state),
836    ])
837    const busy = state.answerRunning
838      ? 'a turn runs'
839      : underWay(state)
840        ? 'a handover runs'
841        : state.countdown !== undefined
842          ? 'a countdown runs'
843          : agents.some(a => a.status === 'running')
844            ? 'a subagent runs'
845            : undefined
846    const verdict = fireVerdict({ busy, lifetime: idle.lifetime, now, touch: idle.touch ?? now })
847    if (!verdict.go) return log('debug', `idle: not prepared, ${verdict.why}`)
848    if (config.dryRun) {
849      idle.prepared = { kind: 'simulated', since }
850      return log('info', 'idle: would prepare')
851    }
852    state.preparing = true
853    state.held = []
854    const io = sessionIo($, state)
855    try {
856      const r = await prepare(io, state, config)
857      const over = idle.endedWhilePreparing
858      if (over !== undefined) log('debug', pauseOverLine(over, true))
859      else if ('stage' in r) {
860        say($, notPreparedLine(r.reason))
861        log('warn', `idle: preparing failed (stage ${r.stage})`)
862      } else {
863        idle.prepared = { kind: 'real', text: r.text, ...(r.file === undefined ? {} : { file: r.file }), since }
864        log('info', `idle: handoff prepared, ${r.text.length} characters`)
865        // Reading the cache keeps it for another hour from when the model was asked (measured); a request that missed it may have written a
866        // short-lived entry of its own. The command's turn needs no check: its requests read or write the session's own entry, and each moved
867        // the touch as it went out.
868        if (r.began !== undefined && !idle.doubted) {
869          if (isHit(r.usage, usage.context.tokens ?? Number.POSITIVE_INFINITY)) idle.touch = r.began
870          else log('debug', 'idle: the handoff missed the cache, the touch stays')
871        }
872        const touch = idle.touch ?? now
873        idle.coldTimer = $.clock.after(Math.max(0, touch + HOUR_MS - (await $.clock.now())), () => {
874          idle.coldTimer = undefined
875          showCold($, state)
876        })
877      }
878    } finally {
879      await release(io, state, undefined, () => {
880        state.preparing = false
881      })
882    }
883  } catch (err) {
884    state.preparing = false
885    say($, notPreparedLine(why(err)))
886    log('warn', 'idle: preparing failed (unexpected error)')
887  }
888}
889
890// The message that came back after the handoff was written, which has already taken it (`claim`). Judged by the clock and by what runs;
891// whatever the verdict, the handoff written is gone. Only a cold cache with nothing running switches: the message is turned away here and
892// goes into the fresh session with the handoff, from a step of its own, since the host refuses a /clear from a hook that holds a turn.
893// Everything else lets the message through into this session. The answer is the drop reason, or `undefined` to let it through.
894const comeBack = async ($: Engine, state: State, prepared: IdlePrepared, input: { text: string; attachments?: readonly unknown[] }) => {
895  const log = logTo($, state)
896  let verdict: ReturnType<typeof returnVerdict>
897  let now: number
898  let turns: number
899  let tokens: number | undefined
900  try {
901    const [agents, at, count, usage] = await Promise.all([$.agent.list(), $.clock.now(), $.session.turns(), $.session.usage()])
902    now = at
903    turns = count
904    tokens = usage.context.tokens
905    const stop = state.lastStop
906    const running = { subagents: agents.filter(a => a.status === 'running').length, backgroundTasks: 0, oneShotTimers: 0, ...stop }
907    const busy = busyReason(running) !== undefined
908    verdict = returnVerdict({ now, touch: state.idle.touch ?? now, busy, attachments: (input.attachments?.length ?? 0) > 0 })
909  } catch {
910    log('warn', 'idle: could not judge the return, the message stays in this session')
911    endReturn($, state)
912    return undefined
913  }
914  if (verdict === 'cold' && prepared.kind === 'real') {
915    // In one step with no wait: from here the holding is the handover's, and the message is the first of what it carries.
916    state.returning = false
917    state.handingOver = true
918    state.held.unshift(input.text)
919    spendMarks($, state, now, turns)
920    log('info', 'idle: return after the cache expired, handing over')
921    $.clock.after(0, () => void returnInto($, state, prepared, input.text, () => switchedLine(idleFor(now - prepared.since))))
922    return HELD_MOMENT
923  }
924  endReturn($, state)
925  if (verdict === 'warm') {
926    state.idle.checkAgainst = tokens
927    log('debug', 'idle: return while the cache is warm, the handoff written is dropped')
928    return undefined
929  }
930  // What remains keeps a cold cache in this session: running work, an attachment, or a dry run, which counts as an attempt as at the threshold.
931  if (verdict === 'busy') say($, WORK_STAYS)
932  else if (verdict === 'attachments') say($, ATTACHMENTS_STAY)
933  else {
934    spendMarks($, state, now, turns)
935    say($, DRY_RUN_RETURN)
936  }
937  log('debug', `idle: return after the cache expired, ${verdict === 'cold' ? 'dry run' : verdict}, the message stays in this session`)
938  return undefined
939}
940
941// The judgment is over and the message goes into this session: what was typed meanwhile follows it, from a step of its own so that it comes
942// after the message the hook lets through.
943const endReturn = ($: Engine, state: State) => {
944  if (state.held.length === 0) {
945    state.returning = false
946    return
947  }
948  $.clock.after(
949    0,
950    () =>
951      void release(sessionIo($, state), state, undefined, () => {
952        state.returning = false
953      }),
954  )
955}
956
957// The switch: /clear, then the handoff with the message that came back. The host leaves a message a hook turned away in the prompt, and that
958// survives the /clear: once the message is in, the prompt is cleared of it, unless the person has changed it since.
959// `asap` hands the handoff over the same way, with no message of its own (`text` undefined) and its own line at the end.
960const returnInto = async ($: Engine, state: State, prepared: Extract<IdlePrepared, { kind: 'real' }>, text: string | undefined, done: () => string) => {
961  const io = sessionIo($, state)
962  try {
963    let delivered = false
964    await settle($, state, { name: 'handover', done }, () =>
965      carryOut(io, state, async () => {
966        const failure = await deliverReturn(io, state, prepared, text !== undefined)
967        delivered = failure === undefined
968        return failure === undefined ? {} : { failure }
969      }),
970    )
971    if (delivered && text !== undefined && (await $.prompt.read()).text.trim() === text.trim()) await $.prompt.fill({ text: '', mode: 'replace' })
972  } catch {
973    io.log('debug', 'idle: could not clear the prompt after the switch')
974  } finally {
975    state.handingOver = false
976  }
977}
978
979// The band says it once the handoff written has gone cold. A late timer (system sleep) shows it late: typing checks the clock as well.
980const showCold = ($: Engine, state: State) => {
981  const idle = state.idle
982  if (idle.prepared?.kind !== 'real' || idle.coldShown) return
983  idle.coldShown = true
984  void $.ui.invalidate('ui.render')
985  logTo($, state)('debug', 'idle: the handoff written has gone cold, the band says so')
986}
987
988// From typing: the level is read only once the band is to show, not at every key.
989const checkCold = async ($: Engine, settings: ConfigResult, state: State) => {
990  const touch = state.idle.touch
991  if (touch === undefined || (await $.clock.now()) - touch < HOUR_MS) return
992  await readLevel($, settings, state)
993  showCold($, state)
994}
995
996export const register: Register = (on, options) => {
997  const settings = configOf(options)
998  const config = settings.ok ? settings.config : undefined
999  const state = freshState()
1000
1001  // Every handler reads the log level, each on its own: a value changed in a settings file reaches the host at the next turn. The read is a
1002  // wait, and two handlers' reads need not come back in the order they went out, so a handler changes the state before it reads, as it
1003  // would with no read at all, and logs after.
1004  on('session.start', async ($, e, next) => {
1005    // Every session reads the transcript at its first turn: a /clear does not reload the mod, but ends what was known of the session.
1006    state.idle.read = true
1007    await readLevel($, settings, state)
1008    await introduce($, settings, state)
1009    return next(e)
1010  })
1011
1012  // The redelivery's timer runs outside any handler; its lines come after its own waits, by when the read here is in.
1013  on('command.run', { command: COMMAND }, async ($, e) => {
1014    // `later` and `asap` end a countdown before they move the handover back or ask for one. Typed, its first character has already ended
1015    // it; a command raises no submit, and from the history it is expected to come with no edit either (docs/host-behaviour.md, Commands).
1016    // `redeliver` leaves it running.
1017    const verb = verbOf(e.args)
1018    const ended = (verb === 'later' || verb === 'asap') && end(countdownIo($, state), state)
1019    const { text, lines = [] } = await commandText($, settings, e.args, state)
1020    await readLevel($, settings, state)
1021    const log = logTo($, state)
1022    if (ended) log('debug', endedLine('command'))
1023    for (const [level, line] of lines) log(level, line)
1024    return { text }
1025  }).catch((_, e, next) => next(e))
1026
1027  // A reload puts the mod into a session that is already running, which raises no session start: the first turn says the settings then.
1028  on('turn.start', async ($, e, next) => {
1029    turnStarted(state.pairing)
1030    // Only a mod with settings it can trust sees the end of the answer (`turn.complete` is past the gate below), so only it marks one running.
1031    if (config !== undefined) state.answerRunning = true
1032    state.lastStop = undefined
1033    const awaited = state.turnWait !== undefined
1034    const own = startOfTurn(state, e.turnId)
1035    // A turn is the end of a pause, unless it is the command's own while it writes the handoff. Any other turn meanwhile ends it: a slash
1036    // command that starts one, or the message held meanwhile, whose turn starts while it is put in (its submit settles once the turn started).
1037    const over = state.preparing && own ? undefined : endPause($, state, 'a turn started')
1038    // A turn is work, whoever started it (a finished background task starts one too): no handover in the middle of it.
1039    const ended = end(countdownIo($, state), state)
1040    await readLevel($, settings, state)
1041    await introduce($, settings, state)
1042    const log = logTo($, state)
1043    log('trace', `turn.start ${e.turnId}`)
1044    if (ended) log('debug', endedLine('turn'))
1045    if (awaited) log('debug', own ? `turn ${e.turnId} is the command's turn` : `turn ${e.turnId} is not the command's turn`)
1046    if (over !== undefined) log('debug', over)
1047    return next(e)
1048  })
1049
1050  // A mod with settings it cannot trust goes no further: it has said why where the person looks, and answers its command.
1051  if (config === undefined) return
1052
1053  // How the session began: a start or a /clear has no conversation, so its first request is the project's baseline.
1054  on('classic.SessionStart', async ($, e, next) => {
1055    if (e.agent_id === undefined) {
1056      state.idle.fresh = e.source === 'startup' || e.source === 'clear'
1057      state.idle.baselineDue = state.idle.fresh
1058      state.idle.transcript = e.transcript_path
1059    }
1060    return next(e)
1061  })
1062
1063  // A change of model says the cache lifetime outright, the one place the host does.
1064  on('classic.PostModelSwitch', async ($, e, next) => {
1065    if (e.agent_id === undefined) {
1066      state.idle.lifetime = e.cache_ttl
1067      await logPauseOver($, settings, state, endPause($, state, 'a change of model'))
1068    }
1069    return next(e)
1070  })
1071
1072  // While the handoff is written nobody is there to answer a permission the command's turn asks for: it is turned down, which ends the turn
1073  // at once (measured), and the mod writes the handoff instead. The mod never grants one, and outside that turn every prompt shows as it is.
1074  on('classic.PermissionRequest', async ($, e, next) => {
1075    if (!state.preparing || state.turnWait?.own === undefined || e.agent_id !== undefined) return next(e)
1076    await readLevel($, settings, state)
1077    logTo($, state)('debug', `idle: a permission for ${e.tool_name} was turned down while the handoff is written`)
1078    return { decision: { behavior: 'deny', interrupt: true } }
1079  })
1080
1081  // Only for the path of the transcript: the end of the first turn reads it, and may come before the stop. After a reload no session start
1082  // came to give it.
1083  on('classic.UserPromptSubmit', async ($, e, next) => {
1084    if (e.agent_id === undefined) state.idle.transcript = e.transcript_path
1085    return next(e)
1086  })
1087
1088  // Each request of the main loop reads or writes the cache: its start is the touch, taken before the request goes out, which is never too
1089  // late. The first request of a fresh session gives the baseline. The level is not read here: a read at every request would be a round
1090  // trip each; the level in force is the one the turn's start read.
1091  on('turn.step', async function* ($, e, next) {
1092    if (e.agentId !== undefined) return yield* next(e)
1093    const idle = state.idle
1094    // The command's turn writing the handoff reads the cache like any turn, unless that is no longer believed.
1095    if (!(state.preparing && idle.doubted)) idle.touch = await $.clock.now()
1096    const r = yield* next(e)
1097    const against = idle.checkAgainst
1098    if (against !== undefined && e.index === 0) {
1099      idle.checkAgainst = undefined
1100      if (r.usage !== null && !isHit(r.usage, against) && !idle.doubted) {
1101        idle.doubted = true
1102        logTo($, state)('warn', 'idle: the request after a warm return missed the cache, the handoff written no longer counts as reading it')
1103      }
1104    }
1105    if (idle.baselineDue && e.index === 0) {
1106      idle.baselineDue = false
1107      if (r.usage !== null) await keepBaseline($, state, r.usage)
1108    }
1109    return r
1110  })
1111
1112  // What is typed while a handoff runs is held, and goes in when it is done. The hold comes first and at once, before any wait: the handoff
1113  // may end during one, and the input must be judged by the state it was typed in.
1114  on('prompt.submit', async ($, e, next) => {
1115    const reason = hold(state, e)
1116    const handingOver = underWay(state)
1117    // Only a message that is let through can be the one that comes back after the handoff was written.
1118    const shown = state.idle.coldShown
1119    const prepared = reason === undefined && !handingOver ? claim(state, e) : undefined
1120    if (prepared !== undefined && shown) void $.ui.invalidate('ui.render')
1121    const ended = end(countdownIo($, state), state)
1122    await readLevel($, settings, state)
1123    if (ended) logTo($, state)('debug', endedLine('submit'))
1124    if (handingOver) logTo($, state)('debug', `input while handing over (${e.origin.kind}): ${reason === undefined ? 'let through' : 'held'}`)
1125    const dropped = prepared === undefined ? reason : await comeBack($, state, prepared, e)
1126    return dropped === undefined ? next(e) : { drop: dropped }
1127  }).catch((_, e, next) => next(e))
1128
1129  // Watches the calls of the command's turn for the user's decline at the permission prompt, and the first call after a compact for the size
1130  // it left; changes nothing about the calls, only the mod's own state. Whatever goes wrong here stays here: the call is the host's, and this hook is only a pair of eyes.
1131  on('tool.call', async ($, e, next) => {
1132    const out = await next(e)
1133    try {
1134      if (noteToolResult(state, e, out)) {
1135        await readLevel($, settings, state)
1136        logTo($, state)('debug', "the command's turn: a permission was declined")
1137      }
1138    } catch {
1139      // Not noting it leaves the run as it was before: the fallback follows.
1140    }
1141    try {
1142      if (e.agentId === undefined) await checkAtToolCall($, settings, config, state)
1143    } catch {
1144      // A size that cannot be read leaves the compact to the pause, as before.
1145      await readLevel($, settings, state)
1146      logTo($, state)('debug', 'could not read the context size at a tool call, the pause judges the compact')
1147    }
1148    return out
1149  })
1150
1151  // The end of the session (exit, /clear, a resume) ends a countdown: a /clear can come with no edit, submit or turn, from the history.
1152  on('session.end', async ($, e, next) => {
1153    await endCountdown($, settings, state, 'session').catch(() => {})
1154    await endAsap($, settings, state, 'the session ended').catch(() => {})
1155    state.lastStop = undefined
1156    state.asapScheduled = false
1157    // What was known of the session's cache and baseline ends with it; the next session's first turn reads again.
1158    const over = endPause($, state, 'the session ended')
1159    state.idle = freshIdle()
1160    await logPauseOver($, settings, state, over)
1161    return next(e)
1162  })
1163
1164  // A compact the mod did not start ends a countdown too. It raises no session end (the session id stays), no turn and no stop; the
1165  // context it leaves is unknown at first. Only watched, before it runs. A precompute only prepares one and changes nothing. The mod's own
1166  // compact comes after its countdown ran out, and meets none.
1167  on('session.compact', async ($, e, next) => {
1168    if (e.trigger !== 'precompute') await endCountdown($, settings, state, 'compact').catch(() => {})
1169    if (e.trigger !== 'precompute' && e.agentId === undefined) await logPauseOver($, settings, state, endPause($, state, 'a compact'))
1170    const result = await next(e)
1171    // After a compact the context is unknown at first, and nothing would end the mark; a compact the host refused leaves it standing. A
1172    // subagent's compact of its own transcript (`agentId`) leaves the main conversation as it was.
1173    if (e.trigger !== 'precompute' && e.agentId === undefined && !('skip' in result)) await endAsap($, settings, state, 'a compact').catch(() => {})
1174    return result
1175  })
1176
1177  // Typing ends a countdown with the button; the other two hand over with the draft, which stays in the prompt. Only the mod's own state
1178  // changes, and the edit lands as it would without the mod. The button shows unless a survey holds the band.
1179  on('prompt.edit', async ($, e, next) => {
1180    const countdown = state.countdown
1181    if (countdown?.kind === 'button' && typed(e, !countdown.survey)) await endCountdown($, settings, state, 'edit').catch(() => {})
1182    const out = await next(e)
1183    // After a sleep the step that shows the band may not have come yet: the typing finds the cache cold first. Never waited for.
1184    if (state.idle.prepared?.kind === 'real' && !state.idle.coldShown) void checkCold($, settings, state).catch(() => {})
1185    return out
1186  })
1187
1188  // The band above the prompt, while a countdown runs or once the handoff written in a pause has gone cold: drawn from the state alone, so drawing waits on nothing and changes nothing but
1189  // whether a survey holds the band, which only the drawing sees. A survey has the band while it shows. The first draw measured gave
1190  // `hasSurvey` as undefined (docs/host-behaviour.md, Commands): that is no survey.
1191  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
1192    const countdown = state.countdown
1193    if (countdown === undefined) {
1194      // The band of a handoff gone cold: no key and no countdown, and a survey has the band while it shows.
1195      if (!state.idle.coldShown || e.props.hasSurvey === true) return next(e)
1196      const { Box, Text } = $.ui.resolve(e)
1197      return (
1198        <Box>
1199          <Text>{COLD_BAND}</Text>
1200        </Box>
hooks/config.ts 122 lines
1// Reads the settings of the plugin. A value that does not fit is rejected with its value and the reason, never replaced by a default:
2// a quiet replacement would hand over at a threshold nobody chose.
3
4// The ways to hand a session over, once: the type is read from this list, `plugin.json` offers the same values and `docs/src/check.py`
5// reads the list (a union type could only be read by a pattern that breaks easily).
6export const METHODS = ['external-command', 'internal-handoff', 'builtin-compact'] as const
7export type Method = (typeof METHODS)[number]
8
9export type Config = {
10  threshold: number
11  dryRun: boolean
12  minGapMinutes: number
13  minGapTurns: number
14  // How far `/sweet-spot later` with no number moves a handover back, in tokens.
15  laterTokens: number
16  // How long the mod waits before an automatic handover, in seconds. 0: no wait.
17  countdownSeconds: number
18  // The main way, and the one tried when it fails before it changed the session. 'none': no second try.
19  handoffMethod: Method
20  handoffFallback: Method | 'none'
21  handoffCommand: string
22  // The handoff after a long pause: on or off, and the context size it starts at. 0: the session's baseline plus a fixed distance.
23  idleHandoff: boolean
24  idleHandoffMinTokens: number
25}
26export type ConfigResult = { ok: true; config: Config } | { ok: false; errors: string[] }
27
28// The defaults of plugin.json. The host checks type and `min` of plugin.json before it loads the mod, so what is checked here is a second
29// line of defence, and the only one for the rules across values; the tests pin these and the defaults of plugin.json to the same values.
30// No command by default: one that merely shares the name `handoff` is not the user's to run.
31export const DEFAULTS: Config = {
32  threshold: 200_000,
33  dryRun: false,
34  minGapMinutes: 10,
35  minGapTurns: 5,
36  laterTokens: 25_000,
37  countdownSeconds: 10,
38  handoffMethod: 'internal-handoff',
39  handoffFallback: 'builtin-compact',
40  handoffCommand: '',
41  idleHandoff: true,
42  idleHandoffMinTokens: 0,
43}
44
45// The same number is `min` and the text of `threshold` in plugin.json and the README; the tests cannot read those files, so a change here
46// is made in all three. Below this a fresh session already starts near the threshold (the CHANGELOG has the numbers, under Chosen, with the reason): every pause would hand over again.
47const MIN_THRESHOLD = 130_000
48
49// The span of `countdown_seconds`, the same as `min` and `max` in plugin.json; `docs/src/check.py` compares the two. The test engine refuses a
50// value above `max` before the mod loads, but lets a fraction through; whether a real load checks `max` is not measured.
51export const COUNTDOWN_SECONDS = { min: 0, max: 60 } as const
52
53// `JSON.stringify` writes NaN and Infinity as null.
54const show = (raw: unknown): string => (typeof raw === 'number' ? String(raw) : (JSON.stringify(raw) ?? String(raw)))
55
56type Checked<T> = { value: T } | { error: string }
57
58const finiteAtLeast = (raw: unknown, min: number, whole: boolean, { why = '', max = Number.POSITIVE_INFINITY } = {}): Checked<number> => {
59  if (typeof raw !== 'number') return { error: `${show(raw)} is not a number` }
60  if (!Number.isFinite(raw)) return { error: `${show(raw)} is not a finite number` }
61  if (raw < min) return { error: `${show(raw)} is below ${min}${why}` }
62  if (raw > max) return { error: `${show(raw)} is above ${max}` }
63  if (whole && !Number.isInteger(raw)) return { error: `${show(raw)} is not a whole number` }
64  return { value: raw }
65}
66
67const flag = (raw: unknown): Checked<boolean> => (typeof raw === 'boolean' ? { value: raw } : { error: `${show(raw)} is not true or false` })
68
69const commandName = (raw: unknown): Checked<string> => {
70  if (typeof raw !== 'string') return { error: `${show(raw)} is not a text` }
71  // Empty is no command of your own: the internal handoff writes it.
72  if (raw.trim() === '') return { value: '' }
73  if (raw.startsWith('/')) return { error: `${show(raw)} starts with a slash, give the name without it` }
74  if (/\s/.test(raw)) return { error: `${show(raw)} has a space, give the name only` }
75  return { value: raw }
76}
77
78const oneOf =
79  <T extends string>(values: readonly T[]) =>
80  (raw: unknown): Checked<T> =>
81    typeof raw === 'string' && (values as readonly string[]).includes(raw) ? { value: raw as T } : { error: `${show(raw)} is not one of ${values.join(', ')}` }
82
83export const configOf = (options: Record<string, unknown>): ConfigResult => {
84  const errors: string[] = []
85  // An unset value is the default; a set one is checked as it is.
86  const take = <T>(key: string, fallback: T, check: (raw: unknown) => Checked<T>): T => {
87    const raw = options[key]
88    if (raw === undefined) return fallback
89    const checked = check(raw)
90    if ('error' in checked) {
91      errors.push(`${key}: ${checked.error}`)
92      return fallback
93    }
94    return checked.value
95  }
96  const config: Config = {
97    threshold: take('threshold', DEFAULTS.threshold, raw =>
98      finiteAtLeast(raw, MIN_THRESHOLD, false, { why: ', a handoff that close to the start would repeat' }),
99    ),
100    dryRun: take('dry_run', DEFAULTS.dryRun, flag),
101    minGapMinutes: take('min_gap_minutes', DEFAULTS.minGapMinutes, raw => finiteAtLeast(raw, 0, false)),
102    minGapTurns: take('min_gap_turns', DEFAULTS.minGapTurns, raw => finiteAtLeast(raw, 0, true)),
103    laterTokens: take('later_tokens', DEFAULTS.laterTokens, raw => finiteAtLeast(raw, 1, true)),
104    countdownSeconds: take('countdown_seconds', DEFAULTS.countdownSeconds, raw =>
105      finiteAtLeast(raw, COUNTDOWN_SECONDS.min, true, { max: COUNTDOWN_SECONDS.max }),
106    ),
107    handoffMethod: take('handoff_method', DEFAULTS.handoffMethod, oneOf(METHODS)),
108    handoffFallback: take('handoff_fallback', DEFAULTS.handoffFallback, oneOf([...METHODS, 'none'] as const)),
109    handoffCommand: take('handoff_command', DEFAULTS.handoffCommand, commandName),
110    idleHandoff: take('idle_handoff', DEFAULTS.idleHandoff, flag),
111    idleHandoffMinTokens: take('idle_handoff_min_tokens', DEFAULTS.idleHandoffMinTokens, raw => finiteAtLeast(raw, 0, true)),
112  }
113  // The same way twice is no second try. Done before the rule below, so that one field asking for the command is told once.
114  if (config.handoffFallback === config.handoffMethod) config.handoffFallback = 'none'
115  // A way that can never run switches the mod off with the reason, rather than failing at every handoff.
116  if (config.handoffCommand === '') {
117    const key = config.handoffMethod === 'external-command' ? 'handoff_method' : config.handoffFallback === 'external-command' ? 'handoff_fallback' : undefined
118    if (key) errors.push(`${key}: "external-command" needs handoff_command, which is empty`)
119  }
120  return errors.length > 0 ? { ok: false, errors } : { ok: true, config }
121}
122
hooks/asap.ts 19 lines
1// What `/sweet-spot asap` makes of the context it is typed at. Pure: the command reads the session and keeps what comes back, so that the
2// answer and the pause judge from the one mark.
3
4import type { Config } from './config'
5import type { Asap } from './decide'
6
7// The mark and the answer at a known context. An answer that is running ends in a pause, and that pause hands over.
8export const asapFor = (config: Config, tokens: number): { asap: Asap; text: string; lines: string[] } => ({
9  asap: { from: tokens },
10  text: `${config.dryRun ? 'dry run, would hand over' : 'hands over'} at the next pause, context ${tokens} tokens`,
11  lines: [`asap: set at context ${tokens}`],
12})
13
14// The debug line for a mark that a shrunken context ended, at a pause.
15export const ASAP_OVER = 'asap: over, the context shrank below where it was asked'
16
17// What the answer adds at rest when the last stop is not known (after /clear, or an Esc with no stop): the mod does not hand over blind.
18export const RUNNING_UNKNOWN = 'what is running is not known until the next answer'
19
hooks/decide.ts 96 lines
1// Whether to hand over now. Pure: the caller takes the snapshot, acts on the answer, and keeps the record of the last attempt.
2
3import type { Config } from './config'
4
5export type Attempt = { at: number; turns: number }
6
7// A handover moved back by `/sweet-spot later`: asked at a context of `from`, to hand over at `until`. `until` is kept as asked, not
8// capped: the cap comes from the window, which can change before the pause that judges it.
9export type Later = { from: number; until: number }
10
11// A handover asked for with `/sweet-spot asap`, at a context of `from`: it lifts the threshold and the minimum gap for the next handover, and
12// nothing else. Work that is running still holds it back, below the emergency brake.
13export type Asap = { from: number }
14
15export type Snapshot = {
16  // Input side of the last answer; undefined in a fresh session and after /clear.
17  tokens: number | undefined
18  window: number
19  backgroundTasks: number
20  oneShotTimers: number
21  subagents: number
22  now: number
23  turns: number
24  lastAttempt: Attempt | undefined
25  later: Later | undefined
26  asap: Asap | undefined
27}
28
29// Work that is still running and reports back to the session. A handoff written now cannot hold its result, and the result would come
30// into a fresh session that does not know what it was for.
31export type BusyReason = 'subagent' | 'background-task' | 'one-shot-timer'
32
33// `turn` only the end of a countdown finds: a turn that started while the countdown began, before there was one for it to end.
34export type DeferReason = BusyReason | 'min-gap' | 'turn'
35
36// `act` and `dry-run` ("would hand over") carry `forced`: the work the emergency brake handed over despite.
37export type Decision =
38  | { kind: 'none' }
39  | { kind: 'defer'; reason: DeferReason; tokens: number }
40  | { kind: 'act'; tokens: number; forced?: BusyReason }
41  | { kind: 'dry-run'; tokens: number; forced?: BusyReason }
42
43// Past the threshold the context may grow this far while work runs, and never beyond 90 percent of the window.
44const BRAKE_EXTRA = 150_000
45
46// Not a setting: 80 percent of the window, so that the handoff itself still fits. Whole numbers, because 0.8 has no exact binary form.
47export const effectiveThreshold = (threshold: number, window: number): number => Math.min(threshold, Math.floor((window * 4) / 5))
48
49// Counted from the threshold in force, which a small window has already lowered.
50export const brakeOf = (threshold: number, window: number): number => Math.min(Math.floor((window * 9) / 10), threshold + BRAKE_EXTRA)
51
52// The brake of the settings at a window, counted from the threshold in force there.
53export const brakeFor = (config: Pick<Config, 'threshold'>, window: number): number => brakeOf(effectiveThreshold(config.threshold, window), window)
54
55// The value a pause judges at: the threshold in force, or the one `later` asked for. That one is capped at the brake of the window as it
56// is now (after a change to a smaller window it would lie past the brake, or past the window, and nothing would ever hand over), and it never
57// lies below the threshold in force (after a change to a larger window it would bring the handover forward). The answer of `later`, the
58// pause's log line and `decide` all take it from here.
59export const thresholdInForce = (threshold: number, window: number, later: Later | undefined): number => {
60  const t = effectiveThreshold(threshold, window)
61  return later === undefined ? t : Math.max(t, Math.min(later.until, brakeOf(t, window)))
62}
63
64// A context below where a mark was set has shrunk (a compact of any kind, or /clear), and ends it; no reading of the context changes
65// nothing, as with the compact check.
66export const shrunk = <T extends { from: number }>(mark: T | undefined, tokens: number | undefined): T | undefined =>
67  mark === undefined || tokens === undefined || tokens >= mark.from ? mark : undefined
68export const laterAt = shrunk<Later>
69
70// The one rule of what counts as running work, for the pause and for coming back after a long one.
71export const busyReason = (s: Pick<Snapshot, 'subagents' | 'backgroundTasks' | 'oneShotTimers'>): BusyReason | undefined =>
72  s.subagents > 0 ? 'subagent' : s.backgroundTasks > 0 ? 'background-task' : s.oneShotTimers > 0 ? 'one-shot-timer' : undefined
73
74// Over when the minutes or the turns have passed, so a count of turns that starts over with a fresh session cannot hold the lock for good.
75// Kept after a failure as after a success: a handoff that fails every time must not retry at every stop.
76const withinGap = (s: Snapshot, config: Config): boolean =>
77  s.lastAttempt !== undefined && s.now - s.lastAttempt.at < config.minGapMinutes * 60_000 && s.turns - s.lastAttempt.turns < config.minGapTurns
78
79// The statuses after which a task no longer wakes the session.
80const FINAL = new Set(['completed', 'failed', 'killed', 'stopped', 'cancelled', 'canceled', 'error'])
81
82export const activeTasks = (tasks: readonly { status: string }[] | undefined): number => (tasks ?? []).filter(t => !FINAL.has(t.status)).length
83
84// A recurring timer is a standing loop: it would not end by waiting.
85export const oneShots = (crons: readonly { recurring: boolean }[] | undefined): number => (crons ?? []).filter(c => !c.recurring).length
86
87export const decide = (snapshot: Snapshot, config: Config): Decision => {
88  const { tokens, window } = snapshot
89  const threshold = effectiveThreshold(config.threshold, window)
90  if (tokens === undefined || (snapshot.asap === undefined && tokens < thresholdInForce(config.threshold, window, snapshot.later))) return { kind: 'none' }
91  const busy = busyReason(snapshot)
92  if (busy !== undefined && tokens < brakeOf(threshold, window)) return { kind: 'defer', reason: busy, tokens }
93  if (snapshot.asap === undefined && withinGap(snapshot, config)) return { kind: 'defer', reason: 'min-gap', tokens }
94  return { kind: config.dryRun ? 'dry-run' : 'act', tokens, ...(busy === undefined ? {} : { forced: busy }) }
95}
96
hooks/hold.ts 47 lines
1// What a person types while a handover runs: the typing is held and goes in when the handover is done, so it is neither lost nor sent into a
2// session that is about to be cleared. These lines carry the name themselves: the host puts it only in
3// front of log lines and command replies.
4
5import { underWay, type IdlePrepared, type State } from './state'
6
7export const HELD = 'sweet-spot: a handover is in progress, your message is held and goes in when it is done'
8export const NO_ATTACHMENTS = 'sweet-spot: a handover is in progress and attachments cannot be held, send the message again when it is done'
9
10// While the handoff after a long pause is written, which takes seconds and changes nothing the person sees.
11export const HELD_MOMENT = 'sweet-spot: your message is held for a moment and goes in right after'
12export const NO_ATTACHMENTS_MOMENT = 'sweet-spot: attachments cannot be held, send the message again in a moment'
13
14type Typing = { text: string; attachments?: readonly unknown[]; origin: { kind: string } }
15
16// Typed at the prompt by a person, and no slash command.
17const byPerson = (input: Typing): boolean => (input.origin.kind === 'composer' || input.origin.kind === 'bridge') && !input.text.trimStart().startsWith('/')
18
19// The reason to drop the input with, when it is held; `undefined` lets it through. Only what a person types at the prompt is held: a slash
20// command still runs, and what other parts of the system send is theirs.
21export const hold = (state: State, input: Typing): string | undefined => {
22  if (!underWay(state)) return undefined
23  const quiet = !state.handingOver
24  if (!byPerson(input)) return undefined
25  // The mod cannot get at the content of an attachment, so the whole input is turned away and nothing of it is half kept.
26  if ((input.attachments?.length ?? 0) > 0) return quiet ? NO_ATTACHMENTS_MOMENT : NO_ATTACHMENTS
27  const text = input.text.trim()
28  if (text === '') return undefined
29  state.held.push(input.text)
30  return quiet ? HELD_MOMENT : HELD
31}
32
33// The first message a person sends after the handoff was written takes it, at once and before any wait, so that a second message never sees
34// it too. Typing that is empty, a slash command and what other parts of the system send leave it: a slash command is no message to carry
35// into a fresh session (`/model`, `/clear`), and a turn it starts ends the pause.
36export const claim = (state: State, input: Typing): IdlePrepared | undefined => {
37  const prepared = state.idle.prepared
38  if (prepared === undefined || !byPerson(input) || input.text.trim() === '') return undefined
39  state.idle.prepared = undefined
40  state.idle.coldTimer?.cancel()
41  state.idle.coldTimer = undefined
42  state.idle.coldShown = false
43  state.returning = true
44  state.held = []
45  return prepared
46}
47
hooks/later.ts 54 lines
1// What `/sweet-spot later` makes of its argument and of the context it is typed at. Pure: the command reads the session and keeps what
2// comes back, so that the answer, the pause line and `decide` take the value in force from the one place (`thresholdInForce`).
3
4import type { Config } from './config'
5import { brakeOf, effectiveThreshold, laterAt, thresholdInForce, type Later } from './decide'
6
7// A whole number of tokens, with `k` for a thousand. Anything else is turned down with what was typed: the mod never quietly rounds
8// `1.5k` or reads `50 k` as 50000, as it never replaces a setting.
9const TOKENS = /^[0-9]+[kK]?$/
10
11// The argument after `later`: none takes `later_tokens`, which only the settings know.
12export const tokensOf = (rest: string): { tokens: number | 'setting' } | { error: string } => {
13  const text = rest.trim()
14  if (text === '') return { tokens: 'setting' }
15  const tokens = TOKENS.test(text) ? Number.parseInt(text, 10) * (/k$/i.test(text) ? 1000 : 1) : 0
16  return tokens >= 1 ? { tokens } : { error: `"${text}" is not a number of tokens, use /sweet-spot later <tokens>, e.g. later 50k` }
17}
18
19// The debug line for a later handover that a shrunken context ended, at a pause or at the command alike.
20export const LATER_OVER = 'later: over, the context shrank below where it was asked'
21
22// The command at a known context: what the answer says, the later handover that holds afterwards, and the debug lines about it. A later handover the context has
23// shrunk below is over here too, as at a pause: a turn that ended without an answer reads the context again without a pause to see it.
24export const laterFor = (
25  config: Config,
26  window: number,
27  tokens: number,
28  current: Later | undefined,
29  n: number,
30): { later: Later | undefined; text: string; lines: string[] } => {
31  const kept = laterAt(current, tokens)
32  const lines = current !== undefined && kept === undefined ? [LATER_OVER] : []
33  const inForce = thresholdInForce(config.threshold, window, kept)
34  const threshold = effectiveThreshold(config.threshold, window)
35  const brake = brakeOf(threshold, window)
36  const context = `context ${tokens} tokens`
37  // Only ever later: a value at or below the one in force would bring the handover forward or change nothing.
38  if (tokens + n <= inForce)
39    return { later: kept, text: `already hands over at ${inForce} tokens, ${context}`, lines: [...lines, `later: turned down, not past ${inForce}`] }
40  // At the brake no later is left to give, and a command with no effect is turned down.
41  if (tokens >= brake)
42    return { later: kept, text: `already at the emergency brake, ${brake} tokens, ${context}`, lines: [...lines, 'later: turned down, at the emergency brake'] }
43  const until = tokens + n
44  const asked = { from: tokens, until }
45  const at = thresholdInForce(config.threshold, window, asked)
46  const capped = until > brake ? ', capped at the emergency brake' : ''
47  const handsOver = config.dryRun ? 'dry run, would hand over' : 'hands over'
48  return {
49    later: asked,
50    text: `${handsOver} at ${at} tokens instead of ${threshold}, for this handover only${capped}, ${context}`,
51    lines: [...lines, `later: from context ${tokens}, ${n} tokens, hands over at ${at}${capped}`],
52  }
53}
54
hooks/status.ts 44 lines
1// What `/sweet-spot status` says: where the context stands against the threshold and the emergency brake. Pure, and built from the same
2// functions as the handover, so that the answer and the behaviour cannot part.
3
4import type { Config } from './config'
5import { brakeFor, effectiveThreshold, thresholdInForce, type Later } from './decide'
6import { HOUR_MS, prepareDueAt } from './idle-handoff'
7
8// `later` is the one that still holds at this context: the caller has already looked at whether the context fell below where it was asked.
9// With no size read yet (just after a compact) the context part says so, and a `later` that was held stays, as nothing says it is over.
10export const statusFor = (config: Config, window: number, tokens: number | undefined, later: Later | undefined): string => {
11  const handover = thresholdInForce(config.threshold, window, later)
12  const at = later === undefined ? `${handover}` : `${handover} (later, threshold ${effectiveThreshold(config.threshold, window)})`
13  const context = tokens === undefined ? `context size unknown until the next answer (window ${window})` : `context ${tokens} of ${window} tokens`
14  return `${config.dryRun ? 'dry run, only reports: ' : ''}${context}, hands over at ${at}, emergency brake at ${brakeFor(config, window)}`
15}
16
17// Where the handoff after a long pause stands: planned with the time left until it is written, written and warm until a time of day the
18// caller gives as text, or written and cold.
19export type IdlePart = { kind: 'planned'; inMs: number } | { kind: 'ready'; until: string } | { kind: 'cold' }
20
21// The time of day in the process's own zone, as a clock shows it.
22const timeOfDay = (ms: number): string => {
23  const d = new Date(ms)
24  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
25}
26
27// Where the handoff after a long pause stands, from what the session keeps of it; nothing while none is planned or written.
28export const idlePart = (idle: { prepared?: unknown; timer?: unknown; touch?: number }, now: number): IdlePart | undefined => {
29  const { prepared, timer, touch } = idle
30  if (touch === undefined) return undefined
31  if (prepared !== undefined) return now - touch >= HOUR_MS ? { kind: 'cold' } : { kind: 'ready', until: timeOfDay(touch + HOUR_MS) }
32  return timer === undefined ? undefined : { kind: 'planned', inMs: prepareDueAt(touch) - now }
33}
34
35// What follows the status: `asap` first, since it acts at the next pause, and the handoff after a long pause, which waits for the next
36// message. Nothing of a part that does not hold.
37export const statusTail = (asap: boolean, idle: IdlePart | undefined): string => {
38  const parts = asap ? ['asap: hands over at the next pause'] : []
39  if (idle?.kind === 'planned') parts.push(`idle handoff ready in ${Math.max(1, Math.ceil(idle.inMs / 60_000))} min`)
40  else if (idle?.kind === 'ready') parts.push(`idle handoff ready, goes with your next message after ${idle.until}`)
41  else if (idle?.kind === 'cold') parts.push('idle handoff ready, goes with your next message')
42  return parts.map(p => `, ${p}`).join('')
43}
44
hooks/compact-check.ts 32 lines
1// Whether a compact brought the context under the threshold. A compact that did not is not repeated at every pause: it counts as a failed
2// way until a pause is below the threshold again. Pure, so that the table of moves is tested on its own.
3
4import type { Config, Method } from './config'
5
6// `pending`: a compact succeeded and no reading of the size has said yet whether it was enough. `insufficient`: it was not; `said` is whether the failure
7// that follows from it has been told to the person (once for each mark).
8export type CompactCheck = { kind: 'pending' } | { kind: 'insufficient'; said: boolean } | undefined
9
10// The mark after a reading of the size: the first tool call after the compact, or a pause. A reading with no size changes nothing: the host
11// reports none right after a compact, and a reading that cannot tell does not decide. A size at or above the threshold turns `pending` into
12// `insufficient`; one below it clears either.
13export const checkOf = (check: CompactCheck, tokens: number | undefined, threshold: number): CompactCheck => {
14  if (check === undefined || tokens === undefined) return check
15  if (tokens < threshold) return undefined
16  return check.kind === 'pending' ? { kind: 'insufficient', said: false } : check
17}
18
19// The debug line for what a reading of the size made of a compact still to be judged: enough, or not. None while it stays to be judged.
20export const judgedLine = (before: CompactCheck, after: CompactCheck, tokens: number | undefined): string | undefined => {
21  if (before?.kind !== 'pending' || after?.kind === 'pending') return undefined
22  return after === undefined ? `compact left ${tokens} tokens, below the threshold` : 'compact left the context above the threshold'
23}
24
25// The way that is tried first. A compact that was not enough is not tried: where it is the main method the fallback is the first, and
26// where there is none, there is no first way and the run fails at the mark. The fallback is what the run opens with, the compact is the
27// failure it falls back from.
28export const firstMethod = (config: Config, state: { compactCheck: CompactCheck }): Method | undefined => {
29  if (config.handoffMethod !== 'builtin-compact' || state.compactCheck?.kind !== 'insufficient') return config.handoffMethod
30  return config.handoffFallback === 'none' ? undefined : config.handoffFallback
31}
32
hooks/countdown.ts 109 lines
1// The countdown before an automatic handover: its deadline, the seconds left, what "Not now" moves the handover back by, and what the
2// band says. Pure, like `log.ts`: what reads the clock and draws the band hands the values in.
3
4import type { Config, Method } from './config'
5import { brakeFor, thresholdInForce, type Decision, type Snapshot } from './decide'
6
7// The deadline is a time, not a count of ticks: a tick that does not come cannot move it.
8export const deadlineOf = (now: number, seconds: number): number => now + seconds * 1000
9
10// A second that has begun is still shown, so the band reads 10 at the start and 1 in the last second.
11export const secondsLeft = (deadline: number, now: number): number => Math.max(0, Math.ceil((deadline - now) / 1000))
12
13// How far past its deadline the end of a countdown may read the clock and still hand over. Later than that, the machine most likely slept
14// or the timer stayed out, and the person, likely away, never saw the band; the countdown ends as it does for typing. An assumption, not
15// measured: neither what the host does while the machine sleeps nor how late a timer usually fires.
16export const LATE_MS = 3000
17
18export const isLate = (deadline: number, now: number): boolean => now - deadline > LATE_MS
19
20// What "Not now" moves the handover back by: `later_tokens`, or less where the emergency brake comes first, as `later` caps it there. The
21// button says the value that holds, not the setting, so it never promises more than the press gives.
22export type MoveBack = { tokens: number; toBrake: boolean }
23
24export const moveBack = (config: Config, window: number, tokens: number): MoveBack => {
25  const until = tokens + config.laterTokens
26  return { tokens: thresholdInForce(config.threshold, window, { from: tokens, until }) - tokens, toBrake: until > brakeFor(config, window) }
27}
28
29// Which countdown a pause past the threshold begins, or none (`immediate`). At or past the emergency brake nothing moves the handover back,
30// so there is no button: with no later handover set it goes at once, as it did before there was a countdown; with one set (capped at the
31// brake, or jumped past by the context) a countdown with no button announces it (`brake`), for whoever pressed Not now expects to be asked
32// again. Below the brake, two deferred pauses or more give the countdown that typing does not stop (`cap`): not just two, since a cap that
33// ended without a handover counts as well and leaves three. Otherwise the countdown with "Not now" (`button`). The setting of 0 and a
34// session with no screen are checked before.
35export type CountdownKind = 'button' | 'cap' | 'brake'
36
37export const kindAt = (s: Pick<Snapshot, 'tokens' | 'window' | 'later'>, config: Config, deferredPauses: number): CountdownKind | 'immediate' => {
38  if (s.tokens !== undefined && s.tokens >= brakeFor(config, s.window)) return s.later === undefined ? 'immediate' : 'brake'
39  return deferredPauses >= 2 ? 'cap' : 'button'
40}
41
42// How a countdown begins. `button` is the countdown with "Not now", which a person can answer, and what it moves the handover back by.
43// `cap` (at the cap of deferred pauses) and `brake` (at the emergency brake) have no button, and typing does not stop them. `threshold`
44// is the threshold in force, the brake for `brake`; `decision` is what the pause decided, which the end of the countdown carries out.
45export type Start = ({ kind: 'button'; moveBack: MoveBack } | { kind: 'cap' | 'brake' }) & {
46  // The way tried first, which the band names.
47  method: Method
48  decision: Extract<Decision, { kind: 'act' | 'dry-run' }>
49  threshold: number
50}
51
52// All the band shows, since drawing waits on nothing (the clock is a wait): the start and the seconds that the start or the last tick
53// read off the clock.
54export type Band = Start & { shown: number }
55
56// A countdown in the mod's state. The band only draws it; what changes here changes on an event, or on a survey coming up in the band or
57// going, which only the drawing sees.
58export type Countdown = Band & {
59  deadline: number
60  // Whether a survey holds the band, as the drawing last reported it.
61  survey: boolean
62  // The time left, kept while a survey holds the countdown: no timer runs then, and when the survey goes the deadline is set again from it.
63  remaining: number | undefined
64  // Stops the countdown's timers: the deadline and the tick that redraws the band.
65  stop: () => void
66  // Starts them again towards the deadline, from `now`.
67  run: (now: number) => void
68}
69
70const SEP = ' · '
71
72// What the band says. `width` is the cells the band lays its tree out in (`bodyColumns`); a band too narrow for all of it drops ` of
73// <threshold>` first, then the context, and always keeps the way with the seconds and the end: the button, or for a countdown with none,
74// what it is. `text` comes before the button, which the terminal draws with its hotkey in front (`1: Not now, +25000`).
75export const bandOf = (c: Band, width: number): { text: string; button: string | undefined } => {
76  const dry = c.decision.kind === 'dry-run'
77  const way =
78    c.method === 'builtin-compact' ? (dry ? 'Would compact' : 'Compacting') : dry ? 'Would hand over to a fresh session' : 'Handing over to a fresh session'
79  const head = `${way} in ${c.shown} s`
80  const button = c.kind === 'button' ? `Not now, +${c.moveBack.tokens}${c.moveBack.toBrake ? ' to the brake' : ''}` : undefined
81  const end = button !== undefined ? `${NOT_NOW_KEY}: ${button}` : c.kind === 'cap' ? 'your draft stays in the prompt' : 'emergency brake'
82  const context = `${SEP}context ${c.decision.tokens}`
83  const fits = (text: string) => text.length + end.length <= width
84  const text = [`${head}${context} of ${c.threshold}${SEP}`, `${head}${context}${SEP}`].find(fits) ?? `${head}${SEP}`
85  return { text: button === undefined ? `${text}${end}` : text, button }
86}
87
88// The key that presses "Not now": a single digit, which the host takes from an empty prompt for a band button.
89export const NOT_NOW_KEY = '1'
90
91// One edit in the prompt, as `prompt.edit` brings it: the draft before, the span it replaces, what goes in, and the key that made it.
92export type Edit = { text: string; start: number; end: number; inputText: string; key?: { key: string; ctrl?: true; shift?: true; meta?: true } }
93
94// Whether an edit is typing, which ends a countdown with the button: it leaves text in the prompt, read without white space. An edit
95// that leaves the prompt empty is none, and neither is a bare move of the cursor in an empty prompt.
96//
97// The one exception is the key of "Not now" in an empty prompt while the band shows the button: the host raises it as an edit first (the
98// digit as `inputText`, an empty draft) and as the press about 0.4 s later, and the prompt stays empty (docs/host-behaviour.md, Commands).
99// That edit is the press's echo, not typing; the press itself moves the handover back. The event alone cannot tell, since its result reads
100// as the digit. Where the digit does land in the prompt (the button out of sight), the prompt is no longer empty and the next edit is
101// typing; where it lands without an edit, the end of the countdown reads the prompt.
102export const typed = (edit: Edit, buttonShown: boolean): boolean => {
103  const after = edit.text.slice(0, edit.start) + edit.inputText + edit.text.slice(edit.end)
104  return after.trim() !== '' && !isDigitEcho(edit, buttonShown)
105}
106
107const isDigitEcho = ({ key, text }: Edit, buttonShown: boolean): boolean =>
108  buttonShown && text === '' && key !== undefined && key.key === NOT_NOW_KEY && !key.ctrl && !key.shift && !key.meta
109
hooks/countdown-flow.ts 234 lines
1// The countdown as it runs: it begins at a pause, ticks the band, and runs out into the handover, unless "Not now" moves the handover
2// back first. The countdown lives in the mod's state, not in the band, which only draws it.
3
4import { deadlineOf, isLate, secondsLeft, type Countdown, type Start } from './countdown'
5import type { Decision, Snapshot } from './decide'
6import type { Level } from './log'
7import { decidedOf } from './report'
8import type { State } from './state'
9
10// What the countdown asks of the session. As with `Io` in `relay.ts`, the host follows `$` only into functions of the file that declares
11// `register`, so the calls on `$` are made there and this module gets them as functions.
12export type CountdownIo = {
13  now: () => Promise<number>
14  after: (ms: number, fn: () => void) => { cancel: () => void }
15  // The tick that redraws the band; the deadline does not hang on it.
16  every: (ms: number, fn: () => void) => { cancel: () => void }
17  surfaces: () => Promise<readonly string[]>
18  // What the prompt holds.
19  prompt: () => Promise<string>
20  // Draws the band again.
21  redraw: () => void
22  toast: (text: string) => void
23  log: (level: Exclude<Level, 'off'>, text: string) => void
24}
25
26// What the command or a press of "Not now" answers, and the diagnostic lines about it, which the handler writes after its read of the log
27// level. A line never holds what was typed.
28export type Answer = { text: string; lines?: [Exclude<Level, 'off'>, string][] }
29
30// The band is drawn on these surfaces only; a session with none of them (VS Code, mobile, a headless run) would count down unseen.
31const DRAWN_ON = ['terminal', 'desktop']
32
33const TICK_MS = 1000
34
35// What the end of a countdown needs: the session read again for a fresh judgment (all of the snapshot but the time, which the end reads
36// last), the judgment, the handover it carries out, with the time and the turn count it was judged at, and the message for a handover
37// postponed, said as at a pause.
38export type Expiry = {
39  look: () => Promise<Omit<Snapshot, 'now'>>
40  judge: (snapshot: Snapshot) => Decision
41  carry: (decision: Countdown['decision'], now: number, turns: number) => void
42  postpone: (decision: Extract<Decision, { kind: 'defer' }>) => void
43}
44
45// Begins a countdown of `seconds` that ends in `expiry`. False where nobody would see the band: the caller hands over at once, as without a
46// countdown. Where the surfaces are is read at each start, not once per session.
47//
48// The countdown with the button does not begin over a draft in the prompt: the handover waits for the next pause. Such a pause counts as
49// deferred, and so does one whose countdown begins, until a handover begins; two of them give the countdown that typing does not stop
50// (`kindAt`), so that whoever types at every pause is not pushed to the emergency brake, which does not wait for running work. That one and
51// the countdown at the brake hand over with a draft, which stays in the prompt.
52//
53// A pause while a countdown runs (a second stop of the same answer, with no turn between) leaves it as it is: begun again, its deadline
54// would move back at each such stop, and its end judges afresh anyway.
55export const begin = async (io: CountdownIo, state: State, seconds: number, start: Start, expiry: Expiry): Promise<boolean> => {
56  if (state.countdown !== undefined) {
57    io.log('debug', 'countdown: runs already')
58    return true
59  }
60  const surfaces = await io.surfaces()
61  if (!surfaces.some(s => DRAWN_ON.includes(s))) {
62    io.log('debug', 'countdown: none, no screen draws the band')
63    return false
64  }
65  const draft = start.kind === 'button' && (await io.prompt()).trim() !== ''
66  const now = await io.now()
67  // Begun meanwhile by a second stop, which read the session after this one: that one stands.
68  if (state.countdown !== undefined) return true
69  const before = state.deferred?.pauses ?? 0
70  state.deferred = { pauses: before + 1, from: state.deferred?.from ?? start.decision.tokens }
71  if (draft) {
72    io.log('debug', `countdown: none, a draft waits in the prompt, deferred pauses ${before}`)
73    return true
74  }
75  const deadline = deadlineOf(now, seconds)
76  let timers: { cancel: () => void }[] = []
77  const countdown: Countdown = {
78    ...start,
79    deadline,
80    shown: secondsLeft(deadline, now),
81    survey: false,
82    remaining: undefined,
83    stop: () => {
84      timers.forEach(t => t.cancel())
85      timers = []
86    },
87    run: from => {
88      timers = [
89        io.after(Math.max(0, countdown.deadline - from), () => void runOut(io, state, countdown, expiry)),
90        io.every(TICK_MS, () => void tick(io, state, countdown)),
91      ]
92    },
93  }
94  state.countdown = countdown
95  countdown.run(now)
96  io.log('info', `countdown: starts, ${seconds} s, method ${countdown.method}, kind ${countdown.kind}, deferred pauses ${before}`)
97  // The announcement at the brake comes once: it spends the later handover as it begins, and if it ends without a handover the next pause
98  // at the brake hands over at once.
99  if (countdown.kind === 'brake' && state.later !== undefined) {
100    state.later = undefined
101    io.log('debug', 'later: spent by the countdown at the emergency brake')
102  }
103  io.redraw()
104  return true
105}
106
107// Why a countdown ended without a handover: typing (`edit`), a submit, a turn, the command `later` or `asap`, the end of the session, a
108// compact the mod did not start, text in the prompt when it ran out (`prompt`), or a clock that ran out long past the deadline (`late`).
109export type EndReason = 'edit' | 'submit' | 'turn' | 'command' | 'session' | 'compact' | 'prompt' | 'late'
110
111// The diagnostic line for it, which a handler writes after its read of the log level.
112export const endedLine = (reason: EndReason) => `countdown: ended, ${reason}`
113
114// Ends the running countdown without a handover and takes the band down; false when none runs. Synchronous, so that a handler ends it
115// before its first wait: the end of the countdown checks after each of its own waits that it still runs.
116export const end = (io: CountdownIo, state: State): boolean => {
117  const countdown = state.countdown
118  if (countdown === undefined) return false
119  countdown.stop()
120  state.countdown = undefined
121  io.redraw()
122  return true
123}
124
125// A survey came up in the band or went, which only the drawing sees (`hasSurvey`). Each draw reports it and only a change is taken up: a
126// change redraws, which a report that changes nothing must not do, and the band draws at every tick. It writes the mod's own state, not
127// `$.state`, which the host refuses while a render hook draws, and does not wait: the clock is read afterwards, in `settle`.
128//
129// A survey holds the countdown: no time counts while it shows, and when it goes the countdown goes on with the time it had left. The band
130// yields to it meanwhile, so the button is gone and its key no press: it is typed, as any key.
131export const surveyed = (io: CountdownIo, state: State, survey: boolean): void => {
132  const countdown = state.countdown
133  if (countdown === undefined || countdown.survey === survey) return
134  countdown.survey = survey
135  void settle(io, state, countdown)
136}
137
138// Brings the countdown in line with the survey, whatever else came first: a survey that went before its clock was read leaves the
139// countdown running as it was. A clock that cannot be read ends it, as at the end of a countdown, rather than leave one that counts beneath
140// a survey or one held for good.
141const settle = async (io: CountdownIo, state: State, countdown: Countdown) => {
142  let now: number
143  try {
144    now = await io.now()
145  } catch {
146    if (state.countdown !== countdown) return
147    end(io, state)
148    io.log('warn', 'countdown: could not read the clock at a survey')
149    return
150  }
151  if (state.countdown !== countdown) return
152  if (countdown.survey && countdown.remaining === undefined) {
153    countdown.stop()
154    countdown.remaining = Math.max(0, countdown.deadline - now)
155    io.log('debug', `countdown: held, survey, ${secondsLeft(countdown.deadline, now)} s left`)
156    io.redraw()
157  } else if (!countdown.survey && countdown.remaining !== undefined) {
158    countdown.deadline = now + countdown.remaining
159    countdown.remaining = undefined
160    countdown.shown = secondsLeft(countdown.deadline, now)
161    countdown.run(now)
162    io.log('debug', `countdown: goes on, survey gone, ${countdown.shown} s left`)
163    io.redraw()
164  }
165}
166
167// Each act checks after its wait that the countdown it belongs to still runs: one that ended or was replaced meanwhile is left alone.
168const tick = async (io: CountdownIo, state: State, countdown: Countdown) => {
169  const now = await io.now()
170  if (state.countdown !== countdown) return
171  countdown.shown = secondsLeft(countdown.deadline, now)
172  io.redraw()
173}
174
175// The end is not one step: it waits on the prompt, the session and the clock, and anything that ends the countdown meanwhile (typing, a
176// submit, a turn, `later`, `asap`) ends it synchronously, before its own first wait. So the check that this countdown still runs comes after the
177// last wait, and the handover is set going in the same step (`carry` sets `handingOver`); a countdown gone by then is left alone.
178//
179// A survey that came up meanwhile holds the countdown here as well: it is left as it is, and its deadline, by then reached, runs out again
180// when the survey goes.
181const runOut = async (io: CountdownIo, state: State, countdown: Countdown, expiry: Expiry) => {
182  const stands = () => state.countdown === countdown && !countdown.survey
183  let read: { draft: string; seen: Omit<Snapshot, 'now'>; now: number }
184  try {
185    // The countdown with the button began with an empty prompt: text there now came by a way that raises no edit (the history, Tab on a
186    // suggestion, another mod's `$.prompt.fill`). The other two hand over with a draft, which stays in the prompt.
187    const draft = countdown.kind === 'button' ? await io.prompt() : ''
188    const seen = await expiry.look()
189    read = { draft, seen, now: await io.now() }
190  } catch {
191    if (!stands()) return
192    end(io, state)
193    io.log('warn', 'countdown: could not read the session when it ran out')
194    return
195  }
196  if (!stands()) return
197  end(io, state)
198  const { draft, seen, now } = read
199  const reason = draft.trim() !== '' ? 'prompt' : isLate(countdown.deadline, now) ? 'late' : undefined
200  if (reason !== undefined) {
201    io.log('debug', endedLine(reason))
202    return
203  }
204  // Judged afresh: the context may no longer be past the threshold, or a subagent may run (one past the brake hands over all the same, as
205  // at a pause). Background tasks and one-shot timers are not read again: the stop that ended the answer reported them, and they change
206  // only in a turn, which ends the countdown. A turn holds the handover at any countdown, also the one at the brake: none can go in the
207  // middle of one. Its start ends the countdown, but a turn that started while the countdown began, before there was one to end, still runs.
208  const judged = expiry.judge({ ...seen, now })
209  const decision =
210    judged.kind !== 'none' && judged.kind !== 'defer' && state.answerRunning
211      ? { kind: 'defer' as const, reason: 'turn' as const, tokens: judged.tokens }
212      : judged
213  if (decision.kind === 'none' || decision.kind === 'defer') {
214    io.log('debug', `countdown: ran out, ${decidedOf(decision)}`)
215    if (decision.kind === 'defer') expiry.postpone(decision)
216    return
217  }
218  io.log('info', 'countdown: ran out')
219  expiry.carry(decision, now, seen.turns)
220}
221
222// "Not now": ends the countdown and moves the handover back as `later` with no number does, through `moveBack`, the command's own path,
223// and answers as it does, in a toast only: nothing goes to the transcript. A press with no countdown running counts for nothing, except
224// during a handover: the digit comes as an edit about 0.4 s before its press, and a countdown that ran out in between has started one. That
225// press is answered as `later` is then.
226export const press = async (io: CountdownIo, state: State, moveBack: () => Promise<Answer>): Promise<NonNullable<Answer['lines']>> => {
227  const ended = end(io, state)
228  if (!ended && !state.handingOver) return []
229  const answer = await moveBack()
230  io.toast(answer.text)
231  const lines = answer.lines ?? []
232  return ended ? [['debug', 'countdown: moved back by Not now'], ...lines] : lines
233}
234
hooks/log.ts 55 lines
1// The diagnostic output, for developers: one function, one level, and only the level decides which lines show. The level comes from an
2// environment variable, not from a setting, so it ships with nothing and shows in no menu. This module never sees the host and never names
3// the variable: what reads it and what writes a line are handed in, so all of it is tested without a host.
4
5// In rising order; each level shows its own lines and those of the levels before it.
6const LEVELS = ['off', 'warn', 'info', 'debug', 'trace'] as const
7
8export type Level = (typeof LEVELS)[number]
9
10// Unset or empty is off. There is no short form such as `1` or `true`: it would leave open which level it means. Anything that is no
11// level turns on `warn` rather than `off`, since `off` would hide the very line that names the typo, and `info` would add a line at every
12// pause to someone who meant `0` or `error`.
13export const levelOf = (raw: string | undefined): { level: Level; bad?: string } => {
14  const value = (raw ?? '').trim().toLowerCase()
15  if (value === '') return { level: 'off' }
16  return (LEVELS as readonly string[]).includes(value) ? { level: value as Level } : { level: 'warn', bad: value }
17}
18
19export const shows = (configured: Level, line: Level): boolean => line !== 'off' && LEVELS.indexOf(line) <= LEVELS.indexOf(configured)
20
21// The level is asked at each line, not once: a line written later, as from a timer, goes by the level of the latest read.
22export const logger =
23  (level: () => Level, out: (text: string) => void) =>
24  (line: Exclude<Level, 'off'>, text: string): void => {
25    if (shows(level(), line)) out(`${line}: ${text}`)
26  }
27
28// What the mod keeps of the variable: the level in force, and the bad value it last reported, so that a bad value is reported once and
29// not at every read. A valid read forgets it: the same typo after a correction is a new mistake.
30export type LevelMemo = { logLevel: Level; badLogLevel: string | undefined }
31
32// Each handler that logs reads the variable at its start, since a value set in a settings file reaches the host at the next turn and a
33// reload does not load the mod again. Everything after the read runs without a break, so two handlers reading at once write one line.
34// A read that throws keeps the level as it was, and says nothing: a hook that throws is skipped by the host, and a switch for diagnostics
35// must not stop the mod's work.
36// It answers with the level before the read, so that the caller can mark a change.
37export const refresh = async (read: () => Promise<string | undefined>, out: (text: string) => void, memo: LevelMemo): Promise<Level> => {
38  let raw: string | undefined
39  try {
40    raw = await read()
41  } catch {
42    return memo.logLevel
43  }
44  // Taken after the wait: of two reads at once, the second sees what the first set.
45  const before = memo.logLevel
46  const { level, bad } = levelOf(raw)
47  memo.logLevel = level
48  if (bad === undefined) memo.badLogLevel = undefined
49  else if (memo.badLogLevel !== bad) {
50    memo.badLogLevel = bad
51    logger(() => memo.logLevel, out)('warn', `log level "${bad}" is not one of ${LEVELS.join(', ')}, logging at warn`)
52  }
53  return before
54}
55
hooks/relay.ts 483 lines
1// What a handoff does to the session: runs the user's command as a turn of its own, waits for the end of that turn, reads the file the
2// command wrote, clears the session and submits the handoff as the first message of the new one. When the command gives no handoff, the
3// mod writes it instead (the internal handoff).
4
5import {
6  ASK_FOR_PATH,
7  COMPACT_CANCELLED,
8  USER_REJECTED,
9  firstMessage,
10  MODEL_FORK_TIMEOUT_MS,
11  NEXT_MESSAGE,
12  modelForkFailure,
13  modelForkKind,
14  handoffPath,
15  newestHandoff,
16  promptFrom,
17  promptPath,
18  unfit,
19  withHeld,
20  withoutTrailingSeparator,
21  type Entry,
22  type ModelForkAnswer,
23  type ModelForkUsage,
24} from './handoff'
25import type { Config, Method } from './config'
26import { fellBackLine } from './report'
27import type { Level } from './log'
28import type { Ended, State } from './state'
29
30// What the flow asks of the session. The host follows `$` only into functions of the file that declares `register`, so the calls on `$`
31// are made there and this module gets them as functions.
32export type Io = {
33  commands: () => Promise<string[]>
34  now: () => Promise<number>
35  run: (command: string, args?: string) => Promise<unknown>
36  after: (ms: number, fn: () => void) => { cancel: () => void }
37  readFile: (path: string) => Promise<unknown>
38  listDir: (path: string) => Promise<Entry[]>
39  tmpdir: () => Promise<string | undefined>
40  home: () => Promise<string | undefined>
41  modelFork: (prompt: string) => Promise<ModelForkAnswer>
42  // Claude Code's own /compact, with no instructions. It resolves with `skip` when a hook vetoed it, and rejects when the host refuses.
43  compact: () => Promise<{ skip?: string }>
44  // Says a change of way where the person looks.
45  tell: (text: string) => void
46  // One line of the diagnostic output; shows only at the log level in force.
47  log: (level: Exclude<Level, 'off'>, text: string) => void
48  submit: (text: string) => Promise<{ drop?: string }>
49  sessionId: () => Promise<string>
50  // The store outlives the session and a reload; `/clear` does not touch it.
51  keep: (key: string, value: unknown) => Promise<void>
52  recall: (key: string) => Promise<unknown>
53  forget: (key: string) => Promise<void>
54}
55
56// The command's turn takes seconds; this is for a turn that never ends, or one held by a permission prompt nobody answers.
57const TURN_TIMEOUT_MS = 5 * 60_000
58
59// The stage says how far the handoff got, and so what is left to put right: up to `read`, and at `model-fork`, nothing has changed in the
60// session (`cancelled`: the user's own Esc, not tried another way), `clear` failed before it changed anything, and `submit` failed after it, in the fresh session. `error` is what no stage
61// expected. `model-fork` is the last way tried: its reason carries the reason the command failed first.
62export type Failure = { stage: 'command' | 'cancelled' | 'turn' | 'path' | 'read' | 'model-fork' | 'compact' | 'clear' | 'submit' | 'error'; reason: string }
63
64// How a run ended: with the failure, or with the way that handed over (a redelivery has no way to name).
65export type Outcome = { failure: Failure; method?: undefined } | { failure?: undefined; method?: Method }
66
67// A handoff written and not yet handed over: the first message, where the command's file is, and for the internal way when the model was
68// asked and what it read.
69export type Prepared = { text: string; file?: string; usage?: ModelForkUsage; began?: number }
70
71const failed = (stage: Failure['stage'], reason: string): Failure => ({ stage, reason })
72
73export const why = (err: unknown): string => (err instanceof Error ? err.message : String(err))
74
75// `command.run` settles at once, long before the turn it starts ends: the wait is for that turn's `turn.complete`, which `endOfTurn` passes
76// on. Registered before the command runs, so that an end cannot come unseen.
77const waitForTurnEnd = (io: Io, state: State) => {
78  let timer: { cancel: () => void } | undefined
79  const ended = new Promise<Ended>(resolve => {
80    const end = (result: Ended) => {
81      timer?.cancel()
82      state.turnWait = undefined
83      resolve(result)
84    }
85    state.turnWait = { own: undefined, end }
86    timer = io.after(TURN_TIMEOUT_MS, () => end({ failure: `the command's turn did not end within ${TURN_TIMEOUT_MS / 60_000} minutes` }))
87  })
88  return { ended, cancel: () => state.turnWait?.end({ failure: 'cancelled' }) }
89}
90
91// Only the end of the command's turn, the first to start after the wait began, is the end awaited; not that of any other turn (a `/loop`
92// trigger, a queued prompt), and a turn whose start was not seen cannot be the command's. The turn that raised the handoff has ended
93// before the wait begins, since a stop is judged only once its turn has ended. An answer ends the wait with its text; a failed or refused
94// turn ends it without one, and an interrupted one ends it as the user's own stop.
95export const startOfTurn = (state: State, turnId: string): boolean => {
96  const wait = state.turnWait
97  if (wait !== undefined && wait.own === undefined) wait.own = turnId
98  return wait?.own === turnId
99}
100
101// Looks at one finished tool call: the user's decline at the permission prompt in the command's own turn is noted. Which turn is by time: the
102// input of `tool.call` carries no turn id, and while `own` is set that turn is the only one that runs. A subagent's calls (`agentId`, also
103// from earlier turns still running) are not the user's answer to the command. True when the decline is new.
104export const noteToolResult = (state: State, e: { agentId?: string }, out: { isError?: unknown; text?: unknown }): boolean => {
105  const wait = state.turnWait
106  if (wait === undefined || wait.own === undefined || e.agentId !== undefined || wait.rejected) return false
107  if (out.isError !== true || typeof out.text !== 'string' || !out.text.startsWith(USER_REJECTED)) return false
108  wait.rejected = true
109  return true
110}
111
112export const endOfTurn = (state: State, e: { turnId: string; answer: string; reason: string }): 'own' | 'foreign' | undefined => {
113  const wait = state.turnWait
114  if (wait === undefined) return undefined
115  if (e.turnId !== wait.own) return 'foreign'
116  if (e.reason === 'answer') wait.end(wait.rejected ? { answer: e.answer, rejected: true } : { answer: e.answer })
117  else if (e.reason === 'aborted') wait.end({ failure: "the command's turn was interrupted", cancelled: true })
118  else wait.end({ failure: `the command's turn ended without an answer (${e.reason})` })
119  return 'own'
120}
121
122// The user's command writes the file wherever it likes; when the answer does not say where, it is the newest one in the temp directory.
123const newestInTemp = async (io: Io, since: number): Promise<string | undefined> => {
124  const dir = withoutTrailingSeparator((await io.tmpdir()) ?? '/tmp')
125  const name = newestHandoff(await io.listDir(dir).catch(() => []), since)
126  return name === undefined ? undefined : `${dir}/${name}`
127}
128
129const FAILURE = 'failure'
130const pendingKey = (sessionId: string) => `pending:${sessionId}`
131
132// A hook that drops the message answers with `drop` and does not throw.
133const submitChecked = async (io: Io, text: string) => {
134  const r = await io.submit(text)
135  if (r.drop !== undefined) throw new Error(`a hook dropped it: ${r.drop}`)
136}
137
138// Whether the store lets go of the key does not matter for the pointer: with `state.pending` cleared nothing submits it again.
139const dropPending = async (io: Io, state: State, key: string) => {
140  state.pending = undefined
141  state.pendingIn = undefined
142  await io.forget(key).catch(() => io.log('debug', 'could not forget the pending message'))
143}
144
145const notDelivered = (err: unknown, file?: string): Failure =>
146  failed('submit', `the fresh session did not get the handoff (${why(err)})${file === undefined ? '' : `; it is in ${file}`}`)
147
148// The step every way of getting a handoff ends in. `file` is where the text can still be found when the fresh session does not get it.
149// The message is kept before `/clear` and until the fresh session has it: it is what `redeliver` submits, and the one place that still has it
150// when the submit fails.
151const deliver = async (io: Io, state: State, text: string, file?: string, heading?: string): Promise<Failure | undefined> => {
152  const included = state.held.length
153  const message = withHeld(text, state.held, heading)
154  const before = await io.sessionId()
155  const key = pendingKey(before)
156  await io.keep(key, message)
157  state.pending = key
158  io.log('info', `clear, session ${before}`)
159  try {
160    await io.run('clear')
161  } catch (err) {
162    await dropPending(io, state, key)
163    return failed('clear', `/clear failed (${why(err)}), the session is as it was`)
164  }
165  // The session the host reports once `/clear` has returned (the fresh one, see docs/host-behaviour.md, `/clear` and the session). The same id means `/clear` did nothing, and the
166  // handoff would go into the session it was meant to leave.
167  const after = await io.sessionId().catch(() => {
168    io.log('debug', 'session id unknown after the clear')
169    return undefined
170  })
171  if (after === before) {
172    await dropPending(io, state, key)
173    return failed('clear', '/clear returned but the session is the same, the session is as it was')
174  }
175  // From here the held typing up to `included` is in the message, wherever that ends up.
176  state.held.splice(0, included)
177  state.pendingIn = after
178  state.deliveredInto = after
179  if (included > 0) io.log('debug', `held inputs in the message: ${included}`)
180  io.log('info', `submit, session ${after ?? 'unknown'}, ${message.length} characters`)
181  try {
182    await submitChecked(io, message)
183  } catch (err) {
184    return notDelivered(err, file)
185  }
186  io.log('info', 'submit done')
187  await dropPending(io, state, key)
188  return undefined
189}
190
191// The handoff written in a pause, with the message that came back after it: that message is the first of what is held, so a failure
192// before `/clear` puts it into this session like any typing held, and one after keeps it in the message for `redeliver`. Handed over by
193// `asap` there is no such message, and what is held is typing like during any handoff.
194export const deliverReturn = (io: Io, state: State, prepared: { text: string; file?: string }, withMessage: boolean): Promise<Failure | undefined> =>
195  deliver(io, state, prepared.text, prepared.file, withMessage ? NEXT_MESSAGE : undefined)
196
197const joined = (failure: Failure | undefined, note: string): Failure =>
198  failure === undefined ? failed('submit', note) : { ...failure, reason: `${failure.reason}; ${note}` }
199
200// Typing that has no place to go is added to the message that is pending, or becomes it.
201const park = async (io: Io, state: State, typing: string) => {
202  const key = state.pending ?? pendingKey(await io.sessionId())
203  const kept = await io.recall(key)
204  await io.keep(key, typeof kept === 'string' ? `${kept}\n\n${typing}` : typing)
205  state.pending = key
206}
207
208// Whatever happens, the typing ends up submitted, kept for `redeliver`, or named in the failure: never gone without a word.
209const putIn = async (io: Io, state: State, typing: string, failure: Failure | undefined): Promise<Failure | undefined> => {
210  let back: string | undefined
211  if (state.pending === undefined) {
212    try {
213      await submitChecked(io, typing)
214      io.log('info', `held typing put in, ${typing.length} characters`)
215      return failure
216    } catch (err) {
217      back = `put back (${why(err)})`
218    }
219  }
220  try {
221    await park(io, state, typing)
222    io.log('warn', `held typing kept for redelivery, ${typing.length} characters`)
223    return back === undefined ? failure : joined(failure, `the typing held meanwhile could not be ${back}, \`/sweet-spot redeliver\` submits it`)
224  } catch (err) {
225    io.log('warn', `held typing lost, ${typing.length} characters`)
226    return joined(failure, `the typing held meanwhile could not be ${back === undefined ? '' : `${back} or `}kept (${why(err)}), it is lost: ${typing}`)
227  }
228}
229
230// Puts in what is still held, until nothing is left. The last look and the end of holding are one step with no wait between
231// them, so that nothing typed in between is held and then forgotten.
232// `done` ends the holding: a handover's by default, or the writing of the handoff after a long pause.
233export const release = async (
234  io: Io,
235  state: State,
236  failure: Failure | undefined,
237  done: () => void = () => {
238    state.handingOver = false
239  },
240): Promise<Failure | undefined> => {
241  for (;;) {
242    const late = state.held.splice(0)
243    if (late.length === 0) {
244      done()
245      return failure
246    }
247    failure = await putIn(io, state, late.join('\n\n'), failure)
248  }
249}
250
251// The reason stays in the store: the line that says it does not survive a `/clear`.
252const record = async (io: Io, sessionId: string, failure: Failure | undefined) => {
253  try {
254    if (failure === undefined) await io.forget(FAILURE)
255    else await io.keep(FAILURE, { at: await io.now(), sessionId, ...failure })
256  } catch {
257    // The line that says the reason is the other copy of it.
258    io.log('debug', failure === undefined ? 'could not forget the failure' : 'could not keep the failure')
259  }
260}
261
262// Runs one way of producing and delivering the handoff, then settles what was held meanwhile and what the failure leaves to put right.
263export const carryOut = async (io: Io, state: State, produce: () => Promise<Outcome>): Promise<Outcome> => {
264  // A message pending from an earlier handoff is not this one's: this one's typing must not be added to it.
265  const earlier = state.pending
266  state.pending = undefined
267  let sessionId = 'unknown'
268  let outcome: Outcome
269  try {
270    sessionId = await io.sessionId()
271    outcome = await produce()
272  } catch (err) {
273    outcome = { failure: failed('error', why(err)) }
274  }
275  const failure = await release(io, state, outcome.failure)
276  state.pending ??= earlier
277  await record(io, sessionId, failure)
278  return failure === outcome.failure ? outcome : { failure }
279}
280
281const pendingMessage = async (io: Io, state: State): Promise<{ key: string; text: string }> => {
282  const key = state.pending
283  const text = key === undefined ? undefined : await io.recall(key)
284  if (key === undefined || typeof text !== 'string') throw new Error('the message is no longer in the store')
285  return { key, text }
286}
287
288// Submits again the first message that did not reach the fresh session, into the session as it is: no `/clear`.
289export const redeliver = async (io: Io, state: State): Promise<Failure | undefined> => {
290  io.log('info', 'redeliver starts')
291  const sessionId = await io.sessionId().catch(() => {
292    io.log('debug', 'session id unknown at redelivery')
293    return 'unknown'
294  })
295  let failure: Failure | undefined
296  try {
297    const { key, text } = await pendingMessage(io, state)
298    if (state.pendingIn !== undefined && sessionId !== state.pendingIn) throw new Error('the message belongs to another session than this one')
299    failure = await submitChecked(io, text).then(() => undefined, notDelivered)
300    if (failure === undefined) await dropPending(io, state, key)
301  } catch (err) {
302    failure = failed('error', why(err))
303  }
304  await record(io, sessionId, failure)
305  io.log('info', `redeliver ends, ${failure === undefined ? 'delivered' : 'not delivered'}`)
306  return failure
307}
308
309// Runs the user's command and reads the file it wrote; hands nothing over.
310export const prepareByCommand = async (io: Io, state: State, command: string): Promise<Prepared | Failure> => {
311  // The name is compared whole: `claude-mem:handoff` stands beside `handoff` in the list and is not the user's command.
312  if (!(await io.commands()).includes(command)) return failed('command', `the command "${command}" does not exist`)
313  io.log('debug', `command "${command}" is listed`)
314  const since = await io.now()
315  const wait = waitForTurnEnd(io, state)
316  try {
317    await io.run(command, ASK_FOR_PATH)
318    io.log('debug', 'command started, waiting for its turn')
319  } catch (err) {
320    wait.cancel()
321    return failed('command', `the command "${command}" did not start (${why(err)})`)
322  }
323  const end = await wait.ended
324  if ('failure' in end) return failed(end.cancelled ? 'cancelled' : 'turn', end.failure)
325  const named = handoffPath(end.answer)
326  const file = named ?? (await newestInTemp(io, since))
327  io.log('trace', "the command's turn ended")
328  // No path in the line: the answer names it, and a log line is copied into bug reports.
329  io.log(
330    'debug',
331    named !== undefined
332      ? 'the answer names a file'
333      : `the answer names no file, ${file === undefined ? 'the temp directory has none' : 'the temp directory has one'}`,
334  )
335  if (file === undefined) {
336    // No file because the user declined a permission the command asked for: their own stop, not a handoff that is missing.
337    if (end.rejected) return failed('cancelled', 'you declined a permission the command asked for')
338    return failed('path', 'the answer names no handoff file and the temp directory has none from this run')
339  }
340  if (named !== undefined) {
341    const at = Math.max(named.lastIndexOf('/'), named.lastIndexOf('\\'))
342    const entry = (await io.listDir(named.slice(0, at + 1) || '/').catch(() => [])).find(e => e.name === named.slice(at + 1))
343    const bad = unfit(entry, named, since)
344    if (bad !== undefined) return failed(bad.stage, bad.reason)
345  }
346  let handoff: unknown
347  try {
348    handoff = await io.readFile(file)
349  } catch (err) {
350    return failed('read', `the handoff file could not be read (${why(err)})`)
351  }
352  if (typeof handoff !== 'string' || handoff.trim() === '') return failed('read', 'the handoff file is empty')
353  io.log('debug', `file read, ${handoff.length} characters`)
354  return { text: firstMessage(handoff), file }
355}
356
357const isFailure = (r: Prepared | Failure): r is Failure => 'stage' in r
358
359const throughCommand = async (io: Io, state: State, command: string): Promise<Failure | undefined> => {
360  const r = await prepareByCommand(io, state, command)
361  return isFailure(r) ? r : deliver(io, state, r.text, r.file)
362}
363
364// A `$.model.fork` call cannot be cancelled: the wait is limited. After the timeout it keeps running in the background and its late
365// answer reaches no one, which is harmless.
366const modelForkWithin = async (io: Io, prompt: string): Promise<ModelForkAnswer> => {
367  let timer: { cancel: () => void } | undefined
368  const late = new Promise<ModelForkAnswer>(resolve => {
369    timer = io.after(MODEL_FORK_TIMEOUT_MS, () => resolve({ isAnswered: false, reason: 'timeout' }))
370  })
371  try {
372    return await Promise.race([io.modelFork(prompt), late])
373  } finally {
374    timer?.cancel()
375  }
376}
377
378// The prompt of the user's own under `~/.claude`, else the one the mod brings: no home directory and no readable file are no failure.
379const internalHandoffPrompt = async (io: Io): Promise<{ prompt: string; source: string }> => {
380  try {
381    const home = await io.home()
382    if (home === undefined) return { prompt: promptFrom(undefined), source: "the mod's own prompt" }
383    const file = promptPath(home)
384    const text = await io.readFile(file)
385    const prompt = promptFrom(text)
386    return { prompt, source: prompt === promptFrom(undefined) ? "the mod's own prompt" : `prompt from ${file}` }
387  } catch {
388    return { prompt: promptFrom(undefined), source: "the mod's own prompt" }
389  }
390}
391
392// Asks the model for the handoff; hands nothing over.
393export const prepareInternally = async (io: Io): Promise<Prepared | Failure> => {
394  let answer: ModelForkAnswer
395  const began = await io.now()
396  try {
397    const { prompt, source } = await internalHandoffPrompt(io)
398    io.log('debug', `internal handoff asked, ${source}`)
399    answer = await modelForkWithin(io, prompt)
400  } catch (err) {
401    return failed('model-fork', `the mod could not write the handoff: writing the handoff failed (${why(err)})`)
402  }
403  const took = `after ${(await io.now()) - began} ms`
404  if (answer.isAnswered) io.log('info', `internal handoff answered, ${answer.text.length} characters, ${took}`)
405  else io.log('warn', `internal handoff gave nothing (${modelForkKind(answer)}), ${took}`)
406  if (!answer.isAnswered) return failed('model-fork', `the mod could not write the handoff: ${modelForkFailure(answer)}`)
407  if (answer.text.trim() === '') return failed('model-fork', 'the mod could not write the handoff: the model replied with no text')
408  return answer.usage === undefined ? { text: firstMessage(answer.text), began } : { text: firstMessage(answer.text), usage: answer.usage, began }
409}
410
411// The handoff written ahead in a long pause: by the user's command when that is the way, else by the mod. A compact is never used, since a
412// warm return must find the whole context, and `throughMethods` is not either: whatever the command's failure, it has changed nothing but
413// add its own turn, so the mod writes the handoff unless the fallback is none.
414export const prepare = async (io: Io, state: State, config: Config): Promise<Prepared | Failure> => {
415  if (config.handoffMethod !== 'external-command') return prepareInternally(io)
416  const first = await prepareByCommand(io, state, config.handoffCommand)
417  if (!isFailure(first) || config.handoffFallback === 'none') return first
418  io.log('warn', `idle: the command gave no handoff (stage ${first.stage}), the mod writes it`)
419  const second = await prepareInternally(io)
420  return isFailure(second) ? { ...second, reason: `${first.reason}; ${second.reason}` } : second
421}
422
423const throughInternalHandoff = async (io: Io, state: State): Promise<Failure | undefined> => {
424  const r = await prepareInternally(io)
425  return isFailure(r) ? r : deliver(io, state, r.text)
426}
427
428// The session stays and only gets smaller: nothing is cleared, nothing is submitted, and nothing is left for `redeliver`. Called directly: the
429// host refuses a compact from inside the stop, and the mod judges only once the stop and the end of its turn are both in, with the stop
430// first (measured, 10 of 10 turns), so this runs from the end of the turn. If the end of a turn ever came before the stop, the host would
431// refuse, which is a failure at stage `compact` like any other, and the fallback takes over.
432const throughBuiltinCompact = async (io: Io, state: State): Promise<Failure | undefined> => {
433  // A compact that left the context above the threshold fails before it is asked: it would do the same again.
434  if (state.compactCheck?.kind === 'insufficient') return failed('compact', 'the last compact left the context above the threshold')
435  const began = await io.now()
436  io.log('debug', 'compact asked')
437  let result: { skip?: string }
438  try {
439    result = await io.compact()
440  } catch (err) {
441    // Esc is the user's own stop: no other way hands over after it. The reason is in the refusal's words, which the log never carries.
442    if (why(err).includes(COMPACT_CANCELLED)) return failed('cancelled', 'you canceled the compact')
443    io.log('warn', 'compact refused')
444    return failed('compact', `compact was refused (${why(err)})`)
445  }
446  if (result.skip !== undefined) {
447    io.log('warn', 'compact skipped')
448    return failed('compact', `compact was skipped (${result.skip})`)
449  }
450  io.log('info', `compacted, after ${(await io.now()) - began} ms`)
451  return undefined
452}
453
454// The main method first. Where it fails before it changed the session, the fallback is tried: the change is said with its reason, and if
455// the fallback fails as well, the handoff fails with both reasons. A failure after the session changed (`submit`), the user's own stop
456// (`cancelled`) and what no stage expected (`error`) are not tried another way.
457// The stages after which the session is as it was: one list, for the first failure and the second.
458const FALLBACK_STAGES: Failure['stage'][] = ['command', 'turn', 'path', 'read', 'model-fork', 'compact', 'clear']
459
460// Whether a way ends in `/clear`: a failure at `/clear` leaves a fallback that needs it nothing to try.
461const NEEDS_CLEAR: Record<Method, boolean> = { 'external-command': true, 'internal-handoff': true, 'builtin-compact': false }
462
463const through = (io: Io, state: State, config: Config, method: Method): Promise<Failure | undefined> =>
464  ({
465    'external-command': () => throughCommand(io, state, config.handoffCommand),
466    'internal-handoff': () => throughInternalHandoff(io, state),
467    'builtin-compact': () => throughBuiltinCompact(io, state),
468  })[method]()
469
470// `configOf` has turned the same way in both settings into `none`, so the fallback is a different way here.
471export const throughMethods = async (io: Io, state: State, config: Config): Promise<Outcome> => {
472  const first = await through(io, state, config, config.handoffMethod)
473  if (first === undefined) return { method: config.handoffMethod }
474  const fallback = config.handoffFallback
475  if (fallback === 'none' || !FALLBACK_STAGES.includes(first.stage)) return { failure: first }
476  if (first.stage === 'clear' && NEEDS_CLEAR[fallback]) return { failure: { ...first, reason: `${first.reason}; the fallback needs /clear too` } }
477  io.tell(fellBackLine(first.reason, config.handoffMethod, fallback))
478  io.log('warn', `fallback to ${fallback} (stage ${first.stage})`)
479  const second = await through(io, state, config, fallback)
480  if (second === undefined) return { method: fallback }
481  return { failure: FALLBACK_STAGES.includes(second.stage) ? { ...second, reason: `${first.reason}; ${second.reason}` } : second }
482}
483