SLOPSHOPPER

desk

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…

panecommandtoastpromptprocessnetwork
v0.1.2MITupdated 2026-10-21miskopisko/claude-toolkit/plugins/desk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desk
│ ┃ Desk ✕ › fix the failing auth test and add an audit log call │ ┃ Branch │ ┃ (detached HEAD) ⏺ Read(src/auth.ts) │ ┃ clean ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Issue ⎿ Added 2 lines, removed 1 line │ ┃ gh answered something unreadable ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ r: refresh │ ┃ Click, or ctrl+x tab then the key. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /desk │ ⎿ desk: Desk pane opened. In fullscreen mode it docks on the right │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Desk
Branch (detached HEAD) clean Issue gh answered something unreadable r: refresh Click, or ctrl+x tab then the key.
README

desk

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.

What it shows

  • Branch: the branch, commits ahead of and behind its upstream, and the staged, changed and new files.
  • Issue: the issue the branch works on, its state and its column on each GitHub project board, and its acceptance criteria as a progress bar and a checklist. The criteria are the checkboxes under the issue's first heading that says "acceptance"; with no such heading, every checkbox in the body counts.
  • Pull request: the branch's PR, its review decision, its checks (passed, failed, running, and the names of the failing ones) and how many review threads are unresolved.
  • Services: your local dev services, each up or down, with a key (1 to 9, in order) to start or stop it, and t to trust the project's config (see below).
  • Last session: when you last worked in this repository, and your last prompts there.

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.

Where you left off

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.

Install

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.

Use

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.

Which issue

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.

Services

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.

Notes

  • Everything desk keeps (your last prompts per repository, the last GitHub read, trust, the processes it started) stays in its own store under your Claude Code configuration directory. Nothing is sent anywhere but GitHub, through your own gh.
  • Where an organization manages Claude Code, the note still works: it rides on your first prompt rather than on the session's opening context.

Develop

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 .

Source 7 files
hooks/register.tsx 671 lines
1import { 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}
671
hooks/format.ts 43 lines
1import 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}
43
hooks/git.ts 83 lines
1import 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}
83
hooks/github.ts 261 lines
1import 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}
261
hooks/stack.ts 259 lines
1import 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}
259
hooks/trail.ts 113 lines
1import 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}
113
types/index.d.ts 120 lines
1/** 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