PR and issue workflow toolkit with a git desk: a detached local server triages in background and a local page, in the Browser pane beside the chat or in the…

A plugin for Claude Code and Codex for working a repository's pull requests and issues as a queue: ten shared skills and a local dashboard server with no dependencies outside the Python standard library.
The shape of the whole thing is one idea: fetch paints facts; an explicit triage publishes verdicts; the model judges only what fields cannot answer. The merge gate, the issue cross-check and the issue shortlist are computed while fetching, on every read — a filter is not a verdict, and a model's copy of one is a thing to keep in sync. The PR triage grid and chase blocks are computed in Python and published by the server itself, on the press — the model adds, per PR, only what a field cannot say: the one line of what it is for, a conflict read off the diff, an analysis.
Built for GitHub today, provider-abstracted so a migration to Forgejo only means implementing one class against the same normalized row shape (a first REST implementation ships in the box).
The dashboard's information design comes from Giovanni's PR Review Desk prototype: the queue-with-states table, the summary strip, and the detail panel are his; this repo replaces the mocked data with live provider reads and wires the verdicts to the skills below.
Prefer pictures? There is an illustrated quick guide — one page per skill — also bound as a PDF.
Claude Code:
claude plugin marketplace add fporcari/git-workflow
claude plugin install git-workflow@fporcari
Codex: point it at the marketplace in .agents/plugins/marketplace.json of this repo; the skills are invoked as $pr-triage, $issue-triage, $git-desk, and so on.
The plugin lives in plugins/git-workflow/, with a manifest per host (.claude-plugin/plugin.json and .codex-plugin/plugin.json) and one agents/openai.yaml per skill. The skills themselves are host-agnostic: they say <PLUGIN_ROOT> instead of any host variable, and refs/runtime.md is the one place that resolves it and answers the other host-specific questions — how to ask the user a question, how to launch a detached desk, how to title a session, how to delegate to a background subagent, how to spawn a dedicated one. The four Claude Code wrappers in commands/ are thin by design: they load a skill and declare that host's tool names, nothing else. Explicit desk actions pick their ephemeral backend with --agent auto|claude|codex. The launching conversation stays attached by default — every click except triage is executed there, as the command it stands for — and detaches only on request. Read-only PR analysis can select a model and effort through the portable GIT_WORKFLOW_ANALYZE_* variables, or their host-specific CODEX / CLAUDE variants. server/tests/test_packaging.py pins the cross-host invariants.
From the checkout of the repo you want to work. Pick the line that matches what you actually want.
"What is on my plate?" — read-only, no side effects, a few seconds:
/pr-triage
Every open PR you are involved in, split into five blocks by the kind of work each needs, plus copy-pasteable messages for the people you are waiting on. Then it offers to act on it.
"Just deal with it." — the loop, one PR at a time:
/pr-loop
First the moves that need no permission (merging your own fully-approved PRs, answering a review request that named its own fix, realigning your DIRTY branches), then everything else presented four lines at a time for a go-ahead. basta ends it, and what was not reached is listed in queue order.
"These three, and I have already decided."
/pr-loop 1145,1128,1059 batch=3
Exactly those, in that order, then stop. They are analysed by background agents, at most four at a time, while you do something else; you are interrupted twice — once with a digest of the proposals (ready, yours by hand, nothing to do), answered in one go, and once with the outcome, each fix in its own worktree. refs/batch.md is the protocol.
The same three lines on the issue side: /issue-triage, /issue-loop, /issue-loop 1156,1149 batch=2.
A PR an agent opened is a subordinate's PR. issue-loop and issue-work label every PR they open needs-verification: it stands under your login, but the hands were not yours, so the desk reads it as work to check, not as your own — verify it comes before any merge, in a repository where there is nobody else to ask. Where somebody else IS asked, that review is the verification and the regime does not fire at all. pr-analyze answers with a numbered verification plan, pr-loop shows the plan as the checklist you approve, asks which instance to run the browser checks on, and hands it to a fresh agent that serves the PR from its own worktree — never your running instance. The pass closes by removing the label and recording the tested SHA on the desk, not by publishing a review you would have signed yourself; a push past that SHA asks for the run again, and the merge is then your decision. The launch recipe is the repo's own (a run/ui-test project skill or .claude/launch.json); without one the UI steps come back blocked, not faked.
"Show me, don't tell me." — the detached dashboard:
/git-desk
One local page over three sections — Pull request, Issue, A chi tocca (per person, whose move it is) — in the Browser pane beside the chat on Claude Code, or in the browser on Codex. It opens at once on facts and only triages in background: the reviews asked of you wait under Da rivedere with ▶ pr-loop on the row, and the loop reads them. Once read — by a loop, or overnight by /pr-nightwork — they are sorted: approve the PRs the analysis would sign (one key on the row, or several picked at once), send the changes it would ask for (the motivation editable in the open row), and decide the doubtful ones, with the doubt and the leaning in front of you. The merge stays with the author. The launching chat is titled Git desk · owner/repo · 2026-09-09 14:32 when the desk opens and gets a · closed suffix once the desk is gone — the server registers itself at boot, and the chat's listener ends by itself when the desk has stopped — so the session list tells the two apart.
Attached clicks carry the originating desk, owning session and a unique request ID. A second chat cannot silently take over a live desk. Each chat executes one click at a time, and late results cannot overwrite a newer request. After updating, restart existing desks and chat listeners to use the new routing protocol.
| skill | what it does |
|---|---|
pr-triage | Every open PR you are involved in, split into five blocks by the kind of work each needs: mergeable now, trivial action, reviews you owe, people to chase (grouped per person, ready to paste), and the calls only you can make. Each row carries number, date, author, what it is, what is to be done, and whether pr-loop would handle it unattended — read from the provider's fields, never by reading diffs. Hands over to pr-loop. |
issue-triage | The ten most recent open issues nobody has looked at yet, each with an urgency band and its one-line reason, the issues it is better worked after, and a proposed resolution order that honours those dependencies; classified DEFECT / REQUEST / QUESTION / DOCS, with existing branches and PRs cross-checked. Its most valuable find is finished work sitting on a branch with no PR. The filter and the cross-check are the desk's; what it writes back is per issue — the position in the order, the urgency and why, the after list, the verified type, the finding, and the date that lets the desk tell a fresh reading from an overtaken one. issue-loop takes that order as its queue and treats after as an edge. Takes batch=N and mine. |
Both are explicit-invocation only, and both take the same mandate: 1145,1128 names the working set (exactly those, in that order, then stop), batch=N (N > 1) runs the analyses in background and hands them back as one digest, clamped to 20, with at most four agents running at once.
| skill | what it does |
|---|---|
pr-loop | Drives the queue until nothing is left that only you can do. Lane A acts without asking — merges your fully-approved PRs, answers small named review requests, realigns DIRTY branches by merging the base in — and iterates until a full pass changes nothing, because its own merges change the queue. Lane B is everything else, presented as author / problem / history / proposal followed by an explicit confirmation question. Canonical home of the rule for what may run in parallel. |
issue-loop | The same loop over the open issues: take the most urgent, analyze that one in a fresh context, propose it in four lines, and on a go-ahead assign it, fix it in a worktree and open the PR. bugfix is the wide mode: every eligible bug analyzed, all the plans read at once, one single go-ahead, then all the approved PRs in parallel — a bug rarely carries an architectural decision, and the PR review is still the control step. |
With batch=N an approved batch is never handed straight to N agents: the loop builds a conflict graph first — same file, stacked PRs, the same issue, a merge or a realign sharing a base — and runs the connected components in parallel while the members of one component run in sequence. Unknown means sequential. Failures are reported per item; nothing ever says a group succeeded.
| skill | what it does |
|---|---|
pr-analyze | One PR, read properly: a compact fresh probe first, reusing the desk's normalized row and any still-valid problem statement. When only the head changed after a review, it compares the reviewed SHA with the new head and stops before the full diff if PR-owned behaviour is unchanged. Otherwise it gathers the complete snapshot and diff once. Exact local Git objects accelerate reads without trusting the working tree. Returns author / problem / history / one proposal and the verdict the desk sorts by — approve, changes with the review body, doubt with its hunk; on your own PR fix or decide with three options — asks for confirmation, and prepares any draft worth posting, never signed by the tool that wrote it. Read-only — never posts, never pushes. Run headless by the desk's preparation and by the nightwork. |
issue-analyze | One issue, in a virgin context: verify the root cause in the actual code (DEFECT), walk the reuse ladder (REQUEST), find the proving line (QUESTION/DOCS). Returns a typed verdict with the minimal change and a verification plan. Read-only — never branches, never comments. |
issue-work | The mandate of a session spawned for a single issue: analyze it fresh, then either fix it in a worktree and open the PR when it is one coherent change, or lay out the phases it really needs. |
Both are explicit-invocation only: launched by hand in the evening, they run the preparation the desk runs when it opens (server/preparation.py) and nothing else, with the read-only ANALYZE profile. Every result is keyed to the PR or issue as it was read, so whatever moves before morning shows as stale and is the only thing prepared again at open; the two triggers share one lock per kind and repository, so an evening run still going is shown by the desk, not doubled. Nothing is posted, pushed or assigned.
| skill | what it does |
|---|---|
pr-nightwork | One pr-analyze job per PR that asks for your judgment and whose analysis is missing or stale, four at a time, plus the conflict readings owed on your own DIRTY PRs. A failure costs only its PR. The outcome lands in the desk's feed and under runs.pr-nightwork. |
issue-nightwork | Ranks the shortlist — the open issues nobody holds, cited by no open PR, that you never commented — then runs one issue-analyze job per shortlisted issue without a current analysis. The outcome lands in the desk's feed and under runs.issue-nightwork. |
| skill | what it does |
|---|---|
git-desk | The detached desk server (default port 8399, a free one when that is taken) and its page. Pull request: Da vedere (every PR whose next move is yours: pick and ▶ pr-loop), In attesa, Tutte, Chase — your own PRs are merged, fixed or answered, never approved. Issue: Da chiudere (already fixed by a merged PR), Per Claude (easy, single-phase, nobody's), Da prendere, Shortlist and the rest. A chi tocca: per person, the PRs and issues whose next move is theirs, with the chase to paste. It only triages at boot, and says so in small at the bottom; the launching chat stays attached by default and executes every click — reviews and closings only there, with the command echoed. The skill also defines the JSON/job contract. |
plugins/git-workflow/server/ is the code under it: a zero-dependency Python stdlib server that reads the provider, prepares the analyses the desk owes, computes where every row stands (stances.py) and serves one page.
The plugin carries a Claude Code mod (function hooks, hooks/register.tsx) for the chat a desk is attached to. It draws no desk of its own: the desk is the page, in the Browser pane.
PR (magenta) or ISSUE (green), always with its repository: in coda, in chat ora, in background, aspetta te first and in yellow. ↗ opens the loop's chat in the desktop app (elsewhere a toast gives claude --resume), ✕ closes the row for good, across restarts; a closed desk takes its rows with it;PR ⏳1 ⏸1: the loops at work and the ones waiting;vai: with two loops waiting for an answer it does not enter and asks which one; with one, the chat is told which it answers;/desk opens this chat's desk in the Browser pane, or launches one; the git-desk skill calls the mod's desk_open tool to learn the session id and whether to open the page now.It reads the desk state files under ~/.local/state/git-workflow/ every four seconds, re-reading only a file that changed, and asks the desk's own server whether it answers, marked as a background poll; no model. claude plugin test plugins/git-workflow runs its tests.
The skills launch it; you can also run it by hand:
python3 plugins/git-workflow/server/prdesk.py # repo from the cwd's origin
python3 plugins/git-workflow/server/prdesk.py --repo owner/repo --desk issue
python3 plugins/git-workflow/server/prdesk.py --org erpy # every repo of an owner
cd ~/Development/erpy-org && python3 …/prdesk.py # a folder of clones
One header row, a toolbar, the rows. The header holds the scope, the three sections as a segmented control with their counts, the preparation while it reads, the chat state and Chiudi il desk in red, in view. Under it a toolbar of its own colour holds the section's filters — a filter is a tab, a key is a button, and they never look alike — and the page opens on the first filter with something to do. Rows are two lines, striped and ruled, each with its link out to GitHub. A click opens the row in place: what it is for, what Claude read, the doubt and the leaning, the motivation to edit, the facts and the gate of its base, and its keys; a second click, or Esc, folds it. j/k move, ⏎ opens, x picks, o opens GitHub, a/r approve or ask changes on an open review. No preview pane and no Analizza: the preparation reads the PRs. Jobs, reports and the feed live in a drawer at the bottom, its last line always in view. A failed preparation is a banner with its reason and Riprova. Colours follow the system's light or dark theme, with a key to choose one; a narrow window puts the keys under the filters.
A scope of several repositories. --org [host/]owner covers every repository of that owner with an open issue or PR (one cross-repo search: no read:organization scope needed on Forgejo); a cwd that holds clones without being one covers that folder, other owners included; --folder DIR and repeated --repo add up. Each member keeps its own cache, state file, jobs and click ledger — exactly the files a desk of its own would write — and the page merges them: every row is repo #n, every click names its repository, a run across repositories becomes one loop per repository, each in its own clone. Clones are found, not configured (the cwd, its children, its parent's children, --clones DIR); a member without one is read and analyzed but never worked, and the page says so on its rows. The scope button lists the members and their clones, and hides a member from the page without changing the scope.
Open the URL of the desk on http://127.0.0.1:<port> line it prints: 8399 for PRs and 8398 for issues when free, a free port the OS picks when another repo or the sibling desk holds it, and the running server's URL when the same desk of the same repo is already up (then the new process just exits). An explicit --port is strict. A desk nobody has used for two hours, with no job running, exits on its own (--idle-exit); the page polling while hidden, or the mod polling, does not count as a use.
Options: --repo (repeatable), --org, --folder, --clones, --provider github|forgejo|fixture, --me, --port, --idle-exit, --refresh-after, --agent auto|claude|codex, --keep-state, --keep-cache, --no-prefetch, --no-prepare.
It prepares at startup, and never blocks on it. It fetches the provider itself and paints in seconds; then, in a thread of its own, it runs the preparation — the grid, one pr-analyze job per PR whose analysis is missing or stale, smallest first, the conflict readings owed on your DIRTY PRs, and beside them the issue ranking and the shortlist's analyses, four jobs at a time with one kept for the issues. A PR over 1500 lines of code (tests, generated bundles, docs and lock files are not counted) is read folder by folder up to 5000 lines, with twice the time, and stays among the doubts with Claude's leaning whatever it concludes. While the desk is looked at, a provider read older than 30 minutes is repeated and what moved is prepared again. A PR that did not move since the last preparation keeps its verdict and costs nothing; each row moves into its filter as its job lands, and you work on the ready ones meanwhile. model_tasks names only the stale analysis or conflict artifacts. A re-read prepares again what moved; --no-prepare leaves it to POST /api/prepare. Completing a loop, an order or a review asks every open tab for one fresh provider snapshot.
Public clicks are exact. Approva, Chiedi modifiche and Chiudi, on a row or on the picked rows, carry the rows shown, on the head shown, with the text shown, to the attached chat, which runs them with the command echoed (▶ approva #1164 #1163); a PR that moved since you saw it is refused, your own PR is never approvable, and without an attached chat the key does not leave. Any other picked rows go to pr-loop/issue-loop as one batch, exactly those, in that order. The same mandate is typed directly at the skill: /pr-loop 1145,1128 batch=2.
Acting belongs to the skills, which log every action on the PR itself.
plugins/git-workflow/server/verdicts.py ports section 7 of the pr-triage skill — the closed verdict vocabulary (merge it, answer the review, realign with the base, waiting on <login>, …). Explicit triage publishes its grid; after that the same engine re-verdicts it on every provider read. It is restricted to what the fields can honestly answer: anything that would need a diff read is reported as asks and left to pr-loop. The single fact a model hands back to it is conflict_kind — mechanical or substantive, keyed to the exact head/base pair — which is what turns a DIRTY row of your own into an unattended realign. The autorun column mirrors what pr-loop does unattended (A1 merge, A3 realign) versus what it brings to you for a go-ahead.
The server, the verdict engine and the UI speak one normalized row shape (documented in plugins/git-workflow/server/providers/base.py). Providers translate a hosting service into it:
gh CLI, reusing the exact GraphQL documents in plugins/git-workflow/server/gql/.gh auth login, once for every host and harness: a macOS keychain item security add-generic-password -s FORGEJO_TOKEN -a <host> -w (account = the instance's host, password = a token with repository and issue read/write, user read); FORGEJO_URL and FORGEJO_TOKEN in the environment override it and are the way on other platforms. Known gap: the API does not expose review-thread resolution, so unresolved is always 0.The provider is read from the checkout's origin: github.com is GitHub, the host of FORGEJO_URL is Forgejo, anything else exits 2 — never a default, because a GitHub read against a Forgejo checkout returns an empty queue, and an empty queue reads as "nothing to do". A new service is one class with a hosts() list and one entry in PROVIDERS (server/providers/detect.py).
plugins/git-workflow/bin/gw (link it into PATH) is what the skills call instead of gh, so a skill written once runs on GitHub and Forgejo:
gw whoami · repo info · repo default-branch · collaborators
gw pr list [--state] [--mine] · pr view <n> · pr reviews <n> · pr diff <n> [--name-only]
gw issue list · issue view <n>
gw issue create --title T --body-file F [--label L] [--assignee @me]
gw pr create --title T --body-file F --head BRANCH [--base B] [--draft] [--label L] [--assignee @me] [--reviewer L]
gw pr edit <n> --add-reviewer L · pr comment <n> --body-file F · issue edit · issue comment
gw pr merge <n> [--squash] [--delete-branch] · pr verified <n> --sha SHA
gw pr review <n> --approve|--request-changes --commit SHA [--body-file F]
gw issue close <n> --body-file F
gw label ensure NAME [--color HEX] [--description D]
gw api <endpoint> [-X METHOD] [-f k=v] [-F k=json] # {repo} expands to owner/repo
JSON out, the same shape on both services (server/providers/base.py). Exit 1 when the service refuses or the item does not exist, 2 when the origin's host is unknown. pr create refuses a reviewer who is not a collaborator and exits 1 when the body's Fixes #n linked nothing. pr merge reads the PR back and reports the state of every issue its body closes. -f sends a string, -F a JSON value — a form field typed as a bool refuses the string. pr review reviews the head you read or nothing (a moved head, your own PR, an empty motivation are refused), and pr review and issue close refuse a text that credits a tool. There is no verb that rewrites a PR body.
plugins/git-workflow/server/tests/run.sh
No network, no GitHub, no rate limit: a few seconds on the fixture provider. The Python suite covers the row contract, verdict engine, merge gate, five-block partition, issue cross-check, cache and cross-host packaging invariants (test_packaging.py). The UI checks drive the real static/index.html against a real desk process through a small DOM shim, so it is the page's own render path that runs, on a desk the real preparation filled with a fake claude (tests/fixtures/fake_claude.py). `plugins/git-workflow/server/tests/R
hooks/register.tsx 255 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Desk, DeskItem } from '../types'
5import {
6 bareGoAhead, deskTarget, isOpen, itemsOf, mentions, pollHeaders, projectOf, recentClosed,
7 statusLine, transitions, unclosed, waiting, wordOf,
8} from './desk'
9import type { StateFile } from './desk'
10
11const shown = atom({ plugin: 'git-workflow', key: 'items' } as const, [] as DeskItem[])
12const deskAtom = atom({ plugin: 'git-workflow', key: 'desk' } as const, null as Desk | null)
13
14const POLL_MS = 4000
15// a file untouched for this long holds no live request
16const STALE_FILE_MS = 26 * 3600 * 1000
17const COLOR = { PR: 'magenta', ISSUE: 'green' } as const
18// only what the user typed is guarded: a notification or a peer saying "ok" is not an answer
19const TYPED = ['composer', 'bridge']
20const STATUS_COLOR: Record<string, string> = { 'needs-input': 'yellow', running: 'cyan' }
21const LAUNCH = 'Apri il git desk con la skill git-desk e collega questa chat.'
22const APP_SESSIONS = 'Library/Application Support/Claude/claude-code-sessions'
23
24const files: Record<string, StateFile> = {}
25const memo: {
26 before: Record<string, string> | null; polling: boolean; etag: string | null; base: string | null
27 closed: Record<string, number> | null
28} = { before: null, polling: false, etag: null, base: null, closed: null }
29
30/** The requests closed with ✕, kept in the plugin's store so a restart does not bring them back. */
31async function closedIds($: EngineInterface, nowMs: number) {
32 if (!memo.closed) {
33 const stored = await $.store.get('closed')
34 memo.closed = recentClosed((stored ?? {}) as Record<string, number>, nowMs, STALE_FILE_MS)
35 }
36 return memo.closed
37}
38
39async function stateDir($: EngineInterface) {
40 const configured = await $.env.get('GIT_WORKFLOW_STATE_DIR')
41 if (configured) return configured
42 const home = await $.env.get('HOME')
43 return home ? `${home}/.local/state/git-workflow` : null
44}
45
46async function readFiles($: EngineInterface, now: number) {
47 const dir = await stateDir($)
48 if (!dir || !(await $.fs.exists(dir))) return
49 for (const entry of await $.fs.list(dir)) {
50 if (entry.kind !== 'file' || !/^[^.].*__.*\.json$/.test(entry.name)) continue
51 if (now - entry.mtimeMs > STALE_FILE_MS) { delete files[entry.name]; continue }
52 if (files[entry.name]?.mtime === entry.mtimeMs) continue
53 try {
54 files[entry.name] = { mtime: entry.mtimeMs, state: JSON.parse(await $.fs.read(`${dir}/${entry.name}`)) }
55 } catch {
56 // a file mid-write: the next poll reads it whole
57 }
58 }
59}
60
61async function getJSON($: EngineInterface, url: string, etag?: string | null) {
62 const got = await $.http.fetch(url, { headers: pollHeaders(etag) })
63 if (got.status === 304) return { data: null, etag: etag ?? null }
64 if (!got.ok) throw new Error(`${url}: HTTP ${got.status}`)
65 return { data: JSON.parse(got.text), etag: got.headers?.etag ?? got.headers?.ETag ?? null }
66}
67
68/** The desk this chat drives, if it answers. */
69async function readDesk($: EngineInterface, session: string, nowSec: number) {
70 const target = deskTarget(files, session, nowSec)
71 const known = await read($, deskAtom)
72 if (!target) {
73 if (known) await update($, deskAtom, () => null)
74 return
75 }
76 const base = `http://127.0.0.1:${target.port}`
77 if (memo.base !== base) Object.assign(memo, { base, etag: null })
78 const fresh = { base, repo: target.repo, attached: target.attached }
79 if (!known || known.base !== fresh.base || known.attached !== fresh.attached)
80 await update($, deskAtom, () => fresh)
81 try {
82 memo.etag = (await getJSON($, `${base}/api/todo`, memo.etag)).etag
83 } catch {
84 // a desk that stopped answering: its registration outlives it until it says it stopped
85 await update($, deskAtom, () => null)
86 memo.etag = null
87 }
88}
89
90// a poll that fails is the next poll's to repeat: nothing waits on it
91const repoll = ($: EngineInterface) => { poll($).catch(() => undefined) }
92
93async function poll($: EngineInterface) {
94 if (memo.polling) return
95 memo.polling = true
96 try {
97 const now = await $.clock.now()
98 await readFiles($, now)
99 const session = await $.session.id()
100 const nowSec = now / 1000
101 const attached = Object.values(files).some(f => mentions(f.state, session))
102 const closed = await closedIds($, now)
103 const all: DeskItem[] = []
104 for (const [name, file] of Object.entries(files)) {
105 const repo = name.replace(/\.json$/, '').replace('__', '/')
106 for (const item of itemsOf(repo, file.state, nowSec))
107 if (!attached || item.session === session) all.push(item)
108 }
109 const items = unclosed(all, closed)
110 if (memo.before) for (const line of transitions(memo.before, items)) $.ui.toast(line, { timeoutMs: 8000 })
111 memo.before = Object.fromEntries(items.map(i => [i.key, i.status]))
112 const open = items.filter(isOpen)
113 await update($, shown, () => open)
114 await readDesk($, session, nowSec)
115 $.ui.status(statusLine(open))
116 } finally {
117 memo.polling = false
118 }
119}
120
121/** The page in the Browser pane where the host has one; otherwise its link. */
122async function openDesk($: EngineInterface, desk: Desk) {
123 const url = `${desk.base}/`
124 try {
125 const got = await $.mcp.call('Claude_Browser', 'preview_start', { url })
126 if (!got.isError) return `Desk di ${desk.repo}: lo apro nel Browser pane.`
127 } catch {
128 // no Browser pane on this host: the link instead
129 }
130 return `Desk di ${desk.repo}: ${url}`
131}
132
133async function closeRow($: EngineInterface, item: DeskItem) {
134 const now = await $.clock.now()
135 const closed = { ...recentClosed(await closedIds($, now), now, STALE_FILE_MS), [item.id]: now }
136 memo.closed = closed
137 await $.store.set('closed', closed)
138 const open = (await read($, shown)).filter(i => i.id !== item.id)
139 await update($, shown, () => open)
140 $.ui.status(statusLine(open))
141}
142
143/** The desktop app's session that runs the loop, by the CLI session id the ledger keeps. */
144async function appSession($: EngineInterface, cliSession: string) {
145 const home = await $.env.get('HOME')
146 if (!home) return null
147 const found = await $.process.run(['grep', '-rlF', '--include=local_*.json', cliSession, `${home}/${APP_SESSIONS}`])
148 for (const path of found.stdout.split('\n').filter(Boolean)) {
149 try {
150 const record = JSON.parse(await $.fs.read(path)) as { sessionId?: string; cliSessionId?: string }
151 if (record.cliSessionId === cliSession && record.sessionId) return record.sessionId
152 } catch {
153 // a record mid-write: the next candidate
154 }
155 }
156 return null
157}
158
159async function gotoChat($: EngineInterface, item: DeskItem) {
160 try {
161 const local = await appSession($, item.session)
162 if (local) {
163 const got = await $.process.run(['open', `claude://claude.ai/epitaxy/${local}`])
164 if (got.exitCode === 0) return
165 }
166 } catch {
167 // no desktop app on this host: the resume command instead
168 }
169 $.ui.toast(`La chat di ${item.label}: claude --resume ${item.session}`, { timeoutMs: 15000 })
170}
171
172export const register: Register = on => {
173 on('session.start', async ($, e, next) => {
174 const result = await next(e)
175 await $.command.register({ name: 'desk', description: 'Open the git desk of this chat in the Browser pane' })
176 await $.tool.register({
177 name: 'desk_open',
178 description: 'Call it right after launching the git desk server: it answers this chat\'s session id ' +
179 'to attach with, and the desk page to open now in the Browser pane.',
180 })
181 await poll($).catch(() => undefined)
182 $.clock.every(POLL_MS, () => repoll($))
183 return result
184 })
185
186 on('command.run', { command: 'desk' }, async $ => {
187 const desk = await read($, deskAtom)
188 if (!desk) return { text: 'Nessun desk aperto: lo avvio in questa chat.', context: [LAUNCH] }
189 return { text: await openDesk($, desk) }
190 })
191
192 on('tool.call', { tool: 'mcp__git-workflow__desk_open' }, async $ => {
193 const session = await $.session.id()
194 const attach = ` This chat's session id is ${session}: pass it as --session to chatdesk.py.`
195 await poll($).catch(() => undefined)
196 const desk = await read($, deskAtom)
197 if (!desk) {
198 return { result: 'No desk answers yet: its server is still binding the port. Call this tool again in a ' +
199 'second.' + attach }
200 }
201 return { result: `The desk of ${desk.repo} is up: open ${desk.base}/ in the Browser pane now ` +
202 '(preview_start with that url), also while its triage runs, which the page shows at the bottom; a host ' +
203 'without one gets the link.' + attach }
204 })
205
206 on('prompt.submit', async ($, e, next) => {
207 if (!TYPED.includes(e.origin?.kind ?? '') || !bareGoAhead(e.text)) return next(e)
208 const loops = waiting(await read($, shown))
209 const repos = loops.map(i => i.repo)
210 const name = (i: DeskItem) => `${i.tag} ${projectOf(i.repo, repos)} ${i.label}`
211 if (loops.length > 1) {
212 return { drop: `Aspettano una risposta in più d'uno: ${loops.map(name).join(', ')}. Scrivi a quale va (es. "pr vai").` }
213 }
214 if (loops.length === 1) {
215 const only = loops[0]!
216 const note = `desk: the one desk request waiting for an answer is ${only.tag} ${only.label} (${only.repo}): this message answers it.`
217 return next({ ...e, context: [...(e.context ?? []), note] })
218 }
219 return next(e)
220 })
221
222 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
223 const items = await read($, shown)
224 if (e.props.hasSurvey || !items.length) return next(e)
225 const session = await $.session.id()
226 const { Box, Text, Button } = $.ui.resolve(e)
227 const repos = items.map(i => i.repo)
228 const rows = [...items].sort((a, b) =>
229 Number(b.status === 'needs-input') - Number(a.status === 'needs-input') || a.at.localeCompare(b.at))
230 return (
231 <Box flexDirection="column">
232 {rows.slice(0, Math.max(1, e.props.maxRows - 1)).map(item => (
233 <Box key={item.key} flexDirection="row" gap={1}>
234 <Box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
235 <Box flexShrink={0}><Text color={COLOR[item.tag]} bold>● {item.tag === 'PR' ? 'PR ' : 'ISSUE'} </Text></Box>
236 <Box flexShrink={1} minWidth={0}>
237 <Text wrap="truncate-end">{`${projectOf(item.repo, repos)} · ${item.label} · `}</Text></Box>
238 <Box flexShrink={0}>
239 <Text wrap="truncate-end" color={STATUS_COLOR[item.status]} bold={item.status === 'needs-input'}>
240 {wordOf(item.status)}{item.at ? ` dalle ${item.at}` : ''}</Text></Box>
241 {item.report ? <Box flexShrink={1} flexGrow={1} minWidth={0}>
242 <Text dimColor wrap="truncate-end"> · {item.report}</Text></Box> : null}
243 </Box>
244 {item.session && item.session !== session ? <Box flexShrink={0}>
245 <Button key={`goto-${item.key}`} plain onPress={() => { void gotoChat($, item) }}>↗</Button></Box> : null}
246 <Box flexShrink={0}>
247 <Button key={`close-${item.key}`} role="dismiss" plain dimColor
248 onPress={() => { void closeRow($, item) }}>✕</Button></Box>
249 </Box>
250 ))}
251 </Box>
252 )
253 })
254}
255hooks/desk.ts 190 lines1import type { DeskItem, Tag } from '../types'
2
3type Raw = {
4 id?: string
5 desk?: string
6 session?: string
7 kind?: string
8 status?: string
9 label?: string
10 via?: string
11 n?: number | null
12 payload?: { flow?: string; ns?: number[] }
13 at?: string
14 epoch?: number
15 taken_at?: string
16 taken_epoch?: number
17 running_at?: string
18 running_epoch?: number
19 closed_at?: string
20 report?: string
21}
22
23// deskstate.py's budgets: past them the desk itself reads the record as stale
24const BUDGET: Record<string, number> = {
25 preparing: 1800, queued: 1800, taken: 3600, running: 3600, 'needs-input': 86400,
26}
27const CLOSED = ['done', 'failed']
28const ISSUE_KINDS = ['issue-analyze', 'close']
29const ISSUE_FLOWS = ['issue-loop', 'issue-triage']
30
31export const OPEN = Object.keys(BUDGET)
32// a loop's numbers past these are on the desk, not in a one-line row
33const MAX_NS = 3
34const HHMM = /^(\d{2}:\d{2}):\d{2}$/
35
36export function tagOf(raw: Raw): Tag {
37 const flow = raw.payload?.flow ?? ''
38 return ISSUE_KINDS.includes(raw.kind ?? '') || ISSUE_FLOWS.includes(flow) ? 'ISSUE' : 'PR'
39}
40
41function startOf(raw: Raw): number {
42 if (raw.status === 'taken') return raw.taken_epoch ?? raw.epoch ?? 0
43 if (raw.status === 'running') return raw.running_epoch ?? raw.epoch ?? 0
44 return raw.epoch ?? 0
45}
46
47function labelOf(raw: Raw): string {
48 const flow = raw.payload?.flow
49 const ns = raw.payload?.ns ?? []
50 const shown = ns.slice(0, MAX_NS).map(n => `#${n}`).join(' ')
51 if (flow) return ns.length ? `${flow} ${shown}${ns.length > MAX_NS ? ` +${ns.length - MAX_NS}` : ''}` : flow
52 return raw.n != null ? `${raw.kind} #${raw.n}` : raw.label ?? raw.kind ?? '?'
53}
54
55function atOf(raw: Raw): string {
56 const at = raw.status === 'running' ? raw.running_at ?? raw.at
57 : raw.status === 'taken' ? raw.taken_at ?? raw.at
58 : CLOSED.includes(raw.status ?? '') ? raw.closed_at ?? raw.at : raw.at
59 return (at ?? '').replace(HHMM, '$1')
60}
61
62/** `2026-10-07 17:24`, the desk's local start, in epoch seconds; 0 when unreadable. */
63export const sinceEpoch = (since: string | undefined) => (Date.parse((since ?? '').replace(' ', 'T')) / 1000) || 0
64
65/** The chat-routed requests of one desk state file, live or just closed, of a desk still up:
66 * a request of a desk since closed, or of one before the running desk, is gone with it. */
67export function itemsOf(repo: string, state: unknown, nowSec: number): DeskItem[] {
68 const s = state as { requests?: Record<string, Raw>; desks?: Record<string, DeskMark> } | null
69 const ledger = s?.requests ?? {}
70 const out: DeskItem[] = []
71 for (const [key, raw] of Object.entries(ledger)) {
72 if (!raw || raw.via !== 'chat-session') continue
73 const mark = s?.desks?.[raw.desk ?? 'pr']
74 if (!mark || mark.stopped || (raw.epoch ?? startOf(raw)) < sinceEpoch(mark.since)) continue
75 const status = raw.status ?? ''
76 const budget = BUDGET[status]
77 if (budget !== undefined && nowSec - startOf(raw) > budget) continue
78 if (budget === undefined && !CLOSED.includes(status)) continue
79 out.push({
80 key: `${repo} ${key}`, id: raw.id ?? `${repo} ${key} ${raw.epoch ?? 0}`, repo, session: raw.session ?? '', tag: tagOf(raw),
81 label: labelOf(raw), status, at: atOf(raw), report: raw.report ?? '',
82 })
83 }
84 return out
85}
86
87export const isOpen = (item: DeskItem) => OPEN.includes(item.status)
88
89/** The mod's poll is never somebody using the desk: it neither keeps it alive nor refreshes it. */
90export function pollHeaders(etag: string | null | undefined): Record<string, string> {
91 const headers: Record<string, string> = etag ? { 'If-None-Match': etag } : {}
92 headers['X-Git-Workflow-Background'] = '1'
93 return headers
94}
95
96/** A repository by its short name, unless another one in view shares it. */
97export function projectOf(repo: string, all: string[] = []): string {
98 const short = repo.split('/').pop() ?? repo
99 return all.some(other => other !== repo && other.split('/').pop() === short) ? repo : short
100}
101
102/** A request closed with ✕ stays closed, whatever it does next. */
103export const unclosed = (items: DeskItem[], closed: Record<string, number> = {}) =>
104 items.filter(i => !(i.id in closed))
105
106/** The closed requests worth remembering: past this age none of them can show again. */
107export const recentClosed = (closed: Record<string, number>, nowMs: number, keepMs: number) =>
108 Object.fromEntries(Object.entries(closed).filter(([, at]) => nowMs - at < keepMs))
109
110export function mentions(state: unknown, session: string): boolean {
111 const s = state as { chats?: Record<string, unknown>; requests?: Record<string, Raw> } | null
112 if (!s || !session) return false
113 if (s.chats && session in s.chats) return true
114 return Object.values(s.requests ?? {}).some(r => r?.session === session)
115}
116
117const WORD: Record<string, string> = {
118 preparing: 'il desk prepara', queued: 'in coda', taken: 'in chat ora',
119 running: 'in background', 'needs-input': 'aspetta te',
120 done: 'finito', failed: 'non riuscito',
121}
122
123export const wordOf = (status: string) => WORD[status] ?? status
124
125const short = (text: string, max: number) =>
126 text.length > max ? `${text.slice(0, max - 1)}…` : text
127
128/** One line per transition worth a toast: a loop now waits for you, or closed. */
129export function transitions(before: Record<string, string>, items: DeskItem[]): string[] {
130 const out: string[] = []
131 const repos = items.map(i => i.repo)
132 for (const item of items) {
133 const was = before[item.key]
134 if (was === undefined || was === item.status) continue
135 if (item.status === 'needs-input' || CLOSED.includes(item.status)) {
136 const report = item.report ? `: ${short(item.report, 80)}` : ''
137 out.push(`${item.tag} ${projectOf(item.repo, repos)} · ${item.label} ${wordOf(item.status)}${report}`)
138 }
139 }
140 return out
141}
142
143/** `PR ⏳1 ⏸1 · ISSUE ⏳1`: the loops at work and the ones waiting for you, per kind. */
144export function statusLine(items: DeskItem[]): string | undefined {
145 const open = items.filter(isOpen)
146 const parts: string[] = []
147 for (const tag of ['PR', 'ISSUE'] as const) {
148 const mine = open.filter(i => i.tag === tag)
149 const wait = mine.filter(i => i.status === 'needs-input').length
150 const busy = mine.length - wait
151 const words = [busy ? `⏳${busy}` : '', wait ? `⏸${wait}` : ''].filter(Boolean)
152 if (words.length) parts.push(`${tag} ${words.join(' ')}`)
153 }
154 return parts.length ? parts.join(' · ') : undefined
155}
156
157const GO = /^\s*(vai|ok|okay|si|sì|procedi|tutte vai|vai su tutte|go)\s*[.!]*\s*$/i
158
159export const bareGoAhead = (text: string) => GO.test(text)
160
161export const waiting = (items: DeskItem[]) => items.filter(i => i.status === 'needs-input')
162
163// deskstate.CHAT_STALE: a heartbeat older than this is no chat
164const CHAT_STALE = 45
165
166type DeskMark = { since?: string; stopped?: string }
167type Mark = { port?: number; stopped?: string }
168type Chat = { epoch?: number }
169export type StateFile = { mtime: number; state: unknown }
170export type Target = { port: number; repo: string; attached: boolean }
171
172export const repoOf = (fileName: string) => fileName.replace(/\.json$/, '').replace('__', '/')
173
174/** The desk this chat drives: the one it is attached to, else the one touched last.
175 * `attached`: some chat listens to it, so the server has a chat to route a click to. */
176export function deskTarget(files: Record<string, StateFile>, session: string, nowSec: number): Target | null {
177 const found: (Target & { mine: boolean; mtime: number })[] = []
178 for (const [fileName, file] of Object.entries(files)) {
179 const s = file.state as { desks?: { pr?: Mark }; chats?: Record<string, Chat> } | null
180 const mark = s?.desks?.pr
181 if (!mark?.port || mark.stopped) continue
182 const chats = s?.chats ?? {}
183 found.push({ port: mark.port, repo: repoOf(fileName), mtime: file.mtime, mine: session in chats,
184 attached: Object.values(chats).some(chat => nowSec - (chat.epoch ?? 0) <= CHAT_STALE) })
185 }
186 found.sort((a, b) => Number(b.mine) - Number(a.mine) || b.mtime - a.mtime)
187 const best = found[0]
188 return best ? { port: best.port, repo: best.repo, attached: best.attached } : null
189}
190types/index.d.ts 34 lines1export type Tag = 'PR' | 'ISSUE'
2
3export type DeskItem = {
4 key: string
5 id: string
6 repo: string
7 session: string
8 tag: Tag
9 label: string
10 status: string
11 at: string
12 report: string
13}
14
15export type Preparation = {
16 status: string
17 phrase: string
18 due: (number | string)[]
19 landed: (number | string)[]
20 failed: Record<string, string>
21 report?: string | null
22}
23
24export type Desk = { base: string; repo: string; attached: boolean }
25
26declare module 'claude-code' {
27 interface PluginState {
28 'git-workflow': {
29 items: DeskItem[]
30 desk: Desk | null
31 }
32 }
33}
34