Lists what is outstanding across your Claude sessions: sessions waiting on you, and your open pull requests.

A Claude Code plugin that lists what you still have outstanding across your sessions: the sessions stopped waiting on you, and the pull requests they created or worked on.
/plugin marketplace add njsoria/dk-tools
/plugin install kore-finisher@dk-tools
| Command | What it does |
|---|---|
/outstanding | Lists what is outstanding, split into the desktop app's sessions and the terminal's and editors' |
Each group lists its items oldest first:
Outstanding: 3
Desktop app (2)
● web · 5m · asked a question
Which database?
○ acme/web #3 · ready for review · updated 10m ago · this session
Add login
https://github.com/acme/web/pull/3
Terminal and editors (1)
○ acme/api #9 · draft · updated 30m ago
Rate limits
https://github.com/acme/api/pull/9
● is a session waiting on you; ○ is a pull request. In the desktop app the URL line is left out, since the app already links owner/repo #N. A group with nothing outstanding still shows, as "nothing outstanding".
gh search prs --author @me --state open, up to 50), so none is missed and a merged or closed one drops off. gh has to be installed and signed in; if it can't be reached the list says so and still shows the waiting sessions. Which group a PR goes in comes from what sessions recorded: a gh pr command other than list or status, or the desktop app's PR binding (bind_pr, set_monitor, set_auto_merge). A PR no session recorded, such as one opened before the plugin ran, is listed under "Not from a recorded session".hooks/register.ts: the plugin.hooks/register.test.ts: its tests.logs/<session id>.waiting.json: whether one session is waiting on you.logs/<session id>.prs.json: the pull requests one session touched.Each session writes only its own files, so sessions running side by side can't overwrite each other. logs/ is git-ignored.
This is where a check on what blocks what, and in what order to finish, will go.
MIT. See LICENSE.
hooks/register.ts 282 lines1// Kore Finisher: what you still have outstanding across your Claude sessions.
2//
3// Two kinds of outstanding work, both kept per session in logs/:
4// - a session waiting on you: a permission prompt, an input request or a
5// question it ended on (`waiting.json`, one file overwritten as it changes);
6// - a pull request the session created or worked on (`prs.json`, one entry
7// per PR URL).
8// `/outstanding` lists both, split into the desktop app's sessions and the
9// terminal's and editors'. The pull requests listed are every open one GitHub
10// says you authored (`gh search prs`), so none is missed and a merged or closed
11// one drops off; what a session recorded says which group a PR belongs in, and
12// one no session recorded is listed apart.
13//
14// Later this is where a sanity check on what blocks what, and in what order to
15// finish, will live.
16
17import type { EngineInterface, Register } from 'claude-code'
18
19type Surface = string | null
20
21type Wait = {
22 sessionId: string
23 surface: Surface
24 cwd: string
25 state: 'waiting' | 'clear' | 'ended'
26 kind?: 'permission' | 'question' | 'input'
27 text?: string
28 since?: string
29 updated: string
30}
31
32type Pull = { url: string; firstSeen: string; lastSeen: string }
33type Pulls = { sessionId: string; surface: Surface; cwd: string; prs: Record<string, Pull> }
34
35type Open = { repository: { nameWithOwner: string }; number: number; title: string; isDraft: boolean; updatedAt: string; url: string }
36
37const LOGS = 'logs'
38const WAITING = 'waiting.json'
39const PULLS = 'prs.json'
40// A session that was killed never says it ended, so a wait this old is dropped.
41// A pull request isn't dropped by age: GitHub says whether it is still open.
42const STALE_AFTER = 12 * 3_600_000
43// How many of your open pull requests one listing asks GitHub for.
44const MAX_OPEN = 50
45const NEEDS = { permission: 'needs permission', question: 'asked a question', input: 'needs input' }
46// A prompt answers a session when a person sent it, not when a notification did.
47const FROM_A_PERSON = new Set(['composer', 'bridge', 'sdk'])
48// `gh pr create`, `view`, `checkout`, `merge` and the rest touch a PR; `list` and `status` only read many.
49const GH_PR = /\bgh\s+pr\s+(?!list\b|status\b)\w+/
50// Desktop-app calls that bind a PR to the session.
51const DESKTOP_PR = /^mcp__ccd_pr__(bind_pr|set_monitor|set_auto_merge)$/
52const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g
53
54// A long text says most about itself at its start and its end.
55const clip = (text: string, max: number) =>
56 text.length <= max ? text : `${text.slice(0, max / 2)}\n[...]\n${text.slice(-max / 2)}`
57
58const logFile = async ($: EngineInterface, kind: string) => `${$.plugin.root}/${LOGS}/${await $.session.id()}.${kind}`
59
60const parseEach = <T>(texts: string[]) =>
61 texts.flatMap(text => {
62 try {
63 return [JSON.parse(text) as T]
64 } catch {
65 return []
66 }
67 })
68
69// One write at a time, so two changes from this session can't cross.
70let writing: Promise<unknown> = Promise.resolve()
71const queue = (work: () => Promise<void>) => {
72 writing = writing.then(work).catch(() => undefined)
73 return writing
74}
75
76// Unknown after a reload, so the first clear looks at the file.
77let mayBeWaiting = true
78
79function setWait($: EngineInterface, change: Pick<Wait, 'state' | 'kind' | 'text'>) {
80 return queue(async () => {
81 const file = await logFile($, WAITING)
82 const held = JSON.parse(await $.fs.read(file).catch(() => 'null')) as Wait | null
83 const isHeld = held?.state === 'waiting'
84 if (change.state !== 'waiting') {
85 mayBeWaiting = false
86 if (!isHeld) return
87 }
88 const now = new Date(await $.clock.now()).toISOString()
89 const wait: Wait = {
90 sessionId: await $.session.id(),
91 surface: await $.session.surface(),
92 cwd: await $.session.cwd(),
93 ...change,
94 since: change.state === 'waiting' ? (isHeld ? held.since : now) : undefined,
95 updated: now,
96 }
97 if (change.state === 'waiting') mayBeWaiting = true
98 await $.fs.write(file, JSON.stringify(wait))
99 })
100}
101
102const stopWaiting = ($: EngineInterface) => (mayBeWaiting ? setWait($, { state: 'clear' }) : undefined)
103
104function notePulls($: EngineInterface, urls: string[]) {
105 if (urls.length === 0) return undefined
106 return queue(async () => {
107 const file = await logFile($, PULLS)
108 const held = JSON.parse(await $.fs.read(file).catch(() => 'null')) as Pulls | null
109 const now = new Date(await $.clock.now()).toISOString()
110 const prs: Record<string, Pull> = { ...held?.prs }
111 for (const url of urls) prs[url] = { url, firstSeen: prs[url]?.firstSeen ?? now, lastSeen: now }
112 const pulls: Pulls = { sessionId: await $.session.id(), surface: await $.session.surface(), cwd: await $.session.cwd(), prs }
113 await $.fs.write(file, JSON.stringify(pulls))
114 })
115}
116
117// A question put in words ends the turn like any answer; this tells them apart.
118const ASKS = /\?[\s"'’”)\]*_`]*$/
119const lastParagraph = (text: string) => text.trim().split(/\n\s*\n/).pop()!.trim()
120
121// A session started by the desktop app, or by a host that draws nothing, has
122// no terminal; the rest run in a terminal or an editor.
123const isApp = (surface: Surface) => surface === 'desktop' || surface === null
124
125const ago = (ms: number) => (ms < 60_000 ? 'now' : ms < 3_600_000 ? `${Math.floor(ms / 60_000)}m` : `${Math.floor(ms / 3_600_000)}h`)
126
127const project = (cwd: string) => cwd.split('/').filter(Boolean).pop() ?? cwd
128
129// Every open PR you authored, from GitHub; `undefined` when `gh` can't say (not
130// installed, not signed in, offline).
131async function openPulls($: EngineInterface) {
132 try {
133 const ran = await $.process.run(
134 ['gh', 'search', 'prs', '--author', '@me', '--state', 'open', '--limit', String(MAX_OPEN), '--json', 'repository,number,title,isDraft,updatedAt,url'],
135 { timeoutMs: 20_000 },
136 )
137 return ran.exitCode === 0 ? (JSON.parse(ran.stdout) as Open[]) : undefined
138 } catch {
139 return undefined
140 }
141}
142
143// `surface` is undefined for a PR no session recorded.
144type Row = { surface: Surface | undefined; at: string; lines: string[] }
145
146async function outstanding($: EngineInterface) {
147 const dir = `${$.plugin.root}/${LOGS}`
148 const names = (await $.fs.list(dir).catch(() => [])).map(entry => entry.name)
149 const read = (suffix: string) =>
150 Promise.all(names.filter(name => name.endsWith(`.${suffix}`)).map(name => $.fs.read(`${dir}/${name}`).catch(() => '')))
151 const now = await $.clock.now()
152 const mine = await $.session.id()
153 // The desktop app already links "owner/repo #N", so a URL line there is a second link to the same PR.
154 const linksRefs = (await $.session.surfaces()).includes('desktop')
155
156 const waits = parseEach<Wait>(await read(WAITING)).filter(wait => wait.state === 'waiting' && now - Date.parse(wait.updated) < STALE_AFTER)
157
158 // The session that last touched each PR, from what the sessions recorded.
159 const touched = new Map<string, Pull & { sessionId: string; surface: Surface }>()
160 for (const pulls of parseEach<Pulls>(await read(PULLS))) {
161 for (const pr of Object.values(pulls.prs)) {
162 const seen = touched.get(pr.url)
163 if (seen === undefined || pr.lastSeen > seen.lastSeen) touched.set(pr.url, { ...pr, sessionId: pulls.sessionId, surface: pulls.surface })
164 }
165 }
166 const open = await openPulls($)
167
168 const rows: Row[] = [
169 ...waits.map(wait => {
170 const head = [project(wait.cwd), ago(now - Date.parse(wait.since ?? wait.updated)), NEEDS[wait.kind ?? 'input'], ...(wait.sessionId === mine ? ['this session'] : [])]
171 return {
172 surface: wait.surface,
173 at: wait.since ?? wait.updated,
174 lines: [` \u25cf ${head.join(' \u00b7 ')}`, ...(wait.text ? [` ${wait.text}`] : [])],
175 }
176 }),
177 ...(open ?? []).map(pr => {
178 const from = touched.get(pr.url)
179 const head = [`${pr.repository.nameWithOwner} #${pr.number}`, pr.isDraft ? 'draft' : 'ready for review', `updated ${ago(now - Date.parse(pr.updatedAt))} ago`, ...(from?.sessionId === mine ? ['this session'] : [])]
180 return {
181 surface: from === undefined ? undefined : from.surface,
182 at: pr.updatedAt,
183 lines: [` \u25cb ${head.join(' \u00b7 ')}`, ` ${clip(pr.title, 120)}`, ...(linksRefs ? [] : [` ${pr.url}`])],
184 }
185 }),
186 ]
187
188 const note = open === undefined ? ['', 'GitHub could not be reached, so pull requests are not listed.'] : []
189 if (rows.length === 0) return ['Nothing outstanding.', ...note].join('\n')
190 const group = (title: string, of: Row[]) => {
191 const sorted = [...of].sort((a, b) => a.at.localeCompare(b.at))
192 return sorted.length === 0 ? [`${title}: nothing outstanding`] : [`${title} (${sorted.length})`, ...sorted.flatMap(row => row.lines)]
193 }
194 const apart = rows.filter(row => row.surface === undefined)
195 return [
196 `Outstanding: ${rows.length}`,
197 '',
198 ...group('Desktop app', rows.filter(row => row.surface !== undefined && isApp(row.surface))),
199 '',
200 ...group('Terminal and editors', rows.filter(row => row.surface !== undefined && !isApp(row.surface))),
201 // Pull requests no session recorded: made before the plugin ran, or from a repo it never saw.
202 ...(apart.length === 0 ? [] : ['', ...group('Not from a recorded session', apart)]),
203 ...note,
204 ].join('\n')
205}
206
207export const register: Register = on => {
208 on('session.start', async ($, e, next) => {
209 await $.command.register({ name: 'outstanding', description: 'List what is outstanding across your Claude sessions: waiting on you, and open pull requests' })
210 return next(e)
211 })
212
213 on('classic.Notification', async ($, e, next) => {
214 if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') {
215 await setWait($, {
216 state: 'waiting',
217 kind: e.notification_type === 'permission_prompt' ? 'permission' : 'input',
218 text: clip(e.message, 160),
219 })
220 }
221 return next(e)
222 })
223
224 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
225 await setWait($, { state: 'waiting', kind: 'question', text: clip(e.questions[0]?.question ?? '', 160) })
226 try {
227 return await next(e)
228 } finally {
229 await stopWaiting($)
230 }
231 })
232
233 // Records the PRs a session creates or works on. It only observes: the call
234 // runs as the session asked, and a failure to record never reaches it.
235 on('tool.call', async ($, e, next) => {
236 const ran = await next(e)
237 try {
238 const tool = String(e.tool)
239 const command = String((e as { command?: unknown }).command ?? '')
240 if (tool === 'Bash' ? GH_PR.test(command) : DESKTOP_PR.test(tool)) {
241 // A PR named by the call, or in what it printed: `gh pr create` prints the new URL.
242 const seen = JSON.stringify([e, ran]).match(PR_URL) ?? []
243 await notePulls($, [...new Set(seen)])
244 }
245 } catch {
246 // Not recording a PR must not fail the call.
247 }
248 return ran
249 })
250
251 on('classic.Stop', async ($, e, next) => {
252 const said = e.last_assistant_message?.trim() ?? ''
253 const isAsking = ASKS.test(said) && !e.background_tasks?.length
254 await (isAsking ? setWait($, { state: 'waiting', kind: 'question', text: clip(lastParagraph(said), 160) }) : stopWaiting($))
255 return next(e)
256 })
257
258 on('prompt.submit', async ($, e, next) => {
259 if (FROM_A_PERSON.has(e.origin.kind)) await stopWaiting($)
260 return next(e)
261 })
262
263 // A new request means whoever was asked has answered.
264 on('turn.step', async function* ($, e, next) {
265 if (e.agentId === undefined) await stopWaiting($)
266 return yield* next(e)
267 })
268
269 on('session.end', async ($, e, next) => {
270 await setWait($, { state: 'ended' })
271 return next(e)
272 })
273
274 on('command.run', { command: 'outstanding' }, async $ => {
275 try {
276 return { text: await outstanding($) }
277 } catch (error) {
278 return { text: `No list: ${(error as Error).message}` }
279 }
280 })
281}
282