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

<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).
Drawn, not recorded: an illustration of the sequence, looping.
Built to be safe
tail, to read the end of a long transcript.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.
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.
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.
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.
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./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./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./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.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./sweet-spot redeliver is the way out: it submits it again./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.dry_run on it only says "would hand over now" and does nothing.What happens to what you type while a handoff runs, and what redeliver is for:
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:
Drawn, not recorded: an illustration of the sequence, looping.
Both ways bring the context down. They differ in what the session holds afterwards.
| Handoff (default) | Compact | |
|---|---|---|
| Afterwards | A fresh session that holds only the handoff | The same session, with Claude Code's summary in place of the history |
| What it keeps | What the handoff prompt asks for; ~/.claude/sweet-spot/handoff-prompt.md replaces it | What Claude Code's /compact keeps; the mod has no say in it |
| Can you read it | Yes, it is the first message of the fresh session | No file; the summary goes straight into the session |
| Time, measured | From 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 command | The compact itself: about 15 s at 65k tokens, 56.5 s at 408k; it grows with the context |
| Context after, measured | At the start of the fresh session: 62k to 98k tokens with your own command, 31k once with the built-in prompt | 130k 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.
~/.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./clear does not touch.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.
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 place | Hands over (typical) | sweet-spot | |
|---|---|---|---|
| New session instead of a summary in the same one | no | yes | yes, or a compact if you choose |
| Acts only when an answer is finished | mostly | mostly | yes |
| Waits for subagents and background work | some | some | yes |
| Report-only mode before it acts | one, as its default | only a manual dry run | yes, a setting |
| Falls back to a second way if the first fails | rare | rare | yes |
| Writes into your project | rarely | often, or by asking Claude to | never |
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".
In the /config menu under sweet-spot.
| Setting | Default | What it does |
|---|---|---|
threshold | 200000 | Context 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_run | off | Only say that a handoff would happen, and do nothing. Turn on to watch what the mod would do before it acts. |
min_gap_minutes | 10 | Least time between two attempts. 0 turns the whole gap off. |
min_gap_turns | 5 | Least number of turns between two attempts. 0 turns the whole gap off. |
later_tokens | 25000 | How far /sweet-spot later moves the next handover back when you give it no number, in tokens. |
countdown_seconds | 10 | Seconds 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_method | internal-handoff | How 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_fallback | builtin-compact | Used 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_handoff | on | After 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_tokens | 0 | Context 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_command | empty | Only 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:
handoff_method builtin-compact, handoff_fallback none.builtin-compact and internal-handoff.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.
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.
| Command | What it does |
|---|---|
/sweet-spot status | Says 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 asap | Hands 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 redeliver | Submits again the first message of a handoff that did not reach the fresh session. |
claude plugin validate; I have no managed installation to try it on.idleCompaction to false in your settings.json. How the two differ: docs/idle-handoff.md.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 forgehooks/register.tsx 1269 lines1import 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 lines1// 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}
122hooks/asap.ts 19 lines1// 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'
19hooks/decide.ts 96 lines1// 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}
96hooks/hold.ts 47 lines1// 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}
47hooks/later.ts 54 lines1// 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}
54hooks/status.ts 44 lines1// 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}
44hooks/compact-check.ts 32 lines1// 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}
32hooks/countdown.ts 109 lines1// 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
109hooks/countdown-flow.ts 234 lines1// 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}
234hooks/log.ts 55 lines1// 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}
55hooks/relay.ts 483 lines1// 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