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…

Your Floatee backlog above Claude Code's prompt, in the terminal and the desktop app.
/todo #N) and ▷ Guided (/todo-guided #N) on each ticket, Run next and Stopclaude 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.
.floatee file at the repo root (token=flt_…, api=…, project=<slug>): create a token in Floatee under Settings → Your agent./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..floatee itself and never shows the token; it masks a token printed by any command, and keeps Claude from reading the file.create=0 on every request).More in the docs.
A bug or an idea? Open an issue.
MIT, see LICENSE.
hooks/register.tsx 2126 lines1// 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 lines1// 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}
36hooks/watch.ts 26 lines1// 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}
26hooks/newticket.ts 33 lines1// #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}
33hooks/markdown.ts 91 lines1// #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}
91hooks/snooze.ts 37 lines1// #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}
37hooks/checklist.ts 57 lines1// 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}
57hooks/config.ts 40 lines1// 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}
40hooks/context.ts 36 lines1// 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}
36hooks/guards.ts 160 lines1// 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}
160types/index.d.ts 85 lines1export 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