SLOPSHOPPER

timeline

Vertical orchestration timeline: talk on the left, work milestones with progress and live subagent cards on the right, per repo across sessions.

newpaneguardcommandtoastprompt
v0.1.0MITupdated 2026-10-07tyree88/tempered_plugins/timeline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · timeline
│ ┃ Timeline ✕ › fix the failing auth test and add an audit log call │ ┃ app · no milestones logged yet │ ┃ ┄┄┄┄┄┄┄┄┄┄┄ session preview- opened · 08:53… ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /timeline │ ⎿ timeline: Timeline opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Timeline
app · no milestones logged yet ┄┄┄┄┄┄┄┄┄┄┄ session preview- opened · 08:53 ┄┄┄┄┄┄┄┄┄┄┄
README

Tempered Plugins

This repository holds plugins from Tempered Works for AI coding tools. Five plugins are for Claude Code. One plugin is for Codex.

A Claude Code "mod" is a plugin of function hooks. A function hook is code that Claude Code runs when an event occurs, for example when a tool call ends. The five Claude Code plugins here are mods. They run inside Claude Code, in the terminal and in the desktop Code tab.

What is in this repository

FolderToolWhat it does
ship-state/Claude CodeShows the git, pull request, CI and deploy state of the current repo in one line above the prompt.
timeline/Claude CodeShows a vertical timeline of the work in a side pane: what you asked, what Claude did, and what each subagent is doing.
limit-resume/Claude CodeShows your usage limits and continues a turn after a rate limit resets.
followups/Claude CodeShows 4 options for your next prompt above the prompt box after each answer. You press 1 to 4 to put one in the box.
lessons/Claude CodeFinds wins and pitfalls in your prompts. It then asks Claude to run your own win-logger and pitfall-logger skills. The status line shows how many entries you logged.
multi-harness/CodexGives Codex 6 skills to plan large work in waves and to track it to completion.
.claude-plugin/marketplace.jsonClaude CodeLists the 5 Claude Code plugins so that Claude Code can install them from this repository.

Why these plugins exist

ship-state. During a coding session, you often need to know if your work is pushed, if CI passed, and if production has the change. Without this plugin, you ask Claude, and Claude runs git and gh commands to find out. Each check costs a model turn. ship-state shows the answer on screen at all times and makes no model calls.

timeline. Long work with many steps and many subagents is hard to follow. Without a record, you ask "what is left?" and "what is the current goal?" many times. You also cannot see which model each subagent uses. timeline keeps one record per repo across sessions and shows it as a timeline.

limit-resume. When a session hits a usage limit, the work stops until you type "try again". If you are away, the session stays idle after the limit resets. limit-resume continues the work at the reset time. It also shows your usage before you reach the limit.

followups. Claude Code shows one grey suggestion for your next prompt. That suggestion is often the wrong one. followups shows 4 options in 4 directions: continue the plan, verify the work, take the alternative path, and wrap up. These options cover the usual next moves. You choose one and edit it. followups sends nothing until you press Enter.

lessons. The same mistakes happen again, and good patterns get lost. A skill that logs them is useful, but it often does not run at the right moment. lessons makes the skill run. It reads your prompts for praise, frustration, and repeated requests. When it finds one, it tells Claude to run the matching skill after Claude finishes your request. The skill always asks "Log it? y/n" before it writes. You stay in control.

multi-harness. Large product work needs a plan, branch and pull request gates, tracker updates, QA evidence, and a safe closeout. multi-harness gives Codex a repeatable method for these steps. The method is the same for every product.

Requirements

  • Claude Code with support for function-hook plugins. We tested the plugins on Claude Code 2.1.288 on macOS. All Claude Code plugins pass claude plugin validate.
  • git 2.31 or newer.
  • The GitHub CLI gh, signed in. ship-state and timeline use it for pull request, CI and deploy data. Without gh, they show only local git data.
  • Production deploy data comes from GitHub deployment records. Vercel and most hosting services create these records.

How to install the Claude Code plugins

Use one of these 2 methods.

Method 1: install from the marketplace. Run these commands in a Claude Code session:

/plugin marketplace add tyree88/tempered_plugins
/plugin install ship-state@tempered-plugins
/plugin install timeline@tempered-plugins
/plugin install limit-resume@tempered-plugins
/plugin install followups@tempered-plugins
/plugin install lessons@tempered-plugins

Install only the plugins that you want.

Method 2: load the folders directly. Clone this repository. Then add the plugin folders to the env block of ~/.claude/settings.json. Separate the folders with :.

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/tempered_plugins/ship-state:/path/to/tempered_plugins/timeline:/path/to/tempered_plugins/followups:/path/to/tempered_plugins/lessons" } }

New sessions load the plugins. Sessions that are already open do not.

How to use ship-state

ship-state needs no action. It starts with each session.

  1. Look at the band above the prompt. It shows the current repo and branch.
  2. Read the state from left to right:
app ⎇ feat/waitlist  ·  3 dirty  ·  ↑2 ↓0  ·  PR #312  ·  CI ⏳ 4/5  ·  prod = HEAD ✓ 2h ago
PartMeaning
3 dirty3 files have changes that are not committed.
↑2 ↓02 commits are not pushed. 0 commits are not pulled.
PR #312The branch has open pull request 312.
CI ⏳ 4/54 of 5 CI checks are complete.
prod = HEAD ✓Production runs the current commit.
  1. After a push or a merge, wait for the toast. ship-state shows "CI ✓", "CI ✗" with the failed check names, or "Live on prod".

ship-state follows Claude when Claude changes to another repo or worktree. It reads local git data every 20 seconds. It reads GitHub data every 5 minutes. After a push or merge, it reads GitHub data every 20 seconds for 10 minutes.

The band of ship-state stacks with the bands of other plugins, such as followups.

How to use timeline

  1. Type /timeline to open or close the pane. The pane also opens by itself when Claude logs the first task of a session.
  2. Read the pane from top to bottom. The newest entry is at the bottom.
  3. Read the left side for your requests and decisions. Claude writes each one as a one-line summary.
  4. Read the right side for the work:
  5. A task card shows a title, a progress bar such as ██████░░░░ 2/3, how Claude did the step, and the next step.
  6. Lines that start with ↳ show commits, pushes, pull requests and CI results for the active task.
  7. A subagent card shows the agent type, the model, the status, and the elapsed time. It also shows the current tool call (now:), the next step (next:), the number of tool calls, the tokens, and the result.
  8. Look for ⚠ no model set: inherited on a subagent card. This warning means that the subagent uses the same model as the main session, because nothing set a model for it.
  9. Hold the pointer on a card in the desktop app to see more detail.
  10. If the timeline has more than 40 entries, use ◀ older and newer ▶ to move between pages.

How timeline works:

  • The plugin adds a tool, mcp__timeline__log. It also adds an instruction of about 100 tokens that tells Claude when to log. Claude logs once for each change of direction, and once at the start, each step, the end, or a block of each task.
  • Each subagent gets a shorter instruction to log its own progress.
  • The log tool needs no permission prompt.
  • timeline stores the history per repo in ~/.claude/timelines/<repo>-<hash>/. Each session writes only its own files. Worktrees of a repo share one timeline.
  • If 2 sessions work in the same repo, each pane shows the entries of the other session within 10 seconds.
  • The desktop app draws the timeline as an image, with colors for light mode and dark mode. The terminal draws it as text. If the terminal is narrower than 70 columns, the text uses 1 column.

Usage cost: each logged entry costs approximately 40 to 80 output tokens. The instruction costs approximately 100 tokens in each session.

Privacy: the timeline files contain Claude's one-line summaries, the first 2000 characters of each subagent prompt, and commit subjects. The files stay on your computer.

Status: timeline is built and reviewed. Live testing is in progress.

How to use followups

followups needs no action. It starts with each session.

  1. Wait for Claude to finish an answer. A few seconds later, 4 options appear in the band above the prompt box. Each option has its own row: 1: … to 4: ….
  2. Read the options. Each option goes in a different direction:
  3. Continue the plan.
  4. Verify or test the work.
  5. Take the alternative path, or ask an open question.
  6. Wrap up or commit.
  7. Press 1 to 4 when the prompt box is empty. You can also click an option. The text of the option goes into the prompt box.
  8. Edit the text, or press Enter to send it. followups never sends anything by itself.
  9. To ignore the options, type your own message.
  10. To turn the plugin off or on, type /followups off or /followups on. Type /followups status to see the current setting and the last error, for example a refused model call.

When the band shows, a digit that you type in an empty prompt box picks an option. To start a message with a digit, type a space first.

The band hides while the prompt box has text, while a turn runs, and while a survey uses the band. It comes back when the prompt box is empty.

followups hides the built-in grey suggestion of Claude Code, but only after its own band has drawn once. In a surface without the band, the built-in suggestion stays.

followups makes no options for subagent turns, interrupted turns, errors, and empty answers. If you send a prompt before Haiku answers, followups drops the old reply. After /clear or a resume, followups removes the old options.

Usage cost: followups makes one Haiku call for each answered turn. A call costs approximately 2,000 input tokens and 150 output tokens. followups adds nothing to the context of the main model.

Privacy: the first 1,500 characters of your last prompt and the last 4,000 characters of the answer go to Haiku. The call uses the API client of Claude Code.

How to use lessons

lessons needs 2 skills of your own. Name them win-logger and pitfall-logger. This repository does not include them. The skills decide where an entry goes, for example a Notion database. Each skill always asks "Log it? y/n" before it writes.

  1. Add the 2 skills to Claude Code. Any skills with these names work.
  2. Work as usual. lessons watches only the prompts that you send yourself: in the terminal, in Remote Control, in your own Slack ping, and in the Claude desktop app. It ignores prompts from plugins, notifications, other sessions, and subagents.
  3. Send a prompt that shows a win or a pitfall. The table below lists the signals.
  4. Claude finishes your request first. Then Claude runs the matching skill. lessons adds a short note to your prompt to cause this. You do not see the note.
  5. Read the draft that the skill shows. Answer y to log it. Answer n to skip it.
  6. Look at the status line. 🌱 2 · ⚠ 1 means 2 wins and 1 pitfall are logged in this session. lessons counts a row when you answer y to a draft. The count resets after /clear or a resume.
  7. To turn the plugin off or on, type /lessons off or /lessons on. Type /lessons status to see the setting, the counts, and where each skill was found.
KindSignals
WinPraise, for example "perfect", "nailed it", "love this", "this is great", "exactly what I wanted". Or an ask: "log this win", "log this as a win", "add this to learnings", "remember this worked".
PitfallFrustration, for example "I already told you", "no, I said", "still wrong", "still failing", "this is the third time", "why did you change…", "not what I asked". Or shouting: several words in all capitals. Or the same request sent again: it has high word overlap with one of your last 10 prompts. Or an ask: "log this", "add this to pitfalls", "remember this lesson".

lessons avoids common false signals. These prompts do not trigger it: "exactly 3 retries", "pixel perfect", "log this error to sentry", "why did you choose zod?", "the second time I click it throws". File names such as README or CHANGELOG do not trigger it. HTTP method names do not trigger it.

lessons adds at most 1 note of each kind for each 5 prompts.

lessons looks for the skills in 2 places:

  • The skills loaded in the session. The name can also be anthropic-skills:<name>.
  • On macOS, the synced-skills folder of the Claude desktop app.

If a skill is missing, lessons shows a toast at most once a day. /lessons status shows "missing" for that skill.

Usage cost: lessons makes no model calls. A note costs approximately 40 tokens. lessons adds a note only to a prompt that matches.

Privacy: your last 10 prompts stay in memory only, for the repeat check. lessons does not write them to disk.

How to use limit-resume

limit-resume needs no action. It starts with each session.

  1. Read the status line below the prompt. It shows your usage, for example 5h 62% · 7d 41%. 5h is the 5-hour window. 7d is the 7-day window.
  2. If the 5-hour usage reaches 85%, a toast tells you. Commit your work at this point.
  3. If a turn stops because of a usage limit, the status line shows when the work continues. At the reset time plus 1 minute, limit-resume sends "Continue from where you left off".
  4. If a turn stops because of a temporary API error, limit-resume tries again after 1, 2, 4, and 8 minutes, then every 15 minutes. It stops after 12 tries.
  5. To cancel a pending continue, type any message.
  6. To turn the plugin off or on, type /autoresume off or /autoresume on. Type /autoresume status to see the current setting.

Claude Code has a built-in setting, autoContinueAtUsageLimit, that also continues after a usage limit. Do not use the built-in setting and limit-resume together for usage limits. If you do, the turn gets 2 "continue" messages.

How to use multi-harness

multi-harness is a Codex plugin. Its manifest is multi-harness/.codex-plugin/plugin.json, and its plugin name is platform-orchestrator. It is not in the Claude Code marketplace file.

  1. Install the multi-harness folder with the Codex plugin installer.
  2. Ask Codex to use Platform Orchestrator on a backlog. For example: "Use Platform Orchestrator to turn this backlog into shippable waves, branch and PR gates, tracker updates, and verification evidence."
  3. Use the templates in multi-harness/assets/templates/ for wave plans, tracker updates, QA evidence, and pull request closeout.

The 6 skills are:

SkillUse
platform-wave-orchestratorDivide a backlog into agent lanes and implementation waves.
platform-agent-patternsChoose how agents work together on a wave.
platform-pr-closeoutGate and close branches and pull requests.
platform-tracker-syncUpdate GitHub and Notion trackers.
platform-qa-evidenceCollect QA evidence for each change.
platform-safety-reviewReview work that touches sensitive data, regulated text, or trust and safety limits.

To check the folder structure, run python3 multi-harness/scripts/check_plugin_structure.py.

Development

Each Claude Code plugin has checks for its logic. Node 23 or newer runs the .ts check files directly.

node limit-resume/check.ts
node ship-state/check.ts
node followups/checks/ask.check.ts
node lessons/checks/detect.check.ts
bash timeline/checks/run.sh

To run the behavior test of followups, run claude plugin test followups. It runs 8 cases on the terminal and desktop surfaces.

To run the behavior test of lessons, run claude plugin test lessons. It runs 9 cases.

To type-check timeline, do these 2 steps:

  1. In a Claude Code session in this repository, run /plugin-types .claude/types. This command writes the Claude Code type declarations.
  2. Run tsc -p timeline/tsconfig.check.json.

License

MIT. See LICENSE. Copyright 2026 Tempered Works LLC.

Source 5 files
hooks/register.tsx 551 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Entry, LiveAgent, View } from '../types'
5import { renderSvg, renderText } from './draw'
6import { buildNodes, paginate, summarize } from './layout'
7import { ciFact, clip, factsFromBash, folderName, fromLog, lastCd, mainArg, mergeEntries, parseJsonl, repoRoot, toJsonl, tzMinutes, type Found } from './model'
8
9const view = atom({ plugin: 'timeline', key: 'view' } as const, null)
10
11const PANE = 'timeline'
12const TOOL = 'mcp__timeline__log'
13const PAGE_SIZE = 40
14const REFRESH_MS = 10_000
15const TONE = { normal: undefined, dim: undefined, accent: 'blue', warn: 'yellow' } as const
16
17const MAIN_NOTE =
18  'Timeline: call `mcp__timeline__log` (1) once after a user message that sets or changes direction: kind "talk", title = one-line summary of what they asked or decided; (2) when you start a task, finish a step of it, finish it, or get blocked: kind "work", a stable kebab-case `task`, `done`/`total` steps, `status`, `how` (one line), `next` (one line). Never once per tool call; about one entry every few minutes of work. Do not mention the logging in replies.'
19const AGENT_NOTE =
20  '\n\nWhile you work, call `mcp__timeline__log` with kind "work" at start, after each step, and at the end, with `done`/`total`, `how`, and `next`. Keep each call short.'
21
22const INPUT_SCHEMA = {
23  type: 'object',
24  required: ['kind', 'title'],
25  properties: {
26    kind: { enum: ['talk', 'work'] },
27    title: { type: 'string' },
28    task: { type: 'string' },
29    done: { type: 'integer' },
30    total: { type: 'integer' },
31    status: { enum: ['active', 'done', 'blocked'] },
32    how: { type: 'string' },
33    next: { type: 'string' },
34  },
35}
36
37// A host command's trimmed stdout, or undefined on a non-zero exit or a spawn failure.
38async function run($: Engine, argv: string[], cwd?: string): Promise<string | undefined> {
39  try {
40    const r = await $.process.run(argv, cwd ? { cwd, timeoutMs: 10_000 } : { timeoutMs: 10_000 })
41    return r.exitCode === 0 ? r.stdout.trim() : undefined
42  } catch {
43    return undefined
44  }
45}
46
47type Repo = { root: string; name: string; isGit: boolean; worktree?: string }
48
49// The repo a path belongs to; worktrees share their main checkout's root. A non-git path is its own identity.
50// One rev-parse call. Git older than 2.31 echoes the unknown --path-format flag, so a non-absolute line means "not git".
51async function identify($: Engine, path: string): Promise<Repo> {
52  const out = await run($, ['git', '-C', path, 'rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel'])
53  const [common, top] = out?.split('\n') ?? []
54  if (!common?.startsWith('/') || !top?.startsWith('/')) {
55    return { root: path, name: path.split('/').filter(Boolean).at(-1) ?? path, isGit: false }
56  }
57  const root = repoRoot(common)
58  const name = root.split('/').filter(Boolean).at(-1) ?? root
59  if (top === root) return { root, name, isGit: true }
60  return { root, name, isGit: true, worktree: top.startsWith(`${root}/`) ? top.slice(root.length + 1) : top }
61}
62
63type Store = {
64  dir: string
65  lastSeq: () => number
66  append: (entry: Entry) => Promise<string | undefined>
67  readAll: () => Promise<{ entries: Entry[]; bad: number }>
68}
69
70type Listed = { name: string; kind: string; mtimeMs: number; size: number }
71
72const PART_CHARS = 1_048_576 // start a new part file past 1 MiB of text; $.fs reads and writes cap at 4 MiB
73
74const seqOf = (entries: readonly Entry[]) => entries.reduce((max, e) => Math.max(max, Number(e.id.split('-').at(-1)) || 0), 0)
75
76// One repo's timeline folder. This session rewrites only its current part file (`<session>.jsonl`, then
77// `<session>-1.jsonl`, …). Every other file, this session's earlier parts included, is read and cached by mtime and size.
78// A part that can't be read or has unreadable lines is never rewritten: writing continues in a new part.
79async function openStore($: Engine, home: string, root: string, session: string): Promise<Store> {
80  const dir = `${home}/.claude/timelines/${folderName(root)}`
81  const partName = (n: number) => (n ? `${session}-${n}.jsonl` : `${session}.jsonl`)
82  const partIndex = (name: string) => {
83    if (name === `${session}.jsonl`) return 0
84    const m = name.match(/^(.+)-(\d+)\.jsonl$/)
85    return m && m[1] === session ? Number(m[2]) : -1
86  }
87  const cache = new Map<string, { mtimeMs: number; size: number; entries: Entry[]; bad: number }>()
88  let part = 0
89  let own: Entry[] = []
90  let saved = 0 // how many entries of `own` are on disk
91
92  const list = async (): Promise<readonly Listed[] | undefined> => {
93    try {
94      return await $.fs.list(dir)
95    } catch {
96      return undefined // no folder yet
97    }
98  }
99
100  const parts = ((await list()) ?? []).filter(f => f.kind === 'file').map(f => partIndex(f.name)).filter(n => n >= 0)
101  if (parts.length) {
102    part = Math.max(...parts)
103    try {
104      const parsed = parseJsonl(await $.fs.read(`${dir}/${partName(part)}`))
105      if (parsed.bad) part += 1
106      else {
107        own = parsed.entries
108        saved = own.length
109      }
110    } catch {
111      part += 1 // over 4 MiB or unreadable: leave it alone
112    }
113  }
114
115  const readAll = async () => {
116    const listed = await list()
117    if (!listed) return { entries: [...own], bad: 0 }
118    const current = partName(part)
119    for (const name of [...cache.keys()]) if (!listed.some(f => f.name === name)) cache.delete(name)
120    for (const file of listed) {
121      if (file.kind !== 'file' || !file.name.endsWith('.jsonl') || file.name === current) continue
122      const hit = cache.get(file.name)
123      if (hit && hit.mtimeMs === file.mtimeMs && hit.size === file.size) continue
124      try {
125        cache.set(file.name, { mtimeMs: file.mtimeMs, size: file.size, ...parseJsonl(await $.fs.read(`${dir}/${file.name}`)) })
126      } catch {
127        cache.delete(file.name)
128      }
129    }
130    const others = [...cache.values()]
131    return { entries: mergeEntries([own, ...others.map(c => c.entries)]), bad: others.reduce((n, c) => n + c.bad, 0) }
132  }
133
134  const write = async (entry: Entry): Promise<string | undefined> => {
135    own = [...own, entry]
136    let text = toJsonl(own)
137    if (text.length > PART_CHARS && saved > 0) {
138      // This part is full; what is saved stays there. Entries not yet on disk move to the next part.
139      part += 1
140      own = own.slice(saved)
141      saved = 0
142      text = toJsonl(own)
143    }
144    try {
145      await $.fs.write(`${dir}/${partName(part)}`, text)
146      saved = own.length
147      return undefined
148    } catch (error) {
149      return error instanceof Error ? error.message : String(error)
150    }
151  }
152
153  // One write at a time: parallel subagents logging together must not let an older whole-file write land last.
154  let chain: Promise<unknown> = Promise.resolve()
155  const append = (entry: Entry): Promise<string | undefined> => {
156    const next = chain.then(() => write(entry))
157    chain = next
158    return next
159  }
160
161  // Highest id suffix across all of this session's parts, so a new id never repeats one.
162  const lastSeq = () =>
163    Math.max(seqOf(own), ...[...cache].filter(([name]) => partIndex(name) >= 0).map(([, c]) => seqOf(c.entries)))
164
165  await readAll()
166  return { dir, lastSeq, append, readAll }
167}
168
169// Shared by the hooks and the top-level helpers below: the loader lets $ reach only functions declared at the top of this file.
170const st = {
171  session: '',
172  home: '',
173  tz: 0,
174  repo: undefined as Repo | undefined,
175  store: undefined as Store | undefined,
176  branch: undefined as string | undefined,
177  seq: 0,
178  page: 0,
179  entries: [] as Entry[],
180  bad: 0,
181  activeTask: undefined as string | undefined,
182  hasAutoOpened: false,
183  hasWarnedWrite: false,
184  lastNewest: '',
185  drawSeq: 0,
186  live: {} as Record<string, LiveAgent>,
187}
188
189const redraw = async ($: Engine) => {
190  const mine = ++st.drawSeq
191  const shown = paginate(buildNodes(st.entries, st.live, st.session, await $.clock.now()), st.page, PAGE_SIZE)
192  st.page = shown.page
193  // Animate the newest node only when it changed; every redraw reloads the drawing and would replay the fade.
194  const newest = shown.nodes.at(-1)
195  const key = newest ? `${newest.kind}|${newest.at}|${newest.title}` : ''
196  const next: View = {
197    header: summarize(st.entries, st.repo?.name ?? 'timeline', st.bad),
198    nodes: shown.nodes,
199    page: shown.page,
200    pages: shown.pages,
201    tz: st.tz,
202    fade: key !== st.lastNewest,
203  }
204  if (mine !== st.drawSeq) return // an older redraw that finishes late must not land last
205  st.lastNewest = key
206  // update retries on a version miss; by then a newer redraw may have started, so keep whatever is newer.
207  await update($, view, prev => (mine === st.drawSeq ? next : prev))
208  // Keep the live end in view: the engine owns the pane's scroll, and a long timeline starts at the top.
209  if (next.fade && next.page === 0) void $.ui.scroll({ in: PANE, to: 'end' }).catch(() => {})
210}
211
212const reload = async ($: Engine) => {
213  if (!st.store) return
214  const all = await st.store.readAll()
215  st.entries = all.entries
216  st.bad = all.bad
217  await redraw($)
218}
219
220const stamp = async ($: Engine, agentId?: string) => ({
221  id: `${st.session.slice(0, 8)}-${++st.seq}`,
222  at: new Date(await $.clock.now()).toISOString(),
223  session: st.session,
224  ...(st.branch ? { branch: st.branch } : {}),
225  ...(st.repo?.worktree ? { worktree: st.repo.worktree } : {}),
226  ...(agentId ? { agentId } : {}),
227})
228
229const isShown = async ($: Engine) => (await $.ui.panes()).some(p => p.id === PANE && p.isShown && p.isPlaced)
230
231const record = async ($: Engine, entry: Entry) => {
232  if (!st.store) return 'timeline has no repo yet'
233  const error = await st.store.append(entry)
234  st.entries = mergeEntries([st.entries, [entry]])
235  if (error && !st.hasWarnedWrite) {
236    st.hasWarnedWrite = true
237    $.ui.toast(`timeline: can't write ${st.store.dir}: ${error}`, { timeoutMs: 10_000 })
238  }
239  if (entry.kind === 'work' && !entry.agentId && !st.hasAutoOpened) {
240    st.hasAutoOpened = true
241    // Skip when the pane is already open. panes() lists only open panes, so one the person closed reopens once after a reload.
242    if (!(await $.ui.panes()).some(p => p.id === PANE)) void $.ui.open({ id: PANE, title: 'Timeline' }).catch(() => {})
243  }
244  await redraw($)
245  return error
246}
247
248// Switch the timeline to the repo `path` is in. requireGit: ignore non-git folders (a `cd` into a scratch dir).
249const enterRepo = async ($: Engine, path: string, requireGit: boolean) => {
250  const found = await identify($, path)
251  if (requireGit && !found.isGit) return
252  const newBranch = found.isGit ? await run($, ['git', '-C', path, 'symbolic-ref', '--short', '-q', 'HEAD']) : undefined
253  if (st.repo?.root === found.root) {
254    st.repo = found
255    st.branch = newBranch ?? st.branch
256    return
257  }
258  if (st.store) {
259    await record($, { v: 1, ...(await stamp($)), kind: 'session', title: `session ${st.session.slice(0, 8)} left for ${found.name}`, event: 'close' })
260  }
261  st.repo = found
262  st.branch = newBranch
263  st.store = await openStore($, st.home, found.root, st.session)
264  st.seq = st.store.lastSeq()
265  st.entries = []
266  st.page = 0
267  st.activeTask = undefined
268  await record($, { v: 1, ...(await stamp($)), kind: 'session', title: `session ${st.session.slice(0, 8)} opened`, event: 'open' })
269  await reload($)
270}
271
272// /clear keeps the process but starts a new session id, with no session.start: follow it on the next prompt.
273const syncSession = async ($: Engine) => {
274  const id = await $.session.id()
275  if (!st.session || id === st.session || !st.repo) return
276  // Open first, then switch id, store and seq together: no log may be stamped with the new id into the old store.
277  const opened = await openStore($, st.home, st.repo.root, id)
278  st.session = id
279  st.store = opened
280  st.seq = opened.lastSeq()
281  st.hasAutoOpened = false
282  st.activeTask = undefined
283  await st.store.append({ v: 1, ...(await stamp($)), kind: 'session', title: `session ${st.session.slice(0, 8)} opened`, event: 'open' })
284  await reload($)
285}
286
287const here = () =>
288  st.repo ? (st.repo.worktree?.startsWith('/') ? st.repo.worktree : st.repo.worktree ? `${st.repo.root}/${st.repo.worktree}` : st.repo.root) : undefined
289
290const refreshBranch = async ($: Engine) => {
291  const dir = here()
292  if (dir) st.branch = (await run($, ['git', '-C', dir, 'symbolic-ref', '--short', '-q', 'HEAD'])) ?? st.branch
293}
294
295const recordFact = async ($: Engine, found: Found) =>
296  record($, {
297    v: 1,
298    ...(await stamp($)),
299    kind: 'fact',
300    title: found.title,
301    fact: found.fact,
302    ...(st.activeTask ? { attachTo: st.activeTask } : {}),
303  })
304
305export const register: Register = on => {
306  let useSection = true
307  let ready: Promise<void> = Promise.resolve()
308
309  on('session.start', async ($, e, next) => {
310    st.session = await $.session.id()
311    st.home = (await $.env.get('HOME')) ?? ''
312    st.tz = tzMinutes(await run($, ['date', '+%z']))
313    try {
314      useSection = (await $.prompt.compose()).sections.some(s => s.id === 'env_info_simple')
315    } catch {
316      useSection = false
317    }
318    await $.tool.register({
319      name: 'log',
320      description:
321        'Record an entry on this repo\'s orchestration timeline. kind "talk": one-line summary of what the user asked or decided. kind "work": a task milestone with a stable kebab-case task id, done/total steps, status, how, and next.',
322      inputSchema: INPUT_SCHEMA,
323    })
324    await $.command.register({ name: 'timeline', description: 'Show or hide the orchestration timeline pane' })
325    // Not awaited: reading a big repo timeline must not delay the session's first prompt. Hooks await `ready`.
326    ready = $.session.cwd().then(cwd => enterRepo($, cwd, false)).catch(() => {})
327    $.clock.every(REFRESH_MS, () => {
328      void (async () => {
329        if (await isShown($)) await reload($)
330      })().catch(() => {})
331    })
332    return next(e)
333  })
334
335  on('session.end', async ($, e, next) => {
336    await ready
337    // append, not record: the session.end chain shares one short wall-clock bound, so no redraw here.
338    if (st.store) await st.store.append({ v: 1, ...(await stamp($)), kind: 'session', title: `session ${st.session.slice(0, 8)} closed`, event: 'close' })
339    return next(e)
340  })
341
342  on('prompt.submit', async ($, e, next) => {
343    // Only a /clear changes the id; otherwise the first prompt must not wait for the initial timeline read.
344    if ((await $.session.id()) !== st.session) {
345      await ready
346      await syncSession($)
347    }
348    return next(e)
349  })
350
351  // The log only appends to a local file: no permission prompt.
352  on('tool.check', { tool: TOOL }, () => ({ decision: 'allow' as const, reason: 'timeline log only appends to a local file' }))
353
354  on('tool.call', { tool: TOOL }, async ($, e) => {
355    await ready
356    const entry = fromLog(e as unknown as Record<string, unknown>, await stamp($, e.agentId))
357    if ('error' in entry) return { deny: entry.error }
358    // The talk side is the user's asks and decisions; a subagent that also got the main note must not write there.
359    if (entry.kind === 'talk' && entry.agentId) return { result: 'logged' }
360    if (entry.kind === 'work' && !entry.agentId) {
361      if (entry.status === 'active') st.activeTask = entry.task
362      else if (st.activeTask === entry.task) st.activeTask = undefined
363    }
364    const error = await record($, entry)
365    return { result: error ? `logged, not saved: ${error}` : 'logged' }
366  })
367
368  on('prompt.section', { name: 'env_info_simple' }, async ($, e, next) => {
369    const r = await next(e)
370    if (!useSection) return r // the note goes through prompt.context instead
371    return { text: `${r.text ?? ''}\n\n${MAIN_NOTE}`.trim() }
372  })
373
374  on('prompt.context', async ($, e, next) => {
375    const r = await next(e)
376    return useSection ? r : { ...r, blocks: [...r.blocks, { name: 'timeline', text: MAIN_NOTE }] }
377  })
378
379  on('command.run', { command: 'timeline' }, async $ => {
380    await ready
381    if (await isShown($)) {
382      await $.ui.close({ id: PANE })
383      return { text: 'Timeline closed.' }
384    }
385    await reload($)
386    await $.ui.open({ id: PANE, title: 'Timeline' })
387    return { text: 'Timeline opened.' }
388  })
389
390  let lastSnap: unknown
391  let bookkeeping: Promise<void> = Promise.resolve() // Bash bookkeeping runs one call at a time, in call order
392
393  // Bash: commit/push/PR facts from any loop (subagents make most commits in orchestration work); the main loop also
394  // follows `cd` into another repo. The bookkeeping runs after the result is returned, one call at a time, so the model never waits on it.
395  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
396    const base = await $.session.cwd() // before the command runs: a `cd` may move the session's cwd
397    const ran = await next(e)
398    if (ran.deny !== undefined) return ran
399    const command = String(e.command)
400    const output = ran.text ?? ''
401    const isError = ran.isError === true
402    bookkeeping = bookkeeping.then(async () => {
403      await ready
404      const target = lastCd(command, base)
405      if (!e.agentId && target) await enterRepo($, target, true)
406      if (!st.repo) return
407      // A subagent working in another repo: its facts belong to that repo's timeline, not this one.
408      if (e.agentId && target) {
409        const there = await identify($, target)
410        if (there.isGit && there.root !== st.repo.root) return
411      }
412      if (/\bgit\b/.test(command)) await refreshBranch($)
413      // A failed call can still have committed or pushed (`git commit && git push && gh …` failing late). PR and merge
414      // facts need success: `gh pr create` exits 1 when a PR exists and prints that PR's URL.
415      for (const found of factsFromBash(command, output)) {
416        if (isError && found.fact.type !== 'commit' && found.fact.type !== 'push') continue
417        await recordFact($, found)
418      }
419    }).catch(() => {})
420    return ran
421  })
422
423  // A subagent's own tool calls feed its live card (`now:` and the tool count), not the file.
424  on('tool.call', async ($, e, next) => {
425    const agent = e.agentId ? st.live[e.agentId] : undefined
426    if (agent && e.tool !== TOOL) {
427      agent.tools += 1
428      agent.now = `${e.tool} ${clip(mainArg(e as unknown as Record<string, unknown>), 40) ?? ''}`.trim()
429      void redraw($).catch(() => {})
430    }
431    return next(e)
432  })
433
434  on('agent.spawn', async ($, e, next) => {
435    // Only the model's own Agent calls get the logging note; another plugin's spawn may expect its prompt untouched.
436    const isModelCall = next.origin.plugin === 'engine'
437    const result = await next(!e.fork && isModelCall ? { ...e, prompt: `${e.prompt}${AGENT_NOTE}` } : e)
438    if (result.deny !== undefined || !result.agentId) return result
439    await ready
440    st.live[result.agentId] = { tools: 0, startedAt: await $.clock.now() }
441    const prompt = clip(e.prompt, 2000)
442    await record($, {
443      v: 1,
444      ...(await stamp($)),
445      kind: 'agent',
446      title: clip(e.description, 80) ?? e.subagentType,
447      agent: {
448        id: result.agentId,
449        phase: 'start',
450        type: e.subagentType,
451        model: result.model,
452        isPinned: !e.fork && (!!e.model || result.model !== e.parentModel), // set on the call or by the agent's own definition; a fork inherits
453        isBackground: e.background,
454        ...(e.parentAgentId ? { parentAgentId: e.parentAgentId } : {}),
455        ...(st.activeTask ? { parentTask: st.activeTask } : {}),
456        ...(prompt ? { prompt } : {}),
457      },
458    })
459    return result
460  })
461
462  // A subagent's turn ended: freeze its card. An agent continued later can end more than once.
463  on('turn.complete', async ($, e, next) => {
464    const result = await next(e)
465    if (!e.agentId) return result
466    await ready
467    // Engine forks and other plugins' agents carry agent ids too: only end agents this timeline started.
468    if (!(e.agentId in st.live) && !st.entries.some(x => x.kind === 'agent' && x.agent?.id === e.agentId)) return result
469    const agent = st.live[e.agentId]
470    delete st.live[e.agentId]
471    const summary = clip(e.answer.split('\n').find(line => line.trim()) ?? '', 160)
472    const u = e.usage
473    // Cached input counts too: with prompt caching, uncached input alone is a small fraction of what the agent read.
474    const tokens = u
475      ? u.input_tokens + u.output_tokens + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
476      : undefined
477    await record($, {
478      v: 1,
479      ...(await stamp($)),
480      kind: 'agent',
481      title: 'agent end',
482      agent: {
483        id: e.agentId,
484        phase: 'end',
485        status: e.reason === 'answer' ? 'done' : 'failed', // aborted, refused or an API error
486        durationMs: e.durationMs,
487        ...(agent ? { tools: agent.tools } : {}),
488        ...(tokens !== undefined ? { tokens } : {}),
489        ...(summary ? { result: summary } : {}),
490      },
491    })
492    return result
493  })
494
495  // ship-state's snapshot for this repo went from pending CI to a result: add a CI fact.
496  on('state.set', { plugin: 'ship-state', key: 'snap' }, async ($, e, next) => {
497    const result = await next(e)
498    await ready
499    const dir = (e.value as { dir?: unknown } | null | undefined)?.dir
500    const isThisRepo =
501      !!st.repo && typeof dir === 'string' && (dir === here() || dir === st.repo.root || dir.startsWith(`${st.repo.root}/`))
502    const found = isThisRepo ? ciFact(e.previous ?? lastSnap, e.value) : undefined
503    lastSnap = e.value
504    if (found) await recordFact($, found)
505    return result
506  })
507
508  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
509    const v = await read($, view)
510    const ui = $.ui.resolve(e)
511    const { Box, Button, Text } = ui
512    if (!v) return <Text dimColor>Timeline loading…</Text>
513
514    const turn = (delta: number) => async () => {
515      st.page = Math.max(0, st.page + delta)
516      await redraw($)
517    }
518    const head = (
519      <Box flexDirection="row">
520        <Text bold>{v.header} </Text>
521        {v.page < v.pages - 1 && <Button key="older" label="◀ older" onPress={turn(1)} />}
522        {v.page > 0 && <Button key="newer" label="newer ▶" onPress={turn(-1)} />}
523      </Box>
524    )
525
526    if (e.surface !== 'terminal' && 'Svg' in ui) {
527      const { Svg } = ui
528      const alt = `${v.header}. ${v.nodes.length} entries shown; newest: ${v.nodes.at(-1)?.title ?? 'none'}.`
529      return (
530        <Box flexDirection="column">
531          {head}
532          <Svg source={renderSvg(v.nodes, v.tz, { fade: v.fade })} alt={alt} isInteractive />
533        </Box>
534      )
535    }
536
537    // bodyColumns is the pane's own width (a docked pane is narrower than the screen viewport).
538    const lines = renderText(v.nodes, e.props.bodyColumns, v.tz)
539    return (
540      <Box flexDirection="column">
541        {head}
542        {lines.map((line, i) => (
543          <Text key={String(i)} color={TONE[line.tone]} dimColor={line.tone === 'dim'} wrap="truncate">
544            {line.text}
545          </Text>
546        ))}
547      </Box>
548    )
549  })
550}
551
hooks/draw.ts 183 lines
1import type { AgentNode, Node, WorkNode } from '../types'
2
3export type Line = { text: string; tone: 'normal' | 'dim' | 'accent' | 'warn' }
4
5// ISO time → HH:MM at `tz` minutes east of UTC (the module's own clock may not know the zone).
6export function hhmm(iso: string, tz: number): string {
7  const d = new Date(Date.parse(iso) + tz * 60_000)
8  if (Number.isNaN(d.getTime())) return '--:--'
9  return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
10}
11
12// Never throws: a bad done/total read back from disk (negative, NaN, total 0) draws an empty or full bar.
13export function bar(done: number, total: number, width: number): string {
14  const ratio = total > 0 ? done / total : 0
15  const filled = Number.isFinite(ratio) ? Math.min(width, Math.max(0, Math.round(ratio * width))) : 0
16  return '█'.repeat(filled) + '░'.repeat(width - filled)
17}
18
19// Cut to width with an ellipsis; never leaves half of a surrogate pair (an emoji) at the cut.
20export const fit = (text: string, width: number) =>
21  text.length > width ? `${text.slice(0, Math.max(0, width - 1)).replace(/[\uD800-\uDBFF]$/, '')}…` : text
22
23const elapsed = (ms?: number) => {
24  if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
25  const s = Math.round(ms / 1000)
26  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
27}
28
29const tokens = (n?: number) => (typeof n !== 'number' || !Number.isFinite(n) ? '' : n >= 1000 ? `${Math.round(n / 1000)}k tokens` : `${n} tokens`)
30
31// The lines under a card's title, shared by the terminal and the SVG.
32export function cardLines(node: WorkNode | AgentNode): string[] {
33  const lines: string[] = []
34  if (node.kind === 'agent') {
35    lines.push(`${node.type} · ${node.model}${node.isPinned ? ' (pinned)' : ''}`)
36    if (!node.isPinned && node.type !== 'fork') lines.push('⚠ no model set: inherited') // a fork always inherits
37  }
38  const progress = node.total ? `${bar(node.done ?? 0, node.total, 10)} ${node.done ?? 0}/${node.total}` : ''
39  const status = node.kind === 'work' ? node.status : [node.state, elapsed(node.elapsedMs)].filter(Boolean).join(' · ')
40  lines.push([progress, status].filter(Boolean).join('  '))
41  if (node.kind === 'work' && node.how) lines.push(`how: ${node.how}`)
42  if (node.kind === 'agent' && node.now && node.state === 'running') lines.push(`now: ${node.now}`)
43  if (node.next) lines.push(`next: ${node.next}`)
44  if (node.kind === 'agent') {
45    lines.push([`${node.tools} tool call${node.tools === 1 ? '' : 's'}`, tokens(node.tokens)].filter(Boolean).join(' · '))
46    if (node.result) lines.push(`result: ${node.result}`)
47  } else {
48    // Newest 5 facts only, so a long commit history can't grow one card without bound.
49    if (node.facts.length > 5) lines.push(`↳ +${node.facts.length - 5} earlier`)
50    for (const fact of node.facts.slice(-5)) lines.push(`↳ ${fact}`)
51  }
52  return lines
53}
54
55// Nodes → terminal lines. From 70 columns: talk left of the line, work right. Below: one column.
56export function renderText(nodes: readonly Node[], columns: number, tz: number): Line[] {
57  const lines: Line[] = []
58  const isWide = columns >= 70
59  const left = Math.floor(columns * 0.38)
60  const right = columns - left - 4
61  const row = (l: string, mid: string, r: string, tone: Line['tone']) => {
62    lines.push({ text: isWide ? `${fit(l, left).padStart(left)}${mid}${fit(r, right)}` : fit(r, columns), tone })
63  }
64
65  for (const node of nodes) {
66    const time = hhmm(node.at, tz)
67    if (node.kind === 'session') {
68      const label = ` ${node.title} · ${time} `
69      const side = '┄'.repeat(Math.max(2, Math.floor((columns - label.length) / 2)))
70      lines.push({ text: fit(`${side}${label}${side}`, columns), tone: 'dim' })
71    } else if (node.kind === 'talk') {
72      if (isWide) row(node.title, ' ◀─┤', ` ${time}`, 'dim')
73      else lines.push({ text: fit(`you: ${node.title}`, columns), tone: 'dim' })
74    } else if (node.kind === 'fact') {
75      row(time, ' ├· ', isWide ? `↳ ${node.title}` : `${time} ↳ ${node.title}`, 'dim')
76    } else if (node.kind === 'work' || node.kind === 'agent') {
77      const indent = node.kind === 'agent' ? '  '.repeat(node.depth) : ''
78      const glyph = node.kind === 'agent' ? (node.isBackground ? '◇ ' : '◆ ') : `#${node.tag} `
79      const tone =
80        (node.kind === 'work' && node.status === 'blocked') || (node.kind === 'agent' && node.state === 'failed')
81          ? 'warn'
82          : node.kind === 'agent' && node.state === 'unknown'
83            ? 'dim'
84            : 'accent'
85      row(time, ' ├─▶', isWide ? ` ${indent}${glyph}${node.title}` : `${time} ${indent}${glyph}${node.title}`, tone)
86      for (const text of cardLines(node)) row('', '  │ ', `   ${indent}${text}`, text.startsWith('⚠') ? 'warn' : 'normal')
87    }
88  }
89  return lines
90}
91
92// Shapes use mid-tones with 3:1 contrast on light and dark panes; text colors come from CSS classes with a dark-mode
93// override, for 4.5:1 on both. Blue/orange, never red against green (color-blind safe).
94const BLUE = '#3b6fd8'
95const ORANGE = '#d96a10'
96const GRAY = '#8b93a1'
97const STYLE =
98  '.b{fill:#5f6670}.s{fill:#5f6670;font-size:11px}.h{font-weight:600}.t{fill:#2f5bb7}.w{fill:#b4520a}' +
99  '@media (prefers-color-scheme:dark){.b,.s{fill:#a3abb8}.t{fill:#7da2f0}.w{fill:#f0954a}}'
100const CHAR_PX = 7.3 // Menlo / ui-monospace advance at 12 px
101const TIP_CHARS = 600 // hover text per node, before escaping
102const SVG_BUDGET = 120_000 // the engine caps an Svg source at 131072 characters; over budget, redraw without hover text
103
104// XML-escape, and drop characters XML forbids (C0 controls except tab/newline/return, lone surrogates).
105const esc = (text: string) =>
106  text
107    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '')
108    .replace(/&/g, '&amp;')
109    .replace(/</g, '&lt;')
110    .replace(/>/g, '&gt;')
111    .replace(/"/g, '&quot;')
112
113export type SvgOptions = { width?: number; withTips?: boolean; fade?: boolean }
114
115// Nodes → one SVG: center line, talk left, cards right, hover text in <title>. `fade` animates the newest node; pass it
116// only when that node is new, because every new source reloads the drawing and would replay the animation.
117export function renderSvg(nodes: readonly Node[], tz: number, options: SvgOptions = {}): string {
118  const { width = 640, withTips = true, fade = true } = options
119  const tipTag = (text: string) => (withTips ? `<title>${esc(fit(text, TIP_CHARS))}</title>` : '')
120  const cx = Math.round(width * 0.4)
121  const cardX = cx + 24
122  const parts: string[] = []
123  let y = 24
124
125  nodes.forEach((node, i) => {
126    const anim = fade && i === nodes.length - 1 ? '<animate attributeName="opacity" from="0" to="1" dur="0.6s" fill="freeze"/>' : ''
127    const time = hhmm(node.at, tz)
128    if (node.kind === 'session') {
129      parts.push(
130        `<g>${anim}<line x1="8" y1="${y}" x2="${width - 8}" y2="${y}" stroke="${GRAY}" stroke-dasharray="4 4"/>` +
131          `<text x="${cx}" y="${y - 6}" text-anchor="middle" class="s">${esc(fit(`${node.title} · ${time}`, Math.floor((width - 16) / CHAR_PX)))}</text></g>`,
132      )
133      y += 30
134    } else if (node.kind === 'talk') {
135      const label = fit(node.title, Math.floor((cx - 24) / CHAR_PX))
136      parts.push(
137        `<g>${anim}${tipTag(node.title)}<circle cx="${cx}" cy="${y}" r="4" fill="${GRAY}"/>` +
138          `<text x="${cx - 12}" y="${y + 4}" text-anchor="end" class="b">${esc(label)}</text>` +
139          `<text x="${cx + 10}" y="${y + 4}" class="s">${time}</text></g>`,
140      )
141      y += 26
142    } else if (node.kind === 'fact') {
143      parts.push(
144        `<g>${anim}<text x="${cardX}" y="${y + 4}" class="s">${esc(fit(`${time} ↳ ${node.title}`, Math.floor((width - cardX - 8) / CHAR_PX)))}</text></g>`,
145      )
146      y += 20
147    } else if (node.kind === 'work' || node.kind === 'agent') {
148      const x = cardX + (node.kind === 'agent' ? 18 * node.depth : 0)
149      const w = width - x - 8
150      const max = Math.floor((w - 20) / CHAR_PX)
151      const lines = cardLines(node)
152      const h = 30 + lines.length * 16
153      const isBlocked = (node.kind === 'work' && node.status === 'blocked') || (node.kind === 'agent' && node.state === 'failed')
154      const isDone = node.kind === 'work' ? node.status === 'done' : node.state === 'done'
155      const color = isBlocked ? ORANGE : BLUE
156      const glyph = node.kind === 'agent' ? (node.isBackground ? '◇ ' : '◆ ') : `#${node.tag} `
157      const tip = [node.title, ...lines, ...(node.kind === 'agent' && node.prompt ? [`prompt: ${node.prompt}`] : [])].join('\n')
158      const body = lines
159        .map((text, j) => `<text x="${x + 10}" y="${y + 38 + j * 16}" class="${text.startsWith('⚠') ? 'w' : 'b'}">${esc(fit(text, max))}</text>`)
160        .join('')
161      parts.push(
162        `<g>${anim}${tipTag(tip)}` +
163          `<circle cx="${cx}" cy="${y + 14}" r="5" fill="${color}"/>` +
164          `<line x1="${cx}" y1="${y + 14}" x2="${x}" y2="${y + 14}" stroke="${color}"/>` +
165          `<text x="${cx - 10}" y="${y + 18}" text-anchor="end" class="s">${time}</text>` +
166          `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="6" fill="${isDone ? color : 'none'}" fill-opacity="0.1" stroke="${color}"${node.kind === 'agent' ? ' stroke-dasharray="5 3"' : ''}/>` +
167          `<text x="${x + 10}" y="${y + 19}" class="h ${isBlocked ? 'w' : 't'}">${esc(fit(`${isDone ? '✓ ' : ''}${glyph}${node.title}`, max))}</text>` +
168          `${body}</g>`,
169      )
170      y += h + 14
171    }
172  })
173
174  const height = y + 8
175  const svg =
176    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="ui-monospace, Menlo, monospace" font-size="12">` +
177    `<style>${STYLE}</style>` +
178    `<line x1="${cx}" y1="0" x2="${cx}" y2="${height}" stroke="${GRAY}" stroke-width="2"/>` +
179    parts.join('') +
180    '</svg>'
181  return withTips && svg.length > SVG_BUDGET ? renderSvg(nodes, tz, { ...options, withTips: false }) : svg
182}
183
hooks/layout.ts 120 lines
1import type { AgentNode, Entry, LiveAgent, Node, WorkNode } from '../types'
2
3// Entries (oldest first) → nodes on the line. `session` is this session's id; `live` holds its running agents.
4export function buildNodes(
5  entries: readonly Entry[],
6  live: Readonly<Record<string, LiveAgent>>,
7  session: string,
8  now: number,
9): Node[] {
10  const nodes: Node[] = []
11  const tags = new Map<string, number>()
12  const lastWork = new Map<string, WorkNode>()
13  const agents = new Map<string, AgentNode>()
14  const startedIn = new Map<string, string>() // agent id → session that started it
15
16  for (const e of entries) {
17    if (e.kind === 'talk' || e.kind === 'session') {
18      nodes.push({ kind: e.kind, at: e.at, title: e.title })
19      // A closed session's agents with no end entry are no longer running there.
20      if (e.kind === 'session' && e.event === 'close') {
21        for (const card of agents.values()) {
22          if (card.state === 'running' && !live[card.id] && startedIn.get(card.id) === e.session) {
23            card.state = 'unknown'
24            delete card.now
25          }
26        }
27      }
28    } else if (e.kind === 'fact') {
29      const card = e.attachTo ? lastWork.get(e.attachTo) : undefined
30      if (card) card.facts.push(e.title)
31      else nodes.push({ kind: 'fact', at: e.at, title: e.title })
32    } else if (e.kind === 'work' && e.agentId) {
33      const card = agents.get(e.agentId)
34      if (!card) continue
35      if (e.next) card.next = e.next
36      if (e.total !== undefined) {
37        card.total = e.total
38        card.done = e.done ?? 0
39      }
40    } else if (e.kind === 'work') {
41      const task = e.task ?? 'task'
42      const tag = tags.get(task) ?? tags.size + 1
43      tags.set(task, tag)
44      const card: WorkNode = { kind: 'work', at: e.at, title: e.title, task, tag, status: e.status ?? 'active', facts: [] }
45      if (e.total !== undefined) {
46        card.total = e.total
47        card.done = e.done ?? 0
48      }
49      if (e.how) card.how = e.how
50      if (e.next) card.next = e.next
51      lastWork.set(task, card)
52      nodes.push(card)
53    } else if (e.kind === 'agent' && e.agent) {
54      const a = e.agent
55      if (a.phase === 'start') {
56        const parent = a.parentAgentId ? agents.get(a.parentAgentId) : undefined
57        const run = live[a.id]
58        const card: AgentNode = {
59          kind: 'agent',
60          at: e.at,
61          id: a.id,
62          title: e.title,
63          depth: parent ? Math.min(parent.depth + 1, 4) : 1,
64          type: a.type ?? 'agent',
65          model: a.model ?? 'unknown',
66          isPinned: a.isPinned ?? false,
67          isBackground: a.isBackground ?? false,
68          // Another session's agent without an end entry is still running there; ours without live state lost it to a reload.
69          state: run || e.session !== session ? 'running' : 'unknown',
70          tools: run?.tools ?? 0,
71        }
72        if (run) {
73          card.elapsedMs = now - run.startedAt
74          if (run.now) card.now = run.now
75        }
76        if (a.prompt) card.prompt = a.prompt
77        agents.set(a.id, card)
78        startedIn.set(a.id, e.session)
79        nodes.push(card)
80      } else {
81        const card = agents.get(a.id)
82        if (!card) continue
83        delete card.result
84        delete card.tokens
85        delete card.elapsedMs
86        card.state = a.status ?? 'done'
87        delete card.now
88        if (a.durationMs !== undefined) card.elapsedMs = a.durationMs
89        if (a.tools !== undefined) card.tools = a.tools
90        if (a.tokens !== undefined) card.tokens = a.tokens
91        if (a.result) card.result = a.result
92      }
93    }
94  }
95  return nodes
96}
97
98// Header line: repo, then counts of tasks by their latest status (subagent logs excluded).
99export function summarize(entries: readonly Entry[], repo: string, bad: number): string {
100  const latest = new Map<string, string>()
101  for (const e of entries) if (e.kind === 'work' && !e.agentId) latest.set(e.task ?? 'task', e.status ?? 'active')
102  const parts = [repo]
103  if (latest.size) {
104    const count = (status: string) => [...latest.values()].filter(v => v === status).length
105    parts.push(`${latest.size} task${latest.size === 1 ? '' : 's'}`, `${count('done')} done`, `${count('blocked')} blocked`, `${count('active')} active`)
106  } else {
107    parts.push('no milestones logged yet')
108  }
109  if (bad) parts.push(`${bad} line${bad === 1 ? '' : 's'} unreadable`)
110  return parts.join(' · ')
111}
112
113// Page 0 is the newest `size` nodes; out-of-range pages clamp.
114export function paginate(nodes: readonly Node[], page: number, size: number): { nodes: Node[]; page: number; pages: number } {
115  const pages = Math.max(1, Math.ceil(nodes.length / size))
116  const clamped = Math.min(Math.max(0, page), pages - 1)
117  const end = nodes.length - clamped * size
118  return { nodes: nodes.slice(Math.max(0, end - size), end), page: clamped, pages }
119}
120
hooks/model.ts 180 lines
1import type { Entry, Fact } from '../types'
2
3export type Stamp = { id: string; at: string; session: string; branch?: string; worktree?: string; agentId?: string }
4export type Found = { title: string; fact: Fact }
5
6// One line, trimmed, cut to max with an ellipsis; undefined for non-strings and blanks.
7export function clip(value: unknown, max: number): string | undefined {
8  if (typeof value !== 'string') return undefined
9  const text = value.replace(/\s+/g, ' ').trim()
10  if (!text) return undefined
11  return text.length > max ? `${text.slice(0, max - 1).replace(/[\uD800-\uDBFF]$/, '')}…` : text
12}
13
14export function slug(text: string): string {
15  return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').slice(0, 40).replace(/^-+|-+$/g, '') || 'task'
16}
17
18// The model's tool input → an entry, or an error message the model reads.
19export function fromLog(input: Record<string, unknown>, stamp: Stamp): Entry | { error: string } {
20  const title = clip(input.title, 80)
21  const kind = input.kind
22  if (!title || (kind !== 'talk' && kind !== 'work')) {
23    return { error: 'timeline log needs kind ("talk" or "work") and title. Work also takes task, done, total, status, how, next.' }
24  }
25  if (kind === 'talk') return { v: 1, ...stamp, kind, title }
26
27  const entry: Entry = {
28    v: 1,
29    ...stamp,
30    kind,
31    title,
32    task: typeof input.task === 'string' && input.task.trim() ? slug(input.task) : slug(title),
33    status: input.status === 'done' || input.status === 'blocked' ? input.status : 'active',
34  }
35  if (typeof input.total === 'number' && Number.isFinite(input.total) && input.total >= 1) {
36    entry.total = Math.floor(input.total)
37    entry.done = typeof input.done === 'number' && Number.isFinite(input.done) ? Math.min(entry.total, Math.max(0, Math.floor(input.done))) : 0
38  }
39  const how = clip(input.how, 200)
40  if (how) entry.how = how
41  const next = clip(input.next, 120)
42  if (next) entry.next = next
43  return entry
44}
45
46const AT_COMMAND = String.raw`(?:^|&&|;|\|\||\n)\s*`
47const GIT = String.raw`git(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--[\w-]+(?:=\S+)?))*\s+`
48const GIT_COMMIT = new RegExp(`${AT_COMMAND}${GIT}commit\\b`)
49const GIT_PUSH = new RegExp(`${AT_COMMAND}${GIT}push\\b`)
50const PR_CREATE = new RegExp(`${AT_COMMAND}gh\\s+pr\\s+create\\b`)
51const PR_MERGE = new RegExp(`${AT_COMMAND}gh\\s+pr\\s+merge\\b([^\\n;&|]*)`)
52
53// Last `cd <dir>` in a shell command, resolved against base; undefined when there is none.
54export function lastCd(command: string, base: string): string | undefined {
55  const all = [...command.matchAll(/(?:^|&&|;|\|\||\n)\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/g)]
56  const m = all.at(-1)
57  const path = m && (m[1] ?? m[2] ?? m[3])
58  if (!path || /^[~$-]/.test(path)) return undefined
59  return path.startsWith('/') ? path : `${base}/${path}`
60}
61
62// Facts a Bash command produced. Commit, push and PR facts must show in the output; a merge comes from the command.
63export function factsFromBash(command: string, output: string): Found[] {
64  const found: Found[] = []
65  if (GIT_COMMIT.test(command)) {
66    for (const m of output.matchAll(/^\[.+? ([0-9a-f]{7,40})\] (.+)$/gm)) {
67      found.push({ title: `commit ${m[1]} ${clip(m[2], 60) ?? ''}`.trim(), fact: { type: 'commit', ref: m[1]! } })
68    }
69  }
70  if (GIT_PUSH.test(command)) {
71    // Git's ref lines: " * [new branch] a -> b", "   1a2b..3c4d  a -> b", " + ... (forced update)"; not "!" rejected, "-" deleted, "=" up to date.
72    for (const m of output.matchAll(/^ [ *+] .*? -> (\S+)/gm)) found.push({ title: `push ${m[1]}`, fact: { type: 'push', ref: m[1]! } })
73  }
74  if (PR_CREATE.test(command)) {
75    const m = output.match(/https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/)
76    if (m) found.push({ title: `PR #${m[1]}`, fact: { type: 'pr', ref: m[1]!, url: m[0] } })
77  }
78  const merge = command.match(PR_MERGE)
79  if (merge && !/--auto\b/.test(merge[1] ?? '')) {
80    const n = merge[1]?.match(/(?:^|\s)#?(\d+)(?=\s|$)|\/pull\/(\d+)/)
81    const ref = n?.[1] ?? n?.[2]
82    found.push({ title: ref ? `merged PR #${ref}` : 'merged PR', fact: { type: 'merge', ref: ref ?? '' } })
83  }
84  return found
85}
86
87const SKIP_ARGS = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
88
89// The first string argument of a tool call: a Bash command, a file path, a pattern.
90export function mainArg(input: Record<string, unknown>): string | undefined {
91  const hit = Object.entries(input).find(([key, value]) => !SKIP_ARGS.has(key) && typeof value === 'string')
92  return hit ? (hit[1] as string) : undefined
93}
94
95type Ci = { sha: string; total: number; pending: number; failed: string[] }
96
97const ciOf = (snap: unknown): Ci | undefined => {
98  const ci = (snap as { ci?: unknown } | null | undefined)?.ci as Partial<Ci> | undefined
99  return ci && typeof ci.sha === 'string' && typeof ci.total === 'number' && typeof ci.pending === 'number' && Array.isArray(ci.failed)
100    ? (ci as Ci)
101    : undefined
102}
103
104// ship-state's snapshot went from pending to final CI on the same SHA.
105export function ciFact(previous: unknown, current: unknown): Found | undefined {
106  const a = ciOf(previous)
107  const b = ciOf(current)
108  if (!a || !b || a.sha !== b.sha || !a.pending || b.pending || !b.total) return undefined
109  const sha = b.sha.slice(0, 7)
110  return b.failed.length
111    ? { title: clip(`CI ✗ ${b.failed.join(', ')} @${sha}`, 80) ?? 'CI ✗', fact: { type: 'ci', ref: b.sha, state: 'failure' } }
112    : { title: `CI ✓ ${b.total} @${sha}`, fact: { type: 'ci', ref: b.sha, state: 'success' } }
113}
114
115// 32-bit FNV-1a as 8 hex digits: a short, stable folder suffix.
116export function fnv1a(text: string): string {
117  let hash = 0x811c9dc5
118  for (let i = 0; i < text.length; i++) {
119    hash ^= text.charCodeAt(i)
120    hash = Math.imul(hash, 0x01000193) >>> 0
121  }
122  return hash.toString(16).padStart(8, '0')
123}
124
125export function folderName(root: string): string {
126  const clean = root.replace(/\/+$/, '') || '/'
127  return `${clean.split('/').filter(Boolean).at(-1) ?? 'root'}-${fnv1a(clean)}`
128}
129
130// `git rev-parse --path-format=absolute --git-common-dir` → the root its worktrees share.
131export const repoRoot = (commonDir: string) => commonDir.replace(/\/\.git\/?$/, '') || commonDir
132
133const KINDS = new Set(['talk', 'work', 'fact', 'agent', 'session'])
134
135const isEntry = (value: unknown): value is Entry => {
136  const e = value as Partial<Entry> | null
137  return (
138    !!e &&
139    e.v === 1 &&
140    typeof e.id === 'string' &&
141    typeof e.at === 'string' &&
142    typeof e.session === 'string' &&
143    typeof e.title === 'string' &&
144    typeof e.kind === 'string' &&
145    KINDS.has(e.kind)
146  )
147}
148
149export function parseJsonl(text: string): { entries: Entry[]; bad: number } {
150  const entries: Entry[] = []
151  let bad = 0
152  for (const line of text.split('\n')) {
153    if (!line.trim()) continue
154    try {
155      const value: unknown = JSON.parse(line)
156      if (isEntry(value)) entries.push(value)
157      else bad++
158    } catch {
159      bad++
160    }
161  }
162  return { entries, bad }
163}
164
165export const toJsonl = (entries: readonly Entry[]) => entries.map(e => JSON.stringify(e)).join('\n') + '\n'
166
167// All lists into one, deduplicated by id, oldest first.
168export function mergeEntries(lists: readonly (readonly Entry[])[]): Entry[] {
169  const byId = new Map<string, Entry>()
170  for (const list of lists) for (const entry of list) byId.set(entry.id, entry)
171  return [...byId.values()].sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id, 'en', { numeric: true }))
172}
173
174// `date +%z` output ("-0500") → minutes east of UTC.
175export function tzMinutes(text: string | undefined): number {
176  const m = text?.trim().match(/^([+-])(\d{2})(\d{2})$/)
177  if (!m) return 0
178  return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]))
179}
180
types/index.d.ts 91 lines
1export type Fact = { type: 'commit' | 'push' | 'pr' | 'merge' | 'ci'; ref: string; state?: string; url?: string }
2
3export type AgentInfo = {
4  id: string
5  phase: 'start' | 'end'
6  type?: string
7  model?: string
8  isPinned?: boolean
9  isBackground?: boolean
10  parentAgentId?: string
11  parentTask?: string
12  prompt?: string
13  status?: 'done' | 'failed'
14  durationMs?: number
15  tools?: number
16  tokens?: number
17  result?: string
18}
19
20export type Entry = {
21  v: 1
22  id: string
23  at: string
24  session: string
25  branch?: string
26  worktree?: string
27  kind: 'talk' | 'work' | 'fact' | 'agent' | 'session'
28  title: string
29  task?: string
30  done?: number
31  total?: number
32  status?: 'active' | 'done' | 'blocked'
33  how?: string
34  next?: string
35  agentId?: string
36  fact?: Fact
37  attachTo?: string
38  agent?: AgentInfo
39  event?: 'open' | 'close'
40}
41
42export type LiveAgent = { now?: string; tools: number; startedAt: number }
43
44export type WorkNode = {
45  kind: 'work'
46  at: string
47  title: string
48  task: string
49  tag: number
50  status: 'active' | 'done' | 'blocked'
51  done?: number
52  total?: number
53  how?: string
54  next?: string
55  facts: string[]
56}
57
58export type AgentNode = {
59  kind: 'agent'
60  at: string
61  id: string
62  title: string
63  depth: number
64  type: string
65  model: string
66  isPinned: boolean
67  isBackground: boolean
68  state: 'running' | 'done' | 'failed' | 'unknown'
69  elapsedMs?: number
70  tools: number
71  tokens?: number
72  now?: string
73  next?: string
74  done?: number
75  total?: number
76  result?: string
77  prompt?: string
78}
79
80export type Node = { kind: 'talk' | 'session' | 'fact'; at: string; title: string } | WorkNode | AgentNode
81
82export type View = { header: string; nodes: Node[]; page: number; pages: number; tz: number // minutes east of UTC (from model.tzMinutes)
83  fade: boolean // newest node changed since the last draw: animate it
84}
85
86declare module 'claude-code' {
87  interface PluginState {
88    timeline: { view: View | null }
89  }
90}
91