SLOPSHOPPER

git-workflow

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…

newbandguardcommandtoaststatus
v0.67.0no licenseupdated 2026-10-07fporcari/git-workflow/plugins/git-workflow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-workflow
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /desk ⎿ git-workflow: Nessun desk aperto: lo avvio in questa chat. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

git-workflow

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.

Install

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.

Two hosts, one plugin

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.

Quickstart

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.

What is in the box

Read the queue — no side effects

skillwhat it does
pr-triageEvery 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-triageThe 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.

Work the queue — the loops

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.

skillwhat it does
pr-loopDrives 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-loopThe 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.

Analyze exactly one

skillwhat it does
pr-analyzeOne 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-analyzeOne 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-workThe 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.

Prepare overnight — read-only

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.

skillwhat it does
pr-nightworkOne 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-nightworkRanks 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.

The dashboards

skillwhat it does
git-deskThe 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 mod — what the chat shows, Claude Code only

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.

  • one row per desk request of this chat, tagged 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;
  • a status line PR ⏳1 ⏸1: the loops at work and the ones waiting;
  • a toast when a loop starts waiting for you or closes;
  • a guard on a bare 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 dashboard

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:&lt;port&gt; 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.

Verdicts

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.

Providers

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:

  • github — shells out to the authenticated gh CLI, reusing the exact GraphQL documents in plugins/git-workflow/server/gql/.
  • forgejo — REST against the Forgejo/Gitea API v1. Credentials like 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.
  • fixture — a recorded payload replayed with no network. What the test suite runs on.

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).

gw — one CLI over every provider

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.

Tests

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

Source 3 files
hooks/register.tsx 255 lines
1import { 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}
255
hooks/desk.ts 190 lines
1import 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}
190
types/index.d.ts 34 lines
1export 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