A todo pane per subject (ticket or conversation) that Claude keeps up to date

My dotfiles for macOS — supports both Apple Silicon (/opt/homebrew) and Intel (/usr/local) Macs.
Make sure you have git and stow installed.
brew install git stow
git clone https://github.com/0inp/dotfiles.git ~/dotfiles
cd ~/dotfiles
chmod +x ./scripts/install.sh && ./scripts/install.sh
install.sh handles Homebrew, the Brewfile, macOS defaults and stowing. If you ever need to run the pieces by hand:
brew bundle --global # HOMEBREW_BUNDLE_FILE_GLOBAL points at this repo
stow -t ~ */ # one package per module directory
Never run
stow -t ~ .— that names the repo root as a single package and would symlinkaerospace/,brew/,CLAUDE.mdand.github/straight into$HOME, bypassing every.stow-local-ignore.
scripts/install.sh to set up dotfiles.scripts/update.sh.cd ~/dotfiles
./scripts/update.sh
Each top-level directory is a GNU Stow package whose contents are symlinked into ~. See CONTEXT_MAP.md for the full module-to-target table, and the CONTEXT.md inside each module for tool-specific detail.
Shell & terminal
zsh/: Zsh configuration (.zshenv, .zprofile, .zshrc, .config/zsh/).ghostty/: Ghostty terminal emulator.herdr/: Agent-aware terminal multiplexer (replaced tmux).aerospace/: Tiling window manager.Development
git/, lazygit/, gh-dash/: Git config, TUI, and PR dashboard.nvim/: Neovim, using the built-in vim.pack manager.mise/: Runtime version pinning (Node, Python, Go).worktrunk/: Worktree/workspace manager for parallel agent sessions.ripgrep/, pgcli/, python/, btop/: Per-tool configuration.AI agents
claude/: Claude Code settings.agents/: Cross-tool agent skills, shared via ~/.agents.vibe/: Mistral Vibe CLI.docs/agents/: Agent skills and domain documentation.System
brew/: The Brewfile (packages and casks).fnox/: Secret references — values live in the macOS keychain.gnupg/: GPG configuration.scripts/: Custom scripts, symlinked into ~/.local/bin.resources/: Helper scripts run by the installer (not stowed).Reinstall them by running following command:
sudo rm -rf $(xcode-select -print-path)
xcode-select --install
Fix it with following command when it's broken.
sudo chown -R "$(whoami)" "$(brew --prefix)"/*
brew doctor
Homebrew installs to different prefixes depending on architecture:
/opt/homebrew/usr/localScripts and shell configs use $(brew --prefix) or branch on [[ -d /opt/homebrew ]] to stay portable across both. PATH is built in zsh/.config/zsh/lib/path.zsh, which sets HOMEBREW_PREFIX by branching on the directory rather than shelling out to brew --prefix on every shell start.
hooks/register.ts 199 lines1// subject-todo: binds the session to a subject (Linear link in a prompt,
2// Claude's `bind`, or a dev-<n> branch), gives Claude a tool to keep the list,
3// shows it in a pane, and tells Claude about it beside every prompt.
4
5import type { EngineInterface as Api, On, RenderElement } from 'claude-code'
6import { inheritOnFork, ticketFromBranch, ticketToBind } from './binding.ts'
7import { TICKET_TEMPLATE } from './template.ts'
8import { applyAction, contextFor, createSubject, cycleItem, parseAction, renderList, type Subject } from './subject.ts'
9import { bindSession, boundKey, loadSubject, mutateSubject, prune, saveSubject, serialQueue, type KV } from './store.ts'
10import { paneTree, type Elements } from './pane.ts'
11
12const PANE = 'subject-todo'
13const TOOL = 'subject_todo'
14const TOOL_FULL = 'mcp__subject-todo__subject_todo'
15
16const TOOL_DESCRIPTION = [
17 "Tient la todo-list du sujet de la conversation (un ticket ou un sujet libre), affichée à l'utilisateur dans un panneau.",
18 "Actions : bind {title} nomme le sujet (le crée s'il n'y en a pas) ; replace {items:[{text,status?}]} remplace la liste ;",
19 'update {id, status?, text?} change un item ; add {text, after?} ajoute un item.',
20 'Statuts : todo, doing, done, skipped. Mets à jour au fil du travail : doing quand tu commences une étape, done quand elle est faite.',
21].join(' ')
22
23const INPUT_SCHEMA = {
24 type: 'object',
25 properties: {
26 action: { type: 'string', enum: ['bind', 'replace', 'update', 'add'] },
27 title: { type: 'string' },
28 id: { type: 'string' },
29 text: { type: 'string' },
30 after: { type: 'string' },
31 status: { type: 'string', enum: ['todo', 'doing', 'done', 'skipped'] },
32 items: {
33 type: 'array',
34 items: {
35 type: 'object',
36 properties: { text: { type: 'string' }, status: { type: 'string', enum: ['todo', 'doing', 'done', 'skipped'] } },
37 required: ['text'],
38 },
39 },
40 },
41 required: ['action'],
42}
43
44let sessionId = ''
45let current: Subject | undefined
46let announced = false // the full list goes to Claude once per bind or resume
47let lastPromptAt: string | undefined
48// One queue for every store write from this process (pane, tool, binding).
49const serial = serialQueue()
50
51// The validator follows $ only into functions declared at the top of this
52// file, never across an import, and refuses $.store passed as a value. So
53// store.ts gets this adapter, whose calls keep the $.store.<method> spelling.
54function kv($: Api): KV {
55 return {
56 get: (key) => $.store.get(key),
57 set: (key, value) => $.store.set(key, value),
58 delete: (key) => $.store.delete(key),
59 keys: () => $.store.keys(),
60 }
61}
62
63const nowIso = async ($: Api) => new Date(await $.clock.now()).toISOString()
64const redraw = ($: Api) => $.ui.invalidate('ui.render')
65
66// An unasked pane waits undrawn below 144 columns; ui.open resolves void,
67// so ask ui.panes whether it was placed and say so when it was not.
68async function show($: Api, s: Subject): Promise<void> {
69 await $.ui.open({ id: PANE, title: 'Todo' })
70 const pane = (await $.ui.panes()).find((p) => p.id === PANE)
71 if (pane && !pane.isPlaced) $.ui.toast(`${s.kind === 'ticket' ? s.key : s.title} rattaché · /todo`)
72}
73
74async function bind($: Api, key: string, make: () => Subject): Promise<void> {
75 current = await serial(async () => {
76 const existing = await loadSubject(kv($), key)
77 const s = existing ?? make()
78 if (!existing) await saveSubject(kv($), s)
79 await bindSession(kv($), sessionId, key)
80 return s
81 })
82 announced = false
83 redraw($)
84 await show($, current)
85}
86
87async function currentBranch($: Api): Promise<string | undefined> {
88 try {
89 const r = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 2_000 })
90 return r.exitCode === 0 ? r.stdout.trim() || undefined : undefined
91 } catch {
92 return undefined
93 }
94}
95
96// Restore this session's subject: its own binding, else (on /branch) the
97// parent's subject, else a dev-<n> branch.
98async function restore($: Api, source = 'startup'): Promise<void> {
99 const previous = current?.key
100 sessionId = await $.session.id()
101 const bound = await boundKey(kv($), sessionId)
102 const inherited = inheritOnFork(source, bound, previous)
103 if (inherited) await serial(() => bindSession(kv($), sessionId, inherited))
104 const key = bound ?? inherited
105 current = key ? await loadSubject(kv($), key) : undefined
106 announced = false
107 lastPromptAt = undefined
108 if (!current) {
109 const ticket = ticketFromBranch(await currentBranch($))
110 if (ticket) {
111 const now = await nowIso($)
112 await bind($, ticket, () => createSubject(ticket, 'ticket', ticket, TICKET_TEMPLATE, now))
113 }
114 }
115 redraw($)
116}
117
118async function mutate($: Api, fn: (s: Subject, now: string) => ReturnType<typeof cycleItem>) {
119 if (!current) return undefined
120 const key = current.key
121 const now = await nowIso($)
122 const out = await serial(() => mutateSubject(kv($), key, (s) => fn(s, now)))
123 if ('subject' in out) {
124 current = out.subject
125 redraw($)
126 }
127 return out
128}
129
130export function register(on: On): void {
131 on('session.start', async ($, e, next) => {
132 await $.tool.register({ name: TOOL, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
133 await $.command.register({ name: 'todo', description: 'Afficher ou masquer la todo du sujet', immediate: true })
134 await prune(kv($), await $.clock.now())
135 await restore($)
136 return next(e)
137 })
138
139 // /clear, /resume and /branch reset the session without a session.start.
140 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
141 await restore($, e.source)
142 return next(e)
143 })
144
145 on('prompt.submit', async ($, e, next) => {
146 const ticket = ticketToBind(current?.key, e.text)
147 if (ticket) {
148 const now = await nowIso($)
149 await bind($, ticket, () => createSubject(ticket, 'ticket', ticket, TICKET_TEMPLATE, now))
150 }
151 if (!current) return next(e)
152 // Pick up changes from the pane or from another session on the same subject.
153 current = (await loadSubject(kv($), current.key)) ?? current
154 const context = contextFor(current, { since: lastPromptAt, full: !announced })
155 announced = true
156 lastPromptAt = await nowIso($)
157 return next({ ...e, context: [...(e.context ?? []), context] })
158 })
159
160 on('tool.call', { tool: TOOL_FULL }, async ($, e) => {
161 const a = parseAction(e)
162 if ('error' in a) return { result: current ? `${a.error}\n\n${renderList(current)}` : a.error }
163 if (!current) {
164 if (a.action !== 'bind') {
165 return { result: 'Aucun sujet rattaché : appelle subject_todo avec action "bind" et un title.' }
166 }
167 const key = `conv:${sessionId}`
168 const now = await nowIso($)
169 await bind($, key, () => createSubject(key, 'conversation', a.title, [], now))
170 announced = true
171 return { result: renderList(current!) }
172 }
173 const out = await mutate($, (s, now) => applyAction(s, a, 'claude', now))
174 if (!out) return { result: 'Aucun sujet rattaché.' }
175 if ('error' in out) return { result: `${out.error}\n\n${renderList(current)}` }
176 return { result: renderList(out.subject) }
177 })
178
179 // A toggle: a shown pane closes; a hidden one (closed, a tab behind another,
180 // or waiting undrawn for width) comes up and takes the keys.
181 on('command.run', { command: 'todo' }, async ($) => {
182 const pane = (await $.ui.panes()).find((p) => p.id === PANE)
183 if (pane?.isShown && pane.isPlaced) await $.ui.close({ id: PANE })
184 else await $.ui.open({ id: PANE, title: 'Todo', focus: true, closeOnEscape: true })
185 return {}
186 })
187
188 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
189 if (e.requestId !== PANE) return next(e)
190 const el = $.ui.resolve(e) as unknown as Elements
191 // paneTree is typed loosely so its specs can draw with fakes; here it is
192 // drawn with the real elements, so the tree it returns is a RenderElement.
193 return paneTree(el, current, {
194 onCycle: (id) => void mutate($, (s, now) => cycleItem(s, id, now)),
195 onAdd: (text) => void mutate($, (s, now) => applyAction(s, { action: 'add', text }, 'user', now)),
196 }) as RenderElement
197 })
198}
199hooks/binding.ts 28 lines1// Which ticket a session is about. A Linear link in a prompt wins; a dev-<n>
2// branch is the fallback. Keys are upper-cased so DEV-1807 is one subject
3// whatever the spelling.
4
5const LINEAR_URL = /linear\.app\/[\w-]+\/issue\/([a-z]+-\d+)/i
6const DEV_BRANCH = /(?:^|[/_-])(dev-\d+)(?=$|[/_-])/i
7
8export function ticketFromText(text: string): string | undefined {
9 return LINEAR_URL.exec(text)?.[1]?.toUpperCase()
10}
11
12export function ticketFromBranch(branch: string | undefined): string | undefined {
13 return branch ? DEV_BRANCH.exec(branch)?.[1]?.toUpperCase() : undefined
14}
15
16// Only an unbound session binds from a prompt: a second link pasted later is
17// a reference, not a change of subject.
18export function ticketToBind(boundKey: string | undefined, text: string): string | undefined {
19 return boundKey === undefined ? ticketFromText(text) : undefined
20}
21
22// /branch forks the conversation under a new session id with no binding of its
23// own; the fork is about the same subject, so it inherits the parent's key.
24// /clear and /resume do not: one starts fresh, the other has its own binding.
25export function inheritOnFork(source: string, bound: string | undefined, previous: string | undefined): string | undefined {
26 return source === 'fork' && bound === undefined ? previous : undefined
27}
28hooks/template.ts 16 lines1// The default list for a ticket. Claude adapts it per ticket with the
2// subject_todo tool (`replace`); edit here to change the workflow itself.
3export const TICKET_TEMPLATE: readonly string[] = [
4 'Lire le ticket et comprendre le sujet',
5 'Écrire la fiche de cadrage',
6 'Relire la fiche de cadrage et la corriger si nécessaire',
7 '1ère implémentation',
8 'Préparer la QA de dev manuelle',
9 'Faire la QA de dev',
10 'Corriger les retours de QA',
11 'Ouvrir les PRs en ready-to-review',
12 'Adresser les commentaires de PRs',
13 'Merge',
14 'Nettoyer : worktrees, fiche doc, branches git, etc.',
15]
16hooks/subject.ts 170 lines1// The subject and its items, and every change Claude or the user can make.
2// Pure: every function returns a new Subject (or an error) and takes `now`
3// as an argument, so the specs control time.
4
5export type Status = 'todo' | 'doing' | 'done' | 'skipped'
6export type Actor = 'claude' | 'user'
7export type Item = { id: string; text: string; status: Status; by: Actor; changedAt: string }
8export type Subject = {
9 key: string
10 kind: 'ticket' | 'conversation'
11 title: string
12 items: Item[]
13 updatedAt: string
14}
15export type ToolAction =
16 | { action: 'bind'; title: string }
17 | { action: 'replace'; items: { text: string; status?: Status }[] }
18 | { action: 'update'; id: string; status?: Status; text?: string }
19 | { action: 'add'; text: string; after?: string }
20export type Outcome = { subject: Subject } | { error: string }
21
22export const GLYPH: Record<Status, string> = { todo: '○', doing: '▶', done: '✓', skipped: '–' }
23export const STATUS_CYCLE: Record<Status, Status> = { todo: 'doing', doing: 'done', done: 'skipped', skipped: 'todo' }
24
25// Three base-36 characters: short enough for Claude to quote, unique per subject.
26function newId(taken: ReadonlySet<string>, rand: () => number): string {
27 for (;;) {
28 const id = Math.floor(rand() * 46656).toString(36).padStart(3, '0')
29 if (!taken.has(id)) return id
30 }
31}
32
33function makeItems(texts: readonly { text: string; status?: Status; id?: string }[], actor: Actor, now: string, rand: () => number, taken = new Set<string>()): Item[] {
34 return texts.map((t) => {
35 const id = t.id ?? newId(taken, rand)
36 taken.add(id)
37 return { id, text: t.text, status: t.status ?? 'todo', by: actor, changedAt: now }
38 })
39}
40
41export function createSubject(
42 key: string,
43 kind: Subject['kind'],
44 title: string,
45 texts: readonly string[],
46 now: string,
47 rand: () => number = Math.random,
48): Subject {
49 return { key, kind, title, items: makeItems(texts.map((text) => ({ text })), 'claude', now, rand), updatedAt: now }
50}
51
52export function progress(s: Subject): { done: number; total: number } {
53 const counted = s.items.filter((i) => i.status !== 'skipped')
54 return { done: counted.filter((i) => i.status === 'done').length, total: counted.length }
55}
56
57const unknownId = (id: string): Outcome => ({ error: `Aucun item avec l'id "${id}".` })
58
59function edit(s: Subject, id: string, patch: Partial<Pick<Item, 'status' | 'text'>>, actor: Actor, now: string): Outcome {
60 if (!s.items.some((i) => i.id === id)) return unknownId(id)
61 const items = s.items.map((i) => (i.id === id ? { ...i, ...patch, by: actor, changedAt: now } : i))
62 return { subject: { ...s, items, updatedAt: now } }
63}
64
65export function cycleItem(s: Subject, id: string, now: string): Outcome {
66 const item = s.items.find((i) => i.id === id)
67 return item ? edit(s, id, { status: STATUS_CYCLE[item.status] }, 'user', now) : unknownId(id)
68}
69
70export function applyAction(s: Subject, a: ToolAction, actor: Actor, now: string, rand: () => number = Math.random): Outcome {
71 switch (a.action) {
72 case 'bind':
73 return { subject: { ...s, title: a.title, updatedAt: now } }
74 case 'update': {
75 const patch: Partial<Pick<Item, 'status' | 'text'>> = {}
76 if (a.status) patch.status = a.status
77 if (a.text) patch.text = a.text
78 return edit(s, a.id, patch, actor, now)
79 }
80 case 'add': {
81 const [item] = makeItems([{ text: a.text }], actor, now, rand, new Set(s.items.map((i) => i.id)))
82 if (a.after === undefined) return { subject: { ...s, items: [...s.items, item], updatedAt: now } }
83 const at = s.items.findIndex((i) => i.id === a.after)
84 if (at < 0) return unknownId(a.after)
85 const items = [...s.items.slice(0, at + 1), item, ...s.items.slice(at + 1)]
86 return { subject: { ...s, items, updatedAt: now } }
87 }
88 case 'replace': {
89 // Reuse the id of an existing item with the same text, so ids Claude
90 // already holds stay valid across an adaptation.
91 const byText = new Map(s.items.map((i) => [i.text, i.id]))
92 const wanted = a.items.map((t) => ({ ...t, id: byText.get(t.text) }))
93 const taken = new Set(wanted.flatMap((t) => (t.id ? [t.id] : [])))
94 return { subject: { ...s, items: makeItems(wanted, actor, now, rand, taken), updatedAt: now } }
95 }
96 }
97}
98
99export function renderList(s: Subject): string {
100 const { done, total } = progress(s)
101 const header = s.kind === 'ticket' ? `${s.key} · ${s.title} (${done}/${total})` : `${s.title} (${done}/${total})`
102 return [header, ...s.items.map((i) => `[${i.id}] ${GLYPH[i.status]} ${i.text}`)].join('\n')
103}
104
105// What Claude reads beside each prompt. Short by default; `full` adds the list
106// with ids (first prompt after a bind or a resume) and, for an untouched
107// ticket, asks Claude to adapt the template.
108export function contextFor(s: Subject, opts: { since?: string; full: boolean }): string {
109 const { done, total } = progress(s)
110 const name = s.kind === 'ticket' ? `Sujet ${s.key} : ${s.title}` : `Sujet : ${s.title}`
111 const lines = [`${name} (${done}/${total}).`]
112 const doing = s.items.filter((i) => i.status === 'doing').map((i) => i.text)
113 if (doing.length > 0) lines.push(`En cours : ${doing.join(', ')}.`)
114 const since = opts.since
115 const byUser = since === undefined ? [] : s.items.filter((i) => i.by === 'user' && i.changedAt > since)
116 if (byUser.length > 0) {
117 lines.push(`Changé par l'utilisateur depuis le panneau : ${byUser.map((i) => `${i.text} (${i.status})`).join(', ')}.`)
118 }
119 lines.push("Tiens la liste à jour avec l'outil subject_todo (update, add, replace) au fil du travail.")
120 if (opts.full) {
121 const untouched = s.kind === 'ticket' && s.items.every((i) => i.status === 'todo' && i.by === 'claude')
122 if (untouched) {
123 lines.push("C'est le modèle par défaut : adapte-le à ce ticket avec replace (passe en skipped ce qui ne s'applique pas, ajoute ce qui manque).")
124 }
125 lines.push('', renderList(s))
126 }
127 return lines.join('\n')
128}
129
130const STATUSES: readonly string[] = ['todo', 'doing', 'done', 'skipped']
131const isStatus = (v: unknown): v is Status => typeof v === 'string' && STATUSES.includes(v)
132const nonEmpty = (v: unknown): v is string => typeof v === 'string' && v.trim() !== ''
133
134// The tool's input is model output: check each action's own fields before it
135// reaches the store, so a malformed call is an error Claude can read and retry,
136// never a subject saved with an undefined title or text.
137export function parseAction(input: unknown): ToolAction | { error: string } {
138 const a = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
139 switch (a.action) {
140 case 'bind':
141 return nonEmpty(a.title) ? { action: 'bind', title: a.title.trim() } : { error: 'bind demande un title non vide.' }
142 case 'add':
143 if (!nonEmpty(a.text)) return { error: 'add demande un text non vide.' }
144 return typeof a.after === 'string' ? { action: 'add', text: a.text.trim(), after: a.after } : { action: 'add', text: a.text.trim() }
145 case 'update': {
146 if (!nonEmpty(a.id)) return { error: 'update demande un id.' }
147 if (a.status === undefined && a.text === undefined) return { error: 'update demande un status ou un text.' }
148 if (a.status !== undefined && !isStatus(a.status)) return { error: `status invalide : ${String(a.status)}.` }
149 if (a.text !== undefined && !nonEmpty(a.text)) return { error: 'update : text ne peut pas être vide.' }
150 const out: ToolAction = { action: 'update', id: a.id }
151 if (a.status !== undefined) out.status = a.status
152 if (nonEmpty(a.text)) out.text = a.text.trim()
153 return out
154 }
155 case 'replace': {
156 if (!Array.isArray(a.items) || a.items.length === 0) return { error: 'replace demande une liste items non vide.' }
157 const items: { text: string; status?: Status }[] = []
158 for (const it of a.items as unknown[]) {
159 const o = (typeof it === 'object' && it !== null ? it : {}) as Record<string, unknown>
160 if (!nonEmpty(o.text)) return { error: 'replace : chaque item demande un text non vide.' }
161 if (o.status !== undefined && !isStatus(o.status)) return { error: `status invalide : ${String(o.status)}.` }
162 items.push(o.status !== undefined ? { text: o.text.trim(), status: o.status } : { text: o.text.trim() })
163 }
164 return { action: 'replace', items }
165 }
166 default:
167 return { error: `action inconnue : ${String(a.action)}. Actions : bind, replace, update, add.` }
168 }
169}
170hooks/store.ts 101 lines1// Subjects and session bindings in $.store, which every session on the machine
2// shares. get-then-set is not atomic, so every change re-reads the subject
3// right before writing it, and each subject has its own key.
4
5import type { Outcome, Subject } from './subject.ts'
6
7export interface KV {
8 get(key: string): Promise<unknown>
9 set(key: string, value: unknown): Promise<void>
10 delete(key: string): Promise<void>
11 keys(): Promise<string[]>
12}
13
14const SUBJECT = 'subject:'
15const SESSION = 'session:'
16const DAY_MS = 86_400_000
17
18const STATUSES: readonly unknown[] = ['todo', 'doing', 'done', 'skipped']
19
20function isItem(v: unknown): boolean {
21 if (typeof v !== 'object' || v === null) return false
22 const i = v as Record<string, unknown>
23 return (
24 typeof i.id === 'string' && typeof i.text === 'string' && STATUSES.includes(i.status) &&
25 (i.by === 'claude' || i.by === 'user') && typeof i.changedAt === 'string'
26 )
27}
28
29// Checks every field the pane and the context text read, so a corrupt or
30// foreign value reads as "no subject" rather than throwing on every prompt.
31function isSubject(v: unknown): v is Subject {
32 if (typeof v !== 'object' || v === null) return false
33 const s = v as Record<string, unknown>
34 return (
35 typeof s.key === 'string' && typeof s.title === 'string' && typeof s.updatedAt === 'string' &&
36 (s.kind === 'ticket' || s.kind === 'conversation') && Array.isArray(s.items) && s.items.every(isItem)
37 )
38}
39
40export async function loadSubject(kv: KV, key: string): Promise<Subject | undefined> {
41 const v = await kv.get(SUBJECT + key)
42 return isSubject(v) ? v : undefined
43}
44
45export async function saveSubject(kv: KV, s: Subject): Promise<void> {
46 await kv.set(SUBJECT + s.key, s)
47}
48
49export async function mutateSubject(kv: KV, key: string, fn: (s: Subject) => Outcome): Promise<Outcome> {
50 const fresh = await loadSubject(kv, key)
51 if (!fresh) return { error: `Le sujet ${key} est introuvable.` }
52 const out = fn(fresh)
53 if ('subject' in out) await saveSubject(kv, out.subject)
54 return out
55}
56
57export async function bindSession(kv: KV, sessionId: string, key: string): Promise<void> {
58 await kv.set(SESSION + sessionId, key)
59}
60
61export async function boundKey(kv: KV, sessionId: string): Promise<string | undefined> {
62 const v = await kv.get(SESSION + sessionId)
63 return typeof v === 'string' ? v : undefined
64}
65
66// Conversation subjects untouched for maxAgeMs go, then every binding whose
67// subject no longer exists. Ticket subjects are kept. Returns how many keys went.
68export async function prune(kv: KV, now: number, maxAgeMs = 30 * DAY_MS): Promise<number> {
69 let removed = 0
70 const keys = await kv.keys()
71 const live = new Set<string>()
72 for (const k of keys.filter((k) => k.startsWith(SUBJECT))) {
73 const s = await kv.get(k)
74 const stale = isSubject(s) && s.kind === 'conversation' && now - Date.parse(s.updatedAt) > maxAgeMs
75 if (stale) {
76 await kv.delete(k)
77 removed++
78 } else live.add(k.slice(SUBJECT.length))
79 }
80 for (const k of keys.filter((k) => k.startsWith(SESSION))) {
81 const target = await kv.get(k)
82 if (typeof target !== 'string' || !live.has(target)) {
83 await kv.delete(k)
84 removed++
85 }
86 }
87 return removed
88}
89
90// get-compute-set is async, so two changes from this one process (a pane press
91// during a tool call, two quick presses) can interleave and the second write
92// drops the first. Every mutation in register.ts goes through one queue.
93export function serialQueue(): <T>(job: () => Promise<T>) => Promise<T> {
94 let tail: Promise<unknown> = Promise.resolve()
95 return <T>(job: () => Promise<T>): Promise<T> => {
96 const run = tail.then(job, job)
97 tail = run.catch(() => undefined)
98 return run
99 }
100}
101hooks/pane.ts 67 lines1// The pane's element tree. Takes the element factories from $.ui.resolve(e)
2// as an argument, so the specs can draw it with fakes and press its buttons.
3
4import { GLYPH, progress, type Status, type Subject } from './subject.ts'
5
6type Factory = (props: Record<string, unknown>) => unknown
7export type Elements = { Box: Factory; Text: Factory; Button: Factory; Input: Factory }
8export type PaneHandlers = { onCycle(id: string): void; onAdd(text: string): void }
9
10// Theme slots only. Button takes no colour, so the glyph is a Text beside it.
11const GLYPH_COLOR: Record<Status, string | undefined> = {
12 todo: undefined, doing: 'yellow', done: 'green', skipped: 'gray',
13}
14
15export function paneTree(el: Elements, s: Subject | undefined, h: PaneHandlers): unknown {
16 const { Box, Text, Button, Input } = el
17 if (!s) {
18 return Text({
19 dimColor: true,
20 children: ['Aucun sujet rattaché. Colle le lien du ticket Linear, ou présente le sujet à Claude.'],
21 })
22 }
23 const { done, total } = progress(s)
24 const title = s.kind === 'ticket' ? `${s.key} · ${s.title}` : s.title
25
26 const header = Box({
27 flexDirection: 'row',
28 justifyContent: 'space-between',
29 children: [
30 Text({ bold: true, wrap: 'truncate-end', children: [title] }),
31 Text({ dimColor: true, children: [`${done}/${total}`] }),
32 ],
33 })
34
35 const rows = s.items.map((i) =>
36 Box({
37 key: `row-${i.id}`,
38 flexDirection: 'row',
39 columnGap: 1,
40 children: [
41 Text({ color: GLYPH_COLOR[i.status], dimColor: i.status === 'skipped', children: [GLYPH[i.status]] }),
42 Button({
43 key: `item-${i.id}`,
44 label: i.text,
45 plain: true,
46 dimColor: i.status === 'skipped',
47 onPress: () => h.onCycle(i.id),
48 }),
49 ],
50 }),
51 )
52
53 const add = Input({
54 key: 'add',
55 label: 'Ajouter',
56 placeholder: 'nouvelle étape, Entrée',
57 value: '',
58 submitLabel: 'add',
59 onSubmit: (value: string) => {
60 const text = value.trim()
61 if (text) h.onAdd(text)
62 },
63 })
64
65 return Box({ flexDirection: 'column', children: [header, ...rows, add] })
66}
67