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

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.
| Folder | Tool | What it does |
|---|---|---|
ship-state/ | Claude Code | Shows the git, pull request, CI and deploy state of the current repo in one line above the prompt. |
timeline/ | Claude Code | Shows 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 Code | Shows your usage limits and continues a turn after a rate limit resets. |
followups/ | Claude Code | Shows 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 Code | Finds 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/ | Codex | Gives Codex 6 skills to plan large work in waves and to track it to completion. |
.claude-plugin/marketplace.json | Claude Code | Lists the 5 Claude Code plugins so that Claude Code can install them from this repository. |
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.
claude plugin validate.git 2.31 or newer.gh, signed in. ship-state and timeline use it for pull request, CI and deploy data. Without gh, they show only local git data.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.
ship-state needs no action. It starts with each session.
app ⎇ feat/waitlist · 3 dirty · ↑2 ↓0 · PR #312 · CI ⏳ 4/5 · prod = HEAD ✓ 2h ago
| Part | Meaning |
|---|---|
3 dirty | 3 files have changes that are not committed. |
↑2 ↓0 | 2 commits are not pushed. 0 commits are not pulled. |
PR #312 | The branch has open pull request 312. |
CI ⏳ 4/5 | 4 of 5 CI checks are complete. |
prod = HEAD ✓ | Production runs the current commit. |
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.
/timeline to open or close the pane. The pane also opens by itself when Claude logs the first task of a session.██████░░░░ 2/3, how Claude did the step, and the next step.↳ show commits, pushes, pull requests and CI results for the active task.now:), the next step (next:), the number of tool calls, the tokens, and the result.⚠ 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.◀ older and newer ▶ to move between pages.How timeline works:
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.~/.claude/timelines/<repo>-<hash>/. Each session writes only its own files. Worktrees of a repo share one timeline.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.
followups needs no action. It starts with each session.
1: … to 4: ….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./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.
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.
y to log it. Answer n to skip it.🌱 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./lessons off or /lessons on. Type /lessons status to see the setting, the counts, and where each skill was found.| Kind | Signals |
|---|---|
| Win | Praise, 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". |
| Pitfall | Frustration, 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:
anthropic-skills:<name>.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.
limit-resume needs no action. It starts with each session.
5h 62% · 7d 41%. 5h is the 5-hour window. 7d is the 7-day window./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.
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.
multi-harness folder with the Codex plugin installer.multi-harness/assets/templates/ for wave plans, tracker updates, QA evidence, and pull request closeout.The 6 skills are:
| Skill | Use |
|---|---|
platform-wave-orchestrator | Divide a backlog into agent lanes and implementation waves. |
platform-agent-patterns | Choose how agents work together on a wave. |
platform-pr-closeout | Gate and close branches and pull requests. |
platform-tracker-sync | Update GitHub and Notion trackers. |
platform-qa-evidence | Collect QA evidence for each change. |
platform-safety-review | Review 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.
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:
/plugin-types .claude/types. This command writes the Claude Code type declarations.tsc -p timeline/tsconfig.check.json.MIT. See LICENSE. Copyright 2026 Tempered Works LLC.
hooks/register.tsx 551 lines1import { 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}
551hooks/draw.ts 183 lines1import 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, '&')
109 .replace(/</g, '<')
110 .replace(/>/g, '>')
111 .replace(/"/g, '"')
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}
183hooks/layout.ts 120 lines1import 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}
120hooks/model.ts 180 lines1import 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}
180types/index.d.ts 91 lines1export 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