A work pane for the branch you're on: its GitHub issue and acceptance criteria, its PR's checks, review and open threads, and your local dev services, plus a…

Part of claude-toolkit. See there for every plugin and how to add the marketplace.
A Claude Code side pane for the work on your current branch. In fullscreen mode it docks on the right of the transcript.
It works in any git repository hosted on GitHub. Nothing in it is tied to one project: the issue comes from your branch name or your PR, and the services come from a small config file each project writes for itself.
1 to 9, in order) to start or stop it, and t to trust the project's config (see below).The issue and PR numbers are links. Past eight criteria the rest fold under "+N more"; c lists them all, and again folds them. r refreshes, i opens the issue and p opens the PR in the browser. Click a key, or focus the pane (ctrl+x tab) and press it.
At the start of each conversation, desk hands Claude a short note the transcript doesn't show: the branch and the state of the tree, the issue with its unticked criteria, the PR's state, and your previous session's last prompts and Claude's last answer. Ask "where did I leave off?" or "what's next?" and Claude answers from it, with no digging. It goes with your first prompt and isn't repeated. /desk note off turns it off.
At the Claude Code prompt:
/plugin marketplace add miskopisko/claude-toolkit /plugin install desk@miskopisko
From a shell:
claude plugin install desk --marketplace miskopisko/claude-toolkit
desk reads GitHub through the gh CLI, so install it and run gh auth login. Project board columns need the project scope: gh auth refresh -s project. Without gh, the pane still shows git and your services.
The pane opens by itself when the terminal is at least 144 columns wide.
/desk open the pane (it docks on the right from 110 columns) /desk close hide it /desk refresh read git, GitHub and the services again /desk note on|off the where-you-left-off note
The pane re-reads git and the services every 5 seconds while it's on screen, and GitHub every 90 seconds, after a turn (at most every 20 seconds) and whenever you switch branch.
desk takes the first issue in this repository that the branch's PR closes (Closes #12 in the PR, or a linked issue). Without one, it reads the number in the branch name: implement/284-conflict-alert, feature/12_login, gh-7 and 123 all work, and bump-1.2.3 names none. A team with another scheme sets issuePattern (below) to a regular expression whose first group is the number.
Each project lists its services in .claude/desk.json, committed so the team shares it. /stack init writes a two-service example to start from. A fuller one:
{
"services": [
{
"name": "Database",
"check": { "port": 5432 },
"start": "docker compose up -d db",
"stop": "docker compose stop db"
},
{
"name": "API",
"check": { "http": "http://localhost:5000/health" },
"start": "dotnet run --project src/Api",
"cwd": "."
},
{
"name": "Web",
"check": { "port": 5173 },
"start": "npm run dev",
"cwd": "web"
}
],
"issuePattern": "(?:^|/)(\\d+)-"
}
check says how to tell the service is up: { "port": 5432 } (on 127.0.0.1 or ::1, or the "host" you give), { "http": "<url>" } (any answer is up), or { "command": "<shell>" } (exit 0 is up). Without one, a service is up while the process desk started is alive. For an https dev server with a self-signed certificate, check its port.start runs in the background, detached from Claude Code, so the service keeps running when you quit. Its output goes to .git/desk/<name>.log, the name lowercased with anything but letters and digits made - (My API logs to my-api.log). Outside a git repository the logs go to a folder under your temporary directory instead, never the project.stop is optional. Without it, desk stops the whole process group it started (a dotnet run and its children, say). A service desk didn't start needs a stop command to be stopped from desk. A process desk started keeps its stop for as long as it runs, even while its check doesn't answer.cwd is relative to the repository, or an absolute path.Your own services, or your own version of a team one, go in .claude/desk.local.json (keep it out of git). It has the same shape, and its services replace the team's by name.
/stack [status] each service and whether it's up /stack up [name…] start the named services, or every one that's down /stack down [name…] stop the named services, or every one desk can stop /stack logs <name> the last lines of a service's log /stack trust allow the commands in this project's config /stack init write a .claude/desk.json to start from
Trust. A config file comes with the repository, so desk runs none of its commands (start, stop, or a check command) until you've read it and run /stack trust. Port and URL checks need no trust. When either file changes, desk asks again.
The hooks module is hooks/register.tsx. git is read in hooks/git.ts, GitHub in hooks/github.ts, services in hooks/stack.ts, and the trail and the note are built in hooks/trail.ts. The pane's state types are in types/index.d.ts.
claude plugin validate . claude plugin test .
hooks/register.tsx 671 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Crumb, GitState, ServiceView, StackView, Trail, WorkView } from '../types'
5import { ago, bar, clip, criteriaCount, reviewWords } from './format'
6import { gitDir, NO_GIT, readGit } from './git'
7import { openInBrowser, readWork } from './github'
8import {
9 CONFIG_FILES,
10 fingerprint,
11 hasCommands,
12 isAlive,
13 loadConfig,
14 logPath,
15 probe,
16 startService,
17 stateOf,
18 stopService,
19 TEMPLATE,
20 viewOf,
21} from './stack'
22import type { LoadedConfig, ServiceSpec } from './stack'
23import { asTrail, leftOffNote, previousOf, withAnswer, withPrompt } from './trail'
24
25const PANE = 'desk'
26const TITLE = 'Desk'
27const WIDTH = 46
28const GUTTER = 1
29/** How often the pane re-reads git and the services while it is drawn. */
30const TICK_MS = 5000
31/** How often it re-reads GitHub while drawn; a turn's end or a branch change reads it sooner. */
32const WORK_EVERY_MS = 90_000
33const WORK_AFTER_TURN_MS = 20_000
34const CRITERIA_SHOWN = 8
35
36const EMPTY_WORK: WorkView = { at: null, repo: null, issueNumber: null, issue: null, pr: null, problem: null, isLoading: false }
37const EMPTY_STACK: StackView = { files: [], isTrusted: false, services: [], problem: null }
38
39const git = atom({ plugin: 'desk', key: 'git' } as const, NO_GIT)
40const work = atom({ plugin: 'desk', key: 'work' } as const, EMPTY_WORK)
41const stack = atom({ plugin: 'desk', key: 'stack' } as const, EMPTY_STACK)
42const previous = atom({ plugin: 'desk', key: 'previous' } as const, null as Crumb | null)
43const now = atom({ plugin: 'desk', key: 'now' } as const, 0)
44const notedSession = atom({ plugin: 'desk', key: 'notedSession' } as const, null as string | null)
45const criteriaOpen = atom({ plugin: 'desk', key: 'criteriaOpen' } as const, false)
46
47/** Store keys, each per repository (or per folder outside one). */
48const keys = {
49 trail: (root: string) => `trail:${root}`,
50 work: (root: string) => `work:${root}`,
51 pids: (root: string) => `pids:${root}`,
52 trust: (root: string) => `trust:${root}`,
53 card: 'card',
54}
55
56type Pids = Record<string, number>
57
58/** What the module keeps between hooks; a reload starts it over from the store. */
59const rt = {
60 cwd: null as string | null,
61 logDir: null as string | null,
62 config: null as LoadedConfig | null,
63 doing: new Map<string, { action: 'start' | 'stop'; since: number }>(),
64 workBranch: undefined as string | null | undefined,
65 /** The GitHub read under way, if one is. */
66 workRead: null as Promise<void> | null,
67 /** When the last GitHub read began. */
68 lastWorkAt: 0,
69 isProbing: false,
70}
71
72/** The key a repository's records go under: its top level, else the session's folder. */
73async function placeOf($: EngineInterface): Promise<string> {
74 const g = await read($, git)
75 return g.root ?? rt.cwd ?? (await $.session.cwd())
76}
77
78async function refreshGit($: EngineInterface): Promise<GitState> {
79 const cwd = rt.cwd ?? (await $.session.cwd())
80 const g = await readGit($, cwd)
81 await update($, git, () => g)
82 if (g.root !== null && rt.workBranch !== undefined && g.branch !== rt.workBranch) void refreshWork($)
83 return g
84}
85
86/**
87 * Reads GitHub again. One read runs at a time: an unforced call while one is
88 * under way does nothing, and a forced one (a refresh asked for) waits for it
89 * and reads once more, so what it shows is from after the ask.
90 */
91async function refreshWork($: EngineInterface, isForced = false) {
92 while (rt.workRead !== null) {
93 if (!isForced) return
94 await rt.workRead.catch(() => undefined)
95 }
96 rt.workRead = (async () => {
97 const g = await read($, git)
98 if (g.root === null) return
99 const root = g.root
100 await update($, work, w => ({ ...w, isLoading: true }))
101 const at = await $.clock.now()
102 rt.lastWorkAt = at
103 const w = await readWork($, root, g.branch, rt.config?.config.issuePattern, at)
104 rt.workBranch = g.branch
105 await update($, work, () => w)
106 if (w.problem === null) await $.store.set(keys.work(root), { ...w, branch: g.branch })
107 })()
108 try {
109 await rt.workRead
110 } finally {
111 rt.workRead = null
112 }
113}
114
115/** Where service logs go: the repository's git directory, else a temporary folder, never the project tree. */
116async function logDirOf($: EngineInterface, root: string): Promise<string> {
117 const dir = await gitDir($, root)
118 if (dir !== null) return `${dir}/desk`
119 let tmp: string | undefined
120 try {
121 tmp = await $.env.get('TMPDIR')
122 } catch {
123 tmp = undefined
124 }
125 return `${(tmp ?? '/tmp').replace(/\/+$/, '')}/claude-desk-logs/${fingerprint(root)}`
126}
127
128async function loadStack($: EngineInterface) {
129 const root = await placeOf($)
130 rt.config = await loadConfig($, root)
131 rt.logDir = await logDirOf($, root)
132 await probeServices($)
133}
134
135async function isTrusted($: EngineInterface, cfg: LoadedConfig): Promise<boolean> {
136 if (!hasCommands(cfg.config)) return true
137 return (await $.store.get(keys.trust(await placeOf($)))) === cfg.fingerprint
138}
139
140async function readPids($: EngineInterface): Promise<Pids> {
141 const v = await $.store.get(keys.pids(await placeOf($)))
142 return typeof v === 'object' && v !== null ? (v as Pids) : {}
143}
144
145async function writePids($: EngineInterface, change: (p: Pids) => Pids) {
146 const root = await placeOf($)
147 const next = change(await readPids($))
148 if (Object.keys(next).length === 0) await $.store.delete(keys.pids(root))
149 else await $.store.set(keys.pids(root), next)
150}
151
152async function probeServices($: EngineInterface) {
153 const cfg = rt.config
154 if (cfg === null || rt.isProbing) return
155 rt.isProbing = true
156 try {
157 const root = await placeOf($)
158 const trusted = await isTrusted($, cfg)
159 const pids = await readPids($)
160 const at = await $.clock.now()
161 const views: ServiceView[] = await Promise.all(
162 cfg.config.services.map(async spec => {
163 const pid = pids[spec.name] ?? null
164 const probed = await probe($, root, spec, pid, trusted)
165 const doing = rt.doing.get(spec.name)
166 const state = stateOf(probed, doing, at)
167 if (doing !== undefined && state !== 'starting' && state !== 'stopping') rt.doing.delete(spec.name)
168 return viewOf(spec, state, pid, trusted)
169 }),
170 )
171 // A process desk started that has exited is forgotten, so its stop is not offered. One still
172 // running whose check does not answer (slow, or checked at the wrong port) keeps its pid.
173 const quiet = views.filter(v => v.pid !== null && v.state === 'down')
174 const exited = await Promise.all(quiet.map(async v => !(await isAlive($, v.pid!))))
175 const gone = quiet.filter((_, i) => exited[i]).map(v => v.name)
176 if (gone.length > 0) await writePids($, p => Object.fromEntries(Object.entries(p).filter(([n]) => !gone.includes(n))))
177 await update($, stack, () => ({
178 files: cfg.files,
179 isTrusted: trusted,
180 services: views.map(v => (gone.includes(v.name) ? { ...v, pid: null, canStop: v.canStop && cfg.config.services.find(s => s.name === v.name)?.stop !== undefined } : v)),
181 problem: cfg.problem,
182 }))
183 } finally {
184 rt.isProbing = false
185 }
186}
187
188function specNamed(name: string): ServiceSpec | undefined {
189 const want = name.toLowerCase()
190 return rt.config?.config.services.find(s => s.name.toLowerCase() === want)
191}
192
193/** The services a command's words name: all the words as one name (`Web App`), else each word as one. */
194function specsNamed(words: string[]): (ServiceSpec | undefined)[] {
195 const whole = specNamed(words.join(' '))
196 return whole !== undefined ? [whole] : words.map(specNamed)
197}
198
199/** Whether a service reads as running: up, starting, or a process desk started still alive. */
200function isRunning(v: ServiceView | undefined): boolean {
201 return v !== undefined && (v.state === 'up' || v.state === 'starting' || v.pid !== null)
202}
203
204/** Starts or stops one service; resolves what happened, in a line. */
205async function act($: EngineInterface, spec: ServiceSpec, action: 'start' | 'stop'): Promise<string> {
206 const cfg = rt.config
207 if (cfg === null) return 'No desk config is loaded.'
208 if (!(await isTrusted($, cfg))) return `The desk config's commands are not trusted yet: read ${cfg.files.join(' and ')}, then /stack trust.`
209 const root = await placeOf($)
210 const at = await $.clock.now()
211 const logDir = rt.logDir ?? (await logDirOf($, root))
212 try {
213 if (action === 'start') {
214 if (spec.start === undefined) return `${spec.name} has no start command.`
215 const view = (await read($, stack)).services.find(v => v.name === spec.name)
216 if (view?.state === 'up' || view?.state === 'starting') return `${spec.name} is already ${view.state}.`
217 if (view?.pid != null) return `${spec.name} is still running (process group ${view.pid}) but its check does not answer: /stack down ${spec.name} first.`
218 const pid = await startService($, root, logDir, spec)
219 await writePids($, p => ({ ...p, [spec.name]: pid }))
220 rt.doing.set(spec.name, { action, since: at })
221 return `${spec.name} starting (process group ${pid}); its log is ${logPath(logDir, spec)}.`
222 }
223 const pid = (await readPids($))[spec.name] ?? null
224 await stopService($, root, spec, pid)
225 await writePids($, p => Object.fromEntries(Object.entries(p).filter(([n]) => n !== spec.name)))
226 rt.doing.set(spec.name, { action, since: at })
227 return `${spec.name} stopping.`
228 } catch (err) {
229 return err instanceof Error ? err.message : String(err)
230 } finally {
231 await probeServices($)
232 }
233}
234
235async function toggle($: EngineInterface, name: string) {
236 const spec = specNamed(name)
237 if (spec === undefined) return
238 const view = (await read($, stack)).services.find(s => s.name === spec.name)
239 const line = await act($, spec, isRunning(view) ? 'stop' : 'start')
240 $.ui.toast(`desk: ${line}`)
241}
242
243async function trust($: EngineInterface): Promise<string> {
244 const cfg = rt.config
245 if (cfg === null || cfg.files.length === 0) return `No desk config here. /stack init writes ${CONFIG_FILES[0]} to start from.`
246 await $.store.set(keys.trust(await placeOf($)), cfg.fingerprint)
247 await probeServices($)
248 return `Trusted the commands in ${cfg.files.join(' and ')}. A change to either file asks again.`
249}
250
251// The pane's clock: one tick every few seconds, run only while the pane is drawn.
252let ticker: Timer | null = null
253
254function startTicker($: EngineInterface) {
255 if (ticker !== null) return
256 ticker = $.clock.every(TICK_MS, async () => {
257 const at = await $.clock.now()
258 await update($, now, () => at)
259 await refreshGit($)
260 // The config is re-read too, so an edit to it shows without a refresh.
261 rt.config = await loadConfig($, await placeOf($))
262 await probeServices($)
263 if (at - rt.lastWorkAt >= WORK_EVERY_MS) await refreshWork($)
264 })
265}
266
267function stopTicker() {
268 ticker?.cancel()
269 ticker = null
270}
271
272async function recordTrail($: EngineInterface, change: (t: Trail | null, sessionId: string, at: number) => Trail) {
273 const root = await placeOf($)
274 const [sessionId, at, stored] = await Promise.all([$.session.id(), $.clock.now(), $.store.get(keys.trail(root))])
275 await $.store.set(keys.trail(root), change(asTrail(stored), sessionId, at))
276}
277
278/**
279 * The where-you-left-off note for this conversation: made once per session id
280 * (a /clear starts another), null when off, already sent, or with nothing to say. It reads the trail before this prompt is recorded.
281 */
282async function makeNote($: EngineInterface): Promise<string | null> {
283 const sessionId = await $.session.id()
284 if ((await read($, notedSession)) === sessionId) return null
285 await update($, notedSession, () => sessionId)
286 if ((await $.store.get(keys.card)) === false) return null
287 const g = await refreshGit($)
288 const root = g.root ?? rt.cwd ?? (await $.session.cwd())
289 const [trail, cached, at] = await Promise.all([
290 $.store.get(keys.trail(root)),
291 $.store.get(keys.work(root)),
292 $.clock.now(),
293 ])
294 const live = await read($, work)
295 const c = cached as (WorkView & { branch: string | null }) | undefined
296 const w = live.at !== null ? live : c !== undefined && c.branch === g.branch ? c : null
297 return leftOffNote(g, w, previousOf(asTrail(trail), sessionId), at)
298}
299
300function stateColor(state: ServiceView['state']): string | undefined {
301 return state === 'up' ? 'success' : state === 'down' ? 'error' : state === 'unknown' ? undefined : 'warning'
302}
303
304function stateMark(state: ServiceView['state']): string {
305 return state === 'up' ? '●' : state === 'down' ? '○' : state === 'unknown' ? '?' : '◐'
306}
307
308export const register: Register = on => {
309 on('session.start', async ($, e, next) => {
310 await $.command.register({
311 name: 'desk',
312 description: 'Open the desk pane: issue, PR and services for this branch (close, refresh, note on|off)',
313 argumentHint: '[close | refresh | note on|off]',
314 })
315 await $.command.register({
316 name: 'stack',
317 description: 'Your local dev services: status, up and down by name, logs, trust, init',
318 argumentHint: '[status | up|down [name…] | logs <name> | trust | init]',
319 })
320 rt.cwd = e.cwd
321 const g = await refreshGit($)
322 const root = g.root ?? e.cwd
323 const [sessionId, trail, cached] = await Promise.all([$.session.id(), $.store.get(keys.trail(root)), $.store.get(keys.work(root))])
324 await update($, previous, () => previousOf(asTrail(trail), sessionId))
325 // The last read, shown until the first new one lands.
326 const c = cached as (WorkView & { branch: string | null }) | undefined
327 if (c !== undefined && c.branch === g.branch) await update($, work, () => ({ ...c, isLoading: true }))
328 await loadStack($)
329 void refreshWork($)
330
331 // Unasked, the pane is placed only on a wide terminal; its first draw starts the clock.
332 if (e.isInteractive) void $.ui.open({ id: PANE, title: TITLE, columns: WIDTH })
333 return next(e)
334 })
335
336 on('command.run', { command: 'desk' }, async ($, e) => {
337 const words = e.args.trim().split(/\s+/).filter(w => w !== '')
338 if (words[0] === 'close') {
339 await $.ui.close({ id: PANE })
340 stopTicker()
341 return { text: 'Desk pane closed.' }
342 }
343 if (words[0] === 'refresh') {
344 await refreshGit($)
345 await Promise.all([refreshWork($, true), loadStack($)])
346 return { text: 'Desk refreshed.' }
347 }
348 if (words[0] === 'note') {
349 if (words[1] !== 'on' && words[1] !== 'off') {
350 const isOff = (await $.store.get(keys.card)) === false
351 return { text: `The where-you-left-off note is ${isOff ? 'off' : 'on'}: /desk note on|off` }
352 }
353 if (words[1] === 'on') await $.store.delete(keys.card)
354 else await $.store.set(keys.card, false)
355 return { text: `The where-you-left-off note is ${words[1]}, from the next conversation.` }
356 }
357 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: WIDTH })
358 return {
359 text: opened.isPlaced ? 'Desk pane opened. In fullscreen mode it docks on the right.' : `Desk pane is waiting to be placed: ${opened.reason}`,
360 }
361 })
362
363 on('command.run', { command: 'stack' }, async ($, e) => {
364 const [verb = 'status', ...names] = e.args.trim().split(/\s+/).filter(w => w !== '')
365 const root = await placeOf($)
366 if (verb === 'init') {
367 const path = `${root}/${CONFIG_FILES[0]}`
368 if (await $.fs.exists(path)) return { text: `${CONFIG_FILES[0]} is there already.` }
369 try {
370 // fs.write makes .claude/ when it is not there yet.
371 await $.fs.write(path, TEMPLATE)
372 } catch (err) {
373 return { text: `Could not write ${CONFIG_FILES[0]}: ${(err instanceof Error ? err.message : String(err)).slice(0, 160)}` }
374 }
375 await loadStack($)
376 return {
377 text: `Wrote ${CONFIG_FILES[0]} with two example services: edit it for this project. Commit it to share with the team; put your own services in ${CONFIG_FILES[1]}.`,
378 }
379 }
380 if (rt.config === null) await loadStack($)
381 const cfg = rt.config!
382 if (cfg.problem !== null) return { text: `The desk config has a problem: ${cfg.problem}` }
383 if (cfg.files.length === 0) return { text: `No services set up here. /stack init writes ${CONFIG_FILES[0]} to start from.` }
384 if (verb === 'trust') return { text: await trust($) }
385 if (verb === 'status') {
386 await probeServices($)
387 const s = await read($, stack)
388 const lines = s.services.map(v => `${v.name}: ${v.state} (${v.probe}${v.pid === null ? '' : `, pid ${v.pid}`})`)
389 if (!s.isTrusted) lines.push(`Commands not trusted yet: read ${s.files.join(' and ')}, then /stack trust.`)
390 return { text: lines.join('\n') }
391 }
392 if (verb === 'logs') {
393 const spec = specNamed(names.join(' '))
394 if (spec === undefined) return { text: `No service named "${names.join(' ')}".` }
395 const path = logPath(rt.logDir ?? (await logDirOf($, root)), spec)
396 if (!(await $.fs.exists(path))) return { text: `${spec.name} has no log yet (desk keeps one for services it starts).` }
397 const tail = (await $.fs.read(path)).split('\n').slice(-40).join('\n')
398 return { text: `${path}, last lines:\n${tail}` }
399 }
400 if (verb === 'up' || verb === 'down') {
401 await probeServices($)
402 const views = (await read($, stack)).services
403 const picked =
404 names.length === 0
405 ? cfg.config.services.filter(s => {
406 const v = views.find(x => x.name === s.name)
407 return verb === 'up' ? v?.state === 'down' && v.pid === null && s.start !== undefined : isRunning(v) && v!.canStop
408 })
409 : specsNamed(names)
410 if (picked.some(s => s === undefined)) return { text: `No such service. The services: ${cfg.config.services.map(s => s.name).join(', ')}.` }
411 if (picked.length === 0) return { text: verb === 'up' ? 'Nothing to start: every service is up or has no start command.' : 'Nothing desk can stop is running.' }
412 const lines: string[] = []
413 for (const spec of picked) lines.push(await act($, spec!, verb === 'up' ? 'start' : 'stop'))
414 return { text: lines.join('\n') }
415 }
416 return { text: 'Usage: /stack [status | up [name…] | down [name…] | logs <name> | trust | init]' }
417 })
418
419 on('ui.close', { id: PANE }, async ($, e, next) => {
420 const closed = await next(e)
421 stopTicker()
422 return closed
423 }).catch(($, e, next) => next(e))
424
425 on('prompt.submit', async ($, e, next) => {
426 const isPerson = e.isMeta !== true && e.command === undefined && (e.origin.kind === 'composer' || e.origin.kind === 'bridge')
427 if (!isPerson) return next(e)
428 // The note rides beside the conversation's first prompt, read by the model alone.
429 const note = await makeNote($)
430 const entered = await next(note === null ? e : { ...e, context: [...(e.context ?? []), note] })
431 const branch = (await read($, git)).branch
432 await recordTrail($, (t, id, at) => withPrompt(t, id, at, e.text, branch))
433 return entered
434 }).catch(($, e, next) => next(e))
435
436 on('turn.complete', async ($, e, next) => {
437 const done = await next(e)
438 if (e.agentId !== undefined) return done
439 const g = await refreshGit($)
440 const at = await $.clock.now()
441 if (at - rt.lastWorkAt >= WORK_AFTER_TURN_MS) void refreshWork($)
442 if (!e.isAborted) {
443 const w = await read($, work)
444 await recordTrail($, (t, id, when) => withAnswer(t, id, when, e.answer, g.branch, w))
445 }
446 return done
447 })
448
449 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
450 startTicker($)
451 const { Box, Button, Text } = $.ui.resolve(e)
452 const [g, w, s, prev, tickAt, isOpen] = await Promise.all([
453 read($, git),
454 read($, work),
455 read($, stack),
456 read($, previous),
457 read($, now),
458 read($, criteriaOpen),
459 ])
460 const at = Math.max(tickAt, await $.clock.now())
461 const cols = Math.max(24, e.props.bodyColumns - GUTTER)
462 const barWidth = Math.max(6, Math.min(20, cols - 22))
463 const root = g.root
464
465 const head = (text: string, aside?: string) => (
466 <Box marginTop={1}>
467 <Text bold color="claude">
468 {text}
469 </Text>
470 {aside !== undefined && <Text dimColor> {aside}</Text>}
471 </Box>
472 )
473 const row = (label: string, value: string, color?: string) => (
474 <Box>
475 <Box width={10} flexShrink={0}>
476 <Text dimColor>{label}</Text>
477 </Box>
478 <Text color={color} wrap="truncate-end">
479 {value}
480 </Text>
481 </Box>
482 )
483
484 const tree = [
485 g.ahead > 0 ? `↑${g.ahead}` : null,
486 g.behind > 0 ? `↓${g.behind}` : null,
487 g.staged > 0 ? `${g.staged} staged` : null,
488 g.changed > 0 ? `${g.changed} changed` : null,
489 g.untracked > 0 ? `${g.untracked} new` : null,
490 ].filter(p => p !== null)
491
492 const issue = w.issue
493 const count = issue === null ? null : criteriaCount(issue.criteria)
494 const pr = w.pr
495 const checks = pr?.checks
496 const checkLine =
497 checks === undefined
498 ? ''
499 : [
500 checks.passed > 0 ? `✓ ${checks.passed}` : null,
501 checks.failed > 0 ? `✗ ${checks.failed}` : null,
502 checks.running > 0 ? `● ${checks.running}` : null,
503 checks.skipped > 0 ? `${checks.skipped} skipped` : null,
504 ]
505 .filter(p => p !== null)
506 .join(' ') || 'none'
507 const fresh = w.at === null ? (w.isLoading ? 'reading…' : '') : `${ago(at - w.at)} ago${w.isLoading ? ' · reading…' : ''}`
508
509 return (
510 <Box flexDirection="column" width={cols + GUTTER} paddingLeft={GUTTER}>
511 <Text bold>Branch</Text>
512 {root === null ? (
513 <Text dimColor>Not a git repository.</Text>
514 ) : (
515 <>
516 <Text wrap="truncate-end">{g.branch ?? '(detached HEAD)'}</Text>
517 <Text dimColor wrap="truncate-end">
518 {tree.length === 0 ? 'clean' : tree.join(' · ')}
519 {g.upstream === null && g.branch !== null ? ' · not pushed' : ''}
520 </Text>
521 </>
522 )}
523
524 {root !== null && head('Issue', w.issueNumber === null ? undefined : fresh)}
525 {root !== null &&
526 (w.problem !== null ? (
527 <Text color="warning" wrap="wrap">
528 {w.problem}
529 </Text>
530 ) : issue === null ? (
531 <Text dimColor wrap="wrap">
532 {w.issueNumber !== null
533 ? w.isLoading
534 ? `Reading #${w.issueNumber}…`
535 : `#${w.issueNumber} was not found.`
536 : w.isLoading && w.at === null
537 ? 'Reading GitHub…'
538 : 'The branch names no issue, and no PR links one.'}
539 </Text>
540 ) : (
541 <>
542 <Box>
543 <Button key="issue-number" plain label={`#${issue.number}`} onPress={() => openInBrowser($, root!, 'issue', issue.number)} />
544 <Text wrap="truncate-end"> {issue.title}</Text>
545 </Box>
546 <Text dimColor wrap="truncate-end">
547 {issue.state.toLowerCase()}
548 {issue.columns.map(c => ` · ${c.status} (${c.project})`).join('')}
549 </Text>
550 {count === null ? (
551 <Text dimColor>No checklist in the issue.</Text>
552 ) : (
553 <>
554 <Box>
555 <Text color={count.done === count.total ? 'success' : 'claude'}>{bar(count.done, count.total, barWidth)}</Text>
556 <Text>
557 {' '}
558 {count.done}/{count.total}
559 </Text>
560 <Text dimColor>{issue.criteriaSource === 'acceptance' ? ' criteria' : ' boxes'}</Text>
561 </Box>
562 {(isOpen ? issue.criteria : issue.criteria.slice(0, CRITERIA_SHOWN)).map((c, i) => (
563 <Text key={`c${i}`} wrap="truncate-end" dimColor={c.isDone}>
564 <Text color={c.isDone ? 'success' : undefined}>{c.isDone ? '✓' : '○'}</Text> {c.text}
565 </Text>
566 ))}
567 {issue.criteria.length > CRITERIA_SHOWN && (
568 <Button
569 key="criteria-toggle"
570 plain
571 hotkey="c"
572 label={isOpen ? 'show fewer' : `+${issue.criteria.length - CRITERIA_SHOWN} more`}
573 onPress={() => update($, criteriaOpen, o => !o)}
574 />
575 )}
576 </>
577 )}
578 </>
579 ))}
580
581 {root !== null && w.problem === null && head('Pull request')}
582 {root !== null &&
583 w.problem === null &&
584 (pr === null ? (
585 <Text dimColor>{w.at === null ? 'Reading GitHub…' : g.branch === null ? 'No branch.' : 'None for this branch.'}</Text>
586 ) : (
587 <>
588 <Box>
589 <Button key="pr-number" plain label={`#${pr.number}`} onPress={() => openInBrowser($, root!, 'pr', pr.number)} />
590 <Text wrap="truncate-end"> {pr.title}</Text>
591 </Box>
592 {row(
593 'State',
594 `${pr.isDraft ? 'draft' : pr.state.toLowerCase()}${pr.state === 'OPEN' ? ` · ${reviewWords(pr.review)}` : ''}`,
595 pr.state === 'MERGED' ? 'success' : pr.review === 'CHANGES_REQUESTED' ? 'error' : pr.review === 'APPROVED' ? 'success' : undefined,
596 )}
597 {row('Checks', checkLine, checks!.failed > 0 ? 'error' : checks!.running > 0 ? 'warning' : checks!.passed > 0 ? 'success' : undefined)}
598 {checks!.failing.slice(0, 3).map((name, i) => (
599 <Text key={`f${i}`} color="error" wrap="truncate-end">
600 {' '}✗ {name}
601 </Text>
602 ))}
603 {pr.state === 'OPEN' &&
604 row(
605 'Threads',
606 pr.unresolved === null ? 'unknown' : pr.unresolved === 0 ? 'all resolved' : `${pr.unresolved} unresolved`,
607 pr.unresolved !== null && pr.unresolved > 0 ? 'warning' : undefined,
608 )}
609 </>
610 ))}
611
612 {(s.services.length > 0 || s.problem !== null) && head('Services', s.files.length === 0 ? undefined : s.isTrusted ? undefined : 'not trusted')}
613 {s.problem !== null && (
614 <Text color="warning" wrap="wrap">
615 {s.problem}
616 </Text>
617 )}
618 {s.services.map((v, i) => {
619 const canPress = isRunning(v) ? v.canStop : v.state === 'down' ? v.canStart : false
620 const verb = isRunning(v) ? 'stop' : 'start'
621 return (
622 <Box key={`s${v.name}`}>
623 <Box width={cols - 9} flexShrink={0}>
624 <Text wrap="truncate-end">
625 <Text color={stateColor(v.state)}>{stateMark(v.state)}</Text> {v.name}{' '}
626 <Text dimColor>
627 {v.state === 'up' || v.state === 'down' ? v.probe : `${v.state}…`}
628 </Text>
629 </Text>
630 </Box>
631 {canPress && i < 9 && (
632 <Button key={`svc-${v.name}`} plain hotkey={String(i + 1)} label={verb} onPress={() => toggle($, v.name)} />
633 )}
634 </Box>
635 )
636 })}
637 {s.services.length > 0 && !s.isTrusted && (
638 <Button key="trust" plain hotkey="t" label={`trust ${s.files.join(' + ')}`} onPress={async () => $.ui.toast(`desk: ${await trust($)}`)} />
639 )}
640
641 {prev !== null && head('Last session', `${ago(at - prev.at)} ago`)}
642 {prev !== null && (
643 <>
644 {prev.branch !== null && prev.branch !== g.branch && <Text dimColor wrap="truncate-end">on {prev.branch}</Text>}
645 {prev.prompts.slice(-2).map((p, i) => (
646 <Text key={`p${i}`} wrap="truncate-end">
647 <Text dimColor>›</Text> {clip(p, 120)}
648 </Text>
649 ))}
650 </>
651 )}
652
653 {root !== null && (
654 <Box marginTop={1}>
655 <Box marginRight={3} flexShrink={0}>
656 <Button key="refresh" plain hotkey="r" label="refresh" onPress={() => Promise.all([refreshGit($), refreshWork($, true), loadStack($)])} />
657 </Box>
658 {issue !== null && (
659 <Box marginRight={3} flexShrink={0}>
660 <Button key="open-issue" plain hotkey="i" label="issue" onPress={() => openInBrowser($, root, 'issue', issue.number)} />
661 </Box>
662 )}
663 {pr !== null && <Button key="open-pr" plain hotkey="p" label="PR" onPress={() => openInBrowser($, root, 'pr', pr.number)} />}
664 </Box>
665 )}
666 {root !== null && <Text dimColor>Click, or ctrl+x tab then the key.</Text>}
667 </Box>
668 )
669 })
670}
671hooks/format.ts 43 lines1import type { Criterion } from '../types'
2
3/** A span of time as a person says it: `40s`, `12m`, `3h`, `2d`. */
4export function ago(ms: number): string {
5 const s = Math.max(0, Math.round(ms / 1000))
6 if (s < 60) return `${s}s`
7 const m = Math.round(s / 60)
8 if (m < 60) return `${m}m`
9 const h = Math.round(m / 60)
10 if (h < 48) return `${h}h`
11 return `${Math.round(h / 24)}d`
12}
13
14/** Text cut to `n` characters, an ellipsis marking the cut. */
15export function clip(text: string, n: number): string {
16 return text.length <= n ? text : `${text.slice(0, Math.max(0, n - 1))}…`
17}
18
19/** A bar of `width` cells, `done` of `total` filled. */
20export function bar(done: number, total: number, width: number): string {
21 const filled = total <= 0 ? 0 : Math.round((Math.min(done, total) / total) * width)
22 return '█'.repeat(filled) + '░'.repeat(width - filled)
23}
24
25export function criteriaCount(criteria: readonly Criterion[]): { done: number; total: number } | null {
26 if (criteria.length === 0) return null
27 return { done: criteria.filter(c => c.isDone).length, total: criteria.length }
28}
29
30/** A review decision in the pane's words. */
31export function reviewWords(review: string | null): string {
32 switch (review) {
33 case 'APPROVED':
34 return 'approved'
35 case 'CHANGES_REQUESTED':
36 return 'changes requested'
37 case 'REVIEW_REQUIRED':
38 return 'review required'
39 default:
40 return 'no review asked'
41 }
42}
43hooks/git.ts 83 lines1import type { EngineInterface } from 'claude-code'
2
3import type { GitState } from '../types'
4
5export const NO_GIT: GitState = {
6 root: null,
7 branch: null,
8 upstream: null,
9 ahead: 0,
10 behind: 0,
11 staged: 0,
12 changed: 0,
13 untracked: 0,
14}
15
16/**
17 * The default way a branch names its issue: a number standing alone between
18 * separators, as in `implement/284-conflict-alert`, `feature/12_login`, `gh-7`
19 * or `123`. A version such as `bump-1.2.3` names none.
20 */
21export const ISSUE_PATTERN = '(?:^|[/_#-])(\\d+)(?=[/_-]|$)'
22
23/** The issue number a branch name carries, by the pattern's first group. */
24export function issueFromBranch(branch: string | null, pattern = ISSUE_PATTERN): number | null {
25 if (branch === null) return null
26 let re: RegExp
27 try {
28 re = new RegExp(pattern)
29 } catch {
30 return null
31 }
32 const found = re.exec(branch)?.[1]
33 const n = found === undefined ? NaN : Number(found)
34 return Number.isInteger(n) && n > 0 ? n : null
35}
36
37/** Reads `git status --porcelain=v2 --branch` into counts. */
38export function parseStatus(text: string): Omit<GitState, 'root'> {
39 const { root: _root, ...state } = NO_GIT
40 for (const line of text.split('\n')) {
41 if (line.startsWith('# branch.head ')) {
42 const head = line.slice('# branch.head '.length)
43 state.branch = head === '(detached)' ? null : head
44 } else if (line.startsWith('# branch.upstream ')) {
45 state.upstream = line.slice('# branch.upstream '.length)
46 } else if (line.startsWith('# branch.ab ')) {
47 const [ahead, behind] = line.slice('# branch.ab '.length).split(' ')
48 state.ahead = Math.abs(Number(ahead) || 0)
49 state.behind = Math.abs(Number(behind) || 0)
50 } else if (line.startsWith('1 ') || line.startsWith('2 ') || line.startsWith('u ')) {
51 const xy = line.slice(2, 4)
52 if (xy[0] !== '.') state.staged += 1
53 if (xy[1] !== '.') state.changed += 1
54 } else if (line.startsWith('? ')) {
55 state.untracked += 1
56 }
57 }
58 return state
59}
60
61/** The working tree at `cwd`; outside a repository, or without git, all empty. */
62export async function readGit($: EngineInterface, cwd: string): Promise<GitState> {
63 try {
64 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 5000 })
65 if (top.exitCode !== 0) return NO_GIT
66 const status = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd, timeoutMs: 10_000 })
67 if (status.exitCode !== 0) return { ...NO_GIT, root: top.stdout.trim() }
68 return { root: top.stdout.trim(), ...parseStatus(status.stdout) }
69 } catch {
70 return NO_GIT
71 }
72}
73
74/** The repository's git directory (where desk keeps service logs), or null. */
75export async function gitDir($: EngineInterface, cwd: string): Promise<string | null> {
76 try {
77 const r = await $.process.run(['git', 'rev-parse', '--absolute-git-dir'], { cwd, timeoutMs: 5000 })
78 return r.exitCode === 0 ? r.stdout.trim() : null
79 } catch {
80 return null
81 }
82}
83hooks/github.ts 261 lines1import type { EngineInterface } from 'claude-code'
2
3import type { CheckSummary, Criterion, IssueView, PrView, WorkView } from '../types'
4import { issueFromBranch } from './git'
5
6const CHECKBOX = /^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/
7const HEADING = /^(#{1,6})\s+(.*)$/
8
9/** Drops the markdown that reads as noise in one line of a pane. */
10function plain(text: string): string {
11 return text
12 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
13 .replace(/[*_`]/g, '')
14 .trim()
15}
16
17/**
18 * The checklist an issue body holds: the checkboxes under its first heading
19 * that says "acceptance" (to the next heading as high or higher), or, with no
20 * such heading, every checkbox in the body.
21 */
22export function parseCriteria(body: string): { criteria: Criterion[]; source: IssueView['criteriaSource'] } {
23 const lines = body.split(/\r?\n/)
24 const all: Criterion[] = []
25 const under: Criterion[] = []
26 let level: number | null = null
27 let isUnder = false
28 for (const line of lines) {
29 const heading = HEADING.exec(line)
30 if (heading !== null) {
31 const depth = heading[1]!.length
32 if (isUnder && level !== null && depth <= level) isUnder = false
33 if (level === null && /acceptance/i.test(heading[2]!)) {
34 level = depth
35 isUnder = true
36 }
37 continue
38 }
39 const box = CHECKBOX.exec(line)
40 if (box === null) continue
41 const item = { text: plain(box[2]!), isDone: box[1] !== ' ' }
42 all.push(item)
43 if (isUnder) under.push(item)
44 }
45 if (under.length > 0) return { criteria: under, source: 'acceptance' }
46 if (all.length > 0) return { criteria: all, source: 'all' }
47 return { criteria: [], source: 'none' }
48}
49
50type RollupEntry = { name?: string; context?: string; status?: string; conclusion?: string; state?: string }
51
52const RUNNING = new Set(['QUEUED', 'IN_PROGRESS', 'PENDING', 'WAITING', 'REQUESTED', 'EXPECTED'])
53const FAILED = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR'])
54const SKIPPED = new Set(['SKIPPED', 'NEUTRAL', 'STALE'])
55
56/** Counts a PR's `statusCheckRollup` (check runs and commit statuses alike). */
57export function summarizeChecks(rollup: readonly RollupEntry[]): CheckSummary {
58 const sum: CheckSummary = { passed: 0, failed: 0, running: 0, skipped: 0, failing: [] }
59 for (const c of rollup) {
60 // A check run has `status` then `conclusion`; a commit status has `state` alone.
61 const word = (c.status !== undefined && c.status !== 'COMPLETED' ? c.status : (c.conclusion ?? c.state ?? '')).toUpperCase()
62 if (RUNNING.has(word) || word === '') sum.running += 1
63 else if (FAILED.has(word)) {
64 sum.failed += 1
65 sum.failing.push(c.name ?? c.context ?? 'check')
66 } else if (SKIPPED.has(word)) sum.skipped += 1
67 else sum.passed += 1
68 }
69 return sum
70}
71
72/** Why a gh call failed, in the words the pane shows. */
73function problemOf(stderr: string): string {
74 if (/gh auth login|not logged in|authentication/i.test(stderr)) return 'gh is not signed in: run gh auth login'
75 if (/not a git repository|no git remotes|none of the git remotes/i.test(stderr)) return 'no GitHub remote here'
76 const line = stderr.trim().split('\n')[0] ?? ''
77 return line === '' ? 'gh failed' : line.slice(0, 120)
78}
79
80class GhError extends Error {}
81
82async function gh($: EngineInterface, cwd: string, args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
83 try {
84 const r = await $.process.run(['gh', ...args], { cwd, timeoutMs: 20_000, env: { GH_PROMPT_DISABLED: '1', NO_COLOR: '1' } })
85 return { ok: r.exitCode === 0, stdout: r.stdout, stderr: r.stderr }
86 } catch (err) {
87 // The command could not start: gh is not installed, or it hung.
88 throw new GhError(/timed out|still running/i.test(String(err)) ? 'gh timed out' : 'gh is not installed')
89 }
90}
91
92function json<T>(text: string): T | null {
93 try {
94 return JSON.parse(text) as T
95 } catch {
96 return null
97 }
98}
99
100/** Repositories already resolved, by the folder they were resolved for. */
101const repos = new Map<string, { owner: string; name: string }>()
102
103async function repoOf($: EngineInterface, root: string) {
104 const known = repos.get(root)
105 if (known !== undefined) return known
106 const r = await gh($, root, ['repo', 'view', '--json', 'owner,name'])
107 if (!r.ok) throw new GhError(problemOf(r.stderr))
108 const v = json<{ owner: { login: string }; name: string }>(r.stdout)
109 if (v === null) throw new GhError('gh answered something unreadable')
110 const repo = { owner: v.owner.login, name: v.name }
111 repos.set(root, repo)
112 return repo
113}
114
115type PrJson = {
116 number: number
117 title: string
118 state: string
119 isDraft: boolean
120 url: string
121 reviewDecision: string | null
122 statusCheckRollup: RollupEntry[] | null
123 closingIssuesReferences: ClosingRef[] | null
124}
125
126type ClosingRef = { number: number; repository?: { name: string; owner: { login: string } } }
127
128/**
129 * The first issue a PR closes in this repository. One in another repository
130 * is passed over: the pane reads and opens issues here.
131 */
132export function closingIssue(refs: readonly ClosingRef[] | null, repo: { owner: string; name: string }): number | null {
133 const same = (refs ?? []).find(
134 r =>
135 r.repository === undefined ||
136 (r.repository.owner.login.toLowerCase() === repo.owner.toLowerCase() && r.repository.name.toLowerCase() === repo.name.toLowerCase()),
137 )
138 return same?.number ?? null
139}
140
141async function readPr($: EngineInterface, root: string, branch: string) {
142 const r = await gh($, root, [
143 'pr',
144 'view',
145 branch,
146 '--json',
147 'number,title,state,isDraft,url,reviewDecision,statusCheckRollup,closingIssuesReferences',
148 ])
149 if (!r.ok) {
150 if (/no (open )?pull requests? found/i.test(r.stderr)) return null
151 throw new GhError(problemOf(r.stderr))
152 }
153 return json<PrJson>(r.stdout)
154}
155
156const THREADS = `query($owner: String!, $name: String!, $number: Int!) {
157 repository(owner: $owner, name: $name) {
158 pullRequest(number: $number) { reviewThreads(first: 100) { nodes { isResolved } } }
159 }
160}`
161
162async function unresolvedThreads($: EngineInterface, root: string, repo: { owner: string; name: string }, number: number) {
163 const r = await gh($, root, [
164 'api',
165 'graphql',
166 '-f',
167 `query=${THREADS}`,
168 '-f',
169 `owner=${repo.owner}`,
170 '-f',
171 `name=${repo.name}`,
172 '-F',
173 `number=${number}`,
174 ])
175 if (!r.ok) return null
176 const v = json<{ data?: { repository?: { pullRequest?: { reviewThreads?: { nodes?: { isResolved: boolean }[] } } } } }>(r.stdout)
177 const nodes = v?.data?.repository?.pullRequest?.reviewThreads?.nodes
178 return nodes === undefined ? null : nodes.filter(t => !t.isResolved).length
179}
180
181type IssueJson = {
182 number: number
183 title: string
184 state: string
185 url: string
186 body: string
187 projectItems?: { title: string; status?: { name: string } | null }[]
188}
189
190async function readIssue($: EngineInterface, root: string, number: number): Promise<IssueView | null> {
191 // projectItems needs the token's `project` scope; without it, read the issue without its board.
192 let r = await gh($, root, ['issue', 'view', String(number), '--json', 'number,title,state,url,body,projectItems'])
193 if (!r.ok && /project/i.test(r.stderr)) r = await gh($, root, ['issue', 'view', String(number), '--json', 'number,title,state,url,body'])
194 if (!r.ok) {
195 if (/could not resolve|not found/i.test(r.stderr)) return null
196 throw new GhError(problemOf(r.stderr))
197 }
198 const v = json<IssueJson>(r.stdout)
199 if (v === null) return null
200 const { criteria, source } = parseCriteria(v.body ?? '')
201 return {
202 number: v.number,
203 title: v.title,
204 state: v.state,
205 url: v.url,
206 columns: (v.projectItems ?? [])
207 .filter(p => p.status?.name !== undefined)
208 .map(p => ({ project: p.title, status: p.status!.name })),
209 criteria,
210 criteriaSource: source,
211 }
212}
213
214/**
215 * Reads the branch's PR and the issue it works on: the first issue the PR
216 * closes, else the number in the branch's name.
217 */
218export async function readWork(
219 $: EngineInterface,
220 root: string,
221 branch: string | null,
222 issuePattern: string | undefined,
223 at: number,
224): Promise<WorkView> {
225 const empty: WorkView = { at, repo: null, issueNumber: null, issue: null, pr: null, problem: null, isLoading: false }
226 try {
227 const repo = await repoOf($, root)
228 const base = { ...empty, repo: `${repo.owner}/${repo.name}` }
229 if (branch === null) return base
230 const prJson = await readPr($, root, branch)
231 const pr: PrView | null =
232 prJson === null
233 ? null
234 : {
235 number: prJson.number,
236 title: prJson.title,
237 state: prJson.state,
238 isDraft: prJson.isDraft,
239 url: prJson.url,
240 review: prJson.reviewDecision === '' ? null : prJson.reviewDecision,
241 checks: summarizeChecks(prJson.statusCheckRollup ?? []),
242 unresolved: prJson.state === 'OPEN' ? await unresolvedThreads($, root, repo, prJson.number) : 0,
243 }
244 const issueNumber = closingIssue(prJson?.closingIssuesReferences ?? null, repo) ?? issueFromBranch(branch, issuePattern)
245 const issue = issueNumber === null ? null : await readIssue($, root, issueNumber)
246 return { ...base, issueNumber, issue, pr }
247 } catch (err) {
248 if (err instanceof GhError) return { ...empty, problem: err.message }
249 return { ...empty, problem: `GitHub could not be read: ${String(err).slice(0, 100)}` }
250 }
251}
252
253/** Opens a PR or issue in the browser through gh, which knows the platform's opener. */
254export async function openInBrowser($: EngineInterface, root: string, kind: 'pr' | 'issue', number: number) {
255 try {
256 await gh($, root, [kind, 'view', String(number), '--web'])
257 } catch {
258 // No gh: nothing to open it with; the pane already says gh is missing.
259 }
260}
261hooks/stack.ts 259 lines1import type { EngineInterface } from 'claude-code'
2
3import type { ServiceState, ServiceView } from '../types'
4
5export type CheckSpec = { port: number; host?: string } | { http: string } | { command: string }
6
7export type ServiceSpec = {
8 name: string
9 /** How to tell it is up. Without one, a service is up while the process desk started lives. */
10 check?: CheckSpec
11 /** A shell command that runs the service; desk runs it in the background and keeps its log. */
12 start?: string
13 /** A shell command that stops it; without one, desk stops the process group it started. */
14 stop?: string
15 /** Where the commands run, relative to the repository; the repository by default. */
16 cwd?: string
17}
18
19export type DeskConfig = { issuePattern?: string; services: ServiceSpec[] }
20
21/** The team's file, committed, then the person's own, which wins by service name. */
22export const CONFIG_FILES = ['.claude/desk.json', '.claude/desk.local.json'] as const
23
24export const TEMPLATE = `{
25 "services": [
26 {
27 "name": "Database",
28 "check": { "port": 5432 },
29 "start": "docker compose up -d db",
30 "stop": "docker compose stop db"
31 },
32 {
33 "name": "Web",
34 "check": { "http": "http://localhost:3000" },
35 "start": "npm run dev"
36 }
37 ]
38}
39`
40
41function isRecord(v: unknown): v is Record<string, unknown> {
42 return typeof v === 'object' && v !== null && !Array.isArray(v)
43}
44
45function parseCheck(v: unknown, where: string): CheckSpec | string {
46 if (!isRecord(v)) return `${where}: "check" must be an object`
47 if (typeof v.port === 'number') {
48 if (!Number.isInteger(v.port) || v.port < 1 || v.port > 65535) return `${where}: "port" must be 1 to 65535`
49 return typeof v.host === 'string' ? { port: v.port, host: v.host } : { port: v.port }
50 }
51 if (typeof v.http === 'string') return /^https?:\/\//.test(v.http) ? { http: v.http } : `${where}: "http" must be an http(s) URL`
52 if (typeof v.command === 'string') return { command: v.command }
53 return `${where}: "check" needs "port", "http" or "command"`
54}
55
56/** Reads one config file's text; a problem names the file and what is wrong. */
57export function parseConfig(text: string, file: string): { config: DeskConfig } | { problem: string } {
58 let raw: unknown
59 try {
60 raw = JSON.parse(text)
61 } catch (err) {
62 return { problem: `${file} is not valid JSON: ${String(err).replace(/^SyntaxError: /, '').slice(0, 100)}` }
63 }
64 if (!isRecord(raw)) return { problem: `${file} must hold a JSON object` }
65 const config: DeskConfig = { services: [] }
66 if (raw.issuePattern !== undefined) {
67 if (typeof raw.issuePattern !== 'string') return { problem: `${file}: "issuePattern" must be a string` }
68 try {
69 new RegExp(raw.issuePattern)
70 } catch {
71 return { problem: `${file}: "issuePattern" is not a valid regular expression` }
72 }
73 config.issuePattern = raw.issuePattern
74 }
75 const services = raw.services ?? []
76 if (!Array.isArray(services)) return { problem: `${file}: "services" must be a list` }
77 for (const [i, s] of services.entries()) {
78 const where = `${file} services[${i}]`
79 if (!isRecord(s) || typeof s.name !== 'string' || s.name.trim() === '') return { problem: `${where} needs a "name"` }
80 const spec: ServiceSpec = { name: s.name.trim() }
81 if (s.check !== undefined) {
82 const check = parseCheck(s.check, where)
83 if (typeof check === 'string') return { problem: check }
84 spec.check = check
85 }
86 for (const key of ['start', 'stop', 'cwd'] as const) {
87 if (s[key] === undefined) continue
88 if (typeof s[key] !== 'string') return { problem: `${where}: "${key}" must be a string` }
89 spec[key] = s[key]
90 }
91 config.services.push(spec)
92 }
93 return { config }
94}
95
96/** Lays the person's file over the team's: same-named services replaced, new ones added. */
97export function mergeConfigs(base: DeskConfig, over: DeskConfig): DeskConfig {
98 const byName = new Map(base.services.map(s => [s.name, s]))
99 for (const s of over.services) byName.set(s.name, s)
100 return { issuePattern: over.issuePattern ?? base.issuePattern, services: [...byName.values()] }
101}
102
103/** Whether any service would run a command of the config's: what trust is asked for. */
104export function hasCommands(config: DeskConfig): boolean {
105 return config.services.some(s => s.start !== undefined || s.stop !== undefined || (s.check !== undefined && 'command' in s.check))
106}
107
108/** A short fingerprint of the config's text, so trust lapses when the commands change. */
109export function fingerprint(text: string): string {
110 let h = 0x811c9dc5
111 for (let i = 0; i < text.length; i++) {
112 h ^= text.charCodeAt(i)
113 h = Math.imul(h, 0x01000193)
114 }
115 return (h >>> 0).toString(16).padStart(8, '0')
116}
117
118export type LoadedConfig = { config: DeskConfig; files: string[]; fingerprint: string; problem: string | null }
119
120export async function loadConfig($: EngineInterface, root: string): Promise<LoadedConfig> {
121 let config: DeskConfig = { services: [] }
122 const files: string[] = []
123 let text = ''
124 for (const file of CONFIG_FILES) {
125 const path = `${root}/${file}`
126 if (!(await $.fs.exists(path))) continue
127 let body: string
128 try {
129 body = await $.fs.read(path)
130 } catch {
131 return { config: { services: [] }, files, fingerprint: '', problem: `${file} could not be read` }
132 }
133 const parsed = parseConfig(body, file)
134 if ('problem' in parsed) return { config: { services: [] }, files, fingerprint: '', problem: parsed.problem }
135 config = mergeConfigs(config, parsed.config)
136 files.push(file)
137 text += `${file}\n${body}\n`
138 }
139 return { config, files, fingerprint: fingerprint(text), problem: null }
140}
141
142export function probeOf(spec: ServiceSpec): string {
143 if (spec.check === undefined) return spec.start === undefined ? 'no check' : 'process'
144 if ('port' in spec.check) return `${spec.check.host ?? ''}:${spec.check.port}`
145 if ('http' in spec.check) return spec.check.http.replace(/^https?:\/\//, '')
146 return 'command'
147}
148
149function cwdOf(root: string, spec: ServiceSpec): string {
150 if (spec.cwd === undefined || spec.cwd === '' || spec.cwd === '.') return root
151 return spec.cwd.startsWith('/') ? spec.cwd : `${root}/${spec.cwd}`
152}
153
154/** Whether the process group desk started is still running. */
155export async function isAlive($: EngineInterface, pid: number): Promise<boolean> {
156 try {
157 return (await $.process.run(['sh', '-c', 'kill -0 "$1" 2>/dev/null', 'desk', String(pid)], { timeoutMs: 3000 })).exitCode === 0
158 } catch {
159 return false
160 }
161}
162
163/**
164 * Whether a service answers now. A command check runs only once the config
165 * is trusted; untrusted, it reads as unknown.
166 */
167export async function probe(
168 $: EngineInterface,
169 root: string,
170 spec: ServiceSpec,
171 pid: number | null,
172 isTrusted: boolean,
173): Promise<'up' | 'down' | 'unknown'> {
174 const check = spec.check
175 try {
176 if (check === undefined) return pid !== null && (await isAlive($, pid)) ? 'up' : pid === null && spec.start === undefined ? 'unknown' : 'down'
177 if ('port' in check) {
178 // Without a host, IPv4 then IPv6: a dev server on `localhost` (Vite, say) often listens on ::1 alone.
179 const hosts = check.host === undefined ? ['127.0.0.1', '::1'] : [check.host]
180 const r = await $.process.run(
181 ['bash', '-c', 'p=$1; shift; for h; do (exec 3<>"/dev/tcp/$h/$p") 2>/dev/null && exit 0; done; exit 1', 'desk', String(check.port), ...hosts],
182 { timeoutMs: 3000 },
183 )
184 return r.exitCode === 0 ? 'up' : 'down'
185 }
186 if ('http' in check) {
187 await $.http.fetch(check.http)
188 return 'up'
189 }
190 if (!isTrusted) return 'unknown'
191 const r = await $.process.run(['sh', '-c', check.command], { cwd: cwdOf(root, spec), timeoutMs: 5000 })
192 return r.exitCode === 0 ? 'up' : 'down'
193 } catch {
194 // A port check past its timeout, a refused connection, a check that hung: not up.
195 return 'down'
196 }
197}
198
199/** A file-name-safe form of a service's name. */
200export function slug(name: string): string {
201 return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'service'
202}
203
204export function logPath(logDir: string, spec: ServiceSpec): string {
205 return `${logDir}/${slug(spec.name)}.log`
206}
207
208/**
209 * Runs the start command in the background, in a process group of its own
210 * (so it can be stopped whole), detached from the session, its output in the
211 * log. Resolves the group's id.
212 */
213export async function startService($: EngineInterface, root: string, logDir: string, spec: ServiceSpec): Promise<number> {
214 if (spec.start === undefined) throw new Error(`${spec.name} has no start command`)
215 // `set -m` gives the background job a process group of its own whose id is its pid.
216 const script = 'set -m; mkdir -p "$(dirname "$3")" || exit 1; cd "$1" || exit 1; nohup sh -c "$2" >"$3" 2>&1 </dev/null & echo $!'
217 const r = await $.process.run(['sh', '-c', script, 'desk', cwdOf(root, spec), spec.start, logPath(logDir, spec)], { timeoutMs: 10_000 })
218 const pid = Number(r.stdout.trim())
219 if (r.exitCode !== 0 || !Number.isInteger(pid) || pid <= 0) {
220 throw new Error(`${spec.name} did not start: ${(r.stderr.trim() || `exit ${r.exitCode}`).slice(0, 160)}`)
221 }
222 return pid
223}
224
225/** Stops a service: by its stop command, else by the process group desk started. */
226export async function stopService($: EngineInterface, root: string, spec: ServiceSpec, pid: number | null): Promise<void> {
227 if (spec.stop !== undefined) {
228 const r = await $.process.run(['sh', '-c', spec.stop], { cwd: cwdOf(root, spec), timeoutMs: 60_000 })
229 if (r.exitCode !== 0) throw new Error(`${spec.name}'s stop command failed: ${(r.stderr.trim() || `exit ${r.exitCode}`).slice(0, 160)}`)
230 return
231 }
232 if (pid === null) throw new Error(`${spec.name} has no stop command, and desk did not start it`)
233 await $.process.run(['sh', '-c', 'kill -TERM -- "-$1" 2>/dev/null || kill -TERM "$1" 2>/dev/null; true', 'desk', String(pid)], {
234 timeoutMs: 5000,
235 })
236}
237
238/** A service's state as the pane shows it, from its probe and what desk is doing to it. */
239export function stateOf(
240 probed: 'up' | 'down' | 'unknown',
241 doing: { action: 'start' | 'stop'; since: number } | undefined,
242 at: number,
243): ServiceState {
244 if (doing?.action === 'start' && probed !== 'up' && at - doing.since < 120_000) return 'starting'
245 if (doing?.action === 'stop' && probed === 'up' && at - doing.since < 30_000) return 'stopping'
246 return probed
247}
248
249export function viewOf(spec: ServiceSpec, state: ServiceState, pid: number | null, isTrusted: boolean): ServiceView {
250 return {
251 name: spec.name,
252 state,
253 pid,
254 probe: probeOf(spec),
255 canStart: isTrusted && spec.start !== undefined,
256 canStop: isTrusted && (spec.stop !== undefined || pid !== null),
257 }
258}
259hooks/trail.ts 113 lines1import type { Crumb, GitState, Trail, WorkView } from '../types'
2import { ago, clip, criteriaCount } from './format'
3
4const PROMPTS_KEPT = 5
5/** Sessions kept per repository, so two open at once each keep their own crumb. */
6const SESSIONS_KEPT = 5
7
8/** A trail as the store holds it; one kept as `{ current, previous }` by an earlier desk reads as its crumbs. */
9export function asTrail(stored: unknown): Trail | null {
10 if (typeof stored !== 'object' || stored === null) return null
11 const v = stored as { crumbs?: unknown; current?: Crumb | null; previous?: Crumb | null }
12 if (Array.isArray(v.crumbs)) return { crumbs: v.crumbs as Crumb[] }
13 return { crumbs: [v.current, v.previous].filter((c): c is Crumb => c != null) }
14}
15
16function crumbFor(trail: Trail | null, sessionId: string, at: number): Crumb {
17 return trail?.crumbs.find(c => c.sessionId === sessionId) ?? { sessionId, at, branch: null, prompts: [], lastAnswer: null, issue: null, pr: null }
18}
19
20/** The trail with this session's crumb, changed, first. */
21function withCrumb(trail: Trail | null, crumb: Crumb): Trail {
22 const others = (trail?.crumbs ?? []).filter(c => c.sessionId !== crumb.sessionId)
23 return { crumbs: [crumb, ...others].slice(0, SESSIONS_KEPT) }
24}
25
26/** The trail with a prompt added to this session's crumb. */
27export function withPrompt(trail: Trail | null, sessionId: string, at: number, text: string, branch: string | null): Trail {
28 const crumb = crumbFor(trail, sessionId, at)
29 const line = clip(text.replace(/\s+/g, ' ').trim(), 200)
30 if (line === '') return trail ?? { crumbs: [] }
31 return withCrumb(trail, { ...crumb, at, branch, prompts: [...crumb.prompts, line].slice(-PROMPTS_KEPT) })
32}
33
34/** The trail with this session's last answer and where the work stood. */
35export function withAnswer(
36 trail: Trail | null,
37 sessionId: string,
38 at: number,
39 answer: string,
40 branch: string | null,
41 work: WorkView,
42): Trail {
43 const crumb = crumbFor(trail, sessionId, at)
44 const first = answer.trim().split('\n').find(l => l.trim() !== '')
45 return withCrumb(trail, {
46 ...crumb,
47 at,
48 branch,
49 lastAnswer: first === undefined ? crumb.lastAnswer : clip(first.trim(), 200),
50 issue: work.issue === null ? crumb.issue : { number: work.issue.number, title: work.issue.title },
51 pr: work.pr === null ? crumb.pr : { number: work.pr.number, state: work.pr.state },
52 })
53}
54
55/** The latest other session in the repository. */
56export function previousOf(trail: Trail | null, sessionId: string): Crumb | null {
57 return trail?.crumbs.find(c => c.sessionId !== sessionId) ?? null
58}
59
60/**
61 * The note the model reads at the start of a conversation: the branch and
62 * tree as they are now, the issue and PR as last read, and the previous
63 * session's last steps. Null when there is nothing to say.
64 */
65export function leftOffNote(git: GitState, work: WorkView | null, previous: Crumb | null, at: number): string | null {
66 if (git.root === null && previous === null) return null
67 const lines = ['Where the person left off in this repository, from the desk plugin. Use it when they ask where they left off or what is next; do not recite it unprompted.']
68 if (git.branch !== null) {
69 const tree = [
70 git.upstream === null ? 'no upstream' : `${git.ahead} ahead, ${git.behind} behind ${git.upstream}`,
71 `${git.staged} staged`,
72 `${git.changed} changed`,
73 `${git.untracked} untracked`,
74 ]
75 lines.push(`Branch: ${git.branch} (${tree.join(', ')})`)
76 }
77 const issue = work?.issue
78 if (issue != null) {
79 const board = issue.columns.map(c => `${c.status} on ${c.project}`).join(', ')
80 const count = criteriaCount(issue.criteria)
81 lines.push(
82 `Issue: #${issue.number} "${issue.title}" (${issue.state.toLowerCase()}${board === '' ? '' : `, ${board}`})` +
83 (count === null ? '' : `, acceptance criteria ${count.done} of ${count.total} ticked`),
84 )
85 const open = issue.criteria.filter(c => !c.isDone)
86 for (const c of open.slice(0, 8)) lines.push(` [ ] ${clip(c.text, 160)}`)
87 if (open.length > 8) lines.push(` ... and ${open.length - 8} more unticked`)
88 }
89 const pr = work?.pr
90 if (pr != null) {
91 const ch = pr.checks
92 const parts = [
93 pr.isDraft ? 'draft' : pr.state.toLowerCase(),
94 pr.review === null ? null : pr.review.toLowerCase().replace(/_/g, ' '),
95 ch.failed + ch.passed + ch.running === 0 ? null : `checks ${ch.passed} passed, ${ch.failed} failed, ${ch.running} running`,
96 pr.unresolved === null || pr.unresolved === 0 ? null : `${pr.unresolved} unresolved review threads`,
97 ].filter(p => p !== null)
98 lines.push(`PR: #${pr.number} "${pr.title}" (${parts.join(', ')})`)
99 } else if (work?.repo != null && git.branch !== null) {
100 lines.push('PR: none for this branch yet')
101 }
102 if (work?.at != null) lines.push(`(Issue and PR as read ${ago(at - work.at)} ago.)`)
103 if (previous !== null) {
104 lines.push(`Previous session here: ${ago(at - previous.at)} ago${previous.branch === null ? '' : `, on ${previous.branch}`}.`)
105 if (previous.prompts.length > 0) {
106 lines.push('Its last prompts, oldest first:')
107 for (const p of previous.prompts) lines.push(` - ${p}`)
108 }
109 if (previous.lastAnswer !== null) lines.push(`Claude's last answer began: ${previous.lastAnswer}`)
110 }
111 return lines.length === 1 ? null : lines.join('\n')
112}
113types/index.d.ts 120 lines1/** The working tree the session runs in, as git reports it. */
2export type GitState = {
3 /** The repository's top level, or null outside a git repository. */
4 root: string | null
5 branch: string | null
6 upstream: string | null
7 ahead: number
8 behind: number
9 staged: number
10 changed: number
11 untracked: number
12}
13
14export type Criterion = { text: string; isDone: boolean }
15
16export type IssueView = {
17 number: number
18 title: string
19 state: string
20 url: string
21 /** The issue's place on each GitHub project board it is on. */
22 columns: { project: string; status: string }[]
23 criteria: Criterion[]
24 /** `acceptance`: the checkboxes under an acceptance-criteria heading; `all`: every checkbox in the body; `none`: no checkboxes. */
25 criteriaSource: 'acceptance' | 'all' | 'none'
26}
27
28export type CheckSummary = {
29 passed: number
30 failed: number
31 running: number
32 skipped: number
33 /** Names of the checks that failed. */
34 failing: string[]
35}
36
37export type PrView = {
38 number: number
39 title: string
40 state: string
41 isDraft: boolean
42 url: string
43 /** `APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED`, or null where no review is asked for. */
44 review: string | null
45 checks: CheckSummary
46 /** Review threads not yet resolved, or null when they could not be read. */
47 unresolved: number | null
48}
49
50/** What GitHub says about the branch: its PR and the issue it works on. */
51export type WorkView = {
52 /** When this was last read, or null before the first read. */
53 at: number | null
54 /** `owner/name` of the GitHub repository. */
55 repo: string | null
56 /** The issue number the branch names, or the PR closes. */
57 issueNumber: number | null
58 issue: IssueView | null
59 pr: PrView | null
60 /** Why GitHub could not be read (no gh, not signed in, not a GitHub repository). */
61 problem: string | null
62 isLoading: boolean
63}
64
65export type ServiceState = 'up' | 'down' | 'starting' | 'stopping' | 'unknown'
66
67export type ServiceView = {
68 name: string
69 state: ServiceState
70 /** The process group desk started it in, or null when desk did not start it. */
71 pid: number | null
72 /** How it is checked, as the pane shows it (`:5432`, a URL, `command`). */
73 probe: string
74 canStart: boolean
75 canStop: boolean
76}
77
78export type StackView = {
79 /** The config files read, relative to the repository. */
80 files: string[]
81 /** Whether the person has trusted these config files' commands. */
82 isTrusted: boolean
83 services: ServiceView[]
84 /** Why the config could not be read. */
85 problem: string | null
86}
87
88/** One session's last steps in a repository. */
89export type Crumb = {
90 sessionId: string
91 at: number
92 branch: string | null
93 /** The session's last prompts, oldest first. */
94 prompts: string[]
95 /** The first line of Claude's last answer. */
96 lastAnswer: string | null
97 issue: { number: number; title: string } | null
98 pr: { number: number; state: string } | null
99}
100
101/** A repository's trail: the last few sessions' crumbs, the latest first. */
102export type Trail = { crumbs: Crumb[] }
103
104declare module 'claude-code' {
105 interface PluginState {
106 desk: {
107 git: GitState
108 work: WorkView
109 stack: StackView
110 /** The session before this one in the repository, for the pane. */
111 previous: Crumb | null
112 now: number
113 /** The session id the where-you-left-off note was last sent in. */
114 notedSession: string | null
115 /** Whether the pane lists every criterion rather than the first few. */
116 criteriaOpen: boolean
117 }
118 }
119}
120