Bridges Claude Code to the towles-tool desktop app: its loopback-HTTP MCP server for board tasks, the Preview pane and calendar, plus a mod that shows this…

Bridges Claude Code to the towles-tool desktop app (tt-app) — the MCP tools it exposes and one hook that keeps its PR view fresh.
Points at the desktop app's own MCP server (crates/tt-mcp, served over loopback HTTP by tt-app), so any session with this plugin enabled gets these tools without manual claude mcp add setup:
task_list, task_status (reads), task_create, task_summary, task_sync, task_start, task_delete and task_done (writes). task_create adds a board card only; task_start is what turns one into work in progress — it mints the task's git worktree and launches a Claude session in it on the task's goal and notes. It answers status: "starting" because the worktree and agent come up asynchronously in the app, so confirm with task_list rather than assuming. task_delete removes the task's panes and git worktree along with its board row, and refuses — deleting nothing — when the worktree still holds uncommitted or unlanded work. task_summary is the last thing an agent finishing a task should call: it writes the wrap-up onto the card, which outlives the worktree and its terminal scrollback, so the record is still there when the user comes to confirm the work. It records only — closing the task and removing the worktree stay the user's call. task_sync records the branch a task's worktree is on now — call it after a git switch -c inside the task, so the card and PR auto-linking follow the new branch.preview_file. Puts a file you wrote on screen in the app's Preview pane, beside the terminal you're running in: the way to hand back something worth looking at — a plan laid out for a decision, a table of what a sweep found, a diagram — rather than as terminal output. Any file works: Markdown renders as prose, a self-contained .html artifact as the page it is (in a sandboxed frame, so inline the CSS/JS and embed images as data: URIs), anything else as text. Call it with the absolute path. The pane hot-reloads — rewrite the file and what's on screen follows, no second call. Write it wherever you like, a scratch dir outside the repo included: the pane opens in your terminal's task, because the request carries the TT_SESSION_ID of the shell you're running in (see .mcp.json below), not because of where the file sits. For a file that already exists rather than a page you wrote, use file_open instead — same pane routing, but it reveals the file in the Files pane (the tt open CLI command is the same call). The user can annotate what you showed and send it straight back to you.needs_you. The app's other sessions waiting on the user (a question, an error, a finished turn not yet looked at), oldest first, with the caller's own terminal left out. The same state the rail badges. And summon, the other direction: chime until the user types into your terminal, for when you need them to do something only they can.calendar_today, calendar_next (reads) and calendar_set (writes). These exist for focus protection — how long until the next meeting, how much uninterrupted time is left — not calendar management.inbox_take, inbox_ack, next_prompt and next_prompt_taken serve the mod below. The mod denies the model any call to them, so an agent never reads another's notes or spends the suggestion budget.The broader dashboard-read tools were pruned in the 2026-07 tool-surface review.
Requires the app to be running. There is no headless fallback: the server lives in tt-app, so app closed means MCP down. Every instance serves its own, on its own port, and a session started in an app terminal reaches the app that spawned it — so with several worktree tasks open, each one's tools act on that task's app and its board.
No token: the endpoint is loopback-only and refuses any request carrying an Origin header or a non-JSON Content-Type, which is what keeps a web page you visit from POSTing to it. That is the whole guard on writes — there is no capability gate any more. See the trust-boundary doc in crates/tt-mcp.
The checked-in .mcp.json never needs editing per checkout, because both the port and the caller's identity ride the environment:
"url": "http://127.0.0.1:${TT_MCP_PORT:-47870}/mcp",
"headers": { "X-TT-Session": "${TT_SESSION_ID:-}" }
TT_MCP_PORT is the app instance's own port — a ${tt:port 47870-48069} claim in its rendered .env, stamped onto every terminal it spawns. So a session in a worktree task's terminal reaches that task's app and board, not whichever instance started first. Outside an app terminal it's unset and the :-47870 default applies. A packaged app in no checkout falls back to "mcp": {"port": N} in the shared settings file.
TT_SESSION_ID is stamped by the app on every terminal it spawns, and Claude Code expands ${VAR:-default} in .mcp.json headers — so each session identifies its own terminal without the model having to know or pass anything. preview_file and file_open route on it. Outside an app terminal the variable is unset, the header arrives empty, and the tool falls back to matching the file's path against tracked folders. It is not a credential and grants nothing: request admission (loopback + no Origin + JSON Content-Type) is still the whole guard.
task-onboarding — walks a repo through adopting tt worktree tasks: discover what the repo needs per-task (dev ports, docker names, setup), pick ${tt:port A-B} pools that don't overlap other onboarded repos, write the tokenized .env.example (or .claude/task-env.template sidecar), then run the mechanical half with tt task init and verify with a smoke task. Triggers on "onboard this repo for tasks" / "set up tt tasks" / a repo whose tasks render an empty .env but need per-task ports. (Tasks work without any template — onboarding is only for repos that need ports/env vars templated per task.)towles-tool — tt CLI reference: journaling and worktree-task commands. Triggers on "tt commands", "daily notes", "meeting notes", or worktree management./towles-tool-app:done: run once you've confirmed the work. It runs /tt:prm (open and merge the PR, admin merge allowed), then calls task_done, which finds the task from the checkout, refuses while a PR is open or work is unlanded or uncommitted, stops the task's own dev server, writes the summary and deletes the worktree and pane. Outside a task it lands the work and reports there was no task to delete. Needs the tt plugin.hooks/register.tsx is a Claude Code mod (a function-hooks module, named under modules in hooks/hooks.json). It carries the app's view of the session, and the user's words from the app, into Claude Code itself.
✦ tt #7 Claude mods (doing) PR #42 open · checks passing ready for /done 4m to Standup n: next prompt
tt task checkout sees its own card. If worktrees are nested, the deepest match wins./towles-tool-app:done needs to land it.The same facts go on the status line too, in short form (tt #7 · PR #42 passing · ready for /done · 4m to Standup), alongside the band.
The data comes from this plugin's own MCP server (task_list, calendar_next) via $.mcp.call. It refreshes at session start, after every turn, and every 60s. App closed means MCP down, so the band and status line hide instead of showing stale state.
A note sent from the app (a session row's Note to agent…, Mod+Shift+M, or annotated feedback from the Preview or Chrome pane) reaches the agent without being typed into its terminal. The mod polls inbox_take every 3 s and after each model step:
Either way a dim transcript line says so, and inbox_ack tells the app, which marks the session answered as typing into it would. A session without this mod cannot take a note; the app says so rather than type into its prompt box.
/park and /next/park [idea] files the idea (or, with no argument, the text you selected) as a backlog task on this checkout's board, through task_create. The session never leaves what it is doing./next, or n on the band, asks what you will type next: one claude -p call with no tools, MCP, settings or CLAUDE.md, given the last turn's tools and commands, its answer, the task's goal and the prompts you typed after similar turns. The answer is shown as the dim suggestion, Tab to take. It never runs on its own, and the app holds it to a daily budget (Settings → Agent Board → Next-prompt suggestion).The $.state contract is types/index.d.ts, and the pure logic in hooks/band.ts, hooks/inbox.ts, hooks/park.ts and hooks/suggest.ts is what tests/ covers:
claude plugin validate packages/app
claude plugin test packages/app
| Hook | Event | Does… |
|---|---|---|
hooks/scripts/gh-pr-nudge.sh | PostToolUse (Bash) | After a gh pr mutation (merge/create/close/reopen/ready) or a gh issue mutation (create/close/reopen), nudges a running tt-app instance to refresh the matching data immediately instead of waiting for its normal poll interval (tt task nudge prs/tt task nudge issues). |
The hook is a no-op unless the session looks towles-tool-relevant — either it's running inside a terminal the app itself spawned (TT_SESSION_ID/ TT_APP_INSTANCE set), or its working directory is inside a checkout on the Agent Board (a tracked repo, or any worktree under one). That test is tt task nudge --only-if-tracked's, not the script's: only tt can read the tracked set, and a shell approximation of it recognised this repo alone, so gh pr create in every other checkout was dropped in silence. This plugin is meant to be enabled globally, so without the test the hook would fire — and make every open window sweep gh — for unrelated projects. A skip lands in the event log as hook.nudge outcome=not_tracked. It also does nothing if no towles-tool app is running; the nudge is picked up on the app's next start otherwise. When the session is relevant and tt exists but the nudge command itself fails (say, an installed tt older than this plugin), the hook reports that to the session instead of staying silent.
In Claude Code 2.1.275 or later, one step:
/plugin install towles-tool-app --marketplace ChrisTowles/towles-tool
Or from a shell:
claude plugin marketplace add ChrisTowles/towles-tool
claude plugin enable towles-tool-app@towles-toolhooks/register.tsx 290 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, McpToolResult, Register } from 'claude-code'
3
4import type { InboxNote, Snapshot } from '../types'
5import {
6 SERVER,
7 checksToast,
8 meetingFrom,
9 prLabel,
10 readyForDone,
11 shouldWarn,
12 statusLine,
13 taskFor,
14 untilLabel,
15} from './band'
16import { type Appended, midTurnText, promptText, remember, stranded, unseen } from './inbox'
17import { parkArgs } from './park'
18import { emptyLog, logTool, taken, withAnswer } from './suggest'
19
20const snapshot = atom({ plugin: 'towles-tool-app', key: 'snapshot' } as const, null)
21const warnedMeeting = atom({ plugin: 'towles-tool-app', key: 'warnedMeeting' } as const, null)
22const seenChecks = atom({ plugin: 'towles-tool-app', key: 'seenChecks' } as const, null)
23const seenNotes = atom({ plugin: 'towles-tool-app', key: 'seenNotes' } as const, [])
24const lastTurn = atom({ plugin: 'towles-tool-app', key: 'lastTurn' } as const, null)
25const suggestion = atom({ plugin: 'towles-tool-app', key: 'suggestion' } as const, null)
26const asking = atom({ plugin: 'towles-tool-app', key: 'asking' } as const, false)
27
28const REFRESH_MS = 60_000
29const INBOX_MS = 3_000
30const SUGGEST_DELAY_MS = 250
31/** The tools only this mod calls: the model is kept off them. */
32const MOD_ONLY = /^mcp__plugin_towles-tool-app_towles-tool__(inbox_|next_prompt)/
33
34/** A tool's result as JSON: the structured result when the server declares one, else its text. */
35function payload(result: McpToolResult): unknown {
36 if (result.isError) throw new Error('tool failed')
37 if (result.structuredContent !== undefined) return result.structuredContent
38 const block = result.content.find(one => one.type === 'text')
39 return JSON.parse(block && 'text' in block && block.text ? block.text : 'null')
40}
41
42async function call<T>($: EngineInterface, tool: string, args?: Record<string, unknown>) {
43 return payload(await $.mcp.call(SERVER, tool, args)) as T
44}
45
46/** App closed means MCP down: the band and status line then hide rather than show stale state. */
47async function refresh($: EngineInterface) {
48 let next: Snapshot | null
49 try {
50 const [cwd, tasks, upcoming] = await Promise.all([
51 $.session.cwd(),
52 call<{ tasks: Parameters<typeof taskFor>[1] }>($, 'task_list'),
53 call<Parameters<typeof meetingFrom>[0]>($, 'calendar_next'),
54 ])
55 next = { task: taskFor(cwd, tasks.tasks), meeting: meetingFrom(upcoming) }
56 } catch {
57 next = null
58 }
59 await update($, snapshot, () => next)
60 $.ui.status(statusLine(next))
61 if (!next) return
62
63 const pr = next.task?.pr ?? null
64 const ci = checksToast(pr, await read($, seenChecks))
65 await update($, seenChecks, () => (pr ? { pr: pr.number, checks: pr.checks } : null))
66 if (ci) $.ui.toast(ci)
67
68 if (shouldWarn(next, await read($, warnedMeeting))) {
69 const m = next.meeting!
70 await update($, warnedMeeting, () => m.id)
71 $.ui.toast(`${m.title} in ${m.minutesUntil}m. Time to wrap up.`)
72 }
73}
74
75/** The main loop's turn as this module saw it; a reload starts it over, mid-turn or not. */
76let busy = false
77let step = -1
78let appended: (Appended & { notes: InboxNote[] })[] = []
79let log = emptyLog()
80let taking = false
81let inboxTimer: { cancel: () => void } | null = null
82
83function preview(notes: readonly InboxNote[]): string {
84 const first = notes[0]?.body.split('\n')[0] ?? ''
85 return first.length > 60 ? `${first.slice(0, 59)}…` : first
86}
87
88async function ack($: EngineInterface, ids: number[], how: 'midTurn' | 'prompt') {
89 try {
90 await call($, 'inbox_ack', { ids, how })
91 } catch {
92 // The lease lapses and the app offers the note again; `seenNotes` keeps it from landing twice.
93 }
94}
95
96async function submitNotes($: EngineInterface, notes: InboxNote[]) {
97 await $.prompt.submit({ text: promptText(notes), asUser: true })
98 $.ui.log(`✉ note from the tt app, sent as your prompt: ${preview(notes)}`)
99 await ack($, notes.map(n => n.id), 'prompt')
100}
101
102/** Mid-turn the note joins the conversation for the model's next step; at rest it is the prompt. */
103async function takeInbox($: EngineInterface) {
104 if (taking) return
105 taking = true
106 try {
107 const taken = await call<{ notes: InboxNote[]; pollMs: number }>($, 'inbox_take')
108 if (taken.pollMs === 0) {
109 inboxTimer?.cancel()
110 inboxTimer = null
111 }
112 const fresh = unseen(taken.notes, await read($, seenNotes))
113 if (!fresh.length) return
114 const ids = fresh.map(n => n.id)
115 await update($, seenNotes, seen => remember(seen, ids))
116 if (busy) {
117 const text = midTurnText(fresh)
118 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
119 appended.push({ ids, text, step, notes: fresh })
120 $.ui.log(`✉ note from the tt app, read at the agent's next step: ${preview(fresh)}`)
121 } else {
122 await submitNotes($, fresh)
123 }
124 } catch {
125 // App closed: the next poll tries again.
126 } finally {
127 taking = false
128 }
129}
130
131async function askNext($: EngineInterface): Promise<string> {
132 const turn = await read($, lastTurn)
133 if (!turn) return 'Nothing to go on yet: /next suggests after a turn has finished.'
134 if (await read($, asking)) return 'Already asking.'
135 await update($, asking, () => true)
136 try {
137 const task = (await read($, snapshot))?.task
138 const answer = await call<{ prompt: string | null; costUsd: number | null; budgetLeftUsd: number }>(
139 $,
140 'next_prompt',
141 { ...turn, ...(task ? { taskId: task.id } : {}) },
142 )
143 if (!answer.prompt) {
144 return answer.budgetLeftUsd <= 0
145 ? "Today's next-prompt budget is spent; raise it in the app's settings."
146 : 'No confident guess this time.'
147 }
148 const text = answer.prompt
149 await update($, suggestion, () => text)
150 // Proposed once the command's own output is drawn: while it runs the box refuses one.
151 $.clock.after(SUGGEST_DELAY_MS, () => void $.prompt.suggest({ text }))
152 return `Next prompt, Tab to take it: ${text} ($${answer.budgetLeftUsd.toFixed(2)} left today)`
153 } catch {
154 return 'The tt app is not reachable, so there is no suggestion.'
155 } finally {
156 await update($, asking, () => false)
157 }
158}
159
160async function park($: EngineInterface, args: string): Promise<string> {
161 const idea = args.trim() || (await $.ui.selection())?.text || ''
162 const task = (await read($, snapshot))?.task ?? null
163 const request = parkArgs(await $.session.cwd(), idea, task)
164 if (!request) return 'Nothing to park: type /park <idea>, or select the text first.'
165 try {
166 const created = await call<{ task: { id: number } }>($, 'task_create', request)
167 return `Parked as #${created.task.id} in the backlog: ${request.title}`
168 } catch {
169 return 'Not parked: the tt app is not reachable, or this checkout is not one it tracks.'
170 }
171}
172
173export const register: Register = on => {
174 on('session.start', async ($, e, next) => {
175 const ran = await next(e)
176 await refresh($)
177 $.clock.every(REFRESH_MS, () => void refresh($))
178 inboxTimer = $.clock.every(INBOX_MS, () => void takeInbox($))
179 // Last, so a host that registers no commands still gets the band and the inbox.
180 await $.command.register({
181 name: 'park',
182 description: 'Park an idea as a backlog task on the tt board, without leaving this session',
183 argumentHint: '[idea, or select it first]',
184 })
185 await $.command.register({
186 name: 'next',
187 description: 'Suggest your next prompt (one small model call, within your daily budget)',
188 })
189 return ran
190 })
191
192 on('command.run', { command: 'park' }, async ($, e) => ({ text: await park($, e.args) }))
193 on('command.run', { command: 'next' }, async $ => ({ text: await askNext($) }))
194
195 // `$.mcp.call` rides `tool.call` too, so the mod's own calls are told apart by their origin.
196 on('tool.call', { tool: MOD_ONLY }, ($, e, next) =>
197 next.origin.plugin === 'towles-tool-app'
198 ? next(e)
199 : { deny: 'This tool belongs to the tt Claude mod; agents never call it.' },
200 )
201
202 on('tool.call', async ($, e, next) => {
203 if (busy && e.agentId === undefined) {
204 log = logTool(log, e.tool, (e as { command?: unknown }).command)
205 }
206 return next(e)
207 })
208
209 on('prompt.submit', async ($, e, next) => {
210 try {
211 const offered = await read($, suggestion)
212 if (offered && e.origin.kind !== 'plugin') {
213 const how = taken(e.text, offered)
214 await update($, suggestion, () => null)
215 if (how) void call($, 'next_prompt_taken', { edited: how === 'edited' }).catch(() => {})
216 }
217 } catch {
218 // Bookkeeping only: the prompt goes through whatever happened here.
219 }
220 return next(e)
221 })
222
223 on('turn.start', async ($, e, next) => {
224 if (e.agentId === undefined) {
225 busy = true
226 step = -1
227 appended = []
228 log = emptyLog()
229 }
230 return next(e)
231 })
232
233 on('turn.step', async function* ($, e, next) {
234 if (e.agentId === undefined) {
235 step = e.index
236 // This step's request carries every note appended before it: those were read.
237 const read = appended.filter(a => a.step < e.index)
238 appended = appended.filter(a => a.step >= e.index)
239 for (const a of read) void ack($, a.ids, 'midTurn')
240 void takeInbox($)
241 }
242 return yield* next(e)
243 })
244
245 on('turn.complete', async ($, e, next) => {
246 const ran = await next(e)
247 if (e.agentId !== undefined) return ran
248 busy = false
249 const missed = stranded(appended, step)
250 appended = []
251 await update($, lastTurn, () => withAnswer(log, e.answer))
252 for (const a of missed) await submitNotes($, a.notes)
253 await refresh($)
254 void takeInbox($)
255 return ran
256 })
257
258 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259 const snap = await read($, snapshot)
260 if (e.props.hasSurvey || !snap || (!snap.task && !snap.meeting)) return next(e)
261
262 const { Box, Text, Button } = $.ui.resolve(e)
263 const { task, meeting } = snap
264 const soon = !!meeting && (meeting.live || meeting.minutesUntil <= 15)
265 const canSuggest = !e.props.isWorking && !!(await read($, lastTurn))
266 return (
267 <Box flexDirection="row" gap={1}>
268 <Text color="magenta">✦ tt</Text>
269 {task && (
270 <Text wrap="truncate-end">
271 #{task.id} {task.text} <Text dimColor>({task.status})</Text>
272 </Text>
273 )}
274 {task?.pr && <Text dimColor>{prLabel(task.pr)}</Text>}
275 {readyForDone(task) && <Text color="green">ready for /done</Text>}
276 {meeting && (
277 <Text color={soon ? 'yellow' : undefined} dimColor={!soon}>
278 {untilLabel(meeting)}
279 </Text>
280 )}
281 {canSuggest && (
282 <Button key="next" hotkey="n" plain dimColor onPress={() => void askNext($)}>
283 next prompt
284 </Button>
285 )}
286 </Box>
287 )
288 })
289}
290hooks/band.ts 87 lines1import type { BandMeeting, BandTask, SeenChecks, Snapshot } from '../types'
2
3/** The app's MCP server, as `/mcp` lists it once this plugin is enabled. */
4export const SERVER = 'plugin:towles-tool-app:towles-tool'
5
6export const WARN_MINUTES = 5
7
8type RawTask = {
9 id: number
10 text: string
11 status: string
12 worktree?: { dir?: string }
13 prs?: { number: number; state: string; checks: string }[]
14}
15
16type RawNext = {
17 event: { id: number; title: string } | null
18 minutesUntil?: number
19 live?: boolean
20}
21
22/** The open task whose worktree holds `cwd`; the deepest wins when worktrees nest. */
23export function taskFor(cwd: string, tasks: RawTask[]): BandTask | null {
24 const holds = (dir: string) => cwd === dir || cwd.startsWith(`${dir}/`)
25 const match = tasks
26 .filter(t => t.worktree?.dir && holds(t.worktree.dir))
27 .sort((a, b) => (b.worktree?.dir?.length ?? 0) - (a.worktree?.dir?.length ?? 0))[0]
28 if (!match) return null
29 const pr = match.prs?.find(p => p.state === 'open') ?? match.prs?.at(-1) ?? null
30 return { id: match.id, text: match.text, status: match.status, pr }
31}
32
33export function meetingFrom(next: RawNext): BandMeeting | null {
34 if (!next.event || next.minutesUntil === undefined) return null
35 return {
36 id: next.event.id,
37 title: next.event.title,
38 minutesUntil: next.minutesUntil,
39 live: next.live ?? false,
40 }
41}
42
43export function untilLabel(m: BandMeeting): string {
44 if (m.live) return `in ${m.title} now`
45 if (m.minutesUntil < 60) return `${m.minutesUntil}m to ${m.title}`
46 const h = Math.floor(m.minutesUntil / 60)
47 const rest = m.minutesUntil % 60
48 return `${h}h${rest ? ` ${rest}m` : ''} to ${m.title}`
49}
50
51export function prLabel(pr: NonNullable<BandTask['pr']>): string {
52 const checks = pr.checks ? ` · checks ${pr.checks}` : ''
53 return `PR #${pr.number} ${pr.state}${checks}`
54}
55
56/** Whether the snapshot's meeting should raise the one-time heads-up toast. */
57export function shouldWarn(snapshot: Snapshot, warned: number | null): boolean {
58 const m = snapshot.meeting
59 return !!m && !m.live && m.minutesUntil <= WARN_MINUTES && m.id !== warned
60}
61
62/** The toast for a PR whose checks settled since last seen; `null` on first sight or no change. */
63export function checksToast(pr: BandTask['pr'], seen: SeenChecks | null): string | null {
64 if (!pr || seen?.pr !== pr.number || seen.checks === pr.checks) return null
65 if (pr.checks === 'failing') return `PR #${pr.number} checks failing`
66 if (pr.checks === 'passing') return `PR #${pr.number} checks passing`
67 return null
68}
69
70/** Open and green: what `/towles-tool-app:done` needs to land it. */
71export function readyForDone(task: BandTask | null): boolean {
72 return task?.pr?.state === 'open' && task.pr.checks === 'passing'
73}
74
75/** The band's facts on one short line, for the status line. */
76export function statusLine(snap: Snapshot | null): string | undefined {
77 if (!snap) return undefined
78 const { task, meeting } = snap
79 const parts = [
80 task && `#${task.id}`,
81 task?.pr && `PR #${task.pr.number} ${task.pr.checks}`,
82 readyForDone(task) && 'ready for /done',
83 meeting && untilLabel(meeting),
84 ].filter(Boolean)
85 return parts.length ? `tt ${parts.join(' · ')}` : undefined
86}
87hooks/inbox.ts 38 lines1import type { InboxNote } from '../types'
2
3/** Delivered ids kept, so a note re-offered after a lapsed lease is not handed over twice. */
4export const SEEN_KEEP = 50
5
6/** How a note reads to the model mid-turn: framed, since it arrives between tool calls. */
7export const MID_TURN_LEAD = 'A note from the user, sent from the tt app while you work:'
8
9function withImages(body: string, images: readonly string[]): string {
10 const paths = images.map(path => `Attached image, read it first: ${path}`)
11 return [body, ...paths].filter(Boolean).join('\n')
12}
13
14/** The notes as the user's own prompt, for a session at rest. */
15export function promptText(notes: readonly InboxNote[]): string {
16 return notes.map(n => withImages(n.body, n.images)).join('\n\n')
17}
18
19export function midTurnText(notes: readonly InboxNote[]): string {
20 return `${MID_TURN_LEAD}\n\n${promptText(notes)}`
21}
22
23export function unseen(notes: readonly InboxNote[], seen: readonly number[]): InboxNote[] {
24 return notes.filter(n => !seen.includes(n.id))
25}
26
27export function remember(seen: readonly number[], ids: readonly number[]): number[] {
28 return [...seen, ...ids].slice(-SEEN_KEEP)
29}
30
31/** A note appended during a turn's step is read by the step after it, so one appended
32 * during the turn's last step was never read; it goes again as a prompt. */
33export type Appended = { ids: number[]; text: string; step: number }
34
35export function stranded<T extends Appended>(appended: readonly T[], lastStep: number): T[] {
36 return appended.filter(a => a.step >= lastStep)
37}
38hooks/park.ts 31 lines1const TITLE_MAX = 80
2
3export type ParkArgs = {
4 dir: string
5 title: string
6 goal: string
7 notes?: string
8 status: 'backlog'
9 origin: 'park'
10}
11
12/** The board task `/park` files: the idea's first line as its title, all of it as the goal. */
13export function parkArgs(
14 cwd: string,
15 text: string,
16 from: { id: number; text: string } | null,
17): ParkArgs | null {
18 const goal = text.trim()
19 if (!goal) return null
20 const first = (goal.split('\n')[0] ?? '').trim()
21 const title = first.length > TITLE_MAX ? `${first.slice(0, TITLE_MAX - 1)}…` : first
22 return {
23 dir: cwd,
24 title,
25 goal,
26 ...(from ? { notes: `Parked from #${from.id} ${from.text}` } : {}),
27 status: 'backlog',
28 origin: 'park',
29 }
30}
31hooks/suggest.ts 33 lines1import type { TurnLog } from '../types'
2
3const TOOLS_MAX = 40
4const COMMANDS_MAX = 20
5const COMMAND_CHARS = 200
6const ANSWER_CHARS = 2000
7
8export const emptyLog = (): TurnLog => ({ tools: [], commands: [], answer: '' })
9
10/** One tool call the model made this turn, as the suggestion question reads it. */
11export function logTool(log: TurnLog, tool: string, command: unknown): TurnLog {
12 const tools = log.tools.length < TOOLS_MAX ? [...log.tools, tool] : log.tools
13 const commands =
14 tool === 'Bash' && typeof command === 'string' && log.commands.length < COMMANDS_MAX
15 ? [...log.commands, command.slice(0, COMMAND_CHARS)]
16 : log.commands
17 return { ...log, tools, commands }
18}
19
20export function withAnswer(log: TurnLog, answer: string): TurnLog {
21 return { ...log, answer: answer.slice(-ANSWER_CHARS) }
22}
23
24/** Whether the prompt sent was the suggestion: as shown, edited first, or not at all. */
25export function taken(sent: string, suggestion: string | null): 'as-is' | 'edited' | null {
26 if (!suggestion) return null
27 const a = sent.trim()
28 const b = suggestion.trim()
29 if (a === b) return 'as-is'
30 const head = b.slice(0, Math.min(20, b.length))
31 return head.length >= 8 && a.startsWith(head) ? 'edited' : null
32}
33types/index.d.ts 38 lines1/** What the band shows: this checkout's board task and the next meeting, as the app reports them. */
2export type BandTask = {
3 id: number
4 text: string
5 status: string
6 pr: { number: number; state: string; checks: string } | null
7}
8
9export type BandMeeting = { id: number; title: string; minutesUntil: number; live: boolean }
10
11export type Snapshot = { task: BandTask | null; meeting: BandMeeting | null }
12
13/** A PR's check state as last seen, so a change raises one toast. */
14export type SeenChecks = { pr: number; checks: string }
15
16/** A note the user sent this session from the app, as `inbox_take` hands it over. */
17export type InboxNote = { id: number; body: string; images: string[] }
18
19/** The last finished turn, as `/next` asks about it. */
20export type TurnLog = { tools: string[]; commands: string[]; answer: string }
21
22declare module 'claude-code' {
23 interface PluginState {
24 'towles-tool-app': {
25 snapshot: Snapshot | null
26 /** The meeting the five-minute toast already fired for, so it fires once. */
27 warnedMeeting: number | null
28 seenChecks: SeenChecks | null
29 /** Notes already handed to the model, newest last. */
30 seenNotes: number[]
31 lastTurn: TurnLog | null
32 /** What `/next` proposed, until a prompt is sent. */
33 suggestion: string | null
34 asking: boolean
35 }
36 }
37}
38