Run your agent in stints. Pit before the redline.

Run your agent in stints. Pit before the redline.
Requires Claude Code 2.1.275+.
/plugin install stint --marketplace digital-stoic-org/agent-skills
claude --plugin-dir <path-to>/stint
claude plugin validate <path-to>/stint --strict
claude plugin test <path-to>/stint
| command | does |
|---|---|
/pit [stream] | The car comes into the pit: the agent writes its notes to a file, /clear runs by itself, and the notes are injected as the first message of the fresh context. If the host refuses the /clear, the status line says to type it. |
/rejoin [stream] | The car goes back on track: the notes of a stream are injected as the first message of a new session. With no stream it takes the only notes file of the folder, lists them when there are several, and says so when there are none. |
At session start, if the folder holds notes and no stream is set, the status line reads Notes for stream "x" found: /rejoin x.
A /pit is refused, and nothing is cleared, when the save fails, when another /pit is running, or when the context is at 95% of the limit where Claude Code compacts on its own: the save runs in that same context and would fail there.
Stream name, in order: the argument (a-z A-Z 0-9 _ -, up to 50 characters, kept for the session and across /clear), the session title set by /rename, then main. pit and rejoin are reserved.
<working directory>/.stint/<stream>-notes.md, at most 8,000 characters, written by one model fork that sees the previous notes of the stream and merges them. Header: stream, saved, status, previous_session, goal. Body lists: read_first, read_if_needed, deliverable, decisions, learnings, discarded, in_progress, next, unknowns, stale. Over the cap, code marks the status degraded, shortens long read_if_needed lines, then drops items oldest first from stale, in_progress, discarded, learnings. Still over, it drops from read_if_needed, next, unknowns, decisions and names the cut in stale. The header, read_first, deliverable and the running agents are never cut; if they alone exceed the cap, nothing is written and the pit is refused. Agents still running are listed in in_progress by code.
Do not load stint and stoa together.
MIT
hooks/register.ts 8 lines1import type { Register } from 'claude-code'
2
3import { registerPit } from './pit.ts'
4
5export const register: Register = on => {
6 registerPit(on)
7}
8hooks/pit.ts 677 lines1import type { EngineInterface, On } from 'claude-code'
2
3// pit (stint): one mod, two commands.
4// /pit [stream] save notes (awaited fork) -> /clear (automatic) -> notes re-injected as the first message of the fresh context
5// /rejoin [stream] notes of a stream -> first message of this session
6// Notes live in <session cwd>/.stint/<stream>-notes.md. One $.model.fork designates every field; code parses, assembles and caps the file.
7// Command names: `$.command.register` takes letters, digits, `_` and `-` only (CommandSpec.name), so no `stint:pit`: the commands are /pit and /rejoin.
8
9const STORE_KEY = 'stint:pending'
10const STALE_MS = 10 * 60 * 1000
11export const NOTES_DIR = '.stint'
12export const DEFAULT_STREAM = 'main'
13// Hard cap of the notes file, enforced by code: the fork is told the ceiling and the room left, assemble() cuts what still overflows.
14export const NOTES_MAX = 8_000
15const SHRINK_LINE = 80
16export const STREAM_RE = /^[a-zA-Z0-9_-]{1,50}$/
17export const RESERVED = ['pit', 'rejoin']
18// $.agent.list() is raced against this: a hang would leave a pit stuck without a word.
19const AGENT_LIST_MS = 5_000
20const ERROR_TOAST_MS = 12_000
21// A flow line older than this says it may be stuck ($.model.fork cannot be cancelled).
22export const SLOW_MS = 3 * 60 * 1000
23// The fork runs in this very context: at or above this share of the wall (where auto-compaction fires) it would fail there.
24export const WALL_CAP = 0.95
25
26// Injected after the notes, verbatim from the previous generation (the "wait for my go" variant).
27export const UNPACK_RULES = `Reply with one line saying you are ready, then stop there.
28Do nothing without my explicit go — no file read, no command, no sub-agent, no continuation of what is in progress.
29Do not read the notes back to me. One line of acknowledgement is the whole answer.
30Write a message only when something changes. The rest of the time stay silent.
31Questions go to me, in this window.
32After my go, read the \`read_first\` files before acting.`
33
34export const FORK_PROMPT = `You are writing the notes of this work stream for yourself. After a /clear the notes file is the ONLY thing the fresh context receives. You designate every field yourself; code only assembles and caps the file.
35First line, exactly one of:
36GATE: ok
37GATE: blocked - <one-line reason>
38Blocked = you are in the middle of a cross-item step (synthesis, deduplication, global arbitration across all units). Then output nothing else.
39If ok, from line 2 output ONLY the block below, keys in this order, no markdown, no prose, no blank line. Scalar = one line \`key: value\`. List = \`key:\` then one \`- item\` line per item; a missing list = empty.
40status: <one word or short phrase: building | blocked | review ...>
41goal: <1 sentence, carried from PREVIOUS NOTES unless it changed>
42read_first:
43- <path — role> (closed list, read only after the human's go, never before the go)
44read_if_needed:
45- <path or URL — when to read it>
46deliverable: <path where the written work lives>
47decisions:
48- <choice — why> (the why is mandatory; do not reopen)
49learnings:
50- <non-obvious fact — command or path that proves it>
51discarded:
52- <path or approach tried — why rejected>
53in_progress:
54- <half-done item — where it stands>
55next:
56- <action> (2-4 actions)
57unknowns:
58- <what is not known> (never empty)
59stale:
60- <path — replaced by X; do not reload>
61Rules:
62- You own every field. Return the COMPLETE current content of every list: merge PREVIOUS NOTES with what this conversation added. Carry forward what is still true; drop what is done, answered, replaced or reversed (a reversed decision goes to discarded). Never restart from scratch.
63- Lines starting "agent in flight:" are written by code: never write one.
64- A pointer (absolute path, path:line, command) replaces any explanation. Do not run commands; write them.`
65
66// ---------- pure helpers ----------
67
68export const pad = (n: number) => String(n).padStart(2, '0')
69export const notesPath = (cwd: string, stream: string) => `${cwd.replace(/\/+$/, '')}/${NOTES_DIR}/${stream}-notes.md`
70const NOTES_FILE_RE = /^(.+)-notes\.md$/
71
72// Stream names found in a `.stint` listing, sorted; anything that is not a valid `<stream>-notes.md` is ignored.
73export function streamsFromNames(names: string[]): string[] {
74 const out: string[] = []
75 for (const name of names) {
76 const m = name.match(NOTES_FILE_RE)
77 if (m && STREAM_RE.test(m[1] as string) && !RESERVED.includes(m[1] as string)) out.push(m[1] as string)
78 }
79 return out.sort()
80}
81
82// A /rename title that is not a valid stream name becomes one; empty or reserved = no stream.
83export function slugify(title: string): string | null {
84 const slug = STREAM_RE.test(title)
85 ? title
86 : title
87 .toLowerCase()
88 .replace(/[^a-z0-9_-]+/g, '-')
89 .replace(/^-+|-+$/g, '')
90 .slice(0, 50)
91 .replace(/-+$/, '')
92 return slug === '' || RESERVED.includes(slug) ? null : slug
93}
94
95export type Cmd = 'pit' | 'rejoin'
96export type ParsedArgs = { stream?: string } | { error: string }
97
98const USAGE: Record<Cmd, string> = { pit: 'Usage: /pit [stream]', rejoin: 'Usage: /rejoin [stream]' }
99
100export function parseArgs(args: string, cmd: Cmd): ParsedArgs {
101 const tokens = args.split(/\s+/).filter(t => t !== '')
102 const bad = tokens.find(t => t.startsWith('-'))
103 if (bad) return { error: `unknown option ${bad}. ${USAGE[cmd]}` }
104 if (tokens.length > 1) return { error: `too many arguments. ${USAGE[cmd]}` }
105 const stream = tokens[0]
106 if (stream === undefined) return {}
107 if (RESERVED.includes(stream)) return { error: `"${stream}" is a reserved word, not a stream name` }
108 if (!STREAM_RE.test(stream)) return { error: `invalid stream "${stream}": use 1-50 chars of a-z A-Z 0-9 _ -` }
109 return { stream }
110}
111
112// Explicit binding (a command argument) > session title (/rename) > the default stream.
113export function resolveStream(bound: string | null, title: string | null): string {
114 return bound ?? title ?? DEFAULT_STREAM
115}
116
117// The wall is where auto-compaction fires; a fork started at or above WALL_CAP of it would fail there.
118export function overWall(tokens: number, wall: number): boolean {
119 return wall > 0 && tokens >= WALL_CAP * wall
120}
121
122export const elapsed = (ms: number) => {
123 const s = Math.floor(ms / 1000)
124 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${pad(s % 60)}s`
125}
126
127export function progressText(label: string, ms: number): string {
128 if (ms >= SLOW_MS) return `${label}: still running after ${elapsed(ms)}, slow or stuck; the outcome will show here`
129 return ms < 1000 ? `${label}...` : `${label}... ${elapsed(ms)}`
130}
131
132export function hintText(streams: string[]): string {
133 if (streams.length === 1) return `Notes for stream "${streams[0]}" found: /rejoin ${streams[0]}`
134 return `Notes found for streams ${streams.map(s => `"${s}"`).join(', ')}: /rejoin <stream>`
135}
136
137const SHORT_REASON = 80
138const shortReason = (reason: string) => {
139 const one = reason.replace(/\s+/g, ' ').trim()
140 return one.length > SHORT_REASON ? `${one.slice(0, SHORT_REASON - 1)}…` : one
141}
142
143// ---------- notes format ----------
144
145export const SCALARS_HEADER = ['stream', 'saved', 'status', 'previous_session', 'goal']
146export const LISTS = ['read_first', 'read_if_needed', 'decisions', 'learnings', 'discarded', 'in_progress', 'next', 'unknowns', 'stale']
147// Body order of the notes file (deliverable is the only scalar among them).
148export const BODY_ORDER = ['read_first', 'read_if_needed', 'deliverable', 'decisions', 'learnings', 'discarded', 'in_progress', 'next', 'unknowns', 'stale']
149
150export type Keyed = { scalars: Record<string, string>; lists: Record<string, string[]> }
151
152// `key: value` scalars and `key:` + `- item` lists; unknown lines are ignored.
153export function parseKeyed(text: string, scalarKeys: string[], listKeys: string[]): Keyed {
154 const out: Keyed = { scalars: {}, lists: {} }
155 let current: string | null = null
156 for (const raw of text.split('\n')) {
157 const line = raw.trimEnd()
158 const key = line.match(/^([a-z_]+):\s*(.*)$/)
159 if (key && scalarKeys.includes(key[1] as string)) {
160 out.scalars[key[1] as string] = (key[2] as string).trim()
161 current = null
162 } else if (key && listKeys.includes(key[1] as string)) {
163 const k = key[1] as string
164 out.lists[k] = out.lists[k] ?? []
165 current = k
166 if ((key[2] as string).trim()) (out.lists[k] as string[]).push((key[2] as string).trim())
167 } else if (current && line.startsWith('- ')) {
168 ;(out.lists[current] as string[]).push(line.slice(2).trim())
169 }
170 }
171 return out
172}
173
174const NOTES_SCALARS = [...SCALARS_HEADER, 'deliverable']
175export const parseNotes = (text: string) => parseKeyed(text, NOTES_SCALARS, LISTS)
176
177const FORK_SCALARS = ['status', 'goal', 'deliverable']
178export type ForkParse = { kind: 'ok'; body: string } | { kind: 'blocked'; reason: string }
179
180export function parseFork(text: string): ForkParse {
181 const lines = text.trim().split('\n')
182 const first = (lines[0] ?? '').trim()
183 if (first === 'GATE: ok') {
184 const body = lines.slice(1).join('\n').trim()
185 return body ? { kind: 'ok', body } : { kind: 'blocked', reason: 'empty fork output' }
186 }
187 const blocked = first.match(/^GATE:\s*blocked\s*[-—:]?\s*(.*)$/)
188 if (blocked) return { kind: 'blocked', reason: blocked[1] || 'no reason given' }
189 return { kind: 'blocked', reason: `malformed first line: ${first.slice(0, 80)}` }
190}
191
192export function parseForkBody(body: string): Keyed | null {
193 const fields = parseKeyed(body, FORK_SCALARS, LISTS)
194 if (Object.keys(fields.scalars).length === 0 && Object.keys(fields.lists).length === 0) return null
195 return fields
196}
197
198const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
199export const AGENT_LINE = 'agent in flight:'
200// The list did not answer in AGENT_LIST_MS: said once in the notes instead of silently listing nobody.
201export const AGENTS_UNKNOWN = 'agents: unknown (list timed out)'
202const AGENTS_MAX = 10
203const AGENT_DESC_MAX = 100
204const LIVE_STATUS = ['pending', 'running', 'waiting', 'idle']
205
206export type AgentLike = { id: string; name?: string; type: string; status: string; description?: string }
207
208// One code-written line per live background agent, so the fresh context after /clear knows whom it can still SendMessage.
209export function agentLinesOf(listed: AgentLike[]): string[] {
210 const live = listed.filter(a => LIVE_STATUS.includes(a.status))
211 const lines = live.slice(0, AGENTS_MAX).map(a => {
212 const desc = oneLine(a.description ?? '')
213 return `${AGENT_LINE} ${oneLine(a.name || a.type)} [${a.id}] ${a.status}${desc ? ` — ${desc.slice(0, AGENT_DESC_MAX)}` : ''}`
214 })
215 if (live.length > AGENTS_MAX) lines.push(`${AGENT_LINE} +${live.length - AGENTS_MAX} more, not listed`)
216 return lines
217}
218
219const uniq = (lines: string[]) => [...new Set(lines)]
220
221export type AssembleInput = {
222 stream: string
223 saved: string
224 sid: string
225 prev: string | null
226 fork: Keyed
227 // In-flight agent lines (agentLines()), written by code; rendered first in in_progress and never cut.
228 agents?: string[]
229}
230export type Assembled =
231 | { kind: 'ok'; state: string; cut: string[]; degraded: boolean }
232 | { kind: 'blocked'; reason: string }
233
234// The fork designated every field; code merges nothing but the header and the agent lines, then enforces the cap.
235export function assemble(input: AssembleInput): Assembled {
236 const prev = input.prev ? parseNotes(input.prev) : null
237 const fields = input.fork
238
239 const lists: Record<string, string[]> = {}
240 for (const sec of LISTS) {
241 let own = uniq((fields.lists[sec] ?? []).map(oneLine).filter(l => l !== ''))
242 if (sec === 'decisions') own = own.map(l => (l.includes('—') ? l : `${l} — why missing`))
243 if (sec === 'in_progress') own = own.filter(l => !l.startsWith(AGENT_LINE) && l !== AGENTS_UNKNOWN) // code writes those, never the fork
244 lists[sec] = own
245 }
246 if (lists.unknowns?.length === 0) lists.unknowns = ['nothing recorded — treat every fact as unverified']
247
248 const prevWriter = (prev?.scalars.saved ?? '').split(/\s+/)[1]
249 const previousSession = prevWriter && prevWriter !== input.sid ? prevWriter : prev?.scalars.previous_session || 'none'
250 const scalars: Record<string, string> = {
251 stream: input.stream,
252 saved: `${input.saved} ${input.sid}`,
253 status: oneLine(fields.scalars.status || prev?.scalars.status || 'unknown'),
254 previous_session: previousSession,
255 goal: oneLine(fields.scalars.goal || prev?.scalars.goal || 'unknown'),
256 deliverable: oneLine(fields.scalars.deliverable || prev?.scalars.deliverable || 'none'),
257 }
258
259 // The overflow line is the last line of stale, outside `lists` so no cut step can drop it; the agent lines sit the same way in front of in_progress.
260 let overflowLine: string | null = null
261 let degraded = false
262 const render = () => {
263 const header = { ...scalars, status: degraded ? `degraded (${scalars.status})` : scalars.status }
264 const head = SCALARS_HEADER.map(k => `${k}: ${header[k]}`).join('\n')
265 const agents = input.agents ?? []
266 const shown = (k: string) => {
267 const list = lists[k] ?? []
268 return k === 'stale' && overflowLine ? [...list, overflowLine] : k === 'in_progress' ? [...agents, ...list] : list
269 }
270 const body = BODY_ORDER.map(k => (k === 'deliverable' ? `deliverable: ${scalars.deliverable}` : `${k}:${shown(k).map(l => `\n- ${l}`).join('')}`)).join('\n')
271 return `${head}\n\n${body}\n`
272 }
273
274 // Hard cap, enforced here whatever the fork wrote. Cut order: read_if_needed (compressed), stale, in_progress, discarded, learnings (each oldest first).
275 // Still over: what is left of read_if_needed, then next, unknowns, decisions. Never cut: header scalars, read_first, deliverable, the agent lines.
276 const cut: string[] = []
277 let text = render()
278 const over = () => text.length > NOTES_MAX
279 if (over()) {
280 degraded = true
281 text = render()
282 }
283 const dropped: Record<string, number> = {}
284 const drop = (sec: string) => {
285 ;(lists[sec] as string[]).shift()
286 dropped[sec] = (dropped[sec] ?? 0) + 1
287 text = render()
288 }
289 if (over()) {
290 const before = (lists.read_if_needed as string[]).join('\n')
291 lists.read_if_needed = (lists.read_if_needed as string[]).map(l => (l.length <= SHRINK_LINE ? l : `${l.slice(0, SHRINK_LINE - 1)}…`))
292 text = render()
293 if ((lists.read_if_needed as string[]).join('\n') !== before) cut.push('read_if_needed compressed')
294 }
295 for (const sec of ['stale', 'in_progress', 'discarded', 'learnings']) {
296 while (over() && (lists[sec] as string[]).length > 0) drop(sec)
297 }
298 if (over()) {
299 // The cut is announced in stale, inside the measured text.
300 const overflow = () =>
301 `overflow: cut to fit ${NOTES_MAX} chars: ${Object.entries(dropped).map(([s, n]) => `${s} -${n}`).join(', ') || 'nothing yet'}`
302 overflowLine = overflow()
303 text = render()
304 for (const sec of ['read_if_needed', 'next', 'unknowns', 'decisions']) {
305 while (over() && (lists[sec] as string[]).length > 0) {
306 drop(sec)
307 overflowLine = overflow()
308 text = render()
309 }
310 }
311 }
312 for (const [sec, n] of Object.entries(dropped)) cut.push(`${sec} -${n}`)
313 if (over()) {
314 return {
315 kind: 'blocked',
316 reason: `notes ${text.length} chars > ${NOTES_MAX} after cutting ${cut.join(', ') || 'nothing cuttable'}; the header, read_first and the agent lines alone exceed the cap: shorten read_first by hand. Nothing written.`,
317 }
318 }
319 return { kind: 'ok', state: text, cut, degraded: degraded }
320}
321
322export function forkPrompt(prev: string | null): string {
323 const n = (v: number) => v.toLocaleString('en-US')
324 const room = NOTES_MAX - (prev?.length ?? 0)
325 const budget = `SIZE: the notes file may not exceed ${n(NOTES_MAX)} characters; past it code cuts, in this order, read_if_needed, stale, in_progress, discarded, learnings, then the rest. PREVIOUS NOTES are ${n(prev?.length ?? 0)} characters: ${room >= 0 ? `${n(room)} left` : `${n(-room)} over`} for what this conversation adds.${room < 1_500 ? ' Room is tight: drop obsolete lines and shorten the rest.' : ''}`
326 return `${FORK_PROMPT}\n\n${budget}\n\nPREVIOUS NOTES:\n${prev ?? '(none)'}`
327}
328
329// The text injected as the first message: the notes, then the rules.
330export const injectText = (notes: string) => `${notes.trimEnd()}\n\n${UNPACK_RULES}`
331
332function forkFailure(reason: string): string {
333 return reason === 'nothing-to-fork' ? 'nothing to save yet: this conversation has no response' : `fork failed (${reason})`
334}
335
336// ---------- module state (survives /clear, lost on mod reload; the pending injection is backed up in $.store) ----------
337
338type Pending = { text: string; createdAt: number; cwd: string; stream: string }
339
340let pending: Pending | null = null
341// A pit is running (the fork is slow): refuse a second one meanwhile.
342let saving = false
343// Stream binding. Same session across /clear, so it survives /clear; reset on startup/resume/fork.
344let boundArg: string | null = null
345let titleStream: string | null = null
346// The single status line: running flow, then last sticky outcome, then the session-start hint.
347let flowLine: string | undefined
348let ticker: { cancel: () => void } | null = null
349let outcome: string | undefined
350let hint: string | undefined
351
352const current = () => resolveStream(boundArg, titleStream)
353
354function paint($: EngineInterface) {
355 $.ui.status(flowLine ?? outcome ?? hint)
356}
357
358function flowStatus($: EngineInterface, text: string | undefined) {
359 stopProgress()
360 flowLine = text
361 paint($)
362}
363
364// A flow line with its elapsed time, ticking every second until the next flowStatus().
365function startProgress($: EngineInterface, label: string) {
366 flowStatus($, progressText(label, 0))
367 let ms = 0
368 ticker = $.clock.every(1000, () => {
369 ms += 1000
370 flowLine = progressText(label, ms)
371 paint($)
372 })
373}
374
375function stopProgress() {
376 ticker?.cancel()
377 ticker = null
378}
379
380function fail($: EngineInterface, text: string) {
381 outcome = `⚠ ${shortReason(text)}`
382 $.ui.toast(text, { timeoutMs: ERROR_TOAST_MS })
383 flowStatus($, undefined)
384}
385
386// ---------- disk ----------
387
388async function readOptional($: EngineInterface, path: string): Promise<string | null> {
389 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
390}
391
392// Streams that have a notes file in the session cwd; a missing directory is no notes.
393async function listStreams($: EngineInterface): Promise<string[]> {
394 try {
395 const entries = await $.fs.list(`${(await $.session.cwd()).replace(/\/+$/, '')}/${NOTES_DIR}`)
396 return streamsFromNames(entries.filter(e => e.kind === 'file').map(e => e.name))
397 } catch {
398 return []
399 }
400}
401
402// $.agent.list() raced against AGENT_LIST_MS. Called BEFORE the fork starts, so the fork never lists itself. Never fails the pit.
403async function agentLines($: EngineInterface): Promise<string[]> {
404 let timer: { cancel: () => void } | null = null
405 try {
406 const timeout = new Promise<'timeout'>(resolve => {
407 timer = $.clock.after(AGENT_LIST_MS, () => resolve('timeout'))
408 })
409 const listed = await Promise.race([$.agent.list(), timeout])
410 if (listed === 'timeout') return [AGENTS_UNKNOWN]
411 return agentLinesOf(listed)
412 } catch {
413 return []
414 } finally {
415 ;(timer as { cancel: () => void } | null)?.cancel()
416 }
417}
418
419// ---------- save ----------
420
421type Built = { kind: 'ok'; state: string; cut: string[]; degraded: boolean } | { kind: 'blocked'; reason: string }
422
423async function askFork($: EngineInterface, prompt: string): Promise<{ kind: 'ok'; fork: Keyed } | { kind: 'blocked'; reason: string }> {
424 const reply = await $.model.fork({ prompt })
425 if (!reply.isAnswered) return { kind: 'blocked', reason: forkFailure(reply.reason) }
426 const parsed = parseFork(reply.text)
427 if (parsed.kind === 'blocked') return parsed
428 const fork = parseForkBody(parsed.body)
429 return fork ? { kind: 'ok', fork } : { kind: 'blocked', reason: 'unparseable fork output' }
430}
431
432async function buildNotes($: EngineInterface, cwd: string, stream: string): Promise<Built> {
433 try {
434 const sid = await $.session.id()
435 const path = notesPath(cwd, stream)
436 const prev = await readOptional($, path)
437 const agents = await agentLines($)
438 // One fork per save: an over-cap answer is cut by assemble(), never re-asked.
439 const asked = await askFork($, forkPrompt(prev))
440 if (asked.kind === 'blocked') return asked
441 const result = assemble({ stream, saved: new Date(await $.clock.now()).toISOString(), sid, prev, fork: asked.fork, agents })
442 if (result.kind === 'blocked') return result
443 await $.fs.write(path, result.state)
444 if (!(await $.fs.exists(path))) return { kind: 'blocked', reason: `${path} not found after writing` }
445 return result
446 } catch (err) {
447 return { kind: 'blocked', reason: `error: ${String(err)}` }
448 }
449}
450
451// Context fill against the wall; null when the host gives no figure (the pit then goes on).
452async function headroom($: EngineInterface): Promise<{ tokens: number; wall: number } | null> {
453 try {
454 const { context } = await $.session.usage({ breakdown: 'summary' })
455 const b = context.breakdown
456 const tokens = context.tokens ?? b?.totalTokens
457 const wall = b?.autoCompactThreshold ?? b?.rawMaxTokens ?? context.window
458 return tokens !== undefined && wall > 0 ? { tokens, wall } : null
459 } catch (err) {
460 $.ui.log(`stint: context figures unavailable, headroom not checked: ${String(err)}`, { to: 'debug' })
461 return null
462 }
463}
464
465async function persist($: EngineInterface) {
466 if (pending) await $.store.set(STORE_KEY, pending satisfies Pending)
467}
468
469async function reset($: EngineInterface) {
470 pending = null
471 await $.store.delete(STORE_KEY)
472 flowStatus($, undefined)
473}
474
475const READY = 'Ready: type /clear to continue in a fresh session'
476
477// The notes to re-inject after /clear: module var first, else a fresh store entry from this cwd.
478async function armedPending($: EngineInterface): Promise<Pending | null> {
479 const stored = pending ?? ((await $.store.get(STORE_KEY)) as Pending | undefined)
480 if (!stored) return null
481 if ((await $.clock.now()) - stored.createdAt >= STALE_MS) return null
482 if (stored.cwd !== (await $.session.cwd())) return null
483 return stored
484}
485
486// ---------- inject (shared by the fresh start after /clear and /rejoin) ----------
487
488// Injection = $.prompt.submit({ asUser: true }): it starts a turn, so UNPACK_RULES can hold the model at its acknowledgement line.
489// The host REFUSES prompt.submit called from inside the command.run hook, so /rejoin passes `defer` and the submit runs from a $.clock.after
490// dispatch, after the hook returned. SessionStart submits directly.
491function inject($: EngineInterface, stream: string, notes: string, defer: boolean) {
492 const run = () => {
493 $.prompt.submit({ text: injectText(notes), asUser: true }).catch(err => {
494 fail($, `Loading the notes of stream "${stream}" failed: ${String(err)}. Try /rejoin ${stream}`)
495 })
496 }
497 if (defer) $.clock.after(1, run)
498 else run()
499}
500
501// ---------- commands ----------
502
503async function pit($: EngineInterface, args: string) {
504 const parsed = parseArgs(args, 'pit')
505 if ('error' in parsed) return { text: `pit: ${parsed.error}` }
506 if (saving) return { text: `pit: a pit is already running, its outcome will show in the status line` }
507
508 const cwd = await $.session.cwd()
509 const room = await headroom($)
510 if (room && overWall(room.tokens, room.wall)) {
511 const pct = Math.round((room.tokens / room.wall) * 100)
512 const text = `pit: context is at ${pct}% of the limit where the host compacts on its own (${room.tokens} of ${room.wall} tokens); the save runs in this context and would fail there. Nothing saved, the session is unchanged.`
513 outcome = `⚠ Pit refused: context at ${pct}% of the compaction limit, nothing saved`
514 paint($)
515 return { text }
516 }
517
518 // Explicit argument binds for the session; a new command replaces the last outcome.
519 if (parsed.stream !== undefined) boundArg = parsed.stream
520 outcome = undefined
521 hint = undefined
522 const stream = current()
523
524 saving = true
525 startProgress($, `Pitting stream "${stream}"`)
526 let built: Built
527 try {
528 built = await buildNotes($, cwd, stream)
529 } finally {
530 saving = false
531 }
532 if (built.kind === 'blocked') {
533 // Rail 1: nothing is cleared, the session stays as it is.
534 fail($, `Pit of stream "${stream}" failed: ${built.reason.replace(/\.$/, '')}. Session kept.`)
535 return { text: `pit: could not save stream "${stream}": ${built.reason.replace(/\.$/, '')}. No /clear run, the session is unchanged.` }
536 }
537
538 pending = { text: built.state, createdAt: await $.clock.now(), cwd, stream }
539 await persist($)
540 flowStatus($, 'Starting a fresh session...')
541 // $.command.run rejects inside the hook the run waits on, so the /clear runs from a $.clock.after dispatch, after the hook returned.
542 // If the host still refuses, the human types /clear.
543 $.clock.after(1, () => {
544 $.command.run({ command: 'clear' }).catch(() => flowStatus($, READY))
545 })
546 const notes = [`${built.state.length.toLocaleString('en-US')} chars`]
547 if (built.cut.length) notes.push(`over the cap, cut: ${built.cut.join(', ')}`)
548 return { text: `pit: stream "${stream}" saved to ${NOTES_DIR}/${stream}-notes.md (${notes.join(', ')}). Starting a fresh session; if nothing happens, type /clear.` }
549}
550
551async function rejoin($: EngineInterface, args: string) {
552 const parsed = parseArgs(args, 'rejoin')
553 if ('error' in parsed) return { text: `rejoin: ${parsed.error}` }
554 outcome = undefined
555 const cwd = await $.session.cwd()
556 const streams = await listStreams($)
557 const list = streams.length ? ` Notes here: ${streams.join(', ')}.` : ''
558
559 let stream = parsed.stream
560 if (stream === undefined) {
561 // No argument: the bound stream when it has notes, else the only notes file there is.
562 if ((boundArg ?? titleStream) !== null && streams.includes(current())) stream = current()
563 else if (streams.length === 1) stream = streams[0]
564 else if (streams.length > 1) return { text: `rejoin: several notes here, pick one with /rejoin <stream>: ${streams.join(', ')}` }
565 else return { text: `rejoin: no notes in ${NOTES_DIR}/ of this folder. Nothing loaded.` }
566 }
567 const chosen = stream as string
568
569 let notes: string | null
570 try {
571 notes = await readOptional($, notesPath(cwd, chosen))
572 } catch (err) {
573 return { text: `rejoin: cannot read the notes of stream "${chosen}": ${String(err)}` }
574 }
575 if (notes === null) return { text: `rejoin: no notes for stream "${chosen}" in this folder. Nothing loaded.${list}` }
576
577 boundArg = chosen
578 hint = undefined
579 paint($)
580 inject($, chosen, notes, true)
581 return { text: `rejoin: loading stream "${chosen}" as the first message.` }
582}
583
584// ---------- hooks ----------
585
586function noteCaught($: EngineInterface, hook: string, error: { kind: string; message?: string }) {
587 try {
588 $.ui.log(`stint: ${hook} hook ${error.kind}${error.message ? `: ${error.message}` : ''}`, { to: 'debug' })
589 } catch {
590 // nothing left to do
591 }
592}
593
594async function noteTitle(title: string | undefined) {
595 if (title === undefined) return
596 const slug = slugify(title)
597 if (slug !== null) titleStream = slug
598}
599
600export function registerPit(on: On) {
601 on('session.start', async ($, e, next) => {
602 const commands = [
603 { name: 'pit', description: 'Save the notes of a stream, then continue it in a fresh session', argumentHint: '[stream]' },
604 { name: 'rejoin', description: 'Load the notes of a stream as the first message of this session', argumentHint: '[stream]' },
605 ]
606 for (const command of commands) {
607 try {
608 await $.command.register(command)
609 } catch (err) {
610 $.ui.log(`stint: command ${command.name} not registered: ${String(err)}`)
611 }
612 }
613 return next(e)
614 })
615
616 on('command.run', { command: 'pit' }, ($, e) => pit($, e.args)).catch(($, e, next) => {
617 noteCaught($, 'command.run pit', next.error)
618 saving = false
619 stopProgress()
620 return { text: 'pit: failed unexpectedly, nothing was cleared. Run it again.' }
621 })
622 on('command.run', { command: 'rejoin' }, ($, e) => rejoin($, e.args)).catch(($, e, next) => {
623 noteCaught($, 'command.run rejoin', next.error)
624 return { text: 'rejoin: failed unexpectedly, nothing was loaded. Run it again.' }
625 })
626
627 on('classic.UserPromptSubmit', async ($, e, next) => {
628 // The hint was seen: it goes at the first prompt.
629 if (hint !== undefined) {
630 hint = undefined
631 paint($)
632 }
633 await noteTitle(e.session_title)
634 return next(e)
635 }).catch(($, e, next) => {
636 noteCaught($, 'classic.UserPromptSubmit', next.error)
637 return next(e)
638 })
639
640 on('classic.SessionStart', async ($, e, next) => {
641 hint = undefined
642 outcome = undefined
643 if (e.source !== 'clear') {
644 // A new, resumed or forked session is another session: unbind and drop any armed injection.
645 if (e.source === 'startup' || e.source === 'resume' || e.source === 'fork') {
646 boundArg = null
647 titleStream = null
648 pending = null
649 saving = false
650 stopProgress()
651 flowLine = undefined
652 }
653 await noteTitle(e.session_title)
654 if (boundArg === null) {
655 const streams = await listStreams($)
656 if (streams.length > 0) hint = hintText(streams)
657 }
658 paint($)
659 return next(e)
660 }
661
662 await noteTitle(e.session_title)
663 const armed = await armedPending($)
664 await reset($)
665 if (armed === null) return next(e)
666
667 // Rail 3: the binding survives /clear; restore it when a mod reload lost it.
668 if (boundArg === null) boundArg = armed.stream
669 inject($, armed.stream, armed.text, false)
670 $.ui.toast(`Fresh session started with the notes of stream "${armed.stream}"`)
671 return next(e)
672 }).catch(($, e, next) => {
673 noteCaught($, 'classic.SessionStart', next.error)
674 return next(e)
675 })
676}
677