SLOPSHOPPER

floatee

Your Floatee backlog inside Claude Code: the open todos above the prompt (or in a side pane), one button to process each, and guards that keep your API token…

newpanebandguardtoaststatus
★ 1v1.3.1MITupdated 2026-10-15FrBmt-BIGetNouf/floatee-claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · floatee
│ ┃ floatee ✕ › fix the failing auth test and add an audit log call │ ┃ No .floatee here: nothing to list. │ ⏺ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · floatee
No .floatee here: nothing to list.
README

Floatee mod for Claude Code

Your Floatee backlog above Claude Code's prompt, in the terminal and the desktop app.

  • The project's open todos in a band above the prompt, or in a side pane on a wide screen
  • ▶ Run (/todo #N) and ▷ Guided (/todo-guided #N) on each ticket, Run next and Stop
  • The ticket in progress with its checklist, ticked from there
  • ✎ New ticket, ◐ Mine filter, ↑ ↓ reorder, ◉ Watch to process the todos as they come
  • A toast when someone replies to a blocked ticket

Install

claude plugin marketplace add FrBmt-BIGetNouf/floatee-claude-mod && claude plugin install floatee@floatee

To move to a newer version, refresh the catalogue and update, then restart Claude Code:

claude plugin marketplace update floatee && claude plugin update floatee@floatee

You can also download it from Floatee (Settings → Your agent) and unzip it into ~/.claude/skills/: one way or the other, not both, or it shows twice.

Requirements

  • A .floatee file at the repo root (token=flt_…, api=…, project=<slug>): create a token in Floatee under Settings → Your agent.
  • The /todo, /todo-guided and /todo-auto commands, from any Floatee agent kit (curl included), for the Run, Guided and Watch buttons. The list itself only needs .floatee.

Safety

  • The mod reads .floatee itself and never shows the token; it masks a token printed by any command, and keeps Claude from reading the file.
  • It never creates a project (create=0 on every request).
  • One ticket in progress per session, and no closing before a check (tests, lint, build) passed or you replied.

More in the docs.

Feedback

A bug or an idea? Open an issue.

License

MIT, see LICENSE.

Source 11 files
hooks/register.tsx 2126 lines
1// Icons: ⨳ Floatee; states ⊙ pending, ⊛ in progress, ⊝ blocked; actions
2// ▶ run, ▷ guided run, ◉ watch, ■ stop, ⟳ refresh, + - fold, ↑ ↓ reorder.
3//
4// Floatee mod, step 1: the project's open todos in one band above the prompt —
5// each row with its state and actions (↑ ↓ reorder, ▶ Run = `/todo #N`, ▷ Guided = `/todo-guided #N`), plus
6// "Run next" (`/todo`) and "Stop" (ends the running turn); `/todo-auto` is
7// for setups without the mod, ◉ Watch (F9) is its band counterpart. It folds to its header line ([+]/[-]), folded at first; the engine's own
8// [-] hides it whole and cannot be removed. Toasts announce what changed between
9// two refreshes (claimed, blocked, closed, back in the queue, new). The UI is in
10// English, like Floatee's.
11//
12// Step 2, the guards (G1–G5, decided in guards.ts, hooked here since a plugin
13// has one hooks module and `$` never crosses an import): `.floatee` never read
14// by the agent, tokens masked in outputs, no project created by accident, no
15// `get` rendering saved as a body, one ticket per session, no `done` unverified.
16//
17// Then: the session's ticket in the system prompt (F8, context.ts), and in the
18// band its checklist, each box pressable, and the time since the claim (F3,
19// checklist.ts); a reply on a blocked todo toasted, with ↻ Resume (F10,
20// replies.ts). ◉ Watch runs todos as they arrive, one autonomous run
21// each while Claude is idle, within limits (F9, watch.ts).
22//
23// Reads the repo's `.floatee` (walking up from the session's directory, like the
24// CLI). The token stays in this module: never in $.state, a log, a toast or a
25// prompt. Every request carries `create=0` — the mod never creates a project.
26import { atom, read, update } from 'claude-code'
27import type { EngineInterface, Register, RenderChildren, RenderNode } from 'claude-code'
28
29import type { Box, Entry, Item } from '../types'
30import { firstLine, reasonOf, replyOf } from './replies'
31import { afterRun, WATCH_ARGS, WATCH_OFF } from './watch'
32import { dropTag, namesType, parseTicket } from './newticket'
33import { blocks } from './markdown'
34import { wakeAt, wakeLabel } from './snooze'
35import type { Block, Span } from './markdown'
36import { boxes, claimedAt, elapsed, toggle, visible } from './checklist'
37import { parseConfig } from './config'
38import { ticketContext } from './context'
39import type { ApiItem } from './context'
40import type { Config } from './config'
41import { cliCalls, isVerification, mask, nextActive, redirectedFile, refusal, TOKEN_RE } from './guards'
42
43const items = atom({ plugin: 'floatee', key: 'items' } as const, [])
44const error = atom({ plugin: 'floatee', key: 'error' } as const, null)
45const isBusy = atom({ plugin: 'floatee', key: 'isBusy' } as const, false)
46const launched = atom({ plugin: 'floatee', key: 'launched' } as const, null)
47const project = atom({ plugin: 'floatee', key: 'project' } as const, null)
48const order = atom({ plugin: 'floatee', key: 'order' } as const, [])
49const isCollapsed = atom({ plugin: 'floatee', key: 'isCollapsed' } as const, true)
50const turnId = atom({ plugin: 'floatee', key: 'turnId' } as const, null)
51// Guards (guards.ts): the ticket this session works on and G4's flags.
52const active = atom({ plugin: 'floatee', key: 'active' } as const, null)
53const verified = atom({ plugin: 'floatee', key: 'verified' } as const, false)
54const heard = atom({ plugin: 'floatee', key: 'heard' } as const, false)
55const ticket = atom({ plugin: 'floatee', key: 'ticket' } as const, null)
56// F3: its checklist under its row, and the clock the elapsed time reads.
57const checklist = atom({ plugin: 'floatee', key: 'checklist' } as const, null)
58const now = atom({ plugin: 'floatee', key: 'now' } as const, 0)
59const isChecklistOpen = atom({ plugin: 'floatee', key: 'isChecklistOpen' } as const, false)
60// F10: replies on blocked todos, and those already announced.
61const blocked = atom({ plugin: 'floatee', key: 'blocked' } as const, {})
62const notified = atom({ plugin: 'floatee', key: 'notified' } as const, {})
63// F9: the watcher.
64const watch = atom({ plugin: 'floatee', key: 'watch' } as const, WATCH_OFF)
65// #680: the new-ticket field.
66// #751: its type, tags and place in the list, chosen in the form.
67const DRAFT_OFF = { isOpen: false, text: '', isSending: false, type: 'todo' as const, tags: [] as string[], position: 'top' as const, isTagging: false, tagText: '', assignee: null, project: null, snooze: null, picking: null, choices: null, isSnoozing: false, whenText: '' }
68const draft = atom({ plugin: 'floatee', key: 'draft' } as const, DRAFT_OFF)
69// #747: a ticket's sheet, open in place of the list.
70// #748: the band shows a ticket's last entries of activity, a comment cut to a few lines.
71const ENTRIES = 4
72const COMMENT_LINES = 3
73const SHEET_OFF = { id: null, step: null, text: '', isSending: false, body: null, feed: null, reply: '', isComposing: false, editing: null, deleting: null, isPosting: false, isAll: false, assignee: null, project: null, picking: null, choices: null, tagText: '', isTagging: false, isSnoozing: false, whenText: '', isDeleting: false, isEditing: false, editLine: null, lineText: '' }
74const sheet = atom({ plugin: 'floatee', key: 'sheet' } as const, SHEET_OFF)
75// #679: the list in the band above the prompt, or in a pane (docked beside the
76// transcript on a wide fullscreen terminal). Remembered across sessions.
77const layout = atom({ plugin: 'floatee', key: 'layout' } as const, 'band')
78const PANE = 'floatee'
79const others = atom({ plugin: 'floatee', key: 'others' } as const, [])
80// #750: the open items asleep (snoozed), apart: the pane lists them, a sheet wakes them.
81const sleepers = atom({ plugin: 'floatee', key: 'sleepers' } as const, [])
82const folds = atom({ plugin: 'floatee', key: 'folds' } as const, {})
83const detail = atom({ plugin: 'floatee', key: 'detail' } as const, null)
84// #669: only the tickets assigned to me (this token or its owner), remembered;
85// and the end of a rate-limit pause (429), 0 when none.
86const isMine = atom({ plugin: 'floatee', key: 'isMine' } as const, false)
87const pausedUntil = atom({ plugin: 'floatee', key: 'pausedUntil' } as const, 0)
88// #751: a dialog of the engine's is up (AskUserQuestion, a plan to approve): it holds
89// the keys, so a field of ours would take no click and no typing meanwhile.
90const asked = atom({ plugin: 'floatee', key: 'asked' } as const, 0)
91const DIALOG_TOOLS = ['AskUserQuestion', 'ExitPlanMode']
92
93// Inert launchers. The theme's `inactive` draws close to plain text on some
94// terminals; a fixed mid grey reads as disabled on dark and light alike.
95const GREY = '#6c6c6c'
96// #692: Floatee's yellow, not the theme's `warning` (orange): the app's dark
97// primary (duck, #FCE588) on a dark background; on a light one it washes out,
98// a deeper gold stands in. Spelled rgb(), as a theme spells its colors: a hex
99// came out as a duller yellow in some terminals.
100const HONEY = 'rgb(252,229,136)'
101const GOLD = 'rgb(154,114,0)'
102let brand = HONEY
103async function readBrand($: EngineInterface) {
104	const rows = await $.config.list().catch(() => [])
105	const theme = rows.find(row => row.key === 'theme')?.value
106	brand = typeof theme === 'string' && theme.startsWith('light') ? GOLD : HONEY
107}
108const SHOWN = 6            // rows drawn before "+N more"
109const BOXES = 5            // checklist lines drawn before "+N more"
110const NARROW = 100         // band columns below which labels shrink to glyphs (#669)
111const NARROW_PANE = 60     // the same in the pane
112const FOLDED_EXTRA = 20    // folded, the columns the ticket in progress needs on top
113const REFRESH_MS = 60_000  // background refresh while the session is open
114// Faster while our ticket is open or the watcher on, Claude idle: boxes ticked
115// in Floatee and new todos show up soon. Every request counts against the
116// token's per-minute cap (8 on Free, shared with the agent's CLI calls): one
117// list read per beat, two a minute, the boxes come with it.
118const WATCH_MS = 30_000
119
120// Titles are raw markdown (the body's first line): drop the emphasis markers and
121// TipTap's backslash escapes so the band reads as text.
122function plainTitle(md: string): string {
123	return md
124		.replace(/\\([\\`*_{}[\]()#+\-.!~>|])/g, '$1')
125		.replace(/\*\*|__|~~|`/g, '')
126		.replace(/^\s*[#>]+\s*/, '')
127		.trim()
128}
129
130// The repo's config, read at session.start; module-local, never in $.state.
131let config: Config | null = null
132// When the list was last read, and until when the API asked us to wait (429).
133let lastRefresh = 0
134let quietUntil = 0
135// Set when the filter changes: the next list is a new baseline, not news.
136let rebase = false
137
138// Where the .floatee was found: the repo the kit lives in.
139let repoRoot: { dir: string; sep: string } | null = null
140
141// #670: the band's buttons run the kit's /todo commands. Missing in this
142// repo, say once how to get them rather than fail on the first press.
143async function checkKit($: EngineInterface) {
144	if (!repoRoot) return
145	const { dir, sep } = repoRoot
146	const missing: string[] = []
147	for (const name of ['todo', 'todo-guided', 'todo-auto']) {
148		const at = [dir, '.claude', 'commands', `${name}.md`].join(sep)
149		if (!(await $.fs.exists(at).catch(() => true))) missing.push(`/${name}`)
150	}
151	if (missing.length) $.ui.toast(`Floatee: ${missing.join(', ')} missing in this repo: they come with the Floatee agent kit (floatee.me/settings/agent).`, TOAST)
152}
153
154// Walking up from the session's directory, like the CLI. Windows paths too
155// (Claude Desktop): `C:\Workspace\repo`, its own separator kept.
156async function findConfig($: EngineInterface): Promise<Config | null> {
157	let dir = await $.session.cwd()
158	const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/'
159	for (let i = 0; i < 30; i++) {
160		const path = `${dir}${sep}.floatee`
161		if (await $.fs.exists(path)) { repoRoot = { dir, sep }; return parseConfig(await $.fs.read(path)) }
162		const up = dir.replace(/[\\/][^\\/]+$/, '')
163		if (!up || up === dir) return null
164		dir = up
165	}
166	return null
167}
168
169async function fail($: EngineInterface, problem: string) {
170	await update($, error, () => problem)
171}
172
173// What changed since the last fetch, as toasts: claimed, blocked, closed, new.
174// `before` is the list kept in $.state, which outlives a reload of this module:
175// empty only at the session's first fetch, when announcing would list the
176// whole backlog.
177const TOAST = { timeoutMs: 8000 }
178function announce($: EngineInterface, before: Item[], after: Item[]) {
179	if (before.length === 0) return
180	const was = new Map(before.map(i => [i.id, i]))
181	const now = new Map(after.map(i => [i.id, i]))
182	const fresh = after.filter(i => !was.has(i.id) && i.status === 'pending')
183	for (const item of after) {
184		const old = was.get(item.id)
185		if (!old || old.status === item.status) continue
186		if (item.status === 'in_progress') $.ui.toast(`#${item.id} claimed: ${item.title}`, TOAST)
187		else if (item.status === 'blocked') $.ui.toast(`#${item.id} blocked: ${item.title}`, TOAST)
188		else if (item.status === 'pending' && old.status !== 'pending') $.ui.toast(`#${item.id} is back in the queue`, TOAST)
189	}
190	for (const old of before) {
191		if (!now.has(old.id) && old.status === 'in_progress') $.ui.toast(`#${old.id} closed: ${old.title}`, TOAST)
192	}
193	if (fresh.length === 1 && fresh[0]) $.ui.toast(`New todo #${fresh[0].id}: ${fresh[0].title}`, TOAST)
194	else if (fresh.length > 1) $.ui.toast(`${fresh.length} new todos in the backlog`, TOAST)
195}
196
197async function refresh($: EngineInterface) {
198	if (!config) return
199	// The elapsed time (F3) moves on with each refresh, offline included.
200	const at = await $.clock.now().catch(() => null)
201	if (at !== null) {
202		await update($, now, () => at)
203		lastRefresh = at
204		if (at < quietUntil) return
205	}
206	// Every open item (all types), so a reorder can send the project's whole order.
207	const mineOnly = await read($, isMine)
208	const url = `${config.api}/items?project=${encodeURIComponent(config.project)}&format=json&limit=200&sleeping=include&create=0${mineOnly ? '&assignee=me' : ''}`
209	let res
210	try {
211		res = await $.http.fetch(url, { headers: { Authorization: `Bearer ${config.token}` } })
212	} catch {
213		await fail($, 'offline, retrying each minute')
214		return
215	}
216	if (res.status === 401) { await fail($, 'invalid token: check token= in .floatee'); return }
217	// Over the per-minute cap: wait as told, the band keeps what it shows.
218	if (res.status === 429) {
219		const wait = Number(res.headers['retry-after'] ?? 60)
220		if (at !== null) quietUntil = at + (Number.isFinite(wait) ? wait : 60) * 1000
221		await update($, pausedUntil, () => quietUntil)
222		return
223	}
224	await update($, pausedUntil, () => 0)
225	if (res.status === 404) { await fail($, 'unknown project: check project= in .floatee'); return }
226	if (!res.ok) { await fail($, `error ${res.status}`); return }
227	let all: Array<{ id: number; title: string; status: string; type: string; sortOrder: number; bodyMd?: string; commentCount?: number; workspaceSlug?: string; tags?: string[]; checklist?: { done: number; total: number }; updatedAt?: number; snoozeUntil?: number | null }>
228	try { all = JSON.parse(res.text) } catch { await fail($, 'unreadable response'); return }
229	all.sort((a, b) => a.sortOrder - b.sortOrder)
230	// #750: the sleeping ones come along, kept apart (the list is the awake).
231	const rows = all.filter(r => !r.snoozeUntil)
232	const toItem = (r: typeof rows[number]): Item => ({
233		id: r.id, title: plainTitle(r.title), status: r.status as Item['status'], ws: r.workspaceSlug ?? null,
234		type: r.type as Item['type'], tags: r.tags ?? [], boxes: r.checklist?.total ? r.checklist : null,
235		updatedAt: r.updatedAt ? r.updatedAt * 1000 : null, comments: r.commentCount ?? 0,
236		...(r.snoozeUntil ? { until: r.snoozeUntil } : {}),
237	})
238	const isOpen = (r: typeof rows[number]) => ['pending', 'in_progress', 'blocked'].includes(r.status)
239	const open: Item[] = rows.filter(r => r.type === 'todo' && isOpen(r)).map(toItem)
240	if (!rebase) announce($, await read($, items), open)
241	rebase = false
242	await update($, items, () => open)
243	await update($, others, () => rows.filter(r => (r.type === 'idea' || r.type === 'note') && isOpen(r)).map(toItem))
244	await update($, sleepers, () => all.filter(r => r.snoozeUntil && isOpen(r)).map(toItem))
245	// A reorder sends the project's whole order: only a full list holds it.
246	if (!mineOnly) await update($, order, () => rows.map(r => r.id))
247	await update($, error, () => null)
248	// #743: our ticket closed or released elsewhere (the web, another
249	// terminal): the session lets it go, or the buttons stay off.
250	if (await released($, all)) {
251		await update($, active, () => null)
252		await update($, verified, () => false)
253		await update($, heard, () => false)
254		await loadTicket($)
255	}
256	// F3: our ticket's boxes ride in the list (its body); a full read only when
257	// the claim time is still missing.
258	const mine = await read($, active)
259	const row = rows.find(r => r.id === mine)
260	const held = await read($, checklist)
261	if (mine !== null && held?.id === mine && typeof row?.bodyMd === 'string') {
262		const body = row.bodyMd
263		await update($, checklist, c => (c && c.id === mine ? { ...c, boxes: boxes(body) } : c))
264	} else if (mine !== null) {
265		await loadChecklist($)
266	}
267	await watchReplies($, rows.filter(r => r.type === 'todo' && r.status === 'blocked'))
268	await maybeWatch($)
269}
270
271// Whether the session's ticket is no longer in progress: its row says so, or,
272// out of the list (closed, deleted, or not mine in the "mine" list), one read
273// tells. A failed read keeps it, unless the ticket is gone.
274async function released($: EngineInterface, rows: Array<{ id: number; status: string }>): Promise<boolean> {
275	const mine = await read($, active)
276	if (mine === null) return false
277	const row = rows.find(r => r.id === mine)
278	if (row) return row.status !== 'in_progress'
279	try { return (await fetchItem($, mine)).status !== 'in_progress' } catch (err) { return (err as Error).message === '404' }
280}
281
282// F9: the watcher starts a run when it is on, Claude idle, nothing queued, no
283// ticket of ours open and a todo waiting.
284async function maybeWatch($: EngineInterface) {
285	const w = await read($, watch)
286	if (!w.isOn || w.inTurn !== null) return
287	if (await read($, isBusy) || await read($, launched) !== null || await read($, active) !== null || await read($, error) !== null) return
288	if (!(await read($, items)).some(i => i.status === 'pending')) return
289	await update($, watch, v => ({ ...v, inTurn: 0 }))
290	$.ui.toast('Watch: running the next todo', TOAST)
291	await launch($, null, 'todo-auto', WATCH_ARGS)
292}
293
294async function toggleWatch($: EngineInterface) {
295	const w = await read($, watch)
296	await update($, watch, () => (w.isOn ? WATCH_OFF : { ...WATCH_OFF, isOn: true }))
297	const cap = config?.watchMax ?? null
298	$.ui.toast(w.isOn ? 'Watch off' : `Watch on: todos run as they arrive${cap ? ` (up to ${cap})` : ''}`, TOAST)
299	if (!w.isOn) await refresh($)
300}
301
302// F10: a blocked todo whose comment count moved is read whole, to see who
303// spoke since the block (READS_PER_REFRESH at most, the rest at the next
304// refresh: every read counts against the per-minute cap). A new reply is
305// toasted once, its time kept in $.state so a reload doesn't toast it again.
306const READS_PER_REFRESH = 2
307async function watchReplies($: EngineInterface, rows: Array<{ id: number; title: string; commentCount?: number }>) {
308	const was = await read($, blocked)
309	const next: typeof was = {}
310	let reads = 0
311	for (const row of rows) {
312		const count = row.commentCount ?? 0
313		const seen = was[row.id]
314		// (A record from before #679 has no `why`: read once more.)
315		if (seen && seen.comments === count && seen.why !== undefined) { next[row.id] = seen; continue }
316		if (count === 0) { next[row.id] = { comments: 0, reply: null, why: null }; continue }
317		if (reads >= READS_PER_REFRESH) { if (seen) next[row.id] = seen; continue }
318		reads++
319		try {
320			const item = await fetchItem($, row.id)
321			next[row.id] = { comments: count, reply: replyOf(item.activity as never), why: reasonOf(item.activity as never) }
322		} catch {
323			if (seen) next[row.id] = seen
324		}
325	}
326	await update($, blocked, () => next)
327	const told = await read($, notified)
328	for (const row of rows) {
329		const reply = next[row.id]?.reply
330		if (!reply || told[row.id] === reply.at) continue
331		$.ui.toast(`Reply on #${row.id} from ${reply.by}: ${plainTitle(row.title)}`, TOAST)
332		await update($, notified, n => ({ ...n, [row.id]: reply.at }))
333	}
334}
335
336// Queue `/todo [#N]` — run by the engine once the session is idle. One launch
337// at a time: the band shows it as such until the turn it started completes.
338async function launch($: EngineInterface, id: number | null, command: 'todo' | 'todo-guided' | 'todo-auto' = 'todo', note = '') {
339	const pending = await read($, launched)
340	if (pending !== null) { $.ui.toast(pending ? `#${pending} is already queued` : 'A todo is already queued'); return }
341	if (await read($, isBusy)) { $.ui.toast('Claude is busy: try again when the turn ends'); return }
342	await update($, launched, () => id ?? 0)
343	const args = id === null ? note : `#${id}`
344	try {
345		await $.command.run({ command, args })
346	} catch {
347		// Not runnable as a command here: hand it over as a typed prompt.
348		await $.prompt.submit({ text: `/${command}${args ? ` ${args}` : ''}`, asUser: true })
349	}
350}
351
352// End the running turn (a watcher's run included), its tools stopped.
353async function stop($: EngineInterface) {
354	// Stop means stop: the watcher too.
355	if ((await read($, watch)).isOn) { await update($, watch, () => WATCH_OFF); $.ui.toast('Watch off', TOAST) }
356	const id = await read($, turnId)
357	if (!id) return
358	await $.turn.abort({ turnId: id }).catch(() => $.ui.toast('Nothing to stop'))
359}
360
361// The person checked the ticket this session works on: their press is G4's
362// "the user spoke", then the agent is asked to close it (resolution note and
363// tags are its to write, as /todo's step 5).
364async function close($: EngineInterface, id: number) {
365	if (await read($, isBusy)) { $.ui.toast('Claude is busy: try again when the turn ends'); return }
366	await update($, heard, () => true)
367	await $.prompt.submit({ text: `#${id} is checked: close it.`, asUser: true })
368}
369
370// Put the session's ticket back in the queue (F11): pending again, with a
371// comment saying why. Only the ticket this session started (`active`), and only
372// while the band still sees it in progress, so a ticket closed or reopened
373// elsewhere meanwhile is left alone. `fresh` re-reads the backlog first (not
374// at session end, whose budget is 1.5 s: the two writes go out together).
375const RELEASED = {
376	end: 'Released by the floatee mod: the Claude Code session ended before this ticket was closed. It is back in the queue.',
377	band: 'Released from the Claude Code band: back in the queue.',
378}
379async function release($: EngineInterface, why: keyof typeof RELEASED, fresh: boolean) {
380	const id = await read($, active)
381	if (id === null || !config) return
382	if (fresh) await refresh($)
383	await update($, active, () => null)
384	await update($, ticket, () => null)
385	await update($, checklist, () => null)
386	const item = (await read($, items)).find(i => i.id === id)
387	if (item?.status !== 'in_progress') return
388	const base = `${config.api}/items/${id}`
389	const query = `?project=${encodeURIComponent(config.project)}&create=0`
390	const headers = { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' }
391	const [status] = await Promise.all([
392		$.http.fetch(base + query, { method: 'PATCH', headers, body: JSON.stringify({ status: 'pending' }) }),
393		$.http.fetch(`${base}/comments${query}`, { method: 'POST', headers, body: JSON.stringify({ body_md: RELEASED[why] }) }),
394	])
395	if (!status.ok) { $.ui.toast(`Could not release #${id} (${status.status})`); return }
396	// Said here; the next refresh must not say it again.
397	await update($, items, l => l.map(i => (i.id === id ? { ...i, status: 'pending' as const } : i)))
398	$.ui.toast(`#${id} is back in the queue`, TOAST)
399}
400
401// A ticket's page in the web app; null when its workspace is unknown.
402function pageOf(item: Item): string | null {
403	return config && item.ws ? `${config.web}/i/${encodeURIComponent(item.ws)}/${item.id}` : null
404}
405
406// Open a URL in the person's browser, with the host's own opener: no shell, the
407// URL one argument. WSL hands it to Windows (explorer.exe exits 1 even when it
408// opened it); Windows, macOS and Linux their usual one.
409async function openPage($: EngineInterface, url: string) {
410	const openers: string[][] = []
411	if (await $.env.get('WSL_DISTRO_NAME')) openers.push(['explorer.exe', url], ['wslview', url])
412	else if (await $.env.get('OS') === 'Windows_NT') openers.push(['rundll32', 'url.dll,FileProtocolHandler', url])
413	else {
414		const os = await $.process.run(['uname', '-s']).then(r => r.stdout.trim(), () => '')
415		openers.push(os === 'Darwin' ? ['open', url] : ['xdg-open', url])
416	}
417	for (const argv of openers) {
418		try {
419			const { exitCode } = await $.process.run(argv, { timeoutMs: 10_000 })
420			if (exitCode === 0 || argv[0] === 'explorer.exe') return
421		} catch { /* not there: the next one */ }
422	}
423	$.ui.toast(`Could not open a browser: ${url}`)
424}
425
426// #680: create the ticket typed in the band's field, at the top of the
427// queue, where it is seen first. The field stays open with its text on a failure.
428async function createTicket($: EngineInterface, text: string) {
429	if (!config || (await read($, draft)).isSending) return
430	const t = parseTicket(text)
431	if (!t) { $.ui.toast('Nothing to create: type a title first'); return }
432	// #751: the form's type unless the text names one; its tags and the text's.
433	const form = await read($, draft)
434	const type = namesType(text) ? t.type : form.type
435	const tags = [...new Set([...(form.tags ?? []), ...t.tags])]
436	// #751: its project (this one unless chosen), assignee and wake-up.
437	const where = form.project?.value ?? config.project
438	await update($, draft, d => ({ ...d, text, isSending: true }))
439	try {
440		const res = await $.http.fetch(`${config.api}/items?project=${encodeURIComponent(where)}&format=json&create=0`, {
441			method: 'POST',
442			headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
443			body: JSON.stringify({
444				body_md: t.body, type, tags, position: form.position ?? 'top',
445				...(form.assignee ? { assignee_handle: form.assignee.value } : {}),
446				...(form.snooze ? { snooze_until: form.snooze } : {}),
447			}),
448		})
449		if (!res.ok) {
450			// The API's own words when it gives some (snooze is Pro).
451			let said = ''
452			try { const err = JSON.parse(res.text) as { detail?: string }; said = err.detail ?? '' } catch { /* no body */ }
453			$.ui.toast(said ? `Could not create the ticket: ${said}` : `Could not create the ticket (${res.status})`)
454			await update($, draft, d => ({ ...d, isSending: false }))
455			return
456		}
457		const made = JSON.parse(res.text) as { id?: number }
458		await update($, draft, () => DRAFT_OFF)
459		$.ui.toast(`Created #${made.id ?? '?'}${where === config.project ? '' : ` in ${form.project?.label ?? where}`}: ${plainTitle(t.body.split('\n')[0] ?? '')}`, TOAST)
460		await refresh($)
461	} catch {
462		$.ui.toast('Could not create the ticket: offline')
463		await update($, draft, d => ({ ...d, isSending: false }))
464	}
465}
466
467// #747: a ticket's sheet — its type and status changed from the band or the pane.
468type Status = Item['status'] | 'done' | 'wontfix'
469const STATUS_LABELS: Record<Status, string> = { pending: 'pending', in_progress: 'in progress', blocked: 'blocked', done: 'done', wontfix: "won't fix" }
470// A todo goes through every state; an idea or a note is open or closed.
471const statusesOf = (item: Item): Status[] => item.type === 'todo' ? ['pending', 'in_progress', 'blocked', 'done', 'wontfix'] : ['pending', 'done', 'wontfix']
472
473async function openSheet($: EngineInterface, id: number) {
474	await update($, draft, () => DRAFT_OFF)
475	await update($, sheet, () => ({ ...SHEET_OFF, id }))
476	await loadSheet($, id)
477}
478
479// Its body, read whole (the box lines count from its top), and #748 its
480// activity, as the site's feed draws it.
481async function loadSheet($: EngineInterface, id: number) {
482	try {
483		const item = await fetchItem($, id)
484		await update($, sheet, s => (s.id === id ? { ...s, body: item.bodyMd, feed: feedOf(item), assignee: item.assigneeName ?? null, project: item.projectName ?? null } : s))
485	} catch { /* the sheet goes without it */ }
486}
487
488// #749: a ticket's tags, assignee and project, changed from its sheet.
489async function setTags($: EngineInterface, item: Item, tags: string[]) {
490	if (!await patchItem($, item.id, { tags })) return
491	await refresh($)
492	await loadSheet($, item.id)
493}
494
495// The tags typed in the field (commas or spaces between them, a leading +
496// dropped), added to the ticket's.
497async function addTags($: EngineInterface, item: Item, text: string) {
498	const typed = text.split(/[\s,]+/).map(t => t.replace(/^\+/, '').trim()).filter(Boolean)
499	await update($, sheet, s => ({ ...s, tagText: '', isTagging: false }))
500	if (!typed.length) return
501	await setTags($, item, [...new Set([...(item.tags ?? []), ...typed])])
502}
503
504// Assign or move: the choices read when asked (the project's assignees, the
505// workspace's other projects), as a row of buttons.
506async function pickMeta($: EngineInterface, item: Item, what: 'assignee' | 'project') {
507	if (!config) return
508	await update($, sheet, s => ({ ...s, picking: what, choices: null, isTagging: false }))
509	try {
510		const choices = await choicesOf($, what, config.project, p => p.slug !== config!.project && (!item.ws || p.workspace?.slug === item.ws))
511		await update($, sheet, s => (s.id === item.id && s.picking === what ? { ...s, choices } : s))
512	} catch {
513		$.ui.toast(`Could not read the ${what === 'assignee' ? 'assignees' : 'projects'}`)
514		await update($, sheet, s => ({ ...s, picking: null }))
515	}
516}
517
518// Who can be assigned in `project` (me, its members and agents, nobody), or
519// the live projects `keep` keeps. Throws when the API says no.
520type Project = { slug: string; name: string; archived?: boolean; workspace?: { slug?: string } }
521async function choicesOf($: EngineInterface, what: 'assignee' | 'project', project: string, keep: (p: Project) => boolean = () => true): Promise<Array<{ value: string; label: string }>> {
522	if (!config) return []
523	const get = async (path: string) => {
524		const res = await $.http.fetch(`${config!.api}/${path}${path.includes('?') ? '&' : '?'}format=json&create=0`, { headers: { Authorization: `Bearer ${config!.token}` } })
525		if (!res.ok) throw new Error(String(res.status))
526		return JSON.parse(res.text) as unknown
527	}
528	if (what === 'assignee') {
529		const got = (await get(`project?project=${encodeURIComponent(project)}`)) as { assignees?: Array<{ handle: string; name: string; type: string }> }
530		return [{ value: 'me', label: 'me' }, ...(got.assignees ?? []).map(a => ({ value: a.handle, label: a.type === 'token' ? `${a.name} (agent)` : a.name })), { value: '', label: 'nobody' }]
531	}
532	return ((await get('projects')) as Project[]).filter(p => !p.archived && keep(p)).map(p => ({ value: p.slug, label: p.name }))
533}
534
535// #751: the New form's assign / move choices.
536async function pickNew($: EngineInterface, what: 'assignee' | 'project') {
537	if (!config) return
538	const d0 = await read($, draft)
539	await update($, draft, d => ({ ...d, picking: what, choices: null, isTagging: false, isSnoozing: false }))
540	try {
541		const choices = await choicesOf($, what, d0.project?.value ?? config.project)
542		await update($, draft, d => (d.picking === what ? { ...d, choices } : d))
543	} catch {
544		$.ui.toast(`Could not read the ${what === 'assignee' ? 'assignees' : 'projects'}`)
545		await update($, draft, d => ({ ...d, picking: null }))
546	}
547}
548
549async function assign($: EngineInterface, item: Item, handle: string) {
550	await update($, sheet, s => ({ ...s, picking: null, choices: null }))
551	if (!await patchItem($, item.id, { assignee_handle: handle })) return
552	$.ui.toast(handle ? `#${item.id} is assigned` : `#${item.id} is unassigned`, TOAST)
553	await loadSheet($, item.id)
554}
555
556// A move takes the ticket out of this project's list: the sheet closes.
557async function moveTo($: EngineInterface, item: Item, slug: string, name: string) {
558	if (!config) return
559	await update($, sheet, s => ({ ...s, picking: null, choices: null }))
560	try {
561		const res = await $.http.fetch(`${config.api}/project?project=${encodeURIComponent(slug)}&format=json&create=0`, { headers: { Authorization: `Bearer ${config.token}` } })
562		if (!res.ok) throw new Error(String(res.status))
563		const target = JSON.parse(res.text) as { id?: string }
564		if (!target.id) throw new Error('no id')
565		if (!await patchItem($, item.id, { project_id: target.id })) return
566	} catch {
567		$.ui.toast(`Could not move #${item.id}`)
568		return
569	}
570	$.ui.toast(`#${item.id} moved to ${name}`, TOAST)
571	await update($, sheet, () => SHEET_OFF)
572	await refresh($)
573}
574
575// #750: put a ticket to sleep until `epoch` (s), or wake it (null). The
576// API's refusal (snooze is Pro) is shown as it words it.
577async function snooze($: EngineInterface, item: Item, epoch: number | null) {
578	if (!config) return
579	try {
580		const res = await $.http.fetch(`${config.api}/items/${item.id}?project=${encodeURIComponent(config.project)}&format=json&create=0`, {
581			method: 'PATCH',
582			headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
583			body: JSON.stringify({ snooze_until: epoch }),
584		})
585		if (!res.ok) {
586			let said = ''
587			try { const err = JSON.parse(res.text) as { detail?: string; error?: string }; said = err.detail ?? err.error ?? '' } catch { /* no body */ }
588			$.ui.toast(said ? `#${item.id}: ${said}` : `Could not ${epoch === null ? 'wake' : 'snooze'} #${item.id} (${res.status})`)
589			return
590		}
591	} catch {
592		$.ui.toast(`Could not ${epoch === null ? 'wake' : 'snooze'} #${item.id}: offline`)
593		return
594	}
595	const at = await $.clock.now().catch(() => Date.now())
596	$.ui.toast(epoch === null ? `#${item.id} is awake` : `#${item.id} sleeps until ${wakeLabel(epoch, at)}`, TOAST)
597	await update($, sheet, s => ({ ...s, isSnoozing: false, whenText: '' }))
598	await refresh($)
599	await loadSheet($, item.id)
600}
601
602// The field's text read as a delay or a date; a toast when it doesn't.
603async function snoozeTyped($: EngineInterface, item: Item, text: string) {
604	const at = await $.clock.now().catch(() => Date.now())
605	const epoch = wakeAt(text, at)
606	if (typeof epoch !== 'number') { $.ui.toast(epoch.error); return }
607	await snooze($, item, epoch)
608}
609
610// Delete, once asked (`isDeleting`): the ticket goes to the site's trash.
611async function deleteItem($: EngineInterface, item: Item) {
612	if (!config) return
613	try {
614		const res = await $.http.fetch(`${config.api}/items/${item.id}?project=${encodeURIComponent(config.project)}&create=0`, {
615			method: 'DELETE', headers: { Authorization: `Bearer ${config.token}` },
616		})
617		if (!res.ok) { $.ui.toast(`Could not delete #${item.id} (${res.status})`); return }
618	} catch {
619		$.ui.toast(`Could not delete #${item.id}: offline`)
620		return
621	}
622	$.ui.toast(`#${item.id} is deleted`, TOAST)
623	await update($, sheet, () => SHEET_OFF)
624	await refresh($)
625}
626
627// #752: one line of the body written (`text` at line `at`; `null` deletes
628// it; `insert` adds it after), on a fresh read: a body changed meanwhile is
629// not overwritten, the sheet reloads it instead. The first line is the title.
630async function writeLine($: EngineInterface, id: number, at: number, text: string | null, insert = false) {
631	const s0 = await read($, sheet)
632	if (s0.body === null || s0.isSending) return
633	await update($, sheet, s => ({ ...s, isSending: true }))
634	const done = (patch: Partial<typeof s0>) => update($, sheet, s => (s.id === id ? { ...s, isSending: false, ...patch } : s))
635	let fresh: FullItem
636	try { fresh = await fetchItem($, id) } catch { $.ui.toast(`Could not read #${id}`); await done({}); return }
637	if (fresh.bodyMd !== s0.body) {
638		$.ui.toast(`#${id} changed meanwhile: reloaded, nothing overwritten`)
639		await done({ body: fresh.bodyMd, feed: feedOf(fresh), editLine: null, lineText: '' })
640		return
641	}
642	const lines = fresh.bodyMd.split('\n')
643	if (insert) lines.splice(at + 1, 0, text ?? '')
644	else if (text === null) lines.splice(at, 1)
645	else lines[at] = text
646	const body = lines.join('\n')
647	if (!body.split('\n')[0]!.trim()) { $.ui.toast('The first line is the title: it cannot be empty'); await done({}); return }
648	if (!await patchItem($, id, { body_md: body })) { await done({}); return }
649	// An inserted line opens for typing; otherwise the line closes.
650	await done({ body, ...(insert ? { editLine: at + 1, lineText: '' } : { editLine: null, lineText: '' }) })
651	await refresh($)
652}
653
654// #748: the site's labels, for the feed's events.
655const STATUS_NAMES: Record<string, string> = { pending: 'Pending', in_progress: 'In progress', blocked: 'Blocked', done: 'Done', wontfix: "Won't fix" }
656const TYPE_NAMES: Record<string, string> = { todo: 'Todo', idea: 'Idea', note: 'Note' }
657
658// A ticket's activity, newest first, its opening last (as on the site): a
659// comment with its markdown, an event in a line, a resolution with its note
660// (the one in the event, else the ticket's on its latest resolution).
661function feedOf(item: FullItem): Entry[] {
662	const all = item.activity ?? []
663	const isResolution = (a: (typeof all)[number]) => a.kind === 'status_change' && (a.meta?.to === 'done' || a.meta?.to === 'wontfix')
664	const latest = all.filter(isResolution).at(-1)
665	const entries = all.map((a, n): Entry => {
666		const m = a.meta ?? {}
667		const event = a.kind === 'comment' ? ''
668			: a.kind === 'status_change' ? `${STATUS_NAMES[m.from ?? ''] ?? m.from} → ${STATUS_NAMES[m.to ?? ''] ?? m.to}`
669			: a.kind === 'promoted' ? `promoted ${TYPE_NAMES[m.from ?? ''] ?? m.from} → ${TYPE_NAMES[m.to ?? ''] ?? m.to}`
670			: a.kind === 'snoozed' ? 'snoozed 💤'
671			: a.kind === 'woke_up' ? 'woke up'
672			: a.kind === 'moved' ? 'moved project'
673			: a.kind === 'assigned' ? (m.to ? `assigned to ${m.to}` : 'unassigned')
674			: a.kind.replace('_', ' ')
675		const note = isResolution(a) ? (m.note?.trim() ? m.note : a === latest ? item.resolutionNote || null : null) : null
676		return {
677			key: a.id ?? n, kind: a.kind, seq: a.kind === 'comment' ? a.seq ?? null : null,
678			author: a.authorName ?? (a.authorType === 'agent' ? 'Agent' : 'Member'), at: (a.createdAt ?? 0) * 1000,
679			body: a.kind === 'comment' ? a.bodyMd : note, event, isEdited: Boolean(m.editedAt), canEdit: a.canEdit === true,
680			isWontfix: m.to === 'wontfix',
681		}
682	}).filter(e => e.kind !== 'comment' || (e.body && e.seq))
683	const opening: Entry = { key: -1, kind: 'opened', seq: null, author: item.createdByName ?? 'Unknown', at: (item.createdAt ?? 0) * 1000, body: null, event: 'opened', isEdited: false, canEdit: false, isWontfix: false }
684	return [...entries.reverse(), opening]
685}
686
687// One PATCH on a ticket; a toast says what failed. True when it went through.
688async function patchItem($: EngineInterface, id: number, body: Record<string, unknown>): Promise<boolean> {
689	if (!config) return false
690	try {
691		const res = await $.http.fetch(`${config.api}/items/${id}?project=${encodeURIComponent(config.project)}&format=json&create=0`, {
692			method: 'PATCH',
693			headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
694			body: JSON.stringify(body),
695		})
696		if (!res.ok) { $.ui.toast(`Could not update #${id} (${res.status})`); return false }
697		return true
698	} catch {
699		$.ui.toast(`Could not update #${id}: offline`)
700		return false
701	}
702}
703
704async function setType($: EngineInterface, item: Item, type: Item['type']) {
705	if (type === item.type) return
706	if (!await patchItem($, item.id, { type })) return
707	$.ui.toast(`#${item.id} is now ${type === 'idea' ? 'an idea' : `a ${type}`}`, TOAST)
708	await refresh($)
709}
710
711// A pick in the status list: pending and in progress act at once; blocked,
712// done and won't fix ask for their text first (the sheet's next step).
713async function pickStatus($: EngineInterface, item: Item, to: Status) {
714	if (to === 'blocked' || to === 'done' || to === 'wontfix') {
715		await update($, sheet, s => ({ ...s, step: to, text: '' }))
716		return
717	}
718	await update($, sheet, s => ({ ...s, step: null }))
719	if (to === item.status) return
720	const busy = await read($, isBusy)
721	if (to === 'in_progress') {
722		// In progress means someone works on it: this session, as ▶ Run.
723		if (busy || await read($, launched) !== null || await read($, active) !== null) {
724			$.ui.toast('Claude is on another ticket: try again when it is closed')
725			return
726		}
727		await update($, sheet, () => SHEET_OFF)
728		await launch($, item.id)
729		return
730	}
731	// Back to pending: the session's own ticket goes the ↩ Release way.
732	if (item.id === await read($, active)) {
733		if (busy) { $.ui.toast('Claude is busy: try again when the turn ends'); return }
734		await update($, sheet, () => SHEET_OFF)
735		await release($, 'band', true)
736		return
737	}
738	if (!await patchItem($, item.id, { status: 'pending' })) return
739	$.ui.toast(`#${item.id} is back in the queue`, TOAST)
740	await update($, sheet, () => SHEET_OFF)
741	await refresh($)
742}
743
744// The step's confirm: blocked with its why (required, a comment), done or
745// won't fix with a resolution note (optional); `verify` hands the closing to
746// Claude, who checks the work first.
747async function confirmStatus($: EngineInterface, item: Item, how: 'blocked' | 'done' | 'wontfix' | 'verify', text: string) {
748	if (!config || (await read($, sheet)).isSending) return
749	const note = text.trim()
750	if (how === 'blocked' && !note) { $.ui.toast('Say why it is blocked first'); return }
751	if (how === 'verify') {
752		if (await read($, isBusy) || await read($, launched) !== null) { $.ui.toast('Claude is busy: try again when the turn ends'); return }
753		await update($, sheet, () => SHEET_OFF)
754		await $.prompt.submit({
755			text: `Verify #${item.id}: run the repo's checks (tests, lint, typecheck or build) and make sure the work matches the ticket. If it holds, close it as done${note ? ` with this resolution note: ${note}` : ''}. If something fails, tell me and leave it open.`,
756			asUser: true,
757		})
758		return
759	}
760	await update($, sheet, s => ({ ...s, text, isSending: true }))
761	if (how === 'blocked') {
762		try {
763			const res = await $.http.fetch(`${config.api}/items/${item.id}/comments?project=${encodeURIComponent(config.project)}&create=0`, {
764				method: 'POST',
765				headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
766				body: JSON.stringify({ body_md: note }),
767			})
768			if (!res.ok) { $.ui.toast(`Could not comment on #${item.id} (${res.status})`); await update($, sheet, s => ({ ...s, isSending: false })); return }
769		} catch {
770			$.ui.toast(`Could not comment on #${item.id}: offline`)
771			await update($, sheet, s => ({ ...s, isSending: false }))
772			return
773		}
774	}
775	const body = how === 'blocked' ? { status: 'blocked' } : { status: how, resolution_note: note || null }
776	if (!await patchItem($, item.id, body)) { await update($, sheet, s => ({ ...s, isSending: false })); return }
777	$.ui.toast(`#${item.id} is ${STATUS_LABELS[how]}`, TOAST)
778	await update($, sheet, () => SHEET_OFF)
779	await refresh($)
780}
781
782// #748: a comment posted, or the one being edited saved (`editing`); the
783// thread read again after.
784async function sendComment($: EngineInterface, id: number, text: string) {
785	const s0 = await read($, sheet)
786	const body = text.trim()
787	if (!config || s0.isPosting || !body) return
788	const at = s0.editing
789	await update($, sheet, s => ({ ...s, reply: text, isPosting: true }))
790	if (!await commentCall($, id, at === null ? 'POST' : 'PATCH', at, { body_md: body })) { await update($, sheet, s => ({ ...s, isPosting: false })); return }
791	await update($, sheet, s => (s.id === id ? { ...s, reply: '', isComposing: false, editing: null, isPosting: false } : s))
792	await loadSheet($, id)
793	await refresh($)
794}
795
796// ✎: the comment back in the field, to fix and send again. The field is one
797// line: a comment on several is fixed on the site.
798async function editComment($: EngineInterface, item: Item, c: Entry) {
799	if (!c.seq || !c.body) return
800	if (c.body.includes('\n')) {
801		$.ui.toast(`c${c.seq} runs on several lines: fix it on the site`)
802		const url = pageOf(item)
803		if (url) await openPage($, url)
804		return
805	}
806	await update($, sheet, s => ({ ...s, reply: c.body!, isComposing: true, editing: c.seq, deleting: null }))
807}
808
809// ✕ asked first (`deleting`), then this.
810async function deleteComment($: EngineInterface, id: number, seq: number) {
811	if (!await commentCall($, id, 'DELETE', seq)) return
812	await update($, sheet, s => (s.id === id ? { ...s, deleting: null, ...(s.editing === seq ? { editing: null, reply: '', isComposing: false } : {}) } : s))
813	await loadSheet($, id)
814	await refresh($)
815}
816
817// One write on a ticket's comments; a toast says what failed.
818async function commentCall($: EngineInterface, id: number, method: 'POST' | 'PATCH' | 'DELETE', seq: number | null, body?: Record<string, unknown>): Promise<boolean> {
819	if (!config) return false
820	try {
821		const res = await $.http.fetch(`${config.api}/items/${id}/comments${seq === null ? '' : `/c${seq}`}?project=${encodeURIComponent(config.project)}&create=0`, {
822			method,
823			headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
824			...(body ? { body: JSON.stringify(body) } : {}),
825		})
826		if (!res.ok) { $.ui.toast(`Could not ${method === 'DELETE' ? 'delete' : 'save'} the comment on #${id} (${res.status})`); return false }
827		return true
828	} catch {
829		$.ui.toast(`Could not ${method === 'DELETE' ? 'delete' : 'save'} the comment on #${id}: offline`)
830		return false
831	}
832}
833
834// Move a todo one row up (-1) or down (+1) in the band: just above / below its
835// neighbour, in the project's full order (ideas and notes keep their places),
836// sent whole to /items/reorder.
837async function move($: EngineInterface, id: number, dir: -1 | 1) {
838	if (!config) return
839	const list = await read($, items)
840	const at = list.findIndex(i => i.id === id)
841	const neighbour = list[at + dir]
842	if (at < 0 || !neighbour) return
843	const ids = (await read($, order)).filter(x => x !== id)
844	const n = ids.indexOf(neighbour.id)
845	ids.splice(dir < 0 ? Math.max(0, n) : n + 1, 0, id)
846	// Optimistic: redraw now, the refresh below brings the server's truth.
847	await update($, items, l => { const c = [...l]; const [m] = c.splice(at, 1); if (m) c.splice(at + dir, 0, m); return c })
848	try {
849		const res = await $.http.fetch(`${config.api}/items/reorder?project=${encodeURIComponent(config.project)}&create=0`, {
850			method: 'POST',
851			headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
852			body: JSON.stringify({ order: ids, position: 'top' }),
853		})
854		if (!res.ok) $.ui.toast(`Reorder failed (${res.status})`)
855	} catch {
856		$.ui.toast('Reorder failed: offline')
857	}
858	await refresh($)
859}
860
861// The body a Floatee write redirects from a file (`edit N -m - < body.md`),
862// for G3; empty when there is none.
863async function bodyFile($: EngineInterface, command: string): Promise<string> {
864	if (!cliCalls(command).some(c => ['edit', 'update', 'add'].includes(c.cmd))) return ''
865	const file = redirectedFile(command)
866	if (!file) return ''
867	const path = file.startsWith('/') ? file : `${await $.session.cwd()}/${file}`
868	try { return (await $.fs.exists(path)) ? await $.fs.read(path) : '' } catch { return '' }
869}
870
871type FullItem = ApiItem & {
872	resolutionNote?: string | null
873	createdByName?: string | null
874	assigneeName?: string | null
875	projectName?: string | null
876	createdAt?: number
877	activity?: Array<{ kind: string; seq?: number | null; bodyMd: string | null; authorName: string | null; authorType: string; id?: number; meta?: { from?: string; to?: string; note?: string; editedAt?: number } | null; createdAt?: number; canEdit?: boolean }>
878}
879
880// One item, whole (body and activity); throws when the API says no.
881async function fetchItem($: EngineInterface, id: number): Promise<FullItem> {
882	if (!config) throw new Error('no config')
883	const res = await $.http.fetch(`${config.api}/items/${id}?project=${encodeURIComponent(config.project)}&format=json&create=0`, { headers: { Authorization: `Bearer ${config.token}` } })
884	if (!res.ok) throw new Error(String(res.status))
885	const item = JSON.parse(res.text) as FullItem
886	if (typeof item?.bodyMd !== 'string') throw new Error('not an item')
887	return item
888}
889
890async function setChecklist($: EngineInterface, item: FullItem) { await setChecklistOf($, item) }
891async function setChecklistOf($: EngineInterface, item: FullItem) {
892	await update($, checklist, () => ({ id: item.id, boxes: boxes(item.bodyMd), since: claimedAt(item.activity) }))
893	// #679: the pane details it too: its description's first lines (the title,
894	// boxes and code left out) and its last comment.
895	const lines = item.bodyMd.split('\n').slice(1)
896	let inFence = false
897	const intro = lines.filter(l => {
898		if (/^\s*(```|~~~)/.test(l)) { inFence = !inFence; return false }
899		return !inFence && l.trim() !== '' && !/^\s*[-*+]\s+\[[ xX]\]/.test(l)
900	}).slice(0, 3).map(firstLine)
901	const said = (item.activity ?? []).filter(a => a.kind === 'comment' && a.bodyMd?.trim()).pop()
902	await update($, detail, () => ({ id: item.id, intro, last: said ? { by: said.authorName ?? 'someone', text: firstLine(said.bodyMd ?? '') } : null }))
903}
904
905// F3: the session's ticket's boxes, re-read with the band (the agent may tick
906// them meanwhile). A failed fetch keeps what is drawn.
907async function loadChecklist($: EngineInterface) {
908	const id = await read($, active)
909	if (id === null) { await update($, checklist, () => null); return }
910	try { await setChecklist($, await fetchItem($, id)) } catch { /* keep the last one */ }
911}
912
913// F8: load the session's ticket for the system prompt (none when `active` is
914// null), and its checklist from the same fetch. A failed fetch keeps the
915// earlier text for the same ticket.
916async function loadTicket($: EngineInterface) {
917	const id = await read($, active)
918	if (id === null || !config) {
919		await update($, ticket, () => null)
920		await update($, checklist, () => null)
921		await update($, detail, () => null)
922		return
923	}
924	try {
925		const item = await fetchItem($, id)
926		await update($, ticket, () => ({ id, text: ticketContext(item) }))
927		await setChecklist($, item)
928	} catch {
929		await update($, ticket, t => (t?.id === id ? t : null))
930	}
931}
932
933// A box pressed in the band: flip it in the ticket's body, read fresh so an
934// edit made meanwhile is kept, the rest of the markdown untouched. The system
935// prompt's copy (F8) stays as loaded: it says "when loaded".
936async function tick($: EngineInterface, id: number, box: Box) {
937	if (!config) return
938	await update($, checklist, c => c && c.id === id
939		? { ...c, boxes: c.boxes.map(b => (b.line === box.line ? { ...b, isDone: !b.isDone } : b)) }
940		: c)
941	// #747: a box pressed in a ticket's sheet; the session's checklist is its
942	// own ticket's alone.
943	const ours = id === await read($, active)
944	const sheetBody = (body: string) => update($, sheet, s => (s.id === id ? { ...s, body } : s))
945	await update($, sheet, s => (s.id === id && s.body !== null ? { ...s, body: toggle(s.body, box) ?? s.body } : s))
946	const setChecklist = async (item: FullItem) => { await sheetBody(item.bodyMd); if (ours) await setChecklistOf($, item) }
947	try {
948		const item = await fetchItem($, id)
949		const body = toggle(item.bodyMd, box)
950		if (body === null) { $.ui.toast(`#${id} changed meanwhile: box not updated`); await setChecklist(item); return }
951		const res = await $.http.fetch(`${config.api}/items/${id}?project=${encodeURIComponent(config.project)}&format=json&create=0`, {
952			method: 'PATCH',
953			headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
954			body: JSON.stringify({ body_md: body }),
955		})
956		if (!res.ok) { $.ui.toast(`Could not update #${id} (${res.status})`); await setChecklist(item); return }
957		await setChecklist({ ...item, bodyMd: body })
958	} catch {
959		$.ui.toast(`Could not update #${id}: offline`)
960		if (ours) await loadChecklist($)
961	}
962}
963
964// After a shell command ran: a passing check verifies the ticket in progress;
965// start / claim / done… move the session's ticket.
966async function track($: EngineInterface, command: string, output: string) {
967	const mine = await read($, active)
968	if (mine !== null && isVerification(command, config?.verify ?? [])) await update($, verified, () => true)
969	const now = nextActive(command, output, mine)
970	if (now === mine) return
971	// A todo the watcher's run got done.
972	if (mine !== null && now === null && cliCalls(command).some(c => c.cmd === 'done' && c.id === mine)) {
973		await update($, watch, w => (w.inTurn === null ? w : { ...w, done: w.done + 1, inTurn: w.inTurn + 1 }))
974	}
975	await update($, active, () => now)
976	await update($, verified, () => false)
977	await update($, heard, () => false)
978	// The band shows the claim / close now, not at the next refresh; the
979	// system prompt follows the ticket.
980	await refresh($).catch(() => undefined)
981	await loadTicket($)
982}
983
984// #688: the band stacks with other mods'. What the hooks after ours draw
985// (`next(e)`) sits above the list, so the band reads the same whichever mod
986// loads first, and shares its rows. The engine's own band is an empty Box.
987function isBlank(node: RenderNode): boolean {
988	if (typeof node === 'string') return node.trim() === ''
989	if (node.type !== 'Box' && node.type !== 'Text') return false
990	return (node.children ?? []).every(isBlank)
991}
992
993// Rows a tree takes, roughly: a column adds its children up, a row takes its
994// tallest, a Text its lines; borders, paddings and margins add theirs.
995function rowsOf(node: RenderNode): number {
996	if (typeof node === 'string') return node.split('\n').length
997	const props = ('props' in node ? node.props : undefined) as Record<string, unknown> | undefined
998	const children = 'children' in node && Array.isArray(node.children) ? node.children as RenderNode[] : []
999	if (node.type === 'Text') return children.filter(c => typeof c === 'string').join('').split('\n').length
1000	if (node.type !== 'Box') return 1
1001	const own = children.map(rowsOf)
1002	const body = props?.flexDirection === 'column' ? own.reduce((a, b) => a + b, 0) : Math.max(0, ...own)
1003	const n = (k: string) => (typeof props?.[k] === 'number' ? props[k] as number : 0)
1004	const edges = (k: string) => (n(`${k}Top`) || n(`${k}Y`) || n(k)) + (n(`${k}Bottom`) || n(`${k}Y`) || n(k))
1005	return body + edges('padding') + edges('margin') + (props?.borderStyle ? 2 : 0)
1006}
1007
1008type Site = { isPane: boolean; busy: boolean; maxRows: number; bodyColumns: number }
1009
1010async function saveLayout($: EngineInterface, to: 'band' | 'pane') {
1011	await update($, layout, () => to)
1012	await $.store.set('layout', to).catch(() => undefined)
1013}
1014
1015async function toggleMine($: EngineInterface) {
1016	const on = !await read($, isMine)
1017	await update($, isMine, () => on)
1018	await $.store.set('mine', on).catch(() => undefined)
1019	rebase = true
1020	await refresh($)
1021}
1022
1023// The person's press: placed at any width.
1024async function setLayout($: EngineInterface, to: 'band' | 'pane') {
1025	await saveLayout($, to)
1026	if (to === 'pane') await $.ui.open({ id: PANE, title: 'Floatee' })
1027	else await $.ui.close({ id: PANE })
1028}
1029
1030// The list, in the band or the pane (#679): header (title, count, Run next, Refresh), then one row per open
1031// todo in manual order — left the id and the title, right the state and the
1032// row's actions.
1033async function draw($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], site: Site) {
1034	const slug = await read($, project)
1035	if (!slug) return null
1036
1037	const elements = $.ui.resolve(e)
1038	const { Box, Button, Text } = elements
1039	// No Input on the mobile app: no field there. (Its table may still name
1040	// it: the surface tells.)
1041	const hasFields = e.surface !== 'mobile'
1042	const Input = hasFields && 'Input' in elements ? elements.Input : null
1043	// #751: under a dialog, a field says why it takes nothing, instead of staying deaf.
1044	const isAsked = await read($, asked) > 0
1045	const field = (input: unknown) => (isAsked
1046		? <Text color="yellow" wrap="wrap">⏸ Answer Claude's question first: this field takes typing again once it's closed.</Text>
1047		: input)
1048	const typed = await read($, draft)
1049	// (Over SHEET_OFF: a sheet a previous version left in the state lacks the fields since.)
1050	const opened = { ...SHEET_OFF, ...await read($, sheet) }
1051	// A ticket's `#id` opens its page in the web app (#677): a bare Button,
1052	// not a Link, which a terminal without OSC 8 draws with the URL spelled
1053	// out beside it. A Button takes no color: the id draws plain. Plain text
1054	// when its workspace is unknown.
1055	const idOf = (item: Item, color: string, bold?: boolean) => {
1056		const url = pageOf(item)
1057		return url
1058			? <Button key={`open-${item.id}`} label={`#${item.id}`} plain onPress={() => openPage($, url)} />
1059			: <Text color={color} bold={bold}>{`#${item.id}`}</Text>
1060	}
1061	const list = await read($, items)
1062	const problem = await read($, error)
1063	const mineOnly = await read($, isMine)
1064	const paused = await read($, pausedUntil)
1065	// The engine's own word on whether a turn runs; our flag can lag a reload.
1066	const busy = site.busy
1067	const sent = await read($, launched)
1068	const folded = !site.isPane && await read($, isCollapsed)
1069	// The ticket this session works on: closed from the band, and while it
1070	// is open no other one is launched (G5). Ours first, even before a
1071	// refresh has seen its claim.
1072	const mine = await read($, active)
1073	const watching = await read($, watch)
1074	// F10: blocked todos with a reply waiting: Resume in Run's place.
1075	const watched = await read($, blocked)
1076	const replyIds = list.filter(i => i.status === 'blocked' && watched[i.id]?.reply).map(i => i.id)
1077	const current = list.find(i => i.id === mine) ?? list.find(i => i.status === 'in_progress')
1078	const waiting = list.filter(i => i.status === 'pending' && i.id !== mine).length
1079	// A ticket in progress (maybe another agent's) doesn't hold the queue back.
1080	const canLaunch = !problem && waiting > 0 && !busy && sent === null && mine === null
1081	// Otherwise (Claude works, a launch is queued, a ticket of ours is open)
1082	// the launchers stay in view, greyed and inert: a Button has no disabled
1083	// state, so a Text drawn like one.
1084	const isHeld = !problem && waiting > 0 && !canLaunch
1085	// Close: a whole Button, the band's main action on our ticket. The
1086	// terminal colors a Button in one way only, primary = the accent (blue):
1087	// it takes no color of ours, and drawing it bare between colored brackets
1088	// leaves the brackets unpressable. Folded too it keeps its chrome, to stay
1089	// blue (a bare Button draws plain): `[ ✓ ]` beside the compact `[▶]`.
1090	const closeButton = (id: number, isShort: boolean) => (
1091		<Button key={`close-${id}`} label={isShort ? '✓' : '✓ Close'} variant="primary" dimColor={busy} onPress={() => close($, id)} />
1092	)
1093	// #669: on a narrow terminal (or pane) the labels give way to their glyph,
1094	// so the titles keep some room.
1095	// Terminal only: Desktop draws native buttons in its own font, where a
1096	// glyph alone reads as nothing and the cell count says little.
1097	const narrow = e.surface === 'terminal' && site.bodyColumns < (site.isPane ? NARROW_PANE : NARROW)
1098	// Folded, the ticket in progress shares the line: a little more room
1099	// is asked before the labels show in full.
1100	const short = narrow || (folded && e.surface === 'terminal' && site.bodyColumns < NARROW + FOLDED_EXTRA)
1101	const label = (long: string, g: string) => (narrow ? g : long)
1102	// Short, buttons are drawn without chrome: we write the brackets ourselves.
1103	const chip = short ? true : undefined
1104	// #678: Claude Desktop draws every Button as a native button, plain or
1105	// not: brackets there would sit inside it. They are the terminal's alone,
1106	// on the glyphs and on the greyed stand-ins.
1107	const isTerminal = e.surface === 'terminal'
1108	const glyph = (g: string) => isTerminal ? `[${g}]` : g
1109	const greyed = (label: string) => isTerminal ? `[ ${label} ]` : label
1110	// The yellow frame, the band's look, in the pane too (inside the engine's).
1111	const frame = { borderStyle: 'round' as const, borderColor: brand, paddingX: 1 }
1112	const cap = site.isPane ? Infinity : SHOWN
1113
1114	// Header. The first line: title + counts (+ the ticket in progress when
1115	// folded) on the left; on the right the main action (Stop while Claude
1116	// works, else Run next), Refresh, band/pane and the fold toggle. Unfolded,
1117	// a second line flush right (Watch, Mine, New), then a blank one before
1118	// the tickets.
1119	const chip2 = narrow ? true : undefined
1120	const header = (
1121		<Box flexDirection="column" marginBottom={folded ? 0 : 1}>
1122			<Box justifyContent="space-between" gap={1}>
1123				{/* Title and count never shrink (they would break mid-word); only the
1124				    ticket in progress gives way, truncated, down to nothing. */}
1125				<Box gap={1} flexShrink={1} overflow="hidden">
1126					<Box gap={1} flexShrink={0}>
1127						<Text color={brand} bold>⨳ Floatee</Text>
1128						{problem
1129							? <Text color="error">{problem}</Text>
1130							: <Text dimColor>{`${list.length === 0 ? 'nothing open' : `${list.length} open`}${mineOnly ? ' · mine' : ''}`}</Text>}
1131						{/* #669: over the per-minute cap, the list waits as the API said. */}
1132						{paused > 0 && <Text color="warning">{short ? '⏸' : '⏸ rate limited'}</Text>}
1133					</Box>
1134					{folded && replyIds.length > 0 && (
1135							<Box flexShrink={0}><Text color="warning">{`✉ ${replyIds.map(id => `#${id}`).join(' ')}`}</Text></Box>
1136					)}
1137					{folded && current && (
1138						<Box flexShrink={1} overflow="hidden">
1139							<Box gap={1} flexShrink={1} overflow="hidden">
1140								<Box gap={1} flexShrink={0}>
1141									<Text color="success">·</Text>
1142									{idOf(current, 'success')}
1143								</Box>
1144								<Text color="success" wrap="truncate-end">{current.title}</Text>
1145							</Box>
1146						</Box>
1147					)}
1148				</Box>
1149				<Box gap={short ? 0 : 1} flexShrink={0}>
1150					{/* Folded, the buttons shrink to a bracketed glyph, side by side
1151					    ([■][▶][»][⟳][+]); the fold toggle reads [+]/[-] like the engine's. */}
1152					{busy && <Button key="stop" label={short ? glyph('■') : '■ Stop'} plain={chip} onPress={() => stop($)} />}
1153					{folded && mine !== null && closeButton(mine, short)}
1154					{/* With Mine on, "next" is my first waiting ticket, not the project's. */}
1155					{canLaunch && <Button key="next" label={short ? glyph('▶') : '▶ Run next'} plain={chip} variant="primary" onPress={() => launch($, mineOnly ? list.find(i => i.status === 'pending' && i.id !== mine)?.id ?? null : null)} />}
1156					{isHeld && <Text color={GREY}>{short ? glyph('▶') : greyed('▶ Run next')}</Text>}
1157					<Button key="refresh" label={short ? glyph('⟳') : '⟳ Refresh'} plain={chip} dimColor onPress={() => refresh($)} />
1158					{/* #679: the list moves to a pane and back. */}
1159					{site.isPane
1160						? <Button key="layout" label="⇤ Band" dimColor onPress={() => setLayout($, 'band')} />
1161						: <Button key="layout" label={short ? glyph('⇥') : '⇥ Pane'} plain={chip} dimColor onPress={() => setLayout($, 'pane')} />}
1162					{!site.isPane && <Button key="fold" label={isTerminal ? (folded ? '[+]' : '[-]') : (folded ? '+' : '−')} plain dimColor onPress={() => update($, isCollapsed, v => !v)} />}
1163				</Box>
1164			</Box>
1165			{!folded && <Box justifyContent="flex-end" gap={narrow ? 0 : 1}>
1166				{/* F9: Watch, blue while on, with its count against the limit. */}
1167				{watching.isOn
1168					? <Button key="watch" label={narrow ? '◉' : `◉ Watching ${watching.done}${config?.watchMax ? `/${config.watchMax}` : ''}`} variant="primary" onPress={() => toggleWatch($)} />
1169					: <Button key="watch" label={narrow ? glyph('◉') : '◉ Watch'} plain={chip2} onPress={() => toggleWatch($)} />}
1170				{mineOnly
1171					? <Button key="mine" label={narrow ? '◐' : '◐ Mine'} variant="primary" onPress={() => toggleMine($)} />
1172					: <Button key="mine" label={narrow ? glyph('◐') : '◐ Mine'} plain={chip2} dimColor onPress={() => toggleMine($)} />}
1173				{Input && <Button key="new" label={narrow ? glyph('✎') : '✎ New'} plain={chip2} onPress={async () => { await update($, sheet, () => SHEET_OFF); await update($, draft, d => ({ ...d, isOpen: true })) }} />}
1174			</Box>}
1175		</Box>
1176	)
1177	// #680: the new-ticket mode takes the whole band, folded or not: the
1178	// field, what it understands, Create and Cancel.
1179	// A press leaves the keys in the prompt and $.ui.focus is refused ("that
1180	// site does not hold the keyboard"): a click on the band gives them, and
1181	// autoFocus lands them in the field.
1182	// (The engine always draws ` ⏎ <submitLabel>` after a focused field: an
1183	// empty label leaves the bare ⏎.)
1184	if (Input && typed.isOpen) {
1185		const nowMs = (await read($, now)) ?? 0
1186		const text = typed.text
1187		const parsed = parseTicket(text)
1188		const newType = namesType(text) && parsed ? parsed.type : typed.type ?? 'todo'
1189		const newTags = [...new Set([...(typed.tags ?? []), ...(parsed?.tags ?? [])])]
1190		const newLabel = (name: string) => <Box flexShrink={0}><Text dimColor>{name.padEnd(8)}</Text></Box>
1191		// Assignee and project as the sheet's: the choice, then its options on a press.
1192		const newPick = (what: 'assignee' | 'project', name: string, value: string, action: string) => (
1193			<Box flexDirection="column">
1194				<Box gap={1}>
1195					{newLabel(name)}
1196					<Text wrap="truncate-end">{value}</Text>
1197					{typed.picking !== what && <Button key={`new-${what}`} label={action} dimColor onPress={() => pickNew($, what)} />}
1198				</Box>
1199				{typed.picking === what && (
1200					<Box gap={1} flexWrap="wrap" paddingLeft={9}>
hooks/replies.ts 36 lines
1// F10: a reply on a blocked ticket — someone (a person, not an agent) commented
2// after it was blocked, most likely the answer it waits for. Pure: the caller
3// fetches the item's activity.
4
5type Activity = { kind: string; authorType: string; authorName: string | null; bodyMd?: string | null; meta?: { to?: string } | null; createdAt?: number }
6
7export type Reply = { at: number; by: string }
8
9// The latest human comment since the ticket last went blocked; null when none.
10export function replyOf(activity: Activity[] | undefined): Reply | null {
11	const list = activity ?? []
12	let start = -1
13	list.forEach((a, i) => { if (a.kind === 'status_change' && a.meta?.to === 'blocked') start = i })
14	if (start < 0) return null
15	const human = list.slice(start + 1).filter(a => a.kind === 'comment' && a.authorType !== 'agent' && a.authorType !== 'system').pop()
16	return human ? { at: human.createdAt ?? 0, by: human.authorName ?? 'someone' } : null
17}
18
19// #679: why it was blocked: the agent's comment that came with the block (just
20// after the status change), else the last comment before it; its first line.
21export function reasonOf(activity: Activity[] | undefined): string | null {
22	const list = activity ?? []
23	let start = -1
24	list.forEach((a, i) => { if (a.kind === 'status_change' && a.meta?.to === 'blocked') start = i })
25	if (start < 0) return null
26	const isText = (a: Activity) => a.kind === 'comment' && !!a.bodyMd?.trim()
27	const said = list.slice(start + 1).find(a => isText(a) && a.authorType === 'agent') ?? list.slice(0, start).filter(isText).pop()
28	return said ? firstLine(said.bodyMd ?? '') : null
29}
30
31// A comment's first line of text, its markdown marks dropped.
32export function firstLine(md: string): string {
33	const line = md.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
34	return line.replace(/^#+\s*|^[-*>]\s+/, '').replace(/\*\*|__|`/g, '')
35}
36
hooks/watch.ts 26 lines
1// F9: the watcher — the band watches the queue without calling the model and
2// starts one autonomous run per todo while Claude is idle. Pure: what to do at
3// the end of a run it started, given what that run closed.
4
5export type Watch = { isOn: boolean; done: number; failures: number; inTurn: number | null }
6export type Limits = { max: number | null; failures: number }
7
8export const WATCH_OFF: Watch = { isOn: false, done: 0, failures: 0, inTurn: null }
9
10// The run's argument: one todo, then the turn ends (no re-poll, which would
11// spend tokens waiting): the band starts the next run when one is there.
12export const WATCH_ARGS = 'once: process ONE todo end to end, then end your turn (no waiting, no re-poll); the Floatee band watches the queue and starts the next run'
13
14// After a run the watcher started: counted, and stopped when a limit is met.
15// `closed` is how many todos it closed done; `left` the ticket it left open.
16export function afterRun(w: Watch, limits: Limits, left: number | null): { watch: Watch; stop: string | null } {
17	const closed = w.inTurn ?? 0
18	const done = w.done
19	const failures = closed > 0 ? 0 : w.failures + 1
20	const next = { ...w, failures, inTurn: null }
21	if (left !== null) return { watch: { ...next, isOn: false }, stop: `#${left} was left in progress` }
22	if (limits.max !== null && done >= limits.max) return { watch: { ...next, isOn: false }, stop: `${done} todos done (watch_max=${limits.max})` }
23	if (failures >= limits.failures) return { watch: { ...next, isOn: false }, stop: `${failures} runs in a row closed nothing (watch_failures=${limits.failures})` }
24	return { watch: next, stop: null }
25}
26
hooks/newticket.ts 33 lines
1// #680: a ticket typed in the band's field — the first line its title, the rest
2// its body (Alt+Enter for a new line). A reduced quick-add, as the app's:
3// `+tag` and `!todo` / `!idea` / `!note` anywhere, taken out of the text. Pure.
4
5export type NewTicket = { body: string; type: 'todo' | 'idea' | 'note'; tags: string[] }
6
7const TOKEN_RE = /(^|[ \t])(\+[^\s+]+|!(?:todo|idea|note))(?=\s|$)/gi
8
9// null when nothing but tokens and blanks was typed.
10export function parseTicket(text: string): NewTicket | null {
11	const tags: string[] = []
12	let type: NewTicket['type'] = 'todo'
13	const body = text
14		.replace(TOKEN_RE, (_m, lead: string, tok: string) => {
15			if (tok[0] === '+') { const t = tok.slice(1).toLowerCase(); if (!tags.includes(t)) tags.push(t) } else type = tok.slice(1).toLowerCase() as NewTicket['type']
16			return lead
17		})
18		.split('\n').map(l => l.replace(/[ \t]+$/, '')).join('\n')
19		.replace(/^\s*\n/, '').trim()
20	return body ? { body, type, tags } : null
21}
22
23// #751: whether the text names its type (`!idea`…), which then wins over the
24// form's buttons.
25export function namesType(text: string): boolean {
26	return /(^|[ \t])!(?:todo|idea|note)(?=\s|$)/i.test(text)
27}
28
29// #751: a `+tag` taken out of the text (its ✕ in the form's tags).
30export function dropTag(text: string, tag: string): string {
31	return text.replace(/(^|[ \t])\+([^\s+]+)(?=\s|$)/g, (m, lead: string, t: string) => (t.toLowerCase() === tag ? lead : m))
32}
33
hooks/markdown.ts 91 lines
1// #747: a ticket's markdown, read into lines the sheet draws with Text styles.
2// A line-by-line reading (the bodies agents write keep their own line breaks):
3// headings, lists, task boxes, quotes, rules and fenced code; inside a line,
4// bold, italic, strikethrough, inline code, links and images.
5
6export type Span = { text: string; bold?: boolean; italic?: boolean; strike?: boolean; code?: boolean; link?: boolean }
7
8export type Block =
9	| { kind: 'heading'; level: number; spans: Span[] }
10	| { kind: 'item'; indent: number; marker: string; spans: Span[] }
11	| { kind: 'task'; indent: number; isDone: boolean; spans: Span[] }
12	| { kind: 'quote'; spans: Span[] }
13	| { kind: 'code'; text: string }
14	| { kind: 'rule' }
15	| { kind: 'blank' }
16	| { kind: 'para'; spans: Span[] }
17
18type Style = Omit<Span, 'text'>
19
20// Paired markers, longest first so `**` wins over `*`.
21const PAIRS: Array<[string, keyof Style]> = [['**', 'bold'], ['__', 'bold'], ['~~', 'strike'], ['*', 'italic'], ['_', 'italic']]
22
23export function inline(text: string, style: Style = {}): Span[] {
24	const out: Span[] = []
25	let plain = ''
26	const flush = () => { if (plain) { out.push({ text: plain, ...style }); plain = '' } }
27	let i = 0
28	while (i < text.length) {
29		const rest = text.slice(i)
30		// An escaped character stands for itself.
31		if (rest[0] === '\\' && rest.length > 1 && /[\\`*_~[\]()#!>-]/.test(rest[1]!)) { plain += rest[1]; i += 2; continue }
32		// Inline code: nothing parsed inside.
33		const code = rest.match(/^(`+)([\s\S]*?[^`])\1(?!`)/)
34		if (code) { flush(); out.push({ text: code[2]!, ...style, code: true }); i += code[0].length; continue }
35		// An image: its alt text, marked as such (a terminal draws no image).
36		const image = rest.match(/^!\[([^\]]*)\]\(([^)\s]*)[^)]*\)/)
37		if (image) { flush(); out.push({ text: `🖼 ${image[1] || 'image'}`, ...style, italic: true }); i += image[0].length; continue }
38		// A link: its text, styled as a link (the URL stays on the site).
39		const link = rest.match(/^\[([^\]]+)\]\(([^)\s]*)[^)]*\)/)
40		if (link) { flush(); out.push(...inline(link[1]!, { ...style, link: true })); i += link[0].length; continue }
41		const pair = PAIRS.find(([m]) => rest.startsWith(m))
42		if (pair) {
43			const [m, key] = pair
44			const end = text.indexOf(m, i + m.length)
45			// `_` inside a word (snake_case) is no emphasis; nor a marker before a space.
46			const isWordy = m[0] === '_' && /\w/.test(text[i - 1] ?? '')
47			if (end > i + m.length && !isWordy && !/\s/.test(text[i + m.length]!) && !/\s/.test(text[end - 1]!)) {
48				flush()
49				out.push(...inline(text.slice(i + m.length, end), { ...style, [key]: true }))
50				i = end + m.length
51				continue
52			}
53		}
54		plain += rest[0]
55		i += 1
56	}
57	flush()
58	return out
59}
60
61export function blocks(md: string): Block[] {
62	const out: Block[] = []
63	let fence: string | null = null
64	for (const line of md.replace(/\r\n?/g, '\n').split('\n')) {
65		const opens = line.match(/^\s*(```+|~~~+)/)
66		if (fence !== null) {
67			if (opens && opens[1]!.startsWith(fence)) fence = null
68			else out.push({ kind: 'code', text: line })
69			continue
70		}
71		if (opens) { fence = opens[1]!; continue }
72		if (!line.trim()) { out.push({ kind: 'blank' }); continue }
73		const heading = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/)
74		if (heading) { out.push({ kind: 'heading', level: heading[1]!.length, spans: inline(heading[2]!) }); continue }
75		if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { out.push({ kind: 'rule' }); continue }
76		const task = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s?(.*)$/)
77		if (task) { out.push({ kind: 'task', indent: task[1]!.length >> 1, isDone: task[2] !== ' ', spans: inline(task[3]!) }); continue }
78		const item = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/)
79		if (item) {
80			const marker = /\d/.test(item[2]!) ? item[2]! : '•'
81			out.push({ kind: 'item', indent: item[1]!.length >> 1, marker, spans: inline(item[3]!) })
82			continue
83		}
84		const quote = line.match(/^\s*>\s?(.*)$/)
85		if (quote) { out.push({ kind: 'quote', spans: inline(quote[1]!) }); continue }
86		out.push({ kind: 'para', spans: inline(line) })
87	}
88	// Blank lines: one at most in a row, none at the ends.
89	return out.filter((b, n, all) => b.kind !== 'blank' || (n > 0 && n < all.length - 1 && all[n - 1]!.kind !== 'blank'))
90}
91
hooks/snooze.ts 37 lines
1// #750: when a ticket wakes, from what the sheet's field takes: a delay
2// (`12h`, `3d`, `2w`, `1m`, `1y`) or a local date (`2026-09-01`, at midnight;
3// `2026-09-01T09:00` or with a space). The CLI's reading (floatee snooze), so
4// both agree: an epoch in seconds, never a duration, and never a past one.
5
6const UNIT_SECONDS: Record<string, number> = { h: 3600, d: 86400, w: 7 * 86400, m: 30 * 86400, y: 365 * 86400 }
7export const WHEN_SYNTAX = '12h, 3d, 2w, 1m, 1y or 2026-09-01[T09:00]'
8
9// The wake-up epoch (s), or why the text doesn't read as one.
10export function wakeAt(text: string, nowMs: number): number | { error: string } {
11	const s = text.trim()
12	const now = Math.floor(nowMs / 1000)
13	const rel = s.match(/^(\d+)\s*([hdwmy])$/i)
14	let epoch: number
15	if (rel) {
16		epoch = now + Math.max(1, parseInt(rel[1]!, 10)) * UNIT_SECONDS[rel[2]!.toLowerCase()]!
17	} else {
18		const abs = s.match(/^(\d{4})-?(\d{2})-?(\d{2})(?:[T ](\d{2}):(\d{2}))?$/)
19		if (!abs) return { error: `Can't read "${s}": use ${WHEN_SYNTAX}` }
20		const dt = new Date(+abs[1]!, +abs[2]! - 1, +abs[3]!, abs[4] ? +abs[4] : 0, abs[5] ? +abs[5] : 0)
21		if (isNaN(dt.getTime())) return { error: `"${s}" isn't a date` }
22		epoch = Math.floor(dt.getTime() / 1000)
23	}
24	if (epoch <= now) return { error: 'That time is already past' }
25	return epoch
26}
27
28// A wake-up as the person reads it: `Thu 16 Oct, 09:00` (the year when not this one).
29export function wakeLabel(epoch: number, nowMs: number): string {
30	const d = new Date(epoch * 1000)
31	const p = (n: number) => String(n).padStart(2, '0')
32	const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()]
33	const month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()]
34	const year = d.getFullYear() === new Date(nowMs).getFullYear() ? '' : ` ${d.getFullYear()}`
35	return `${day} ${d.getDate()} ${month}${year}, ${p(d.getHours())}:${p(d.getMinutes())}`
36}
37
hooks/checklist.ts 57 lines
1// F3: the checklist of the ticket in progress, drawn under its row in the band,
2// and the time since it was claimed. Pure: the caller fetches the item and
3// writes the body back.
4
5import type { Box } from '../types'
6
7const BOX_RE = /^(\s*[-*+]\s+\[)([ xX])(\]\s?)(.*)$/
8const FENCE_RE = /^\s*(```|~~~)/
9
10// Every task box of a markdown body, with its line number (code blocks skipped).
11export function boxes(body: string): Box[] {
12	const out: Box[] = []
13	let inFence = false
14	body.split('\n').forEach((l, line) => {
15		if (FENCE_RE.test(l)) { inFence = !inFence; return }
16		const m = inFence ? null : l.match(BOX_RE)
17		if (m) out.push({ line, text: (m[4] ?? '').trim(), isDone: m[2] !== ' ' })
18	})
19	return out
20}
21
22// The body with one box flipped and nothing else touched; null when the line no
23// longer holds that box (the body changed since it was drawn).
24export function toggle(body: string, box: Box): string | null {
25	const lines = body.split('\n')
26	const m = lines[box.line]?.match(BOX_RE)
27	if (!m || (m[4] ?? '').trim() !== box.text || (m[2] !== ' ') !== box.isDone) return null
28	lines[box.line] = `${m[1]}${box.isDone ? ' ' : 'x'}${m[3]}${m[4]}`
29	return lines.join('\n')
30}
31
32// The boxes drawn, at most `max`: all of them in order when they fit or the
33// list is opened, else the unchecked ones only. `more` (unchecked) and
34// `checked` count what's left out, for the "+N more · M checked" line.
35export function visible(all: Box[], max: number, isOpen = false): { shown: Box[]; more: number; checked: number } {
36	const pool = isOpen || all.length <= max ? all : all.filter(b => !b.isDone)
37	const shown = pool.slice(0, max)
38	const left = all.filter(b => !shown.includes(b))
39	return { shown, more: left.filter(b => !b.isDone).length, checked: left.filter(b => b.isDone).length }
40}
41
42type Activity = { kind: string; meta?: { to?: string } | null; createdAt?: number }
43
44// When the ticket last went in progress, in ms (the API counts in seconds).
45export function claimedAt(activity: Activity[] | undefined): number | null {
46	const last = (activity ?? []).filter(a => a.kind === 'status_change' && a.meta?.to === 'in_progress' && a.createdAt).pop()
47	return last?.createdAt ? last.createdAt * 1000 : null
48}
49
50export function elapsed(ms: number): string {
51	const min = Math.max(0, Math.floor(ms / 60_000))
52	if (min < 1) return 'just now'
53	if (min < 60) return `${min}m`
54	const h = Math.floor(min / 60)
55	return h < 24 ? `${h}h ${min % 60}m` : `${Math.floor(h / 24)}d ${h % 24}h`
56}
57
hooks/config.ts 40 lines
1// The repo's `.floatee`, shared by the band and the guards. Read by the mod
2// only: the token stays in module memory, never in $.state, a log or a toast.
3export type Config = {
4	token: string
5	api: string
6	project: string
7	/** The web app, where a ticket's page lives (`web=`; else the API's origin). */
8	web: string
9	/** Extra verification commands (`verify=a, b`), on top of the defaults. */
10	verify: string[]
11	/** The watcher's limits (F9): todos done before it stops (null: no limit), runs in a row closing nothing. */
12	watchMax: number | null
13	watchFailures: number
14}
15
16const positive = (v: string | undefined, fallback: number) => {
17	const n = Number(v)
18	return Number.isInteger(n) && n > 0 ? n : fallback
19}
20
21// `key=value` lines, `#` comments — the CLI's own format.
22export function parseConfig(text: string): Config | null {
23	const kv: Record<string, string> = {}
24	for (const line of text.split(/\r?\n/)) {
25		const m = line.match(/^\s*([a-z_]+)\s*=\s*(.*?)\s*$/i)
26		if (m && m[1] && !line.trim().startsWith('#')) kv[m[1]] = m[2] ?? ''
27	}
28	if (!kv.token || !kv.project) return null
29	const api = (kv.api || 'https://floatee.me/api/v1').replace(/\/+$/, '')
30	return {
31		token: kv.token,
32		api,
33		web: (kv.web || api.replace(/(\/api)?\/v1$/, '')).replace(/\/+$/, ''),
34		project: kv.project,
35		verify: (kv.verify ?? '').split(',').map(s => s.trim()).filter(Boolean),
36		watchMax: kv.watch_max ? positive(kv.watch_max, 0) || null : null,
37		watchFailures: positive(kv.watch_failures, 2),
38	}
39}
40
hooks/context.ts 36 lines
1// F8: the ticket this session works on, as a section of the system prompt, so
2// the agent keeps it after a /compact or a long turn. Pure: the caller fetches
3// the item (GET /items/:id?format=json) and hands it here.
4import { mask } from './guards'
5
6type Activity = { kind: string; bodyMd: string | null; authorName: string | null; authorType: string }
7export type ApiItem = { id: number; title: string; status: string; bodyMd: string; activity?: Activity[] }
8
9const MAX_BOXES = 15
10const MAX_COMMENTS = 3
11const MAX_COMMENT = 500
12const MAX_TEXT = 4000
13
14const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
15
16// Kept stable between a claim and a close (it sits in the prompt's cached
17// part): no timestamps, no counts that move on their own.
18export function ticketContext(item: ApiItem): string {
19	const open = item.bodyMd.split('\n').filter(l => /^\s*[-*]\s+\[ \]/.test(l)).map(l => l.trim())
20	const comments = (item.activity ?? []).filter(a => a.kind === 'comment' && a.bodyMd).slice(-MAX_COMMENTS)
21	const lines = [
22		'# Floatee: the ticket in progress',
23		`This session is working on Floatee ticket #${item.id} "${item.title}" (claimed here; status when loaded: ${item.status}). Keep to it; \`floatee get ${item.id}\` shows its current state, and it ends with \`floatee done\`, \`block\` or \`wontfix\` (after the user's go-ahead or a passing check).`,
24	]
25	if (open.length) {
26		lines.push('', 'Unchecked items when loaded:')
27		lines.push(...open.slice(0, MAX_BOXES))
28		if (open.length > MAX_BOXES) lines.push(`(+${open.length - MAX_BOXES} more)`)
29	}
30	if (comments.length) {
31		lines.push('', 'Latest comments:')
32		for (const c of comments) lines.push(`- ${c.authorName ?? c.authorType}: ${clip((c.bodyMd ?? '').replace(/\s+/g, ' ').trim(), MAX_COMMENT)}`)
33	}
34	return clip(mask(lines.join('\n')) as string, MAX_TEXT)
35}
36
hooks/guards.ts 160 lines
1// Floatee mod, guards: what the /todo skills can only ask, enforced on every
2// tool call (the main loop and subagents alike). Each refusal tells the agent
3// what to do instead.
4//
5// G1  the agent never reads `.floatee` (the token would enter the context)
6// G1b a Floatee token in any tool output is masked before the model reads it
7// G2  no call that could create a project by accident (no `.floatee`, a
8//     `--project` override without `--no-create`, a raw API call without `create=0`)
9// G3  the rendering of `floatee get` is never saved back as a body
10// G5  one ticket in progress per session
11// G4  no `floatee done` on that ticket before a verification command passed or
12//     the user spoke since it was started. The defaults cover the common test /
13//     lint / typecheck / build commands; `verify=cmd1, cmd2` in `.floatee` adds
14//     the repo's own.
15import type { Config } from './config'
16
17// flt_ + 43 base64url chars (32 random bytes). Shorter look-alikes (doc
18// placeholders, test fixtures) are left alone so files stay editable.
19export const TOKEN_RE = /flt_[A-Za-z0-9_-]{40,}/g
20const MASK = 'flt_[hidden by the floatee mod]'
21
22// `.floatee` as a path, not as a word inside another name (floatee.mjs,
23// Floatee/) nor an escaped regex (`grep "\.floatee"`).
24const DOTFILE_RE = /(^|[^\w.\\-])\.floatee(?![\w.-])/
25// Prose that merely names the file (a ticket body, a commit message): heredocs
26// and quoted strings holding a space. A path is a single word.
27const PROSE_RE = /<<-?\s*(['"]?)(\w+)\1[\s\S]*?\n\s*\2(?=\s|$)|"[^"\n]*\s[^"]*"|'[^'\n]*\s[^']*'/g
28// An existence test is fine: it prints nothing of the file.
29const EXISTS_RE = /(test\s+-[efs]|\[\s+-[efs])\s+["']?[^\s;&|"']*\.floatee["']?(\s*\])?/g
30
31// One CLI invocation: the script (node floatee.mjs, python floatee.py,
32// ./floatee.sh or floatee on the PATH), its subcommand and its arguments up to
33// the next shell separator.
34const CLI_RE = /(?:^|[\s;&|(`"'])(?:node\s+\S*floatee\.mjs|python3?\s+\S*floatee\.py|\S*floatee\.sh|floatee)\s+([a-z]+)([^\n;&|`)]*)/g
35const PROJECT_CMDS = new Set(['next', 'claim', 'get', 'start', 'pending', 'reopen', 'done', 'wontfix', 'block', 'comment', 'snooze', 'wake', 'edit', 'update', 'rm', 'add', 'assign', 'reorder', 'search', 'ls', 'project', 'fetch'])
36// What `floatee get` prints that a body never starts with: its heading and
37// status line (at a line start, or right after the quote opening an argument).
38const RENDERING_RE = /(^|["'])[ \t]*#\s*\[#\d+\][^\n]*\n-\s+status:/m
39
40const VERIFY_DEFAULTS = [
41	/\b(npm|pnpm|yarn|bun)\s+(run\s+)?(test|lint|typecheck|type-check|check|build|generate)\b/,
42	/\b(vue-tsc|tsc|eslint|vitest|jest|playwright|mocha|biome|stylelint)\b/,
43	/\b(pytest|tox|nox|ruff|mypy|pyright|flake8)\b|\bpython3?\s+-m\s+(pytest|unittest)\b/,
44	/\bcargo\s+(test|check|clippy|build)\b/,
45	/\bgo\s+(test|vet|build)\b/,
46	/\bphp(\.exe)?\s+-l\b|\b(phpunit|phpstan|psalm|pest)\b/,
47	/\bmake\s+(test|check|lint)\b/,
48	/\b(mvn|gradle|gradlew)\b.*\b(test|verify|check)\b/,
49	/\bdotnet\s+(test|build)\b/,
50	/\b(rspec|rubocop)\b|\brake\s+test\b/,
51	/\bdeno\s+(test|check|lint)\b|\bswift\s+(test|build)\b/,
52	/\bnuxi?\s+(generate|build|typecheck)\b/,
53	/\bclaude\s+plugin\s+(test|validate)\b/,
54]
55
56type Call = { cmd: string; args: string; id: number | null }
57
58// #669: what a command runs, its prose left out: a heredoc's body (a script,
59// a comment's text) and a quoted string holding a space are data, unless the
60// string is a shell's own command (`bash -c "…"`, `sh -c '…'`).
61function runnable(command: string): string {
62	return command.replace(PROSE_RE, (text: string, ...rest: unknown[]) => {
63		const at = rest[rest.length - 2] as number
64		return !text.startsWith('<<') && /(^|\s)-c\s+$/.test(command.slice(0, at)) ? text : ' '
65	})
66}
67
68export function cliCalls(command: string): Call[] {
69	const out: Call[] = []
70	for (const m of runnable(command).matchAll(CLI_RE)) {
71		const cmd = m[1] ?? ''
72		const args = m[2] ?? ''
73		if (!PROJECT_CMDS.has(cmd) && !['projects', 'workspaces', 'init', 'selfupdate', 'help'].includes(cmd)) continue
74		const id = args.match(/^\s+#?(\d+)\b/)
75		out.push({ cmd, args, id: id && id[1] ? Number(id[1]) : null })
76	}
77	return out
78}
79
80export function isVerification(command: string, extra: string[]): boolean {
81	return VERIFY_DEFAULTS.some(re => re.test(command)) || extra.some(s => command.includes(s))
82}
83
84// A copy of a tool's record with every token masked; the same object when none.
85export function mask(value: unknown): unknown {
86	if (typeof value === 'string') return value.replace(TOKEN_RE, MASK)
87	if (Array.isArray(value)) return value.map(mask)
88	if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, mask(v)]))
89	return value
90}
91
92// What a refusal needs to know: the repo's config (null without one), the
93// ticket this session works on and its G4 flags, and the file a write
94// redirects its body from (`< file`), read by the caller.
95export type Context = { config: Config | null; active: number | null; verified: boolean; heard: boolean; bodyFile: string }
96
97// The file a shell command feeds on stdin (`< path`), if any.
98export function redirectedFile(command: string): string | null {
99	const file = command.match(/(?<![<0-9])<\s*["']?([^\s"'<>|;&()]+)/)
100	return file && file[1] ? file[1] : null
101}
102
103// Why this call must not run, or null.
104export function refusal(e: { tool: string } & Record<string, unknown>, ctx: Context): string | null {
105	const { config } = ctx
106	const str = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
107	const READ_NOTE = 'The floatee mod keeps `.floatee` out of the conversation: it holds the API token. Use the `floatee` CLI, which reads it itself (`floatee project` shows the project).'
108
109	// G1: the file itself, through the file tools or a shell command.
110	if (['Read', 'Edit', 'NotebookEdit'].includes(e.tool) && /(^|[\\/])\.floatee$/.test(str('file_path') || str('notebook_path'))) return READ_NOTE
111	if (e.tool === 'Grep' && (/(^|[\\/])\.floatee$/.test(str('path')) || /floatee/.test(str('glob')) && /^\.|[\\/]\./.test(str('glob')))) return READ_NOTE
112	if (e.tool !== 'Bash') return null
113	const command = str('command')
114	if (DOTFILE_RE.test(command.replace(PROSE_RE, '').replace(EXISTS_RE, ''))) return `${READ_NOTE} To check that it exists: \`test -f .floatee\`.`
115
116	const calls = cliCalls(command)
117
118	// G2: nothing that could mint a project.
119	if (!config && calls.some(c => PROJECT_CMDS.has(c.cmd)))
120		return 'No `.floatee` in this repo: a Floatee call here could create a project by accident. Run `floatee projects` (creates nothing), agree on a slug with the user, then `floatee init --token flt_… --project <slug>`.'
121	const override = calls.find(c => /--project\b/.test(c.args) && !/--no-create\b/.test(c.args))
122	if (override) return '`--project` names a project that may not exist, and the CLI would create it. Add `--no-create` (a 404 instead), or check the slug with `floatee projects` first.'
123	if (/\bcurl\b/.test(command) && /\/api\/v1\//.test(command) && /[?&]project=/.test(command) && !/[?&]create=0\b/.test(command))
124		return 'A raw API call with `project=` creates that project when it does not exist. Use the `floatee` CLI, or add `create=0` to the query.'
125
126	// G3: the rendering of `get` saved back as a body.
127	const writes = calls.filter(c => ['edit', 'update', 'add'].includes(c.cmd))
128	if (writes.length) {
129		const piped = calls.some(c => c.cmd === 'get' && !/--raw\b/.test(c.args))
130		if (piped || RENDERING_RE.test(`${command}\n${ctx.bodyFile}`))
131			return 'This saves the rendering of `floatee get` (heading, status, comments) as the body, which corrupts the ticket. Start from `floatee get <id> --raw` (the body alone), or use `floatee edit <id> --append -m "…"` to add to it.'
132	}
133
134	// G5: one ticket at a time.
135	const mine = ctx.active
136	if (mine !== null) {
137		const other = calls.find(c => c.cmd === 'claim' || c.cmd === 'start' && c.id !== mine)
138		if (other) return `#${mine} is still in progress in this session: one ticket at a time. Close it (\`floatee done\`, \`block\` or \`wontfix\`) first; if the user wants to switch, release it with \`floatee pending ${mine}\`.`
139	}
140
141	// G4: verified, or the user spoke, before closing.
142	const closing = calls.find(c => c.cmd === 'done' && c.id !== null && c.id === mine)
143	if (closing && !ctx.verified && !ctx.heard)
144		return `Nothing verified #${mine} yet: run the repo's checks (tests, lint, typecheck or build) and make sure they pass, or ask the user to check it and wait for their answer, then close. A repo with its own check command can list it in \`.floatee\` as \`verify=<command>\`.`
145	return null
146}
147
148// The ticket this session works on after a call that ran: started (`start N`,
149// or `claim`'s output heading), released (done / wontfix / block / pending),
150// or unchanged.
151export function nextActive(command: string, output: string, mine: number | null): number | null {
152	let now = mine
153	for (const c of cliCalls(command)) {
154		if (c.cmd === 'start' && c.id !== null) now = c.id
155		if (c.cmd === 'claim') { const m = output.match(/^#\s*\[#(\d+)\]/m); if (m && m[1]) now = Number(m[1]) }
156		if (['done', 'wontfix', 'block', 'pending', 'reopen'].includes(c.cmd) && c.id !== null && c.id === now) now = null
157	}
158	return now
159}
160
types/index.d.ts 85 lines
1export type Item = {
2  id: number
3  title: string
4  status: 'pending' | 'in_progress' | 'blocked'
5  /** Its workspace's slug: the ticket's page is `<web>/i/<ws>/<id>`. */
6  ws: string | null
7  /** For the pane (#679): its type, tags, checklist count, last change (ms) and comment count. */
8  type: 'todo' | 'idea' | 'note'
9  tags: string[]
10  boxes: { done: number; total: number } | null
11  updatedAt: number | null
12  comments: number
13  /** #750: asleep until then (epoch, s); absent when awake. */
14  until?: number
15}
16
17/** A task box of a ticket's body: its line in the body, its text, checked or not. */
18export type Box = { line: number; text: string; isDone: boolean }
19
20/** #748: an entry of a ticket's activity: a comment (its public id `c3`, its markdown, edited since, whether this token may edit or delete it) or an event (its line, as the site words it; a resolution's note as `body`), or the ticket's opening; who and when (ms). */
21export type Entry = { key: number; kind: string; seq: number | null; author: string; at: number; body: string | null; event: string; isEdited: boolean; canEdit: boolean; isWontfix: boolean }
22
23declare module 'claude-code' {
24  interface PluginState {
25    floatee: {
26      /** Open todos of the project (pending, in_progress, blocked), in manual order. */
27      items: Item[]
28      /** Every open item id of the project (todos, ideas, notes), in manual order: what a reorder sends. */
29      order: number[]
30      /** Last fetch problem shown in the band, null when fine. */
31      error: string | null
32      /** A model turn is running. */
33      isBusy: boolean
34      /** Ticket launched from the band (0 = "next"), until the turn it started completes. */
35      launched: number | null
36      /** Band folded to its header line (folded at first). */
37      isCollapsed: boolean
38      /** The ticket's checklist drawn whole (F3), not just its first unchecked boxes. */
39      isChecklistOpen: boolean
40      /** Blocked todos (F10), by id: the comment count last looked at, the human reply since the block, if any, and (#679) why it was blocked. */
41      blocked: Record<string, { comments: number; reply: { at: number; by: string } | null; why?: string | null }>
42      /** Replies already announced, by ticket id: the reply's time. Kept across reloads, so a toast is never repeated. */
43      notified: Record<string, number>
44      /** The watcher (F9): on or off, todos it got done, runs in a row that closed nothing, and the dones of the run it started (null when the running turn isn't its). */
45      watch: { isOn: boolean; done: number; failures: number; inTurn: number | null }
46      /** #679: the list in the band above the prompt, or in a pane. */
47      layout: 'band' | 'pane'
48      /** #669: only the tickets assigned to me (this token or its owner). */
49      isMine: boolean
50      /** #669: when a rate-limit pause (429) ends, in ms; 0 when none. */
51      pausedUntil: number
52      /** #751: engine dialogs up (AskUserQuestion, a plan to approve), which hold the keys; 0 when none. */
53      asked: number
54      /** #679: the open ideas and notes, for the pane's sections. */
55      others: Item[]
56      /** #750: the open items asleep (snoozed), apart from the list; the pane lists them. */
57      sleepers: Item[]
58      /** #679: the pane's sections folded (ideas, notes), by name. */
59      folds: Record<string, boolean>
60      /** #679: `active` in detail for the pane: the start of its description and its last comment. */
61      detail: { id: number; intro: string[]; last: { by: string; text: string } | null } | null
62      /** The new-ticket field (#680): open or not, the text typed so far, a create on its way. #751: the type, tags and position chosen in the form, and its tag field (open, text); its assignee and project (null: the defaults, this token and this project), its wake-up (epoch, s), the choice open with its options, and the date field (open, text). */
63      draft: { isOpen: boolean; text: string; isSending: boolean; type: 'todo' | 'idea' | 'note'; tags: string[]; position: 'top' | 'bottom'; isTagging: boolean; tagText: string; assignee: { value: string; label: string } | null; project: { value: string; label: string } | null; snooze: number | null; picking: 'assignee' | 'project' | null; choices: Array<{ value: string; label: string }> | null; isSnoozing: boolean; whenText: string }
64      /** #747: the ticket whose sheet is open (null: the list), the closing step asking for a text (blocked's why, a resolution note), the text typed, a write on its way, its description (the whole body; null until read). #748: its activity, newest first (null until read), the comment field's text and whether it is open, the comment it edits or asks to delete (cN), a comment on its way, and the whole thread shown in the band. #749: its assignee and project (names), the choice open (assign / move) with its options once read, and the tag field's text and whether it is open. #750: the snooze field open and its text, and a delete asked. #752: the body shown raw for editing, the line in the field (0 = the title) and its text. */
65      sheet: { id: number | null; step: 'blocked' | 'done' | 'wontfix' | null; text: string; isSending: boolean; body: string | null; feed: Entry[] | null; reply: string; isComposing: boolean; editing: number | null; deleting: number | null; isPosting: boolean; isAll: boolean; assignee: string | null; project: string | null; picking: 'assignee' | 'project' | null; choices: Array<{ value: string; label: string }> | null; tagText: string; isTagging: boolean; isSnoozing: boolean; whenText: string; isDeleting: boolean; isEditing: boolean; editLine: number | null; lineText: string }
66      /** The running turn's id, what Stop ends; null between turns. */
67      turnId: string | null
68      /** Project slug, once a .floatee was found. */
69      project: string | null
70      /** Ticket this session started (start / claim) and has not closed yet. */
71      active: number | null
72      /** A verification command passed since `active` was started. */
73      verified: boolean
74      /** The user typed a prompt since `active` was started. */
75      heard: boolean
76      /** `active` as the system prompt shows it (F8), loaded at the claim and after a compaction. */
77      ticket: { id: number; text: string } | null
78      /** `active`'s task boxes and claim time (F3), drawn under its row; refreshed with the band. */
79      checklist: { id: number; boxes: Box[]; since: number | null } | null
80      /** The time the band last ticked, in ms: what the elapsed time is counted to. */
81      now: number
82    }
83  }
84}
85