SLOPSHOPPER

trail

A side pane that tells you what happened in a long conversation: what was done in a few words, what is waiting on you with the command or link to act on it…

newpanerowsguardcommandtoast
v0.5.0MITupdated 2026-10-10thats2easyyy/claude-trail
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · trail
│ ┃ Trail ✕ › fix the failing auth test and add an audit log call │ ┃ highlights outputs every prompt │ ┃ 0 updates · quiet since Thu Oct 9 08:53 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Nothing yet. The feed fills in as the ⏺ Update(src/auth.ts) │ ┃ conversation goes. ⎿ 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 │ │ › /trail │ ⎿ trail: Trail opened. Point at an entry and click the arrow besid │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Trail
highlights outputs every prompt 0 updates · quiet since Thu Oct 9 08:53 Nothing yet. The feed fills in as the conversation goes.
README

Trail

A Claude Code mod for coming back to a long conversation and not knowing what happened. It adds a pane on the right that lists what was done, newest at the top, and keeps a separate list of anything waiting on you.

 1 waiting on you   highlights   outputs  every prompt
12 updates · 1 waiting on you · quiet since Fri Oct 9 16:13

Fri Oct 9 ────────────────────────────────────────

16:10
• Opened the release pull request
• Release build passing its checks

15:42
• Fixed the settings screen layout
• Found the spacing bug in the sidebar

The pane has these views:

  • highlights: what was done, as a few short actions for each stretch of work, newest first
  • outputs: the screenshots, videos, files and links Claude made or sent you, each by name with [open]. One you have not opened yet is in bold; once opened it is dimmed
  • every prompt: each thing you asked, newest first
  • waiting on you: appears only while something waits on you. It lists what the work left for you to do (a command to run, a decision, something to try), each with [done]. Under each is what you need to do it: the command, with [copy], and any link, with [open]. A pull request named by number gets its link. Commands are only ever copied, never run. Questions Claude asked in the pane are here too, and you answer them there

Nothing is drawn in the pane and nothing is copied: a picture or a file is named, described in a line, and opened where it lies.

Hover an entry and press → to jump to that point in the conversation.

Requirements

  • Claude Code 2.1.290 or later
  • macOS ([open] uses open)
  • Node 22.18 or later on your PATH, used to read a conversation back from its transcript

Install

In a terminal session of Claude Code, type:

/plugin install trail --marketplace thats2easyyy/claude-trail

Answer y to add the marketplace, then press Enter to install it for yourself. It is active from then on, in every session.

To get a newer version later, run claude plugin update trail@claude-trail in your shell and restart Claude Code. A session that is already open keeps the version it started with.

Use

TypeWhat it does
/trailOpens the pane and writes the latest updates
/trail closeCloses it, and keeps it closed in new sessions until you type /trail. A closed pane uses no tokens
/trail describeAsks the conversation what its recent pictures show (expensive: see what it costs)
/trail rebuildReads the whole conversation again from its transcript, and looks again at the latest of it for what is waiting on you
/trail whereSays where this session's trail is kept and how much disk the mod is using

In a terminal at least 144 columns wide the pane opens by itself when a session starts. A conversation that began before you installed the mod is read back from its transcript the next time you resume it.

What it costs

Nothing while the pane is closed. The mod notes each step as it happens, which is free, and asks no model for anything.

While the pane is showing, it makes one kind of model call, on the session's own model:

  • The update, after each turn. One small call (around 2,000 tokens) writes the update, spots anything waiting on you, and describes the turn's pictures and files from what the conversation said about them.

That is the only call it makes by itself.

When you open the pane later, one call writes the latest of what you missed, including what is waiting on you. Anything further back stays unsummarised until you press [summarise].

/trail describe asks the conversation itself what its recent pictures show. That gives a better description, but it re-reads the whole conversation, which in a long one is hundreds of thousands of tokens, so it only ever happens when you type it.

A session nobody is at (claude -p, a script) is ignored entirely.

What it keeps on disk

One small file per conversation, in ~/.claude/trail/<session id>/: its trail, a few kilobytes of text. No pictures or copies of files are kept.

A conversation's folder is removed once it has been idle for as long as Claude Code keeps transcripts (cleanupPeriodDays in your settings, 30 days by default). Versions before 0.5 kept copies of pictures there; those are removed once a conversation has been idle a day. The cleanup runs once a day, from whichever session starts first.

Working on it

claude plugin validate .
claude plugin test .

To run a working copy instead of the installed one, name its folder in CLAUDE_CODE_PLUGIN_DIRS (in the env block of ~/.claude/settings.json) and uninstall the other.

Licence

MIT. See LICENSE.

Source 8 files
hooks/register.tsx 1228 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Beat, Mode, OutputType, Picked, Question, Saved, Shot, Source, Trail } from '../types'
5import { adopt, backlogOf, feedIn, nextJob, parseBeats, parseCleared, parsePictures, SYSTEM, withPullLinks } from './digest.ts'
6import { captionJob, parseCaptions } from './caption.ts'
7import { candidates, reach } from './jump.ts'
8import { isSession, keepDays, prunable, sized, staleCopies } from './prune.ts'
9import type { Kept } from './prune.ts'
10import { add, basename, clean, emptyTrail, isEarlier, mended, outputType, paneAnswer, publishedAt, SENT, tag, toolNote, videosNamed, waitingCount } from './trail.ts'
11import type { TrailEvent } from './trail.ts'
12import { clock, layout, zoneOf } from './view.ts'
13import type { Target } from './view.ts'
14
15/**
16 * Trail: what this conversation did, as a feed in a side pane.
17 *
18 * Hooks on the conversation's rows and tool calls feed `add`, which folds
19 * them into steps: what was asked, what the model said it was doing with a
20 * count of the work under it, the pictures and files it looked at or made,
21 * how each turn came out. Noting these costs nothing. While the pane is
22 * showing, and only then, a model reads the steps a stretch at a time and
23 * writes an update for each, a few sentences on what was done and what came
24 * of it, the way a match is told as it goes, and says what is left waiting
25 * on the person. Those updates, newest first, with what the work produced
26 * between them, are what the pane lists; what the person asked is one key
27 * away. Nothing is drawn and nothing is copied: a picture or a file is told
28 * of in words and opened where it lies, and reads as new until it has been.
29 * Claude can also put a question to the person in the pane, and their answer
30 * goes back as their next message.
31 *
32 * The trail is kept in a file, by session, so it is there when the session
33 * is opened again; a session that ran before the mod did is read back from
34 * its transcript file by `tools/backfill.ts`.
35 */
36
37const PANE = 'trail'
38const TITLE = 'Trail'
39const COLUMNS = 62
40/** Cells of empty margin each side of the pane's body. */
41const MARGIN = 1
42/** The pane is drawn again for a change no more often than this. */
43const PUSH_MS = 800
44const SAVE_MS = 5000
45const RM = '/bin/rm'
46const DU = '/usr/bin/du'
47/** How often, across every session on the machine, what is past keeping is looked for; and how long after a session starts. */
48const PRUNE_EVERY_MS = 86_400_000
49const PRUNE_AFTER_MS = 8000
50/** How often a drawing of the pane may set the writing of updates going. */
51const NUDGE_MS = 5000
52const COPIES_AT_ONCE = 40
53const OPEN = '/usr/bin/open'
54const DATE = '/bin/date'
55const REBUILD_MS = 120_000
56const USAGE = 'Usage: /trail [close|describe|rebuild|where]'
57const OPENED = 'Trail opened. Point at an entry and click the arrow beside its time to jump to it in the conversation.'
58/**
59 * The way to an entry's place in the conversation: an arrow beside its time,
60 * there only while the pointer is over the entry. Actions are a word or two
61 * at most, so the pane is the work and not its controls.
62 */
63const GO = '→'
64/** The same, where no pointer rests: on a question, which is not passed over but answered. */
65const GO_WORD = '[jump]'
66const NOTED = 60
67const JOURNAL_MOST = 5000
68/** Highlights are written a moment after there is something to write them from, one stretch at a time. */
69const DIGEST_MS = 1500
70const DIGEST_NEXT_MS = 300
71const DIGEST_RETRY_MS = 60_000
72const DIGEST_TIMEOUT_MS = 90_000
73const DIGEST_TOKENS = 1400
74/** Captions are asked for a little after a turn ends, while the conversation is fresh in the provider's cache. */
75/**
76 * The session's own model writes the updates: a stretch is a page of log and a few lines of reply, and a
77 * small model was seen to report what was asked for as done. The small one stands in where the session's cannot be asked.
78 */
79const DIGEST_FALLBACK = 'haiku'
80/** The tool as the model calls it. Matched by pattern: the engine's list of tool names is written before this one exists. */
81const ASK_TOOL = /^mcp__trail__ask$/
82const ASK_DESCRIPTION = [
83  'Puts a question to the person in their Trail side pane, where it stays until they answer, optionally with a picture it is about for them to open.',
84  'Use it whenever you need them to confirm or choose something, and always when the question is about an image: pass the image so they can open it from the question ("Is this the garage where you put the letter H?").',
85  'It is the right way to ask when they may be away from the conversation. It does not wait: their answer arrives later as their next message, so end your turn if you cannot go on without it, or carry on with what does not depend on it.',
86].join(' ')
87const ASK_SCHEMA = {
88  type: 'object',
89  properties: {
90    question: { type: 'string', description: 'The question, in one or two plain sentences.' },
91    options: { type: 'array', items: { type: 'string' }, description: 'Up to four short answers to offer as buttons, such as "Yes" and "No". They can always type their own.' },
92    image: { type: 'string', description: 'Absolute path of a picture the question is about, for the person to open from it.' },
93  },
94  required: ['question'],
95}
96
97const TYPED_MEANWHILE = /The user sent a new message while you were working:\s*([\s\S]*?)\s*(?:\n\s*This is how Claude Code surfaces|<\/system-reminder>|$)/
98
99const EMPTY: Saved = { v: 5, sid: '', trail: emptyTrail() }
100const NONE: Picked = { id: '' }
101const saved = atom({ plugin: 'trail', key: 'saved' } as const, EMPTY)
102const picked = atom({ plugin: 'trail', key: 'picked' } as const, NONE)
103const page = atom({ plugin: 'trail', key: 'page' } as const, 0)
104const mode = atom({ plugin: 'trail', key: 'mode' } as const, 'feed')
105const status = atom({ plugin: 'trail', key: 'status' } as const, '')
106
107type Dollar = EngineInterface
108type Block = { type: string; [field: string]: unknown }
109type Message = { type: string; name?: string; isMeta?: true; content: readonly Block[] }
110type Held = { data: string; type: string }
111
112// The engine reads this file to see what the mod calls: `$` may only be handed to functions declared here at
113// the top of it, so everything that needs `$` is one, and what they share is the module's own.
114
115/** The trail as it stands; the session's state has it as of the last push, the file as of the last save. */
116let live: Trail = emptyTrail()
117/** The session the trail is of, and where its file and pictures are; empty until `begin` has run. */
118let sid = ''
119let dir = ''
120let zone = 0
121let starting: Promise<void> | null = null
122let isRebuilding = false
123/**
124 * What happened since the transcript file was last trusted to hold everything: kept from the moment a trail
125 * begins until its transcript has been read back, to lay over what the file held.
126 */
127let journal: TrailEvent[] | null = null
128let pushTimer: Timer | null = null
129let lastSavedAt = 0
130
131let digestTimer: Timer | null = null
132let isDigesting = false
133/** The model the updates are written with, once one has answered. */
134let digestModel = ''
135/** Steps a model was asked about and gave nothing readable for: they stay as steps rather than be asked about again. */
136const skipped = new Set<string>()
137
138/** The person asked for the updates of what went untold while the pane was closed: written until there are none left. */
139let wantsEarlier = false
140/** When a drawing of the pane last set the writing of updates going: a pane brought to the front has no other event. */
141let nudgedAt = 0
142/**
143 * Nobody is at this session: it was started to run one prompt and print the answer (`claude -p`, a script, another
144 * tool's call). It has no pane to show and may be one of hundreds, so nothing of it is noted, kept or put to a
145 * model. Picked up again by a person, it is read back from its transcript like any session that ran without the mod.
146 */
147let isUnwatched = false
148let isWatchSettled = false
149
150/**
151 * Whether somebody is at this session. The session's start says so; an event
152 * that comes before it settles the same by whether the session draws anywhere.
153 */
154const watched = async ($: Dollar): Promise<boolean> => {
155  if (!isWatchSettled) {
156    isWatchSettled = true
157    const surfaces = await $.session.surfaces().catch(() => null)
158    if (surfaces !== null && surfaces.length === 0) isUnwatched = true
159  }
160  return !isUnwatched
161}
162let isCaptioning = false
163
164/** Questions the person has answered in the pane: a second press before the pane is drawn again sends nothing more. */
165const answered = new Set<string>()
166
167/** A folded run's id in the transcript, by each tool call in it: what a jump to a folded call scrolls to. */
168const groupsByCall = new Map<string, string>()
169
170/** What the mod saw, for whoever is working on it: written beside the trail as diag.json. */
171const diag = {
172  appends: [] as string[],
173  jumps: [] as string[],
174  shots: [] as string[],
175  rebuilds: [] as string[],
176  digests: [] as string[],
177  captions: [] as string[],
178  questions: [] as string[],
179  prunes: [] as string[],
180  faults: [] as string[],
181}
182
183const jot = (list: string[], line: string) => {
184  list.push(line)
185  if (list.length > NOTED) list.shift()
186}
187
188const fault = ($: Dollar, where: string, error: unknown) => {
189  jot(diag.faults, `${where}: ${String(error)}`)
190  $.ui.log(`trail: ${where}: ${String(error)}`, { to: 'debug' })
191}
192
193/** Why a call on the engine did not do what was asked: what it answered, or what it threw. */
194const whyNot = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 120)
195
196
197const save = async ($: Dollar, now: number) => {
198  if (!sid || !dir) return
199  lastSavedAt = now
200  const kept: Saved = { v: 5, sid, trail: live }
201  await $.fs.write(`${dir}/trail.json`, JSON.stringify(kept))
202  await $.fs.write(`${dir}/diag.json`, JSON.stringify({ at: now, sid, zone, items: live.items.length, updates: live.feed.length, model: digestModel, ...diag }, null, 1))
203}
204
205/** Hands the trail to the session's state, which draws the pane again, and to its file when that is due. */
206const push = async ($: Dollar, isSaving: boolean) => {
207  pushTimer?.cancel()
208  pushTimer = null
209  try {
210    const kept: Saved = { v: 5, sid, trail: live }
211    await update($, saved, () => kept)
212    const now = await $.clock.now()
213    if (isSaving || now - lastSavedAt >= SAVE_MS) await save($, now)
214  } catch (error) {
215    fault($, 'push', error)
216  }
217}
218
219const soon = ($: Dollar) => {
220  if (!pushTimer) pushTimer = $.clock.after(PUSH_MS, () => void push($, false))
221}
222
223const tell = async ($: Dollar, text: string) => {
224  try {
225    await update($, status, () => text)
226  } catch {
227    // The line is a courtesy; the feed goes on without it.
228  }
229}
230
231/**
232 * Puts a stretch of steps to a model and resolves its reply, or null when no
233 * model gave one. The first model to answer is the one asked from then on.
234 */
235const complete = async ($: Dollar, prompt: string): Promise<string | null> => {
236  const names = digestModel ? [digestModel] : [await $.session.model().catch(() => ''), DIGEST_FALLBACK]
237  for (const model of names.filter((name, index) => name !== '' && names.indexOf(name) === index)) {
238    try {
239      const reply = await $.model.complete({ model, system: SYSTEM, prompt, maxTokens: DIGEST_TOKENS, effort: 'low', timeoutMs: DIGEST_TIMEOUT_MS })
240      if (reply.isAnswered) {
241        digestModel = model
242        jot(diag.digests, `${model} in=${reply.usage.input_tokens} out=${reply.usage.output_tokens}`)
243        return reply.text
244      }
245      jot(diag.digests, `${model}: ${reply.reason}${reply.reason === 'api-error' ? ` ${reply.error} ${reply.status}` : ''}`)
246      // A call cut short says nothing about the model; one the provider refused may fare better on another.
247      if (reply.reason === 'aborted') return null
248    } catch (error) {
249      jot(diag.digests, `${model} refused: ${whyNot(error)}`)
250    }
251  }
252  return null
253}
254
255/**
256 * Writes the updates for the steps that are ready for them, one model call at
257 * a time and never from inside a hook the conversation is waiting on, and only
258 * while the pane is showing: a closed pane costs nothing. Opened, it writes
259 * the latest of what went untold; the history behind that waits until the
260 * person asks for it. Resolves how long to wait before the next.
261 */
262const digest = async ($: Dollar): Promise<number | null> => {
263  if (isDigesting || isRebuilding || !sid) return null
264  // The pane is the switch: nothing is asked of a model unless the person has the feed in front of them.
265  if (!(await isPaneShown($))) return null
266  const job = nextJob(live, skipped)
267  if (!job) {
268    wantsEarlier = false
269    await tell($, '')
270    return null
271  }
272  // What went untold while the pane was closed is history once the latest is told: written only when asked for.
273  if (job.prompt && !job.isLatest && !wantsEarlier) {
274    await tell($, '')
275    return null
276  }
277  const of = sid
278  isDigesting = true
279  try {
280    let beats: Beat[] = []
281    let cleared: string[] = []
282    let captions: { id: string; text: string }[] = []
283    if (job.prompt) {
284      await tell($, 'writing the latest update…')
285      const reply = await complete($, job.prompt)
286      if (of !== sid) return null
287      if (reply === null) {
288        // No model answered: the steps are shown as they are, and a while later it is tried again.
289        await tell($, 'updates are waiting on a model')
290        return DIGEST_RETRY_MS
291      }
292      // A pull request named by number gets its link, where the session's repository says where that is.
293      beats = withPullLinks(parseBeats(reply, job), await remoteOf($))
294      cleared = parseCleared(reply, job)
295      captions = parsePictures(reply, job)
296      if (beats.length === 0) {
297        for (const id of job.ids) skipped.add(id)
298        jot(diag.digests, `unreadable reply: ${reply.slice(0, 160)}`)
299        return DIGEST_NEXT_MS
300      }
301    }
302    live = await linked($, feedIn(live, job, beats, cleared, captions))
303    await push($, true)
304    return DIGEST_NEXT_MS
305  } catch (error) {
306    fault($, 'digest', error)
307    return DIGEST_RETRY_MS
308  } finally {
309    isDigesting = false
310  }
311}
312
313/** After `ms`, writes the updates the steps are ready for, and goes on until none is. */
314const digestSoon = ($: Dollar, ms: number) => {
315  if (digestTimer) return
316  digestTimer = $.clock.after(ms, () => {
317    digestTimer = null
318    void digest($).then(again => {
319      if (again !== null) digestSoon($, again)
320    })
321  })
322}
323
324/**
325 * Asks the conversation itself what its pictures show and what they were
326 * about, and sets the answers under them. The question is put over the whole
327 * transcript, so it is asked only just after a turn has ended, when the
328 * provider still has that transcript at hand, and for all the pictures at once.
329 */
330const isPaneShown = ($: Dollar): Promise<boolean> =>
331  $.ui.panes().then(
332    panes => panes.some(pane => pane.id === PANE && pane.isShown),
333    () => false,
334  )
335
336/**
337 * Asks the conversation itself what its pictures show, which is the one call
338 * that sees them and the one that costs: it reads the whole conversation
339 * again. So it is never made unasked: only when the person types
340 * `/trail describe`. Otherwise a picture keeps the caption the update's model
341 * made out from the words around it, which costs next to nothing. Resolves
342 * how many pictures it captioned, or null when it could not ask.
343 */
344const caption = async ($: Dollar): Promise<number | null> => {
345  if (isCaptioning || isRebuilding || !sid) return null
346  const job = captionJob(live, at => clock(at, zone))
347  if (!job) return 0
348  const of = sid
349  isCaptioning = true
350  try {
351    const reply = await $.model.fork({ prompt: job.prompt })
352    if (of !== sid) return null
353    if (!reply.isAnswered) {
354      jot(diag.captions, `no answer: ${reply.reason}`)
355      return null
356    }
357    const told = parseCaptions(reply.text, job)
358    jot(diag.captions, `${told.filter(one => one.text).length} of ${job.shots.length} captioned, cache read ${reply.usage.cache_read_input_tokens}, in ${reply.usage.input_tokens}, out ${reply.usage.output_tokens}`)
359    for (const one of told) await record($, now => ({ t: 'caption', id: one.id, at: now, text: one.text }))
360    await push($, true)
361    return told.filter(one => one.text).length
362  } catch (error) {
363    fault($, 'caption', error)
364    return null
365  } finally {
366    isCaptioning = false
367  }
368}
369
370const load = async ($: Dollar): Promise<Trail | null> => {
371  try {
372    const path = `${dir}/trail.json`
373    if (!(await $.fs.exists(path))) return null
374    const kept = JSON.parse(await $.fs.read(path)) as Partial<Saved>
375    return kept.v === 5 && kept.sid === sid ? mended(kept.trail) : null
376  } catch (error) {
377    fault($, 'load', error)
378    return null
379  }
380}
381
382/**
383 * Reads the session's transcript file back into a trail, pictures included: a
384 * host command, since the file is far larger than a mod may read. What only
385 * the mod knew (the updates written so far, the answers given in the pane)
386 * is carried over. Asked for by the person, the latest of it is put to the
387 * model again, which is how a session told before the mod knew to look finds
388 * what is waiting on them. Resolves why not when it could not ('' when it did).
389 */
390const rebuild = async ($: Dollar, isAsked = false): Promise<string> => {
391  if (isRebuilding) return 'already rebuilding'
392  const [of, into] = [sid, dir]
393  if (!of || !into) return 'no session yet'
394  isRebuilding = true
395  journal ??= []
396  let why = ''
397  try {
398    const had = live
399    // Run from the mod's own folder: in the session's, a project's pin may pick a Node that cannot run the tool.
400    const ran = await $.process.run(['node', `${$.plugin.root}/tools/backfill.ts`, of, into], { cwd: $.plugin.root, timeoutMs: REBUILD_MS })
401    const built = ran.exitCode === 0 && of === sid ? await load($) : null
402    if (ran.exitCode !== 0) why = (ran.stderr || ran.stdout || `exit ${ran.exitCode}`).trim().slice(-200)
403    else if (of !== sid) why = 'the session changed meanwhile'
404    else if (!built) why = 'nothing readable was written'
405    else {
406      live = await linked($, journal.reduce(add, adopt(built, had, isAsked)))
407      await push($, true)
408    }
409    jot(diag.rebuilds, why || ran.stdout.trim().slice(0, 300))
410  } catch (error) {
411    why = whyNot(error)
412    fault($, 'rebuild', error)
413  } finally {
414    isRebuilding = false
415    journal = null
416  }
417  digestSoon($, DIGEST_MS)
418  return why
419}
420
421/**
422 * Removes what the mod kept for other sessions that is past keeping: the
423 * pictures of one idle a week, the whole folder of one idle as long as Claude
424 * Code keeps a transcript, and in those that stay the whole-size copies made
425 * a week ago or more. Once a day at most, whichever session comes to it
426 * first, and never this session's own. Only folders the mod made, named by a
427 * session's id inside the mod's own folder, are ever removed.
428 */
429const prune = async ($: Dollar) => {
430  try {
431    const root = dir.slice(0, dir.lastIndexOf('/'))
432    if (!sid || !root.startsWith('/') || !root.endsWith('/.claude/trail')) return
433    const now = await $.clock.now()
434    const last = await $.store.get('prunedAt')
435    if (typeof last === 'number' && now - last < PRUNE_EVERY_MS) return
436    await $.store.set('prunedAt', now)
437    const kept: Kept[] = []
438    for (const entry of await $.fs.list(root)) {
439      if (entry.kind !== 'dir') continue
440      const at = `${root}/${entry.name}`
441      const written = (path: string) =>
442        $.fs.stat(path).then(
443          stat => stat.mtimeMs,
444          () => 0,
445        )
446      kept.push({ name: entry.name, touchedAt: (await written(`${at}/trail.json`)) || (await written(at)), hasShots: await $.fs.exists(`${at}/shots`).catch(() => false) })
447    }
448    const settings = (await $.settings.read().catch(() => ({}))) as Record<string, unknown>
449    const { folders, pictures } = prunable(kept, now, sid, keepDays(settings['cleanupPeriodDays']))
450    for (const name of folders) await $.process.run([RM, '-rf', `${root}/${name}`], { timeoutMs: 60_000 })
451    for (const name of pictures) await $.process.run([RM, '-rf', `${root}/${name}/shots`], { timeoutMs: 60_000 })
452    // In the sessions that stay, this one included, a whole-size copy goes a week after it was made.
453    let copies = 0
454    for (const one of kept) {
455      if (!isSession(one.name) || !one.hasShots || folders.includes(one.name) || pictures.includes(one.name)) continue
456      const shots = `${root}/${one.name}/shots`
457      const stale = staleCopies(await $.fs.list(shots).catch(() => []), now)
458      for (let from = 0; from < stale.length; from += COPIES_AT_ONCE) await $.process.run([RM, '-f', ...stale.slice(from, from + COPIES_AT_ONCE).map(name => `${shots}/${name}`)], { timeoutMs: 60_000 })
459      copies += stale.length
460    }
461    jot(diag.prunes, `${kept.length} sessions kept: removed ${folders.length} whole, the pictures of ${pictures.length}, ${copies} whole-size copies`)
462  } catch (error) {
463    fault($, 'prune', error)
464  }
465}
466
467/** How much the mod keeps on disk, for `/trail where`: this session's folder and all of them together. */
468const keptSize = async ($: Dollar): Promise<string> => {
469  try {
470    const root = dir.slice(0, dir.lastIndexOf('/'))
471    const ran = await $.process.run([DU, '-sk', dir, root], { timeoutMs: 20_000 })
472    const [own, all] = ran.stdout.split('\n').map(line => Number(line.trim().split(/\s+/)[0]))
473    return own !== undefined && all !== undefined && Number.isFinite(own) && Number.isFinite(all) ? ` (${sized(own)}; every conversation together ${sized(all)})` : ''
474  } catch {
475    return ''
476  }
477}
478
479const zoneNow = async ($: Dollar): Promise<number> => {
480  try {
481    const ran = await $.process.run([DATE, '+%z'], { timeoutMs: 5000 })
482    const minutes = zoneOf(ran.stdout)
483    if (minutes !== undefined) return minutes
484  } catch {
485    // Without the host's answer the module's own idea of the zone has to do.
486  }
487  return -new Date().getTimezoneOffset()
488}
489
490/** Finds this session's trail: in the state after a reload, else in its file, else in its transcript. */
491const begin = async ($: Dollar) => {
492  const home = (await $.env.get('HOME')) ?? ''
493  const id = await $.session.id()
494  dir = `${home}/.claude/trail/${id}`
495  zone = await zoneNow($)
496  skipped.clear()
497  const held = await read($, saved)
498  const again = held.v === 5 && held.sid === id ? mended(held.trail) : null
499  sid = id
500  if (again) {
501    live = await linked($, again)
502    digestSoon($, DIGEST_MS)
503    // Told before the mod looked for what waits on the person: its latest is read once more, for that.
504    if (!live.sought) $.clock.after(50, () => void rebuild($, true))
505    $.clock.after(PRUNE_AFTER_MS, () => void prune($))
506    return
507  }
508  live = (await load($)) ?? emptyTrail()
509  journal = []
510  await update($, picked, () => NONE)
511  await update($, page, () => 0)
512  await push($, false)
513  // The file may be from before the session went on without the mod; its transcript has all of it.
514  $.clock.after(PRUNE_AFTER_MS, () => void prune($))
515  const isBehind = !live.sought
516  $.clock.after(50, () => void rebuild($, isBehind).then(why => (why ? $.ui.log(`trail: could not read this session's transcript back (${why}); /trail rebuild tries again`) : undefined)))
517}
518
519const ready = async ($: Dollar) => {
520  if (sid) return
521  starting ??= begin($).finally(() => {
522    starting = null
523  })
524  await starting
525}
526
527/** Folds one thing that happened into the trail, never letting a fault of this mod's reach what it is watching. */
528const record = async ($: Dollar, made: (now: number) => TrailEvent | null) => {
529  try {
530    await ready($)
531    const event = made(await $.clock.now())
532    if (!event) return
533    if (journal && journal.length < JOURNAL_MOST) journal.push(event)
534    const next = add(live, event)
535    if (next === live) return
536    live = next
537    soon($)
538    // A turn that has ended, or a running one that has said enough, has a stretch to write an update for.
539    if (event.t === 'end' || event.t === 'say') digestSoon($, DIGEST_MS)
540  } catch (error) {
541    fault($, 'record', error)
542  }
543}
544
545/**
546 * Notes a picture or a file for the trail: where it lies, and when it was last
547 * written, so it is opened only while it is still the thing that was noted.
548 * Nothing is copied and nothing is drawn: the pane tells of it in words, and
549 * the person opens the file itself. One that never was a file (a picture the
550 * person pasted, a browser's screenshot) is told of and has nothing to open.
551 */
552const locate = async ($: Dollar, source: string, type?: OutputType): Promise<Shot> => {
553  const none: Shot = { file: '', w: 0, h: 0, ...(type ? { type } : {}) }
554  if (!source.startsWith('/')) return none
555  const lies = await $.fs.stat(source).catch(() => null)
556  return lies?.kind === 'file' ? { ...none, full: source, stamp: Math.round(lies.mtimeMs) } : none
557}
558
559/** Something the work produced that is no picture: a video, a file handed over, a page published. Set in the trail where it happened. */
560const output = async ($: Dollar, id: string, shot: Shot, label: string, anchor: string, note?: string) => {
561  await record($, now => ({ t: 'shot', id, at: now, anchor, file: shot.file, w: shot.w, h: shot.h, label, ...(shot.full ? { full: shot.full } : {}), ...(shot.stamp !== undefined ? { stamp: shot.stamp } : {}), ...(shot.type ? { type: shot.type } : {}), ...(note ? { note } : {}) }))
562}
563
564/**
565 * The videos a command left behind: files it names, in what it ran or what
566 * it printed, that were written while it ran. Rendering and recording make
567 * these, and no tool ever opens them, so this is the only place they show.
568 */
569const heedVideos = async ($: Dollar, id: string, text: string, since: number) => {
570  try {
571    const named = videosNamed(text)
572    if (named.length === 0) return
573    const home = (await $.env.get('HOME')) ?? ''
574    const cwd = await $.session.cwd()
575    for (const name of named) {
576      const path = name.startsWith('/') ? name : name.startsWith('~/') ? home + name.slice(1) : `${cwd}/${name.replace(/^\.\//, '')}`
577      const made = await $.fs.stat(path).catch(() => null)
578      // One that was there before the command ran was only named by it, not made by it.
579      if (!made || made.kind !== 'file' || made.size === 0 || made.mtimeMs < since - 2000) continue
580      const key = `out:${tag(path)}:${Math.round(made.mtimeMs)}`
581      if (live.items.some(item => item.id === `${key}:shot`)) continue
582      await output($, key, await locate($, path, 'video'), basename(path), id)
583    }
584  } catch (error) {
585    fault($, 'heedVideos', error)
586  }
587}
588
589/** A picture the model looked at or sent, set in the trail where it happened. Resolves whether the file was there. */
590const shoot = async ($: Dollar, id: string, source: string, label: string, anchor: string, from?: Source, note?: string): Promise<boolean> => {
591  try {
592    const shot = await locate($, source)
593    if (!shot.full) return false
594    await record($, now => ({ t: 'shot', id, at: now, anchor, file: '', w: 0, h: 0, label, full: shot.full!, ...(shot.stamp !== undefined ? { stamp: shot.stamp } : {}), ...(from ? { from } : {}), ...(note ? { note } : {}) }))
595    return true
596  } catch (error) {
597    fault($, 'shoot', error)
598    return false
599  }
600}
601
602/** The same for a picture that never was a file: a browser's screenshot, one the person pasted. It is told of, with nothing to open. */
603const shootInline = async ($: Dollar, id: string, label: string, anchor: string, from?: Source) => {
604  await record($, now => ({ t: 'shot', id, at: now, anchor, file: '', w: 0, h: 0, label, ...(from ? { from } : {}) }))
605}
606
607const textsOf = (message: Message): string[] =>
608  message.content.filter(block => block.type === 'text' && typeof block['text'] === 'string').map(block => block['text'] as string)
609
610const imagesOf = (blocks: readonly Block[]): Held[] =>
611  blocks.flatMap(block => {
612    const source = block.type === 'image' ? (block['source'] as { data?: unknown; media_type?: unknown } | undefined) : undefined
613    return typeof source?.data === 'string' ? [{ data: source.data, type: typeof source.media_type === 'string' ? source.media_type : 'image/png' }] : []
614  })
615
616/** One row the conversation kept: a prompt, a command, what the model said, a recap, a compaction. */
617const heed = async ($: Dollar, door: string, uuid: string, message: Message, origin: string) => {
618  try {
619    const texts = textsOf(message)
620    jot(diag.appends, `${door} ${message.type}${message.name ? '/' + message.name : ''}${message.isMeta ? ' meta' : ''} ${origin} ${uuid}`)
621    if (door === 'response') {
622      let index = 0
623      for (const text of texts) {
624        const id = index === 0 ? uuid : `${uuid}:${index}`
625        index++
626        await record($, now => ({ t: 'say', id, at: now, text }))
627      }
628      return
629    }
630    if (door === 'notice') {
631      const text = texts[0]
632      if (message.name === 'away_summary' && text) await record($, now => ({ t: 'recap', id: uuid, at: now, text }))
633      return
634    }
635    if (door === 'compaction') {
636      await record($, now => ({ t: 'compact', id: uuid, at: now }))
637      return
638    }
639    // What the person typed while the turn ran reaches it wrapped in the engine's words; theirs are inside.
640    if (door === 'delivery' && message.type === 'attachment' && message.name === 'queued_command') {
641      const typed = TYPED_MEANWHILE.exec(texts.join('\n'))?.[1]
642      if (typed) await record($, now => ({ t: 'prompt', id: uuid, at: now, text: typed }))
643      return
644    }
645    if (message.isMeta || message.type !== 'user' || texts.length === 0) return
646    const text = texts.join('\n')
647    // The mod's own command is no part of the work.
648    if (/<command-name>\s*\/?trail\s*</.test(text)) return
649    await record($, now => ({ t: 'prompt', id: uuid, at: now, text, isCommand: door === 'command' }))
650    let index = 0
651    for (const _ of imagesOf(message.content)) await shootInline($, `${uuid}:${index++}`, 'You pasted a picture', uuid, 'person')
652  } catch (error) {
653    fault($, 'heed', error)
654  }
655}
656
657/** Pictures a connected tool answered with: they are in the row its result is kept as, and nowhere on disk. */
658const heedPictures = async ($: Dollar, message: Message, tool: string) => {
659  try {
660    const short = tool.split('__').slice(2).join(' ') || tool
661    for (const block of message.content) {
662      const id = block['tool_use_id']
663      const inside = block['content']
664      if (block.type !== 'tool_result' || typeof id !== 'string' || !Array.isArray(inside)) continue
665      const held = imagesOf(inside as Block[]).at(-1)
666      if (held) await shootInline($, id, `Picture from ${short}`, id)
667    }
668  } catch (error) {
669    fault($, 'heedPictures', error)
670  }
671}
672
673/** What came of a tool call: a failure to count, or a picture the model looked at or sent. */
674const heedResult = async ($: Dollar, id: string, tool: string, input: Record<string, unknown>, since: number, ran: { deny?: string; isError?: true; result?: unknown; text?: string }) => {
675  const file = typeof input['file_path'] === 'string' ? input['file_path'] : undefined
676  try {
677    if (ran.deny !== undefined || ran.isError === true) {
678      await record($, now => ({ t: 'fail', id, at: now }))
679      return
680    }
681    const result = (typeof ran.result === 'object' && ran.result !== null ? ran.result : {}) as { type?: unknown; attachments?: unknown; file?: { base64?: unknown; type?: unknown } }
682    if (tool === 'Read' && file && result.type === 'image') {
683      // Read finds a file whose name it was given nearly right; the picture it returned is there when the name is not.
684      // A picture that was on disk before the session began was looked at, not made: it is no output of Claude's.
685      const written = await $.fs.stat(file).then(
686        stat => stat.mtimeMs,
687        () => 0,
688      )
689      const from = isEarlier(written, live.items[0]?.at ?? 0) ? ('earlier' as const) : undefined
690      const isKept = await shoot($, id, file, basename(file), id, from)
691      const data = result.file?.base64
692      if (!isKept && typeof data === 'string' && data) await shootInline($, id, basename(file), id, from)
693    }
694    if (tool === 'SendUserFile' && Array.isArray(result.attachments)) {
695      const said = typeof input['caption'] === 'string' ? input['caption'] : undefined
696      let index = 0
697      for (const sent of result.attachments as { path?: unknown; isImage?: unknown }[]) {
698        if (typeof sent.path === 'string') {
699          const key = `${id}:${index}`
700          const label = SENT + basename(sent.path)
701          // Whatever was handed over is an output: a picture is kept as one, anything else by its preview. What
702          // Claude said of it as it sent it is its caption, there at once.
703          if (sent.isImage === true) await shoot($, key, sent.path, label, id, undefined, said)
704          else await output($, key, await locate($, sent.path, outputType(sent.path) === 'video' ? 'video' : 'file'), label, id, said)
705        }
706        index++
707      }
708    }
709    if (tool === 'Bash') await heedVideos($, id, `${typeof input['command'] === 'string' ? input['command'] : ''}\n${ran.text ?? ''}`, since)
710    if (tool === 'Artifact') {
711      const url = publishedAt(ran.text ?? '')
712      if (url) await output($, id, { file: '', w: 0, h: 0, full: url, type: 'link' }, `Published ${typeof input['file_path'] === 'string' ? basename(input['file_path']) : 'a page'}`, id)
713    }
714  } catch (error) {
715    fault($, 'heedResult', error)
716  }
717}
718
719const openPane = ($: Dollar) => $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
720
721/** Moves the pane's own window back to its top, where the newest is; a pane not drawn yet has no window to move. */
722const paneToTop = async ($: Dollar) => {
723  try {
724    await $.ui.scroll({ in: PANE, to: 'start' })
725  } catch (error) {
726    jot(diag.faults, `paneToTop: ${whyNot(error)}`)
727  }
728}
729
730/**
731 * Puts Claude's question in the pane, under its picture when it has one, and
732 * resolves what to tell Claude about it. The pane is brought up for it, since
733 * a question nobody sees is no question.
734 */
735const post = async ($: Dollar, id: string, text: string, options: string[], image: string): Promise<string> => {
736  // The picture a question is about is not drawn: the question has the way to open it.
737  const found = image ? await locate($, image).catch(() => null) : null
738  const shot: Shot | null = found?.full ? found : null
739  await record($, now => ({ t: 'question', id, at: now, text, options, ...(shot ? { shot } : {}) }))
740  await push($, true)
741  jot(diag.questions, `asked ${id}${image ? (shot ? ' with a picture' : ' (picture unreadable)') : ''}`)
742  try {
743    // A question is to be answered before anything else is read: the pane turns to it.
744    await update($, mode, () => ASKING)
745    await update($, page, () => 0)
746    const isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
747    if (isShown) await paneToTop($)
748    else {
749      void openPane($).catch(() => undefined)
750      $.ui.toast('Claude has a question for you: /trail')
751    }
752  } catch (error) {
753    fault($, 'post', error)
754  }
755  const pictured = image && !shot ? ' The picture could not be found, so the question shows without it.' : ''
756  return `The question is in the person's Trail pane.${pictured} Their answer will arrive as their next message: end your turn if you cannot go on without it.`
757}
758
759/** The person's answer to a question in the pane: kept beside it, and sent to the conversation as their own words. */
760const answer = async ($: Dollar, question: Question, text: string) => {
761  try {
762    const said = clean(text)
763    if (!said || answered.has(question.id)) return
764    answered.add(question.id)
765    await record($, now => ({ t: 'answer', id: question.id, at: now, text: said }))
766    // With nothing left waiting the pane goes back to the feed.
767    if (waitingCount(live) === 0) await update($, mode, now => (now === ASKING ? 'feed' : now))
768    await push($, true)
769    jot(diag.questions, `answered ${question.id}`)
770    await $.prompt.submit({ text: paneAnswer(question.text, said), asUser: true })
771  } catch (error) {
772    fault($, 'answer', error)
773  }
774}
775
776/** The session's repository's remote, asked once: '' when it has none. */
777let remote: string | undefined
778const remoteOf = async ($: Dollar): Promise<string> => {
779  remote ??= await $.session.repo().then(
780    repo => repo?.remote ?? '',
781    () => '',
782  )
783  return remote
784}
785
786/** A trail whose things waiting that name pull requests by number have the link to each: one waiting since before the mod gave links gains them. */
787const linked = async ($: Dollar, trail: Trail): Promise<Trail> => {
788  const feed = withPullLinks(trail.feed, await remoteOf($))
789  return feed.some((beat, index) => beat !== trail.feed[index]) ? { ...trail, feed } : trail
790}
791
792/** Puts a command a thing waiting names on the clipboard. It is never run from the pane: the person runs it, where and when they choose. */
793const copyRef = async ($: Dollar, text: string) => {
794  try {
795    const copied = await $.ui.copy({ text })
796    $.ui.toast(copied.isCopied ? 'Copied.' : `Could not copy: ${copied.reason}`)
797  } catch (error) {
798    fault($, 'copyRef', error)
799  }
800}
801
802/** Opens a link a thing waiting names, in the person's browser. */
803const openLink = async ($: Dollar, url: string) => {
804  try {
805    if (!isLink(url)) return
806    const ran = await $.process.run([OPEN, url], { timeoutMs: 10_000 })
807    if (ran.exitCode !== 0) $.ui.toast(`Trail could not open it: ${ran.stderr.trim().slice(0, 80)}`)
808  } catch (error) {
809    fault($, 'openLink', error)
810  }
811}
812
813const isLink = (ref: string): boolean => /^https?:\/\/\S+$/.test(ref)
814
815/** The person says a thing the work left for them is done: it stops waiting. */
816const settle = async ($: Dollar, id: string) => {
817  try {
818    const now = await $.clock.now()
819    live = { ...live, feed: live.feed.map(beat => (beat.id === id && beat.over === undefined ? { ...beat, over: now } : beat)) }
820    if (waitingCount(live) === 0) await update($, mode, at => (at === ASKING ? 'feed' : at))
821    await push($, true)
822  } catch (error) {
823    fault($, 'settle', error)
824  }
825}
826
827/**
828 * Scrolls the transcript to the row a line stands for. The scroll is the
829 * first thing awaited: the transcript is the person's to move, and only what
830 * answers their press may move it.
831 */
832const jump = async ($: Dollar, target: Target) => {
833  try {
834    const { why, tried } = await reach(candidates(target, groupsByCall), id =>
835      $.ui.scroll({ to: { requestId: id }, block: 'start' }).then(
836        moved => moved.deny,
837        error => whyNot(error),
838      ),
839    )
840    jot(diag.jumps, `${target.kind} ${target.id} ${tried.join(' | ')}`)
841    await update($, picked, now => ({ id: target.id, jump: why, ...(now.id === target.id && now.isOpen ? { isOpen: true as const } : {}) }))
842  } catch (error) {
843    fault($, 'jump', error)
844  }
845}
846
847/** The person asks for the updates of what happened while the pane was closed, further back than the latest. */
848const writeEarlier = async ($: Dollar) => {
849  wantsEarlier = true
850  await tell($, 'writing the earlier updates…')
851  digestSoon($, 50)
852}
853
854/** Shows all an entry has to say, or goes back to its first rows when it is the one shown whole. */
855const unfold = async ($: Dollar, target: Target) => {
856  try {
857    await update($, picked, now => (now.id === target.id && now.isOpen ? NONE : { id: target.id, isOpen: true }))
858  } catch (error) {
859    fault($, 'unfold', error)
860  }
861}
862
863/**
864 * Opens an output in the person's own viewer: the thing itself where it
865 * lies, or the small copy the mod kept when a picture's file is gone or has
866 * been written over since (the same name now holds another picture).
867 */
868const openPicture = async ($: Dollar, shot: Shot, id?: string) => {
869  try {
870    // Opened from the pane, an output is read from then on, whatever comes of opening it.
871    if (id) {
872      await record($, now => ({ t: 'opened', id, at: now }))
873      await push($, true)
874    }
875    let path = shot.full || shot.file
876    if (shot.full && shot.type !== 'link') {
877      const lies = await $.fs.stat(shot.full).catch(() => null)
878      const isSame = lies?.kind === 'file' && (shot.stamp === undefined || Math.round(lies.mtimeMs) === shot.stamp)
879      // A video or a document has no copy to fall back on: what is there now is opened, if anything is.
880      if (!isSame && shot.file) {
881        path = shot.file
882        $.ui.toast(lies ? 'The file has changed since: opening the copy Trail kept.' : 'The file is gone: opening the copy Trail kept.')
883      } else if (!lies) {
884        $.ui.toast(`Trail could not open it: ${basename(shot.full)} is no longer there.`)
885        return
886      }
887    }
888    if (!path) return
889    const ran = await $.process.run([OPEN, path], { timeoutMs: 10_000 })
890    if (ran.exitCode !== 0) $.ui.toast(`Trail could not open it: ${ran.stderr.trim().slice(0, 80)}`)
891  } catch (error) {
892    fault($, 'openPicture', error)
893  }
894}
895
896const turnPage = async ($: Dollar, by: number) => {
897  try {
898    await update($, page, at => Math.max(0, by === 0 ? 0 : at + by))
899    await paneToTop($)
900  } catch (error) {
901    fault($, 'turnPage', error)
902  }
903}
904
905/** The view for what waits on the person, questions and things left for them to do: among the tabs only while something does. */
906const ASKING: Mode = 'questions'
907
908const EMPTY_VIEW: Record<Mode, string> = {
909  questions: 'Nothing is waiting on you.',
910  feed: 'Nothing yet. The feed fills in as the conversation goes.',
911  outputs: 'Nothing made yet. What Claude produces shows here by name, to open: screenshots of its work, videos, files it sends you, pages it publishes. What you have not opened yet is in bold.',
912  steps: 'Nothing yet. Each thing you ask shows here.',
913}
914
915/** The views, as the tabs name them. No hotkeys: their letters beside each name were more to read than the names. */
916const TABS: readonly { mode: Mode; label: string }[] = [
917  { mode: 'feed', label: 'highlights' },
918  { mode: 'outputs', label: 'outputs' },
919  { mode: 'steps', label: 'every prompt' },
920]
921
922const switchMode = async ($: Dollar, to: Mode) => {
923  try {
924    await update($, mode, () => to)
925    await update($, page, () => 0)
926    await update($, picked, () => NONE)
927    await paneToTop($)
928  } catch (error) {
929    fault($, 'switchMode', error)
930  }
931}
932
933export const register: Register = on => {
934  on('session.start', async ($, e, next) => {
935    isUnwatched = e.isInteractive === false
936    isWatchSettled = true
937    if (isUnwatched) return next(e)
938    await $.command.register({
939      name: 'trail',
940      description: 'Show what this conversation did as a feed in a side pane; click a line to jump there',
941      argumentHint: '[close|describe|rebuild|where]',
942      immediate: true,
943    })
944    await $.tool.register({ name: 'ask', description: ASK_DESCRIPTION, inputSchema: ASK_SCHEMA })
945    try {
946      await ready($)
947      const isClosed = (await $.store.get('closed')) === true
948      const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
949      // Unasked, so the engine seats it only where there is room for a sidebar; /trail seats it anywhere.
950      if (!isClosed && !isUp) void openPane($).catch(() => undefined)
951    } catch (error) {
952      fault($, 'session.start', error)
953    }
954
955    return next(e)
956  })
957
958  on('command.run', { command: 'trail' }, async ($, e) => {
959    const verb = e.args.trim().toLowerCase()
960    await ready($)
961    if (verb === 'close') {
962      await $.store.set('closed', true)
963      await $.ui.close({ id: PANE })
964      return { text: 'Trail closed.' }
965    }
966    if (verb === 'where') return { text: `Trail keeps this session in ${dir}${await keptSize($)}` }
967    if (verb === 'describe') {
968      // The person asks for it, knowing what it costs: the conversation reads itself again to say what its pictures show.
969      const count = await caption($)
970      return { text: count === null ? 'Trail could not ask the conversation about its pictures just now. Try again once it is at rest.' : count === 0 ? 'Every recent picture already has a caption from the conversation itself.' : `Trail asked the conversation what its pictures show: ${count} captioned.` }
971    }
972    if (verb === 'rebuild') {
973      const why = await rebuild($, true)
974      if (why) return { text: `Trail could not read the transcript back: ${why}` }
975    } else if (verb !== '' && verb !== 'open') return { text: USAGE }
976
977    await $.store.set('closed', false)
978    await push($, false)
979    const opened = await openPane($)
980    if (!opened.isPlaced) return { text: `Trail is open but not shown: ${opened.reason}` }
981    await paneToTop($)
982    // Opened, the feed catches up on the latest of what happened while it was closed.
983    digestSoon($, 50)
984
985    return { text: verb === 'rebuild' ? `Trail rebuilt from the transcript: ${live.items.length} steps.` : OPENED }
986  })
987
988  on('session.append', { door: ['prompt', 'command', 'response', 'delivery', 'compaction', 'notice'] }, async ($, e, next) => {
989    // The row is the session's to store: it is read on its way there and passed on as it came.
990    if (e.agentId === undefined && (await watched($))) await heed($, e.door, e.uuid, e.message, e.origin.kind)
991
992    return next(e)
993  })
994
995  on('session.append', { door: 'tool-result', origin: { kind: 'tool', tool: /^mcp__/ } }, async ($, e, next) => {
996    if (e.agentId === undefined && (await watched($))) await heedPictures($, e.message, e.origin.kind === 'tool' ? e.origin.tool : '')
997
998    return next(e)
999  })
1000
1001  on('tool.call', { tool: ASK_TOOL }, async ($, e) => {
1002    const input = e as unknown as Record<string, unknown>
1003    const question = typeof input['question'] === 'string' ? clean(input['question']) : ''
1004    if (!question) return { isError: true, result: 'No question was given: pass `question`.' }
1005    const options = Array.isArray(input['options']) ? input['options'].filter((option): option is string => typeof option === 'string') : []
1006    const image = typeof input['image'] === 'string' ? input['image'] : ''
1007    try {
1008      return { result: await post($, e.tool_use_id, question, options, image) }
1009    } catch (error) {
1010      fault($, 'ask', error)
1011      return { isError: true, result: `The question could not be put in the pane: ${whyNot(error)}. Ask it in the conversation instead.` }
1012    }
1013  })
1014
1015  on('tool.call', async ($, e, next) => {
1016    if (e.agentId !== undefined || !(await watched($))) return next(e)
1017    const id = e.tool_use_id
1018    const tool = String(e.tool)
1019    const input = e as unknown as Record<string, unknown>
1020    const file = typeof input['file_path'] === 'string' ? input['file_path'] : undefined
1021    const since = await $.clock.now().catch(() => 0)
1022    await record($, now => ({ t: 'tool', id, at: now, tool, note: toolNote(tool, input), file }))
1023    const ran = await next(e)
1024    await heedResult($, id, tool, input, since, ran)
1025
1026    return ran
1027  })
1028
1029  on('turn.complete', async ($, e, next) => {
1030    if (e.agentId === undefined && (await watched($))) {
1031      // A turn the person stopped, or that broke, did not come out as what was last said.
1032      const isCut = e.isAborted || e.reason !== 'answer'
1033      await record($, now => ({ t: 'end', at: now, ...(isCut ? { aborted: true } : {}) }))
1034      // A reload of this module comes when a turn ends, so the trail is handed over and saved before then.
1035      await push($, true)
1036    }
1037
1038    return next(e)
1039  })
1040
1041  on('session.end', async ($, e, next) => {
1042    if (!(await watched($))) return next(e)
1043    await push($, true)
1044    if (e.reason === 'clear' || e.reason === 'resume') {
1045      // The process goes on under another session id, and no session.start follows: the next row begins its trail.
1046      sid = ''
1047      live = emptyTrail()
1048      await update($, saved, () => EMPTY).catch(() => undefined)
1049    }
1050
1051    return next(e)
1052  })
1053
1054  // The engine's word that the process now runs under a session: after /resume or /clear no session.start says so.
1055  on('classic.SessionStart', async ($, e, next) => {
1056    if (!(await watched($))) return next(e)
1057    try {
1058      if (sid && e.session_id !== sid) {
1059        sid = ''
1060        live = emptyTrail()
1061      }
1062      await ready($)
1063    } catch (error) {
1064      fault($, 'classic.SessionStart', error)
1065    }
1066
1067    return next(e)
1068  })
1069
1070  on('ui.close', async ($, e, next) => {
1071    const closed = await next(e)
1072    if (e.id === PANE && e.origin.kind === 'person') await $.store.set('closed', true).catch(() => undefined)
1073
1074    return closed
1075  })
1076
1077  // A run of calls the transcript folded into one line, left as it is: its id is what a jump to one of them scrolls to.
1078  on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
1079    for (const call of e.props.calls) if (call.tool_use_id) groupsByCall.set(call.tool_use_id, e.requestId)
1080
1081    return next(e)
1082  })
1083
1084  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1085    const kept = await read($, saved)
1086    const pick = await read($, picked)
1087    const back = await read($, page)
1088    const kind = await read($, mode)
1089    const trail = mended(kept.trail) ?? emptyTrail()
1090    const waiting = waitingCount(trail)
1091    // A pane brought to the front is drawn, and that is all the mod hears of it: the drawing sets the updates going.
1092    if (!digestTimer && !isDigesting && Date.now() - nudgedAt > NUDGE_MS) {
1093      nudgedAt = Date.now()
1094      digestSoon($, 300)
1095    }
1096    // The tab for what waits on the person is there only while something does; with nothing left, the pane is back at the feed.
1097    const tabs = waiting > 0 ? [{ mode: ASKING, label: `${waiting} waiting on you` }, ...TABS] : TABS
1098    const listing: Mode = tabs.some(tab => tab.mode === kind) ? kind : 'feed'
1099    // Steps from while the pane was closed, further back than the latest, are summarised only when asked for.
1100    const untold = listing === 'feed' && !wantsEarlier ? backlogOf(trail) : 0
1101    const doing = await read($, status)
1102    // A cell of margin each side keeps the words off the pane's frame.
1103    const width = Math.max(18, Math.min(200, e.props.bodyColumns) - 2 * MARGIN)
1104    const { rows, earlier, later, foot } = layout(trail, { width, picked: pick, page: back, mode: listing, zone })
1105    const { Box, Button, Text } = $.ui.resolve(e)
1106    const Input = e.surface === 'mobile' ? null : $.ui.resolve(e).Input
1107
1108    return (
1109      <Box flexDirection="column" paddingX={MARGIN}>
1110        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1111          {tabs.map(tab =>
1112            tab.mode === listing ? (
1113              <Text bold inverse>{` ${tab.label} `}</Text>
1114            ) : (
1115              <Button key={`tab:${tab.mode}`} plain dimColor={tab.mode !== ASKING} label={tab.label} onPress={() => void switchMode($, tab.mode)} />
1116            ),
1117          )}
1118        </Box>
1119        <Text dimColor wrap="truncate-end">
1120          {doing ? `${foot} · ${doing}` : foot}
1121        </Text>
1122        {later > 0 && (
1123          <Box flexDirection="row" columnGap={2}>
1124            <Button key="later" plain dimColor label={`[${later} later]`} onPress={() => void turnPage($, -1)} />
1125            <Button key="latest" plain dimColor label="[latest]" onPress={() => void turnPage($, 0)} />
1126          </Box>
1127        )}
1128        <Box flexDirection="column" marginTop={1} rowGap={1}>
1129          {rows.length === 0 && <Text dimColor>{EMPTY_VIEW[listing]}</Text>}
1130          {rows.map(row => {
1131            if (row.kind === 'day') {
1132              return (
1133                <Text dimColor wrap="truncate-end">
1134                  {`${row.text} ${'─'.repeat(Math.max(0, width - row.text.length - 1))}`}
1135                </Text>
1136              )
1137            }
1138            if (row.kind === 'question') {
1139              const { question } = row
1140              const whole = question.shot && (question.shot.full || question.shot.file) ? question.shot : undefined
1141              return (
1142                <Box key={`row:${row.key}`} flexDirection="column" rowGap={1}>
1143                  <Text dimColor wrap="truncate-end">{`Claude is asking you · ${row.time}`}</Text>
1144                  <Box flexDirection="column">
1145                    {row.lines.map(text => (
1146                      <Text bold wrap="truncate-end">
1147                        {text}
1148                      </Text>
1149                    ))}
1150                  </Box>
1151                  {question.options.length > 0 && (
1152                    <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1153                      {question.options.map((option, index) => (
1154                        <Button key={`answer:${question.id}:${index}`} label={option} onPress={() => void answer($, question, option)} />
1155                      ))}
1156                    </Box>
1157                  )}
1158                  {Input && <Input key={`say:${question.id}`} placeholder="Type your answer" submitLabel="Send" onSubmit={value => void answer($, question, value)} />}
1159                  <Box flexDirection="row" columnGap={2}>
1160                    {whole && <Button key={`open:${row.key}`} plain dimColor label="[open]" onPress={() => void openPicture($, whole)} />}
1161                    <Button key={`go:${row.key}`} plain dimColor label={GO_WORD} onPress={() => void jump($, { id: row.key, anchor: question.id, kind: 'question' })} />
1162                  </Box>
1163                </Box>
1164              )
1165            }
1166            const { target, open, settles, refs } = row
1167            const isAsk = row.tone === 'ask'
1168            const isQuiet = row.tone === 'quiet'
1169            const pad = ' '.repeat(row.pad)
1170            return (
1171              <Box key={`row:${row.key}`} flexDirection="column">
1172                <Box flexDirection="row" columnGap={2}>
1173                  <Text dimColor>{row.time}</Text>
1174                  {open && <Button key={`open:${row.key}`} plain dimColor label="[open]" onPress={() => void openPicture($, open, row.key)} />}
1175                  {row.hasMore && <Button key={`more:${row.key}`} plain dimColor label={row.isOpen ? '[less]' : '[more]'} onPress={() => void unfold($, target)} />}
1176                  {settles && <Button key={`settle:${row.key}`} plain dimColor label="[done]" onPress={() => void settle($, settles)} />}
1177                  <Box display="none" hover={{ display: 'flex' }}>
1178                    <Button key={`go:${row.key}`} plain label={GO} onPress={() => void jump($, target)} />
1179                  </Box>
1180                </Box>
1181                {row.note !== '' && (
1182                  <Text dimColor wrap="truncate-end">
1183                    {row.note}
1184                  </Text>
1185                )}
1186                {row.lines.map((text, index) => (
1187                  <Text bold={isAsk} dimColor={isQuiet} wrap="truncate-end">
1188                    {index === 0 && row.pad > 0 ? `${row.glyph} ${text}` : pad + text}
1189                  </Text>
1190                ))}
1191                {refs?.map((ref, index) => (
1192                  <Box key={`ref:${row.key}:${index}`} flexDirection="row" columnGap={1}>
1193                    <Text>{pad}</Text>
1194                    {isLink(ref) ? (
1195                      <Button key={`link:${row.key}:${index}`} plain dimColor label="[open]" onPress={() => void openLink($, ref)} />
1196                    ) : (
1197                      <Button key={`copy:${row.key}:${index}`} plain dimColor label="[copy]" onPress={() => void copyRef($, ref)} />
1198                    )}
1199                    <Text dimColor wrap="truncate-end">
1200                      {ref}
hooks/digest.ts 394 lines
1// The feed's updates: the steps of the work, put to a model as a log divided into stretches, and its reply
2// read back as one short account of each stretch, the way a match is told as it goes. Pure: the model call is
3// the hooks module's; this makes the prompt and reads the answer.
4
5import type { Beat, Item, Trail } from '../types'
6import { clean, clip, MAX_BEATS, SENT, waitsOf, workCount, workLine } from './trail.ts'
7
8/** Steps to write updates for. */
9export type Job = {
10  /** The steps it covers, which are marked once the updates are in. */
11  ids: string[]
12  /** The log's numbered stretches, by number: each the step its update is tied to, the last of the stretch. */
13  slots: Item[]
14  /** The log, when a model is to read it. */
15  prompt: string
16  /** The updates, when the work is too slight to need a model: an exchange with no work in it. */
17  local?: Beat[]
18  /** The log runs to the latest of the session: only then does it show what is left waiting on the person. */
19  isLatest: boolean
20  /** The things still waiting on the person that the log names, in the order it marks them w1, w2 and so on. */
21  open: string[]
22  /** The pictures and files without a caption that the log names, in the order it marks them p1, p2 and so on. */
23  pics: string[]
24}
25
26/** Characters of log put to the model at once. */
27const LOG_MAX = 9000
28/** Characters of the newest turns told again when the person asks for the transcript to be read back: under one log's worth. */
29const RETOLD_MAX = 6000
30/** Steps to a stretch: each stretch is told in one update, so this is how often a long run is told of. */
31const STRETCH_STEPS = 4
32const UPDATE_MAX = 420
33/** Actions to a stretch, and the length of one: an update is a few short lines, not a paragraph. */
34const ACTIONS_MOST = 3
35const ACTION_MAX = 90
36/** What stands in for an exchange with no work in it: the start of the answer. */
37const ANSWER_SHOWN = 110
38/** Things one reply may leave waiting on the person, the length of each, and how many still waiting are put to the model. */
39const WAITS_MOST = 3
40const WAIT_MAX = 200
41const OPEN_MOST = 8
42/** Commands and links one thing waiting may carry, and the length of one. */
43const REFS_MOST = 4
44const REF_MAX = 240
45/** Pictures one log asks captions for, and the length of a caption. */
46const PICS_MOST = 12
47const CAPTION_MAX = 320
48const STILL_WAITING = 'STILL WAITING ON THE PERSON FROM BEFORE THIS LOG:'
49/** What in an answer with no work in it says something may be left with the person: it is then put to a model like any other. */
50const LEFT_WITH_THEM = /\?|\b(?:waits?|waiting|blocked) on you\b|\bneeds? you(?:r)?\b|\byou(?:'ll| will)? (?:need|have) to\b|\b(?:once|until|after) you\b|\byour (?:call|go-ahead|approval|answer|decision|choice|pick|sign-off)\b|\b(?:let|tell) me\b|\bsay which\b|\bup to you\b|\bconfirm\b/i
51
52export const SYSTEM = [
53  'You write the activity feed of a coding session between a person and Claude, an AI coding agent: a terse list of what was done, so the person can see what happened at a glance without reading the conversation.',
54  '',
55  'You are given a log of part of the session: what the person asked, then what Claude said and did, divided into numbered stretches marked [1], [2] and so on.',
56  'For each stretch, write the things done in it that matter, as one to three actions. An action is a few words, eight at most, in the past tense, starting with a verb and naming the thing: "Fixed the scroll jump in the list", "Pushed the new look, 228 tests passing", "Opened PR 8", "Found the leak in the cache". Put the outcome or the number in it where there is one. Separate the actions of a stretch with " ; ".',
57  'Only what was done or found. Leave out reading files, re-running commands, plans, explanations, what will be done next, and anything about how Claude went about it. No sentences, no "Claude" or "you" as the subject, no filler. Fewer actions is better: a stretch that did one thing gets one.',
58  'Report only what the log shows was done or found. What the person asked for is there so you know what the work was for: never report it as done unless the log shows it.',
59  '',
60  'Reply with one line per stretch:',
61  'N|action ; action',
62  'where N is the number of the stretch.',
63  '',
64  'Then, only if the end of the log leaves something waiting on the person, add one line for each such thing:',
65  'wait|N|what they are to do, in at most twenty words ::: command or link ;; command or link',
66  'where N is the stretch that left it waiting. Something waits on the person when a piece of the work cannot go on, or cannot be finished, until they act: a command only they can run, a sign-in or a permission, an approval, a choice or a question Claude put to them, something to try and report back on. Begin with the verb and say what it unblocks ("Run `gh auth refresh -s workflow` so the packaging branch can be pushed"). An offer of more work, a suggestion, and anything Claude will do by itself are not waiting on them, and most logs leave nothing waiting.',
67  'After " ::: " give what they need to do it with, up to four, separated by " ;; ": the exact command to run, or the link to open (a pull request, a page, a dashboard). Copy each exactly from the log. Where the log names the thing but not the command, give the command only if it is the plain, standard one for that action and tool (merging pull request 13 with the GitHub CLI is "gh pr merge 13"); never guess at flags, names or addresses. With nothing to give, leave " ::: " out.',
68  'The log may open with things still waiting on the person from before, marked w1, w2 and so on. Never write a wait line for one of those again. If the log shows that one of them was done or answered, or no longer matters, add a line:',
69  'clear|w1',
70  'with its mark.',
71  '',
72  'Then, for each picture or file the log marks p1, p2 and so on, add one line:',
73  'pic|p1|what it shows and what it was for, in at most thirty words',
74  'going only by what the log says around it: what the person said as they pasted it, what Claude said it saw in it, what Claude made or sent it for. You cannot see the picture, so never guess at what is in it: if the log does not say, leave the caption empty ("pic|p1|").',
75  'Write nothing else.',
76].join('\n')
77
78const bearing = (item: Item): boolean => item.kind === 'ask' || item.kind === 'step' || item.kind === 'done'
79const isStep = (item: Item): boolean => item.kind === 'step' || item.kind === 'done'
80
81const beatAt = (item: Item, text: string): Beat => ({
82  id: 'b:' + item.id,
83  ref: item.id,
84  turn: item.turn,
85  at: item.at,
86  kind: 'summary',
87  title: clip(clean(text), UPDATE_MAX),
88  detail: '',
89  anchor: item.anchor,
90  ...(item.alt ? { alt: item.alt } : {}),
91})
92
93/** Work too slight to need a model: something asked and answered with nothing done, or a command nothing followed. */
94const slight = (items: Item[]): Beat[] | undefined => {
95  const steps = items.filter(isStep)
96  const worked = items.some(item => item.work !== undefined && workCount(item.work) > 0) || items.some(item => item.kind === 'shot')
97  if (worked || steps.length > 1) return undefined
98  const [answer] = steps
99  // An answer that may leave something with the person is read by a model, which says what waits on them.
100  if (answer && LEFT_WITH_THEM.test(answer.more ?? answer.line)) return undefined
101  // The answer is the whole of what happened: its first sentence, kept short, is the update.
102  return answer ? [beatAt(answer, clip(answer.line, ANSWER_SHOWN))] : []
103}
104
105/** A turn's steps as stretches, each to be told in one update; a lone last step joins the stretch before it. */
106const stretchesOf = (items: Item[]): Item[][] => {
107  const parts: Item[][] = [[]]
108  let steps = 0
109  for (const item of items) {
110    if (isStep(item) && steps >= STRETCH_STEPS) {
111      parts.push([])
112      steps = 0
113    }
114    parts[parts.length - 1]!.push(item)
115    if (isStep(item)) steps++
116  }
117  const last = parts[parts.length - 1]!
118  if (parts.length > 1 && last.filter(isStep).length < 2) parts.splice(parts.length - 2, 2, [...parts[parts.length - 2]!, ...last])
119  return parts.filter(part => part.length > 0)
120}
121
122/** How the log tells of a picture or a file, by where it came from; marked when a caption is wanted for it. */
123const pictureText = (item: Item, mark: string): string => {
124  const marked = mark ? `[${mark}] ` : ''
125  const type = item.shot?.type ?? 'image'
126  if (item.shot?.from === 'person') return `${marked}the person pasted a picture with what they asked`
127  if (item.line.startsWith(SENT)) return `${marked}sent the person ${type === 'video' ? 'a video' : type === 'file' ? 'a file' : 'a picture'}: ${item.line.slice(SENT.length)}`
128  if (type === 'link') return `${marked}published a page: ${item.line.replace(/^Published /, '')}`
129  if (type === 'video') return `${marked}a command made a video: ${item.line}`
130  return `${marked}looked at a picture: ${item.line}`
131}
132
133const entryText = (item: Item, isLast: boolean, mark = ''): string => {
134  const did = item.work && workCount(item.work) > 0 ? ` [did: ${[...item.work.notes, workLine(item.work)].join('; ')}]` : ''
135  if (item.kind === 'shot') return pictureText(item, mark)
136  if (item.kind === 'ask') return `(before saying anything)${did}`
137  const said = item.more ?? item.line
138  return `${item.kind === 'done' && isLast ? 'final answer' : 'said'}: ${said}${did}`
139}
140
141const logOf = (turns: Item[][]): { prompt: string; slots: Item[]; pics: string[] } => {
142  const slots: Item[] = []
143  const pics: string[] = []
144  const lines: string[] = []
145  // An output nothing has said anything of yet is marked, for the model to say what the log tells of it. A picture
146  // the person pasted, or one from before the session, is listed nowhere and gets no caption.
147  const markOf = (item: Item): string => (item.kind === 'shot' && item.shot?.note === undefined && item.shot?.from === undefined && pics.length < PICS_MOST ? `p${pics.push(item.id)}` : '')
148  for (const items of turns) {
149    const ask = items.find(item => item.kind === 'ask')
150    if (lines.length > 0) lines.push('')
151    // A request whose first stretch is told already is there for what the work is about, not to be told again.
152    lines.push(ask?.fed ? 'EARLIER THE PERSON ASKED (already told of: do not repeat it):' : lines.length === 0 ? 'THE PERSON ASKED:' : 'THEN THE PERSON ASKED:')
153    lines.push(ask ? (ask.more ?? ask.line) : '(the work carried on from before)')
154    lines.push(ask?.fed ? 'CLAUDE WENT ON:' : 'CLAUDE:')
155    const shown = items.filter(item => bearing(item) || item.kind === 'shot').filter(item => item.kind !== 'ask' || (item.work !== undefined && workCount(item.work) > 0))
156    for (const stretch of stretchesOf(shown)) {
157      slots.push([...stretch].reverse().find(bearing) ?? stretch[stretch.length - 1]!)
158      lines.push(`[${slots.length}]`)
159      for (const item of stretch) lines.push(entryText(item, item === shown[shown.length - 1], markOf(item)))
160    }
161  }
162  return { prompt: lines.join('\n'), slots, pics }
163}
164
165/**
166 * The next steps worth writing updates for, newest first so what the person
167 * sees on opening the pane is written before the history behind it: the
168 * steps of a finished turn not yet told, or of a running one once a stretch
169 * of them is there. Finished turns with little in them are taken together.
170 */
171export const nextJob = (trail: Trail, skipped: ReadonlySet<string> = new Set()): Job | null => {
172  const byTurn = new Map<number, Item[]>()
173  for (const item of trail.items) {
174    if (item.kind === 'recap' || item.kind === 'compact') continue
175    byTurn.set(item.turn, [...(byTurn.get(item.turn) ?? []), item])
176  }
177  const waiting: Item[][] = []
178  for (const turn of [...byTurn.keys()].sort((a, b) => b - a)) {
179    const items = byTurn.get(turn)!
180    const isRunning = trail.isOpen && turn === trail.turn
181    // The newest step of a running turn is still gathering its work; the rest can be told.
182    const lastBearer = [...items].reverse().find(bearing)
183    const open = items.filter(item => !item.fed && !skipped.has(item.id) && (!isRunning || item !== lastBearer))
184    const fresh = open.filter(item => bearing(item) || item.kind === 'shot')
185    if (fresh.filter(bearing).length === 0) continue
186    if (isRunning && fresh.filter(isStep).length < STRETCH_STEPS) continue
187    const stretch = [...(open.some(item => item.kind === 'ask') ? [] : items.filter(item => item.kind === 'ask')), ...open]
188    if (waiting.length > 0 && logOf([...waiting, stretch]).prompt.length > LOG_MAX) break
189    waiting.push(stretch)
190    if (isRunning) break
191  }
192  if (waiting.length === 0) return null
193
194  // Oldest first in the log, as it happened.
195  waiting.reverse()
196  const ids = waiting.flat().map(item => item.id)
197  // Only a log that runs to the latest shows how things now stand: what is left with the person, and what no
198  // longer is. One with something already told after it is history being caught up on.
199  const last = Math.max(...waiting.flat().map(item => item.turn))
200  const isLatest = !trail.items.some(item => item.turn > last && item.fed)
201  const open = isLatest ? waitsOf(trail).slice(-OPEN_MOST) : []
202  // While something waits on the person every exchange is read by a model: any of them may be where it was done.
203  const isSlight = (items: Item[]): Beat[] | undefined => (open.length > 0 ? undefined : slight(items))
204  const told = waiting.filter(items => isSlight(items) === undefined)
205  const local = waiting.flatMap(items => isSlight(items) ?? [])
206  if (told.length === 0) return { ids, slots: [], prompt: '', local, isLatest, open: [], pics: [] }
207  const { prompt, slots, pics } = logOf(told)
208  const still = open.length > 0 ? [STILL_WAITING, ...open.map((beat, index) => `w${index + 1}: ${beat.title}`), '', ''].join('\n') : ''
209  return { ids, slots, prompt: still + clip(prompt, LOG_MAX * 2), ...(local.length > 0 ? { local } : {}), isLatest, open: open.map(beat => beat.id), pics }
210}
211
212/**
213 * How many steps have no update and will get none unasked: those of turns
214 * with something already told after them. They went by while the pane was
215 * closed, and are history now that the latest is told.
216 */
217export const backlogOf = (trail: Trail): number => {
218  const told = trail.items.reduce((latest, item) => (item.fed ? Math.max(latest, item.turn) : latest), -1)
219  return trail.items.filter(item => isStep(item) && !item.fed && item.turn < told).length
220}
221
222/** The model's reply as updates: each line it wrote in the form asked for, tied to the last step of the stretch it numbers. */
223export const parseBeats = (reply: string, job: Job): Beat[] => {
224  const beats: Beat[] = []
225  const told = new Set<number>()
226  let waits = 0
227  for (const raw of reply.split('\n')) {
228    // Something left waiting on the person: tied to the stretch that left it, and listed apart until it is done.
229    const wait = /^\s*[-*]?\s*wait\s*\|\s*\[?(\d+)\]?\s*\|\s*(.+?)\s*$/i.exec(raw)
230    if (wait) {
231      const left = job.slots[Number(wait[1]) - 1]
232      const [said = '', withIt = ''] = wait[2]!.split(/\s+:::\s*/, 2)
233      const what = clip(clean(said), WAIT_MAX)
234      // What they need to do it with: commands to copy and links to open, as the model gave them.
235      const refs = withIt
236        .split(/\s*;;\s*/)
237        .map(ref => clean(ref).replace(/^`+|`+$/g, ''))
238        .filter(ref => ref.length >= 2 && ref.length <= REF_MAX)
239        .slice(0, REFS_MOST)
240      if (left && what.length >= 4 && waits < WAITS_MOST) beats.push({ ...beatAt(left, what), id: `w:${left.id}:${waits++}`, kind: 'waiting', ...(refs.length > 0 ? { refs } : {}) })
241      continue
242    }
243    if (/^\s*[-*]?\s*(?:clear|pic)\s*\|/i.test(raw)) continue
244    const found = /^\s*[-*]?\s*\[?(\d+)\]?\s*\|\s*(.+?)\s*$/.exec(raw)
245    if (!found) continue
246    const slot = Number(found[1])
247    const at = job.slots[slot - 1]
248    // The actions of the stretch, each kept short and on a line of its own.
249    const actions = found[2]!
250      .split(/\s+;\s+|\s*;\s*$/)
251      .map(action => clip(clean(action).replace(/^[;\s]+|[.;,\s]+$/g, ''), ACTION_MAX))
252      .filter(action => action.length >= 3)
253      .slice(0, ACTIONS_MOST)
254    // One update to a stretch; a number that names none, or a line left empty, says nothing.
255    if (!at || told.has(slot) || actions.join('').length < 4) continue
256    told.add(slot)
257    beats.push({ ...beatAt(at, ''), title: actions.join('\n') })
258  }
259  return beats
260}
261
262/** The things still waiting on the person that the model's reply says no longer are: done, answered, or not needed now. */
263export const parseCleared = (reply: string, job: Job): string[] => {
264  const cleared = new Set<string>()
265  for (const raw of reply.split('\n')) {
266    const found = /^\s*[-*]?\s*clear\s*\|\s*\[?w?(\d+)\]?\s*$/i.exec(raw)
267    const id = found ? job.open[Number(found[1]) - 1] : undefined
268    if (id) cleared.add(id)
269  }
270  return [...cleared]
271}
272
273/**
274 * What the model's reply says of the pictures the log marked, by picture: only where it had something to say.
275 * One the log told nothing of keeps no caption, so the conversation itself can still be asked about it.
276 */
277export const parsePictures = (reply: string, job: Job): { id: string; text: string }[] => {
278  const told = new Map<string, string>()
279  for (const raw of reply.split('\n')) {
280    const found = /^\s*[-*]?\s*pic\s*\|\s*\[?p?(\d+)\]?\s*\|\s*(.*?)\s*$/i.exec(raw)
281    const id = found ? job.pics[Number(found[1]) - 1] : undefined
282    const text = found ? clean(found[2]!) : ''
283    if (id && !told.has(id) && text.length >= 4 && !/^[-–—]+$/.test(text)) told.set(id, clip(text, CAPTION_MAX))
284  }
285  return [...told].map(([id, text]) => ({ id, text }))
286}
287
288/** The web page of a repository on GitHub, from its remote as git has it; '' for any other. */
289export const repoPage = (remote: string | null | undefined): string => {
290  const found = /^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec((remote ?? '').trim())
291  return found ? `https://github.com/${found[1]}/${found[2]}` : ''
292}
293
294/**
295 * Things waiting on the person that name pull requests by number, with the
296 * link to each added when the session's repository is on GitHub: the number
297 * and the repository are all a link is made of, so it is no guess.
298 */
299export const withPullLinks = (beats: Beat[], remote: string | null | undefined): Beat[] => {
300  const page = repoPage(remote)
301  if (!page) return beats
302  return beats.map(beat => {
303    if (beat.kind !== 'waiting') return beat
304    const numbers: string[] = []
305    for (const named of beat.title.matchAll(/\b(?:PRs?|pull requests?)\s*#?(\d+)((?:\s*(?:,|or|and|&)\s*#?\d+)*)/gi)) {
306      for (const one of [named[1]!, ...[...named[2]!.matchAll(/\d+/g)].map(found => found[0])]) if (!numbers.includes(one)) numbers.push(one)
307    }
308    const had = beat.refs ?? []
309    const links = numbers.map(number => `${page}/pull/${number}`).filter(link => !had.some(ref => ref.replace(/\/$/, '') === link))
310    return links.length > 0 ? { ...beat, refs: [...links, ...had].slice(0, REFS_MOST + links.length) } : beat
311  })
312}
313
314const sameWords = (a: string, b: string): boolean => a.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() === b.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
315
316/**
317 * The trail with the updates in, the steps they tell of marked as told and relieved of what was kept only for
318 * the telling. What the reply left waiting on the person is taken only from a log that ran to the latest (an
319 * older stretch's is long since dealt with), and what it cleared stops waiting as of the log's last step.
320 * What it said of the pictures becomes their captions.
321 */
322export const feedIn = (trail: Trail, job: Job, beats: Beat[], cleared: string[] = [], captions: { id: string; text: string }[] = []): Trail => {
323  const covered = new Set(job.ids)
324  const notes = new Map(captions.map(one => [one.id, one.text]))
325  const known = new Set(trail.feed.map(beat => beat.id))
326  const over = new Set(cleared)
327  const overAt = job.slots.reduce((latest, slot) => Math.max(latest, slot.at), 0)
328  // A thing already waiting that the reply tells of again, now with what it is done with, gains that.
329  const gained = (beat: Beat): string[] | undefined =>
330    beat.kind === 'waiting' && beat.over === undefined && !beat.refs?.length ? beats.find(told => told.kind === 'waiting' && told.refs?.length && (told.id === beat.id || sameWords(told.title, beat.title)))?.refs : undefined
331  const had = trail.feed.map(beat => {
332    const refs = gained(beat)
333    const kept = refs ? { ...beat, refs } : beat
334    return over.has(kept.id) && kept.over === undefined ? { ...kept, over: overAt } : kept
335  })
336  const stillOpen = had.filter(beat => beat.kind === 'waiting' && beat.over === undefined)
337  const fresh = [...(job.local ?? []), ...beats].filter(beat => !known.has(beat.id) && (beat.kind !== 'waiting' || (job.isLatest && !stillOpen.some(other => sameWords(other.title, beat.title)))))
338  const feed = [...had, ...fresh].sort((a, b) => a.turn - b.turn || a.at - b.at)
339  return {
340    ...trail,
341    feed: feed.slice(-MAX_BEATS),
342    items: trail.items.map(found => {
343      const note = notes.get(found.id)
344      const item = note !== undefined && found.shot && found.shot.note === undefined ? { ...found, shot: { ...found.shot, note, guess: true as const } } : found
345      if (!covered.has(item.id)) return item
346      // What was kept only for the telling is let go; a request keeps its words, which are the person's to read back.
347      const { more, ...told } = item
348      return { ...told, ...(item.kind === 'ask' && more ? { more } : {}), fed: true as const, ...(told.work ? { work: { ...told.work, notes: [] } } : {}) }
349    }),
350  }
351}
352
353/**
354 * A trail read back from its transcript, with what only the mod knew kept:
355 * the updates already written for it (nothing is told twice), the captions
356 * of its pictures and the answers given to its questions. Asked to tell the
357 * latest again, it leaves the newest turns that fit one log untold: their
358 * updates stand, and the model reads them once more for what they left
359 * waiting on the person.
360 */
361export const adopt = (built: Trail, had: Trail, isRetelling = false): Trail => {
362  const again = new Set<number>()
363  if (isRetelling) {
364    let room = RETOLD_MAX
365    for (const turn of [...new Set(built.items.map(item => item.turn))].sort((a, b) => b - a)) {
366      if (built.isOpen && turn === built.turn) continue
367      room -= built.items.filter(item => item.turn === turn).reduce((sum, item) => sum + (item.more ?? item.line).length + 60, 0)
368      if (room < 0 && again.size > 0) break
369      again.add(turn)
370    }
371  }
372  const told = new Set(had.items.filter(item => item.fed).map(item => item.id))
373  for (const item of built.items) if (again.has(item.turn)) told.delete(item.id)
374  const notes = new Map(had.items.flatMap(item => (item.shot?.note === undefined ? [] : [[item.id, { note: item.shot.note, ...(item.shot.guess ? { guess: true as const } : {}) }] as const])))
375  const turnOf = new Map(built.items.map(item => [item.id, item.turn]))
376  const asked = new Map(had.questions.map(question => [question.id, question]))
377  const { sought: _, ...rest } = built
378  return {
379    ...rest,
380    // What was told before the mod looked for what waits on the person stays so marked until its latest is told again.
381    ...(isRetelling || had.sought ? { sought: true as const } : {}),
382    // An update keeps to the step it was tied to, wherever that step's turn now counts from.
383    feed: had.feed.map(beat => ({ ...beat, turn: turnOf.get(beat.ref) ?? beat.turn })),
384    questions: [...built.questions.map(question => asked.get(question.id) ?? question), ...had.questions.filter(question => !built.questions.some(other => other.id === question.id))].sort((a, b) => a.at - b.at),
385    items: built.items.map(found => {
386      const note = notes.get(found.id)
387      const item = note !== undefined && found.shot ? { ...found, shot: { ...found.shot, ...note } } : found
388      if (!told.has(item.id)) return item
389      const { more, ...kept } = item
390      return { ...kept, ...(item.kind === 'ask' && more ? { more } : {}), fed: true as const }
391    }),
392  }
393}
394
hooks/caption.ts 86 lines
1// What each picture of the feed shows and what it was about, asked of the conversation itself: the model that
2// writes the updates reads a log and sees no picture, while the conversation holds them and what was said of
3// them. Pure: the hooks module puts the question; this words it and reads the answer.
4
5import type { Item, Trail } from '../types'
6import { clean, clip } from './trail.ts'
7
8/** Pictures to ask about. */
9export type CaptionJob = {
10  /** The pictures, by the number each has in the question. */
11  shots: Item[]
12  prompt: string
13}
14
15/** Pictures asked about at once, newest first: more would be a long answer and old ones may have left the conversation. */
16const MOST = 10
17const CAPTION_MAX = 320
18const SAID_MAX = 110
19
20const INTRO = [
21  'This message is not from the person. It is the Trail side pane, which shows them an activity feed of this conversation, asking you for captions for the pictures and other outputs in that feed. Do not go on with the work and do not use any tool: only answer this.',
22  '',
23  'For each picture listed below, write a caption of one or two plain sentences, at most thirty-five words, that says what the picture shows and what it was about:',
24  '- for a picture the person pasted: what they were showing you and what they were telling you with it;',
25  '- for a picture you opened or were given by a tool: why you looked at it and what you saw in it;',
26  '- for a picture you sent them: what it shows and why you sent it.',
27  'Say what you made of it, concretely, so that someone who has not seen the conversation understands the picture. Write to the person as "you". No filler.',
28  'If you cannot find a picture in this conversation, write a single dash for it.',
29  '',
30  'The pictures, oldest first:',
31]
32
33const OUTRO = ['', 'Reply with one line per picture and nothing else:', 'N|caption']
34
35/** How the question names a picture, so the conversation can tell which is meant. */
36const described = (shot: Item, trail: Trail, time: (at: number) => string): string => {
37  const when = shot.at > 0 ? ` at ${time(shot.at)}` : ''
38  const ask = trail.items.find(item => item.kind === 'ask' && item.id === shot.anchor)
39  if (ask) {
40    const fellows = trail.items.filter(item => item.kind === 'shot' && item.anchor === shot.anchor)
41    const place = fellows.length > 1 ? ` (picture ${fellows.indexOf(shot) + 1} of ${fellows.length} in that message)` : ''
42    return `a picture the person pasted${when}, with their message beginning "${clip(ask.line, SAID_MAX)}"${place}`
43  }
44  const type = shot.shot?.type ?? 'image'
45  if (type === 'link') return `the page you published${when} (${shot.line}): say what it is and what it is for`
46  if (type === 'video') return `the video ${shot.line.replace(/^Sent you /, '')}, which ${shot.line.startsWith('Sent you ') ? 'you sent the person' : 'a command you ran made'}${when}: you cannot see it here, so say what it is of and what it was made for`
47  if (type === 'file') return `the file ${shot.line.replace(/^Sent you /, '')}, which you sent the person${when}: say what it is and what it is for`
48  if (shot.line.startsWith('Sent you ')) return `the picture ${shot.line.slice('Sent you '.length)}, which you sent the person${when}`
49  if (shot.line.startsWith('Picture from ')) return `a picture a tool returned to you${when} (${shot.line.slice('Picture from '.length)})`
50  return `the picture ${shot.line}, which you opened${when}`
51}
52
53/**
54 * The pictures still without a caption of the conversation's own (none at
55 * all, or one only made out from the words around it), as a question for it:
56 * only those since the context was last compacted, which are the ones it
57 * still holds. Null when there are none.
58 */
59export const captionJob = (trail: Trail, time: (at: number) => string): CaptionJob | null => {
60  const since = trail.items.map(item => item.kind).lastIndexOf('compact')
61  const shots = trail.items.filter((item, index) => index > since && item.kind === 'shot' && item.shot !== undefined && (item.shot.note === undefined || item.shot.guess === true)).slice(-MOST)
62  if (shots.length === 0) return null
63  const lines = shots.map((shot, index) => `${index + 1}. ${described(shot, trail, time)}`)
64  return { shots, prompt: [...INTRO, ...lines, ...OUTRO].join('\n') }
65}
66
67/**
68 * The answer as captions, by picture. One the conversation could not find,
69 * or gave no line for, gets an empty caption: it has been asked about, and
70 * is not asked about again.
71 */
72export const parseCaptions = (reply: string, job: CaptionJob): { id: string; text: string }[] => {
73  const told = new Map<number, string>()
74  for (const raw of reply.split('\n')) {
75    const found = /^\s*[-*]?\s*\[?(\d+)\]?\s*[|.:)]\s*(.*?)\s*$/.exec(raw)
76    if (!found) continue
77    const index = Number(found[1]) - 1
78    if (index < 0 || index >= job.shots.length || told.has(index)) continue
79    const text = clean(found[2]!)
80    told.set(index, /^[-–—]*$/.test(text) ? '' : clip(text, CAPTION_MAX))
81  }
82  // An answer with no line in the form asked for is no answer: the pictures are asked about another time.
83  if (told.size === 0) return []
84  return job.shots.map((shot, index) => ({ id: shot.id, text: told.get(index) ?? '' }))
85}
86
hooks/jump.ts 37 lines
1// Which transcript row a line jumps to, and what to try when that row will not scroll.
2// Pure: the scroll itself is handed in, so the order of tries can be tested without a transcript.
3
4/** The engine's word for a scroll nothing the person did asked for: no other row will fare better. */
5const NOT_ASKED = 'not person-initiated'
6
7/**
8 * The ids a line's row may answer to, likeliest first. A message's row is
9 * drawn under its own id. A tool call's row is too, unless the transcript
10 * folded it into a run of calls ("Read 3 files"): then the run's id is the
11 * one that scrolls. After those, the same for the tool call beside the row.
12 */
13export const candidates = (target: { anchor: string; alt?: string }, groupsByCall: ReadonlyMap<string, string>): string[] => {
14  const all = [groupsByCall.get(target.anchor), target.anchor, target.alt ? groupsByCall.get(target.alt) : undefined, target.alt]
15  return all.filter((id, index): id is string => typeof id === 'string' && id !== '' && all.indexOf(id) === index)
16}
17
18export type Reached = {
19  /** Why the transcript did not move; '' when it did. */
20  why: string
21  /** Each id tried and what came of it, for whoever is working on the mod. */
22  tried: string[]
23}
24
25/** Tries each id in turn until one scrolls; `scroll` resolves why not, or undefined when it moved. */
26export const reach = async (ids: readonly string[], scroll: (id: string) => Promise<string | undefined>): Promise<Reached> => {
27  const tried: string[] = []
28  let why = 'no row is known for this line'
29  for (const id of ids) {
30    const deny = await scroll(id)
31    tried.push(`${id}=${deny ?? 'ok'}`)
32    why = deny ?? ''
33    if (why === '' || why === NOT_ASKED) break
34  }
35  return { why, tried }
36}
37
hooks/prune.ts 53 lines
1// What of the mod's kept data is past keeping. Pure: the hooks module lists the folders and removes what this names.
2//
3// Each session has a folder with its trail in it, which is small. The mod no longer keeps pictures at all (it tells
4// of them in words and opens the file itself), so the `shots` folders that older versions filled are only there to
5// be cleared: a session's goes once it has been idle a day, and a whole-size copy a day after it was made. The
6// folder itself goes when the transcript would, after the days Claude Code keeps one.
7
8/** Days a session may be idle before the pictures an older version kept for it are let go. */
9export const PICTURES_DAYS = 1
10/** Days a session's folder is kept when the person's settings do not say how long transcripts are. */
11export const KEEP_DAYS = 30
12const DAY_MS = 86_400_000
13/** A session's folder is named by its id; nothing else in the mod's folder is ever removed. */
14const SESSION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
15/** A whole-size copy of a picture, as the mod names one beside its small copy. */
16const WHOLE_COPY = /^[A-Za-z0-9_-]+\.full\.[A-Za-z0-9]+$/
17
18export const isSession = (name: string): boolean => SESSION.test(name)
19
20/** One folder in the mod's own: its name, when its trail was last written (0 when that is not known), whether it has pictures. */
21export type Kept = { name: string; touchedAt: number; hasShots: boolean }
22
23/** The days a session's folder is kept: as long as Claude Code keeps its transcript (`cleanupPeriodDays`), a month when that is not set. */
24export const keepDays = (setting: unknown): number => (typeof setting === 'number' && Number.isFinite(setting) && setting >= 1 ? Math.floor(setting) : KEEP_DAYS)
25
26/**
27 * The folders to remove whole, and those to remove only the pictures of.
28 * The session asking is never among them, nor is a folder whose age is not
29 * known or whose name is not a session's.
30 */
31export const prunable = (kept: readonly Kept[], now: number, own: string, days: number): { folders: string[]; pictures: string[] } => {
32  const folders: string[] = []
33  const pictures: string[] = []
34  for (const one of kept) {
35    if (!SESSION.test(one.name) || one.name === own || one.touchedAt <= 0) continue
36    const idle = (now - one.touchedAt) / DAY_MS
37    if (idle >= days) folders.push(one.name)
38    else if (idle >= Math.min(PICTURES_DAYS, days) && one.hasShots) pictures.push(one.name)
39  }
40  return { folders, pictures }
41}
42
43/**
44 * The whole-size copies in one session's pictures that are past keeping: made
45 * a day ago or more. Versions before 0.4 copied every picture whole, which
46 * was most of what the mod kept; none is made any more.
47 */
48export const staleCopies = (files: readonly { name: string; kind: string; mtimeMs: number }[], now: number): string[] =>
49  files.filter(file => file.kind === 'file' && WHOLE_COPY.test(file.name) && file.mtimeMs > 0 && now - file.mtimeMs >= PICTURES_DAYS * DAY_MS).map(file => file.name)
50
51/** A size in bytes as a person reads it: "143 MB". */
52export const sized = (kilobytes: number): string => (kilobytes >= 1024 * 1024 ? (kilobytes / 1024 / 1024).toFixed(1) + ' GB' : kilobytes >= 1024 ? Math.round(kilobytes / 1024) + ' MB' : Math.max(0, Math.round(kilobytes)) + ' KB')
53
hooks/trail.ts 547 lines
1// The trail's steps: conversation events in, a list of what happened out.
2// Pure (no `$`, no Node, no DOM), so the hooks, the tests and the rebuild tool share it.
3
4import type { Beat, Item, ItemKind, OutputType, Question, Shot, Source, Trail, Work } from '../types'
5
6export type TrailEvent =
7  | { t: 'prompt'; id: string; at: number; text: string; isCommand?: boolean }
8  | { t: 'say'; id: string; at: number; text: string }
9  | { t: 'tool'; id: string; at: number; tool: string; note: string; file?: string }
10  | { t: 'fail'; id: string; at: number }
11  | { t: 'shot'; id: string; at: number; anchor: string; file: string; w: number; h: number; label: string; full?: string; stamp?: number; type?: OutputType; from?: Source; note?: string }
12  | { t: 'recap'; id: string; at: number; text: string }
13  | { t: 'compact'; id: string; at: number }
14  | { t: 'question'; id: string; at: number; text: string; options: string[]; shot?: Shot }
15  | { t: 'answer'; id: string; at: number; text: string }
16  | { t: 'caption'; id: string; at: number; text: string }
17  | { t: 'opened'; id: string; at: number }
18  | { t: 'end'; at: number; aborted?: boolean }
19
20export const MAX_ITEMS = 400
21export const MAX_BEATS = 300
22const MAX_QUESTIONS = 40
23const QUESTION_MAX = 400
24const OPTIONS_MOST = 4
25const OPTION_MAX = 28
26/** What a question the conversation went on past is taken to have been answered with. */
27export const ANSWERED_THERE = 'answered in the conversation'
28const FILES_KEPT = 3
29const EXTRA_KEPT = 24
30const NOTES_KEPT = 8
31const RECENT_KEPT = 60
32const LINE_MAX = 200
33const RECAP_MAX = 600
34const NOTE_MAX = 320
35/** How much of what was said is kept for a highlight to be written from: all of an answer, the start of a step. */
36const ANSWER_MAX = 1600
37/** Of an answer longer than that, how much is its end: where it says how things stand and what waits on the person. */
38const ANSWER_END = 600
39const STEP_MAX = 320
40const SENTENCE_MIN = 32
41/** A line this long before a list or a break stands on its own. */
42const LEAD_MIN = 20
43const CARRIED_ON = 'Carried on without a new prompt'
44const TASK = 'Background task: '
45
46/** How an answer given in the pane reads when it reaches the conversation, and how it is known again in a transcript. */
47export const paneAnswer = (question: string, answer: string): string => `About your question in the Trail pane ("${question}"): ${answer}`
48const PANE_ANSWER = /^About your question in the Trail pane \("[\s\S]*?"\): ([\s\S]+)$/
49
50export const emptyTrail = (): Trail => ({ items: [], feed: [], questions: [], turn: 0, isOpen: false, isFresh: false, lastTool: '', lastAt: 0, recent: [], sought: true })
51
52const emptyWork = (): Work => ({ cmds: 0, reads: 0, edits: 0, agents: 0, other: 0, fails: 0, files: [], moreFiles: 0, extra: [], now: '', notes: [] })
53
54const READS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'ToolSearch'])
55const EDITS = new Set(['Write', 'Edit', 'NotebookEdit'])
56const AGENTS = new Set(['Agent', 'Task', 'Workflow'])
57
58/** A short name for a long string, the same wherever it is worked out: what a file made from a path is called by. */
59export const tag = (text: string): string => {
60  let hash = 5381
61  for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0
62  return (hash >>> 0).toString(36)
63}
64
65const MOVING = /\.(mp4|mov|m4v|webm|mkv|gif)$/i
66const STILL = /\.(png|jpe?g|webp|heic|tiff?|bmp)$/i
67
68/** A file last written this long before a session's first event was there before it: looked at, not made. */
69const EARLIER_MS = 60_000
70
71/** Whether a file Claude opened is from before the session, given when it was last written and when the session began. */
72export const isEarlier = (writtenAt: number, beganAt: number): boolean => beganAt > 0 && writtenAt > 0 && writtenAt < beganAt - EARLIER_MS
73
74/**
75 * Whether a picture of the trail is Claude's own output: something the
76 * session made, sent or published, and not one the person pasted or a file
77 * from before the session that Claude only looked at.
78 */
79export const isOwnOutput = (item: Item, trail: Trail): boolean =>
80  item.kind === 'shot' && item.shot?.from === undefined && !trail.items.some(other => other.kind === 'ask' && other.id === item.anchor)
81
82/** The things the work left for the person to do that they have not done yet, oldest first. */
83export const waitsOf = (trail: Trail): Beat[] => trail.feed.filter(beat => beat.kind === 'waiting' && beat.over === undefined)
84
85/** How many things wait on the person: questions put to them in the pane, and what the work left for them to do. */
86export const waitingCount = (trail: Trail): number => trail.questions.filter(question => question.answer === undefined).length + waitsOf(trail).length
87
88/** How a file Claude handed the person is named in the trail. */
89export const SENT = 'Sent you '
90
91/** What kind of output a file is, by its name. */
92export const outputType = (path: string): OutputType => (MOVING.test(path) ? 'video' : STILL.test(path) ? 'image' : 'file')
93
94/**
95 * The video files a command names, in what it ran and what it printed: what
96 * rendering or recording leaves behind and nobody opens with a tool. Paths
97 * as written, a few at most; whether each is a file just made is the caller's
98 * to find out.
99 */
100export const videosNamed = (text: string): string[] => {
101  const found = new Set<string>()
102  for (const match of text.matchAll(/(?:^|[\s'"=(:])((?:~\/|\.{0,2}\/)?[\w@%+,.\-\/]+\.(?:mp4|mov|m4v|webm|mkv|gif))(?=$|[\s'"),;:])/gim)) {
103    found.add(match[1]!)
104    if (found.size >= 5) break
105  }
106  return [...found]
107}
108
109/** The address a page was published at, in what the tool that published it answered. */
110export const publishedAt = (text: string): string | undefined => /\bPublished\b[^\n]*?\bat (https:\/\/[^\s)]+)/.exec(text)?.[1]
111
112export const basename = (path: string): string => {
113  const cut = path.replace(/\/+$/, '')
114  return cut.slice(cut.lastIndexOf('/') + 1)
115}
116
117const span = (from: number, to: number): string => String.fromCharCode(from) + '-' + String.fromCharCode(to)
118/** Control characters and the marks that turn text around or hide in it: one of them in a tree refuses the whole pane. */
119const UNSEEN = new RegExp('[' + span(0, 0x1f) + span(0x7f, 0x9f) + span(0x200b, 0x200f) + span(0x2028, 0x202e) + String.fromCharCode(0xfeff) + ']', 'g')
120
121/** One line of plain characters. */
122export const clean = (text: string): string => text.replace(UNSEEN, ' ').replace(/\s+/g, ' ').trim()
123
124/** Cut by characters, never through one, at a word where there is one near. */
125export const clip = (text: string, max: number): string => {
126  const chars = Array.from(text)
127  if (chars.length <= max) return text
128  const cut = chars.slice(0, max - 1).join('')
129  const space = cut.lastIndexOf(' ')
130  return (space > cut.length * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,;:]+$/, '') + '…'
131}
132
133const plain = (text: string): string =>
134  clean(
135    text
136      .replace(/```[\s\S]*?(```|$)/g, ' ')
137      .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, '')
138      .replace(/\*\*([^*]+)\*\*/g, '$1')
139      .replace(/`([^`]+)`/g, '$1')
140      .replace(/\[([^\]]+)\]\((?:[^)]+)\)/g, '$1'),
141  )
142
143/** Characters kept of one block of code in what is put to the model. */
144const CODE_KEPT = 300
145
146/**
147 * What was said, flattened for the model that tells of it: as `plain`, but
148 * with what the person may have to act on kept. The commands in its code
149 * blocks stay, each on one line between backticks, and a link keeps where it
150 * leads. Without these the model cannot say what to run or what to open.
151 */
152const telling = (text: string): string =>
153  clean(
154    text
155      .replace(/```(?:[A-Za-z0-9_+-]*\n)?([\s\S]*?)(?:```|$)/g, (_, code: string) => (code.trim() ? ' `' + clip(code.replace(/\s+/g, ' ').trim(), CODE_KEPT) + '` ' : ' '))
156      .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, '')
157      .replace(/\*\*([^*]+)\*\*/g, '$1')
158      .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label: string, to: string) => (/^https?:/.test(to) && to !== label ? `${label} (${to})` : label)),
159  )
160
161/** What is kept of an answer: all of it, or of a long one its start and its end, the middle left out. */
162const headAndEnd = (flat: string): string => {
163  const chars = Array.from(flat)
164  if (chars.length <= ANSWER_MAX) return flat
165  const end = chars.slice(-ANSWER_END).join('')
166  return clip(flat, ANSWER_MAX - ANSWER_END) + ' ' + end.slice(end.indexOf(' ') + 1)
167}
168
169/** Where the first sentence of a plain line ends; a very short one takes the next along. */
170const sentenceEnd = (flat: string): number => {
171  const stop = /[.!?:](?=\s+[A-Z"'`(\[]|$)/g
172  for (let match = stop.exec(flat); match; match = stop.exec(flat)) {
173    const isLast = match.index + 1 >= flat.length
174    if (!isLast && (match[0] === ':' || match.index + 1 < SENTENCE_MIN)) continue
175    return match.index + 1
176  }
177  return flat.length
178}
179
180/** A line that leads into a list or a new paragraph says its piece alone: the first item is not the rest of its sentence. */
181const lead = (text: string): string => {
182  const cut = /\n(?=\s*\n|\s{0,3}(?:[-*+]|\d+\.)\s)/.exec(text)
183  return cut && plain(text.slice(0, cut.index)).length >= LEAD_MIN ? text.slice(0, cut.index) : text
184}
185
186/** The first sentence of what the model said, as one plain line. */
187export const firstSentence = (text: string): string => {
188  const flat = plain(lead(text))
189  return clip(flat.slice(0, sentenceEnd(flat)), LINE_MAX)
190}
191
192const TAGGED = /<(system-reminder|local-command-stdout|local-command-stderr|local-command-caveat|task-notification|command-message|skill-format|bash-stdout|bash-stderr)>[\s\S]*?<\/\1>/g
193const INTERRUPTED = /^\s*\[Request interrupted by user/
194
195/** The person's prompt as one plain line; empty for rows that are not a prompt at all. */
196export const promptLine = (text: string, max = LINE_MAX): string => {
197  const command = /<command-name>\s*(\/?[^<\s]+)\s*<\/command-name>/.exec(text)
198  if (command) {
199    const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text)
200    const name = command[1]!.startsWith('/') ? command[1]! : '/' + command[1]!
201    return clip(clean(name + ' ' + (args?.[1] ?? '')), max)
202  }
203  const task = /<task-notification>([\s\S]*?)(?:<\/task-notification>|$)/.exec(text)
204  const summary = task && /<summary>([\s\S]*?)<\/summary>/.exec(task[1]!)
205  if (summary) {
206    // How the task came out is what matters about it: said first, where no clip takes it.
207    const said = clean(summary[1]!)
208    const status = clean(/<status>([\s\S]*?)<\/status>/.exec(task![1]!)?.[1] ?? '')
209    return clip((status && !said.toLowerCase().includes(status.toLowerCase()) ? `Background task ${status}: ` : TASK) + said, max)
210  }
211  // The engine's own mark for a turn the person stopped is no prompt of theirs.
212  if (INTERRUPTED.test(text)) return ''
213  const shell = /<bash-input>([\s\S]*?)<\/bash-input>/.exec(text)
214  if (shell) return clip(clean('! ' + shell[1]!), max)
215  const said = text
216    .replace(TAGGED, ' ')
217    .replace(/<\/?pasted_content[^>]*>/g, ' ')
218    .replace(/\[Image(?: #\d+|: source: [^\]]*)\]/g, ' ')
219  return clip(clean(said), max)
220}
221
222/** One short phrase for a tool call, from its own arguments. */
223export const toolNote = (tool: string, input: Record<string, unknown>): string => {
224  const text = (key: string): string => (typeof input[key] === 'string' ? (input[key] as string) : '')
225  const note =
226    text('description') ||
227    (text('file_path') && basename(text('file_path'))) ||
228    text('skill') ||
229    text('query') ||
230    text('url') ||
231    text('pattern') ||
232    text('command') ||
233    text('name')
234  const short = tool.startsWith('mcp__') ? tool.split('__').slice(2).join(' ') || tool : tool
235  return clip(clean(note || short), 80)
236}
237
238export const workCount = (work: Work): number => work.cmds + work.reads + work.edits + work.agents + work.other
239
240const tally = (work: Work, tool: string, note: string, file?: string): Work => {
241  const next = { ...work, now: note }
242  // A call that says what it is for is what a highlight is written from; a bare file name says little.
243  const isTelling = note !== '' && (tool === 'Bash' || AGENTS.has(tool) || tool.startsWith('mcp__') || tool === 'Skill' || tool === 'WebSearch')
244  if (isTelling && next.notes.length < NOTES_KEPT && !next.notes.includes(note)) next.notes = [...next.notes, note]
245  if (tool === 'Bash') next.cmds++
246  else if (READS.has(tool)) next.reads++
247  else if (EDITS.has(tool)) {
248    next.edits++
249    const name = file ? clean(basename(file)) : ''
250    if (name && !next.files.includes(name) && !next.extra.includes(name)) {
251      if (next.files.length < FILES_KEPT) next.files = [...next.files, name]
252      else {
253        next.moreFiles++
254        if (next.extra.length < EXTRA_KEPT) next.extra = [...next.extra, name]
255      }
256    }
257  } else if (AGENTS.has(tool)) next.agents++
258  else next.other++
259  return next
260}
261
262const merge = (into: Work, from: Work): Work => {
263  const files = [...into.files]
264  const extra = [...into.extra]
265  let moreFiles = into.moreFiles
266  for (const name of [...from.files, ...from.extra]) {
267    if (files.includes(name) || extra.includes(name)) continue
268    if (files.length < FILES_KEPT) files.push(name)
269    else {
270      moreFiles++
271      if (extra.length < EXTRA_KEPT) extra.push(name)
272    }
273  }
274  // Names past what either kept are counted, though not known.
275  moreFiles += Math.max(0, from.moreFiles - from.extra.length)
276  return {
277    cmds: into.cmds + from.cmds,
278    reads: into.reads + from.reads,
279    edits: into.edits + from.edits,
280    agents: into.agents + from.agents,
281    other: into.other + from.other,
282    fails: into.fails + from.fails,
283    files,
284    moreFiles,
285    extra,
286    now: from.now || into.now,
287    notes: [...into.notes, ...from.notes.filter(note => !into.notes.includes(note))].slice(0, NOTES_KEPT),
288  }
289}
290
291export const change = (items: Item[], index: number, to: (item: Item) => Item): Item[] => {
292  const next = items.slice()
293  next[index] = to(next[index]!)
294  return next
295}
296
297const withKind = (items: Item[], index: number, kind: ItemKind): Item[] => change(items, index, item => ({ ...item, kind }))
298
299/** The line of the turn that tool calls count under: its newest ask, step or result. */
300const bearerOf = (items: Item[], turn: number, before = items.length): number => {
301  for (let i = before - 1; i >= 0; i--) {
302    const item = items[i]!
303    if (item.kind === 'recap' || item.kind === 'compact' || item.kind === 'shot') continue
304    return item.turn === turn ? i : -1
305  }
306  return -1
307}
308
309/**
310 * Keeps the list bounded. Steps go first, their work kept under the line
311 * before them: those of older turns, then the oldest of the running turn. A
312 * turn never loses its ask, result or pictures that way; only when nothing
313 * but those is left do whole turns go, oldest first.
314 */
315const thin = (items: Item[]): Item[] => {
316  if (items.length <= MAX_ITEMS) return items
317  const out = items.slice()
318  let over = out.length - MAX_ITEMS
319  const newest = out[out.length - 1]!.turn
320  for (const ofNewest of [false, true]) {
321    for (let i = 0; i < out.length - 1 && over > 0; i++) {
322      const item = out[i]!
323      if (item.kind !== 'step' || (item.turn === newest) !== ofNewest) continue
324      const bearer = bearerOf(out, item.turn, i)
325      if (bearer < 0) continue
326      if (item.work) out[bearer] = { ...out[bearer]!, work: merge(out[bearer]!.work ?? emptyWork(), item.work) }
327      out.splice(i, 1)
328      i--
329      over--
330    }
331  }
332  if (over <= 0) return out
333  let cut = over
334  while (cut < out.length - 1 && out[cut]!.turn === out[cut - 1]!.turn) cut++
335  return out.slice(cut)
336}
337
338const has = (trail: Trail, id: string): boolean => trail.items.some(item => item.id === id)
339
340/** A turn to put work under: the one running, the newest ask's if it has not ended, else one nobody asked for. */
341const reopen = (trail: Trail, id: string, at: number): Trail => {
342  if (trail.isOpen) return trail
343  if (trail.isFresh) return { ...trail, isOpen: true }
344  const turn = trail.turn + 1
345  const ask: Item = { id: id + ':turn', anchor: id, kind: 'ask', at, turn, line: CARRIED_ON }
346  return { ...trail, items: [...trail.items, ask], turn, isOpen: true, isFresh: true }
347}
348
349const seenOnce = (trail: Trail, key: string): Trail | null =>
350  trail.recent.includes(key) ? null : { ...trail, recent: [...trail.recent, key].slice(-RECENT_KEPT) }
351
352/** Folds one thing that happened into the trail. */
353export const add = (trail: Trail, event: TrailEvent): Trail => {
354  const next = fold(trail, event)
355  // A caption is written after the fact: it is no sign of when the work last moved.
356  return event.t !== 'caption' && event.at > next.lastAt ? { ...next, lastAt: event.at } : next
357}
358
359const fold = (trail: Trail, event: TrailEvent): Trail => {
360  switch (event.t) {
361    case 'prompt': {
362      if (has(trail, event.id)) return trail
363      const line = promptLine(event.text)
364      if (!line) return trail
365      // A command typed while a turn runs is beside the point of that turn: it neither ends it nor heads the work after.
366      if (event.isCommand && trail.isOpen) return trail
367      // A turn never seen to end stopped when it last did something, not when the person next spoke, and with no result.
368      const closed = trail.isOpen ? fold(trail, { t: 'end', at: trail.lastAt || event.at, aborted: true }) : trail
369      const turn = closed.turn + 1
370      const ask: Item = { id: event.id, anchor: event.id, kind: 'ask', at: event.at, turn, line }
371      const whole = promptLine(event.text, ANSWER_MAX)
372      if (whole.length > line.length) ask.more = whole
373      // Whatever the person says next is taken as their answer to what was waiting on them: said in the pane, it
374      // names the question; said in the conversation, the conversation has it.
375      const isTheirs = event.isCommand !== true && !/^Background task\b/.test(line)
376      const given = PANE_ANSWER.exec(whole)?.[1]
377      const questions = isTheirs
378        ? closed.questions.map(question => (question.answer === undefined ? { ...question, answer: clip(given ?? ANSWERED_THERE, QUESTION_MAX), answeredAt: event.at } : question))
379        : closed.questions
380      return { ...closed, items: thin([...closed.items, ask]), questions, turn, isOpen: event.isCommand !== true, isFresh: true }
381    }
382    case 'say': {
383      if (has(trail, event.id)) return trail
384      const flat = plain(event.text)
385      const line = clip(flat.slice(0, sentenceEnd(flat)), LINE_MAX)
386      if (!line) return trail
387      const base = reopen(trail, event.id, event.at)
388      // What was said before this turned out not to be the turn's answer: its start is enough to keep.
389      const before = bearerOf(base.items, base.turn)
390      const items =
391        before >= 0 && base.items[before]!.kind === 'step' && (base.items[before]!.more?.length ?? 0) > STEP_MAX
392          ? change(base.items, before, item => ({ ...item, more: clip(item.more ?? '', STEP_MAX) }))
393          : base.items
394      const step: Item = { id: event.id, anchor: event.id, kind: 'step', at: event.at, turn: base.turn, line }
395      // What is kept for the telling has the commands and the links of what was said, which the line shown does not.
396      const told = telling(event.text)
397      if (flat.length > line.length || told !== flat) step.more = headAndEnd(told)
398      if (base.lastTool) step.alt = base.lastTool
399      return { ...base, items: thin([...items, step]) }
400    }
401    case 'tool': {
402      const counted = seenOnce(trail, event.id)
403      if (!counted) return trail
404      const base = { ...reopen(counted, event.id, event.at), lastTool: event.id }
405      const bearer = bearerOf(base.items, base.turn)
406      if (bearer < 0) return base
407      const items = base.items[bearer]!.kind === 'done' ? withKind(base.items, bearer, 'step') : base.items
408      return {
409        ...base,
410        items: change(items, bearer, item => ({
411          ...item,
412          // The row a line stands for may not scroll; the first call made under it is beside it and does.
413          alt: item.work && workCount(item.work) > 0 ? item.alt : event.id,
414          work: tally(item.work ?? emptyWork(), event.tool, event.note, event.file),
415        })),
416      }
417    }
418    case 'fail': {
419      const counted = seenOnce(trail, 'fail:' + event.id)
420      if (!counted) return trail
421      const bearer = bearerOf(counted.items, counted.turn)
422      if (bearer < 0) return counted
423      return { ...counted, items: change(counted.items, bearer, item => ({ ...item, work: { ...(item.work ?? emptyWork()), fails: (item.work?.fails ?? 0) + 1 } })) }
424    }
425    case 'shot': {
426      const id = event.id + ':shot'
427      if (has(trail, id)) return trail
428      const base = reopen(trail, event.id, event.at)
429      const shot: Item = {
430        id,
431        anchor: event.anchor,
432        kind: 'shot',
433        at: event.at,
434        turn: base.turn,
435        line: clip(clean(event.label), LINE_MAX),
436        shot: { file: event.file, w: event.w, h: event.h },
437      }
438      if (event.full) shot.shot!.full = event.full
439      if (event.stamp !== undefined) shot.shot!.stamp = event.stamp
440      if (event.type && event.type !== 'image') shot.shot!.type = event.type
441      if (event.from) shot.shot!.from = event.from
442      // What Claude said of a file as it handed it over is its caption from the start.
443      if (event.note && clean(event.note)) shot.shot!.note = clip(clean(event.note), NOTE_MAX)
444      return { ...base, items: thin([...base.items, shot]) }
445    }
446    case 'recap': {
447      const line = clip(plain(event.text), RECAP_MAX)
448      if (!line || has(trail, event.id)) return trail
449      // Only where things stand now is worth its rows: a newer recap takes the older one's place.
450      const recap: Item = { id: event.id, anchor: event.id, kind: 'recap', at: event.at, turn: trail.turn, line }
451      return { ...trail, items: thin([...trail.items.filter(item => item.kind !== 'recap'), recap]) }
452    }
453    case 'compact': {
454      if (has(trail, event.id) || trail.items[trail.items.length - 1]?.kind === 'compact') return trail
455      const at: Item = { id: event.id, anchor: event.id, kind: 'compact', at: event.at, turn: trail.turn, line: 'Context was compacted here' }
456      if (trail.lastTool) at.alt = trail.lastTool
457      return { ...trail, items: thin([...trail.items, at]) }
458    }
459    case 'question': {
460      const text = clip(clean(event.text), QUESTION_MAX)
461      if (!text || trail.questions.some(question => question.id === event.id)) return trail
462      const options = event.options.map(option => clip(clean(option), OPTION_MAX)).filter(Boolean).slice(0, OPTIONS_MOST)
463      const question: Question = { id: event.id, at: event.at, text, options }
464      if (event.shot) question.shot = event.shot
465      return { ...trail, questions: [...trail.questions, question].slice(-MAX_QUESTIONS) }
466    }
467    case 'answer': {
468      const text = clip(clean(event.text), QUESTION_MAX)
469      const index = trail.questions.findIndex(question => question.id === event.id && question.answer === undefined)
470      if (index < 0 || !text) return trail
471      const questions = trail.questions.slice()
472      questions[index] = { ...questions[index]!, answer: text, answeredAt: event.at }
473      return { ...trail, questions }
474    }
475    case 'opened': {
476      // `id` is the output's own line: the person has opened it, so it reads as read from now on.
477      const index = trail.items.findIndex(item => item.id === event.id && item.shot !== undefined)
478      if (index < 0) return trail
479      return { ...trail, items: change(trail.items, index, item => ({ ...item, shot: { ...item.shot!, opened: event.at } })) }
480    }
481    case 'caption': {
482      // `id` is the picture's own line; an empty caption still says the picture was asked about.
483      const index = trail.items.findIndex(item => item.id === event.id && item.shot !== undefined)
484      if (index < 0) return trail
485      const { guess: _, ...seen } = trail.items[index]!.shot!
486      if (seen.note === event.text && trail.items[index]!.shot!.guess === undefined) return trail
487      // The conversation saw the picture: what it says replaces what was only made out from the words around it.
488      return { ...trail, items: change(trail.items, index, item => ({ ...item, shot: { ...seen, note: event.text || seen.note || '' } })) }
489    }
490    case 'end': {
491      if (!trail.isOpen) return trail.isFresh ? { ...trail, isFresh: false } : trail
492      let items = trail.items
493      const last = bearerOf(items, trail.turn)
494      // What the model said last, with nothing done after it, is how the turn came out, unless it was cut short.
495      if (!event.aborted && last >= 0 && items[last]!.kind === 'step' && workCount(items[last]!.work ?? emptyWork()) === 0) items = withKind(items, last, 'done')
496      const ask = items.findIndex(item => item.turn === trail.turn && item.kind === 'ask')
497      if (ask >= 0) items = change(items, ask, item => ({ ...item, ms: Math.max(0, event.at - item.at) }))
498      return { ...trail, items, isOpen: false, isFresh: false }
499    }
500  }
501}
502
503const isQuestion = (question: unknown): question is Question =>
504  typeof question === 'object' && question !== null && typeof (question as Question).id === 'string' && typeof (question as Question).text === 'string' && Array.isArray((question as Question).options)
505
506const isBeat = (beat: unknown): beat is Beat =>
507  typeof beat === 'object' && beat !== null && typeof (beat as Beat).id === 'string' && typeof (beat as Beat).title === 'string' && typeof (beat as Beat).anchor === 'string'
508
509/** A trail read back from a file or the state, with anything missing made whole. */
510export const mended = (value: unknown): Trail | null => {
511  if (typeof value !== 'object' || value === null) return null
512  const from = value as Partial<Trail>
513  if (!Array.isArray(from.items)) return null
514  const items = from.items
515    .filter((item): item is Item => typeof item === 'object' && item !== null && typeof item.id === 'string' && typeof item.line === 'string' && typeof item.kind === 'string')
516    .map(item => (item.work && (!Array.isArray(item.work.notes) || !Array.isArray(item.work.extra)) ? { ...item, work: { ...emptyWork(), ...item.work, notes: item.work.notes ?? [], extra: item.work.extra ?? [] } } : item))
517  return {
518    items,
519    feed: Array.isArray(from.feed) ? from.feed.filter(isBeat) : [],
520    questions: Array.isArray(from.questions) ? from.questions.filter(isQuestion) : [],
521    turn: typeof from.turn === 'number' ? from.turn : 0,
522    isOpen: from.isOpen === true,
523    isFresh: from.isFresh === true,
524    lastTool: typeof from.lastTool === 'string' ? from.lastTool : '',
525    lastAt: typeof from.lastAt === 'number' ? from.lastAt : items.reduce((latest, item) => Math.max(latest, item.at), 0),
526    recent: Array.isArray(from.recent) ? from.recent.filter((id): id is string => typeof id === 'string') : [],
527    ...(from.sought === true ? { sought: true as const } : {}),
528  }
529}
530
531/** "1 failed · 6 commands · edited draw.ts, png.ts +2" */
532export const workLine = (work: Work): string => {
533  const parts: string[] = []
534  const count = (n: number, one: string, many: string): void => {
535    if (n > 0) parts.push(n + ' ' + (n === 1 ? one : many))
536  }
537  // What failed is said first: the line is clipped from its end, and that is the last thing to lose.
538  count(work.fails, 'failed', 'failed')
539  count(work.cmds, 'command', 'commands')
540  if (work.files.length > 0) parts.push('edited ' + work.files.join(', ') + (work.moreFiles > 0 ? ' +' + work.moreFiles : ''))
541  else count(work.edits, 'edit', 'edits')
542  count(work.reads, 'read', 'reads')
543  count(work.agents, 'agent run', 'agent runs')
544  count(work.other, 'other call', 'other calls')
545  return parts.join(' · ')
546}
547
hooks/view.ts 392 lines
1// What the pane shows, as plain rows: the trail laid out for a width, before any element is made.
2// Pure, so a test or the preview tool can read a page without a terminal.
3
4import type { Beat, Item, ItemKind, Mode, OutputType, Picked, Question, Shot, Trail } from '../types'
5import { clip, isOwnOutput, waitingCount, workCount, workLine } from './trail.ts'
6
7/** What a press on a line jumps to: the transcript row it stands for, and a tool call beside it. */
8export type Target = { id: string; anchor: string; alt?: string; kind: string }
9
10
11/**
12 * One entry of the pane, whatever the view. Every entry is drawn the same
13 * way: a quiet row with its time and its actions in brackets, then what it
14 * says under that, then its picture if it has one.
15 */
16export type Row =
17  | { kind: 'day'; key: string; text: string }
18  | {
19      kind: 'line'
20      key: string
21      /** What `[go to conversation]` scrolls the transcript to. */
22      target: Target
23      time: string
24      /** The mark before the words, or a space for an entry that has none (an update is read as a short piece). */
25      glyph: string
26      /** The words, wrapped: a few rows at rest, all of them once `[more]` is pressed. */
27      lines: string[]
28      /** Cells the words after the first row are set in by: the mark's width. */
29      pad: number
30      /** There is more to read than is shown: the entry has `[more]`, or `[less]` while it is all shown. */
31      hasMore: boolean
32      isOpen: boolean
33      work: string
34      /** Once the person has asked to go to the entry's place in the conversation: that it went, or why it did not. */
35      note: string
36      /** How the words are weighted: what was asked, something to be read, or an aside. */
37      tone: 'ask' | 'plain' | 'quiet'
38      /** An output's entry: the thing itself, for `[open]`. */
39      open?: Shot
40      /** A thing the work left for the person to do: what `[done]` stops waiting. */
41      settles?: string
42      /** And what they need to do it with: links to open and commands to copy. */
43      refs?: string[]
44    }
45  | {
46      kind: 'question'
47      key: string
48      question: Question
49      time: string
50      /** The question, wrapped. */
51      lines: string[]
52    }
53
54export type Page = {
55  rows: Row[]
56  /** Entries older than the page shows, and newer. */
57  earlier: number
58  later: number
59  foot: string
60}
61
62export type Look = {
63  width: number
64  picked: Picked
65  page: number
66  mode: Mode
67  /** Minutes east of UTC where the person is. */
68  zone: number
69}
70
71/** Lines to a page, and how far a page back moves: pages overlap so a place is not lost. */
72export const PAGE_LINES = 120
73export const PAGE_STEP = 80
74/** Cells an entry's mark takes before its words, and that the rows under the first are set in by. */
75const MARK = 2
76/** The widest a line of text is set, in cells: wider is tiring to read. */
77const MEASURE = 72
78const NOBODYS = /^(Background task\b|Carried on without a new prompt$)/
79
80const GLYPH: Record<ItemKind, string> = { ask: '❯', step: '•', done: '✓', shot: '▣', recap: '≡', compact: '~' }
81/** Lines of a request or a step shown once it is opened, and of an update always: an update is there to be read whole. */
82const DETAIL_OPEN = 12
83const UPDATE_LINES = 12
84/** Rows one action of an update may take, and the longest an update made of actions is when it is one line of text. */
85const ACTION_LINES = 3
86const ACTION_WIDEST = 110
87const CAPTION_LINES = 6
88const OUTPUT_GLYPH: Record<OutputType, string> = { image: '▣', video: '▶', file: '▤', link: '↗' }
89const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
90const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
91
92const isWide = (code: number): boolean =>
93  (code >= 0x1100 && code <= 0x115f) ||
94  (code >= 0x2e80 && code <= 0xa4cf) ||
95  (code >= 0xac00 && code <= 0xd7a3) ||
96  (code >= 0xf900 && code <= 0xfaff) ||
97  (code >= 0xfe30 && code <= 0xfe4f) ||
98  (code >= 0xff00 && code <= 0xff60) ||
99  (code >= 0xffe0 && code <= 0xffe6) ||
100  (code >= 0x1f300 && code <= 0x1faff) ||
101  (code >= 0x20000 && code <= 0x3fffd)
102
103/** How many terminal cells a string takes, near enough to cut it by. */
104export const cells = (text: string): number => {
105  let count = 0
106  for (const char of text) count += isWide(char.codePointAt(0) ?? 0) ? 2 : 1
107  return count
108}
109
110const cutTo = (text: string, width: number): string => {
111  let out = ''
112  let used = 0
113  for (const char of text) {
114    const size = isWide(char.codePointAt(0) ?? 0) ? 2 : 1
115    if (used + size > width) break
116    out += char
117    used += size
118  }
119  return out
120}
121
122/** Wraps at words into at most `max` lines of `width` cells; what does not fit ends in an ellipsis. */
123export const wrap = (text: string, width: number, max: number): string[] => {
124  const room = Math.max(4, width)
125  const out: string[] = []
126  let line = ''
127  const words = text.split(' ').filter(Boolean)
128  let index = 0
129  for (; index < words.length; index++) {
130    let word = words[index]!
131    const joined = line ? line + ' ' + word : word
132    if (cells(joined) <= room) {
133      line = joined
134      continue
135    }
136    if (line) {
137      out.push(line)
138      line = ''
139      if (out.length === max) break
140    }
141    // A word wider than the line is broken across lines.
142    while (cells(word) > room) {
143      const piece = cutTo(word, room)
144      out.push(piece)
145      word = word.slice(piece.length)
146      if (out.length === max) break
147    }
148    if (out.length === max) break
149    line = word
150  }
151  if (line && out.length < max) {
152    out.push(line)
153    index = words.length
154  }
155  if (index < words.length && out.length > 0) {
156    const last = out[out.length - 1]!
157    out[out.length - 1] = (cells(last) >= room ? cutTo(last, room - 1) : last).replace(/[\s,;:.]+$/, '') + '…'
158  }
159  return out
160}
161
162/** Wrapped lines with a tail set after the last of them, the line cut to make room: how long a turn ran is said whatever the length of what was asked. */
163const tailed = (lines: string[], tail: string, room: number): string[] => {
164  if (!tail || lines.length === 0) return lines
165  const last = lines[lines.length - 1]!
166  const fits = cells(last) + 2 + cells(tail) <= room
167  const kept = fits ? last : cutTo(last, Math.max(1, room - cells(tail) - 3)).replace(/[\s,;:.…]+$/, '') + '…'
168  return [...lines.slice(0, -1), kept + '  ' + tail]
169}
170
171const local = (at: number, zone: number): Date => new Date(at + zone * 60_000)
172const two = (n: number): string => String(n).padStart(2, '0')
173
174export const clock = (at: number, zone: number): string => {
175  const date = local(at, zone)
176  return two(date.getUTCHours()) + ':' + two(date.getUTCMinutes())
177}
178
179const dayOf = (at: number, zone: number): string => {
180  const date = local(at, zone)
181  return DAYS[date.getUTCDay()]! + ' ' + MONTHS[date.getUTCMonth()]! + ' ' + date.getUTCDate()
182}
183
184/** "42s", "3m", "2h 05m", "1d 4h": how long something took, or how long ago it was. */
185export const span = (ms: number): string => {
186  const minutes = Math.round(ms / 60_000)
187  if (minutes < 1) return Math.max(1, Math.round(ms / 1000)) + 's'
188  if (minutes < 60) return minutes + 'm'
189  const hours = Math.floor(minutes / 60)
190  if (hours < 24) return hours + 'h ' + two(minutes % 60) + 'm'
191  return Math.floor(hours / 24) + 'd ' + (hours % 24) + 'h'
192}
193
194/** "+0900" or "-1000", as `date +%z` prints it, in minutes east of UTC; undefined when it is neither. */
195export const zoneOf = (text: string): number | undefined => {
196  const found = /^([+-])(\d\d)(\d\d)$/.exec(text.trim())
197  if (!found) return undefined
198  return (found[1] === '-' ? -1 : 1) * (Number(found[2]) * 60 + Number(found[3]))
199}
200
201const counted = (count: number, one: string, many: string): string => count + ' ' + (count === 1 ? one : many)
202
203const footOf = (trail: Trail, look: Look): string => {
204  const prompts = trail.items.filter(item => item.kind === 'ask' && !NOBODYS.test(item.line)).length
205  const unopened = (of: Item[]): string => {
206    const left = of.filter(item => item.shot !== undefined && Boolean(item.shot.full || item.shot.file) && !item.shot.opened).length
207    return left > 0 ? ` (${left} not opened)` : ''
208  }
209  const outputs = trail.items.filter(item => isOwnOutput(item, trail)).length
210  const waiting = waitingCount(trail)
211  const told = trail.feed.filter(beat => beat.kind !== 'built' && beat.kind !== 'waiting').length
212  const parts =
213    look.mode === 'questions'
214      ? []
215      : [look.mode === 'feed' ? counted(told, 'update', 'updates') : look.mode === 'outputs' ? counted(outputs, 'output', 'outputs') + unopened(trail.items.filter(item => isOwnOutput(item, trail))) : counted(prompts, 'prompt', 'prompts')]
216  if (waiting > 0) parts.push(waiting + ' waiting on you')
217  const last = Math.max(trail.lastAt, trail.items.reduce((latest, item) => Math.max(latest, item.at), 0))
218  // The day, not how long ago: the pane may sit undrawn for a night, and an age would go stale in it.
219  if (trail.isOpen) parts.push('working now')
220  else if (last > 0) parts.push('quiet since ' + dayOf(last, look.zone) + ' ' + clock(last, look.zone))
221  return parts.join(' · ')
222}
223
224type Entry = { at: number; rank: number; seq: number; row: () => Row }
225
226/** Rows of an entry's words shown at rest and once opened, by what the entry is. */
227const AT_REST: Record<ItemKind, number> = { ask: 3, step: 2, done: 4, shot: 1, recap: 8, compact: 1 }
228
229export const layout = (trail: Trail, look: Look): Page => {
230  // Text is set no wider than reads well, however wide the pane is drawn.
231  const width = Math.max(16, Math.min(look.width, MEASURE))
232  const isOpen = (id: string): boolean => look.picked.id === id && look.picked.isOpen === true
233  const noteFor = (id: string): string => {
234    if (look.picked.id !== id || look.picked.jump === undefined) return ''
235    return look.picked.jump ? clip("can't go there: " + look.picked.jump, width) : 'the conversation is at this point now'
236  }
237  const timeOf = (at: number): string => (at > 0 ? clock(at, look.zone) : '--:--')
238  const entries: Entry[] = []
239  let seq = 0
240
241  // The feed is the updates and nothing else: what was done, as it has been told. What was asked, and what the
242  // work produced, are the other views'; a step no update tells of yet is not shown, the line under the tabs saying
243  // that work is going on.
244  for (const item of trail.items) {
245    if (look.mode === 'questions' || look.mode === 'feed') break
246    // The outputs are Claude's: what the session made, sent or published, not what the person showed it.
247    if (look.mode === 'outputs' && !isOwnOutput(item, trail)) continue
248    // The last view is the person's own side of the conversation: each thing they asked, and nothing of what was done about it.
249    if (look.mode === 'steps' && (item.kind !== 'ask' || NOBODYS.test(item.line))) continue
250    entries.push({
251      at: item.at,
252      rank: item.kind === 'ask' ? 0 : 1,
253      seq: seq++,
254      row: () => {
255        const opened = isOpen(item.id)
256        const isOutput = item.kind === 'shot'
257        const room = Math.max(8, width - MARK)
258        const took = item.kind === 'ask' && item.ms !== undefined && item.ms >= 60_000 ? '(' + span(item.ms) + ')' : ''
259        // Opened, a request or a step shows all that was said; at rest, its first sentence.
260        const rest = AT_REST[item.kind] ?? 2
261        const name = item.line
262        const atRest = wrap(name, room, rest)
263        const whole = wrap(item.more ?? name, room, DETAIL_OPEN)
264        const hasMore = whole.length > atRest.length || (item.more !== undefined && item.more.length > item.line.length) || (atRest.at(-1)?.endsWith('…') ?? false)
265        const said = tailed(opened ? whole : atRest, took, room)
266        // An output is told of too: what it shows and what it was about, under its name.
267        const lines = item.shot?.note ? [...said, ...wrap(item.shot.note, room, CAPTION_LINES)] : said
268        // Something to open that the person has not opened yet is new to them; opened, it is read.
269        const canOpen = isOutput && item.shot !== undefined && Boolean(item.shot.full || item.shot.file)
270        return {
271          kind: 'line',
272          key: item.id,
273          target: { id: item.id, anchor: item.anchor, kind: item.kind, ...(item.alt ? { alt: item.alt } : {}) },
274          time: timeOf(item.at),
275          glyph: isOutput ? OUTPUT_GLYPH[item.shot?.type ?? 'image'] : (GLYPH[item.kind] ?? '•'),
276          lines,
277          pad: MARK,
278          hasMore: hasMore && !isOutput,
279          isOpen: opened,
280          // In the feed the tally is noise; under a step it is what the step came to.
281          work: look.mode === 'steps' && item.work && workCount(item.work) > 0 ? clip(workLine(item.work), Math.max(8, room - 2)) : '',
282          note: noteFor(item.id),
283          // A request is the person's; work a finished background task set off, or that simply carried on, is nobody's.
284          tone: canOpen ? (item.shot!.opened ? 'quiet' : 'ask') : item.kind === 'ask' ? (NOBODYS.test(item.line) ? 'quiet' : 'ask') : item.kind === 'recap' || item.kind === 'compact' ? 'quiet' : 'plain',
285          ...(canOpen ? { open: item.shot! } : {}),
286        }
287      },
288    })
289  }
290
291  const beatRow = (beat: Beat): Row => {
292    const isTask = beat.kind === 'built'
293    const isWait = beat.kind === 'waiting'
294    // A thing to do is a line with its mark. An update is its actions, each a short line of its own with a
295    // mark; one written before updates were actions is a short piece, read across the whole pane.
296    const pad = isTask || isWait ? MARK : 0
297    const words = beat.detail ? beat.title + ' ' + beat.detail : beat.title
298    const isActions = !isTask && !isWait && (words.includes('\n') || words.length <= ACTION_WIDEST)
299    const lines = isActions
300      ? words.split('\n').flatMap(action => wrap(action, Math.max(8, width - MARK), ACTION_LINES).map((text, index) => (index === 0 ? '• ' : '  ') + text))
301      : wrap(words, width - pad, UPDATE_LINES)
302    return {
303      kind: 'line',
304      key: beat.id,
305      target: { id: beat.id, anchor: beat.anchor, kind: isTask ? 'task' : isWait ? 'wait' : 'update', ...(beat.alt ? { alt: beat.alt } : {}) },
306      time: timeOf(beat.at),
307      glyph: isTask ? '✓' : isWait ? '○' : ' ',
308      lines,
309      pad,
310      hasMore: false,
311      isOpen: false,
312      work: '',
313      note: noteFor(beat.id),
314      tone: isTask || isWait ? 'ask' : 'plain',
315      ...(isWait ? { settles: beat.id } : {}),
316      ...(isWait && beat.refs && beat.refs.length > 0 ? { refs: beat.refs } : {}),
317    }
318  }
319  if (look.mode === 'feed' || look.mode === 'questions') {
320    for (const beat of trail.feed) {
321      // A thing left with the person has a list of its own; the feed is the updates. (A big task done, which older versions listed, is no longer shown.)
322      const isWait = beat.kind === 'waiting'
323      if (isWait ? look.mode !== 'questions' || beat.over !== undefined : beat.kind === 'built' || look.mode === 'questions') continue
324      // An update tells of what came before it, pictures included: it sits above them.
325      entries.push({ at: beat.at, rank: 3, seq: seq++, row: () => beatRow(beat) })
326    }
327  }
328
329  // A question asked and answered is a thing that happened: it is told among the steps, and nowhere else.
330  for (const question of trail.questions) {
331    if (question.answer === undefined || look.mode !== 'steps') continue
332    const id = 'q:' + question.id
333    entries.push({
334      at: question.at,
335      rank: 2,
336      seq: seq++,
337      row: () => {
338        const room = Math.max(8, width - MARK)
339        return {
340          kind: 'line',
341          key: id,
342          target: { id, anchor: question.id, kind: 'question' },
343          time: timeOf(question.at),
344          glyph: '?',
345          lines: [...wrap(question.text, room, 3), ...wrap('You: ' + question.answer, room, 3)],
346          pad: MARK,
347          hasMore: false,
348          isOpen: false,
349          work: '',
350          note: noteFor(id),
351          tone: 'plain',
352        }
353      },
354    })
355  }
356
357  // The page reads from the latest back, strictly by the clock: what a request came to sits above the things
358  // that happened in it, and the request itself below them, where it began. What has no time keeps its order.
359  entries.sort((a, b) => (a.at && b.at ? a.at - b.at : 0) || a.rank - b.rank || a.seq - b.seq)
360  entries.reverse()
361
362  const total = entries.length
363  const [size, step] = [PAGE_LINES, PAGE_STEP]
364  const start = Math.min(Math.max(0, look.page) * step, Math.max(0, total - 1))
365  const shown = entries.slice(start, start + size)
366  const rows: Row[] = []
367  // What waits on the person has a view of its own, there only while something waits: it is to be answered, not read past.
368  if (look.mode === 'questions') {
369    for (const question of [...trail.questions].reverse()) {
370      if (question.answer !== undefined) continue
371      rows.push({
372        kind: 'question',
373        key: 'q:' + question.id,
374        question,
375        time: timeOf(question.at),
376        lines: wrap(question.text, width, 8),
377      })
378    }
379  }
380  let day = ''
381  for (const entry of shown) {
382    const today = dayOf(entry.at, look.zone)
383    if (entry.at > 0 && today !== day) {
384      day = today
385      rows.push({ kind: 'day', key: 'day:' + entry.seq, text: today })
386    }
387    rows.push(entry.row())
388  }
389
390  return { rows, earlier: Math.max(0, total - start - shown.length), later: start, foot: footOf(trail, look) }
391}
392
types/index.d.ts 189 lines
1/** The work done under one step: what was called, what was edited, what failed. */
2export type Work = {
3  cmds: number
4  reads: number
5  edits: number
6  agents: number
7  other: number
8  fails: number
9  /** Basenames written or edited, oldest first, a few at most. */
10  files: string[]
11  /** How many more files were edited than `files` names, and which (a few at most), so one edited twice counts once. */
12  moreFiles: number
13  extra: string[]
14  /** What the latest call said it was doing. */
15  now: string
16  /** What the calls said they were doing, oldest first, a few at most: what a highlight is written from. */
17  notes: string[]
18}
19
20/** What an output is: a picture (when nothing says otherwise), a video, some other file handed over, or a page published. */
21export type OutputType = 'image' | 'video' | 'file' | 'link'
22
23/**
24 * Something the work produced or looked at, kept for the trail: a small PNG
25 * of the mod's own to draw in the pane (a video's or a document's is its
26 * preview; `file` is empty where there is none), its shape, and the whole
27 * thing to open: a file's path, or a page's address. A picture that is a
28 * file on disk is not copied: `full` is the file itself and `stamp` when it
29 * was last written as the picture was kept, so it is opened only while it
30 * is still that picture, and the small PNG otherwise. `note` is its caption,
31 * what it shows and what it was about: absent until the conversation has been
32 * asked, empty when it had nothing to say. `guess` marks a caption written
33 * from what was said around the picture by a model that did not see it
34 * (which costs next to nothing): the conversation, which did see it, may
35 * still be asked for a better one. `opened` is when the person last opened
36 * it from the pane: what they have opened reads as read, the rest as new.
37 * Nothing is drawn in the pane and nothing is copied: `file` is empty for
38 * whatever the mod has kept since it stopped keeping pictures.
39 */
40export type Shot = { file: string; w: number; h: number; full?: string; stamp?: number; note?: string; guess?: true; opened?: number; type?: OutputType; from?: Source }
41
42/**
43 * Where a picture came from when it is not Claude's own work: the `person`
44 * pasted it, or it is a file from `earlier` than the session that Claude only
45 * looked at. Absent, it is something the session made, sent or published.
46 */
47export type Source = 'person' | 'earlier'
48
49/**
50 * What a step is: the person's `ask`, a `step` the model narrated, the turn's
51 * `done` line, a picture (`shot`), the engine's `recap` of where things
52 * stand, or a mark where the context was compacted.
53 */
54export type ItemKind = 'ask' | 'step' | 'done' | 'shot' | 'recap' | 'compact'
55
56/** One thing the conversation holds, as it happened: the layer the highlights are written from, and shown under them on request. */
57export type Item = {
58  /** Unique in the trail. */
59  id: string
60  /** The transcript row a click scrolls to: the row's id, or a tool call's. */
61  anchor: string
62  /** A tool call beside the row, tried when the row itself will not scroll. */
63  alt?: string
64  kind: ItemKind
65  /** Milliseconds since the epoch. */
66  at: number
67  turn: number
68  line: string
69  /** More of what was said than its first sentence, kept until a highlight has been written from it. */
70  more?: string
71  work?: Work
72  shot?: Shot
73  /** On an ask: how long its turn ran, once it has ended. */
74  ms?: number
75  /** A highlight has been written that covers this. */
76  fed?: true
77}
78
79/**
80 * An update of the feed is a `summary`: a short account of a stretch of work.
81 * A big task brought to a finish is `built`: it is what the list of things
82 * done holds. The other kinds are from when the feed listed single things
83 * that happened: something `built` or changed,
84 * something `found` out, something `decided`, a `problem` met, one `fixed`,
85 * something `checked` and how it came out, something `asked` of the person
86 * (it waits on them), or a plain `note`. A `summary` is not one thing but
87 * what a whole request came to, in a few sentences. Something `waiting` is
88 * not a telling at all: it is a thing the work left for the person to do,
89 * listed apart until it is done.
90 */
91export type BeatKind = 'summary' | 'built' | 'waiting' | 'found' | 'decided' | 'problem' | 'fixed' | 'checked' | 'asked' | 'note'
92
93/** One update of the feed: what was done in a stretch of work and what came of it, in a few sentences, tied to the row where the stretch ended. */
94export type Beat = {
95  id: string
96  /** The step it tells of. */
97  ref: string
98  turn: number
99  /** When the step it tells of happened. */
100  at: number
101  kind: BeatKind
102  /** The update's text. */
103  title: string
104  /** More of it, where an older kind kept a headline apart from its telling. */
105  detail: string
106  /** The transcript row a click scrolls to, and a tool call beside it. */
107  anchor: string
108  alt?: string
109  /** Of something waiting on the person: when it stopped waiting, done or no longer needed. Absent while it waits. */
110  over?: number
111  /** Of something waiting on the person: what they need to do it with, in order. A link to open (it begins `http`), or a command to run, which is only ever copied. */
112  refs?: string[]
113}
114
115/**
116 * Something Claude asked the person in the pane, with the picture it is about
117 * when there is one. It waits there until answered; the answer goes back to
118 * the conversation as the person's next message.
119 */
120export type Question = {
121  /** The tool call that asked it, which is also the transcript row a click scrolls to. */
122  id: string
123  at: number
124  text: string
125  /** Answers offered as buttons; the person may always type their own. */
126  options: string[]
127  shot?: Shot
128  answer?: string
129  answeredAt?: number
130}
131
132export type Trail = {
133  items: Item[]
134  /** The feed's updates, oldest first. */
135  feed: Beat[]
136  questions: Question[]
137  turn: number
138  /** A turn is running. */
139  isOpen: boolean
140  /** The newest ask has seen no turn end yet, so work that follows is its own. */
141  isFresh: boolean
142  /** The latest tool call's id. */
143  lastTool: string
144  /** When the latest thing happened: where a turn that was never seen to end is taken to have stopped. */
145  lastAt: number
146  /** Tool calls already counted, newest last: an event seen twice counts once. */
147  recent: string[]
148  /**
149   * Its updates are written by a model that also looks for what waits on the
150   * person. Absent on a trail told before the mod looked for that: the latest
151   * of such a trail is read once more.
152   */
153  sought?: true
154}
155
156/** The trail as kept in the session's state and in its file; `v` is the shape's, so one kept by older code is read back from the transcript instead. */
157export type Saved = { v: 5; sid: string; trail: Trail }
158
159/**
160 * The entry the person last pressed an action of. `isOpen`: they asked for
161 * more of it, and it shows all it has. `jump` is what came of going to it in
162 * the conversation: absent until they ask, '' once it landed, else why it did
163 * not.
164 */
165export type Picked = { id: string; jump?: string; isOpen?: boolean }
166
167/**
168 * What the pane lists: the feed of updates (its highlights), everything the
169 * work produced, each thing the person asked (`steps`, shown as "every
170 * prompt"), or what waits on the person (`questions`: the questions Claude
171 * put to them in the pane and the things the work left for them to do), which
172 * is a view only while something does.
173 */
174export type Mode = 'feed' | 'outputs' | 'steps' | 'questions'
175
176declare module 'claude-code' {
177  interface PluginState {
178    trail: {
179      saved: Saved
180      picked: Picked
181      /** How many pages back from the newest lines the pane shows. */
182      page: number
183      mode: Mode
184      /** How the writing of updates is going, when there is something to say about it. */
185      status: string
186    }
187  }
188}
189