SLOPSHOPPER

stint

Run your agent in stints. Pit before the redline.

newcommandtoaststatusmodeltimer
★ 20v0.1.0MITupdated 2026-10-26digital-stoic-org/agent-skills/stint
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stint
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ stint │ ⏺ Read(src/auth.ts) │ Pit of stream "main" failed: malformed │ ⎿ Read 6 lines │ first line: OK. Session kept. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pit ⎿ stint: pit: could not save stream "main": malformed first line: OK. No /clear run, the session is unchanged. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ stint: ⚠ Pit of stream "main" failed: malformed first line: OK. Session kept.
README

stint

Run your agent in stints. Pit before the redline.

Requires Claude Code 2.1.275+.

Install

/plugin install stint --marketplace digital-stoic-org/agent-skills

Load from a local copy

claude --plugin-dir <path-to>/stint
claude plugin validate <path-to>/stint --strict
claude plugin test <path-to>/stint

Commands

commanddoes
/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.

Notes file

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

License

MIT

Source 2 files
hooks/register.ts 8 lines
1import type { Register } from 'claude-code'
2
3import { registerPit } from './pit.ts'
4
5export const register: Register = on => {
6  registerPit(on)
7}
8
hooks/pit.ts 677 lines
1import 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