Keeps the last part of your 5-hour and weekly quota for you. At the reserve, spare10 holds all work and asks you whether to continue. After a Resume, it asks…

A quota circuit breaker for Claude Code and the OpenAI Codex CLI. It keeps the last part of your 5-hour and weekly quota windows for you.
The idea comes from spare10 by Alessandro Diano. spare10 wraps the claude command and stops Claude Code before the quota runs out. spare10-mod keeps the idea and moves it into the agent, as a plugin. Many texts and rules come from spare10. Thank you, Alessandro.
spare10-mod is early access software. In Claude Code, it uses function hooks, an early access feature of Claude Code 2.1.281 and later. In Codex, it uses plugin hooks and a small MCP server. It is tested on Codex CLI 0.157.0. The plugin name is spare10 in both.
spare10 watches two quota windows: the 5-hour window and the weekly window. By default, it keeps the last 10% of each window for you. At the reserve of either window, spare10 holds all work at the next step. It then asks you one question: Stop here or Resume.
At 100% used, spare10 holds all work until the reset, and asks once: Continue at the reset or Stop here.
Near the reset, a reserve that you do not use is lost. So spare10 opens the reserve in the last 20 minutes of the 5-hour window and the last 8 hours of the weekly window. Then, or after the reset, spare10 continues held and stopped work by itself. You can switch each of these off.
env entry in ~/.claude/settings.json: { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
If the file has an env block already, add only the CLAUDE_CODE_ENABLE_FUNCTION_HOOKS line to it. This flag switches on function hooks for every installed plugin that has a hooks module. Before you start tells you more.
Run this command in bash, zsh or sh. It needs jq. macOS has jq in /usr/bin.
sh -c 'f="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"; s="$f"; [ -e "$f" ] || s=/dev/null; t=$(mktemp) || exit 1; jq -s "if length > 1 then error(\"more than one JSON value\") else (.[0] // {}) | .env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS = \"1\" end" "$s" > "$t" || { rm -f "$t"; exit 1; }; mkdir -p "$(dirname "$f")" && cat "$t" > "$f" || { echo "Could not write $f. The new settings are in $t" >&2; exit 1; }; rm -f "$t"; echo "Function hooks are on in $f"'
The command keeps your other settings. jq rewrites the file with two-space indentation. If jq cannot read or parse the file, the command shows an error and changes nothing. If you export CLAUDE_CONFIG_DIR, the command uses that folder, as Claude Code does. Run it once for each config folder that you use.
claude plugin marketplace add chrisns/spare10-mod
claude plugin install spare10@spare10
The install can say that 12 userConfig options are not set yet. You do not have to set them. The defaults apply until you change them in /config.
⧗ spare10 until spare10 reads your quota. Then it shows ● spare10. Type /spare10 to see the full status./ to see the command list. If /spare10 is not in the list, spare10 did not load.claude doctor. If it shows Invalid settings, correct that entry."0" in the env block of a project's .claude/settings.json switches function hooks off in that project.You need Node.js 20 or later. spare10 looks for it in Homebrew, in /usr/local/bin, in /usr/bin and in Volta. Then it tries the versions of nvm, mise, asdf and fnm, and last your PATH. spare10 runs it outside the Codex sandbox. Differences in Codex item 26 says where to install it.
codex plugin marketplace add chrisns/spare10-mod
codex plugin add spare10@spare10
codex. Codex shows Hooks need review. Choose Trust all and continue. Codex runs plugin hooks only after you trust them. codex exec never asks, so trust the hooks once in the TUI first.spare10 as the whole prompt, and press Enter. spare10 shows its status in the transcript, and sends nothing to the model.Use the Codex shell prefix !, such as !spare10 resume. Codex does not use your shell aliases there. So add the spare10 folder to your PATH in ~/.zshrc or ~/.bashrc:
export PATH="$HOME/.codex/plugins/data/spare10-spare10/bin:$PATH"
If you set CODEX_HOME, use that folder in place of $HOME/.codex. Start a new Codex session after you change the file. See Install in Codex.
spare10 starts one small Node.js process for each Codex thread. If spare10 finds no Node.js 20 or later, Codex cannot start a session. This includes the Codex desktop app and the IDE extension. Install Node.js 20 or later, or remove the plugin:
codex plugin remove spare10@spare10
You can also set enabled = false under [plugins."spare10@spare10"] in ~/.codex/config.toml.
A test reading trips spare10 at any time. It spends no quota when you choose Stop here.
/spare10 simulate 92 in Claude Code, or spare10 simulate 92 in Codex./spare10 simulate off, or spare10 simulate off in Codex, to clear the test reading.If the 5-hour window resets in less than 20 minutes, the reserve is open, and spare10 does not ask. Then use simulate 92 in 1h. On a Codex plan with only a weekly window, the test reading is for the weekly window. If the weekly window resets in less than 8 hours, use spare10 simulate 92 in 9h. See Test reading.
The badge at the right of the prompt footer, and the /spare10 report:

At the reserve, spare10 holds the work and asks you:

After Stop here, the work stops until the reserve opens, 20 min before the reset:

When the reserve opens, spare10 continues the stopped work by itself:

These screenshots use a test reading from /spare10 simulate, so they show (test). They come from spare10-mod 0.2, which had no floor.
At the reserve, spare10 asks you in a Codex form:

Stop here drops the prompt. Codex sends no model request:


The Codex screenshots show a real account whose plan has only a weekly window.
| What it does | Claude Code | Codex |
|---|---|---|
| Show the status | /spare10 | spare10 |
| Continue on the reserve | /spare10 resume | spare10 resume |
| Stop at the reserve now | /spare10 stop | spare10 stop |
| Set a test reading | /spare10 simulate 92 | spare10 simulate 92 |
| Change an option | /config | spare10 set reserve 15 |
In Codex, type the command as the whole prompt. During a turn, put ! in front of it.
| Option | Default | What it does |
|---|---|---|
reserve | 10 | The percent of the 5-hour window that you keep. |
weeklyReserve | 10 | The percent of the weekly window that you keep. 0 switches the weekly guard off. |
lastMinutes | 20 | spare10 opens the reserve in these last minutes of the 5-hour window. 0 switches this off. |
weeklyLastHours | 8 | spare10 opens the weekly reserve in these last hours of the weekly window. 0 switches this off. |
resumeFloor | 5 | After a Resume, spare10 asks again when this percent of the 5-hour window is left. |
weeklyResumeFloor | 5 | The same for the weekly window. |
pausePrompt | empty | Text here tells the agents to wind down, and spare10 stops nothing. At 100% used, limitPause still holds all work and asks you. |
autoResume | on | spare10 continues held and stopped work when the reserve opens, or after the reset. |
limitPause | on | At 100% used, spare10 holds all work and asks you once, also with a pause prompt. With no answer, it continues the work after the reset, unless autoResume is off. Switch it off when you pay for extra usage. |
headless | off | What spare10 does in unattended runs: off, prompt, stop or wait. |
scope | all | all guards every interactive session. opt-in guards only runs started with SPARE10=on. |
badge | on | Shows the badge in the Claude Code footer. Codex has no badge. |
In Claude Code, set the options in /config, in the row for spare10. In Codex, use spare10 set <option> <value>. A SPARE10_* variable changes an option for one run. See Configure.
/spare10 command.MIT. See LICENSE. spare10-mod ports texts and design from spare10 by Alessandro Diano, also under the MIT license.
hooks/register.tsx 2123 lines1import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
2import {
3 DEFAULTS,
4 LIMIT_UNREAD,
5 NO_SPANS,
6 childHeadless,
7 flagOnlyInShell,
8 fromOptions,
9 questionTimeout,
10 reserveOf,
11 spanOf,
12 unreadEnv,
13 watchedKinds,
14 withEnv,
15} from './core/config.ts'
16import type { Effective, EnvReads, Settings, Spans } from './core/config.ts'
17import {
18 BUDGET_FLOOR_MS,
19 CHECK_MS,
20 TICK_MS,
21 afterFailure,
22 askVerdict,
23 buried,
24 bury,
25 consentCounts,
26 consentCovers,
27 formatConsent,
28 formatStopped,
29 fullCovers,
30 heldPast,
31 isOverdue,
32 joinableAt,
33 limitVerdict,
34 noteSlot,
35 parseConsent,
36 parseStopped,
37 shouldAbortTurn,
38 slotList,
39 stopAction,
40 stopDue,
41 unbury,
42 withoutFloor,
43} from './core/decide.ts'
44import type { Answered, Consent, ConsentSlots, Holder, Mode, Outcome, Site, StoppedRecord, Tomb } from './core/decide.ts'
45import {
46 KINDS,
47 anchoredOf,
48 asAnchored,
49 atLimit,
50 basis,
51 initialMemory,
52 isTripped,
53 limitOf,
54 newer,
55 parseSimulate,
56 sawLive,
57 sawMeasure,
58 viewOf,
59 voidedByReset,
60 withoutVoided,
61} from './core/reading.ts'
62import type { Anchored, Basis, Kind, Memory } from './core/reading.ts'
63import {
64 ARGUMENT_HINT,
65 COMMAND_DESCRIPTION,
66 HEADER,
67 HEADLESS_GENERIC,
68 LIMIT_OPTIONS,
69 NOT_STARTED_GENERIC,
70 QUESTION_OPTIONS,
71 RESUME_LABEL,
72 STOP_GENERIC,
73 W_FLAG,
74 bgEnvWarning,
75 commandFailed,
76 debugLine,
77 limitQuestionText,
78 notPerson,
79 notice,
80 questionText,
81 resetContext,
82 resumeContext,
83 resumePrompt,
84 resumeReply,
85 simulateReply,
86 statusReport,
87 stopReply,
88 timeoutWarning,
89 unknownVerb,
90 withdrawnText,
91} from './core/text.ts'
92import type { Ended } from './core/text.ts'
93import { badgeView } from './core/badge.ts'
94import type { View } from './core/badge.ts'
95import {
96 againNotice,
97 answeredOf,
98 answersKind,
99 checksStop,
100 claimTold,
101 commandHolders,
102 consentBeyond,
103 consentOfEnd,
104 continuesLine,
105 dueRelease,
106 dueStep,
107 dueWait,
108 emptySplit,
109 endedFor,
110 extendNotice,
111 extended,
112 factsFrom,
113 floorEndsOf,
114 gatesAfter,
115 handoverTakes,
116 holdersFrom,
117 modeOf,
118 namedKinds,
119 namedOf,
120 namedStop,
121 needsRealList,
122 noFloor,
123 notStartedFor,
124 promptWaitsLine,
125 questionEdges,
126 questionOf,
127 quietOf,
128 raisesInPlace,
129 refusalText,
130 resetTooRecent,
131 resumeAskingReply,
132 resumeAtLimit,
133 resumeCase,
134 resumeNotice,
135 resumeReadReply,
136 seenOf,
137 seenSplit,
138 sensedNote,
139 sensesOf,
140 simulateText,
141 splitKind,
142 statusInput,
143 stopAutoOf,
144 stopAskingIdle,
145 stopAskingReply,
146 stopCase,
147 stopInForce,
148 stopKept,
149 stopKeptReply,
150 stopNewer,
151 stopNotice,
152 stopOpenNotice,
153 stopOverdueReply,
154 stopPlan,
155 stopRecordOf,
156 stopTrippedReply,
157 stopWriteOf,
158 stopsAtLimit,
159 supersedes,
160 takenOf,
161 takeoverSense,
162 tellText,
163 testReading,
164 tickPlan,
165 tierAtResume,
166 toldMainOf,
167 toldNotice,
168 unansweredGating,
169 unansweredHolders,
170 unattendedLines,
171 untilOf,
172 verdictOf,
173 viewedOf,
174 withRealEntries,
175} from './core/flow.ts'
176import type {
177 Acted,
178 Bases,
179 KindSense,
180 Late,
181 QuestionCore,
182 Seen,
183 Sensed,
184 Sourced,
185 Split,
186 StopSense,
187 StopWrite,
188 Taken,
189 Told,
190 Via,
191} from './core/flow.ts'
192
193// The only file that uses $ (design 10.9.6). Sense fails open, act fails closed (4.2). Every held
194// dispatch parks on the re-armed $.spare10.park carrier (4.4). The first waiter raises the one question
195// in its own dispatch, and a lost raiser hands it on (4.5). Decisions cross module copies in the env.
196// 0.2: one basis, consent and told set per kind. A ticker that session.start arms continues held and
197// stopped work at the reset (4 of the 0.2 design). The gates decide in rounds (5.3).
198// Skip near the reset: a tripped kind in the last span before its reset is open. It never gates, and no
199// stop holds it (B41, B44). While its skip start is ahead, that start is its hold end, with no margin (B42).
200// The resume floor (floor B48 to B55): a Resume at the reserve consents only until the floor point. A
201// consent to the floor applies while the reading is below its end point, and a gate path ends it for
202// good when its own basis reaches that point (B52). Then the kind gates again: the second question.
203// The quota limit (limit design): at 100% used with a known reset, a kind gates in every state, also open
204// or consented. The limit question asks Continue at the reset or Stop here. Continue at the reset writes
205// nothing: the held loops wait in place, and its due time releases them after the reset.
206
207type Ctx = { site: Site; agentId?: string; person?: boolean; resumed?: readonly Answered[] } // resumed: what the Resume that ended the last round answered (B50)
208type Settled = Outcome | 'again' // again: ended without an answer (4.5)
209type Raiser = { signal: AbortSignal }
210type Question = QuestionCore & {
211 budgetLogAt: number // the last B40 debug line
212 checking: boolean // one waiter runs the check at a time
213 waiting: number
214}
215type Release = { raw: string; record: StoppedRecord; cancelled: boolean }
216type Wake = { p: Promise<'woke'>; fire: () => void }
217
218const NAME = 'spare10'
219const ENV = crypto.randomUUID() // this copy of the module
220const HANDOFF_LIMIT = 5
221const FAST_MS = 1000
222const FAST_LIMIT = 3
223const PULSE_MS = 1000
224const PULSE_REUSE_MS = 5 * PULSE_MS // a pulse render reuses the last tripped inputs this long: only the glyph changes
225const BUDGET_LOG_MS = 600_000 // one budget debug line per 10 minutes per question (B40)
226const EDGE_LIMIT = 64
227const WATCH_MS = 300_000 // the period of the watch timer, the ticker's slow second clock (4.2)
228const BOX_DEFER_LIMIT = 10 // ticks the resume prompt waits for the prompt box (4.6.2)
229const STALE_TICK_MS = 90_000 // /spare10 warns when the last tick is older (2.7)
230
231function noKinds<T>(make: () => T): Record<Kind, T> {
232 return { five_hour: make(), seven_day: make() }
233}
234
235let base: Settings = DEFAULTS // register()
236let effective: Promise<Effective> | undefined // register() resets it
237let autoNow: boolean = DEFAULTS.autoResume // this copy's effective autoResume, answered by $.spare10.auto()
238let spansNow: Spans = NO_SPANS // B47: this copy's spans of its last successful settings read, answered by $.spare10.spans()
239let limitNow: boolean = LIMIT_UNREAD // this copy's effective limitPause, answered by $.spare10.limit(): on until a settings read succeeds
240let attended: boolean | undefined // session.start, else lazily
241let sid: string | undefined // session.start, refreshed by stoppedNow, writeStopped, writeConsent, and act on a tell or headless verdict
242let endedSid: string | undefined // the id the last /clear or /resume ended: its stop no longer counts (D3)
243const pastIds = new Set<string>() // ids that /clear or /resume ended in this process: their consent is this process's
244let bgKind: boolean | undefined // CLAUDE_CODE_SESSION_KIND=bg, read once
245const mem: Record<Kind, Memory> = noKinds(initialMemory)
246let seedLoaded = false
247let test: Partial<Record<Kind, Anchored>> = {}
248let testFromEnvDone = false
249let consentCache: Partial<Record<Kind, ConsentSlots>> = {}
250let testConsent: Partial<Record<Kind, ConsentSlots>> = {} // a Resume on a test reading: never in the env, cleared with the test reading
251const tombs: Partial<Record<Kind, Tomb[]>> = {} // B52: the real consents to the floor that a gate of this copy ended
252let consentEpoch = 0
253const fallbackEnd: Partial<Record<Kind, number>> = {} // R11: one fallback window end per kind and episode
254let startWarnings: string[] = []
255let seq = 0
256let openKey: string | undefined
257const questions = new Map<string, Question>()
258const outcomes = new Map<string, Settled>()
259const outcomeWaits = new Map<string, Array<(o: Settled | 'continue') => void>>() // 'continue': Continue at the reset withdraws the dialog
260const needsRaise = new Set<string>()
261const raising: Array<{ text: string; key: string }> = [] // one entry per $.ui.ask in flight, until that ask ends
262const parked = new Map<string, (why: string) => void>()
263let wake = newWake()
264const stepped = new Set<string>()
265const refusedTurns: string[] = []
266const HOLDING = new WeakSet<object>()
267const told: Told = noKinds(() => ({ windowEnd: 0, keys: new Set<string>() }))
268const toldNoticeFor: Record<Kind, string> = noKinds(() => '') // `${windowEnd}:${stage}` of the last B12 notice (B51)
269const unattendedNoteFor: Record<Kind, number> = noKinds(() => 0) // window end of the last B15 debug line
270const openNoteFor: Record<Kind, number> = noKinds(() => 0) // window end of the last open B15 debug line (skip 2.5)
271let tickerWanted = false // session.start: enabled, and attended or wait
272let ticker: Timer | undefined
273let tickGen = 0
274let lastTick = 0 // clock time of the last tick (the watchdog reads it)
275let ticking = false // a tick is running: the next one skips its work
276const edges: number[] = [] // times at which the badge can change
277let release: Release | undefined // the ticker's stop release in flight in this copy (4.6)
278let taking = false // a person path takes an overdue stop over (4.6.3)
279let handedOver: { record: StoppedRecord; at: number } | undefined // cleared by the ticker while a person prompt was in flight
280let personHeld = 0 // person prompt.submit dispatches in flight in this copy
281let stopEpoch = 0 // writeStopped and clearStopped in this copy
282let workMarked = false // markWork ran for the current stop
283let lastRestored: string | undefined // the draft restoreDraft put back
284let resumeDefers = 0 // ticks the resume prompt waited for the prompt box
285let deferFor: string | undefined // the SPARE10_STOPPED value that resumeDefers counts for
286let watch: Timer | undefined // the second clock: it re-arms a dead ticker (4.2)
287let lastWatch = 0 // clock time of the last watch period (the ticker reads it)
288let pulse: Timer | undefined
289let blink = true
290let drawGen = 0 // redraw() calls: a render after one senses afresh
291let pulseDue = false // a pulse asked for renders, and no redraw came since: each render of each surface may reuse
292let trippedInputs: { gen: number; at: number; reserve: number; test: boolean; mode: Mode } | undefined // the last full tripped render
293let viewKey = ''
294
295// ---- Settings (8.2) ----
296
297function settings($: EngineInterface): Promise<Effective> {
298 effective ??= readEnv($).then(
299 (env) => {
300 const eff = withEnv(base, env)
301 autoNow = eff.autoResume
302 spansNow = { lastMinutes: eff.lastMinutes, weeklyLastHours: eff.weeklyLastHours }
303 limitNow = eff.limitPause
304 return eff
305 },
306 () => {
307 effective = undefined // a failed read is tried again at the next event
308 return unreadEnv(base) // B47: spans of 0 until a read succeeds
309 },
310 )
311 return effective
312}
313
314async function readEnv($: EngineInterface): Promise<EnvReads> {
315 const reserve = await $.env.get('SPARE10_RESERVE')
316 const weeklyReserve = await $.env.get('SPARE10_WEEKLY_RESERVE')
317 const lastMinutes = await $.env.get('SPARE10_LAST_MINUTES')
318 const weeklyLastHours = await $.env.get('SPARE10_WEEKLY_LAST_HOURS')
319 const resumeFloor = await $.env.get('SPARE10_RESUME_FLOOR')
320 const weeklyResumeFloor = await $.env.get('SPARE10_WEEKLY_RESUME_FLOOR')
321 const pausePrompt = await $.env.get('SPARE10_PAUSE_PROMPT')
322 const autoResume = await $.env.get('SPARE10_AUTO_RESUME')
323 const limitPause = await $.env.get('SPARE10_LIMIT_PAUSE')
324 const headless = await $.env.get('SPARE10_HEADLESS')
325 const onOff = await $.env.get('SPARE10')
326 const simulate = await $.env.get('SPARE10_SIMULATE')
327 return {
328 ...(reserve !== undefined && { reserve }),
329 ...(weeklyReserve !== undefined && { weeklyReserve }),
330 ...(lastMinutes !== undefined && { lastMinutes }),
331 ...(weeklyLastHours !== undefined && { weeklyLastHours }),
332 ...(resumeFloor !== undefined && { resumeFloor }),
333 ...(weeklyResumeFloor !== undefined && { weeklyResumeFloor }),
334 ...(pausePrompt !== undefined && { pausePrompt }),
335 ...(autoResume !== undefined && { autoResume }),
336 ...(limitPause !== undefined && { limitPause }),
337 ...(headless !== undefined && { headless }),
338 ...(onOff !== undefined && { onOff }),
339 ...(simulate !== undefined && { simulate }),
340 }
341}
342
343// ---- Attendance (9.1) ----
344
345async function isAttended($: EngineInterface): Promise<boolean> {
346 if (attended !== undefined) return attended
347 attended = (await $.session.surfaces()).includes('terminal')
348 return attended
349}
350
351// ---- Reading (6, 3.1 of 0.2) ----
352
353/** A test reading of a kind (flow.ts testReading). Its end is a time at which the badge can change. */
354function testReadingAt(pct: number, kind: Kind, live: SessionRateLimit | undefined, now: number, inMs?: number): Anchored {
355 const reading = testReading(pct, kind, live, now, inMs)
356 addEdge(reading.resetsAtMs)
357 return reading
358}
359
360/**
361 * The spans in force (B47): the newest copy's answer. A copy that cannot reach the noun uses 0. The
362 * try also covers a newest copy whose noun has no spans method (an older build): that call throws
363 * before it returns a promise, and a throw here would fail the whole sense open.
364 */
365async function spansInForce($: EngineInterface): Promise<Spans> {
366 try {
367 return await $.spare10.spans()
368 } catch {
369 return NO_SPANS
370 }
371}
372
373/** One usage read, the seeds once, the test reading per kind. */
374async function currentBases($: EngineInterface, now: number): Promise<Bases> {
375 if (!seedLoaded) {
376 seedLoaded = true
377 const five = asAnchored(await $.store.get('seed').catch(() => undefined))
378 if (five !== undefined) mem.five_hour = { ...mem.five_hour, seed: newer(mem.five_hour.seed, five) }
379 const week = asAnchored(await $.store.get('seed-weekly').catch(() => undefined))
380 if (week !== undefined) mem.seven_day = { ...mem.seven_day, seed: newer(mem.seven_day.seed, week) }
381 }
382 const limits = (await $.session.usage()).rateLimits
383 for (const kind of KINDS) {
384 const live = limitOf(limits, kind)
385 if (live !== undefined) mem[kind] = sawLive(mem[kind], live, now)
386 }
387 if (!testFromEnvDone) {
388 testFromEnvDone = true
389 const cfg = await settings($)
390 if (cfg.testPct !== undefined) {
391 const kind = cfg.testKind ?? 'five_hour'
392 test[kind] = testReadingAt(cfg.testPct, kind, limitOf(limits, kind), now, cfg.testInMs)
393 }
394 }
395 const both = (kind: Kind): { basis: Basis; real: Basis } => ({
396 basis: basis(limitOf(limits, kind), mem[kind], now, test[kind], kind),
397 real: basis(limitOf(limits, kind), mem[kind], now, undefined, kind),
398 })
399 return { five_hour: both('five_hour'), seven_day: both('seven_day') }
400}
401
402/** The spans for one sense: asked only when a watched kind is tripped, so an event below the reserve costs no noun call. */
403async function spansFor($: EngineInterface, cfg: Effective, bases: Bases): Promise<Spans> {
404 const anyTripped = watchedKinds(cfg).some((kind) => isTripped(bases[kind].basis, reserveOf(cfg, kind)))
405 return anyTripped ? await spansInForce($) : NO_SPANS
406}
407
408/**
409 * The settings with the pause at the quota limit in force: the newest copy's answer, asked only when a
410 * watched kind is at 100% or more with a reset (so an event below it costs no noun call). A reload changes
411 * the option, and a held loop of an older copy then reads the new value. A copy that cannot reach the noun,
412 * or a newest copy with no limit method (an older build: the call throws before it returns a promise),
413 * keeps this copy's value.
414 */
415async function limitFor($: EngineInterface, cfg: Effective, bases: Bases): Promise<Effective> {
416 if (!watchedKinds(cfg).some((kind) => atLimit(bases[kind].basis))) return cfg
417 let on: boolean
418 try {
419 on = await $.spare10.limit()
420 } catch {
421 on = cfg.limitPause
422 }
423 return on === cfg.limitPause ? cfg : { ...cfg, limitPause: on }
424}
425
426/** Every watched kind at now (flow.ts sensesOf). The skip starts of tripped kinds are badge edges (skip 4.1). */
427function sensesNow(cfg: Effective, bases: Bases, spans: Spans, now: number): KindSense[] {
428 const r = sensesOf(cfg, bases, spans, now, fallbackEnd, mem)
429 for (const t of r.edges) addEdge(t) // the ticker redraws at the skip start
430 return r.kinds
431}
432
433function noteBasis($: EngineInterface, kinds: ReadonlyArray<{ kind: Kind; basis: Basis; tripped: boolean; open: boolean }>): void {
434 // The reset is in the key too: a new window at the same figure starts a new told set (5.1).
435 const key = kinds
436 .map((k) => `${k.kind}:${k.basis.kind}:${k.basis.kind === 'none' ? k.basis.why : `${k.basis.pct}:${k.basis.resetsAtMs}`}:${k.tripped}:${k.open}`)
437 .join('|')
438 if (key === viewKey) return
439 viewKey = key
440 redraw($)
441}
442
443// ---- Consent and stopped (3.5, 3.2) ----
444
445async function isBg($: EngineInterface): Promise<boolean> {
446 bgKind ??= (await $.env.get('CLAUDE_CODE_SESSION_KIND')) === 'bg'
447 return bgKind
448}
449
450/**
451 * The consents of a kind (floor 4.2): this copy's slots, the test slots on a test basis, and an env value
452 * that belongs to this process (3.5, 9.3) with its raw text. A clear during the read gives the slots only.
453 * An env value that a tomb of this copy buries is no consent, and it goes by compare-and-set (B52).
454 * `realEnd` (A22): the reset of the kind's real reading, null when unknown. A stamped env value and a real
455 * slot that an early reset voided are no consent either: the value goes by compare-and-set, the slot at once.
456 * A bare value that you set yourself stays until its time.
457 */
458async function consentsOf(
459 $: EngineInterface,
460 kind: Kind,
461 attendedNow: boolean,
462 testBasis: boolean,
463 realEnd: number | null = null,
464): Promise<Sourced[]> {
465 const epoch = consentEpoch
466 // Two branches, so each $.env.get keeps a literal name.
467 const raw = kind === 'seven_day' ? await $.env.get('SPARE10_WEEKLY_CONSENT') : await $.env.get('SPARE10_CONSENT')
468 const read = parseConsent(raw)
469 const voided = read?.sessionId !== undefined && realEnd !== null && voidedByReset(read, realEnd)
470 const dead = read !== undefined && (buried(tombs[kind], read) || voided)
471 if (dead) void unsetIfSame($, kind, raw).catch(() => undefined)
472 const c = dead ? undefined : read
473 const counts =
474 c !== undefined &&
475 consentCounts(c.sessionId, {
476 attended: attendedNow,
477 bg: attendedNow && c.sessionId === undefined ? await isBg($) : false,
478 ids: attendedNow && c.sessionId !== undefined ? [...pastIds, await $.session.id()] : [],
479 })
480 if (epoch !== consentEpoch) return slotsOf(kind, false, realEnd) // a clear ran meanwhile: this read is stale
481 const env: Sourced[] = counts ? [{ c: { until: c.until, ...(c.to === undefined ? {} : { to: c.to }) }, from: 'env', ...(raw === undefined ? {} : { raw }) }] : []
482 return [...slotsOf(kind, testBasis, realEnd), ...env]
483}
484
485/**
486 * This copy's consents of a kind as they are now: the slots, and the test slots on a test basis. A22: a
487 * real slot that an early reset voided (`realEnd`) goes at once. A test slot never does: its window is the test's.
488 */
489function slotsOf(kind: Kind, testBasis: boolean, realEnd: number | null = null): Sourced[] {
490 if (realEnd !== null) consentCache[kind] = withoutVoided(consentCache[kind], realEnd)
491 return [
492 ...slotList(consentCache[kind]).map((x): Sourced => ({ c: x, from: 'slot' })),
493 ...(testBasis ? slotList(testConsent[kind]).map((x): Sourced => ({ c: x, from: 'test' })) : []),
494 ]
495}
496
497/** A Resume of this copy. A new real consent to the floor lifts the tombs that bury it (B52). */
498function noteConsent(kind: Kind, c: Consent, isTest: boolean): void {
499 if (isTest) {
500 testConsent[kind] = noteSlot(testConsent[kind], c)
501 return
502 }
503 consentCache[kind] = noteSlot(consentCache[kind], c)
504 if (c.to !== undefined) tombs[kind] = unbury(tombs[kind], c)
505}
506
507/**
508 * A Resume's consent of one kind. A consent to the floor never replaces a full value of this process
509 * for the same window in the env (floor 3.3): the stronger tier stays. `noted`: the caller noted the
510 * consent in this copy's slots when it decided (settle), so a split that ended it since stays ended.
511 */
512async function writeConsent($: EngineInterface, kind: Kind, c: Consent, now: number, isTest: boolean, noted = false): Promise<void> {
513 if (c.until <= now) return // never for a window that has ended (R10)
514 if (!noted) noteConsent(kind, c, isTest)
515 addEdge(c.until)
516 if (isTest) return // a Resume on a test reading never carries into real use (3.5)
517 sid = await $.session.id() // stamped: only this process honours it in an attended session (9.3)
518 if (buried(tombs[kind], c)) return // B52: a split ended it since the Resume
519 const value = formatConsent(sid, c.until, c.to)
520 if (kind === 'seven_day') {
521 if (c.to !== undefined && fullCovers(parseConsent(await $.env.get('SPARE10_WEEKLY_CONSENT')), [...pastIds, sid], c.until, now)) return
522 await $.env.set('SPARE10_WEEKLY_CONSENT', value)
523 } else {
524 if (c.to !== undefined && fullCovers(parseConsent(await $.env.get('SPARE10_CONSENT')), [...pastIds, sid], c.until, now)) return
525 await $.env.set('SPARE10_CONSENT', value)
526 }
527 // B52: a split that ended this consent to the floor while it was written (the reading passed its point
528 // meanwhile) removed its slot and buried it. The late write must not bring it back: unset it by
529 // compare-and-set. A split that ends it after this check finds the value in its sweep.
530 if (c.to !== undefined && (buried(tombs[kind], c) || !holdsConsent(consentCache[kind], c, now))) await unsetIfSame($, kind, value)
531}
532
533/** This copy's slots still hold a consent at least as strong as `c` for its window: `c` did not end. */
534const holdsConsent = (slots: ConsentSlots | undefined, c: Consent, now: number): boolean =>
535 slotList(slots).some((x) => consentCovers(x.until, now, c.until) && (x.to === undefined || x.to >= (c.to ?? 0)))
536
537/**
538 * B52: the consents to the floor of a tripped, not open kind whose own basis has reached their end point
539 * end for good (flow.ts floorEndsOf). This copy's slots go at once (synchronously). A real consent also
540 * gets a tomb at once, and a sweep unsets the env value that the tomb buries, fire and forget. `failed`:
541 * the env read failed, so `list` has only the slots. Never throws.
542 */
543function endFloors($: EngineInterface, k: KindSense, list: readonly Sourced[], now: number, failed = false): void {
544 const ends = floorEndsOf(k, list, now, failed)
545 for (const e of ends.unset) {
546 if (e.from === 'test') testConsent[k.kind] = withoutFloor(testConsent[k.kind]) // never in the env, so no tomb
547 else if (e.from === 'slot') consentCache[k.kind] = withoutFloor(consentCache[k.kind])
548 }
549 for (const t of ends.tombs) tombs[k.kind] = bury(tombs[k.kind], t, now)
550 if (ends.tombs.length > 0) void sweep($, k.kind).catch(() => undefined)
551}
552
553/** B52: unsets the env value of a kind that a tomb of this copy buries. It reads the value first. */
554async function sweep($: EngineInterface, kind: Kind): Promise<void> {
555 const raw = kind === 'seven_day' ? await $.env.get('SPARE10_WEEKLY_CONSENT') : await $.env.get('SPARE10_CONSENT')
556 const c = parseConsent(raw)
557 if (c !== undefined && buried(tombs[kind], c)) await unsetIfSame($, kind, raw)
558}
559
560/** B52: unsets a consent value only while it still holds the raw text that the split read. */
561async function unsetIfSame($: EngineInterface, kind: Kind, raw: string | undefined): Promise<void> {
562 if (raw === undefined) return
563 // Two branches, so each $.env call keeps a literal name.
564 if (kind === 'seven_day') {
565 if ((await $.env.get('SPARE10_WEEKLY_CONSENT')) === raw) await $.env.set('SPARE10_WEEKLY_CONSENT', undefined)
566 } else if ((await $.env.get('SPARE10_CONSENT')) === raw) await $.env.set('SPARE10_CONSENT', undefined)
567}
568
569async function clearConsent($: EngineInterface): Promise<void> {
570 consentEpoch += 1
571 consentCache = {}
572 testConsent = {}
573 await Promise.all([$.env.set('SPARE10_CONSENT', undefined), $.env.set('SPARE10_WEEKLY_CONSENT', undefined)])
574}
575
576/**
577 * After /clear or /resume: a consent stamped with an ended id of this process takes the new id, with its
578 * end point. Each write goes only over the raw value read first (floor 3.3): a consent that a gate ended
579 * or a second Resume replaced meanwhile stays as it is now. A consent that a tomb buries is never
580 * written again, and a gate that buries it during the write unsets the new value (B52).
581 */
582async function restampConsent($: EngineInterface): Promise<void> {
583 const epoch = consentEpoch
584 const fiveRaw = await $.env.get('SPARE10_CONSENT')
585 const weekRaw = await $.env.get('SPARE10_WEEKLY_CONSENT')
586 const five = parseConsent(fiveRaw)
587 const week = parseConsent(weekRaw)
588 const ended = (c: typeof five): c is { until: number; sessionId: string; to?: number } =>
589 c?.sessionId !== undefined && pastIds.has(c.sessionId)
590 if (!ended(five) && !ended(week)) return
591 const id = await $.session.id()
592 if (epoch !== consentEpoch) return // cleared meanwhile
593 if (ended(five) && id !== five.sessionId && (await $.env.get('SPARE10_CONSENT')) === fiveRaw && !buried(tombs.five_hour, five)) {
594 const value = formatConsent(id, five.until, five.to)
595 await $.env.set('SPARE10_CONSENT', value)
596 if (buried(tombs.five_hour, five)) await unsetIfSame($, 'five_hour', value)
597 }
598 if (ended(week) && id !== week.sessionId && (await $.env.get('SPARE10_WEEKLY_CONSENT')) === weekRaw && !buried(tombs.seven_day, week)) {
599 const value = formatConsent(id, week.until, week.to)
600 await $.env.set('SPARE10_WEEKLY_CONSENT', value)
601 if (buried(tombs.seven_day, week)) await unsetIfSame($, 'seven_day', value)
602 }
603}
604
605/**
606 * The stop of this conversation that applies now, if any. `gating`: the kinds that gate now. `holders`:
607 * the kinds whose real reading gates now. A stop past its until that one of them keeps applies too (TS1:
608 * `holdsPast`), with the end it will have.
609 */
610async function stoppedNow(
611 $: EngineInterface,
612 now: number,
613 gating: readonly KindSense[],
614 holders: readonly Holder[],
615): Promise<StoppedRecord | undefined> {
616 sid = await $.session.id()
617 const st = parseStopped(await $.env.get('SPARE10_STOPPED'))
618 // A stop of the conversation that /clear or /resume ended no longer counts, also before the engine
619 // answers the new id (D3).
620 return stopInForce(st, sid, endedSid, now, gating, holders)
621}
622
623/**
624 * 5.7: the 0.2 record, merged with an earlier stop of this session (3.2). Returns what it wrote, for the
625 * texts. `real` (TS1): the kinds whose real reading gates at the stop, each with the reset of its real
626 * reading now. Only those of `kinds` are kept.
627 */
628async function writeStopped($: EngineInterface, n: StopWrite, now: number): Promise<StoppedRecord> {
629 sid = await $.session.id() // R3: a fresh id, a /clear may have run since the last read
630 const prev = parseStopped(await $.env.get('SPARE10_STOPPED').catch(() => undefined))
631 const r = stopRecordOf(prev, n, sid, now)
632 stopEpoch += 1
633 workMarked = r.work === true
634 await $.env.set('SPARE10_STOPPED', formatStopped(r))
635 addEdge(r.windowEnd)
636 addEdge(stopDue(r))
637 return r
638}
639
640async function clearStopped($: EngineInterface): Promise<void> {
641 stopEpoch += 1
642 workMarked = false
643 await $.env.set('SPARE10_STOPPED', undefined)
644}
645
646/** 5.7: the first refused loop of a stop adds `work`, so the reset continues it. Never delays the refusal. */
647function markWork($: EngineInterface): void {
648 if (workMarked) return
649 workMarked = true
650 const epoch = stopEpoch
651 void (async () => {
652 const raw = await $.env.get('SPARE10_STOPPED')
653 const r = parseStopped(raw)
654 if (r?.kinds === undefined || r.work === true || r.sessionId !== sid) return
655 // A Resume, a release or a new Stop may have come meanwhile: write only over the value just read.
656 if ((await $.env.get('SPARE10_STOPPED')) !== raw || epoch !== stopEpoch) return
657 await $.env.set('SPARE10_STOPPED', formatStopped({ ...r, work: true }))
658 })().catch(() => {
659 workMarked = false
660 })
661}
662
663async function listed($: EngineInterface, agentId: string): Promise<boolean> {
664 return (await $.agent.list()).some((a) => a.id === agentId)
665}
666
667// ---- The decision (5.2) ----
668
669async function sense($: EngineInterface): Promise<Sensed> {
670 const own = await settings($)
671 const now = await $.clock.now()
672 const bases = await currentBases($, now)
673 const cfg = await limitFor($, own, bases) // the pause at the limit in force
674 const kinds = sensesNow(cfg, bases, await spansFor($, cfg, bases), now)
675 noteBasis($, kinds)
676 const tripped = kinds.some((k) => k.tripped)
677 const att = tripped ? await isAttended($) : attended === true
678 return { cfg, now, kinds: att ? kinds : kinds.map(noFloor), tripped, attended: att } // B55
679}
680
681/**
682 * Skip 3.3: the tripped kinds, split into those with a consent that applies (B49), those that gate and
683 * those that are open. An open kind whose only covering consent is a consent to the floor is open (floor
684 * 1.3 item 7). B52: a consent to the floor of a kind that is not open ends for good when its own basis
685 * reaches its end point, also when the env read fails. Never throws: an unreadable consent is not consent.
686 */
687async function splitOf($: EngineInterface, s: { kinds: readonly KindSense[]; now: number; attended: boolean }): Promise<Split> {
688 const out = emptySplit()
689 for (const k of s.kinds) {
690 if (!k.tripped) continue
691 const read = await consentsOf($, k.kind, s.attended, k.test, k.realReset).then(
692 (list) => ({ list, failed: false }),
693 () => ({ list: slotsOf(k.kind, k.test, k.realReset), failed: true }),
694 )
695 if (!k.open) endFloors($, k, read.list, s.now, read.failed) // B52: sync slots and tombs, void env
696 splitKind(out, k, read.list, read.failed, s.now) // unreadable: not consented
697 }
698 return out
699}
700
701/** The watched kinds that gate: tripped, not consented and not open. Never throws. */
702async function gatingOf($: EngineInterface, s: Sensed): Promise<KindSense[]> {
703 return (await splitOf($, s)).gating
704}
705
706/**
707 * TS1: the kinds whose real reading gates now. A kind whose view is its real reading gates as the view
708 * says, so it is in `gating`. Beneath a test reading, the real reading gates when it is tripped, not open
709 * and no real consent applies on the real reading (B49): one read of the real consent, never a Resume on
710 * the test reading (3.5). A real reading without a reset time takes the one-hour bound. It only reads:
711 * the next split ends a real consent to the floor (B52). Never throws: an unreadable consent is not consent.
712 */
713async function holdersOf(
714 $: EngineInterface,
715 s: { kinds: readonly KindSense[]; now: number; attended: boolean },
716 gating: readonly KindSense[],
717): Promise<Holder[]> {
718 const lists: Partial<Record<Kind, Sourced[]>> = {}
719 for (const k of s.kinds) {
720 if (needsRealList(k)) lists[k.kind] = await consentsOf($, k.kind, s.attended, false, k.realReset).catch((): Sourced[] => [])
721 }
722 return holdersFrom(s.kinds, gating, (k) => lists[k.kind] ?? [], s.now)
723}
724
725async function act($: EngineInterface, s: Sensed, ctx: Ctx): Promise<Acted> {
726 // The round after a Resume leaves out the kinds it answered: only a kind the dialog did not name asks
727 // again (B38). B50: only on the Resume's basis and below its end point, so no step passes the floor.
728 const resumed = ctx.resumed ?? []
729 const gating = s.cfg.enabled ? unansweredGating(resumed, await gatingOf($, s)) : []
730 // TS1: the kinds whose real reading gates. They keep a stop past its end, and a Stop here names them.
731 const holders = s.cfg.enabled ? unansweredHolders(s, resumed, await holdersOf($, s, gating)) : []
732 const stopped = checksStop(s, gating) ? (await stoppedNow($, s.now, gating, holders).catch(() => undefined)) !== undefined : false
733 const toldMain = toldMainOf(told, gating, sid ?? '')
734 let { verdict } = verdictOf({ s, site: ctx.site, person: ctx.person === true, gating, holders, stopped, toldMain })
735 if (
736 (verdict.kind === 'hold' || verdict.kind === 'refuse') &&
737 ctx.site === 'tool' &&
738 ctx.agentId !== undefined &&
739 !stepped.has(ctx.agentId) &&
740 !(await listed($, ctx.agentId).catch(() => true))
741 ) {
742 verdict = { kind: 'pass', trip: true } // an engine fork: never held (G8)
743 }
744 // The told key and HEADLESS carry the current id: a /clear brings no session.start (3.6), and an
745 // unattended run never reaches stoppedNow.
746 if (verdict.kind === 'tell' || (!s.attended && (verdict.kind === 'refuse' || verdict.kind === 'hold'))) {
747 sid = await $.session.id().catch(() => sid)
748 }
749 if (s.cfg.enabled && !s.attended) noteUnattended($, s) // B15 debug line, once per kind and window (R2: enabled runs only)
750 return { verdict, stopped, gating, holders }
751}
752
753// ---- The hold (4.4, 5.4) ----
754
755function newWake(): Wake {
756 let fire: () => void = () => undefined
757 const p = new Promise<'woke'>((resolve) => {
758 fire = () => resolve('woke')
759 })
760 return { p, fire }
761}
762
763function wakeAll(): void {
764 for (const resolve of parked.values()) resolve('wake')
765 parked.clear()
766 wake.fire()
767 wake = newWake()
768}
769
770function outcomeOf(key: string): Promise<Settled | 'continue'> {
771 const o = outcomes.get(key)
772 if (o !== undefined) return Promise.resolve(o)
773 return new Promise((resolve) => outcomeWaits.set(key, [...(outcomeWaits.get(key) ?? []), resolve]))
774}
775
776async function hold($: EngineInterface, signal: AbortSignal, key: string, left: () => number): Promise<Settled | 'aborted'> {
777 const q0 = questions.get(key)
778 if (q0 !== undefined) q0.waiting += 1
779 try {
780 const waiter = crypto.randomUUID()
781 let fast = 0
782 for (;;) {
783 const o = outcomes.get(key)
784 if (o !== undefined) return o
785 if (signal.aborted) return 'aborted'
786 if (!questions.has(key)) return 'stop' // closed under a live waiter: fail closed
787 if (left() < BUDGET_FLOOR_MS) {
788 void settle($, key, 'stop', 'time limit') // B40: before the budget runs out
789 return 'stop'
790 }
791 if (needsRaise.has(key)) {
792 needsRaise.delete(key)
793 raise($, key, { signal }) // this waiter raises the dialog (4.5)
794 }
795 const t0 = performance.now()
796 const carrier = $.spare10.park({ waiter }).then(
797 () => 'woke' as const,
798 () => 'rejected' as const,
799 )
800 // The park call is in flight from here: the reads below cost the budget nothing (G2).
801 const d = await decidedElsewhere($, key)
802 if (d !== undefined) {
803 void settle($, key, d, 'elsewhere')
804 return d
805 }
806 if (await dueCheck($, key)) continue // the top returns 'again'
807 await noteBudget($, key, left())
808 // A hand-off or a close that came while this waiter read the env: act on it now, not after a carrier cycle.
809 if (outcomes.has(key) || signal.aborted || needsRaise.has(key) || !questions.has(key)) continue
810 // One abort promise per round, its listener gone when the round ends: a promise that never
811 // settles would keep one race reaction per cycle for the whole hold. No await since the check above.
812 let end = (): void => undefined
813 const round = new Promise<'aborted'>((resolve) => {
814 end = () => resolve('aborted')
815 })
816 signal.addEventListener('abort', end, { once: true })
817 let r: 'woke' | 'rejected' | 'aborted'
818 try {
819 r = await Promise.race([carrier, wake.p, round])
820 } finally {
821 signal.removeEventListener('abort', end)
822 }
823 if (r === 'aborted') return 'aborted'
824 if (r === 'rejected' && performance.now() - t0 < FAST_MS) {
825 fast += 1
826 if (fast >= FAST_LIMIT) return 'stop' // the noun is gone: never spin, fail closed
827 } else fast = 0
828 }
829 } catch {
830 return 'stop' // a hold never throws
831 } finally {
832 const q = questions.get(key)
833 if (q !== undefined) {
834 q.waiting -= 1
835 // Nobody left to raise it, or a quiet hold with no waiter (5.5): silent, or chosen at the limit. The next step asks again.
836 if (q.waiting === 0 && !outcomes.has(key) && (needsRaise.has(key) || quietOf(q))) forget($, key)
837 }
838 }
839}
840
841async function decidedElsewhere($: EngineInterface, key: string): Promise<Outcome | undefined> {
842 const q = questions.get(key)
843 if (q === undefined) return undefined
844 const now = await $.clock.now().catch(() => q.since)
845 let covered = q.kinds.length > 0 && q.limit !== true // a consent never answers the limit question
846 for (const kind of covered ? q.kinds : []) {
847 const end = q.ends[kind]
848 // B50 item 3: a consent answers a kind at a matching tier. A consent to the floor never answers a kind asked at the floor.
849 // A22: a consent of an earlier window is void, also when the gate could not unset it. As Codex question.ts.
850 const realEnd = end === undefined || end.test ? null : end.end
851 const list = end === undefined ? [] : await consentsOf($, kind, !q.silent, end.test, realEnd).catch((): Sourced[] => [])
852 if (!answersKind(end, list, now)) {
853 covered = false
854 break
855 }
856 }
857 if (covered) return 'resume'
858 // R6: a stop from another copy settles a question in hold and tell mode alike.
859 const st = parseStopped(await $.env.get('SPARE10_STOPPED').catch(() => undefined))
860 if (!stopNewer(st, q, now)) return undefined
861 const id = await $.session.id().catch(() => sid)
862 return st.sessionId === id ? 'stop' : undefined
863}
864
865/** 4.5: the waiter's check. True when the question ended as again. */
866async function dueCheck($: EngineInterface, key: string): Promise<boolean> {
867 const q = questions.get(key)
868 if (q === undefined || q.checking || outcomes.has(key)) return false
869 q.checking = true // before the first await: one waiter at a time
870 try {
871 const now = await $.clock.now()
872 if (dueWait(q, now)) return false
873 q.nextCheck = now + CHECK_MS
874 // The setting in force: a quiet question never reads it.
875 const step = dueStep(q, now, quietOf(q) || (await $.spare10.auto().catch(() => q.auto)))
876 if (step === 'note') {
877 q.noted = true
878 $.ui.log(notice.resetWaitingFor(namedOf(q))) // D0.2, byte for byte
879 return false
880 }
881 if (step === 'noteSensed') {
882 // B43: the note names what opened or reset, so it senses. A throw: the catch logs it, the next check tries again.
883 const s = await sense($)
884 const n = sensedNote(q, s, await gatingOf($, s), now)
885 if ('noteAt' in n) {
886 q.noteAt = n.noteAt // B45: nothing of the question opened yet. Wait for its hold end.
887 addEdge(q.noteAt)
888 return false
889 }
890 q.noted = true
891 $.ui.log(n.text)
892 return false
893 }
894 if (step === 'wait') return false
895 const s = await sense($) // throws: nothing is released
896 const gatingNow = await gatingOf($, s)
897 const via = dueRelease(q, now, gatingNow, resetTooRecent(s, mem))
898 if (via === undefined) return false
899 if (outcomes.has(key)) return false // answered meanwhile
900 settleAgain($, key, via, gatingNow, s)
901 return true
902 } catch (err) {
903 $.ui.log(debugLine.checkFailed(String(err)), { to: 'debug' })
904 return false
905 } finally {
906 q.checking = false
907 }
908}
909
910/** 4.5: the question ends without an answer. Synchronous, writes nothing: every held loop decides afresh. */
911function settleAgain($: EngineInterface, key: string, via: 'reset' | 'quota' | 'limit', gatingNow: readonly KindSense[], s: Sensed): void {
912 if (outcomes.has(key)) return
913 const q = questions.get(key)
914 outcomes.set(key, 'again')
915 needsRaise.delete(key)
916 for (const resolve of outcomeWaits.get(key) ?? []) resolve('again') // withdraws this copy's dialog
917 outcomeWaits.delete(key)
918 if (openKey === key) openKey = undefined
919 wakeAll()
920 if (q !== undefined) $.ui.log(againNotice(q, via, gatingNow, s))
921 redraw($)
922}
923
924/** B40: the waiter logs its budget once per 10 minutes per question, so LC25 can measure a cycle. */
925async function noteBudget($: EngineInterface, key: string, leftMs: number): Promise<void> {
926 const q = questions.get(key)
927 if (q === undefined) return
928 const now = await $.clock.now()
929 if (now - q.budgetLogAt < BUDGET_LOG_MS) return
930 q.budgetLogAt = now
931 $.ui.log(debugLine.budget(Math.round((now - q.since) / 60_000), Math.round(leftMs)), { to: 'debug' })
932}
933
934// ---- The question (4.3, 4.5, 4.6, 5.6) ----
935
936function ensureQuestion($: EngineInterface, opener: 'loop' | 'prompt', s: Sensed, a: Acted): string {
937 const g = namedKinds(s, a)
938 if (openKey !== undefined) {
939 const key = openKey
940 const open = questions.get(key)
941 if (open !== undefined && !outcomes.has(key) && supersedes(open, g)) {
942 // A kind is at the quota limit now: the question at the reserve gives way. Its waiters decide again
943 // and join the limit question that opens below (synchronous: no race).
944 settleAgain($, key, 'limit', g, s)
945 } else if (open !== undefined && joinableAt(outcomes.get(key), answeredOf(open), g.map(viewedOf))) {
946 if (opener === 'loop') open.loops += 1
947 else {
948 // A person prompt after Continue at the reset joins with no dialog: say that it waits.
949 const line = promptWaitsLine(open, s.now)
950 if (line !== undefined) $.ui.log(line)
951 }
952 return key // join (synchronous check: no race)
953 }
954 // A settled again, or a settled Resume that does not answer a kind that gates now (B50): a new question.
955 }
956 seq += 1
957 const key = `${ENV}:${seq}`
958 openKey = key
959 const q: Question = { ...questionOf(opener, s, a, s.now), budgetLogAt: s.now, checking: false, waiting: 0 }
960 questions.set(key, q)
961 if (!q.silent) needsRaise.add(key) // the first waiter to loop raises it
962 for (const t of questionEdges(q)) addEdge(t)
963 redraw($)
964 return key
965}
966
967function raise($: EngineInterface, key: string, r: Raiser): void {
968 const q = questions.get(key)
969 if (q === undefined || outcomes.has(key) || q.chosen === true) return // settled, or chosen at the limit, before this waiter got to it
970 if (q.limit === true) return raiseLimit($, key, q, r)
971 const text = questionText(q.facts, q.opener, q.mode, q.auto)
972 // The entry lives until this ask ends, never less: a question settled before its dialog reaches hook 5
973 // must still find it there, so that hook 5 withdraws the dialog (4.3).
974 const entry = { text, key }
975 raising.push(entry)
976 const ended = (): void => {
977 const i = raising.indexOf(entry)
978 if (i >= 0) raising.splice(i, 1)
979 }
980 try {
981 void $.ui.ask(text, { options: [...QUESTION_OPTIONS], header: HEADER }).then(
982 (answer) => {
983 ended()
984 void settle($, key, askVerdict(answer, RESUME_LABEL), 'dialog')
985 },
986 () => {
987 ended()
988 lost($, key, r)
989 },
990 )
991 } catch {
992 ended()
993 void settle($, key, 'stop', 'could not ask')
994 }
995}
996
997/**
998 * The limit question: Continue at the reset first, with the focus. Only the exact Stop here label stops.
999 * Every other answer, and a dialog that cannot show, continues at the reset (`chooseContinue`). A dialog
1000 * that ends with no answer goes to `lost`: Esc and "Chat about this" continue there too.
1001 */
1002function raiseLimit($: EngineInterface, key: string, q: Question, r: Raiser): void {
1003 const text = limitQuestionText(q.facts, q.opener, q.auto)
1004 const entry = { text, key } // as in raise: the entry lives until this ask ends, so hook 5 can withdraw it
1005 raising.push(entry)
1006 const ended = (): void => {
1007 const i = raising.indexOf(entry)
1008 if (i >= 0) raising.splice(i, 1)
1009 }
1010 try {
1011 void $.ui.ask(text, { options: [...LIMIT_OPTIONS], header: HEADER }).then(
1012 (answer) => {
1013 ended()
1014 if (limitVerdict(answer, LIMIT_OPTIONS[1]) === 'stop') void settle($, key, 'stop', 'dialog')
1015 else chooseContinue($, key, 'dialog')
1016 },
1017 () => {
1018 ended()
1019 lost($, key, r)
1020 },
1021 )
1022 } catch {
1023 ended()
1024 chooseContinue($, key, 'could not ask')
1025 }
1026}
1027
1028/**
1029 * Continue at the reset on an open limit question. It writes no outcome, so the held loops keep waiting in
1030 * place, and new loops join the question with no dialog. The question becomes quiet (`quietOf`): no raise
1031 * and no hand-off again, a dialog still up is withdrawn, and its check runs at once. Its due time releases
1032 * the work after the reset in both autoResume modes.
1033 */
1034function chooseContinue($: EngineInterface, key: string, via: Via): void {
1035 const q = questions.get(key)
1036 if (q === undefined || outcomes.has(key) || q.limit !== true || q.chosen === true) return
1037 q.chosen = true
1038 q.nextCheck = 0
1039 needsRaise.delete(key)
1040 for (const resolve of outcomeWaits.get(key) ?? []) resolve('continue') // withdraws this copy's dialog
1041 outcomeWaits.delete(key)
1042 if (via !== 'command') void noteContinues($, q)
1043 wakeAll()
1044 redraw($)
1045}
1046
1047/** The line of Continue at the reset, only while the reset is ahead (flow.ts continuesLine). A failed clock read logs it. */
1048async function noteContinues($: EngineInterface, q: Question): Promise<void> {
1049 const now = await $.clock.now().catch(() => undefined)
1050 const line = now === undefined ? notice.limitContinues(q.facts) : continuesLine(q, now)
1051 if (line !== undefined) $.ui.log(line)
1052}
1053
1054function lost($: EngineInterface, key: string, r: Raiser): void {
1055 const q = questions.get(key)
1056 if (q === undefined || outcomes.has(key) || q.chosen === true) return // withdrawn by spare10 itself, or chosen at the limit
1057 if (r.signal.aborted) {
1058 // The raiser's dispatch went away and nobody answered.
1059 if (q.waiting === 0) return forget($, key) // nothing is held any more
1060 if (q.handoffs < HANDOFF_LIMIT) {
1061 q.handoffs += 1
1062 needsRaise.add(key)
1063 $.ui.log(debugLine.handedOn(q.handoffs), { to: 'debug' })
1064 wakeAll() // a live waiter picks it up
1065 return
1066 }
1067 }
1068 // Esc, dismissed, time limit, no tool: Stop here, and at the limit Continue at the reset.
1069 if (q.limit === true) chooseContinue($, key, 'dialog ended without an answer')
1070 else void settle($, key, 'stop', 'dialog ended without an answer')
1071}
1072
1073function forget($: EngineInterface, key: string): void {
1074 questions.delete(key)
1075 needsRaise.delete(key)
1076 outcomeWaits.delete(key)
1077 if (openKey === key) openKey = undefined
1078 redraw($)
1079}
1080
1081function takeRaising(text: string): string | undefined {
1082 const i = raising.findIndex((r) => r.text === text)
1083 return i < 0 ? undefined : raising.splice(i, 1)[0]?.key
1084}
1085
1086const askedText = (e: unknown): string =>
1087 (e as { questions?: Array<{ question?: string }> }).questions?.[0]?.question ?? ''
1088
1089/**
1090 * Settles a question. For a Stop that this copy writes, it returns the record as written (merged, 3.2)
1091 * and its time for the reply. For a Stop after the skip start (B46), it also returns what opened.
1092 */
1093async function settle($: EngineInterface, key: string, outcome: Outcome, via: Via): Promise<Late> {
1094 if (outcomes.has(key)) return {}
1095 // Every synchronous cache first, so an event that arrives during the writes sees the decision.
1096 outcomes.set(key, outcome)
1097 needsRaise.delete(key)
1098 const q = questions.get(key)
1099 for (const resolve of outcomeWaits.get(key) ?? []) resolve(outcome) // withdraws this copy's dialog
1100 outcomeWaits.delete(key)
1101 if (outcome === 'resume' && q !== undefined) {
1102 for (const kind of q.kinds) {
1103 const end = q.ends[kind]
1104 if (end !== undefined) noteConsent(kind, consentOfEnd(end), end.test) // B49: each kind at its tier
1105 }
1106 }
1107 wakeAll()
1108 let late: Late = {}
1109 try {
1110 if (via === 'elsewhere' || q === undefined) return {}
1111 const now = await $.clock.now()
1112 if (outcome === 'resume') {
1113 for (const kind of q.kinds) {
1114 const end = q.ends[kind]
1115 if (end !== undefined) await writeConsent($, kind, consentOfEnd(end), now, end.test, true) // each kind's own test flag, noted above
1116 }
1117 await clearStopped($)
1118 if (via !== 'command') $.ui.log(resumeNotice(q, now))
1119 } else if (!q.silent && (q.mode === 'hold' || via === 'command')) {
1120 late = await settleStop($, q, via, now)
1121 }
1122 if (openKey === key) openKey = undefined // the decision is readable in env now
1123 await $.spare10.poke({ from: ENV }) // wake the newest copy's waiters (3.6)
1124 } catch (err) {
1125 // Best effort: this copy's caches and outcomes already hold the decision. The debug log says what failed.
1126 $.ui.log(debugLine.settleFailed(String(err)), { to: 'debug' })
1127 } finally {
1128 // Closed only now: a crossing during the writes joins the settled question and gets its outcome.
1129 if (openKey === key) openKey = undefined
1130 redraw($)
1131 }
1132 return late
1133}
1134
1135/**
1136 * A Stop here (skip 3.5, B46). One sense gives the kinds whose real reading gates now (TS1: the `real`
1137 * tag). When the question's time has passed (its hold end with autoResume on, its skip start with it
1138 * off), it also gives the kinds that gate now (`late`) and the question's kinds that are open now
1139 * (`opened`). The kinds that gate are stopped as usual. When nothing gates, a kind of the question is
1140 * open, and no work waits for a resume prompt, nothing is written: such a stop would never apply. A
1141 * failed sense gives empty lists: the D0.2 write, with the real kinds of the question when it opened.
1142 */
1143async function settleStop($: EngineInterface, q: Question, via: Via, now: number): Promise<Late> {
1144 let sNow: StopSense | undefined
1145 try {
1146 const sensed = await sense($)
1147 const split = await splitOf($, sensed)
1148 sNow = { s: sensed, split, holders: await holdersOf($, sensed, split.gating) }
1149 } catch {
1150 sNow = undefined // fail closed: the question's real kinds (flow.ts stopPlan)
1151 }
1152 // The setting in force. A Stop here at the limit never continues by itself, also on a question at the
1153 // reserve while a kind at the limit gates now, but the hold time limit does (flow.ts stopsAtLimit).
1154 const auto = stopAutoOf(q, via, await $.spare10.auto().catch(() => autoNow), sNow)
1155 const plan = stopPlan(q, now, auto, sNow, stopsAtLimit(q, via, sNow))
1156 if (plan.kind === 'open') {
1157 // B46 open: nothing is stopped. Held work is refused (the outcome is stop), new work passes.
1158 const text = stopOpenNotice(q, plan.ended, via)
1159 if (text !== undefined) $.ui.log(text)
1160 return { ended: plan.ended }
1161 }
1162 const written = await writeStopped($, plan.record, now)
1163 const n = stopNotice(q, plan, written, via, now, auto)
1164 if (n.text !== undefined) $.ui.log(n.text)
1165 return n.late
1166}
1167
1168/** This copy's open question, if it is not settled yet. */
1169const openQuestion = (): string | undefined => (openKey !== undefined && !outcomes.has(openKey) ? openKey : undefined)
1170
1171// ---- Tell mode (5) ----
1172
1173function noteTold($: EngineInterface, s: Sensed, a: Acted, key: string): void {
1174 $.ui.log(debugLine.told(key), { to: 'debug' })
1175 const text = toldNotice(s, a, toldNoticeFor)
1176 if (text === undefined) return
1177 $.ui.log(text)
1178 redraw($)
1179}
1180
1181function noteUnattended($: EngineInterface, s: Sensed): void {
1182 for (const line of unattendedLines(s, { reserve: unattendedNoteFor, open: openNoteFor })) $.ui.log(line, { to: 'debug' })
1183}
1184
1185// ---- Turn end and draft (4.7) ----
1186
1187function endTurn($: EngineInterface, turnId: string, attendedNow: boolean): void {
1188 const before = refusedTurns.includes(turnId)
1189 if (!before) {
1190 refusedTurns.push(turnId)
1191 if (refusedTurns.length > 32) refusedTurns.shift()
1192 }
1193 if (!shouldAbortTurn(attendedNow, before)) return
1194 void $.turn.abort({ turnId }).catch((err: unknown) => {
1195 $.ui.log(debugLine.abortFailed(String(err)), { to: 'debug' })
1196 })
1197}
1198
1199function restoreDraft($: EngineInterface, text: string): void {
1200 lastRestored = texthooks/core/config.ts 369 lines1import type { PluginOptions, Settings as HostSettings } from 'claude-code'
2import { KINDS, parseSimulate, simulateWords } from './reading.ts'
3import type { Kind } from './reading.ts'
4import { badWarning, floorWarning, fmtPct, simulateWarning } from './text.ts'
5
6// Options, per-run env overrides, scope and the start-up checks (design section 8). No $ here.
7// Precedence, highest first: SPARE10_* in the process env, pluginConfigs (managed, then --settings,
8// then user), the manifest default. One normaliser per field, for the option and the env alike.
9
10export type Headless = 'off' | 'prompt' | 'stop' | 'wait'
11export type Scope = 'all' | 'opt-in'
12export type Settings = {
13 reserve: number
14 weeklyReserve: number // 0: the weekly window is not watched
15 lastMinutes: number // the 5-hour span: the reserve opens this many minutes before the reset. 0 is off
16 weeklyLastHours: number // the weekly span, in hours. 0 is off
17 resumeFloor: number // B48: the 5-hour floor in % left. A Resume at the reserve lasts until 100 - this. 0 is off
18 weeklyResumeFloor: number // B48: the weekly floor. 0 is off
19 pausePrompt: string | null
20 autoResume: boolean
21 limitPause: boolean // at 100% used, spare10 holds all work until the reset (the quota limit). Off: work runs into the limit
22 headless: Headless
23 scope: Scope
24 badge: boolean
25}
26export type Source = 'option' | 'env'
27/** Where a span comes from. 'unread': the env read failed, so the span is 0 (B47). */
28export type SpanSource = Source | 'unread'
29/** The spans in force (B47): the newest copy answers them through $.spare10.spans(). */
30export type Spans = { lastMinutes: number; weeklyLastHours: number }
31/** Both spans off: the guard holds until the reset. Every unknown gives this. */
32export const NO_SPANS: Spans = Object.freeze({ lastMinutes: 0, weeklyLastHours: 0 })
33/**
34 * The pause at the limit that a copy answers through $.spare10.limit() until its first successful settings
35 * read, as NO_SPANS (B47): on, so an older copy's held loop never lets work past 100% on a guess.
36 */
37export const LIMIT_UNREAD: boolean = true
38export type Effective = Settings & {
39 enabled: boolean
40 from: {
41 reserve: Source
42 weeklyReserve: Source
43 lastMinutes: SpanSource
44 weeklyLastHours: SpanSource
45 resumeFloor: Source
46 weeklyResumeFloor: Source
47 pausePrompt: Source
48 autoResume: Source
49 limitPause: Source
50 headless: Source
51 enabled: 'scope' | 'SPARE10'
52 }
53 testPct?: number // from SPARE10_SIMULATE
54 testKind?: Kind // only when SPARE10_SIMULATE names the weekly window
55 testInMs?: number // only when SPARE10_SIMULATE has `in`
56 warnings: string[] // B27 wording
57}
58export type EnvReads = {
59 reserve?: string
60 weeklyReserve?: string
61 lastMinutes?: string
62 weeklyLastHours?: string
63 resumeFloor?: string
64 weeklyResumeFloor?: string
65 pausePrompt?: string
66 autoResume?: string
67 limitPause?: string
68 headless?: string
69 onOff?: string
70 simulate?: string
71}
72
73export const DEFAULTS: Settings = {
74 reserve: 10,
75 weeklyReserve: 10,
76 lastMinutes: 20,
77 weeklyLastHours: 8,
78 resumeFloor: 5,
79 weeklyResumeFloor: 5,
80 pausePrompt: null,
81 autoResume: true,
82 limitPause: true,
83 headless: 'off',
84 scope: 'all',
85 badge: true,
86}
87
88const HEADLESS: readonly Headless[] = ['off', 'prompt', 'stop', 'wait']
89const SCOPES: readonly Scope[] = ['all', 'opt-in']
90
91const word = (raw: unknown): string | undefined => (typeof raw === 'string' ? raw.trim().toLowerCase() : undefined)
92
93const numberOf = (raw: unknown): number =>
94 typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw.trim()) : Number.NaN
95
96/** 1 to 99, rounded to one decimal. A number or a numeric string. */
97export function parseReserve(raw: unknown): number | undefined {
98 const n = numberOf(raw)
99 if (!Number.isFinite(n)) return undefined
100 const r = Math.round(n * 10) / 10
101 return r >= 1 && r <= 99 ? r : undefined
102}
103
104/** 0 (the weekly guard is off), or 1 to 99 rounded to one decimal. A number or a numeric string. */
105export function parseWeeklyReserve(raw: unknown): number | undefined {
106 const n = numberOf(raw)
107 if (!Number.isFinite(n)) return undefined
108 const r = Math.round(n * 10) / 10
109 if (r === 0) return 0
110 return r >= 1 && r <= 99 ? r : undefined
111}
112
113// One rule for both spans: 0 to max, rounded to one decimal. A number or a numeric string.
114function parseSpan(raw: unknown, max: number): number | undefined {
115 const n = numberOf(raw)
116 if (!Number.isFinite(n)) return undefined
117 const r = Math.round(n * 10) / 10 + 0 // + 0: never -0
118 return r >= 0 && r <= max ? r : undefined
119}
120
121/** The 5-hour span in minutes: 0 (off) to 299, rounded to one decimal. */
122export const parseLastMinutes = (raw: unknown): number | undefined => parseSpan(raw, 299)
123
124/** The weekly span in hours: 0 (off) to 167, rounded to one decimal. */
125export const parseWeeklyLastHours = (raw: unknown): number | undefined => parseSpan(raw, 167)
126
127/** B48: a resume floor in % left: 0 (off) to 99, rounded to one decimal. A number or a numeric string. */
128export const parseResumeFloor = (raw: unknown): number | undefined => parseSpan(raw, 99)
129
130/**
131 * B48, B54: the floor of a kind when it is above 0 and below that kind's reserve, else 0. No attendance
132 * here: an unattended run has no floor in force either (B55), and the module applies that.
133 */
134export const floorOf = (s: Pick<Settings, 'reserve' | 'weeklyReserve' | 'resumeFloor' | 'weeklyResumeFloor'>, kind: Kind): number => {
135 const floor = kind === 'seven_day' ? s.weeklyResumeFloor : s.resumeFloor
136 const reserve = kind === 'seven_day' ? s.weeklyReserve : s.reserve
137 return floor > 0 && floor < reserve ? floor : 0
138}
139
140/** A kind's span in ms (B41). 0 is off. */
141export const spanOf = (s: Spans, kind: Kind): number =>
142 Math.round(kind === 'seven_day' ? s.weeklyLastHours * 3_600_000 : s.lastMinutes * 60_000)
143
144/** Blank or white space means stop and ask. Anything else is the instruction, verbatim. */
145export const parsePausePrompt = (raw: unknown): string | null => (typeof raw === 'string' && raw.trim() !== '' ? raw : null)
146
147export function parseHeadless(raw: unknown): Headless | undefined {
148 const w = word(raw)
149 return HEADLESS.find((h) => h === w)
150}
151
152export function parseScope(raw: unknown): Scope | undefined {
153 const w = word(raw)
154 return SCOPES.find((s) => s === w)
155}
156
157export function parseSwitch(raw: unknown): 'on' | 'off' | undefined {
158 const w = word(raw)
159 return w === 'on' || w === 'off' ? w : undefined
160}
161
162/** A boolean with no default arrives as "", so only an explicit false turns the badge off. */
163export const parseBadge = (raw: unknown): boolean => raw !== false
164
165/** As parseBadge: only an explicit false turns autoResume off. */
166export const parseAutoResume = (raw: unknown): boolean => raw !== false
167
168/** As parseBadge: only an explicit false turns the pause at the limit off. */
169export const parseLimitPause = (raw: unknown): boolean => raw !== false
170
171/** The twelve declared fields, defaults filled. Extra stored keys are ignored. */
172export function fromOptions(options: PluginOptions): Settings {
173 return {
174 reserve: parseReserve(options['reserve']) ?? DEFAULTS.reserve,
175 weeklyReserve: parseWeeklyReserve(options['weeklyReserve']) ?? DEFAULTS.weeklyReserve,
176 lastMinutes: parseLastMinutes(options['lastMinutes']) ?? DEFAULTS.lastMinutes,
177 weeklyLastHours: parseWeeklyLastHours(options['weeklyLastHours']) ?? DEFAULTS.weeklyLastHours,
178 resumeFloor: parseResumeFloor(options['resumeFloor']) ?? DEFAULTS.resumeFloor,
179 weeklyResumeFloor: parseResumeFloor(options['weeklyResumeFloor']) ?? DEFAULTS.weeklyResumeFloor,
180 pausePrompt: parsePausePrompt(options['pausePrompt']),
181 autoResume: parseAutoResume(options['autoResume']),
182 limitPause: parseLimitPause(options['limitPause']),
183 headless: parseHeadless(options['headless']) ?? DEFAULTS.headless,
184 scope: parseScope(options['scope']) ?? DEFAULTS.scope,
185 badge: parseBadge(options['badge']),
186 }
187}
188
189/**
190 * The kinds spare10 acts on, five_hour first: the weekly window only while its reserve is above 0.
191 * `present` (Codex design 4.15): the kinds the host reports a window for. Claude passes none: both.
192 */
193export const watchedKinds = (s: Pick<Settings, 'weeklyReserve'>, present: readonly Kind[] = KINDS): Kind[] =>
194 KINDS.filter((k) => present.includes(k) && (k === 'five_hour' || s.weeklyReserve > 0))
195
196export const reserveOf = (s: Pick<Settings, 'reserve' | 'weeklyReserve'>, kind: Kind): number =>
197 kind === 'seven_day' ? s.weeklyReserve : s.reserve
198
199/** B37: the SPARE10_HEADLESS a guarded session sets for its children, when the variable is not set. */
200export const childHeadless = (headless: Headless, envSet: boolean): 'stop' | undefined =>
201 !envSet && (headless === 'off' || headless === 'wait') ? 'stop' : undefined
202
203/**
204 * The per-run env over the options. A bad value is ignored with a B27 warning, never fatal.
205 * `o.simulateKind` (Codex design 4.15): the kind of a SPARE10_SIMULATE with no kind word, the weekly
206 * window on a host that reports no 5-hour window. Claude passes none: the 5-hour window.
207 */
208export function withEnv(base: Settings, env: EnvReads, o: { simulateKind?: Kind } = {}): Effective {
209 const warnings: string[] = []
210 const out: Effective = {
211 ...base,
212 enabled: base.scope === 'all',
213 from: {
214 reserve: 'option',
215 weeklyReserve: 'option',
216 lastMinutes: 'option',
217 weeklyLastHours: 'option',
218 resumeFloor: 'option',
219 weeklyResumeFloor: 'option',
220 pausePrompt: 'option',
221 autoResume: 'option',
222 limitPause: 'option',
223 headless: 'option',
224 enabled: 'scope',
225 },
226 warnings,
227 }
228 if (env.reserve !== undefined) {
229 const r = parseReserve(env.reserve)
230 if (r === undefined) warnings.push(badWarning('SPARE10_RESERVE', env.reserve, fmtPct(base.reserve)))
231 else {
232 out.reserve = r
233 out.from.reserve = 'env'
234 }
235 }
236 if (env.weeklyReserve !== undefined) {
237 const r = parseWeeklyReserve(env.weeklyReserve)
238 if (r === undefined) warnings.push(badWarning('SPARE10_WEEKLY_RESERVE', env.weeklyReserve, fmtPct(base.weeklyReserve)))
239 else {
240 out.weeklyReserve = r
241 out.from.weeklyReserve = 'env'
242 }
243 }
244 if (env.lastMinutes !== undefined) {
245 const m = parseLastMinutes(env.lastMinutes)
246 if (m === undefined) warnings.push(badWarning('SPARE10_LAST_MINUTES', env.lastMinutes, fmtPct(base.lastMinutes)))
247 else {
248 out.lastMinutes = m
249 out.from.lastMinutes = 'env'
250 }
251 }
252 if (env.weeklyLastHours !== undefined) {
253 const h = parseWeeklyLastHours(env.weeklyLastHours)
254 if (h === undefined) warnings.push(badWarning('SPARE10_WEEKLY_LAST_HOURS', env.weeklyLastHours, fmtPct(base.weeklyLastHours)))
255 else {
256 out.weeklyLastHours = h
257 out.from.weeklyLastHours = 'env'
258 }
259 }
260 if (env.resumeFloor !== undefined) {
261 const f = parseResumeFloor(env.resumeFloor)
262 if (f === undefined) warnings.push(badWarning('SPARE10_RESUME_FLOOR', env.resumeFloor, fmtPct(base.resumeFloor)))
263 else {
264 out.resumeFloor = f
265 out.from.resumeFloor = 'env'
266 }
267 }
268 if (env.weeklyResumeFloor !== undefined) {
269 const f = parseResumeFloor(env.weeklyResumeFloor)
270 if (f === undefined) warnings.push(badWarning('SPARE10_WEEKLY_RESUME_FLOOR', env.weeklyResumeFloor, fmtPct(base.weeklyResumeFloor)))
271 else {
272 out.weeklyResumeFloor = f
273 out.from.weeklyResumeFloor = 'env'
274 }
275 }
276 if (env.pausePrompt !== undefined) {
277 // Set but empty is meaningful: `SPARE10_PAUSE_PROMPT= claude` forces stop-and-ask for this run.
278 out.pausePrompt = parsePausePrompt(env.pausePrompt)
279 out.from.pausePrompt = 'env'
280 }
281 if (env.autoResume !== undefined) {
282 const a = parseSwitch(env.autoResume)
283 if (a === undefined) warnings.push(badWarning('SPARE10_AUTO_RESUME', env.autoResume, base.autoResume ? 'on' : 'off'))
284 else {
285 out.autoResume = a === 'on'
286 out.from.autoResume = 'env'
287 }
288 }
289 if (env.limitPause !== undefined) {
290 const l = parseSwitch(env.limitPause)
291 if (l === undefined) warnings.push(badWarning('SPARE10_LIMIT_PAUSE', env.limitPause, base.limitPause ? 'on' : 'off'))
292 else {
293 out.limitPause = l === 'on'
294 out.from.limitPause = 'env'
295 }
296 }
297 if (env.headless !== undefined) {
298 const h = parseHeadless(env.headless)
299 if (h === undefined) warnings.push(badWarning('SPARE10_HEADLESS', env.headless, base.headless))
300 else {
301 out.headless = h
302 out.from.headless = 'env'
303 }
304 }
305 if (env.onOff !== undefined) {
306 const s = parseSwitch(env.onOff)
307 if (s === undefined) warnings.push(badWarning('SPARE10', env.onOff, base.scope))
308 else {
309 out.enabled = s === 'on'
310 out.from.enabled = 'SPARE10'
311 }
312 }
313 // SPARE10_SIMULATE: a blank value or `off` is no test reading and no warning. Junk is B27. A weekly test
314 // reading while the weekly reserve is 0 changes nothing yet, so it is B27 too. It stays set, as before: a
315 // weekly reserve that the person sets later puts it in force, and the warning says so.
316 const words = simulateWords(env.simulate)
317 const parsed = parseSimulate(words, o.simulateKind)
318 if (parsed === undefined && words.length > 0) warnings.push(simulateWarning(env.simulate ?? ''))
319 const spec = parsed === 'off' ? undefined : parsed
320 if (spec !== undefined) {
321 if (spec.kind === 'seven_day' && out.weeklyReserve <= 0) warnings.push(simulateWarning(env.simulate ?? '', true))
322 out.testPct = spec.pct
323 if (spec.kind !== 'five_hour') out.testKind = spec.kind
324 if (spec.inMs !== undefined) out.testInMs = spec.inMs
325 }
326 // B54: a floor above 0 at or above its reserve does nothing. The weekly one only while it is watched.
327 if (out.resumeFloor > 0 && out.resumeFloor >= out.reserve) warnings.push(floorWarning('five_hour', out.resumeFloor, out.reserve))
328 if (out.weeklyReserve > 0 && out.weeklyResumeFloor > 0 && out.weeklyResumeFloor >= out.weeklyReserve) {
329 warnings.push(floorWarning('seven_day', out.weeklyResumeFloor, out.weeklyReserve))
330 }
331 return out
332}
333
334/**
335 * B47: the settings after a failed env read. The D0.2 fallback (the options only), with both spans 0
336 * from 'unread': an unknown span keeps the guard on until the reset. The floors keep their options: for a
337 * floor, the guarded side is a floor in force (floor 1.3 item 9).
338 */
339export function unreadEnv(base: Settings): Effective {
340 const out = withEnv(base, {})
341 out.lastMinutes = 0
342 out.weeklyLastHours = 0
343 out.from.lastMinutes = 'unread'
344 out.from.weeklyLastHours = 'unread'
345 return out
346}
347
348function hasFlag(s: HostSettings | null | undefined): boolean {
349 if (typeof s !== 'object' || s === null) return false
350 const env = s['env']
351 if (typeof env !== 'object' || env === null) return false
352 return (env as Record<string, unknown>)['CLAUDE_CODE_ENABLE_FUNCTION_HOOKS'] !== undefined
353}
354
355/** B28: the process has the flag and no settings source (user, project, local, flag, policy) does. `flag` is `--settings`. */
356export const flagOnlyInShell = (inProcess: boolean, sources: readonly HostSettings[]): boolean =>
357 inProcess && !sources.some((s) => hasFlag(s))
358
359/** B29: the first of a positive askUserQuestionTimeout in the merged settings, a set CLAUDE_AFK_TIMEOUT_MS. */
360export function questionTimeout(
361 merged: HostSettings,
362 afk: string | undefined,
363): 'askUserQuestionTimeout' | 'CLAUDE_AFK_TIMEOUT_MS' | undefined {
364 const t = typeof merged === 'object' && merged !== null ? merged['askUserQuestionTimeout'] : undefined
365 if (typeof t === 'number' && Number.isFinite(t) && t > 0) return 'askUserQuestionTimeout'
366 if (afk !== undefined) return 'CLAUDE_AFK_TIMEOUT_MS'
367 return undefined
368}
369hooks/core/decide.ts 601 lines1import type { Headless } from './config.ts'
2import { KINDS, marginOf, windowMs } from './reading.ts'
3import type { Basis, Kind } from './reading.ts'
4
5// The gate's decision table (design 4.2) and the phase precedence (3.1), written once. No $ here.
6
7/** One step of the reset clock (4.2). */
8export const TICK_MS = 30_000
9
10/** A waiter checks the quota at most this often before the due time (4.5). */
11export const CHECK_MS = 60_000
12
13/** Below this much hook budget a hold ends as Stop here (B40). */
14export const BUDGET_FLOOR_MS = 2_000
15
16export type Site = 'tool' | 'step' | 'prompt'
17export type Mode = 'hold' | 'tell'
18export type Outcome = 'resume' | 'stop'
19
20export type Snapshot = {
21 site: Site
22 tripped: boolean
23 enabled: boolean
24 consented: boolean
25 attended: boolean
26 headless: Headless
27 mode: Mode
28 person: boolean
29 stopped: boolean
30 mainTold: boolean
31 seedOnly?: boolean // every gating kind rests on a seed (row 6a)
32 limit?: boolean // a gating kind is at the quota limit (row 7b)
33}
34// consented (row 3): tripped, and no kind gates. A kind gates when it is tripped, not consented and
35// not open (B41), so a kind in its skip window passes here too.
36
37export type Verdict =
38 | { kind: 'pass'; trip: boolean } // trip: inside the reserve, let through
39 | { kind: 'tell' }
40 | { kind: 'refuse'; text: 'stop' | 'paused' | 'headless' }
41 | { kind: 'hold' } // open or join the question, then wait
42
43const PASS: Verdict = { kind: 'pass', trip: false }
44const THROUGH: Verdict = { kind: 'pass', trip: true }
45
46/** The decision table of 4.2, top to bottom, first match wins. */
47export function decide(s: Snapshot): Verdict {
48 if (!s.tripped) return PASS // row 1
49 if (!s.enabled) return THROUGH // row 2
50 if (s.consented) return THROUGH // row 3
51 if (!s.attended) {
52 if (s.headless === 'stop') return s.site === 'prompt' ? THROUGH : { kind: 'refuse', text: 'headless' } // row 5
53 if (s.headless === 'prompt') return s.site === 'tool' ? { kind: 'tell' } : THROUGH // row 6
54 if (s.headless === 'wait') return s.site === 'prompt' || s.seedOnly === true ? THROUGH : { kind: 'hold' } // row 6a
55 return THROUGH // row 4
56 }
57 if (s.stopped) {
58 // row 7, in either mode
59 if (s.site === 'tool') return { kind: 'refuse', text: 'stop' }
60 if (s.site === 'step') return { kind: 'refuse', text: 'paused' }
61 return s.person ? { kind: 'hold' } : THROUGH
62 }
63 if (s.limit === true) {
64 // row 7b: at the quota limit every loop holds, in either mode. A stop still wins (row 7).
65 if (s.site === 'prompt') return s.person ? { kind: 'hold' } : THROUGH
66 return { kind: 'hold' }
67 }
68 if (s.mode === 'tell') {
69 // row 8
70 if (s.site === 'tool') return { kind: 'tell' }
71 if (s.site === 'step') return THROUGH
72 return s.person && !s.mainTold ? { kind: 'hold' } : THROUGH
73 }
74 if (s.site === 'prompt') return s.person ? { kind: 'hold' } : THROUGH // row 9
75 return { kind: 'hold' }
76}
77
78/** Only the exact yes label resumes. Everything else is Stop here. */
79export const askVerdict = (answer: string, yes: string): Outcome => (answer === yes ? 'resume' : 'stop')
80
81/** The limit question: only the exact Stop here label stops. Every other answer continues at the reset. */
82export const limitVerdict = (answer: string, stop: string): 'stop' | 'continue' => (answer === stop ? 'stop' : 'continue')
83
84/** What a .catch does: let a failure after next stand, refuse while holding, pass while deciding. */
85export function afterFailure(called: boolean, holding: boolean): 'replay' | 'refuse' | 'pass' {
86 if (called) return 'replay'
87 return holding ? 'refuse' : 'pass'
88}
89
90/** Consent counts only for the current window (3.5): a later time is ignored. */
91export const consentCovers = (until: number, now: number, windowEnd: number): boolean =>
92 now < until && until <= windowEnd + 60_000
93
94// ---- The resume floor: two tiers of consent (floor B48 to B52) ----
95
96/** B49: a consent of one kind. `to`: the % used where a consent to the floor ends. None: a full consent. */
97export type Consent = { until: number; to?: number }
98
99/** B49: the end point now: `to`, or the floor point in force when that is lower. */
100export const endOf = (to: number, point: number | null): number => (point === null ? to : Math.min(to, point))
101
102/** B49: consentCovers, and for a consent to the floor a reading below its end point. */
103export const consentApplies = (c: Consent, now: number, windowEnd: number, pct: number, point: number | null): boolean =>
104 consentCovers(c.until, now, windowEnd) && (c.to === undefined || pct < endOf(c.to, point))
105
106/** B52: a consent to the floor in its window whose end point `pct` has reached. Never a full consent. */
107export const floorEnded = (c: Consent, now: number, windowEnd: number, pct: number, point: number | null): boolean =>
108 c.to !== undefined && consentCovers(c.until, now, windowEnd) && pct >= endOf(c.to, point)
109
110/** B49: the consent that applies: a full one first (the latest until), else the consent to the floor with the highest to. */
111export function coveringConsent(
112 list: readonly Consent[],
113 now: number,
114 windowEnd: number,
115 pct: number,
116 point: number | null,
117): Consent | undefined {
118 const applying = list.filter((c) => consentApplies(c, now, windowEnd, pct, point))
119 const full = applying.filter((c) => c.to === undefined).sort((a, b) => b.until - a.until)[0]
120 if (full !== undefined) return full
121 return highestTo(applying)
122}
123
124/** The consent to the floor with the highest end point, then the latest until. */
125const highestTo = (list: readonly Consent[]): Consent | undefined =>
126 list
127 .filter((c) => c.to !== undefined)
128 .sort((a, b) => (b.to ?? 0) - (a.to ?? 0) || b.until - a.until)[0]
129
130/** The report: a consent to the floor that covers the window and has reached its end point on `pct`. The highest to. */
131export function endedFloor(
132 list: readonly Consent[],
133 now: number,
134 windowEnd: number,
135 pct: number,
136 point: number | null,
137): Consent | undefined {
138 return highestTo(list.filter((c) => floorEnded(c, now, windowEnd, pct, point)))
139}
140
141/** B49: this copy's consents of one kind: the latest full until, and the latest consent to the floor. */
142export type ConsentSlots = { full?: number; floor?: { until: number; to: number } }
143
144/** full: the later until (0.2 max). floor: replaced by a later until, or the same until with a higher to. */
145export function noteSlot(s: ConsentSlots | undefined, c: Consent): ConsentSlots {
146 const out: ConsentSlots = { ...(s ?? {}) }
147 if (c.to === undefined) {
148 out.full = Math.max(out.full ?? 0, c.until)
149 return out
150 }
151 const f = out.floor
152 if (f === undefined || c.until > f.until || (c.until === f.until && c.to > f.to)) out.floor = { until: c.until, to: c.to }
153 return out
154}
155
156/** The slots as a list of consents: the full one first. */
157export function slotList(s: ConsentSlots | undefined): Consent[] {
158 if (s === undefined) return []
159 return [...(s.full === undefined ? [] : [{ until: s.full }]), ...(s.floor === undefined ? [] : [{ until: s.floor.until, to: s.floor.to }])]
160}
161
162/** B52: the slots without the consent to the floor (undefined when nothing is left). */
163export function withoutFloor(s: ConsentSlots | undefined): ConsentSlots | undefined {
164 if (s?.full === undefined) return undefined
165 return { full: s.full }
166}
167
168/**
169 * B52: a tomb is a real consent to the floor that a gate of this copy ended: the time in its value and
170 * its end point. A tomb buries each consent to the floor with that time and an end point at or below
171 * its own, in any stamp. So a late write, a restamp or a value that a failed read missed never applies
172 * again. A tomb never buries a full consent, and it ends when its window resets.
173 */
174export type Tomb = { until: number; to: number }
175
176/** B52: a tomb buries `c`: a consent to the floor with the tomb's until and an end point at or below the tomb's. */
177export function buried(tombs: readonly Tomb[] | undefined, c: Consent): boolean {
178 const to = c.to
179 return to !== undefined && (tombs ?? []).some((t) => t.until === c.until && to <= t.to)
180}
181
182/** B52: the tombs with `t` added. A tomb whose window has reset goes, and so does a tomb that `t` covers. */
183export function bury(tombs: readonly Tomb[] | undefined, t: Tomb, now: number): Tomb[] {
184 const live = (tombs ?? []).filter((x) => now < x.until)
185 if (now >= t.until || buried(live, t)) return live
186 return [...live.filter((x) => !(x.until === t.until && x.to <= t.to)), t]
187}
188
189/**
190 * B52: a new Resume of this copy lifts each tomb that buries its consent. After a fall in the window,
191 * the first question consents to the floor again (floor 1.4 item 7).
192 */
193export const unbury = (tombs: readonly Tomb[] | undefined, c: Consent): Tomb[] => (tombs ?? []).filter((t) => !buried([t], c))
194
195/**
196 * B50: a kind as the gate sees it now. `end`: its consent bound, the identity of its window. None: no reset
197 * time is known. `limit`: the kind is at the quota limit, so no Resume answers it.
198 */
199export type Viewed = { kind: Kind; pct: number; test: boolean; end?: number; limit?: boolean }
200
201/** B50: what a settled Resume answered for one kind: its basis, its end point, and the consent bound of the question. */
202export type Answered = { kind: Kind; test: boolean; to?: number; end?: number }
203
204/**
205 * B50: the window of a Resume's answer is the window of the view: `sameWindow` of the two ends. `jitter`
206 * (Codex A22): a real window that ends more than the jitter after the answer's end is a new window, as
207 * `voidedByReset` voids a consent after a reset credit. Claude passes none.
208 */
209const answerWindow = (a: Answered, k: Viewed, jitter: number): boolean =>
210 sameWindow(k.kind, a.end ?? null, k.end ?? null) && (jitter <= 0 || k.test || a.end === undefined || k.end === undefined || k.end - a.end <= jitter)
211
212/**
213 * B50: a Resume answers a kind on the same basis and in the same window (`answerWindow`) while its reading
214 * is below the Resume's end point for it. A window that reset after the question asks again, also past
215 * its floor. An unknown end (no reset time, or an older answer) matches, as in TS1. A Resume never answers
216 * a kind at the quota limit: the limit question asks it.
217 */
218export const answers = (named: readonly Answered[], k: Viewed, jitter = 0): boolean =>
219 k.limit !== true && named.some((a) => a.kind === k.kind && a.test === k.test && answerWindow(a, k, jitter) && (a.to === undefined || k.pct < a.to))
220
221/**
222 * B50: joinable with the floor and the basis. A loop joins an open question, or one settled as Stop
223 * here, or one settled as Resume when that Resume answers every kind that gates now. Never after again.
224 * `jitter`: as in `answers`.
225 */
226export function joinableAt(
227 outcome: 'resume' | 'stop' | 'again' | undefined,
228 named: readonly Answered[],
229 gating: readonly Viewed[],
230 jitter = 0,
231): boolean {
232 if (outcome === undefined || outcome === 'stop') return true
233 return outcome === 'resume' && gating.every((k) => answers(named, k, jitter))
234}
235
236/**
237 * B50: another copy's consent answers a question's kind: it covers the kind's consent bound, and a
238 * consent to the floor answers only a kind asked at the reserve, with a point at least as high.
239 */
240export const answersQuestion = (c: Consent, end: { end: number; to?: number }, now: number): boolean =>
241 consentCovers(c.until, now, end.end) && (c.to === undefined || (end.to !== undefined && c.to >= end.to))
242
243/**
244 * SPARE10_CONSENT (3.3 of the floor design): `${sessionId} ${iso}` (full), `${sessionId} ${iso} to:${pct}`
245 * (a consent to the floor), or a bare time that a person set before launch (full). A 0.2 value has no
246 * `to`, so it is full.
247 */
248export type ConsentRecord = { until: number; sessionId?: string; to?: number }
249
250// Date.parse is lenient ('abc-123' is a number), so a time must also start like an ISO date.
251const isoMs = (text: string): number => (/^\d{4}-\d{2}-\d{2}T/.test(text) ? Date.parse(text) : Number.NaN)
252
253// An end point: 0.1 to 99.9, one decimal at most.
254const TO_TOKEN = /^to:(\d{1,2}(?:\.\d)?)$/
255
256export function parseConsent(raw: string | undefined): ConsentRecord | undefined {
257 const text = (raw ?? '').trim()
258 const m = /^(\S+) (\S+)(?: (\S+))?$/.exec(text)
259 if (m !== null && m[3] !== undefined) {
260 const until = isoMs(m[2] ?? '')
261 const t = TO_TOKEN.exec(m[3])
262 const to = t === null ? Number.NaN : Number(t[1])
263 if (!Number.isFinite(until) || !(to > 0)) return undefined // fail closed: a junk end point is no consent
264 return { until, sessionId: m[1] ?? '', to }
265 }
266 const stamped = m === null ? Number.NaN : isoMs(m[2] ?? '')
267 if (m !== null && Number.isFinite(stamped)) return { until: stamped, sessionId: m[1] ?? '' }
268 const bare = /\s/.test(text) ? Number.NaN : isoMs(text) // a bare time is one token: `<iso> to:95` is no consent
269 return Number.isFinite(bare) ? { until: bare } : undefined
270}
271
272export const formatConsent = (sessionId: string, until: number, to?: number): string =>
273 `${sessionId} ${new Date(until).toISOString()}${to === undefined ? '' : ` to:${String(Math.round(to * 10) / 10 + 0)}`}`
274
275/**
276 * 3.3: a full value stamped with one of these ids that covers the window of `until`. A write of a
277 * consent to the floor keeps it: the stronger tier stays.
278 */
279export const fullCovers = (prev: ConsentRecord | undefined, ids: readonly string[], until: number, now: number): boolean =>
280 prev !== undefined && prev.to === undefined && prev.sessionId !== undefined && ids.includes(prev.sessionId) && consentCovers(prev.until, now, until)
281
282/** B51: the told key of a stage. */
283export const stageKey = (key: string, atFloor: boolean): string => (atFloor ? `${key}:floor` : key)
284
285/** B51: a told key's loop part and stage. */
286export function keyStage(key: string): { base: string; atFloor: boolean } {
287 return key.endsWith(':floor') ? { base: key.slice(0, -':floor'.length), atFloor: true } : { base: key, atFloor: false }
288}
289
290/**
291 * Whose consent counts (3.5, 9.3). The process env reaches every descendant, also a long-lived one: the
292 * transient `claude daemon` and every --bg session it starts. So an attended session takes only a
293 * consent stamped with its own id (now or before a /clear or /resume in this process). An unattended
294 * run takes any consent: a nested `claude -p` follows the session that started it. A bare time is the
295 * person's own answer before launch, and counts outside a --bg session.
296 */
297export function consentCounts(
298 stamp: string | undefined,
299 who: { attended: boolean; bg: boolean; ids: readonly string[] },
300): boolean {
301 if (!who.attended) return true
302 if (stamp === undefined) return !who.bg
303 return who.ids.includes(stamp)
304}
305
306/**
307 * TS1: a kind whose real reading gates now: tripped, not open and not consented, read on the real basis
308 * beneath any test reading. `resetsAtMs`: the reset of that real reading, null when unknown. An entry of
309 * a stop's `real` tag has the same form: the kind, and the reset of its real window when it was written.
310 */
311export type Holder = { kind: Kind; resetsAtMs: number | null }
312
313/**
314 * A stop. No `kinds`: a 0.1 value, which stops the 5-hour window until `windowEnd` and is never
315 * continued. `windowEnd` is the `until` of 3.2. `work`: the stop held or refused a loop. `auto`:
316 * autoResume was on when the stop was made. `test`: every kind of the stop was a test reading.
317 * `skip`: the until is a skip start and the stop is a skip owner, so it has no margin (skip 3.5).
318 * `real` (TS1): the kinds of the stop whose real reading, beneath any test reading, was in the reserve
319 * when the entry was written: tripped, not open and not consented. Each entry keeps the reset of that
320 * real reading, the identity of its window (null: unknown). The value carries it only with `skip` or
321 * `test`, the stops that can hold past their end (`holdsPast`). A value without it (0.1, 0.2 before
322 * TS1, or a stop that ends at a reset) names none. One entry per kind, in KINDS order.
323 */
324export type StoppedRecord = {
325 sessionId: string
326 windowEnd: number
327 at: number
328 kinds?: Kind[]
329 work?: boolean
330 auto?: boolean
331 test?: boolean
332 skip?: boolean
333 real?: Holder[]
334}
335
336/**
337 * TS1: the tag of a real entry, `real_<kind>:<resetMs>`. A bare `real_<kind>` has an unknown reset: this
338 * build writes it for a real reading without a reset time, and a 0.2 value from before the reset in the
339 * tag reads the same way (fail closed). `:0` also reads as unknown.
340 */
341const realTag = (h: Holder): string => (h.resetsAtMs === null ? `real_${h.kind}` : `real_${h.kind}:${h.resetsAtMs}`)
342
343const REAL_TAG = /^real_(five_hour|seven_day)(?::(\d{1,16}))?$/
344
345const TAGS = new Set(['five_hour', 'seven_day', 'work', 'auto', 'test', 'skip'])
346
347/** TS1: a real tag as an entry, or undefined when the tag is not a real tag. */
348function realOf(tag: string): Holder | undefined {
349 const m = REAL_TAG.exec(tag)
350 if (m === null) return undefined
351 const kind: Kind = m[1] === 'seven_day' ? 'seven_day' : 'five_hour'
352 const ms = m[2] === undefined ? 0 : Number(m[2])
353 return { kind, resetsAtMs: ms > 0 ? ms : null }
354}
355
356/**
357 * TS1: of two entries of one kind, the one of the later window: a known reset over an unknown one, else
358 * the later reset (a tie: the first). The known reset is the more exact, and a stop of the current
359 * window still holds with it: the reading of that window has that reset.
360 */
361const laterEntry = (a: Holder, b: Holder): Holder => {
362 if (a.resetsAtMs === null) return b
363 if (b.resetsAtMs === null) return a
364 return b.resetsAtMs > a.resetsAtMs ? b : a
365}
366
367/** TS1: an entry whose recorded reset has passed: the window of that entry is over. */
368const windowOver = (h: Holder, now: number): boolean => h.resetsAtMs !== null && h.resetsAtMs <= now
369
370/** One entry per kind of `kinds`, in KINDS order: the later entry of each kind (`laterEntry`). */
371function perKind(entries: readonly Holder[], kinds: readonly Kind[] = KINDS): Holder[] {
372 const out: Holder[] = []
373 for (const k of KINDS) {
374 if (!kinds.includes(k)) continue
375 const mine = entries.filter((h) => h.kind === k)
376 const first = mine[0]
377 if (first !== undefined) out.push(mine.slice(1).reduce(laterEntry, first))
378 }
379 return out
380}
381
382/** The latest time that a Date can hold. A later until or at is junk: every clock text of it throws. */
383const DATE_MAX_MS = 8.64e15
384
385/**
386 * SPARE10_STOPPED is `${sessionId} ${untilMs} ${atMs} ${tags}`, or the 0.1 `${sessionId} ${windowEndMs} ${atMs}`.
387 * Anything else is not stopped, also a time that no Date can hold.
388 */
389export function parseStopped(raw: string | undefined): StoppedRecord | undefined {
390 const m = /^(\S+) (\d+) (\d+)(?: (\S+))?$/.exec(raw ?? '')
391 if (m === null) return undefined
392 const rec = { sessionId: m[1] ?? '', windowEnd: Number(m[2]), at: Number(m[3]) }
393 if (rec.windowEnd > DATE_MAX_MS || rec.at > DATE_MAX_MS) return undefined // also Infinity: a digit string is never NaN or below 0
394 if (m[4] === undefined) return rec
395 const tags = m[4].split(',')
396 if (tags.some((t) => !TAGS.has(t) && realOf(t) === undefined)) return undefined
397 const kinds = KINDS.filter((k) => tags.includes(k))
398 if (kinds.length === 0) return undefined
399 const out: StoppedRecord = { ...rec, kinds, work: tags.includes('work'), auto: tags.includes('auto'), test: tags.includes('test') }
400 if (tags.includes('skip')) out.skip = true
401 const real = perKind(tags.map(realOf).filter((h): h is Holder => h !== undefined), kinds)
402 if (real.length > 0) out.real = real
403 return out
404}
405
406export function formatStopped(s: StoppedRecord): string {
407 const head = `${s.sessionId} ${s.windowEnd} ${s.at}`
408 const kinds = KINDS.filter((k) => s.kinds?.includes(k) === true)
409 if (kinds.length === 0) return head
410 const tags = [
411 ...kinds,
412 ...(s.work === true ? ['work'] : []),
413 ...(s.auto === true ? ['auto'] : []),
414 ...(s.test === true ? ['test'] : []),
415 ...(s.skip === true ? ['skip'] : []),
416 ...(s.skip === true || s.test === true ? perKind(s.real ?? [], kinds).map(realTag) : []),
417 ]
418 return `${head} ${tags.join(',')}`
419}
420
421/**
422 * TS1: the real entries of two stops of one session, one per kind (`laterEntry`). An entry whose window
423 * is over is dropped: a trip in a later window asks again.
424 */
425export const joinReal = (a: readonly Holder[] | undefined, b: readonly Holder[] | undefined, now: number): Holder[] =>
426 perKind([...(a ?? []), ...(b ?? [])].filter((h) => !windowOver(h, now)))
427
428/**
429 * TS1 (B34): the real entries of an extension to `kinds`. A kind whose real reading gates now gets its
430 * current reset, which replaces its earlier entry. Any other kind keeps its earlier entry while that
431 * window lasts.
432 */
433export function extendedReal(prev: readonly Holder[] | undefined, holders: readonly Holder[], kinds: readonly Kind[], now: number): Holder[] {
434 const out: Holder[] = []
435 for (const k of KINDS) {
436 if (!kinds.includes(k)) continue
437 const h = holders.find((x) => x.kind === k)
438 const old = prev?.find((x) => x.kind === k)
439 if (h !== undefined) out.push({ kind: k, resetsAtMs: h.resetsAtMs })
440 else if (old !== undefined && !windowOver(old, now)) out.push(old)
441 }
442 return out
443}
444
445/**
446 * 3.2: a new stop keeps what an earlier 0.2 stop of the same session knew, while that stop still
447 * applies, or while it is an auto stop past its end that nobody released yet (it is still in the env,
448 * so its work still waits for the reset). Kinds join, the later end wins (a tie: the new record), work
449 * if either had it, test only if both had it, auto and at are new. `skip` is the later record's, and
450 * with `auto` only while the earlier record's due time is not after the later end (skip 3.5): a kind
451 * whose release rests on a reset never loses its margin to a skip start. `real` joins by kind (TS1,
452 * `joinReal`): the entry of the later window, and none whose window is over.
453 */
454export function mergeStopped(prev: StoppedRecord | undefined, next: StoppedRecord, now: number): StoppedRecord {
455 if (prev?.kinds === undefined || next.kinds === undefined) return next
456 if (prev.sessionId !== next.sessionId) return next
457 if (now >= prev.windowEnd && prev.auto !== true) return next // it ended by time, and nothing continues it
458 const joined = [...prev.kinds, ...next.kinds]
459 const later = prev.windowEnd > next.windowEnd ? prev : next
460 const earlier = later === prev ? next : prev
461 const { skip: _skip, real: _real, ...rest } = next
462 const out: StoppedRecord = {
463 ...rest,
464 kinds: KINDS.filter((k) => joined.includes(k)),
465 windowEnd: later.windowEnd,
466 work: prev.work === true || next.work === true,
467 test: prev.test === true && next.test === true,
468 }
469 if (later.skip === true && (out.auto !== true || stopDue(earlier) <= later.windowEnd)) out.skip = true
470 const real = joinReal(prev.real, next.real, now)
471 if (real.length > 0) out.real = real
472 return out
473}
474
475/** When spare10 may end a stop by itself: its end plus the margin (4.8). A skip stop has no margin. */
476export const stopDue = (r: StoppedRecord): number => r.windowEnd + marginOf(r.skip === true, r.test === true)
477
478/**
479 * The skip tag of a new stop (skip 3.5): its until is a skip start of its kinds, and with autoResume on
480 * no kind of it is due later (its hold end plus margin). With autoResume off spare10 never releases the
481 * stop, so only the first part counts.
482 */
483export const skipTag = (until: number, skipStarts: readonly number[], dues: readonly number[], auto: boolean): boolean =>
484 skipStarts.includes(until) && (!auto || dues.every((d) => d <= until))
485
486/**
487 * TS1: two resets of one kind name the same window: they lie less than half a window apart. An unknown
488 * reset matches any (fail closed). A reset that moves by a few seconds stays in its window.
489 */
490export const sameWindow = (kind: Kind, a: number | null, b: number | null): boolean =>
491 a === null || b === null || Math.abs(a - b) < windowMs(kind) / 2
492
493/**
494 * TS1: a kind whose real reading gates now keeps a 0.2 stop past its end, when that end was not a reset
495 * of the kind. A skip stop ends at a skip start: a kind that gates there has not opened (a test skip
496 * start over a real trip, B45, or a span lowered after the stop was written, B47). A test stop ends when
497 * its test window ends, and the real reading beneath can still gate. The stop must name the kind with
498 * its `real` tag: its real reading was in the reserve when the entry was written. The reading must be in
499 * the window of that entry (`sameWindow` of the two resets), or either reset is unknown (fail closed: it
500 * keeps the stop while it gates). The time of the stop plays no part: an extension adopts a kind whose
501 * window started after it. A later window or a later trip asks again, as in D0.2. Any other stop ended
502 * at a reset. A 0.1 value ends by time.
503 */
504export const holdsPast = (r: StoppedRecord, h: Holder): boolean =>
505 (r.skip === true || r.test === true) &&
506 r.kinds?.includes(h.kind) === true &&
507 r.real?.some((t) => t.kind === h.kind && sameWindow(h.kind, t.resetsAtMs, h.resetsAtMs)) === true
508
509/** TS1: a 0.2 stop past its end still holds while a kind that gates now keeps it (`holdsPast`). The ticker extends such an auto stop at its due time (B34). */
510export const heldPast = (r: StoppedRecord, holders: readonly Holder[]): boolean => holders.some((h) => holdsPast(r, h))
511
512/**
513 * B35: an auto 0.2 stop of this conversation whose end has passed, that nobody released yet, and that
514 * no kind of it still holds (TS1: `holders` are the kinds whose real reading gates now).
515 */
516export function isOverdue(
517 r: StoppedRecord | undefined,
518 sessionId: string,
519 endedSid: string | undefined,
520 now: number,
521 holders: readonly Holder[],
522): r is StoppedRecord {
523 return (
524 r?.kinds !== undefined &&
525 r.auto === true &&
526 r.sessionId === sessionId &&
527 r.sessionId !== endedSid &&
528 now >= r.windowEnd &&
529 !heldPast(r, holders)
530 )
531}
532
533export type StopAction = 'none' | 'drop' | 'check'
534
535/** What the ticker does with a stop (4.6): nothing, drop a stop of another conversation, or check the quota. */
536export function stopAction(i: {
537 record: StoppedRecord
538 now: number
539 sessionId: string
540 endedSid?: string
541 autoResume: boolean
542 enabled: boolean
543 attended: boolean
544}): StopAction {
545 const r = i.record
546 if (r.kinds === undefined || r.auto !== true || i.now < stopDue(r)) return 'none'
547 if (!(i.autoResume && i.enabled && i.attended)) return 'none'
548 if (r.sessionId !== i.sessionId || r.sessionId === i.endedSid) return 'drop'
549 return 'check'
550}
551
552/**
553 * 5.6: a loop joins the open question while it is open, or settled as Stop here, or settled as Resume
554 * for every kind that gates now. Never after again, and never a Resume for a kind the dialog did not name.
555 */
556export function joinable(outcome: 'resume' | 'stop' | 'again' | undefined, named: readonly Kind[], gating: readonly Kind[]): boolean {
557 if (outcome === undefined || outcome === 'stop') return true
558 return outcome === 'resume' && gating.every((k) => named.includes(k))
559}
560
561/** Attended: every refused main step ends its turn. Unattended: only a repeat in the same turn. */
562export const shouldAbortTurn = (attended: boolean, refusedBefore: boolean): boolean => attended || refusedBefore
563
564export type Phase = 'off' | 'blind' | 'waiting' | 'armed' | 'limit' | 'consented' | 'open' | 'stopped' | 'asking' | 'told' | 'reserve' | 'tripped'
565
566export type PhaseInput = {
567 enabled: boolean
568 basis: Basis
569 tripped: boolean
570 consented: boolean // tripped, and every tripped kind is consented
571 open?: boolean // tripped, no kind gates, and a kind is in its skip window (B41)
572 stopped: boolean
573 asking: boolean
574 told: boolean
575 attended: boolean
576 bases?: readonly Basis[] // Codex design 2.1: the bases of the kinds the host reports. Absent: [basis]
577 limit?: boolean // a kind that gates is at the quota limit
578}
579
580/**
581 * The breaker phase, first match: off, asking, blind, waiting, armed, limit, consented, open, stopped,
582 * told, reserve, tripped. The limit phase: attended, a kind at the quota limit gates, and no stop applies. `basis` is the five_hour basis, `tripped` is any watched kind (3.5). A stop never
583 * holds an open kind (B44), so open outranks stopped. With `bases` (a host that can lack a window, such
584 * as a weekly-only plan), the reading rule reads every basis: blind when all are blind, waiting when
585 * all are none, else armed. Claude passes no `bases`, so its rule does not change.
586 */
587export function phaseOf(i: PhaseInput): Phase {
588 if (!i.enabled) return 'off'
589 if (i.asking) return 'asking' // an open question outranks any reading (B6)
590 const bs = i.bases ?? [i.basis]
591 if (!i.tripped && bs.every((b) => b.kind === 'none')) return bs.length > 0 && bs.every((b) => b.kind === 'none' && b.why === 'blind') ? 'blind' : 'waiting'
592 if (!i.tripped) return 'armed'
593 if (i.limit === true && i.attended && !i.stopped) return 'limit'
594 if (i.consented) return 'consented'
595 if (i.open === true) return 'open'
596 if (i.stopped && i.attended) return 'stopped'
597 if (i.told) return 'told'
598 if (!i.attended) return 'reserve'
599 return 'tripped'
600}
601hooks/core/reading.ts 311 lines1import type { SessionRateLimit } from 'claude-code'
2import type { ConsentSlots } from './decide.ts'
3
4// The reading rule of the gate (design section 6), as pure functions. No $ here: register.tsx feeds
5// in what $.session.usage(), session.measure, $.store and the clock said. 0.2: one rule per kind.
6
7/** A window as SessionRateLimit.kind names it. Texts say 5-hour and weekly. */
8export type Kind = 'five_hour' | 'seven_day'
9
10/** The watched kinds, five_hour first. */
11export const KINDS: readonly Kind[] = ['five_hour', 'seven_day']
12
13/** A reading with the window it belongs to. */
14export type Anchored = { pct: number; resetsAtMs: number }
15
16/** What this copy of the module remembers of one kind: the newest reading with a reset, the blind count, and the first sight of a reading without a reset. */
17export type Memory = { seed?: Anchored; misses: number; noReset?: { since: number; pct: number } }
18
19export type Basis =
20 | { kind: 'live' | 'seed' | 'test'; pct: number; resetsAtMs: number | null }
21 | { kind: 'none'; why: 'no-reading' | 'window-reset' | 'blind' }
22
23/** One five-hour window plus a minute of clock slack: no reset is further away than this. */
24export const WINDOW_MS = 5 * 3_600_000 + 60_000
25
26/** The weekly window. */
27export const WEEK_MS = 7 * 24 * 3_600_000
28
29/** A release waits this long after a real reset (4.8). */
30export const RESET_MARGIN_MS = 300_000
31
32/** A release waits this long after the end of a test window (4.8). */
33export const TEST_MARGIN_MS = 60_000
34
35/** Two response-backed measures with no window: one can be a turn that ended just past a reset. */
36export const BLIND_AFTER = 2
37
38/** spare10's one-hour fallback when a reading has no reset time. It bounds consent and stopped only. */
39export const FALLBACK_MS = 3_600_000
40
41/** The window of a test reading that has no live reset to borrow. */
42export const TEST_WINDOW_MS = 5 * 3_600_000
43
44/** The length of a window: 5 h or 7 d. */
45export const windowMs = (kind: Kind): number => (kind === 'seven_day' ? WEEK_MS : 5 * 3_600_000)
46
47/** Two resets this close are one window (A22): a reset time moves by seconds, an early reset by much more. */
48export const RESET_JITTER_MS = 600_000
49
50/**
51 * A22: a real consent is void when the kind's real window ends more than RESET_JITTER_MS after the
52 * consent's end: the window reset early, and the consent belongs to the old one.
53 */
54export const voidedByReset = (c: { until: number }, windowEnd: number): boolean => windowEnd - c.until > RESET_JITTER_MS
55
56/**
57 * A22: this copy's real slots of a kind without the consents that an early reset voided. `realEnd`: the
58 * reset of the real reading, null when it is unknown (then nothing is void). Undefined when nothing is left.
59 */
60export function withoutVoided(s: ConsentSlots | undefined, realEnd: number | null): ConsentSlots | undefined {
61 if (s === undefined || realEnd === null) return s
62 const full = s.full !== undefined && !voidedByReset({ until: s.full }, realEnd) ? s.full : undefined
63 const floor = s.floor !== undefined && !voidedByReset(s.floor, realEnd) ? s.floor : undefined
64 if (full === undefined && floor === undefined) return undefined
65 return { ...(full === undefined ? {} : { full }), ...(floor === undefined ? {} : { floor }) }
66}
67
68const kindOfLimit = (live: SessionRateLimit): Kind => (live.kind === 'seven_day' ? 'seven_day' : 'five_hour')
69
70export const initialMemory = (): Memory => ({ misses: 0 })
71
72export const limitOf = (list: readonly SessionRateLimit[], kind: Kind): SessionRateLimit | undefined =>
73 list.find((r) => r.kind === kind)
74
75export const fiveHour = (list: readonly SessionRateLimit[]): SessionRateLimit | undefined => limitOf(list, 'five_hour')
76
77export function parseReset(iso: string | undefined): number | null {
78 if (iso === undefined) return null
79 const ms = Date.parse(iso)
80 return Number.isFinite(ms) ? ms : null
81}
82
83/** A live reading as something to remember. A reading without a reset time is never remembered. */
84export function anchoredOf(live: SessionRateLimit): Anchored | undefined {
85 const resetsAtMs = parseReset(live.resetsAt)
86 return resetsAtMs === null ? undefined : { pct: live.percentUsed, resetsAtMs }
87}
88
89/** A remembered reading counts while its window is open, and never for longer than one window of its kind. */
90export const inWindow = (r: Anchored, now: number, kind: Kind = 'five_hour'): boolean =>
91 now < r.resetsAtMs && r.resetsAtMs - now <= windowMs(kind) + 60_000
92
93/** The later window wins, then the higher percentage. */
94export function newer(a: Anchored | undefined, b: Anchored | undefined): Anchored | undefined {
95 if (a === undefined) return b
96 if (b === undefined) return a
97 if (a.resetsAtMs !== b.resetsAtMs) return a.resetsAtMs > b.resetsAtMs ? a : b
98 return b.pct > a.pct ? b : a
99}
100
101/** What the gate decides on at `now`: live, then blind, then the remembered reading, then none. */
102export function basis(live: SessionRateLimit | undefined, mem: Memory, now: number, test?: Anchored, kind: Kind = 'five_hour'): Basis {
103 const b = realBasis(live, mem, now, kind)
104 if (test === undefined || !inWindow(test, now, kind)) return b
105 if (b.kind === 'none' || test.pct > b.pct) return { kind: 'test', pct: test.pct, resetsAtMs: test.resetsAtMs }
106 return b
107}
108
109function realBasis(live: SessionRateLimit | undefined, mem: Memory, now: number, kind: Kind): Basis {
110 if (live !== undefined) {
111 // Live always wins over anything remembered, never max(live, remembered).
112 const resetsAtMs = parseReset(live.resetsAt)
113 if (resetsAtMs !== null && now >= resetsAtMs) return { kind: 'none', why: 'window-reset' }
114 // Without a reset time, a figure unchanged for one window since its first sight is stale (3.1).
115 const n = mem.noReset
116 if (resetsAtMs === null && n !== undefined && now >= n.since + windowMs(kind) && live.percentUsed === n.pct) {
117 return { kind: 'none', why: 'window-reset' }
118 }
119 return { kind: 'live', pct: live.percentUsed, resetsAtMs }
120 }
121 if (mem.misses >= BLIND_AFTER) return { kind: 'none', why: 'blind' }
122 if (mem.seed !== undefined && inWindow(mem.seed, now, kind)) return { kind: 'seed', ...mem.seed }
123 const expired = mem.seed !== undefined && now >= mem.seed.resetsAtMs
124 return { kind: 'none', why: expired ? 'window-reset' : 'no-reading' }
125}
126
127/** A live reading arrived (a gate read or a measure). With `now`, it also tracks the first sight of a reading without a reset. */
128export function sawLive(mem: Memory, live: SessionRateLimit, now?: number): Memory {
129 const seed = newer(mem.seed, anchoredOf(live))
130 const noReset = now === undefined ? mem.noReset : firstSight(mem.noReset, live, now)
131 return { ...(seed === undefined ? {} : { seed }), misses: 0, ...(noReset === undefined ? {} : { noReset }) }
132}
133
134// A fall means a new window, and so does a new figure one window after the first sight. A rise keeps it.
135function firstSight(n: Memory['noReset'], live: SessionRateLimit, now: number): Memory['noReset'] {
136 if (parseReset(live.resetsAt) !== null) return undefined
137 const pct = live.percentUsed
138 if (n === undefined || pct < n.pct) return { since: now, pct }
139 if (pct === n.pct) return n
140 if (now >= n.since + windowMs(kindOfLimit(live))) return { since: now, pct }
141 return { since: n.since, pct }
142}
143
144/**
145 * A session.measure arrived. `cost` in `changed` means a billed response completed. If the engine
146 * still lists no window of this kind, that response carried none: one miss.
147 */
148export function sawMeasure(
149 mem: Memory,
150 e: { rateLimits: readonly SessionRateLimit[]; changed: readonly string[] },
151 kind: Kind = 'five_hour',
152 now?: number,
153): Memory {
154 const live = limitOf(e.rateLimits, kind)
155 if (live !== undefined) return sawLive(mem, live, now)
156 if (!e.changed.includes('cost')) return mem
157 return { ...mem, misses: mem.misses + 1 }
158}
159
160/** A value read back from $.store, validated. Anything else is no seed. */
161export function asAnchored(v: unknown): Anchored | undefined {
162 if (typeof v !== 'object' || v === null) return undefined
163 const { pct, resetsAtMs } = v as Record<string, unknown>
164 return typeof pct === 'number' && Number.isFinite(pct) && typeof resetsAtMs === 'number' && Number.isFinite(resetsAtMs)
165 ? { pct, resetsAtMs }
166 : undefined
167}
168
169/** B48: the floor point, 100 - floor on the one-decimal grid: 95 for 5, 97.5 for 2.5, 89.4 for 10.6. */
170export const pointOf = (floor: number): number => Math.round((100 - floor) * 10) / 10
171
172// The trip point on the one-decimal grid, so 100 - 10.6 is 89.4 and not 89.40000000000001.
173const tripAt = (reserve: number): number => pointOf(reserve)
174
175/** The first point of the reserve trips: reserve 10 trips at 90.0 and passes at 89.9. */
176export const isTripped = (b: Basis, reserve: number): boolean => b.kind !== 'none' && b.pct >= tripAt(reserve)
177
178/** The quota limit: 100% used. Past it the host refuses each model request until the reset. */
179export const LIMIT_PCT = 100
180
181/** A basis at or past the quota limit, with a known reset. A reading without a reset time never counts. */
182export const atLimit = (b: Basis): boolean => b.kind !== 'none' && b.resetsAtMs !== null && b.pct >= LIMIT_PCT
183
184/** B48: a basis at or past a floor point. None with no point, and never for a none basis. */
185export const atPoint = (b: Basis, point: number | null): boolean => point !== null && b.kind !== 'none' && b.pct >= point
186
187/**
188 * 4.8: the last real reading of a kind was in the reserve, and its window reset less than
189 * RESET_MARGIN_MS ago. The local clock may run ahead of the server, so spare10 releases nothing by
190 * itself yet, also when a test window ends first.
191 */
192export const inResetMargin = (seed: Anchored | undefined, reserve: number, now: number): boolean =>
193 seed !== undefined && seed.resetsAtMs <= now && now - seed.resetsAtMs < RESET_MARGIN_MS && seed.pct >= tripAt(reserve)
194
195export const pctOf = (b: Basis): number | undefined => (b.kind === 'none' ? undefined : b.pct)
196
197/** The window end that bounds consent and stopped. Never a release time for held work. */
198export const windowEndOf = (b: Basis, now: number): number =>
199 b.kind !== 'none' && b.resetsAtMs !== null ? b.resetsAtMs : now + FALLBACK_MS
200
201/** The hold end (3.1): the reset time, else the first sight plus one window. Never the one-hour fallback. */
202export function holdEndOf(b: Basis, mem: Memory, now: number, kind: Kind = 'five_hour'): number {
203 if (b.kind === 'none') return now + windowMs(kind)
204 if (b.resetsAtMs !== null) return b.resetsAtMs
205 return (mem.noReset?.since ?? now) + windowMs(kind)
206}
207
208// ---- Skip near the reset (B41, B42, B45) ----
209
210/** The skip start of a basis: its reset minus the span. Null when the span is 0 or the reset is unknown. */
211export function skipStartOf(b: Basis, spanMs: number): number | null {
212 if (!(spanMs > 0) || b.kind === 'none' || b.resetsAtMs === null) return null
213 return b.resetsAtMs - spanMs
214}
215
216/** One kind at now: the basis it rests on, whether it is tripped, its skip start while ahead, and whether it is open. */
217export type KindView = { basis: Basis; tripped: boolean; skipAt: number | null; open: boolean }
218
219/**
220 * One kind at now (B41, B45). A kind is open when it is tripped and its skip start has come. A kind
221 * without a reset time is never open (fail safe). A test reading in its skip window yields to a real
222 * trip that is not open, so a test reading never releases a real hold.
223 */
224export function viewOf(real: Basis, withTest: Basis, reserve: number, spanMs: number, now: number): KindView {
225 const one = (b: Basis): KindView => {
226 const tripped = isTripped(b, reserve)
227 const start = skipStartOf(b, spanMs)
228 return { basis: b, tripped, skipAt: start !== null && now < start ? start : null, open: tripped && start !== null && now >= start }
229 }
230 const v = one(withTest)
231 if (withTest.kind !== 'test' || !v.open) return v
232 const r = one(real)
233 return r.tripped && !r.open ? r : v
234}
235
236/** The release margin (4.7): 0 at a skip start, 60 s after a test window, 5 min after a real reset. */
237export const marginOf = (skip: boolean, test: boolean): number => (skip ? 0 : test ? TEST_MARGIN_MS : RESET_MARGIN_MS)
238
239/** SPARE10_SIMULATE and /spare10 simulate: 0 to 100, rounded to one decimal. Junk is none. */
240export function parseTestPct(raw: string | undefined): number | undefined {
241 if (raw === undefined || raw.trim() === '') return undefined
242 const n = Number(raw.trim())
243 if (!Number.isFinite(n)) return undefined
244 const r = Math.round(n * 10) / 10
245 return r >= 0 && r <= 100 ? r : undefined
246}
247
248/** A test reading: its percentage, its kind, and when set, the time to its reset. */
249export type TestSpec = { pct: number; kind: Kind; inMs?: number }
250
251const UNIT_MS = new Map<string, number>([
252 ['s', 1_000],
253 ['m', 60_000],
254 ['h', 3_600_000],
255 ['d', 86_400_000],
256])
257
258/** `90s`, `2m`, `3h` or `1d`, any case. Under 10 s is none. */
259export function parseDuration(word: string): number | undefined {
260 const m = /^(\d+)([smhd])$/i.exec(word.trim())
261 if (m === null) return undefined
262 const ms = Number(m[1]) * (UNIT_MS.get((m[2] ?? '').toLowerCase()) ?? Number.NaN)
263 return Number.isFinite(ms) && ms >= 10_000 ? ms : undefined
264}
265
266const KIND_WORDS = new Map<string, Kind>([
267 ['5h', 'five_hour'],
268 ['5-hour', 'five_hour'],
269 ['five_hour', 'five_hour'],
270 ['weekly', 'seven_day'],
271 ['7d', 'seven_day'],
272 ['seven_day', 'seven_day'],
273])
274
275/**
276 * The 2.9 grammar: `off`, or a percentage, then a kind word and `in {n}{s|m|h|d}` in either order.
277 * `defaultKind`: the kind with no kind word (Codex design 4.15: the weekly window on a weekly-only plan).
278 */
279export function parseSimulate(words: readonly string[], defaultKind: Kind = 'five_hour'): TestSpec | 'off' | undefined {
280 const [first, ...rest] = words
281 if (first === undefined) return undefined
282 if (first.toLowerCase() === 'off') return rest.length === 0 ? 'off' : undefined
283 const pct = parseTestPct(first)
284 if (pct === undefined) return undefined
285 let kind: Kind | undefined
286 let inMs: number | undefined
287 for (let i = 0; i < rest.length; i += 1) {
288 const w = (rest[i] ?? '').toLowerCase()
289 const k = KIND_WORDS.get(w)
290 if (k !== undefined && kind === undefined) {
291 kind = k
292 continue
293 }
294 const d = w === 'in' && inMs === undefined ? parseDuration(rest[i + 1] ?? '') : undefined
295 if (d === undefined) return undefined
296 inMs = d
297 i += 1
298 }
299 const k = kind ?? defaultKind
300 return inMs === undefined ? { pct, kind: k } : { pct, kind: k, inMs: Math.min(inMs, windowMs(k)) }
301}
302
303/** SPARE10_SIMULATE as words: split on white space. A blank value has none. */
304export const simulateWords = (raw: string | undefined): string[] => (raw ?? '').trim().split(/\s+/).filter((w) => w !== '')
305
306/** SPARE10_SIMULATE: the same words, split on white space. `off` and junk are no test reading. */
307export function parseSimulateEnv(raw: string | undefined, defaultKind: Kind = 'five_hour'): TestSpec | undefined {
308 const spec = parseSimulate(simulateWords(raw), defaultKind)
309 return spec === 'off' ? undefined : spec
310}
311hooks/core/text.ts 1187 lines1import type { Headless, Scope, Source, SpanSource } from './config.ts'
2import type { Mode, Phase } from './decide.ts'
3import type { Basis, Kind } from './reading.ts'
4import { HOST } from './host.ts'
5
6// Every user-facing and model-facing string (design section 2), verbatim. No $ here.
7// Every text function takes the time zone through Facts.timeZone (the kit ignores TZ).
8//
9// The engine puts `spare10: ` in front of each transcript line ($.ui.log without `to`) and of each
10// command.run reply (live check LC1, defect D1). So a notice, a warning or a reply never starts with
11// `spare10: ` here. Texts the engine does not prefix keep it: model texts (STOP, PAUSED, HEADLESS, the
12// pause instruction, the resume note), the drop reasons of prompt.submit, and debug lines.
13//
14// A word that differs between hosts (a command, the host name) comes from HOST (host.ts). The Codex
15// bundle swaps host.ts, so the same texts carry the Codex words there (Codex design 2.1).
16
17export const VERSION: string = '0.4.0' // keep equal to .claude-plugin/plugin.json and .codex-plugin/plugin.json
18export const HEADER: string = 'spare10'
19export const QUESTION_OPTIONS: readonly [string, string] = ['Stop here', 'Resume']
20/** The limit question: Continue at the reset first, with the focus. Only the exact Stop here label stops. */
21export const LIMIT_OPTIONS: readonly [string, string] = ['Continue at the reset', 'Stop here']
22/** The way out of a pause at the limit: a reading that is out of date, or paid extra usage. */
23export const LIMIT_OFF: string = `To let work run past the limit, ${HOST.limitOff}.`
24export const RESUME_LABEL: string = 'Resume'
25export const COMMAND_DESCRIPTION: string = 'Show the spare10 quota breaker, or resume or stop at the reserve.'
26export const ARGUMENT_HINT: string = '[resume|stop]'
27export const STOP_GENERIC: string = 'spare10: stopped at the quota reserve. Stop now and wait for the user. Do not call any further tools.'
28export const NOT_STARTED_GENERIC: string = `spare10: not started. spare10 could not ask you. Send the prompt again, or run ${HOST.command} resume.`
29export const HEADLESS_GENERIC: string = 'spare10 stopped this unattended run at the quota reserve. No further model requests were sent.'
30export const W_FLAG: string =
31 'function hooks are on only in this shell. Background sessions and pane teammates start without spare10. Put CLAUDE_CODE_ENABLE_FUNCTION_HOOKS in the env block of ~/.claude/settings.json.'
32
33/**
34 * The figures of one kind. No `kind`: five_hour. `now` feeds the date rule of a weekly clock (2.1).
35 * `holdEnd`: the hold end when it is not the reset (3.1): a reading without a reset time, or a skip
36 * start that is still ahead (skip 2.1), for {at}. `span`: the kind's span in ms, set only when a text
37 * may name {lead} or {span} (a skip owner). `test`: a test reading, for {soon} and {lead}.
38 * The floor (floor 6.4): `to` is the end point of the consent that the text describes (the tier of a
39 * question or a Resume at the reserve, or the end point now of a covering consent to the floor).
40 * `floor` is set only for a kind at the floor: {R} becomes {F}. With neither, every text is the 0.2 text.
41 * `limit` is set only for a kind at the quota limit (100% used): {R} becomes the quota limit.
42 */
43export type Facts = {
44 used: number
45 left: number
46 resetsAtMs: number | null
47 reserve: number
48 timeZone?: string
49 kind?: Kind
50 now?: number
51 holdEnd?: number
52 span?: number
53 test?: boolean
54 to?: number
55 floor?: number
56 limit?: boolean
57}
58
59/** A window that reset or ended, for {reset}. */
60export type Named = { kind: Kind; test: boolean }
61
62/** Which kinds of a question or a stop reset, and which are open now (skip 4.4). */
63export type Ended = { reset: readonly Named[]; open: readonly Facts[] }
64
65const round1 = (n: number): number => Math.round(n * 10) / 10
66
67/** The figures of a basis that applies. A 'none' basis gives zero used and no reset. */
68export function factsOf(b: Basis, reserve: number, timeZone?: string, kind?: Kind, now?: number): Facts {
69 const used = b.kind === 'none' ? 0 : round1(b.pct)
70 const weekly = kind === 'seven_day'
71 return {
72 used,
73 left: round1(Math.max(0, 100 - used)),
74 resetsAtMs: b.kind === 'none' ? null : b.resetsAtMs,
75 reserve,
76 ...(timeZone === undefined ? {} : { timeZone }),
77 ...(weekly ? { kind } : {}),
78 ...(weekly && now !== undefined ? { now } : {}),
79 }
80}
81
82/** 91 is '91', 91.5 is '91.5', 91.54 is '91.5'. */
83export const fmtPct = (n: number): string => String(round1(n) + 0)
84
85const part = (ms: number, opts: Intl.DateTimeFormatOptions, timeZone?: string): string =>
86 new Intl.DateTimeFormat('en-GB', { ...opts, ...(timeZone === undefined ? {} : { timeZone }) }).format(new Date(ms))
87
88/** HH:MM, 24-hour, in the given zone (local time when none). */
89export function formatClock(ms: number, timeZone?: string): string {
90 return part(ms, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }, timeZone)
91}
92
93const DAY_MS = 86_400_000
94// A fixed table: en-GB says 'Sept' in some runtimes and 'Sep' in others.
95const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
96
97/**
98 * {clock} (2.1): 'HH:MM' for the 5-hour window. 'ddd HH:MM' for the weekly window, and 'ddd D MMM HH:MM'
99 * more than 6 days after now. Separate formatters, so no locale punctuation gets in.
100 */
101export function clockText(ms: number, kind: Kind, timeZone?: string, now?: number): string {
102 const clock = formatClock(ms, timeZone)
103 if (kind !== 'seven_day') return clock
104 const weekday = part(ms, { weekday: 'short' }, timeZone)
105 if (now === undefined || ms - now <= 6 * DAY_MS) return `${weekday} ${clock}`
106 const month = MONTHS[Number(part(ms, { month: 'numeric' }, timeZone)) - 1] ?? ''
107 return `${weekday} ${part(ms, { day: 'numeric' }, timeZone)} ${month} ${clock}`
108}
109
110/** '3 d 21 h', '2 h 14 min', '14 min' or 'under 1 min'. */
111export function fmtDuration(ms: number): string {
112 const min = Math.floor(Math.max(0, ms) / 60_000)
113 if (min < 1) return 'under 1 min'
114 if (min < 60) return `${min} min`
115 const hours = Math.floor(min / 60)
116 if (hours < 24) return `${hours} h ${min % 60} min`
117 return `${Math.floor(hours / 24)} d ${hours % 24} h`
118}
119
120/** {at}: the clock of a hold end, with the weekday form when the kinds name the weekly window. */
121export const atText = (ms: number, kinds: readonly Kind[], timeZone?: string, now?: number): string =>
122 clockText(ms, kinds.includes('seven_day') ? 'seven_day' : 'five_hour', timeZone, now)
123
124const oneReset = (n: Named): string => {
125 if (n.kind === 'seven_day') return n.test ? 'the weekly test window ended' : 'the weekly window reset'
126 return n.test ? 'the test window ended' : 'the 5-hour window reset'
127}
128
129const cap = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1)
130
131/** {reset}, five_hour first. No window named: the 5-hour window (a 0.1 stop). */
132export function resetText(named: readonly Named[], capital = false): string {
133 const five = named.find((n) => n.kind === 'five_hour')
134 const week = named.find((n) => n.kind === 'seven_day')
135 let text: string
136 if (five === undefined || week === undefined) text = oneReset(five ?? week ?? { kind: 'five_hour', test: false })
137 else if (five.test && week.test) text = 'the test windows ended'
138 else if (!five.test && !week.test) text = 'the 5-hour and weekly windows reset'
139 else text = `${oneReset(five)} and ${oneReset(week)}`
140 return capital ? cap(text) : text
141}
142
143/**
144 * The armed detail: 'spare10 steps in at 90% used.', with the weekly trip point when it is watched.
145 * `fiveAbsent` (Codex design 2.1): the host reports no 5-hour window, so only the weekly trip point
146 * counts, or no window when the weekly window is not watched either. Claude never passes it.
147 */
148export const stepsIn = (reserve: number, weeklyReserve?: number, fiveAbsent = false): string => {
149 const weekly = weeklyReserve !== undefined && weeklyReserve > 0
150 if (fiveAbsent) return weekly ? `spare10 steps in at ${fmtPct(100 - weeklyReserve)}% used of the weekly window.` : 'spare10 watches no window.'
151 return weekly
152 ? `spare10 steps in at ${fmtPct(100 - reserve)}% used, or at ${fmtPct(100 - weeklyReserve)}% used of the weekly window.`
153 : `spare10 steps in at ${fmtPct(100 - reserve)}% used.`
154}
155
156// ---- Placeholders of 2.1. A list of Facts reads five_hour first. ----
157
158const kindOf = (f: Facts): Kind => f.kind ?? 'five_hour'
159const isWeekly = (f: Facts): boolean => kindOf(f) === 'seven_day'
160const isList = (f: Facts | readonly Facts[]): f is readonly Facts[] => Array.isArray(f)
161const listOf = (f: Facts | readonly Facts[]): Facts[] =>
162 (isList(f) ? [...f] : [f]).sort((a, b) => Number(isWeekly(a)) - Number(isWeekly(b)))
163const onlyOf = (fs: readonly Facts[]): Facts | undefined => (fs.length === 1 ? fs[0] : undefined)
164
165const clockOf = (f: Facts): string =>
166 f.resetsAtMs === null ? 'at an unknown time' : clockText(f.resetsAtMs, kindOf(f), f.timeZone, f.now)
167const reserveOf = (f: Facts): string => `${fmtPct(f.reserve)}%`
168const UNTIL_RESET = 'until the window resets' // only when a caller has no figures
169
170/** {R}, or {F} for a kind at the floor (floor 2.1), or the quota limit for a kind at 100% used. */
171const reserveName = (f: Facts): string =>
172 f.limit === true
173 ? isWeekly(f)
174 ? 'weekly quota limit'
175 : 'quota limit'
176 : f.floor !== undefined
177 ? `${fmtPct(f.floor)}% ${isWeekly(f) ? 'weekly floor' : 'floor'}`
178 : `${reserveOf(f)} ${isWeekly(f) ? 'weekly reserve' : 'reserve'}`
179/** {Rs} */
180export const yourReserves = (fs: readonly Facts[]): string => fs.map((f) => `your ${reserveName(f)}`).join(' and ')
181/** {is} */
182const isAre = (fs: readonly Facts[]): string => (fs.length > 1 ? 'are' : 'is')
183/** {Rq}. One kind at the floor: the quota floor (floor 2.1). */
184const quotaReserve = (fs: readonly Facts[]): string => {
185 const f = onlyOf(fs)
186 if (f === undefined) return 'quota reserves'
187 if (f.floor !== undefined) return `${fmtPct(f.floor)}% ${isWeekly(f) ? 'weekly quota floor' : 'quota floor'}`
188 return `${reserveOf(f)} ${isWeekly(f) ? 'weekly quota reserve' : 'quota reserve'}`
189}
190/** {names} */
191const windowNames = (kinds: readonly Kind[]): string => {
192 const five = kinds.includes('five_hour')
193 const week = kinds.includes('seven_day')
194 if (five && week) return '5-hour and weekly windows'
195 return week ? 'weekly window' : '5-hour window'
196}
197
198const onePerson = (f: Facts): string => `${fmtPct(f.used)}% used · ${fmtPct(f.left)}% left · resets ${clockOf(f)}`
199
200/** {pf} */
201export function personFacts(f: Facts | readonly Facts[]): string {
202 const fs = listOf(f)
203 const one = onlyOf(fs)
204 if (one !== undefined) return onePerson(one)
205 return fs.map((x) => `${isWeekly(x) ? 'weekly' : '5-hour'} window ${onePerson(x)}`).join(', ')
206}
207
208const oneModel = (f: Facts): string =>
209 f.limit === true
210 ? `${fmtPct(f.used)}% of ${isWeekly(f) ? 'weekly quota' : 'quota'} used · resets ${clockOf(f)}`
211 : `into your ${reserveName(f)} · ${fmtPct(f.left)}% of ${isWeekly(f) ? 'weekly quota' : 'quota'} left · resets ${clockOf(f)}`
212
213/** {mf} */
214export const modelFacts = (f: Facts | readonly Facts[]): string => listOf(f).map(oneModel).join(', and ')
215
216/** {quiet}: 'until 14:00', 'for one hour' with no reset time, or 'until they reset (15:00 and Mon 09:00)'. */
217export function untilText(f: Facts | readonly Facts[]): string {
218 const fs = listOf(f)
219 const one = onlyOf(fs)
220 if (one !== undefined) return one.resetsAtMs === null ? 'for one hour' : `until ${clockOf(one)}`
221 const clocks = fs.map((x) => (x.resetsAtMs === null ? 'an unknown time' : clockOf(x)))
222 return `until they reset (${clocks.join(' and ')})`
223}
224
225/** {p}: an end point, '95% used', and for the weekly window '95% used of the weekly window'. */
226const pointText = (to: number, kind: Kind): string => `${fmtPct(to)}% used${kind === 'seven_day' ? ' of the weekly window' : ''}`
227
228/** A fact that names the floor: an end point (at the reserve) or the floor stage. */
229const hasFloor = (f: Facts): boolean => f.to !== undefined || f.floor !== undefined
230
231/** {use} of one kind (floor 2.1): at the floor the last part left, at the reserve its end point, else 0.2. */
232function useOne(f: Facts): string {
233 const weekly = isWeekly(f)
234 if (f.floor !== undefined) return `the last ${fmtPct(f.left)}%${weekly ? ' of the weekly window' : ''} ${untilText(f)}`
235 const name = weekly ? 'the weekly reserve' : 'the reserve'
236 if (f.to !== undefined) return f.resetsAtMs === null ? `${name} for one hour, or until ${fmtPct(f.to)}% used` : `${name} until ${fmtPct(f.to)}% used`
237 return `${name} ${untilText(f)}`
238}
239
240/** {use} */
241const useText = (fs: readonly Facts[]): string => {
242 const one = onlyOf(fs)
243 if (one !== undefined) return useOne(one)
244 if (!fs.some(hasFloor)) return `both reserves ${untilText(fs)}`
245 const [a, b] = fs
246 if (
247 a !== undefined &&
248 b !== undefined &&
249 a.floor === undefined &&
250 b.floor === undefined &&
251 a.to !== undefined &&
252 a.to === b.to &&
253 a.resetsAtMs !== null &&
254 b.resetsAtMs !== null
255 ) {
256 return `both reserves until ${fmtPct(a.to)}% used`
257 }
258 return fs.map(useOne).join(' and ')
259}
260
261/** {verb} (floor 2.1): what spare10 does at the floor in each mode. */
262const verbOf = (mode: Mode): string => (mode === 'tell' ? 'spare10 tells the agents to wind down' : 'spare10 asks you again')
263
264/** A skip start that is still ahead: a hold end that is not the reset, on a reading with a reset time. */
265const skipAhead = (f: Facts): boolean => f.resetsAtMs !== null && f.holdEnd !== undefined
266
267/**
268 * {asks} (floor 2.1): when spare10 asks again, for the kinds with an end point (`to`). '' when none.
269 * While a skip start is ahead, it says until when: from the skip start nothing gates.
270 */
271export function asksText(f: Facts | readonly Facts[], mode: Mode = 'hold'): string {
272 const fs = listOf(f).filter((x) => x.to !== undefined)
273 const verb = verbOf(mode)
274 const one = onlyOf(fs)
275 if (one !== undefined) {
276 const p = pointText(one.to ?? 0, kindOf(one))
277 if (!skipAhead(one)) return `At ${p}, ${verb}.`
278 return `Until ${clockText(one.holdEnd ?? 0, kindOf(one), one.timeZone, one.now)}, ${verb} at ${p}.`
279 }
280 const [a, b] = fs
281 if (a === undefined || b === undefined) return ''
282 const points = a.to === b.to ? `${fmtPct(a.to ?? 0)}% used of either window` : `${pointText(a.to ?? 0, 'five_hour')}, or at ${pointText(b.to ?? 0, 'seven_day')}`
283 if (!skipAhead(a) && !skipAhead(b)) return `At ${points}, ${verb}.`
284 return `Until its reserve opens, ${verb} at ${points}.`
285}
286
287/** ' {asks}', or '' when there is none. */
288const asksPart = (fs: readonly Facts[], mode: Mode): string => {
289 const a = asksText(fs, mode)
290 return a === '' ? '' : ` ${a}`
291}
292
293// ---- Skip near the reset: the placeholders of skip 2.1 ----
294
295/** The hold end of one kind: a given hold end, else its skip start when it has a span, else its reset. */
296const timeOf = (f: Facts): number | null =>
297 f.holdEnd ?? (f.span !== undefined && f.resetsAtMs !== null ? f.resetsAtMs - f.span : f.resetsAtMs)
298
299/** {span}: '20 min' for the 5-hour window, '8 h' for the weekly window. */
300export function spanText(f: Facts): string {
301 const ms = f.span ?? 0
302 return isWeekly(f) ? `${fmtPct(ms / 3_600_000)} h` : `${fmtPct(ms / 60_000)} min`
303}
304
305/** {lead}: where a skip start lies, such as '20 min before the reset'. Undefined without a span. */
306export function leadText(f: Facts): string | undefined {
307 if (f.span === undefined) return undefined
308 const end = isWeekly(f)
309 ? f.test === true
310 ? 'the weekly test window ends'
311 : 'the weekly reset'
312 : f.test === true
313 ? 'the test window ends'
314 : 'the reset'
315 return `${spanText(f)} before ${end}`
316}
317
318const oneSoon = (f: Facts): string => {
319 const clock = clockOf(f)
320 if (isWeekly(f)) return f.test === true ? `the weekly test window ends at ${clock}` : `the weekly window resets at ${clock}`
321 return f.test === true ? `the test window ends at ${clock}` : `the 5-hour window resets at ${clock}`
322}
323
324/** {soon}: when the open windows reset, five_hour first, joined with ', and '. Always the reset clock. */
325export const soonText = (open: Facts | readonly Facts[]): string => listOf(open).map(oneSoon).join(', and ')
326
327/** {event}: the windows that reset, then the open ones and their reserves. '' when both lists are empty. */
328export function eventText(named: readonly Named[], open: Facts | readonly Facts[]): string {
329 const fs = listOf(open)
330 const parts: string[] = []
331 if (named.length > 0) parts.push(resetText(named))
332 if (fs.length > 0) parts.push(soonText(fs), `${yourReserves(fs)} ${isAre(fs)} open until then`)
333 return parts.map((p, i) => (i === 0 ? p : cap(p))).join('. ')
334}
335
336// The D0.2 {reset} while no kind is open (4.4), else {event}.
337const eventOr = (named: readonly Named[], open: readonly Facts[]): string =>
338 open.length === 0 ? resetText(named) : eventText(named, open)
339
340/** {at} of a list of Facts: the latest hold end, and the owner's {lead} when the owner has a span. */
341export function whenOf(f: Facts | readonly Facts[]): { at: string; lead?: string } {
342 const fs = listOf(f)
343 const times = fs.map(timeOf).filter((t): t is number => t !== null)
344 if (times.length === 0) return { at: 'the reset' }
345 const latest = Math.max(...times)
346 const at = atText(latest, fs.map(kindOf), fs[0]?.timeZone, fs.find((x) => x.now !== undefined)?.now)
347 const owner = fs.find((x) => x.span !== undefined && timeOf(x) === latest)
348 const lead = owner === undefined ? undefined : leadText(owner)
349 return lead === undefined ? { at } : { at, lead }
350}
351
352/**
353 * {at} of a stop's until, and the {lead} of the kind whose skip start it is. The lead only when `skip`
354 * (a skip owner) and a kind with a span has its skip start at `ms`.
355 */
356export function untilFor(
357 f: Facts | readonly Facts[],
358 ms: number,
359 kinds: readonly Kind[],
360 skip: boolean,
361 now?: number,
362): { at: string; lead?: string } {
363 const fs = listOf(f)
364 const at = atText(ms, kinds, fs[0]?.timeZone, now ?? fs.find((x) => x.now !== undefined)?.now)
365 if (!skip) return { at }
366 const owner = fs.find((x) => x.span !== undefined && x.resetsAtMs !== null && x.resetsAtMs - x.span === ms)
367 const lead = owner === undefined ? undefined : leadText(owner)
368 return lead === undefined ? { at } : { at, lead }
369}
370
371/** '16:20', or '16:20, 20 min before the reset' with a lead. */
372export const untilPhrase = (u: { at: string; lead?: string }): string => (u.lead === undefined ? u.at : `${u.at}, ${u.lead}`)
373
374/**
375 * B2, and the tell-mode prompt wording. With `auto`, what Stop here and no answer mean (2.2). A skip
376 * owner's question names where its time lies (skip 2.2), and says `at`, not `after`: there is no margin.
377 */
378export function questionText(f: Facts | readonly Facts[], opener: 'loop' | 'prompt', mode: Mode, auto = false): string {
379 const fs = listOf(f)
380 const head = `${cap(yourReserves(fs))} ${isAre(fs)} reached: ${personFacts(fs)}.`
381 const ask = `Continue on ${useText(fs)}?${asksPart(fs, mode)}`
382 const hold = opener === 'loop' ? 'All work is on hold.' : mode === 'tell' ? 'spare10 holds your prompt.' : 'spare10 holds your prompt and any other work.'
383 if (!auto) return `${head} ${hold} ${ask}`
384 const { at, lead } = whenOf(fs)
385 if (lead !== undefined) {
386 const when = `${at}, ${lead}`
387 const after =
388 opener === 'loop'
389 ? `If you choose Stop here or do not answer, the work waits until ${when}. Then spare10 continues it, unless a reserve is still reached.`
390 : mode === 'tell'
391 ? `If you do not answer, your prompt goes in at ${when}, unless a reserve is still reached. Stop here ${HOST.backIt}.`
392 : `If you do not answer, all of it continues at ${when}, unless a reserve is still reached. Stop here ${HOST.backPrompt} and pauses other work until ${at}.`
393 return `${head} ${hold} ${ask} ${after}`
394 }
395 const after =
396 opener === 'loop'
397 ? `If you choose Stop here or do not answer, the work waits until ${at}. Then spare10 continues it, unless a reserve is still reached.`
398 : mode === 'tell'
399 ? `If you do not answer, your prompt goes in after ${at}, unless a reserve is still reached. Stop here ${HOST.backIt}.`
400 : `If you do not answer, all of it continues after ${at}, unless a reserve is still reached. Stop here ${HOST.backPrompt} and pauses other work until ${at}.`
401 return `${head} ${hold} ${ask} ${after}`
402}
403
404/** The facts of a model text: only the kinds at the quota limit when some kind is, else all. */
405const modelList = (f: Facts | readonly Facts[]): Facts[] => {
406 const fs = listOf(f)
407 const at = fs.filter((x) => x.limit === true)
408 return at.length > 0 ? at : fs
409}
410
411/** Where work stopped, as a model text says it: the quota limit, or the quota reserve. */
412const placeOf = (fs: readonly Facts[]): string => (fs.some((x) => x.limit === true) ? 'quota limit' : 'quota reserve')
413
414/** The head of a text at the quota limit: which limit is reached. Both windows: the quota limits of both. */
415export function limitHead(f: Facts | readonly Facts[]): string {
416 const fs = listOf(f)
417 if (fs.length > 1) return 'The quota limits of both windows are reached'
418 return fs.some(isWeekly) ? 'The weekly quota limit is reached' : 'The quota limit is reached'
419}
420
421/**
422 * The limit question (100% used): what it holds, and what no answer and Stop here mean. {at} is the latest
423 * reset. `auto`: autoResume when it opened. No answer: with it on, the work continues after the reset, and
424 * with it off, the work waits for the answer. Stop here stops until the reset and continues nothing. The
425 * last sentence names the way out, for a reading that is out of date or for paid extra usage (LIMIT_OFF).
426 */
427export function limitQuestionText(f: Facts | readonly Facts[], opener: 'loop' | 'prompt', auto = false): string {
428 const fs = listOf(f)
429 const { at } = whenOf(fs)
430 const head = `${limitHead(fs)}: ${personFacts(fs)}.`
431 const back = `After the reset, type a prompt to continue. ${LIMIT_OFF}`
432 if (opener === 'loop') {
433 const none = auto ? `If you do not answer, the work waits until ${at}. Then spare10 continues it, unless a reserve is still reached.` : 'Until you answer, the work waits.'
434 return `${head} All work is on hold. Continue the work at the reset? ${none} Stop here stops the work. ${back}`
435 }
436 const none = auto ? `If you do not answer, all of it continues after ${at}, unless a reserve is still reached.` : 'Until you answer, all of it waits.'
437 return `${head} spare10 holds your prompt and any other work. Continue the work at the reset? ${none} Stop here ${HOST.backPrompt} and stops other work. ${back}`
438}
439
440/** B7 STOP: the deny text of a tool call. */
441export const stopText = (f: Facts | readonly Facts[]): string => {
442 const fs = modelList(f)
443 return `spare10: the user stopped work at the ${placeOf(fs)} (${modelFacts(fs)}). Stop now and wait for the user. Do not call any further tools.`
444}
445
446/** B7 PAUSED: the answer of a refused model request. */
447export const pausedText = (f: Facts | readonly Facts[]): string => {
448 const fs = modelList(f)
449 return `spare10: work stopped at the ${placeOf(fs)} (${modelFacts(fs)}). No model request was sent, so this task is not finished. Wait for the user.`
450}
451
452/** B15 HEADLESS. */
453export const headlessText = (f: Facts | readonly Facts[], sessionId: string): string => {
454 const fs = modelList(f)
455 return `spare10 stopped this unattended run at the ${placeOf(fs)} (${modelFacts(fs)}). No further model requests were sent. To pick it up later: ${HOST.resume} ${sessionId}`
456}
457
458/** B12: spare10's template. Without user text there is no User instructions paragraph. A kind at the floor: the floor sentence. */
459export function pauseInstruction(f: Facts | readonly Facts[], pausePrompt: string | null): string {
460 const fs = modelList(f)
461 const reached = fs.some((x) => x.limit === true)
462 ? 'You have reached the quota limit for this session'
463 : fs.some((x) => x.floor !== undefined)
464 ? 'You have reached the floor of the quota reserve for this session'
465 : 'You have reached the safe usage limit for this session'
466 const head =
467 `spare10 budget guard. ${reached} (${modelFacts(fs)}). ` +
468 'Immediately wrap up your work and stop. Immediately stop any subagent, unless the user instructs otherwise.'
469 return pausePrompt === null || pausePrompt.trim() === '' ? head : `${head}\n\nUser instructions: ${pausePrompt}`
470}
471
472/** B9: the hidden context of a resumed prompt in a stopped session. */
473export function resumeContext(f: Facts | readonly Facts[]): string {
474 const fs = listOf(f)
475 return `spare10: earlier work stopped at the ${quotaReserve(fs)}. The user now chose to continue on ${useText(fs)}. Follow their message.`
476}
477
478/** B10: the reason of a dropped prompt. At the quota limit it names only the kinds at the limit. */
479export function notStarted(f: Facts | readonly Facts[]): string {
480 const at = listOf(f).filter((x) => x.limit === true)
481 if (at.length > 0) return `spare10: not started. ${limitHead(at)} until ${whenOf(at).at}. Send the prompt again after the reset.`
482 const fs = listOf(f)
483 return `spare10: not started. This session is inside ${yourReserves(fs)} ${untilText(fs)}. Send the prompt again to be asked again, or run ${HOST.command} resume.`
484}
485
486/** B35: the hidden context of a person prompt that ends a stop with work after the reset, or after the reserve opened. */
487export function resetContext(named: readonly Named[], open: readonly Facts[] = []): string {
488 const tail = "The stopped task is not finished. After the user's message, continue it unless the user says otherwise."
489 if (open.length === 0) return `spare10: earlier work stopped at the quota reserve. ${resetText(named, true)} since then, so the stop is over. ${tail}`
490 return `spare10: earlier work stopped at the quota reserve. ${cap(eventText(named, open))}, so the stop is over. ${tail}`
491}
492
493const RESUME_TAIL = `Continue the task from the point where it stopped. ${HOST.stoppedAgent} Run it again if you still need its result.`
494
495/** B34: the plugin prompt at the reset, or when the reserve opens. The engine frames it, so it has no prefix. */
496export function resumePrompt(named: readonly Named[], open: readonly Facts[] = []): string {
497 if (open.length === 0) {
498 return `${resetText(named, true)}, so the stop at the quota reserve is over. spare10 is set to continue the work at the reset, so do not wait for the user. ${RESUME_TAIL}`
499 }
500 return `${cap(eventText(named, open))}, so the stop at the quota reserve is over. spare10 is set to continue the work when the reserve opens, so do not wait for the user. ${RESUME_TAIL}`
501}
502
503/** The deny that withdraws this copy's dialog. */
504export const withdrawnText = (outcome: string | undefined): string => `spare10: withdrawn (${outcome ?? 'closed'})`
505
506/** B27 */
507export function badWarning(
508 name:
509 | 'SPARE10_RESERVE'
510 | 'SPARE10_WEEKLY_RESERVE'
511 | 'SPARE10_LAST_MINUTES'
512 | 'SPARE10_WEEKLY_LAST_HOURS'
513 | 'SPARE10_RESUME_FLOOR'
514 | 'SPARE10_WEEKLY_RESUME_FLOOR'
515 | 'SPARE10_AUTO_RESUME'
516 | 'SPARE10_LIMIT_PAUSE'
517 | 'SPARE10_HEADLESS'
518 | 'SPARE10',
519 raw: string,
520 used: string,
521): string {
522 if (name === 'SPARE10_RESERVE') return `SPARE10_RESERVE="${raw}" is not 1 to 99. spare10 uses ${used}.`
523 if (name === 'SPARE10_WEEKLY_RESERVE') return `SPARE10_WEEKLY_RESERVE="${raw}" is not 0 or 1 to 99. spare10 uses ${used}.`
524 if (name === 'SPARE10_LAST_MINUTES') return `SPARE10_LAST_MINUTES="${raw}" is not 0 to 299. spare10 uses ${used}.`
525 if (name === 'SPARE10_WEEKLY_LAST_HOURS') return `SPARE10_WEEKLY_LAST_HOURS="${raw}" is not 0 to 167. spare10 uses ${used}.`
526 if (name === 'SPARE10_RESUME_FLOOR') return `SPARE10_RESUME_FLOOR="${raw}" is not 0 to 99. spare10 uses ${used}.`
527 if (name === 'SPARE10_WEEKLY_RESUME_FLOOR') return `SPARE10_WEEKLY_RESUME_FLOOR="${raw}" is not 0 to 99. spare10 uses ${used}.`
528 if (name === 'SPARE10_AUTO_RESUME') return `SPARE10_AUTO_RESUME="${raw}" is not on or off. spare10 uses ${used}.`
529 if (name === 'SPARE10_LIMIT_PAUSE') return `SPARE10_LIMIT_PAUSE="${raw}" is not on or off. spare10 uses ${used}.`
530 if (name === 'SPARE10_HEADLESS') return `SPARE10_HEADLESS="${raw}" is not off, prompt, stop or wait. spare10 uses ${used}.`
531 return `SPARE10="${raw}" is not on or off. spare10 uses the scope option (${used}).`
532}
533
534/**
535 * B27 for SPARE10_SIMULATE: a value that is not a test reading, or a weekly test reading while the weekly
536 * reserve is 0. The second stays set: a weekly reserve that the person sets later puts it in force.
537 * Never "names the weekly window": on a Codex weekly-only plan a value with no kind word is weekly too.
538 */
539export const simulateWarning = (raw: string, weeklyOff = false): string =>
540 weeklyOff
541 ? `SPARE10_SIMULATE="${raw}" is a weekly test reading, and the weekly reserve is 0. spare10 uses it only when the weekly reserve is more than 0.`
542 : `SPARE10_SIMULATE="${raw}" is not a test reading. spare10 uses none.`
543
544/** B54: a floor at or above its reserve does nothing. */
545export const floorWarning = (kind: Kind, floor: number, reserve: number): string =>
546 kind === 'seven_day'
547 ? `the weekly resume floor (${fmtPct(floor)}%) is not below the weekly reserve (${fmtPct(reserve)}%), so it does nothing. Set it below the weekly reserve, or to 0.`
548 : `the resume floor (${fmtPct(floor)}%) is not below the reserve (${fmtPct(reserve)}%), so it does nothing. Set it below the reserve, or to 0.`
549
550/** B29 */
551export const timeoutWarning = (name: string, auto = false): string =>
552 auto
553 ? `questions here continue by themselves after a time limit (${name}). An unanswered spare10 question then counts as Stop here, and spare10 continues the work at the time that the question names.`
554 : `questions here continue by themselves after a time limit (${name}). An unanswered spare10 question then counts as Stop here.`
555
556/** B30 */
557export const consentWarning = (raw: string, kind: Kind = 'five_hour'): string =>
558 kind === 'seven_day'
559 ? `SPARE10_WEEKLY_CONSENT="${raw}" names a time after this weekly window. spare10 ignores it.`
560 : `SPARE10_CONSENT="${raw}" names a time after this 5-hour window. spare10 ignores it.`
561
562/** B31: SPARE10 switches in a --bg session come from the daemon or a settings file (9.3). */
563export const bgEnvWarning = (set: ReadonlyArray<readonly [string, string]>): string =>
564 `this background session has ${set.map(([name, raw]) => `${name}=${JSON.stringify(raw)}`).join(', ')}. ` +
565 'A background session gets such values from the claude daemon or a settings file, not from your terminal.'
566
567/** The way back after a stop: a new prompt asks again, or the resume command. */
568const AGAIN = `Type a prompt to be asked again, or run ${HOST.command} resume.`
569
570/** Work of the session is held in place under a stop (Codex design 4.19, 4.20): it waits, and the resume command continues it now. */
571export const HELD_WAITS = `Held work waits. Run ${HOST.anytime} resume to continue it now.`
572
573/** A stop's time: {at}, or {at} and the owner's {lead}, and whether spare10 continues the work then. */
574type AutoAt = { at: string; work: boolean; lead?: string }
575
576/**
577 * Transcript notices (B3, B4, B6, B12, 2.4). The engine prefixes them with `spare10: `. Each reset
578 * notice takes an optional list of open kinds (skip 2.4): with none, it is the D0.2 text.
579 */
580export const notice = {
581 /** Floor 2.4: with an end point on some kind, one part per kind and {asks}. Else the 0.2 text. */
582 continuing: (f: Facts | readonly Facts[], mode: Mode = 'hold'): string => {
583 const fs = listOf(f)
584 if (!fs.some((x) => x.to !== undefined)) return `continuing on ${yourReserves(fs)}. spare10 stays quiet ${untilText(fs)}.`
585 const parts = fs.map((x) => `your ${reserveName(x)} ${x.to !== undefined ? `until ${fmtPct(x.to)}% used` : untilText(x)}`)
586 return `continuing on ${parts.join(' and ')}.${asksPart(fs, mode)}`
587 },
588 newWindow: 'held work continues on the new 5-hour window.',
589 newWindowFor: (kinds: readonly Kind[]): string => `held work continues on the new ${windowNames(kinds)}.`,
590 stopped: (f: Facts | readonly Facts[], auto?: AutoAt): string => {
591 const rs = yourReserves(listOf(f))
592 if (auto === undefined) return `stopped at ${rs}. ${AGAIN}`
593 if (!auto.work) return `stopped at ${rs} until ${untilPhrase(auto)}. ${AGAIN}`
594 return `stopped at ${rs} until ${untilPhrase(auto)}. Then spare10 continues the work, unless a reserve is still reached. ${AGAIN}`
595 },
596 holdLimit: (f: Facts | readonly Facts[], auto?: AutoAt): string => {
597 const rs = yourReserves(listOf(f))
598 if (auto === undefined) return `the hold reached its time limit. The work is stopped at ${rs}. ${AGAIN}`
599 if (!auto.work) return `the hold reached its time limit. The work is stopped at ${rs} until ${untilPhrase(auto)}. ${AGAIN}`
600 return `the hold reached its time limit. The work is stopped at ${rs} until ${untilPhrase(auto)}. Then spare10 continues it, unless a reserve is still reached.`
601 },
602 /** B46: Stop here after the skip start. soon: the next tick continues the work. Else nothing is stopped. */
603 stoppedLate: (f: Facts | readonly Facts[], e: Ended, soon: boolean): string => {
604 const ev = cap(eventText(e.reset, e.open))
605 if (soon) return `stopped at ${yourReserves(listOf(f))}. ${ev}, so spare10 continues the work soon, unless a reserve is still reached.`
606 return `stopped. Held work is refused. ${ev}, so new work goes on with no question.`
607 },
608 /** B46 through the B40 time limit. */
609 holdLimitLate: (f: Facts | readonly Facts[], e: Ended, soon: boolean): string => {
610 const ev = cap(eventText(e.reset, e.open))
611 if (soon) {
612 return `the hold reached its time limit. The work is stopped at ${yourReserves(listOf(f))}. ${ev}, so spare10 continues it soon, unless a reserve is still reached.`
613 }
614 return `the hold reached its time limit. Held work is refused. ${ev}, so new work goes on with no question.`
615 },
616 resetWaiting: 'the 5-hour window reset. Held work still waits for your answer.',
617 /** `newWork`: no kind gates now, so new work goes on. False while another window still holds new work. */
618 resetWaitingFor: (named: readonly Named[], open: readonly Facts[] = [], newWork = open.length > 0): string =>
619 open.length === 0
620 ? `${resetText(named)}. Held work still waits for your answer.`
621 : `${eventText(named, open)}, but held work still waits for your answer.${newWork ? ' New work goes on with no question.' : ''}`,
622 told: (f: Facts | readonly Facts[]): string => {
623 const fs = listOf(f)
624 return `${yourReserves(fs)} ${isAre(fs)} reached. spare10 told the agents to wind down.`
625 },
626 resetContinues: (named: readonly Named[], open: readonly Facts[] = []): string => `${eventOr(named, open)}. Held work continues.`,
627 resetStillHeld: (named: readonly Named[], still: readonly Facts[], open: readonly Facts[] = []): string => {
628 const fs = listOf(still)
629 const tail = `${yourReserves(fs)} ${isAre(fs)} reached. Held work still waits.`
630 return named.length === 0 && open.length === 0 ? tail : `${eventOr(named, open)}, but ${tail}`
631 },
632 outOfReserve: 'the quota is no longer in the reserve. Held work continues.',
633 resetResumes: (named: readonly Named[], open: readonly Facts[] = []): string => `${eventOr(named, open)}. spare10 continues the stopped work.`,
634 resetStopOver: (named: readonly Named[], open: readonly Facts[] = []): string =>
635 `${eventOr(named, open)}, and the stop is over. Type a prompt to continue.`,
636 stopTakenOver: (named: readonly Named[], open: readonly Facts[] = []): string =>
637 named.length === 0 && open.length === 0 ? 'the stop is over.' : `${eventOr(named, open)}, and the stop is over.`,
638 stopExtended: (named: readonly Named[], still: readonly Facts[], at: string, open: readonly Facts[] = []): string => {
639 const fs = listOf(still)
640 const tail = `${yourReserves(fs)} ${isAre(fs)} reached. The stop lasts until ${at}.`
641 return named.length === 0 && open.length === 0 ? tail : `${eventOr(named, open)}, but ${tail}`
642 },
643 resumeFailed: (reason: string): string => `could not continue the stopped work: ${reason}. Type a prompt to continue.`,
644 /** Continue at the reset on the limit question: held work waits for the reset. It names the way out (LIMIT_OFF). */
645 limitContinues: (f: Facts | readonly Facts[]): string =>
646 `held work waits until ${whenOf(f).at}. Then spare10 continues it, unless a reserve is still reached. ${LIMIT_OFF}`,
647 /** A person prompt after Continue at the reset: it waits with the held work, and asks nothing. */
648 limitPromptWaits: (f: Facts | readonly Facts[]): string =>
649 `your prompt waits with the held work until ${whenOf(f).at}. Then spare10 continues all of it, unless a reserve is still reached.`,
650 /**
651 * A stop at the quota limit: Stop here, or a question at the reserve that ends with no answer at the limit.
652 * `cont`: the stop continues the work at the reset. No `at`: the reset has passed, so the line names no time.
653 */
654 limitStopped: (at?: string, cont = false): string =>
655 at === undefined
656 ? 'stopped at the quota limit. Type a prompt to continue.'
657 : cont
658 ? `stopped at the quota limit until ${at}. Then spare10 continues the work, unless a reserve is still reached.`
659 : `stopped at the quota limit until ${at}. After the reset, type a prompt to continue.`,
660 /** The hold time limit at the quota limit. `cont`: the stop continues the work at the reset. No `at` (only without `cont`): the reset has passed. */
661 limitHoldLimit: (at: string | undefined, cont: boolean): string =>
662 at === undefined
663 ? 'the hold reached its time limit. The work is stopped at the quota limit. Type a prompt to continue.'
664 : cont
665 ? `the hold reached its time limit. The work is stopped at the quota limit until ${at}. Then spare10 continues it, unless a reserve is still reached.`
666 : `the hold reached its time limit. The work is stopped at the quota limit until ${at}. After the reset, type a prompt to continue.`,
667 /** A question at the reserve gives way to the limit question. */
668 limitReached: 'the quota limit is reached. spare10 asks you again.',
669 /** A limit question ends before its reset: no kind that gates is at the quota limit now. */
670 limitOver: 'the pause at the limit is over. Held work continues, unless a reserve is still reached.',
671}
672
673/** Debug lines (B12, B15, 2.5, 4.5, 4.7, 10.9.6). The engine does not prefix them, so they keep `spare10: `. */
674export const debugLine = {
675 unattended: (f: Facts | readonly Facts[], policy: Headless): string =>
676 `spare10: unattended run inside the reserve (${personFacts(f)}), policy ${policy}.`,
677 unattendedOpen: (f: Facts | readonly Facts[]): string =>
678 `spare10: unattended run inside the reserve (${personFacts(f)}), but the reset is near. spare10 lets it through.`,
679 told: (key: string): string => `spare10: told ${key}`,
680 handedOn: (n: number): string => `spare10: the loop that asked went away. spare10 asks again (${n}).`,
681 abortFailed: (err: string): string => `spare10: turn.abort failed: ${err}`,
682 droppedStop: 'spare10: a stop of another conversation ended at its reset. spare10 dropped it.',
683 resumeSkipped: 'spare10: the conversation changed before the resume prompt. spare10 sent nothing.',
684 boxDefer: (n: number): string => `spare10: the prompt box has text. The resume prompt waits (${n} of 10).`,
685 budget: (min: number, ms: number): string => `spare10: held ${min} min. Budget left ${ms} ms.`,
686 checkFailed: (err: string): string => `spare10: the reset check did not run: ${err}`,
687 settleFailed: (err: string): string => `spare10: could not write the answer: ${err}`,
688 startFailed: (err: string): string => `spare10: a step of the session start failed: ${err}`,
689}
690
691export type StatusInput = {
692 phase: Phase
693 mode: Mode
694 reserve: number
695 reserveFrom: Source
696 pausePrompt: string | null
697 attended: boolean
698 headless: Headless
699 headlessFrom: Source
700 childPolicy: string // live SPARE10_HEADLESS, else the option
701 enabled: boolean
702 enabledFrom: 'scope' | 'SPARE10'
703 scope: Scope
704 basis: Basis
705 facts?: Facts // facts when basis is not 'none'
706 now: number
707 consentUntil?: number // only when it covers this window
708 toldCount: number
709 warnings: string[]
710 timeZone?: string
711 // 0.2. Every field below is optional: absent gives the 0.1 report.
712 weekly?:
713 | { reserve: number; from: Source; basis: Basis; facts?: Facts; consentUntil?: number; consentTo?: number; consentEnded?: boolean }
714 | 'off' // reserve 0 is off too
715 autoResume?: { on: boolean; from: Source }
716 limitPause?: { on: boolean; from: Source } // the pause at the quota limit. Its row shows only while it is off
717 limit?: { ms: number; kinds: readonly Kind[]; held: boolean } // the limit phase: when held work continues. held: a limit question after Continue at the reset. The stopped phase: the reset of the kinds at the limit
718 at?: { ms: number; kinds: readonly Kind[]; skip?: boolean } // when an open question or a stop continues. skip: a skip start ('at', not 'after')
719 work?: boolean // the stop has work
720 autoStop?: boolean // the stop has auto, and autoResume is on
721 tickerStale?: boolean // the reset clock is wanted and its last step is more than 90 s old
722 // Skip near the reset. Absent: no rows and the D0.2 lines.
723 spans?: { lastMinutes: number; lastMinutesFrom: SpanSource; weeklyLastHours: number; weeklyLastHoursFrom: SpanSource }
724 open?: readonly Facts[] // the open kinds: the open phase line, and the asking line
725 skipStop?: boolean // the stop that applies has the skip tag
726 // The resume floor (floor 2.7). Absent: no floor rows and the 0.2 help line.
727 floors?: { resumeFloor: number; resumeFloorFrom: Source; weeklyResumeFloor: number; weeklyResumeFloorFrom: Source }
728 consentTo?: number // the end point now of the 5-hour consent to the floor, in force or ended
729 consentEnded?: boolean // that consent reached its end point (consentUntil is then absent)
730 consented?: readonly Facts[] // the consented kinds, `to` set from their covering consents: the phase line {asks}
731 // Host inputs (Codex design 2.1). Claude passes none of them, so its report does not change.
732 absent?: readonly Kind[] // the kinds the host reports no window for (CX17)
733 heldInPlace?: boolean // work of this session is held in place under a stop: the stopped line says so
734 extraRows?: ReadonlyArray<readonly [string, string]> // rows after the claude -p (or unattended) row, as [label, value]
735 extraHelp?: readonly string[] // lines after the two help lines
736}
737
738const GLYPH: Record<Phase, string> = {
739 off: '○',
740 blind: '⚠',
741 waiting: '⧗',
742 armed: '●',
743 limit: '‖',
744 consented: '⨯',
745 open: '↻',
746 stopped: '■',
747 asking: '?',
748 told: '⏸',
749 reserve: '⚠',
750 tripped: '⚠',
751}
752
753/** D4: the phase detail and every field value start in one column: two spaces, a mark, a space, this width. */
754const LABEL_WIDTH = 15
755
756type Weekly = Exclude<StatusInput['weekly'], 'off' | undefined>
757
758/** The weekly window when it is watched. */
759const watchedWeekly = (s: StatusInput): Weekly | undefined =>
760 s.weekly !== undefined && s.weekly !== 'off' && s.weekly.reserve > 0 ? s.weekly : undefined
761
762/** The consented part of the consented phase line: {quiet} of the consented kinds. */
763function quietOf(s: StatusInput): string {
764 const ends: Array<[number, Kind]> = []
765 if (s.consentUntil !== undefined) ends.push([s.consentUntil, 'five_hour'])
766 const w = watchedWeekly(s)
767 if (w?.consentUntil !== undefined) ends.push([w.consentUntil, 'seven_day'])
768 const clocks = ends.map(([ms, kind]) => clockText(ms, kind, s.timeZone, s.now))
769 if (clocks.length === 1) return `until ${clocks[0] ?? ''}`
770 if (clocks.length > 1) return `until they reset (${clocks.join(' and ')})`
771 return s.facts ? untilText(s.facts) : UNTIL_RESET
772}
773
774function phaseLine(s: StatusInput): string {
775 const at = s.at === undefined ? undefined : atText(s.at.ms, s.at.kinds, s.timeZone, s.now)
776 // A stop at the quota limit: the resume command changes nothing until the reset, so the line names the reset and the way out.
777 const limitClock = s.phase === 'stopped' && s.limit !== undefined ? atText(s.limit.ms, s.limit.kinds, s.timeZone, s.now) : undefined
778 const again = limitClock !== undefined ? `Type a prompt to be asked again. ${LIMIT_OFF}` : s.heldInPlace === true ? HELD_WAITS : AGAIN
779 const open = s.open === undefined ? [] : listOf(s.open)
780 const openRs = open.length === 0 ? '' : `${cap(yourReserves(open))} ${isAre(open)} open ${untilText(open)}`
781 const stopped =
782 at === undefined
783 ? limitClock === undefined
784 ? `you chose Stop here. ${again}`
785 : `you chose Stop here, until ${limitClock}. ${again}`
786 : s.skipStop === true
787 ? s.work === true && s.autoStop === true
788 ? `you chose Stop here. spare10 continues the work at ${at}. ${again}`
789 : `you chose Stop here, until ${at}. ${again}`
790 : s.autoStop !== true
791 ? `you chose Stop here. ${again}`
792 : s.work === true
793 ? `you chose Stop here. spare10 continues the work after ${at}. ${again}`
794 : `you chose Stop here, until ${at}. ${again}`
795 const openNote = openRs === '' ? '' : ` ${openRs}, so new work goes on.`
796 const asking =
797 at === undefined || s.autoResume?.on !== true
798 ? `a question is open. Held work waits until you answer.${openNote} If no ${HOST.dialog} shows, run ${HOST.anytime} resume or ${HOST.anytime} stop.`
799 : `a question is open. Held work waits until you answer, or until ${at}.${openNote} If no ${HOST.dialog} shows, run ${HOST.anytime} resume or ${HOST.anytime} stop.`
800 const detail: Record<Phase, string> = {
801 off: 'spare10 only watches in this run.',
802 blind: `${HOST.blind} spare10 lets all work through.`,
803 waiting: 'no reading yet. spare10 lets all work through.',
804 armed: stepsIn(s.reserve, absentKind(s, 'seven_day') ? undefined : watchedWeekly(s)?.reserve, absentKind(s, 'five_hour')),
805 limit: limitLine(s),
806 consented:
807 s.consented !== undefined && s.consented.some((f) => f.to !== undefined)
808 ? `you chose to continue. ${asksText(s.consented, s.mode)}`
809 : `you chose to continue. spare10 is quiet ${quietOf(s)}.`,
810 open: openRs === '' ? 'the reset is near, so spare10 lets all work through.' : `the reset is near. ${openRs}, so spare10 lets all work through.`,
811 stopped,
812 asking,
813 told: `the wind-down went to ${s.toldCount} agent(s).`,
814 reserve:
815 s.headless === 'wait' && at !== undefined
816 ? `unattended run, policy wait. Held work continues ${s.at?.skip === true ? 'at' : 'after'} ${at}.`
817 : `unattended run, policy ${s.headless}.`,
818 tripped:
819 s.mode === 'tell'
820 ? 'spare10 tells each agent to wind down at its next step.'
821 : 'spare10 holds the next step and asks you.',
822 }
823 const name = s.phase === 'reserve' ? 'tripped' : s.phase
824 return ` ${GLYPH[s.phase]} ${name.padEnd(LABEL_WIDTH)}${detail[s.phase]}`
825}
826
827/**
828 * The limit phase line: held work waits for the reset, or the next step holds and asks. Both name the way
829 * out, for a reading that is out of date or for paid extra usage (how-it-works items 58 and 59).
830 */
831function limitLine(s: StatusInput): string {
832 const l = s.limit
833 const at = l === undefined ? 'the reset' : atText(l.ms, l.kinds, s.timeZone, s.now)
834 if (l?.held === true) return `the quota limit is reached. Held work waits until ${at}. Then spare10 continues it, unless a reserve is still reached. ${LIMIT_OFF}`
835 return `the quota limit is reached until ${at}. spare10 holds the next step and asks you. ${LIMIT_OFF}`
836}
837
838/** The host reports no window of this kind (CX17). */
839const absentKind = (s: Pick<StatusInput, 'absent'>, kind: Kind): boolean => s.absent?.includes(kind) === true
840
841/** The reading row. `absent`: the host reports no window of this kind for this plan (CX17). */
842function readingValue(b: Basis, f: Facts, now: number, absent = false, kind: Kind = 'five_hour'): string {
843 if (absent) return `none: ${HOST.name} reports no ${kind === 'seven_day' ? 'weekly' : '5-hour'} window for this plan`
844 if (b.kind === 'none') {
845 if (b.why === 'blind') return `none: ${HOST.name} reports no quota (blind)`
846 return b.why === 'window-reset' ? 'none: the window reset' : 'none: no reading yet'
847 }
848 const src = b.kind === 'live' ? 'live' : b.kind === 'seed' ? 'seed from another session' : 'test reading'
849 // D2: {pf} already says `resets HH:MM`, so the time left is in brackets, not a second `resets`.
850 const left = b.resetsAtMs === null ? '' : ` (in ${fmtDuration(b.resetsAtMs - now)})`
851 return `${src} · ${personFacts(f)}${left}`
852}
853
854function guardedValue(s: StatusInput): string {
855 if (!s.enabled) {
856 return s.enabledFrom === 'SPARE10' ? 'no: SPARE10=off. spare10 only watches.' : `no: scope opt-in. ${HOST.optIn}`
857 }
858 if (!s.attended) return 'no: this session is unattended.'
859 return s.enabledFrom === 'SPARE10' ? 'yes (SPARE10=on)' : 'yes (scope all)'
860}
861
862function actionValue(s: StatusInput): string {
863 if (!s.attended) return `unattended policy ${s.headless}`
864 return s.mode === 'tell' ? `tell every agent: ${JSON.stringify(s.pausePrompt ?? '')}` : 'stop and ask you'
865}
866
867const field = (label: string, value: string): string => ` · ${label.padEnd(LABEL_WIDTH)}${value}`
868const fromText = (from: Source, env: string): string => (from === 'env' ? `from ${env}` : `from ${HOST.config}`)
869const spanFrom = (from: SpanSource, env: string): string => (from === 'unread' ? `spare10 could not read ${HOST.unreadSource}` : fromText(from, env))
870
871/** Skip 2.7: the `reserve opens` row, and the `weekly opens` row while the weekly window is watched. */
872function spanRows(s: StatusInput): string[] {
873 const sp = s.spans
874 if (sp === undefined) return []
875 const five =
876 sp.lastMinutes > 0
877 ? `in the last ${fmtPct(sp.lastMinutes)} min of the 5-hour window (${spanFrom(sp.lastMinutesFrom, 'SPARE10_LAST_MINUTES')})`
878 : `only at the reset (${spanFrom(sp.lastMinutesFrom, 'SPARE10_LAST_MINUTES')})`
879 const rows = [field('reserve opens', five)]
880 if (watchedWeekly(s) !== undefined) {
881 const week =
882 sp.weeklyLastHours > 0
883 ? `in the last ${fmtPct(sp.weeklyLastHours)} h of the weekly window (${spanFrom(sp.weeklyLastHoursFrom, 'SPARE10_WEEKLY_LAST_HOURS')})`
884 : `only at the reset (${spanFrom(sp.weeklyLastHoursFrom, 'SPARE10_WEEKLY_LAST_HOURS')})`
885 rows.push(field('weekly opens', week))
886 }
887 return rows
888}
889
890/** 2.7: the weekly rows, or the one off row, or nothing for a 0.1 input. */
891function weeklyRows(s: StatusInput): { reserve: string[]; reading: string[]; consent: string[] } {
892 const none = { reserve: [], reading: [], consent: [] }
893 if (s.weekly === undefined) return none
894 const w = watchedWeekly(s)
895 if (w === undefined) {
896 const from = s.weekly === 'off' ? 'option' : s.weekly.from
897 return { ...none, reserve: [field('weekly reserve', `off. spare10 does not watch the weekly window (${fromText(from, 'SPARE10_WEEKLY_RESERVE')})`)] }
898 }
899 const f = w.facts ?? factsOf(w.basis, w.reserve, s.timeZone, 'seven_day', s.now)
900 const consent = consentValue(
901 w.consentUntil === undefined ? undefined : clockText(w.consentUntil, 'seven_day', s.timeZone, s.now),
902 w.consentTo,
903 w.consentEnded,
904 )
905 return {
906 reserve: [field('weekly reserve', `${fmtPct(w.reserve)}% of the weekly window (${fromText(w.from, 'SPARE10_WEEKLY_RESERVE')})`)],
907 reading: [field('weekly reading', readingValue(w.basis, f, s.now, absentKind(s, 'seven_day'), 'seven_day'))],
908 consent: [field('weekly consent', consent)],
909 }
910}
911
912/** Floor 2.7: a consent row. Full: until the reset. To the floor: its end point or the reset. Ended: at its end point. */
913function consentValue(until: string | undefined, to: number | undefined, ended: boolean | undefined): string {
914 if (ended === true && to !== undefined) return `ended at ${fmtPct(to)}% used (you chose to continue until then)`
915 if (until === undefined) return 'none'
916 return to === undefined ? `until ${until} (you chose to continue)` : `until ${fmtPct(to)}% used or ${until} (you chose to continue)`
917}
918
919/** Floor 2.7: the floor is in force for a kind: an attended run, and 0 < floor < reserve. */
920const floorInForce = (floor: number, reserve: number, attended: boolean): boolean => attended && floor > 0 && floor < reserve
921
922/** Floor 2.7: the `resume floor` row, and the `weekly floor` row while the weekly window is watched. */
923function floorRows(s: StatusInput): string[] {
924 const fl = s.floors
925 if (fl === undefined) return []
926 const verb = s.mode === 'tell' ? 'spare10 tells the agents to wind down' : 'spare10 asks again'
927 const row = (floor: number, reserve: number, from: string, weekly: boolean): string => {
928 if (floor <= 0) return `off. A Resume lasts until the ${weekly ? 'weekly reset' : 'reset'} (${from})`
929 if (floor >= reserve) return `${fmtPct(floor)}% does nothing, because it is not below the ${weekly ? 'weekly reserve' : 'reserve'} (${from})`
930 if (!s.attended) return `${fmtPct(floor)}%: this run is unattended and never asks, so the floor does nothing (${from})`
931 return `${fmtPct(floor)}%: after a Resume, ${verb} at ${pointText(Math.round((100 - floor) * 10) / 10, weekly ? 'seven_day' : 'five_hour')} (${from})`
932 }
933 const rows = [field('resume floor', row(fl.resumeFloor, s.reserve, fromText(fl.resumeFloorFrom, 'SPARE10_RESUME_FLOOR'), false))]
934 const w = watchedWeekly(s)
935 if (w !== undefined) {
936 rows.push(field('weekly floor', row(fl.weeklyResumeFloor, w.reserve, fromText(fl.weeklyResumeFloorFrom, 'SPARE10_WEEKLY_RESUME_FLOOR'), true)))
937 }
938 return rows
939}
940
941/** Floor 2.7: the help line names the floor while a floor is in force for a watched kind. */
942function floorHelp(s: StatusInput): boolean {
943 const fl = s.floors
944 if (fl === undefined) return false
945 const w = watchedWeekly(s)
946 return floorInForce(fl.resumeFloor, s.reserve, s.attended) || (w !== undefined && floorInForce(fl.weeklyResumeFloor, w.reserve, s.attended))
947}
948
949// The warning of 2.7 when the reset clock does not run.
950const TICKER_WARNING = 'spare10 cannot check the reset in this session. Type a prompt to continue after the reset.'
951
952/**
953 * B22: what /spare10 prints. The engine puts `spare10: ` in front of the reply, so the first line
954 * reads `spare10: version {VERSION}` (a deviation from B22's `spare10 {VERSION}`, defect D1).
955 */
956export function statusReport(s: StatusInput): string {
957 const consent = consentValue(s.consentUntil === undefined ? undefined : formatClock(s.consentUntil, s.timeZone), s.consentTo, s.consentEnded)
958 const weekly = weeklyRows(s)
959 const atReset =
960 s.autoResume === undefined || !s.attended
961 ? []
962 : [field('at the reset', `${s.autoResume.on ? 'continue by itself' : 'wait for your answer'} (${fromText(s.autoResume.from, 'SPARE10_AUTO_RESUME')})`)]
963 // The pause at the quota limit shows only while it is off, so a default report stays as it was.
964 const atLimit = s.limitPause === undefined || s.limitPause.on ? [] : [field('at the limit', `off. spare10 does not pause at the limit (${fromText(s.limitPause.from, 'SPARE10_LIMIT_PAUSE')})`)]
965 const lines = [
966 `version ${VERSION}`,
967 '',
968 phaseLine(s),
969 field('reserve', `${fmtPct(s.reserve)}% of the 5-hour window (${fromText(s.reserveFrom, 'SPARE10_RESERVE')})`),
970 ...weekly.reserve,
971 ...spanRows(s),
972 ...floorRows(s),
973 field('at the reserve', actionValue(s)),
974 ...atReset,
975 ...atLimit,
976 field('reading', readingValue(s.basis, s.facts ?? factsOf(s.basis, s.reserve, s.timeZone), s.now, absentKind(s, 'five_hour'))),
977 ...weekly.reading,
978 field('consent', consent),
979 ...weekly.consent,
980 field('guarded', guardedValue(s)),
981 s.attended
982 ? field(HOST.child, `runs started here: ${s.childPolicy}`)
983 : field('unattended', `${s.headless} (${fromText(s.headlessFrom, 'SPARE10_HEADLESS')})`),
984 ...(s.extraRows ?? []).map(([label, value]) => field(label, value)),
985 ...(s.tickerStale === true ? [` ⚠ ${TICKER_WARNING}`] : []),
986 ...s.warnings.map((w) => ` ⚠ ${w}`),
987 '',
988 floorHelp(s)
989 ? `${`${HOST.command} resume`.padEnd(18)}continue on the reserve until the floor, or past the floor until the reset`
990 : `${`${HOST.command} resume`.padEnd(18)}continue on the reserve until the window resets`,
991 `${`${HOST.command} stop`.padEnd(18)}stop at the reserve now`,
992 ...(s.extraHelp ?? []),
993 ]
994 return lines.join('\n')
995}
996
997export type ReplyCase = 'asking' | 'stopped' | 'tripped' | 'consented' | 'below' | 'none' | 'off'
998
999// The replies of /spare10 (B23 to B26, 12.1, 2.8). The engine prefixes each reply with `spare10: `.
1000
1001/** {Rs} {is} open {quiet} of the open kinds, for the open replies (skip 2.8). */
1002const openText = (fs: readonly Facts[]): string =>
1003 fs.length === 0 ? 'the reserve is open' : `${yourReserves(fs)} ${isAre(fs)} open ${untilText(fs)}`
1004
1005/** Floor 2.8: one part of the consented reply: its end point, or its reset. */
1006const resumedPart = (f: Facts): string => {
1007 if (f.to !== undefined) return `until ${pointText(f.to, kindOf(f))}`
1008 return isWeekly(f) ? `${untilText(f)} on the weekly window` : untilText(f)
1009}
1010
1011/**
1012 * B23 ('tripped' is "tripped, not stopped"). 'overdue': a stop past its end that nobody released yet,
1013 * with the windows that reset and the open ones. 'open': no kind gates and some kind is open (`f`: the
1014 * open kinds). `absent` (Codex design 2.1): the kinds the host reports no window for. With five_hour in
1015 * it, a reply with no figures names no 5-hour reading. 'limit-late': a resume settles a limit question
1016 * whose reset has passed (it waited for the answer), so the reply names no time.
1017 */
1018export function resumeReply(
1019 c: ReplyCase | 'overdue' | 'open' | 'limit-asking' | 'limit' | 'limit-late',
1020 f?: Facts | readonly Facts[],
1021 named?: readonly Named[],
1022 open?: readonly Facts[],
1023 mode: Mode = 'hold',
1024 absent?: readonly Kind[],
1025): string {
1026 const noReading = absent?.includes('five_hour') === true ? 'nothing to resume. There is no reading yet.' : 'nothing to resume. There is no 5-hour reading yet.'
1027 const fs = f === undefined ? [] : listOf(f)
1028 const use = fs.length === 0 ? `the reserve ${UNTIL_RESET}` : useText(fs)
1029 const asks = asksPart(fs, mode)
1030 switch (c) {
1031 case 'asking':
1032 return `resumed. Held work continues on ${use}.${asks}`
1033 case 'stopped':
1034 return `resumed. You can use ${use}.${asks} Type a prompt to continue.`
1035 case 'overdue': {
1036 const ev = eventText(named ?? [], open ?? [])
1037 return ev === '' ? 'the stop is over. Type a prompt to continue.' : `${ev}, and the stop is over. Type a prompt to continue.`
1038 }
1039 case 'open':
1040 return `nothing to resume. The reset is near, so ${openText(fs)}.`
1041 case 'tripped':
1042 return `you can use ${use}.${asks}`
1043 case 'consented':
1044 if (fs.some((x) => x.to !== undefined)) return `already resumed ${fs.map(resumedPart).join(', and ')}.${asks}`
1045 return `already resumed ${fs.length === 0 ? UNTIL_RESET : untilText(fs)}.`
1046 case 'below':
1047 if (fs.length > 0) return `nothing to resume. ${personFacts(fs)}.`
1048 return noReading
1049 case 'none':
1050 return noReading
1051 case 'off':
1052 return 'this run is not guarded. Nothing changed.'
1053 case 'limit-asking':
1054 return notice.limitContinues(fs)
1055 case 'limit':
1056 return `nothing to resume now. ${limitHead(fs)} until ${whenOf(fs).at}. spare10 holds all work until then. ${LIMIT_OFF}`
1057 case 'limit-late':
1058 return 'resumed. Held work continues.'
1059 }
1060}
1061
1062/**
1063 * B24 ('tripped' covers consented). `auto`: the time the stop ends, {at} and for a skip owner {lead}.
1064 * `continues` (default true): autoResume is on, so spare10 continues the stopped work then.
1065 * `weeklyTrip`: the weekly window is watched. `ended`: the windows of a Stop here after the skip start
1066 * (B46). 'open' and 'overdue-open' take the open kinds as `f`. `absent` (Codex design 2.1): the kinds the
1067 * host reports no window for. With five_hour in it, 'below' and 'none' name no 5-hour trip point.
1068 */
1069export function stopReply(
1070 c:
1071 | Exclude<ReplyCase, 'consented'>
1072 | 'overdue'
1073 | 'overdue-open'
1074 | 'overdue-skip'
1075 | 'open'
1076 | 'asking-soon'
1077 | 'asking-open'
1078 | 'limit',
1079 f?: Facts | readonly Facts[],
1080 trip?: number,
1081 auto?: { at: string; lead?: string; continues?: boolean },
1082 weeklyTrip?: number,
1083 ended?: Ended,
1084 absent?: readonly Kind[],
1085): string {
1086 const fs = f === undefined ? [] : listOf(f)
1087 const ev = cap(eventText(ended?.reset ?? [], ended?.open ?? []))
1088 switch (c) {
1089 case 'asking':
1090 if (auto === undefined) return 'stopped. Held work is refused.'
1091 return auto.lead === undefined
1092 ? `stopped. Held work is refused. spare10 continues it after ${auto.at}.`
1093 : `stopped. Held work is refused. spare10 continues it at ${untilPhrase(auto)}.`
1094 case 'asking-soon':
1095 return `stopped. Held work is refused. ${ev}, so spare10 continues it soon, unless a reserve is still reached.`
1096 case 'asking-open':
1097 return `stopped. Held work is refused. ${ev}, so new work goes on with no question.`
1098 case 'tripped':
1099 if (auto === undefined) return `stopped at the reserve. ${AGAIN}`
1100 return auto.continues === false
1101 ? `stopped at the reserve until ${untilPhrase(auto)}. ${AGAIN}`
1102 : `stopped at the reserve until ${untilPhrase(auto)}. Then spare10 continues any stopped work. ${AGAIN}`
1103 case 'stopped':
1104 return auto === undefined ? 'already stopped.' : `already stopped until ${auto.at}.`
1105 case 'overdue':
1106 return 'the stop ended at the reset. spare10 will not continue the stopped work.'
1107 case 'overdue-open':
1108 return `the stop is over, because the reset is near. ${cap(openText(fs))}. spare10 will not continue the stopped work.`
1109 case 'overdue-skip':
1110 return 'the stop is over. spare10 will not continue the stopped work.'
1111 case 'open':
1112 return `nothing to stop. The reset is near, so ${openText(fs)}. To keep a reserve until the reset, ${HOST.keepOpen}.`
1113 case 'below':
1114 case 'none': {
1115 if (absent?.includes('five_hour') === true) return `nothing to stop. ${stepsIn(0, weeklyTrip === undefined ? undefined : 100 - weeklyTrip, true)}`
1116 const at = fmtPct(trip ?? 100 - ((f === undefined ? undefined : listOf(f)[0])?.reserve ?? 10))
1117 if (weeklyTrip === undefined) return `nothing to stop. spare10 steps in at ${at}% used.`
1118 return `nothing to stop. spare10 steps in at ${at}% used, or at ${fmtPct(weeklyTrip)}% used of the weekly window.`
1119 }
1120 case 'off':
1121 return 'this run is not guarded. Nothing changed.'
1122 case 'limit':
1123 return notice.limitStopped(auto?.at ?? 'the reset')
1124 }
1125}
1126
1127/** B25 */
1128export const notPerson = (verb: string): string => `only you can run ${HOST.command} ${verb}. Nothing changed.`
1129
1130/** B25 */
1131export const unknownVerb = (verb: string): string =>
1132 `unknown command "${verb}". Use ${HOST.command}, ${HOST.command} resume or ${HOST.command} stop.`
1133
1134/**
1135 * Section 12.1, 2.9. `opens` (skip 2.9), for a test reading at or above the trip point with a span:
1136 * when its reserve opens ({at} and {lead}), 'now' when it is open at once (`f.span` gives {span}), or
1137 * 'real' when the real reading beneath is in the reserve too and keeps the hold (B45). Floor 2.9:
1138 * 'raised' is a raise in place (B53). 'replaced' replaces a test reading of the kind: a new test (3.5),
1139 * so the consents of both windows and the stop are cleared. `pastFloor`: the floor of the kind when the
1140 * test reading is past it. `realIn`: the real reading beneath is in the reserve, so a Resume on the test
1141 * reading covers it.
1142 */
1143export function simulateReply(
1144 kind: 'set' | 'raised' | 'replaced' | 'off' | 'bad' | 'weekly-off',
1145 f?: Facts,
1146 opens?: { at: string; lead: string } | 'now' | 'real',
1147 pastFloor?: number,
1148 realIn?: boolean,
1149 limit?: boolean,
1150): string {
1151 if (kind === 'off') return 'test readings cleared. Your consents for both windows and any stop are cleared too.'
1152 if (kind === 'weekly-off') return 'the weekly reserve is 0, so spare10 does not watch the weekly window. Nothing changed.'
1153 if (kind === 'bad' || f === undefined) {
1154 return `${HOST.leadSimulate} takes a percentage from 0 to 100, or off. Add weekly for the weekly window, and in 22m for a test window that resets in 22 minutes.`
1155 }
1156 const weekly = isWeekly(f)
1157 const of = weekly ? ' of the weekly window' : ''
1158 let opensText = ''
1159 if (opens === 'real') {
1160 opensText = weekly
1161 ? ' The real weekly reading is also in the weekly reserve, so the weekly test window does not open it.'
1162 : ' The real reading is also in the reserve, so the test window does not open it.'
1163 } else if (opens === 'now') {
1164 opensText = weekly
1165 ? ` The weekly test window ends within ${spanText(f)}, so the weekly reserve is open at once.`
1166 : ` The test window ends within ${spanText(f)}, so the reserve is open at once.`
1167 } else if (opens !== undefined) {
1168 opensText = ` The ${weekly ? 'weekly reserve' : 'reserve'} opens at ${opens.at}, ${opens.lead}.`
1169 }
1170 // Floor 2.9: the test reading is past the floor, and a Resume on it covers the real reading beneath.
1171 const floorText = pastFloor === undefined ? '' : ` This is past your ${fmtPct(pastFloor)}% ${weekly ? 'weekly floor' : 'floor'}.`
1172 const realText = realIn === true ? ` A Resume on the test reading also lets real work use the ${weekly ? 'weekly reserve' : 'reserve'}.` : ''
1173 // The quota limit: all work holds until the test window ends. It takes the place of the other sentences.
1174 const limitText = limit === true ? ` This is the quota limit, so spare10 holds all work until the ${weekly ? 'weekly test window' : 'test window'} ends.` : ''
1175 const verb = kind === 'raised' ? 'raised' : 'set'
1176 const stays =
1177 kind === 'raised'
1178 ? ' Your earlier answers stay.'
1179 : kind === 'replaced'
1180 ? ' This starts a new test. Your consents for both windows and any stop are cleared.'
1181 : ''
1182 return `test reading ${verb} to ${fmtPct(f.used)}% used${of}, resets ${clockOf(f)}.${stays} It can only raise the real reading.${limitText === '' ? `${floorText}${opensText}${realText}` : limitText} Run ${HOST.command} simulate off to clear it.`
1183}
1184
1185/** A /spare10 that threw. */
1186export const commandFailed = (message: string): string => `${HOST.leadFailed} failed: ${message}`
1187hooks/core/badge.ts 57 lines1import type { Mode, Phase } from './decide.ts'
2import { fmtPct } from './text.ts'
3
4// The footer badge (design B21), one row per phase. No $ here.
5
6export type View = { text: string; color?: 'inactive' | 'success' | 'warning'; pulse: boolean }
7
8/** 'spare10', or 'spare10 (40%)' when the reserve is not 10, plus ' (test)' under a test reading. */
9export function badgeLabel(reserve: number, test: boolean): string {
10 const label = reserve === 10 ? 'spare10' : `spare10 (${fmtPct(reserve)}%)`
11 return test ? `${label} (test)` : label
12}
13
14/**
15 * The table in B21. Only the tripped row pulses, by swapping its glyph for one space. `until`: the
16 * clock at which a stop or an open question continues by itself, or, in the open row, the reset that
17 * ends the open reserve first (2.6), or, in the limit row, the time at which held work continues after
18 * the quota limit. `to` (floor 2.6): the end point now of the consent to the floor nearest its end, such
19 * as '95'. The consented row names it.
20 */
21export function badgeView(
22 phase: Phase,
23 i: { reserve: number; test: boolean; mode: Mode; blink: boolean; until?: string; to?: string },
24): View {
25 const label = badgeLabel(i.reserve, i.test)
26 const until = i.until === undefined ? '' : ` until ${i.until}`
27 switch (phase) {
28 case 'off':
29 return { text: `○ ${label} off`, color: 'inactive', pulse: false }
30 case 'blind':
31 return { text: '⚠ spare10 quota unavailable', pulse: false }
32 case 'waiting':
33 return { text: `⧗ ${label}`, color: 'inactive', pulse: false }
34 case 'armed':
35 return { text: `● ${label}`, color: 'success', pulse: false }
36 case 'limit':
37 return { text: `‖ ${label}: at the limit${until}`, color: 'warning', pulse: false }
38 case 'consented':
39 return { text: i.to === undefined ? `⨯ ${label}` : `⨯ ${label}: resumed until ${i.to}% used`, color: 'warning', pulse: false }
40 case 'open':
41 return { text: `↻ ${label}: reserve open${until}`, color: 'warning', pulse: false }
42 case 'stopped':
43 return { text: `■ ${label}: stopped${until}`, color: 'warning', pulse: false }
44 case 'asking':
45 return { text: `? ${label}: waiting for you${until}`, color: 'warning', pulse: false }
46 case 'told':
47 return { text: `⏸ ${label}`, color: 'warning', pulse: false }
48 case 'reserve':
49 return { text: `⚠ ${label}: in the reserve`, color: 'warning', pulse: false }
50 case 'tripped': {
51 const glyph = i.blink ? '⚠' : ' '
52 const what = i.mode === 'tell' ? 'Winding down at next step' : 'Pausing at next step'
53 return { text: `${glyph} ${what}`, color: 'warning', pulse: true }
54 }
55 }
56}
57hooks/core/flow.ts 1654 lines1import type { SessionRateLimit } from 'claude-code'
2import { floorOf, reserveOf, spanOf, watchedKinds } from './config.ts'
3import type { Effective, Spans } from './config.ts'
4import {
5 CHECK_MS,
6 answers,
7 answersQuestion,
8 coveringConsent,
9 decide,
10 endOf,
11 endedFloor,
12 extendedReal,
13 floorEnded,
14 heldPast,
15 holdsPast,
16 joinReal,
17 keyStage,
18 mergeStopped,
19 parseConsent,
20 phaseOf,
21 skipTag,
22 stageKey,
23} from './decide.ts'
24import type { Answered, Consent, Holder, Mode, Phase, Site, StoppedRecord, Tomb, Verdict, Viewed } from './decide.ts'
25import {
26 FALLBACK_MS,
27 KINDS,
28 LIMIT_PCT,
29 atLimit,
30 atPoint,
31 basis,
32 holdEndOf,
33 inResetMargin,
34 inWindow,
35 isTripped,
36 marginOf,
37 parseReset,
38 pctOf,
39 pointOf,
40 skipStartOf,
41 viewOf,
42 windowMs,
43} from './reading.ts'
44import type { Anchored, Basis, Kind, Memory, TestSpec } from './reading.ts'
45import {
46 HELD_WAITS,
47 atText,
48 clockText,
49 consentWarning,
50 debugLine,
51 factsOf,
52 fmtPct,
53 headlessText,
54 leadText,
55 notStarted,
56 notice,
57 pauseInstruction,
58 pausedText,
59 resumeReply,
60 simulateReply,
61 stopReply,
62 stopText,
63 untilFor,
64 untilPhrase,
65 whenOf,
66} from './text.ts'
67import type { Ended, Facts, Named, StatusInput } from './text.ts'
68
69// The host-free steps of the gate (Codex design A14, 7.1), lifted from register.tsx without a change of
70// logic. register.tsx and the Codex adapter both call them. No host here, and no module state: every
71// function takes its state as arguments, and some update the objects they are given.
72
73// ---- The sense of each kind (register.tsx 5.2) ----
74
75/** One watched kind at now, as the gate sees it. */
76export type KindSense = {
77 kind: Kind
78 reserve: number
79 basis: Basis // the view basis: the real one when a test reading yields to a real trip (B45)
80 tripped: boolean
81 windowEnd: number // the consent bound
82 holdEnd: number // the skip start while it is ahead, else the D0.2 hold end (B42)
83 stopEnd: number // the end of a stop with autoResume off: the skip start while it is ahead, else windowEnd
84 span: number // ms, 0 is off
85 skipAt: number | null // the skip start while it is ahead
86 open: boolean // tripped and in its skip window (B41)
87 test: boolean
88 seed: boolean
89 realIn: boolean // TS1: the real reading, beneath any test reading, is tripped and not open
90 realReset: number | null // TS1: the reset of the real reading, null when unknown
91 floor: number // B48: the floor in force in %, 0 when none (also for an unattended run, B55)
92 point: number | null // B48: pointOf(floor), null when floor is 0
93 atFloor: boolean // B48: tripped, not open, and the view reading at or past point
94 realPct: number | undefined // TS1, B52: the pct of the real basis
95 limit: boolean // at the quota limit with limitPause on (and on Codex no credits that pay): it gates in every state
96}
97
98/** One sense: the settings, the time, every watched kind, and the attendance. */
99export type Sensed = { cfg: Effective; now: number; kinds: KindSense[]; tripped: boolean; attended: boolean }
100
101/** A decision: the verdict, and what it rests on. `holders`: TS1. */
102export type Acted = { verdict: Verdict; stopped: boolean; gating: KindSense[]; holders: Holder[] }
103
104/** The basis of each kind with the test reading, and without it (B45). */
105export type Bases = Record<Kind, { basis: Basis; real: Basis }>
106
107/** R11: one fallback window end per kind and episode. `windowEndFor` keeps it. */
108export type FallbackEnds = Partial<Record<Kind, number>>
109
110/** What the host remembers of each kind (reading.ts Memory). */
111export type Mems = Readonly<Record<Kind, Memory>>
112
113/** The window end that bounds consent and stopped. Without resetsAt, one fallback per kind and episode (R11). */
114export function windowEndFor(kind: Kind, b: Basis, now: number, fallback: FallbackEnds): number {
115 if (b.kind !== 'none' && b.resetsAtMs !== null) {
116 delete fallback[kind]
117 return b.resetsAtMs
118 }
119 const f = fallback[kind]
120 if (f !== undefined && now < f) return f
121 fallback[kind] = now + FALLBACK_MS
122 return now + FALLBACK_MS
123}
124
125/**
126 * A basis is at the quota limit for the gate: 100% used or more with a known reset (`atLimit`), limitPause
127 * on, and on Codex no credits that pay past 100% (`paid`, A23).
128 */
129export const limitOf = (cfg: Pick<Effective, 'limitPause'>, b: Basis, paid = false): boolean => cfg.limitPause && !paid && atLimit(b)
130
131/**
132 * Every watched kind at now. `edges`: the skip starts of the tripped kinds, and the reset of a kind at the
133 * quota limit, the times at which the badge can change (skip 4.1). `watched` defaults to the kinds of the
134 * settings. `o.paid` (Codex A23): credits pay past 100%, so no kind is at the quota limit. A kind at the
135 * quota limit (`limitOf`) is tripped on its view basis with the test reading, and never open: its hold
136 * end, stop end and consent bound are its reset. So a test reading at 100% never yields to a real one (B45).
137 */
138export function sensesOf(
139 cfg: Effective,
140 bases: Bases,
141 spans: Spans,
142 now: number,
143 fallback: FallbackEnds,
144 mems: Mems,
145 watched: readonly Kind[] = watchedKinds(cfg),
146 o: { paid?: boolean } = {},
147): { kinds: KindSense[]; edges: number[] } {
148 const edges: number[] = []
149 const paid = o.paid === true
150 const kinds = watched.map((kind): KindSense => {
151 const reserve = reserveOf(cfg, kind)
152 const span = spanOf(spans, kind)
153 const real = bases[kind].real
154 const withTest = bases[kind].basis
155 const limit = limitOf(cfg, withTest, paid)
156 const v = limit ? { basis: withTest, tripped: true, skipAt: null, open: false } : viewOf(real, withTest, reserve, span, now) // B41, B45
157 const rv = viewOf(real, real, reserve, span, now) // TS1: the real reading alone
158 const b = v.basis
159 const windowEnd = windowEndFor(kind, b, now, fallback)
160 if (v.tripped && v.skipAt !== null) edges.push(v.skipAt)
161 if (limit && b.kind !== 'none' && b.resetsAtMs !== null) edges.push(b.resetsAtMs)
162 const floor = floorOf(cfg, kind) // B48
163 const point = floor > 0 ? pointOf(floor) : null
164 return {
165 kind,
166 reserve,
167 basis: b,
168 tripped: v.tripped,
169 windowEnd,
170 holdEnd: v.skipAt ?? holdEndOf(b, mems[kind], now, kind),
171 stopEnd: v.skipAt ?? windowEnd,
172 span,
173 skipAt: v.skipAt,
174 open: v.open,
175 test: b.kind === 'test',
176 seed: b.kind === 'seed',
177 realIn: rv.tripped && (!rv.open || limitOf(cfg, real, paid)), // a real reading at the limit is never open
178 realReset: real.kind === 'none' ? null : real.resetsAtMs,
179 floor,
180 point,
181 atFloor: !limit && v.tripped && !v.open && atPoint(b, point), // the limit question replaces the second question
182 realPct: real.kind === 'none' ? undefined : real.pct,
183 limit,
184 }
185 })
186 return { kinds, edges }
187}
188
189/** B55: an unattended run never asks, so it has no floor in force: no stage and no floor names. */
190export const noFloor = (k: KindSense): KindSense => ({ ...k, floor: 0, point: null, atFloor: false })
191
192/** B48: the end point of a Resume on a kind: its floor point when it is at the reserve with a floor in force. A kind at the limit has no tier. */
193export const resumeTo = (k: KindSense): number | undefined =>
194 k.tripped && !k.open && !k.atFloor && !k.limit && k.point !== null ? k.point : undefined
195
196/** A question's end of one kind: the consent bound, the test flag, the skip start and the tier (B50). */
197export type QuestionEnd = { end: number; test: boolean; skipAt?: number; to?: number } // to: the tier of a kind asked at the reserve (B50)
198
199/** A consent of a question's kind: its bound, and its end point when it was asked at the reserve (B49). */
200export const consentOfEnd = (end: QuestionEnd): Consent => ({ until: end.end, ...(end.to === undefined ? {} : { to: end.to }) })
201
202/** TS1: a kind whose real reading gates now, as heldPast reads it. */
203export const realHolder = (k: KindSense): Holder => ({ kind: k.kind, resetsAtMs: k.realReset })
204
205/**
206 * TS1: a command's view of the kinds whose real reading gates now. A command never waits on a consent
207 * read, so a consented kind counts too: the stop is then kept, the safe side.
208 */
209export const commandHolders = (kinds: readonly KindSense[]): Holder[] => kinds.filter((k) => k.realIn).map(realHolder)
210
211/**
212 * B50: a kind as the gate sees it: its view percentage, basis and window. A reading with no reset time
213 * has no known window: its fallback end is no window of its own (R11).
214 */
215export const viewedOf = (k: KindSense): Viewed => ({
216 kind: k.kind,
217 pct: pctOf(k.basis) ?? 0,
218 test: k.test,
219 ...(k.basis.kind === 'none' || k.basis.resetsAtMs === null ? {} : { end: k.windowEnd }),
220 ...(k.limit ? { limit: true } : {}),
221})
222
223/** The consent bound of a kind's real reading: the view window, or beneath a test reading the real reset (TS1). */
224export const realBound = (k: KindSense, now: number): number => (k.test ? (k.realReset ?? now + FALLBACK_MS) : k.windowEnd)
225
226/** Hold mode, or tell mode with a pause prompt. */
227export const modeOf = (cfg: Pick<Effective, 'pausePrompt'>): Mode => (cfg.pausePrompt === null ? 'hold' : 'tell')
228
229/**
230 * A test reading of a kind: `in` from now, else the live reset while it is ahead and within one window,
231 * else one window from now. A live reading keeps its reset after the window resets, until the next
232 * response: such a reset makes a test reading that basis() drops at once.
233 */
234export function testReading(pct: number, kind: Kind, live: SessionRateLimit | undefined, now: number, inMs?: number): Anchored {
235 const liveReset = live === undefined ? null : parseReset(live.resetsAt)
236 const borrow = liveReset !== null && inWindow({ pct, resetsAtMs: liveReset }, now, kind) ? liveReset : null
237 const resetsAtMs = inMs !== undefined ? now + inMs : (borrow ?? now + windowMs(kind))
238 return { pct, resetsAtMs }
239}
240
241/**
242 * 4.8: a watched kind whose last real reading was in the reserve reset less than the 5-minute margin
243 * ago. A release waits for it: the 60 s margin of a test window never applies at a real reset.
244 */
245export const resetTooRecent = (s: Pick<Sensed, 'kinds' | 'now'>, mems: Mems): boolean =>
246 s.kinds.some((k) => inResetMargin(mems[k.kind].seed, k.reserve, s.now))
247
248
249// ---- Facts and texts (register.tsx 5.2, 5.4) ----
250
251/**
252 * The figures of some kinds, five_hour first. A hold end that is not the reset (a reading without a
253 * reset time, or a skip start ahead) rides along for {at}. `owner`: a skip owner, whose kinds with a
254 * skip start ahead carry their span for {lead} (skip 2.1). A kind at the floor carries its floor, so
255 * the texts name it (B48). `toOf`: the end point of the consent that the text describes (floor 6.6).
256 */
257export function factsFrom(ks: readonly KindSense[], now: number, owner = false, toOf?: (k: KindSense) => number | undefined): Facts[] {
258 return ks.map((k) => {
259 const f = factsOf(k.basis, k.reserve, undefined, k.kind, now)
260 const reset = k.basis.kind === 'none' ? undefined : k.basis.resetsAtMs
261 const to = toOf?.(k)
262 return {
263 ...f,
264 ...(reset !== undefined && k.holdEnd !== reset ? { holdEnd: k.holdEnd } : {}),
265 ...(k.test ? { test: true } : {}),
266 ...(owner && k.skipAt !== null ? { span: k.span } : {}),
267 ...(k.atFloor ? { floor: k.floor } : {}),
268 ...(to === undefined ? {} : { to }),
269 ...(k.limit ? { limit: true } : {}),
270 }
271 })
272}
273
274/** Floor 6.6: the end point now of a covering consent to the floor, for the texts of a consented kind. */
275export const consentEnd =
276 (consents: ReadonlyArray<{ k: KindSense; c: Consent }>) =>
277 (k: KindSense): number | undefined => {
278 const c = consents.find((x) => x.k.kind === k.kind)?.c
279 return c?.to === undefined ? undefined : endOf(c.to, k.point)
280 }
281
282/** The kinds a text names: the gating kinds, else the tripped ones. */
283export const namedKinds = (s: Pick<Sensed, 'kinds'>, a: Pick<Acted, 'gating'>): KindSense[] =>
284 a.gating.length > 0 ? a.gating : s.kinds.filter((k) => k.tripped)
285
286/** The model text of a refused tool or step. `sessionId`: the id that the headless text names. */
287export function refusalText(kind: 'stop' | 'paused' | 'headless', s: Pick<Sensed, 'kinds' | 'now'>, a: Pick<Acted, 'gating'>, sessionId: string): string {
288 const f = factsFrom(namedKinds(s, a), s.now)
289 if (kind === 'stop') return stopText(f)
290 if (kind === 'paused') return pausedText(f)
291 return headlessText(f, sessionId)
292}
293
294/** The pause instruction of tell mode (5). */
295export const tellText = (s: Pick<Sensed, 'kinds' | 'now' | 'cfg'>, a: Pick<Acted, 'gating'>): string =>
296 pauseInstruction(factsFrom(namedKinds(s, a), s.now), s.cfg.pausePrompt)
297
298/** The drop reason of a person prompt that spare10 did not start. */
299export const notStartedFor = (s: Pick<Sensed, 'kinds' | 'now'>, a: Pick<Acted, 'gating'>): string => notStarted(factsFrom(namedKinds(s, a), s.now))
300
301/**
302 * The open question of a session, as the core builds and reads it (4.3, 4.5, 4.6, 5.6). A host adds its
303 * own fields: the waiter count and the check flag.
304 */
305export type QuestionCore = {
306 kinds: Kind[] // the gating kinds when it opened, five_hour first
307 ends: Partial<Record<Kind, QuestionEnd>> // consent bound, test flag, skip start and end point per kind
308 latestEnd: number // the latest consent bound (the B6 note of a question that is not a skip owner)
309 real: Holder[] // TS1: its kinds whose real reading gated when it opened, with their resets, for a Stop here whose sense fails
310 stopEnd: number // the latest stop end of its kinds: the until of a Stop here with autoResume off
311 holdEnd: number // the latest hold end of its kinds
312 due: number // the latest hold end plus margin of its kinds
313 skip: boolean // a skip owner: its hold end is a skip start, and no kind of it is due later (B42)
314 noteAt: number // autoResume off: the time of the one note. B43 can move it later
315 nextCheck: number // the next check before the due time
316 silent: boolean // an unattended wait hold: no dialog, never raised
317 auto: boolean // autoResume when it opened: the text only
318 loops: number // tool and step waiters that joined (a Stop with loops has work)
319 since: number
320 mode: Mode
321 opener: 'loop' | 'prompt'
322 facts: Facts[]
323 handoffs: number
324 noted: boolean
325 limit?: boolean // a limit question: its kinds are at the quota limit. Optional, so a 0.3 question file still reads
326 chosen?: boolean // a limit question after Continue at the reset: no dialog again, released after its reset
327}
328
329/** The kinds of a question, with their basis, for {reset}. */
330export const namedOf = (q: Pick<QuestionCore, 'kinds' | 'ends'>): Named[] => q.kinds.map((kind) => ({ kind, test: q.ends[kind]?.test === true }))
331
332/**
333 * B50: what a Resume of this question answers per kind: its basis, its end point when asked at the
334 * reserve, and its consent bound, so a later window is not answered.
335 */
336export const answeredOf = (q: Pick<QuestionCore, 'kinds' | 'ends'> | undefined): Answered[] =>
337 q === undefined
338 ? []
339 : q.kinds.map((kind) => {
340 const end = q.ends[kind]
341 return { kind, test: end?.test === true, ...(end?.to === undefined ? {} : { to: end.to }), ...(end === undefined ? {} : { end: end.end }) }
342 })
343
344/** The kinds of a stop, with its basis, for {reset}. A 0.1 value names the 5-hour window. */
345export const namedStop = (r: StoppedRecord): Named[] => (r.kinds ?? ['five_hour']).map((kind) => ({ kind, test: r.test === true }))
346
347/** Facts in KINDS order. */
348export const byKind = (fs: readonly Facts[]): Facts[] =>
349 [...fs].sort((a, b) => KINDS.indexOf(a.kind ?? 'five_hour') - KINDS.indexOf(b.kind ?? 'five_hour'))
350
351// ---- The consent split and the holders (register.tsx 5.2, floor 4.2, TS1) ----
352
353/** A consent of a kind and where it comes from. `raw`: the stored text, for a compare-and-set (B52). */
354export type Sourced = { c: Consent; from: 'slot' | 'test' | 'env'; raw?: string }
355
356/** Skip 3.3, floor 4.2: the tripped kinds with a consent that applies, those that gate and those that are open. */
357export type Split = { gating: KindSense[]; open: KindSense[]; consented: Array<{ k: KindSense; c: Consent }> }
358
359/** A split with no kind in it. */
360export const emptySplit = (): Split => ({ gating: [], open: [], consented: [] })
361
362/**
363 * B52: the consents to the floor of a kind whose own basis has reached their end point end for good. A
364 * real consent ends by the real reading, a test consent by the test reading. `unset`: the entries that
365 * ended, which the host removes (a test or slot entry at once, a stored value by compare-and-set).
366 * `tombs`: what to bury, in order: each ended real consent, and with `failed` (the stored read failed,
367 * so `list` has only the slots) the window whose end point the real reading reached. Call it only for
368 * a tripped kind that is not open.
369 */
370export function floorEndsOf(k: KindSense, list: readonly Sourced[], now: number, failed = false): { unset: Sourced[]; tombs: Tomb[] } {
371 const unset: Sourced[] = []
372 const tombs: Tomb[] = []
373 for (const e of list) {
374 if (e.c.to === undefined) continue
375 const test = e.from === 'test'
376 const pct = test ? pctOf(k.basis) : k.realPct
377 const bound = test ? k.windowEnd : realBound(k, now)
378 if (pct === undefined || !floorEnded(e.c, now, bound, pct, k.point)) continue
379 unset.push(e)
380 if (!test) tombs.push({ until: e.c.until, to: e.c.to }) // a test consent is never stored, so no tomb
381 }
382 if (failed && k.realPct !== undefined) tombs.push({ until: realBound(k, now), to: k.realPct })
383 return { unset, tombs }
384}
385
386/**
387 * One tripped kind into the split. An open kind whose only covering consent is a consent to the floor
388 * is open (floor 1.3 item 7). `failed`: the consent read failed, and an unreadable consent is not consent.
389 * A kind at the quota limit always gates: its consent is kept, but it does not apply.
390 */
391export function splitKind(out: Split, k: KindSense, list: readonly Sourced[], failed: boolean, now: number): void {
392 if (k.limit) {
393 out.gating.push(k) // at the quota limit no consent applies and the kind is never open
394 return
395 }
396 const use = failed ? [] : list
397 const c = coveringConsent(
398 use.map((e) => e.c),
399 now,
400 k.windowEnd,
401 pctOf(k.basis) ?? 0,
402 k.point,
403 )
404 if (c !== undefined && !(k.open && c.to !== undefined)) {
405 out.consented.push({ k, c })
406 return
407 }
408 if (k.open) out.open.push(k)
409 else out.gating.push(k)
410}
411
412/**
413 * Skip 3.3: the tripped kinds, split. `lists` gives the consents of each tripped kind, in KINDS order,
414 * once. A host ends the floor consents of a kind that is not open inside it (`floorEndsOf`), before the
415 * kind is classified, as register.tsx does.
416 */
417export function splitFrom(kinds: readonly KindSense[], lists: (k: KindSense) => { list: readonly Sourced[]; failed: boolean }, now: number): Split {
418 const out = emptySplit()
419 for (const k of kinds) {
420 if (!k.tripped) continue
421 const r = lists(k)
422 splitKind(out, k, r.list, r.failed, now)
423 }
424 return out
425}
426
427/** TS1: a kind whose real consent `holdersFrom` reads: its real reading gates beneath a test reading. */
428export const needsRealList = (k: KindSense): boolean => k.realIn && k.test
429
430/**
431 * TS1: the kinds whose real reading gates now. A kind whose view is its real reading gates as the view
432 * says, so it is in `gating`. Beneath a test reading, the real reading gates when it is tripped, not open
433 * and no real consent applies on the real reading (B49). `realLists` gives the real consents of each
434 * kind that `needsRealList` names, never a Resume on the test reading (3.5). A real reading without a
435 * reset time takes the one-hour bound.
436 */
437export function holdersFrom(
438 kinds: readonly KindSense[],
439 gating: readonly KindSense[],
440 realLists: (k: KindSense) => readonly Sourced[],
441 now: number,
442): Holder[] {
443 const out: Holder[] = []
444 for (const k of kinds) {
445 if (!k.realIn) continue
446 if (!k.test) {
447 if (gating.some((g) => g.kind === k.kind)) out.push(realHolder(k))
448 continue
449 }
450 const list = realLists(k)
451 const bound = k.realReset ?? now + FALLBACK_MS
452 const c = coveringConsent(
453 list.map((e) => e.c),
454 now,
455 bound,
456 k.realPct ?? 0,
457 k.point,
458 )
459 if (c === undefined) out.push(realHolder(k))
460 }
461 return out
462}
463
464// ---- The verdict and the told keys (register.tsx 5.2, 5, B38, B50, B51) ----
465
466/**
467 * B38, B50: the round after a Resume leaves out the kinds it answered, on its basis, in its window and
468 * below its end point. `jitter`: Codex passes RESET_JITTER_MS (A22, `answers`). Claude passes none.
469 */
470export const unansweredGating = (resumed: readonly Answered[], gating: readonly KindSense[], jitter = 0): KindSense[] =>
471 gating.filter((k) => !answers(resumed, viewedOf(k), jitter))
472
473/** B50: the holders that a Resume did not answer. A kind the sense does not list reads as 0% on the real basis. `jitter`: as above. */
474export function unansweredHolders(s: Pick<Sensed, 'kinds'>, resumed: readonly Answered[], holders: readonly Holder[], jitter = 0): Holder[] {
475 const viewOfKind = (kind: Kind): Viewed => {
476 const k = s.kinds.find((x) => x.kind === kind)
477 return k === undefined ? { kind, pct: 0, test: false } : viewedOf(k)
478 }
479 return holders.filter((h) => !answers(resumed, viewOfKind(h.kind), jitter))
480}
481
482/** Decide row 3: tripped, and no kind gates. */
483export const consentedOf = (s: Pick<Sensed, 'cfg' | 'tripped'>, gating: readonly KindSense[]): boolean =>
484 s.cfg.enabled && s.tripped && gating.length === 0
485
486/** Whether the stop matters to the verdict: guarded, attended and not consented. Else it is not read. */
487export const checksStop = (s: Pick<Sensed, 'cfg' | 'tripped' | 'attended'>, gating: readonly KindSense[]): boolean =>
488 s.cfg.enabled && s.attended && !consentedOf(s, gating)
489
490/** The told keys of each kind in its window (5, B51). */
491export type Told = Record<Kind, { windowEnd: number; keys: Set<string> }>
492
493/** Told sets with no key. */
494export const newTold = (): Told => ({ five_hour: { windowEnd: 0, keys: new Set<string>() }, seven_day: { windowEnd: 0, keys: new Set<string>() } })
495
496/**
497 * B51: a told window end is the window of `k`. `jitter`: Codex passes RESET_JITTER_MS, because its
498 * `resets_at` moves a little from one reading to the next (3.6). A test reading keeps its end exactly.
499 * Claude passes none, so the ends must be equal.
500 */
501export const toldWindow = (end: number, k: Pick<KindSense, 'windowEnd' | 'test'>, jitter = 0): boolean =>
502 end === k.windowEnd || (jitter > 0 && !k.test && Math.abs(end - k.windowEnd) <= jitter)
503
504/** B51: a loop's told key carries the stage of the kind: a second tell at the floor. */
505export function toldHas(told: Told, k: KindSense, key: string, jitter = 0): boolean {
506 const t = told[k.kind]
507 return toldWindow(t.windowEnd, k, jitter) && t.keys.has(stageKey(key, k.atFloor))
508}
509
510/** Decide `mainTold`: every gating kind told the main loop of this session at its stage. */
511export const toldMainOf = (told: Told, gating: readonly KindSense[], sessionId: string, jitter = 0): boolean =>
512 gating.length > 0 && gating.every((k) => toldHas(told, k, `${sessionId}:main`, jitter))
513
514/**
515 * The verdict (5.2) from what the host read: the gating kinds and the holders, both already without the
516 * kinds a Resume answered (`unansweredGating`, `unansweredHolders`), whether a stop applies (read only
517 * when `checksStop`), and `toldMain`. The host may still turn a hold or a refusal into a pass (the
518 * engine fork rule G8 of the Claude host).
519 */
520export function verdictOf(i: {
521 s: Sensed
522 site: Site
523 person: boolean
524 gating: KindSense[]
525 holders: Holder[]
526 stopped: boolean
527 toldMain: boolean
528}): Acted {
529 const { s, gating } = i
530 const verdict = decide({
531 site: i.site,
532 tripped: s.tripped,
533 enabled: s.cfg.enabled,
534 consented: consentedOf(s, gating),
535 attended: s.attended,
536 headless: s.cfg.headless,
537 mode: modeOf(s.cfg),
538 person: i.person,
539 stopped: i.stopped,
540 mainTold: i.toldMain,
541 seedOnly: gating.length > 0 && gating.every((k) => k.seed),
542 limit: gating.some((k) => k.limit),
543 })
544 return { verdict, stopped: i.stopped, gating, holders: i.holders }
545}
546
547/**
548 * A loop is told when some gating kind lacks its key of the kind's stage (B51). The claim adds the key
549 * of its stage to every gating kind. So each loop is told once at the reserve and once at the floor. A
550 * kind in its told window (`toldWindow`) keeps the stored end, so a reset that moves never drifts.
551 */
552export function claimTold(told: Told, gating: readonly KindSense[], key: string, jitter = 0): boolean {
553 let fresh = false
554 for (const k of gating) {
555 if (!toldWindow(told[k.kind].windowEnd, k, jitter)) told[k.kind] = { windowEnd: k.windowEnd, keys: new Set() }
556 const staged = stageKey(key, k.atFloor)
557 if (told[k.kind].keys.has(staged)) continue
558 told[k.kind].keys.add(staged)
559 fresh = true
560 }
561 return fresh
562}
563
564/** B51: the mark of the B12 notice per kind: its window and stage. */
565export const toldMark = (k: KindSense): string => `${k.windowEnd}:${k.atFloor ? 'floor' : 'reserve'}`
566
567/**
568 * B12, B51: the told notice, once per kind, window (`toldWindow`) and stage. It updates `marks`, and
569 * keeps the mark of a kind already shown. Undefined: shown already. A mark that is not text is not shown.
570 */
571export function toldNotice(s: Pick<Sensed, 'kinds' | 'now'>, a: Pick<Acted, 'gating'>, marks: Record<Kind, string>, jitter = 0): string | undefined {
572 const ks = namedKinds(s, a)
573 const shown = (k: KindSense): boolean => {
574 const m: unknown = marks[k.kind]
575 if (typeof m !== 'string') return false
576 const mine = toldMark(k)
577 if (m === mine) return true
578 const cut = m.lastIndexOf(':')
579 const end = Number(m.slice(0, cut))
580 return cut > 0 && m.slice(cut) === mine.slice(mine.lastIndexOf(':')) && Number.isFinite(end) && toldWindow(end, k, jitter)
581 }
582 const fresh = ks.filter((k) => !shown(k))
583 if (fresh.length === 0) return undefined
584 for (const k of fresh) marks[k.kind] = toldMark(k)
585 return notice.told(factsFrom(ks, s.now))
586}
587
588/** The window end of the last B15 debug line per kind, in the reserve and open (skip 2.5). */
589export type UnattendedMarks = { reserve: Record<Kind, number>; open: Record<Kind, number> }
590
591/** B15, skip 2.5: the debug lines of an unattended run, once per kind and window (`toldWindow`). It updates `marks`. */
592export function unattendedLines(s: Pick<Sensed, 'kinds' | 'now' | 'cfg'>, marks: UnattendedMarks, jitter = 0): string[] {
593 const out: string[] = []
594 const fresh = s.kinds.filter((k) => k.tripped && !k.open && !toldWindow(marks.reserve[k.kind], k, jitter))
595 if (fresh.length > 0) {
596 for (const k of fresh) marks.reserve[k.kind] = k.windowEnd
597 out.push(debugLine.unattended(factsFrom(fresh, s.now), s.cfg.headless)) // R9: every policy
598 }
599 const opened = s.kinds.filter((k) => k.open && !toldWindow(marks.open[k.kind], k, jitter))
600 if (opened.length > 0) {
601 for (const k of opened) marks.open[k.kind] = k.windowEnd
602 out.push(debugLine.unattendedOpen(factsFrom(opened, s.now))) // skip 2.5: every policy lets it through
603 }
604 return out
605}
606
607// ---- The question (register.tsx 4.3, 4.5, 4.6, 5.6) ----
608
609/** A kind's hold end plus this is when it may be released: 0 at a skip start (B42), else the reset margin (4.8). */
610export const dueMargin = (k: KindSense): number => marginOf(k.skipAt !== null, k.test)
611
612/**
613 * A new question on the kinds that `namedKinds` gives: per kind its consent bound, test flag, skip start
614 * and tier (B48, B50), and the ends, the due time, the skip owner and the note time of them all. When a
615 * kind of them is at the quota limit, it is a limit question on those kinds only, in hold mode also with a
616 * pause prompt: it holds until their latest reset plus the margin, with no skip start and no tier.
617 */
618export function questionOf(opener: 'loop' | 'prompt', s: Sensed, a: Pick<Acted, 'gating' | 'holders'>, now: number): QuestionCore {
619 const g0 = namedKinds(s, a)
620 const limit = g0.some((k) => k.limit)
621 const g = limit ? g0.filter((k) => k.limit) : g0
622 const ends: QuestionCore['ends'] = {}
623 for (const k of g) {
624 const to = resumeTo(k) // B48, B50: the tier of the question per kind
625 ends[k.kind] = { end: k.windowEnd, test: k.test, ...(k.skipAt === null ? {} : { skipAt: k.skipAt }), ...(to === undefined ? {} : { to }) }
626 }
627 const latestEnd = Math.max(...g.map((k) => k.windowEnd))
628 const real = a.holders.filter((h) => g.some((k) => k.kind === h.kind))
629 const holdEnd = Math.max(...g.map((k) => k.holdEnd))
630 const due = Math.max(...g.map((k) => k.holdEnd + dueMargin(k)))
631 // B42: a skip owner only when a skip start is the hold end, and no kind of it is due later.
632 const skip = g.some((k) => k.skipAt === holdEnd) && due === holdEnd
633 return {
634 kinds: g.map((k) => k.kind),
635 ends,
636 latestEnd,
637 real,
638 stopEnd: Math.max(...g.map((k) => k.stopEnd)),
639 holdEnd,
640 due,
641 skip,
642 noteAt: skip ? holdEnd : latestEnd,
643 nextCheck: now + CHECK_MS,
644 silent: !s.attended,
645 auto: s.cfg.autoResume,
646 loops: opener === 'loop' ? 1 : 0,
647 since: now,
648 mode: limit ? 'hold' : modeOf(s.cfg),
649 opener,
650 facts: factsFrom(g, now, skip, resumeTo),
651 handoffs: 0,
652 noted: false,
653 ...(limit ? { limit: true } : {}),
654 }
655}
656
657/** A question that raises no dialog: an unattended wait hold, or a limit question after Continue at the reset. */
658export const quietOf = (q: Pick<QuestionCore, 'silent' | 'chosen'>): boolean => q.silent || q.chosen === true
659
660/**
661 * An open question that is not a limit question gives way when a kind at the quota limit gates now. A
662 * silent question (an unattended wait) never does: it asks nobody, and holds until its due time anyway.
663 */
664export const supersedes = (q: Pick<QuestionCore, 'limit' | 'silent'>, gating: readonly KindSense[]): boolean =>
665 q.limit !== true && !q.silent && gating.some((k) => k.limit)
666
667/**
668 * A Stop here at the quota limit: any Stop here on the limit question but the hold time limit, or a
669 * person's own Stop here (the dialog or the stop command) on another question while a kind at the quota
670 * limit gates now (`sNow`). It lasts until the reset and never continues by itself. A question at the
671 * reserve that ends with no answer keeps the setting in force, as the limit question does.
672 */
673export const stopsAtLimit = (q: Pick<QuestionCore, 'limit'>, via: Via, sNow?: Pick<StopSense, 'split'>): boolean =>
674 via !== 'time limit' && (q.limit === true || ((via === 'dialog' || via === 'command') && sNow?.split.gating.some((k) => k.limit) === true))
675
676/** Stop here at the limit never continues by itself (`stopsAtLimit`). Else the auto stop of the setting in force. */
677export const stopAutoOf = (q: Pick<QuestionCore, 'limit'>, via: Via, auto: boolean, sNow?: Pick<StopSense, 'split'>): boolean =>
678 stopsAtLimit(q, via, sNow) ? false : auto
679
680/**
681 * The line of Continue at the reset, while the reset is still ahead. After the reset the release line
682 * follows at once, so a line that names a passed time would only confuse.
683 */
684export const continuesLine = (q: Pick<QuestionCore, 'facts' | 'holdEnd'>, now: number): string | undefined =>
685 now < q.holdEnd ? notice.limitContinues(q.facts) : undefined
686
687/** A person prompt joins a limit question after Continue at the reset: it waits with the held work, and says so. */
688export const promptWaitsLine = (q: Pick<QuestionCore, 'facts' | 'holdEnd' | 'chosen'>, now: number): string | undefined =>
689 q.chosen === true && now < q.holdEnd ? notice.limitPromptWaits(q.facts) : undefined
690
691/** The times at which a new question can change the badge: each consent bound, its hold end, due, stop end and note time. */
692export const questionEdges = (q: QuestionCore): number[] => [
693 ...q.kinds.map((kind) => q.ends[kind]?.end ?? 0),
694 q.holdEnd,
695 q.due,
696 q.stopEnd,
697 q.noteAt,
698]
699
700/** The B9 notice of a Resume: what continues, or a new window when every consent bound has passed. */
701export function resumeNotice(q: Pick<QuestionCore, 'kinds' | 'ends' | 'facts' | 'mode'>, now: number): string {
702 const open = q.kinds.filter((kind) => (q.ends[kind]?.end ?? 0) > now)
703 return open.length > 0
704 ? notice.continuing(
705 q.facts.filter((f) => open.includes(f.kind ?? 'five_hour')),
706 q.mode,
707 )
708 : notice.newWindowFor(q.kinds)
709}
710
711// ---- Stops (register.tsx 5.7, 3.2, skip 3.5, B34, B46, TS1) ----
712
713/** The skip starts that are still ahead of some kinds. */
714export const skipStarts = (ks: readonly KindSense[]): number[] => ks.map((k) => k.skipAt).filter((t): t is number => t !== null)
715
716/**
717 * B34: an auto stop extended to the kinds that gate now, until their latest hold end. The test tag keeps
718 * the short margin only while every kind that gates now is a test reading. TS1: each kind whose real
719 * reading gates now (`holders`) gets a real entry with its current reset, the window it gates in. Any
720 * other kind that still gates keeps its entry while that window lasts (`extendedReal`).
721 */
722export function extended(r: StoppedRecord, gatingNow: readonly KindSense[], holders: readonly Holder[], now: number): StoppedRecord {
723 const until = Math.max(...gatingNow.map((k) => k.holdEnd))
724 const skip = skipTag(until, skipStarts(gatingNow), gatingNow.map((k) => k.holdEnd + dueMargin(k)), true)
725 const { skip: _old, real: _real, ...kept } = r
726 const kinds = gatingNow.map((k) => k.kind)
727 const real = extendedReal(r.real, holders, kinds, now)
728 return {
729 ...kept,
730 kinds,
731 windowEnd: until,
732 test: r.test === true && gatingNow.every((k) => k.test),
733 ...(skip ? { skip: true } : {}),
734 ...(real.length > 0 ? { real } : {}),
735 }
736}
737
738/**
739 * TS1: a stop held past its until, as the badge, /spare10 and the stop reply show it. An auto stop gets
740 * the record that the ticker writes at its next tick (B34). A stop made with autoResume off lasts until
741 * the stop end of its kinds that keep it: their skip start, or else the consent bound. With no such kind
742 * among the kinds that gate (a Resume on a test reading over the real one), the record as it is.
743 */
744export function stillHeld(st: StoppedRecord, gating: readonly KindSense[], holders: readonly Holder[], now: number): StoppedRecord {
745 const mine = gating.filter((k) => holders.some((h) => h.kind === k.kind && holdsPast(st, h)))
746 if (mine.length === 0) return st
747 if (st.auto === true) return extended(st, gating, holders, now)
748 const until = Math.max(...mine.map((k) => k.stopEnd))
749 const skip = skipTag(until, skipStarts(mine), [], false)
750 const { skip: _old, ...kept } = st
751 return { ...kept, windowEnd: until, ...(skip ? { skip: true } : {}) }
752}
753
754/**
755 * The stop of this conversation that applies now, if any. `gating`: the kinds that gate now. `holders`:
756 * the kinds whose real reading gates now. A stop past its until that one of them keeps applies too (TS1:
757 * `holdsPast`), with the end it will have. A stop of an ended conversation (`endedSid`) no longer counts.
758 */
759export function stopInForce(
760 st: StoppedRecord | undefined,
761 sessionId: string,
762 endedSid: string | undefined,
763 now: number,
764 gating: readonly KindSense[],
765 holders: readonly Holder[],
766): StoppedRecord | undefined {
767 if (st === undefined || st.sessionId !== sessionId || st.sessionId === endedSid) return undefined
768 if (now < st.windowEnd) return st
769 return heldPast(st, holders) ? stillHeld(st, gating, holders, now) : undefined
770}
771
772/** A new stop to write. `real` (TS1): the kinds whose real reading gates at the stop, with their resets now. */
773export type StopWrite = { kinds: Kind[]; windowEnd: number; work: boolean; auto: boolean; test: boolean; skip: boolean; real: readonly Holder[] }
774
775/** 5.7: the 0.2 record, merged with an earlier stop of this session (3.2). Only the real entries of `kinds` are kept. */
776export function stopRecordOf(prev: StoppedRecord | undefined, n: StopWrite, sessionId: string, now: number): StoppedRecord {
777 const { skip, real: realNow, ...rest } = n
778 const real = realNow.filter((h) => n.kinds.includes(h.kind))
779 return mergeStopped(prev, { ...rest, ...(skip ? { skip: true } : {}), ...(real.length > 0 ? { real } : {}), sessionId, at: now }, now)
780}
781
782/**
783 * Skip 4.4: which kinds of a question or a stop reset, and which are open now. A kind is named as reset
784 * when it is neither open nor gating now. With no skip owner and no open kind, the D0.2 lists (every
785 * named kind reset), so each notice keeps its D0.2 wording. `owner` with no sense: nothing is known.
786 */
787export function endedFor(
788 named: readonly Named[],
789 s: { kinds: readonly KindSense[]; now: number } | undefined,
790 gatingNow: readonly KindSense[],
791 owner: boolean,
792): Ended {
793 if (s === undefined) return owner ? { reset: [], open: [] } : { reset: [...named], open: [] }
794 const open = factsFrom(
795 s.kinds.filter((k) => k.open && named.some((n) => n.kind === k.kind)),
796 s.now,
797 )
798 if (!owner && open.length === 0) return { reset: [...named], open: [] }
799 const reset = named.filter((n) => !open.some((f) => (f.kind ?? 'five_hour') === n.kind) && !gatingNow.some((k) => k.kind === n.kind))
800 return { reset, open }
801}
802
803/** How a question settles. */
804export type Via = 'dialog' | 'command' | 'elsewhere' | 'could not ask' | 'dialog ended without an answer' | 'reset' | 'quota' | 'limit' | 'time limit'
805
806/** A settle's result for a Stop (B46): the record as written, what opened, and the until for the reply. */
807export type Late = { record?: StoppedRecord; ended?: Ended; until?: { at: string; lead?: string } }
808
809/** A sense at a Stop here: the kinds, the split and the holders of now. */
810export type StopSense = { s: Sensed; split: Split; holders: readonly Holder[] }
811
812/**
813 * What a Stop here writes. 'open' (B46 open): nothing, because nothing gates and a kind of the question is
814 * open. `limit`: a Stop here at the quota limit (`stopsAtLimit`), with the limit texts.
815 */
816export type StopPlan =
817 | { kind: 'open'; until: number; ended: Ended }
818 | { kind: 'write'; until: number; ended?: Ended; late: KindSense[]; record: StopWrite; limit?: true }
819
820/**
821 * A Stop here (skip 3.5, B46). The sense gives the kinds whose real reading gates now (TS1: the `real`
822 * tag). When the question's time has passed (its hold end with autoResume on, its skip start with it
823 * off), it also gives the kinds that gate now (`late`) and the question's kinds that are open now. The
824 * kinds that gate are stopped as usual. When nothing gates, a kind of the question is open, and no work
825 * waits for a resume prompt, nothing is written: such a stop would never apply. No sense (it failed):
826 * the D0.2 write, with the real kinds of the question when it opened. `auto`: the setting in force.
827 * The kinds at the quota limit that gate now are always late, so the stop lasts until their reset, also
828 * when a question at the reserve had an earlier end: a Stop here, no answer, or the hold time limit. Such
829 * a stop has the limit texts. `atLimit` (`stopsAtLimit`): the limit texts also when the sense failed.
830 */
831export function stopPlan(q: QuestionCore, now: number, auto: boolean, sNow?: StopSense, atLimit = false): StopPlan {
832 const work = q.loops > 0
833 const passed = auto ? q.holdEnd <= now : q.skip && q.stopEnd <= now
834 const real = sNow === undefined ? q.real : sNow.holders // fail closed: the question's real kinds when the sense fails
835 const limitNow = sNow === undefined ? [] : sNow.split.gating.filter((k) => k.limit)
836 const late = sNow === undefined ? [] : passed ? sNow.split.gating : limitNow
837 const opened = sNow !== undefined && passed ? sNow.split.open.filter((k) => q.kinds.includes(k.kind)) : []
838 const until = auto ? Math.max(q.holdEnd, ...late.map((k) => k.holdEnd)) : Math.max(q.stopEnd, ...late.map((k) => k.stopEnd))
839 const ended = sNow === undefined || opened.length === 0 ? undefined : endedFor(namedOf(q), sNow.s, late, true)
840 if (until <= now && ended !== undefined && !(auto && work)) return { kind: 'open', until, ended }
841 const kinds = KINDS.filter((k) => q.kinds.includes(k) || late.some((l) => l.kind === k))
842 const allTest = q.kinds.every((kind) => q.ends[kind]?.test === true) && late.every((k) => k.test)
843 const starts = [
844 ...q.kinds.map((kind) => q.ends[kind]?.skipAt).filter((t): t is number => t !== undefined),
845 ...late.map((k) => k.skipAt).filter((t): t is number => t !== null),
846 ]
847 const skip = skipTag(until, starts, [q.due, ...late.map((k) => k.holdEnd + dueMargin(k))], auto)
848 return {
849 kind: 'write',
850 until,
851 ...(ended === undefined ? {} : { ended }),
852 late,
853 record: { kinds, windowEnd: until, work, auto, test: allTest, skip, real },
854 ...(atLimit || limitNow.length > 0 ? { limit: true as const } : {}),
855 }
856}
857
858/** B46 open: the transcript line of a Stop here that writes nothing. None for a command. */
859export function stopOpenNotice(q: Pick<QuestionCore, 'facts'>, ended: Ended, via: Via): string | undefined {
860 if (via === 'time limit') return notice.holdLimitLate(q.facts, ended, false)
861 if (via !== 'command') return notice.stoppedLate(q.facts, ended, false)
862 return undefined
863}
864
865/**
866 * The transcript line of a Stop here that wrote `written`, and the settle's result. The texts follow the
867 * record as written: the merge can add work, kinds and a later end (3.2). For a skip owner, a late kind's
868 * fresh facts replace the question's (under B45 it is now sensed on the real basis). Else only the late
869 * kinds that the question does not name are added (D0.2). No text for a command.
870 */
871export function stopNotice(
872 q: QuestionCore,
873 plan: Extract<StopPlan, { kind: 'write' }>,
874 written: StoppedRecord,
875 via: Via,
876 now: number,
877 auto: boolean,
878): { text?: string; late: Late } {
879 const work = q.loops > 0
880 const late = plan.late
881 const fresh = factsFrom(late, now, written.skip === true)
882 const facts = byKind(
883 q.skip
884 ? [...q.facts.filter((f) => !late.some((k) => k.kind === (f.kind ?? 'five_hour'))), ...fresh]
885 : [...q.facts, ...fresh.filter((f) => !q.kinds.includes(f.kind ?? 'five_hour'))],
886 )
887 const u = untilFor(facts, written.windowEnd, written.kinds ?? plan.record.kinds, written.skip === true, now)
888 const text = (limit: string, stopped: string): { text?: string } => (via === 'time limit' ? { text: limit } : via !== 'command' ? { text: stopped } : {})
889 if (q.limit === true || plan.limit === true) {
890 // Stop here at the limit stops until the reset and continues nothing. The hold time limit keeps the setting
891 // in force, and so does a question at the reserve that ends with no answer. A stop that continues nothing
892 // and whose end has passed (the question waited past its reset for the answer) names no time, as below.
893 const cont = auto && written.auto === true && written.work === true
894 const at = cont || written.windowEnd > now ? u.at : undefined
895 return { ...text(notice.limitHoldLimit(at, cont), notice.limitStopped(at, cont)), late: { record: written, until: u } }
896 }
897 const ended = plan.ended
898 if (ended !== undefined && late.length === 0 && auto && work && plan.until <= now) {
899 // B46 soon: the stop is written with its passed until, and the next tick continues the work.
900 return { ...text(notice.holdLimitLate(facts, ended, true), notice.stoppedLate(facts, ended, true)), late: { record: written, ended, until: u } }
901 }
902 // A skip stop shows its end in both modes: with autoResume off it ends by time then (skip 1.3 item 6).
903 // With autoResume off, an end that has already passed (the window reset while the question waited,
904 // or the sense failed) is no help to the person: the D0.2 text with no time.
905 const shows = written.skip === true && (auto || written.windowEnd > now)
906 const a = shows ? { ...u, work: auto && written.work === true } : auto ? { ...u, work: written.work === true } : undefined
907 return { ...text(notice.holdLimit(facts, a), notice.stopped(facts, a)), late: { record: written, until: u } }
908}
909
910/** B34: the transcript line of an auto stop that the ticker extended to `longer`. */
911export function extendNotice(r: StoppedRecord, longer: StoppedRecord, gatingNow: readonly KindSense[], s: Pick<Sensed, 'kinds' | 'now'>): string {
912 const skip = longer.skip === true
913 const ended = endedFor(namedStop(r), s, gatingNow, r.skip === true)
914 const facts = factsFrom(gatingNow, s.now, skip)
915 return notice.stopExtended(ended.reset, facts, untilPhrase(untilFor(facts, longer.windowEnd, longer.kinds ?? [], skip, s.now)), ended.open)
916}
917
918/** takeOverdueStop's result (skip 4.6): the stop that was taken over, and what reset and what opened. */
919export type Taken = { record: StoppedRecord } & Ended
920
921/** A stop taken over. `s`: a sense of now, so the notice can name what opened. None: nothing is known. */
922export const takenOf = (record: StoppedRecord, s: { kinds: readonly KindSense[]; now: number } | undefined): Taken => ({
923 record,
924 ...endedFor(namedStop(record), s, [], record.skip === true),
925})
926
927/** 4.6.3: a stop that the ticker cleared while a person prompt was in flight, and when. */
928export type HandedOver = { record: StoppedRecord; at: number }
929
930/**
931 * 4.6.3: the prompt takes a handed-over stop over when the hand-over came less than a check period ago
932 * and no kind of the stop still holds it (TS1).
933 */
934export const handoverTakes = (h: HandedOver | undefined, now: number, holders: readonly Holder[]): h is HandedOver =>
935 h !== undefined && now - h.at < CHECK_MS && !heldPast(h.record, holders)
936
937// ---- The waits (register.tsx 4.5, 4.6, 4.8, B43) ----
938
939/**
940 * 4.5: a waiter's check has nothing to do yet: the question is not due, the next check has not come, and
941 * the note is done or not yet due. Else the host moves `nextCheck` on by CHECK_MS and asks `dueStep`.
942 */
943export const dueWait = (q: Pick<QuestionCore, 'due' | 'nextCheck' | 'noted' | 'noteAt'>, now: number): boolean =>
944 !(now >= q.due) && now < q.nextCheck && (q.noted || now < q.noteAt)
945
946/**
947 * 4.5, B6, B43: the waiter's check after `dueWait`. `auto`: the autoResume setting in force (a quiet
948 * question never reads it: pass true). With it off, a question waits for the answer: 'note' writes the
949 * D0.2 note, 'noteSensed' (a skip owner) senses first and asks `sensedNote`. A quiet question (`quietOf`:
950 * an unattended wait, or a limit question after Continue at the reset) releases by time in both modes.
951 * Past the reset, inside the margin: 'wait'. Else 'check': sense, and ask `dueRelease`.
952 */
953export function dueStep(
954 q: Pick<QuestionCore, 'due' | 'silent' | 'chosen' | 'noted' | 'noteAt' | 'skip' | 'holdEnd'>,
955 now: number,
956 auto: boolean,
957): 'wait' | 'note' | 'noteSensed' | 'check' {
958 const due = now >= q.due
959 if (!quietOf(q) && !auto) {
960 // The setting in force now says wait for the answer (1.3 item 6, B6).
961 if (!q.noted && now >= q.noteAt) return q.skip ? 'noteSensed' : 'note'
962 return 'wait'
963 }
964 if (!due && now >= q.holdEnd) return 'wait' // past the reset, inside the margin: wait (4.8)
965 return 'check'
966}
967
968/**
969 * B43: the note of a skip owner names what opened or reset. `text`: the note, and the question is noted.
970 * `noteAt`: nothing of the question opened yet (a real trip beneath a test reading, B45), so the note
971 * waits for the hold end of its kinds that gate.
972 */
973export function sensedNote(
974 q: Pick<QuestionCore, 'kinds' | 'ends'>,
975 s: Pick<Sensed, 'kinds' | 'now'>,
976 gatingNow: readonly KindSense[],
977 now: number,
978): { text: string } | { noteAt: number } {
979 const ended = endedFor(namedOf(q), s, gatingNow, true)
980 if (ended.reset.length === 0 && ended.open.length === 0) {
981 const mine = gatingNow.filter((k) => q.kinds.includes(k.kind))
982 return { noteAt: Math.max(now + CHECK_MS, ...mine.map((k) => k.holdEnd)) }
983 }
984 // New work goes on only while no kind gates: a window that still gates holds new work too.
985 return { text: notice.resetWaitingFor(ended.reset, ended.open, gatingNow.length === 0) }
986}
987
988/**
989 * 4.5: whether a check releases the held work, and why. Before the due time only when no kind gates. A
990 * stop never holds an open kind (B44), so no kind gating means the gate lets the held loops through (skip
991 * 4.3). `tooRecent` (`resetTooRecent`): a test window that ends near a real reset waits (4.8). 'limit': a
992 * question that is not a limit question gives way to the limit question (`supersedes`). A limit question
993 * ends before its due time only when no kind that gates is at the quota limit any more ('quota').
994 */
995export function dueRelease(
996 q: Pick<QuestionCore, 'due' | 'limit' | 'silent'>,
997 now: number,
998 gatingNow: readonly KindSense[],
999 tooRecent: boolean,
1000): 'reset' | 'quota' | 'limit' | undefined {
1001 const due = now >= q.due
1002 if (supersedes(q, gatingNow)) return 'limit'
1003 if (!due && (q.limit === true ? gatingNow.some((k) => k.limit) : gatingNow.length > 0)) return undefined
1004 if (gatingNow.length === 0 && tooRecent) return undefined
1005 return due ? 'reset' : 'quota'
1006}
1007
1008/**
1009 * 4.5: the transcript line of a question that ends without an answer. The D0.2 texts byte for byte. A
1010 * question that gives way to the limit question, and a limit question that ends before its reset, have
1011 * their own lines.
1012 */
1013export function againNotice(
1014 q: Pick<QuestionCore, 'kinds' | 'ends' | 'skip' | 'limit'>,
1015 via: 'reset' | 'quota' | 'limit',
1016 gatingNow: readonly KindSense[],
1017 s: Pick<Sensed, 'kinds' | 'now'>,
1018): string {
1019 if (via === 'limit') return notice.limitReached
1020 if (via === 'quota' && q.limit === true) return notice.limitOver
1021 const ended = endedFor(namedOf(q), s, gatingNow, q.skip)
1022 if (!q.skip && ended.open.length === 0) {
1023 if (via === 'quota') return notice.outOfReserve
1024 if (gatingNow.length === 0) return notice.resetContinues(namedOf(q))
1025 return notice.resetStillHeld(namedOf(q), factsFrom(gatingNow, s.now))
1026 }
1027 if (gatingNow.length === 0) return ended.reset.length + ended.open.length > 0 ? notice.resetContinues(ended.reset, ended.open) : notice.outOfReserve
1028 return notice.resetStillHeld(ended.reset, factsFrom(gatingNow, s.now), ended.open)
1029}
1030
1031/**
1032 * 4.6: what the ticker does with an auto stop past its due time. 'skip': nothing gates, but a real reset
1033 * came less than the margin ago (4.8). 'extend': a kind still gates (B34). 'end': the stop ends.
1034 */
1035export const tickPlan = (gatingNow: readonly KindSense[], tooRecent: boolean): 'skip' | 'extend' | 'end' =>
1036 gatingNow.length === 0 && tooRecent ? 'skip' : gatingNow.length > 0 ? 'extend' : 'end'
1037
1038/** B50 item 3: a consent answers a question's kind at a matching tier. A consent to the floor never answers a kind asked at the floor. */
1039export const answersKind = (end: QuestionEnd | undefined, list: readonly Sourced[], now: number): boolean =>
1040 end !== undefined && list.some((e) => answersQuestion(e.c, end, now))
1041
1042/** R6: a stop newer than the question and still in force settles it as Stop here, when it is of this session. */
1043export const stopNewer = (st: StoppedRecord | undefined, q: Pick<QuestionCore, 'since'>, now: number): st is StoppedRecord =>
1044 st !== undefined && st.at > q.since && now < st.windowEnd
1045
1046// ---- Commands (register.tsx 2.7, 2.8, 2.9, 4.6, B23 to B26, B44, B46, B50, B53) ----
1047
1048/** The trip point of a reserve, on the one-decimal grid. */
1049export const tripOf = (reserve: number): number => Math.round((100 - reserve) * 10) / 10
1050
1051/**
1052 * What a command's takeover knows of now (skip 4.6, TS1): the kinds, and the kinds whose real reading
1053 * gates (`commandHolders`). With no sense nothing is known, and the takeover follows 4.6.
1054 */
1055export function takeoverSense(sNow: Pick<Sensed, 'kinds'> | undefined): { kinds?: readonly KindSense[]; holders: readonly Holder[] } {
1056 if (sNow === undefined) return { holders: [] }
1057 return { kinds: sNow.kinds, holders: commandHolders(sNow.kinds) }
1058}
1059
1060/**
1061 * B50 item 4: resume on an open question raises a kind to a full Resume when the fresh sense of that
1062 * kind is at the floor, on the question's basis and in the question's window. Its facts then follow the
1063 * fresh sense, for the reply and the B9 note. Else the question's tier stands. It updates `q`.
1064 */
1065export function raiseAtFloor(q: Pick<QuestionCore, 'ends' | 'facts' | 'skip'>, sNow: Pick<Sensed, 'kinds' | 'now'>): void {
1066 for (const k of sNow.kinds) {
1067 const end = q.ends[k.kind]
1068 if (end?.to === undefined || !k.atFloor || k.test !== end.test || Math.abs(k.windowEnd - end.end) > 60_000) continue
1069 const { to: _to, ...full } = end
1070 q.ends[k.kind] = full
1071 q.facts = byKind([...q.facts.filter((f) => (f.kind ?? 'five_hour') !== k.kind), ...factsFrom([k], sNow.now, q.skip)])
1072 }
1073}
1074
1075/**
1076 * Resume on an open limit question whose limit is gone (limitPause off, credits that pay, or a new test
1077 * reading): the limit question asked no tier, so each kind of it takes its tier now. A kind before its
1078 * floor, also one below its reserve, gets a consent to the floor. A kind at the floor or open keeps a full
1079 * Resume. Only on the question's basis and in its window. Its facts and mode then follow the fresh sense,
1080 * for the reply. So no work runs past the floor without a second Resume. It updates `q`.
1081 */
1082export function lowerAfterLimit(
1083 q: Pick<QuestionCore, 'ends' | 'facts' | 'skip' | 'limit' | 'mode'>,
1084 sNow: Pick<Sensed, 'kinds' | 'now' | 'cfg'>,
1085): void {
1086 if (q.limit !== true) return
1087 let lowered = false
1088 for (const k of sNow.kinds) {
1089 const end = q.ends[k.kind]
1090 if (end === undefined || end.to !== undefined || k.limit || k.test !== end.test || Math.abs(k.windowEnd - end.end) > 60_000) continue
1091 const to = k.open || k.atFloor || k.point === null ? undefined : k.point
1092 if (to === undefined) continue
1093 q.ends[k.kind] = { ...end, to }
1094 q.facts = byKind([...q.facts.filter((f) => (f.kind ?? 'five_hour') !== k.kind), ...factsFrom([k], sNow.now, q.skip, () => to)])
1095 lowered = true
1096 }
1097 if (lowered) q.mode = modeOf(sNow.cfg)
1098}
1099
1100/** A resume command on an open question: each kind at its tier now (`raiseAtFloor`, `lowerAfterLimit`). It updates `q`. */
1101export function tierAtResume(
1102 q: Pick<QuestionCore, 'ends' | 'facts' | 'skip' | 'limit' | 'mode'>,
1103 sNow: Pick<Sensed, 'kinds' | 'now' | 'cfg'>,
1104): void {
1105 raiseAtFloor(q, sNow)
1106 lowerAfterLimit(q, sNow)
1107}
1108
1109/**
1110 * B23: the reply of resume from the reading alone: no reading, or below every reserve. Undefined: split
1111 * next. `absent` (Codex design 2.1, CX17): the kinds the host reports no window for. Claude passes none.
1112 */
1113export function resumeReadReply(s: Pick<Sensed, 'kinds' | 'now' | 'tripped'>, absent?: readonly Kind[]): string | undefined {
1114 const read = s.kinds.filter((k) => k.basis.kind !== 'none')
1115 if (read.length === 0) return resumeReply('none', undefined, undefined, undefined, 'hold', absent)
1116 if (!s.tripped) return resumeReply('below', factsFrom(read, s.now), undefined, undefined, 'hold', absent)
1117 return undefined
1118}
1119
1120/**
1121 * B23, B44, floor 2.8: resume after the split. A reply when nothing gates: an open kind has nothing to
1122 * resume, and a consented kind is already resumed. Else the consents to write, each gating kind at its
1123 * tier now (floor 1.3 item 2: before the floor to the floor, past it until the reset), and the facts of
1124 * the reply ('stopped' when a stop applied, else 'tripped').
1125 */
1126export function resumeCase(
1127 s: Pick<Sensed, 'now'>,
1128 split: Split,
1129 mode: Mode,
1130): { reply: string } | { gating: KindSense[]; write: Array<{ kind: Kind; c: Consent; test: boolean }>; facts: Facts[] } {
1131 const gating = split.gating
1132 if (gating.length === 0 && split.open.length > 0) return { reply: resumeReply('open', factsFrom(split.open, s.now)) } // B44: nothing to resume
1133 if (gating.length === 0) {
1134 // Floor 2.8: the facts come from the consents that cover each kind, never from the stage. The floor
1135 // form names the tripped kinds that are not open. With no consent to the floor, the 0.2 form names them all.
1136 const shut = split.consented.filter((x) => !x.k.open)
1137 const named = shut.some((x) => x.c.to !== undefined) ? shut : split.consented
1138 return {
1139 reply: resumeReply(
1140 'consented',
1141 factsFrom(
1142 named.map((x) => x.k),
1143 s.now,
1144 false,
1145 consentEnd(named),
1146 ),
1147 undefined,
1148 undefined,
1149 mode,
1150 ),
1151 }
1152 }
1153 const write = gating.map((k) => {
1154 const to = resumeTo(k)
1155 return { kind: k.kind, c: { until: k.windowEnd, ...(to === undefined ? {} : { to }) }, test: k.test }
1156 })
1157 return { gating, write, facts: factsFrom(gating, s.now, false, resumeTo) }
1158}
1159
1160/**
1161 * Resume at the quota limit. An open limit question that is not chosen yet, while a kind is at the quota
1162 * limit (or the sense failed): choose Continue at the reset (`choose`). Else, while a kind is at the quota
1163 * limit (or a limit question is open and the sense failed): a reply that nothing resumes now. Nothing is
1164 * written or cleared: no consent, no stop takeover, no stop clear. Undefined: the resume of today, also
1165 * for a limit question whose limit is gone (limitPause off, credits that pay, or a reset): it settles as Resume,
1166 * each kind at its tier now (`tierAtResume`). A host whose first sense failed asks again on its second one.
1167 */
1168export function resumeAtLimit(
1169 sNow: Pick<Sensed, 'kinds' | 'now'> | undefined,
1170 q: Pick<QuestionCore, 'limit' | 'chosen' | 'facts'> | undefined,
1171): { choose: boolean; reply: string } | undefined {
1172 const at = sNow?.kinds.filter((k) => k.limit) ?? []
1173 if (q?.limit === true && q.chosen !== true && (sNow === undefined || at.length > 0)) return { choose: true, reply: resumeReply('limit-asking', q.facts) }
1174 if (sNow !== undefined && at.length > 0) return { choose: false, reply: resumeReply('limit', factsFrom(at, sNow.now)) }
1175 if (sNow === undefined && q?.limit === true) return { choose: false, reply: resumeReply('limit', q.facts) }
1176 return undefined
1177}
1178
1179/**
1180 * The reply of a resume that settles an open question. A limit question whose reset has passed (it waited
1181 * for the answer with autoResume off) names no time: a passed reset is no help to the person.
1182 */
1183export const resumeAskingReply = (q: Pick<QuestionCore, 'facts' | 'mode' | 'limit' | 'holdEnd'> | undefined, now: number): string =>
1184 q?.limit === true && q.holdEnd <= now ? resumeReply('limit-late') : resumeReply('asking', q?.facts, undefined, undefined, q?.mode)
1185
1186/** The kinds that gate after a stop: tripped and not open. Unknown (false) when the sense failed. */
1187export const gatesAfter = (sNow: Pick<Sensed, 'kinds'> | undefined): boolean => sNow?.kinds.some((k) => k.tripped && !k.open) === true
1188
1189/**
1190 * B24: the reply of a stop that took an overdue stop over while no kind gates (D0.2). An open kind needs
1191 * no stop (B44). The reply names a reset or an open reserve only when one came.
1192 */
1193export function stopOverdueReply(t: Ended): string {
1194 if (t.open.length > 0) return stopReply('overdue-open', t.open)
1195 if (t.reset.length > 0) return stopReply('overdue')
1196 return stopReply('overdue-skip')
1197}
1198
1199/**
1200 * B24, B46: the reply of a stop on an open question, from its settle. It follows the record as writtenhooks/core/host.ts 44 lines1// The words that differ between hosts (Codex design 2.1). Pure: it imports nothing.
2// A core text names a host word only through HOST, so each host renders its own words.
3
4/** The words that differ between hosts. The Codex bundle swaps this file for codex/src/host.ts. */
5export type Host = {
6 name: string // the host, as texts say it
7 command: string // the command a person types at an idle prompt
8 anytime: string // the command a person runs at any time, also during a turn
9 config: string // where options come from, in the report: 'from {config}'
10 child: string // the report label of nested unattended runs, 15 characters at most
11 resume: string // the command that picks up an unattended run, before its id
12 keepOpen: string // the end of 'To keep a reserve until the reset, {keepOpen}.'
13 limitOff: string // the end of 'To let work run past the limit, {limitOff}.'
14 backIt: string // what Stop here does with a held prompt, tell mode
15 backPrompt: string // what Stop here does with a held prompt, hold mode
16 blind: string // the blind phase detail, first sentence
17 leadSimulate: string // the first words of the bad simulate reply
18 leadFailed: string // the first words of the failed command reply
19 stoppedAgent: string // the RESUME_TAIL sentence about a subagent that did not finish
20 dialog: string // the name of the question in the asking phase line
21 optIn: string // the guarded row hint under scope opt-in
22 unreadSource: string // what spare10 could not read when a span source is 'unread'
23}
24
25export const HOST: Host = {
26 name: 'Claude Code',
27 command: '/spare10',
28 anytime: '/spare10',
29 config: '/config',
30 child: 'claude -p',
31 resume: 'claude --resume',
32 keepOpen: 'set its Open reserve option to 0 in /config',
33 limitOff: 'turn off Pause at the limit in /config',
34 backIt: 'gives it back to you',
35 backPrompt: 'gives your prompt back',
36 blind: 'Claude Code reports no 5-hour quota.',
37 leadSimulate: '/spare10 simulate',
38 leadFailed: '/spare10',
39 stoppedAgent: 'A subagent whose result says "spare10: work stopped" or "spare10: the user stopped work" did not finish.',
40 dialog: 'dialog',
41 optIn: 'Start with SPARE10=on to guard a run.',
42 unreadSource: 'the env',
43}
44types.d.ts 22 lines1// The spare10 type contract (plugin.json "types"). Types only: no import, export-from or reference.
2
3/** The budget-free carrier a held dispatch waits on, and its wake-up (section 4.4 of the design). */
4export type Spare10Hold = {
5 /** Parks the caller until spare10 wakes its waiters, or until the same waiter parks again. */
6 park: (args: { waiter: string }) => Promise<string>
7 /** Wakes every waiter parked in the newest copy of spare10. */
8 poke: (args: { from: string }) => Promise<string>
9 /** The autoResume setting in force: noun calls route to the newest copy of spare10. */
10 auto: () => Promise<boolean>
11 /** The spans in force: noun calls route to the newest copy of spare10. */
12 spans: () => Promise<{ lastMinutes: number; weeklyLastHours: number }>
13 /** The pause at the quota limit in force (limitPause): noun calls route to the newest copy of spare10. */
14 limit: () => Promise<boolean>
15}
16
17declare module 'claude-code' {
18 interface EngineInterface {
19 spare10: Spare10Hold
20 }
21}
22