SLOPSHOPPER

towles-tool-app

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…

newbandguardcommandtoaststatus
v0.1.38NOASSERTIONupdated 2026-10-16ChrisTowles/towles-tool/packages/app
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · towles-tool-app
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /park ⎿ towles-tool-app: Nothing to park: type /park <idea>, or select the text first. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

towles-tool-app

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.

MCP server

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:

  • Board — 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 — 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.
  • Attention — 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 — 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.
  • The mod's own — 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.

Skills

  • 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.

Commands

  • /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.

Mod: the app inside Claude Code

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.

The band above the prompt

✦ tt  #7 Claude mods (doing)  PR #42 open · checks passing  ready for /done  4m to Standup  n: next prompt
  • Task. This is the open board task whose worktree holds the session's cwd, so a session in a tt task checkout sees its own card. If worktrees are nested, the deepest match wins.
  • PR. The task's open PR, or else its latest, with check state. When its checks settle to passing or failing, a toast says so once.
  • Ready for /done. Shown when that PR is open with passing checks: what /towles-tool-app:done needs to land it.
  • Next meeting. Time until the next meeting, the only calendar fact the app surfaces. It turns yellow inside 15 minutes, and a toast fires once per meeting at 5 minutes.

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.

Notes from the app

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:

  • While the agent works, the note joins the conversation as a user row the model reads at its next step. A note that lands during the turn's last step has no next step to read it, so it goes again as a prompt when the turn ends.
  • While it is idle, the note is submitted as your own prompt.

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

Hooks

HookEventDoes…
hooks/scripts/gh-pr-nudge.shPostToolUse (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.

Installation

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-tool
Source 6 files
hooks/register.tsx 290 lines
1import { 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}
290
hooks/band.ts 87 lines
1import 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}
87
hooks/inbox.ts 38 lines
1import 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}
38
hooks/park.ts 31 lines
1const 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}
31
hooks/suggest.ts 33 lines
1import 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}
33
types/index.d.ts 38 lines
1/** 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