A raid frame for every live session on the machine: who is working, who is waiting on you and for how long, per-PR locks so two sessions never act on one PR…

▄▀▀▄ ▄▀▀▄ ▄▀▀▄ █▀█ ▄▀█ █▀█ ▀█▀ █▄█
▄▀▀▄ ▄▀▀▄ ▄▀▀▄ █▀▀ █▀█ █▀▄ █ █
THE RAID FRAME FOR YOUR SESSIONS

When you run several Claude Code sessions at once, each one sits in its own terminal tab. You only find out that one has been waiting on a permission prompt for twenty minutes when you happen to look at it, and nothing stops two sessions from merging, commenting on or editing the same PR a minute apart.
party gives every session on the machine one shared view: who is working, who is waiting on you and for how long, and a lock that makes the second session ask before it acts on a PR the first one just touched.
The problem, in one line: up to 5–10 sessions ran at once, they spent about 190 hours in total waiting on a human answer, and two sessions acted on the same PR.
/plugin marketplace add pourya7/claude-code-mods
/plugin install party@claude-code-mods
Install it in every session you want in the party. A session only shows up once party is loaded in it.
| State | When |
|---|---|
working | A turn is running. |
waiting-on-you | A permission prompt, an AskUserQuestion question or an ExitPlanMode plan is open and waiting for your answer. Each wait belongs to the tool call that raised it and ends only when that call returns, so a parallel call or a subagent's call that finishes first does not end it. When the last wait ends, the session goes back to working if a turn is running and to idle if not. |
idle | The last turn ended and nothing is running. |
done | The session ended. It stays in the view until it goes stale. |
nagMinutes, its bar turns red and you get one toast, for example PARTY ▸ ship the release WAITING ON YOU 5M. It toasts once per wait.gh pr merge, close, comment, review or edit that names a PR by number (42, #42, with -R owner/repo if given) or by URL (https://github.com/owner/repo/pull/42). A bare number is read against the session's origin remote. If another live session ran one of those on the same PR within lockMinutes, the call becomes a permission prompt that names that session: PARTY LOCK: another session ("ship the release", app-two@feat/release) acted on
example/app#42 3M AGO. Two sessions acting on one PR collide; allow only if this is meant.
The same session never locks itself out, and a deny from your settings stays a deny.
| Command | What it does |
|---|---|
/party | Opens the raid-frame pane and replies with the roster as text, which is what you see in claude -p and VS Code. |
/broadcast <text> | Sends the text to every other live session through $.session.send. It skips this session and any session that is done. If a session cannot be reached, the reply names it and the text is copied to your clipboard so you can paste it there yourself. |
Set them in /config or under pluginConfigs.party.options in settings.
| Option | Default | Meaning |
|---|---|---|
nagMinutes | 5 | How long another session may wait on you before its bar turns red and party toasts once. |
lockMinutes | 10 | How long a PR action in one session makes the same action in another session ask first. |
In the terminal the sprites are drawn in PICO-8 colours, with pink as party's colour. Each session's class icon matches its state: a blue knight with a raised sword while it works, a pink mage with a red ! while it waits on you, a grey sleeper with a lavender z while it is idle, and a gold star once it is done. This text capture loses the colours.
The /party pane:
▄▀▀▄ ▄▀▀▄ ▄▀▀▄ P A R T Y
▄▀▀▄ ▄▀▀▄ ▄▀▀▄ 3 IN PARTY · 1 WAITING ON YOU
▄▀▀▄▀ 2P ship the release
▀▀▄▄ ██████████ WAITING ON YOU · PERMISSION 6M
app-two@feat/release · LAST Bash
▄▀▀▄▀ 3P refactor the cache
▀▀▀ ░░░░░░░░░░ WORKING 2M
app-three@feat/cache · LAST Edit
▄▀▀▄▀ 1UP fix the login page
▀▀▄▀ ░░░░░░░░░░ IDLE 40S
app@feat/login · LAST Bash
Waiting sessions are listed first, longest wait first. The bar is HP-style: it fills over nagMinutes, lime and then yellow, and is full and red once the wait passes nagMinutes. Rows for sessions that are not waiting show an empty grey bar and the time spent in their state. 1UP is the session you are looking from.
The status line under the prompt stays under 40 columns:
PARTY 3 ▸ 1 WAITING
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
None. No $.http. | git -C <cwd> rev-parse --abbrev-ref HEAD (to read the branch) at session start and after each turn. Nothing else. | No $.fs. It writes one entry per session to its own plugin store ($.store, a JSON file under your Claude Code config directory). The entry holds the session id, the first line of the first prompt (40 characters at most), the working directory, the branch, the repository (owner/name from origin, or its root path), the state, the last tool's name, and recent PR numbers. Every session on the machine reads every entry, and deletes stale ones. | No. | No. /broadcast sends your text only when you run it, and only to your other sessions. | None. /broadcast delivers to your own sessions through Claude Code's $.session.send, and copies to your clipboard if a session cannot be reached. |
tool.check verdict of ask for a call this session is running. There is no event for the moment you answer a dialog, so the wait lasts until that call returns, including the time the tool then runs. If you approve a command that runs for ten minutes, the session shows WAITING ON YOU · PERMISSION for those ten minutes, its bar turns red and the other sessions get a nag. In a mode that decides asks for you (auto mode's classifier, a headless host), the same applies: an asked call shows as waiting until it returns.AskUserQuestion and ExitPlanMode count as waiting by name until they return.idle, not waiting-on-you. Other prompts (an MCP server asking for input, a login) are not detected.gh pr only. gh pr merge with no number acts on the current branch's PR, which only gh can resolve, so it is not locked. gh api calls and GitHub MCP tools are not covered. The lock is a permission prompt, not a deny: a mode that answers prompts for you decides it./clear and /resume. Both end the conversation while the process goes on under another session id, with no new session.start. party marks the old id done, keeps beating under the new id as a fresh member (no title until the next prompt; same directory, branch and repository), and keeps the heartbeat running.session.start runs again on a reload and re-arms it, and the session's entry is restored from $.state.claude plugin validate party
claude plugin test party
Pure logic lives in hooks/party.ts (staleness, state transitions, PR targets, locks, wait bars and text) and hooks/pixels.ts (the palette, the class icons and the half-block renderer). hooks/register.tsx connects them to the engine. The tests use mock.clock and an in-memory store seeded with other sessions' entries, and they mount the pane on both terminal and desktop.
hooks/register.tsx 416 lines1// party: the raid frame for your sessions. Every session heartbeats into the
2// plugin store; the pane shows who is working and who waits on you; PR actions
3// another session took recently turn into a permission prompt here.
4import { atom, read } from 'claude-code'
5import type { EngineInterface, Register, Timer } from 'claude-code'
6
7import type { PartyMember, PartyState, PartyWait } from '../types'
8import {
9 HEARTBEAT_MS,
10 KEY_PREFIX,
11 PLUGIN,
12 addTouch,
13 broadcastTargets,
14 describeRoster,
15 displayName,
16 formatAge,
17 liveMembers,
18 lockReason,
19 memberKey,
20 nagDue,
21 nagKey,
22 otherTouch,
23 place,
24 playerTags,
25 prTarget,
26 repoFromRemote,
27 staleKeys,
28 stateText,
29 statusLine,
30 titleFrom,
31 waitBar,
32 waitKind,
33 withState,
34} from './party'
35import type { PrTarget } from './party'
36import { BANNER, BAR_COLOR, CLASS_ICON, PICO, SIGNATURE, STATE_COLOR, barText, halfBlockRows } from './pixels'
37import type { PixelRun } from './pixels'
38
39const PANE = 'party'
40const BAR_CELLS = 10
41const GIT_TIMEOUT_MS = 5_000
42
43const EMPTY_SELF: PartyMember = {
44 sessionId: '',
45 title: '',
46 cwd: '',
47 branch: '',
48 repo: null,
49 state: 'idle',
50 since: 0,
51 waitingFor: null,
52 lastTool: '',
53 beatAt: 0,
54 touches: [],
55}
56
57const SELF = { plugin: 'party', key: 'self' } as const
58const ROSTER = { plugin: 'party', key: 'roster' } as const
59const NAGGED = { plugin: 'party', key: 'nagged' } as const
60const selfAtom = atom(SELF, EMPTY_SELF)
61const rosterAtom = atom(ROSTER, [] as PartyMember[])
62const naggedAtom = atom(NAGGED, [] as string[])
63
64// This session's entry. The engine's `$.state` reads one moment per dispatch,
65// so a tool.call that waited through a permission dialog would read its own
66// entry from before the dialog; the module copy is the truth inside the
67// process and `$.state` the mirror a hot reload restores it from
68// (session.start, raised again on a reload).
69let self: PartyMember = EMPTY_SELF
70let nagged: string[] = []
71// Timers cannot live in $.state; session.start re-arms the heartbeat.
72let heartbeat: Timer | null = null
73// Tool calls under way in this process, by tool_use_id: an ask inside one is a dialog.
74const running = new Set<string>()
75// The open waits on the person, oldest first, by the tool_use_id of the call
76// that raised each. A wait ends only when its own call returns, so a parallel
77// call or a subagent's call that returns first leaves it standing.
78const waits = new Map<string, PartyWait>()
79// A main-loop turn is running: what this session is back to once no wait is open.
80let isTurnRunning = false
81// Ids for a call that came without a tool_use_id.
82let localIds = 0
83// The heartbeat queue: writes go out in the order they were asked for.
84let beating: Promise<void> = Promise.resolve()
85
86function stopHeartbeat() {
87 heartbeat?.cancel()
88 heartbeat = null
89}
90
91/** Every session entry in the store, live or not. A failed read is an empty party. */
92async function readEntries($: EngineInterface): Promise<Record<string, unknown>> {
93 const entries: Record<string, unknown> = {}
94 try {
95 for (const key of await $.store.keys()) {
96 if (key.startsWith(KEY_PREFIX)) entries[key] = await $.store.get(key)
97 }
98 } catch {
99 // The store is shared decoration; a session alone still works.
100 }
101 return entries
102}
103
104async function readLive($: EngineInterface, now: number): Promise<PartyMember[]> {
105 return liveMembers(Object.values(await readEntries($)), now)
106}
107
108async function branchOf($: EngineInterface, cwd: string): Promise<string> {
109 try {
110 const ran = await $.process.run(['git', '-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: GIT_TIMEOUT_MS })
111 return ran.exitCode === 0 ? ran.stdout.trim() : ''
112 } catch {
113 return ''
114 }
115}
116
117async function repoKey($: EngineInterface): Promise<string | null> {
118 try {
119 const repo = await $.session.repo()
120 if (repo === null) return null
121 return repoFromRemote(repo.remote) ?? repo.root
122 } catch {
123 return null
124 }
125}
126
127/**
128 * One heartbeat: write this session's entry, prune stale ones, refresh the
129 * roster the pane draws from and the status line, and toast new long waits.
130 * One at a time, so a slow write never lands after a newer one.
131 */
132function beat($: EngineInterface, nagMs: number): Promise<void> {
133 beating = beating.then(() => beatOnce($, nagMs))
134 return beating
135}
136
137async function beatOnce($: EngineInterface, nagMs: number): Promise<void> {
138 try {
139 const now = await $.clock.now()
140 const sessionId = await $.session.id()
141 self = { ...self, sessionId, beatAt: now }
142 await $.state.set(SELF, self)
143 await $.store.set(memberKey(sessionId), self)
144 const entries = await readEntries($)
145 for (const key of staleKeys(entries, now)) {
146 if (key !== memberKey(sessionId)) await $.store.delete(key)
147 }
148 const members = liveMembers(Object.values(entries), now)
149 await $.state.set(ROSTER, members)
150 $.ui.status(statusLine(members))
151
152 const due = nagDue(members, sessionId, now, nagMs, nagged)
153 for (const one of due) $.ui.toast(`PARTY ▸ ${displayName(one)} WAITING ON YOU ${formatAge(now - one.since)}`)
154 const liveKeys = new Set(members.map(nagKey))
155 const kept = [...nagged.filter(key => liveKeys.has(key)), ...due.map(nagKey)]
156 if (kept.length !== nagged.length || due.length > 0) {
157 nagged = kept
158 await $.state.set(NAGGED, nagged)
159 }
160 } catch {
161 // A missed beat is made up 15 seconds later.
162 }
163}
164
165/** The state the open waits and the turn add up to: the oldest wait, else working or idle. */
166async function settle($: EngineInterface, nagMs: number) {
167 const [oldest] = waits.values()
168 const state: PartyState = oldest !== undefined ? 'waiting-on-you' : isTurnRunning ? 'working' : 'idle'
169 self = withState(self, state, await $.clock.now(), oldest ?? null)
170 await beat($, nagMs)
171}
172
173async function broadcast($: EngineInterface, text: string): Promise<string> {
174 if (text === '') return 'Usage: /broadcast <text> sends it to every other live session.'
175 const now = await $.clock.now()
176 const selfId = await $.session.id()
177 const targets = broadcastTargets(await readLive($, now), selfId)
178 if (targets.length === 0) return 'PARTY: nobody else is online. Nothing sent.'
179 const missed: string[] = []
180 for (const one of targets) {
181 try {
182 const sent = await $.session.send({ to: { sessionId: one.sessionId }, text })
183 if (!sent.isDelivered) missed.push(`${displayName(one)} (${sent.reason})`)
184 } catch (error) {
185 missed.push(`${displayName(one)} (${error instanceof Error ? error.message : String(error)})`)
186 }
187 }
188 const delivered = targets.length - missed.length
189 const head = `BROADCAST ▸ ${delivered}/${targets.length} DELIVERED`
190 if (missed.length === 0) return head
191 let copied = false
192 try {
193 copied = (await $.ui.copy({ text })).isCopied
194 } catch {
195 copied = false
196 }
197 const fallback = copied ? 'The text is on your clipboard to paste there.' : 'The clipboard was not available either.'
198 return `${head}. Not delivered: ${missed.join(', ')}. ${fallback}`
199}
200
201export const register: Register = (on, options) => {
202 const nagMs = Math.max(1, Number(options.nagMinutes ?? 5)) * 60_000
203 const lockMs = Math.max(1, Number(options.lockMinutes ?? 10)) * 60_000
204
205 on('session.start', async ($, e, next) => {
206 try {
207 await $.command.register({ name: PLUGIN, description: 'party: open the raid frame of every live session', immediate: true })
208 await $.command.register({
209 name: 'broadcast',
210 description: 'party: send a message to every other live session',
211 argumentHint: '<text>',
212 immediate: true,
213 })
214 } catch {
215 $.ui.toast('PARTY: commands could not be registered')
216 }
217 if (self.sessionId === '') {
218 // A fresh process, or a reload: pick up where the mirror left off.
219 self = await read($, selfAtom)
220 nagged = await read($, naggedAtom)
221 isTurnRunning = self.state === 'working'
222 }
223 const now = await $.clock.now()
224 const [branch, repo] = await Promise.all([branchOf($, e.cwd), repoKey($)])
225 self = {
226 ...self,
227 cwd: e.cwd,
228 branch,
229 repo,
230 since: self.since === 0 ? now : self.since,
231 state: self.state === 'done' ? 'idle' : self.state,
232 }
233 await beat($, nagMs)
234 stopHeartbeat()
235 heartbeat = $.clock.every(HEARTBEAT_MS, () => void beat($, nagMs))
236 return next(e)
237 })
238
239 on('turn.start', async ($, e, next) => {
240 const title = titleFrom(e.text)
241 if (self.title === '' && title !== '') self = { ...self, title }
242 isTurnRunning = true
243 await settle($, nagMs)
244 return next(e)
245 })
246
247 on('turn.complete', async ($, e, next) => {
248 const result = await next(e)
249 if (e.agentId !== undefined) return result
250 const branch = await branchOf($, self.cwd)
251 if (branch !== '') self = { ...self, branch }
252 isTurnRunning = false
253 await settle($, nagMs)
254 return result
255 })
256
257 on('tool.call', async ($, e, next) => {
258 const id = e.tool_use_id ?? `local-${(localIds += 1)}`
259 self = { ...self, lastTool: e.tool }
260 const kind = waitKind(e.tool)
261 if (kind !== null) {
262 waits.set(id, kind)
263 await settle($, nagMs)
264 }
265 const target: PrTarget | null = e.tool === 'Bash' ? prTarget(e.command, self.repo) : null
266
267 running.add(id)
268 let result: Awaited<ReturnType<typeof next>>
269 try {
270 result = await next(e)
271 } finally {
272 running.delete(id)
273 // This call's dialog or question was answered: back to what else is open.
274 if (waits.delete(id)) await settle($, nagMs)
275 }
276
277 if (target !== null && result.deny === undefined) {
278 self = { ...self, touches: addTouch(self.touches, target, await $.clock.now(), lockMs) }
279 await beat($, nagMs)
280 }
281 return result
282 })
283
284 on('tool.check', async ($, e, next) => {
285 const decided = await next(e)
286 if (decided.decision === 'deny') return decided
287 let verdict = decided
288 try {
289 const input = e.input as { command?: unknown } | null
290 if (e.tool === 'Bash' && typeof input?.command === 'string') {
291 const target = prTarget(input.command, self.repo)
292 if (target !== null) {
293 const now = await $.clock.now()
294 const selfId = await $.session.id()
295 const found = otherTouch(await readLive($, now), selfId, target, now, lockMs)
296 if (found !== null) verdict = { decision: 'ask', reason: lockReason(found.member, found.touch, now) }
297 }
298 }
299 // An ask inside a call this session is running puts a dialog in front of the
300 // person until that call returns (the mods API does not say when the dialog closes).
301 const id = e.tool_use_id
302 const isDialog = verdict.decision === 'ask' && waitKind(e.tool) === null && id !== undefined && running.has(id)
303 if (isDialog && !waits.has(id)) {
304 waits.set(id, 'permission')
305 await settle($, nagMs)
306 }
307 } catch {
308 // The lock is a courtesy; the engine's own verdict stands.
309 }
310 return verdict
311 })
312
313 on('session.end', async ($, e, next) => {
314 // A /clear or a /resume ends this conversation, but the process goes on
315 // under another id with no session.start: keep beating, as a fresh member.
316 const goesOn = e.reason === 'clear' || e.reason === 'resume'
317 if (!goesOn) stopHeartbeat()
318 isTurnRunning = false
319 try {
320 const now = await $.clock.now()
321 await $.store.set(memberKey(e.sessionId), { ...withState(self, 'done', now), sessionId: e.sessionId, beatAt: now })
322 self = goesOn
323 ? { ...EMPTY_SELF, cwd: self.cwd, branch: self.branch, repo: self.repo, since: now }
324 : { ...withState(self, 'done', now), sessionId: e.sessionId, beatAt: now }
325 await $.state.set(SELF, self)
326 } catch {
327 // It goes stale in two minutes anyway.
328 }
329 return next(e)
330 })
331
332 on('command.run', { command: 'party' }, async ($, e) => {
333 await beat($, nagMs)
334 try {
335 await $.ui.open({ id: PANE, title: 'PARTY' })
336 } catch {
337 // No pane here (claude -p, VS Code): the text reply is the view.
338 }
339 const now = await $.clock.now()
340 return { text: describeRoster(await read($, rosterAtom), await $.session.id(), now) }
341 })
342
343 on('command.run', { command: 'broadcast' }, async ($, e) => ({ text: await broadcast($, (e.args ?? '').trim()) }))
344
345 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
346 const { Box, Text } = $.ui.resolve(e)
347 const members = await read($, rosterAtom)
348 const now = await $.clock.now()
349 const tags = playerTags(members, self.sessionId)
350 const waiting = members.filter(one => one.state === 'waiting-on-you').length
351 const width = Math.max(16, e.props.bodyColumns - 24)
352 const cut = (text: string) => (text.length > width ? `${text.slice(0, width - 1)}~` : text)
353 const sprite = (grid: readonly string[]) =>
354 halfBlockRows(grid).map(runs => (
355 <Box flexDirection="row">
356 {runs.map((run: PixelRun) => (
357 <Text color={run.color} backgroundColor={run.backgroundColor}>
358 {run.text}
359 </Text>
360 ))}
361 </Box>
362 ))
363
364 return (
365 <Box flexDirection="column">
366 <Box flexDirection="row">
367 <Box key="banner" flexDirection="column" marginRight={2}>
368 {sprite(BANNER)}
369 </Box>
370 <Box flexDirection="column">
371 <Text bold color={SIGNATURE}>
372 P A R T Y
373 </Text>
374 <Box key="summary">
375 <Text color={waiting > 0 ? PICO.pink : PICO.lightGrey}>
376 {members.length} IN PARTY · {waiting} WAITING ON YOU
377 </Text>
378 </Box>
379 </Box>
380 </Box>
381 {members.map((one, index) => {
382 const waited = now - one.since
383 const isWaiting = one.state === 'waiting-on-you'
384 const bar = waitBar(isWaiting ? waited : 0, nagMs, BAR_CELLS)
385 const cells = barText(bar.filled, BAR_CELLS)
386 const tool = one.lastTool !== '' ? ` · LAST ${one.lastTool}` : ''
387 return (
388 <Box key={`member-${one.sessionId}`} flexDirection="row" marginTop={1}>
389 <Box key={`icon-${one.sessionId}`} flexDirection="column" marginRight={1}>
390 {sprite(CLASS_ICON[one.state])}
391 </Box>
392 <Box flexDirection="column">
393 <Box flexDirection="row">
394 <Text bold color={one.sessionId === self.sessionId ? PICO.yellow : PICO.lightGrey}>
395 {tags[index] ?? ''}{' '}
396 </Text>
397 <Text color={PICO.white}>{cut(displayName(one))}</Text>
398 </Box>
399 <Box flexDirection="row">
400 <Box key={`bar-${one.sessionId}`} flexDirection="row">
401 <Text color={isWaiting ? BAR_COLOR[bar.color] : PICO.darkGrey}>{cells.full}</Text>
402 <Text color={PICO.darkGrey}>{cells.empty}</Text>
403 </Box>
404 <Text color={STATE_COLOR[one.state]}> {stateText(one)}</Text>
405 <Text color={PICO.lightGrey}> {formatAge(waited)}</Text>
406 </Box>
407 <Text dimColor>{cut(`${place(one)}${tool}`)}</Text>
408 </Box>
409 </Box>
410 )
411 })}
412 </Box>
413 )
414 })
415}
416hooks/party.ts 305 lines1// party: pure logic. Heartbeats, staleness, PR targets, locks, wait bars and text.
2import type { PartyMember, PartyState, PartyTouch, PartyWait } from '../types'
3
4export const PLUGIN = 'party'
5export const HEARTBEAT_MS = 15_000
6export const STALE_MS = 2 * 60_000
7export const KEY_PREFIX = 'session:'
8
9const STATES: readonly PartyState[] = ['working', 'waiting-on-you', 'idle', 'done']
10const STATE_ORDER: Record<PartyState, number> = { 'waiting-on-you': 0, working: 1, idle: 2, done: 3 }
11/** The gh pr verbs that act on a PR, each with its flags that take no value (so the word after them may be the selector). */
12const BOOLEAN_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
13 merge: new Set(['--squash', '-s', '--merge', '-m', '--rebase', '-r', '--auto', '--admin', '--disable-auto', '-d', '--delete-branch']),
14 close: new Set(['-d', '--delete-branch']),
15 comment: new Set(['--editor', '-e', '--web', '-w', '--edit-last', '--delete-last', '--create-if-none', '--yes']),
16 review: new Set(['--approve', '-a', '--request-changes', '-r', '--comment', '-c']),
17 edit: new Set(['--remove-milestone']),
18}
19const PR_URL = /^https?:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)(?:[/?#].*)?$/
20
21export type PrTarget = { repo: string; pr: number }
22
23export const memberKey = (sessionId: string) => `${KEY_PREFIX}${sessionId}`
24
25export function isMember(value: unknown): value is PartyMember {
26 if (typeof value !== 'object' || value === null) return false
27 const one = value as Record<string, unknown>
28 return (
29 typeof one.sessionId === 'string' &&
30 typeof one.title === 'string' &&
31 typeof one.cwd === 'string' &&
32 typeof one.branch === 'string' &&
33 (one.repo === null || typeof one.repo === 'string') &&
34 STATES.includes(one.state as PartyState) &&
35 typeof one.since === 'number' &&
36 typeof one.beatAt === 'number' &&
37 typeof one.lastTool === 'string' &&
38 Array.isArray(one.touches)
39 )
40}
41
42export const isLive = (one: PartyMember, now: number) => now - one.beatAt <= STALE_MS
43
44/** Live sessions only: waiting first (longest wait first), then working, idle, done. */
45export function liveMembers(values: readonly unknown[], now: number): PartyMember[] {
46 return values
47 .filter(isMember)
48 .filter(one => isLive(one, now))
49 .sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state] || a.since - b.since || a.sessionId.localeCompare(b.sessionId))
50}
51
52/** Session keys whose entry is stale or unreadable; other keys are never touched. */
53export function staleKeys(entries: Readonly<Record<string, unknown>>, now: number): string[] {
54 return Object.entries(entries)
55 .filter(([key, value]) => key.startsWith(KEY_PREFIX) && !(isMember(value) && isLive(value, now)))
56 .map(([key]) => key)
57}
58
59/** The member in `state`: the clock restarts only when the state changes. */
60export function withState(one: PartyMember, state: PartyState, now: number, waitingFor: PartyWait | null = null): PartyMember {
61 const isSame = one.state === state && (state !== 'waiting-on-you' || one.waitingFor === waitingFor)
62 return {
63 ...one,
64 state,
65 since: isSame ? one.since : now,
66 waitingFor: state === 'waiting-on-you' ? waitingFor : null,
67 }
68}
69
70/** The tools whose call waits on the person until they answer. */
71export function waitKind(tool: string): PartyWait | null {
72 if (tool === 'AskUserQuestion') return 'question'
73 if (tool === 'ExitPlanMode') return 'plan'
74 return null
75}
76
77const SEPARATORS = new Set(['&&', '||', ';', '|', '&', '\n'])
78
79/** Splits a shell command into simple commands of words; quotes keep a word whole. Best effort. */
80export function splitCommands(command: string): string[][] {
81 const commands: string[][] = []
82 let words: string[] = []
83 let word = ''
84 let hasWord = false
85 let quote: '"' | "'" | null = null
86 const endWord = () => {
87 if (hasWord) words.push(word)
88 word = ''
89 hasWord = false
90 }
91 const endCommand = () => {
92 endWord()
93 if (words.length > 0) commands.push(words)
94 words = []
95 }
96 for (let index = 0; index < command.length; index += 1) {
97 const char = command[index] ?? ''
98 if (quote !== null) {
99 if (char === quote) quote = null
100 else word += char
101 continue
102 }
103 if (char === '"' || char === "'") {
104 quote = char
105 hasWord = true
106 continue
107 }
108 const pair = command.slice(index, index + 2)
109 if (pair === '&&' || pair === '||') {
110 endCommand()
111 index += 1
112 continue
113 }
114 if (SEPARATORS.has(char)) {
115 endCommand()
116 continue
117 }
118 if (char === ' ' || char === '\t') {
119 endWord()
120 continue
121 }
122 word += char
123 hasWord = true
124 }
125 endCommand()
126 return commands
127}
128
129function selectorTarget(word: string, repo: string | null): PrTarget | null {
130 const url = PR_URL.exec(word)
131 if (url) return { repo: url[1] ?? '', pr: Number(url[2]) }
132 const number = /^#?(\d+)$/.exec(word)
133 if (number && repo !== null) return { repo, pr: Number(number[1]) }
134 return null
135}
136
137/**
138 * The PR a Bash command acts on: `gh pr merge|close|comment|review|edit` with a
139 * number (read against `-R/--repo` or `sessionRepo`) or a PR URL. Null for
140 * reads, other verbs, and a call with no selector (the current branch's PR,
141 * which only gh can resolve).
142 */
143export function prTarget(command: string, sessionRepo: string | null): PrTarget | null {
144 for (const words of splitCommands(command)) {
145 const verb = words[2] ?? ''
146 if (words[0] !== 'gh' || words[1] !== 'pr' || !Object.hasOwn(BOOLEAN_FLAGS, verb)) continue
147 const booleans = BOOLEAN_FLAGS[verb] ?? new Set<string>()
148 let repo = sessionRepo
149 let selector: string | null = null
150 for (let index = 3; index < words.length; index += 1) {
151 const word = words[index] ?? ''
152 if (word === '-R' || word === '--repo') {
153 repo = words[index + 1] ?? repo
154 index += 1
155 } else if (word.startsWith('--repo=')) {
156 repo = word.slice('--repo='.length)
157 } else if (word.startsWith('-')) {
158 if (!word.includes('=') && !booleans.has(word)) index += 1
159 } else if (selector === null) {
160 selector = word
161 }
162 }
163 const target = selector === null ? null : selectorTarget(selector, repo)
164 if (target !== null) return target
165 }
166 return null
167}
168
169/** `owner/name` from a GitHub remote URL (ssh or https), else null. */
170export function repoFromRemote(remote: string | null | undefined): string | null {
171 if (!remote) return null
172 const match = /github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(remote.trim())
173 return match ? `${match[1]}/${match[2]}` : null
174}
175
176/** The other live session that touched `target` inside the lock window, if any. */
177export function otherTouch(
178 members: readonly PartyMember[],
179 selfId: string,
180 target: PrTarget,
181 now: number,
182 lockMs: number,
183): { member: PartyMember; touch: PartyTouch } | null {
184 for (const one of members) {
185 if (one.sessionId === selfId || !isLive(one, now)) continue
186 const touch = one.touches.find(
187 found => found.repo === target.repo && found.pr === target.pr && now - found.at <= lockMs,
188 )
189 if (touch) return { member: one, touch }
190 }
191 return null
192}
193
194/** The touches with `target` stamped now: one per PR, expired ones dropped. */
195export function addTouch(touches: readonly PartyTouch[], target: PrTarget, now: number, lockMs: number): PartyTouch[] {
196 const kept = touches.filter(one => now - one.at <= lockMs && !(one.repo === target.repo && one.pr === target.pr))
197 return [...kept, { repo: target.repo, pr: target.pr, at: now }]
198}
199
200export function basename(path: string): string {
201 const parts = path.split('/').filter(part => part !== '')
202 return parts.at(-1) ?? path
203}
204
205export const displayName = (one: PartyMember) => (one.title !== '' ? one.title : basename(one.cwd))
206
207export const place = (one: PartyMember) => (one.branch !== '' ? `${basename(one.cwd)}@${one.branch}` : basename(one.cwd))
208
209export function formatAge(ms: number): string {
210 const seconds = Math.max(0, Math.floor(ms / 1000))
211 if (seconds < 60) return `${seconds}S`
212 const minutes = Math.floor(seconds / 60)
213 if (minutes < 60) return `${minutes}M`
214 return `${Math.floor(minutes / 60)}H${String(minutes % 60).padStart(2, '0')}`
215}
216
217export function lockReason(other: PartyMember, touch: PartyTouch, now: number): string {
218 return (
219 `PARTY LOCK: another session ("${displayName(other)}", ${place(other)}) acted on ` +
220 `${touch.repo}#${touch.pr} ${formatAge(now - touch.at)} AGO. ` +
221 'Two sessions acting on one PR collide; allow only if this is meant.'
222 )
223}
224
225/** The first line of a prompt, cut to 40 columns: a session's title. */
226export function titleFrom(text: string): string {
227 const line = text.trim().split('\n')[0]?.trim() ?? ''
228 return line.length > 40 ? line.slice(0, 40) : line
229}
230
231export type BarColor = 'lime' | 'yellow' | 'red'
232
233/** HP-style: fills over nagMinutes; full and red once the wait passes it. */
234export function waitBar(waitedMs: number, nagMs: number, cells: number): { filled: number; color: BarColor } {
235 const ratio = nagMs <= 0 ? 1 : Math.min(1, Math.max(0, waitedMs / nagMs))
236 const filled = Math.round(ratio * cells)
237 const color: BarColor = ratio >= 1 ? 'red' : ratio >= 0.5 ? 'yellow' : 'lime'
238 return { filled, color }
239}
240
241export const nagKey = (one: PartyMember) => `${one.sessionId}@${one.since}`
242
243/** Other sessions waiting past nagMinutes that were not toasted for this wait yet. */
244export function nagDue(
245 members: readonly PartyMember[],
246 selfId: string,
247 now: number,
248 nagMs: number,
249 nagged: readonly string[],
250): PartyMember[] {
251 return members.filter(
252 one =>
253 one.sessionId !== selfId &&
254 one.state === 'waiting-on-you' &&
255 now - one.since > nagMs &&
256 !nagged.includes(nagKey(one)),
257 )
258}
259
260export function statusLine(members: readonly PartyMember[]): string {
261 const waiting = members.filter(one => one.state === 'waiting-on-you').length
262 return `PARTY ${members.length} ▸ ${waiting} WAITING`
263}
264
265/** Every other live session still running (a `done` one has ended). */
266export const broadcastTargets = (members: readonly PartyMember[], selfId: string) =>
267 members.filter(one => one.sessionId !== selfId && one.state !== 'done')
268
269export const STATE_LABEL: Record<PartyState, string> = {
270 working: 'WORKING',
271 'waiting-on-you': 'WAITING ON YOU',
272 idle: 'IDLE',
273 done: 'DONE',
274}
275
276export const WAIT_LABEL: Record<PartyWait, string> = {
277 permission: 'PERMISSION',
278 question: 'QUESTION',
279 plan: 'PLAN',
280}
281
282export function stateText(one: PartyMember): string {
283 return one.state === 'waiting-on-you' && one.waitingFor !== null
284 ? `${STATE_LABEL[one.state]} · ${WAIT_LABEL[one.waitingFor]}`
285 : STATE_LABEL[one.state]
286}
287
288/** `1UP` for this session, `2P`, `3P`, ... for the others in roster order. */
289export function playerTags(members: readonly PartyMember[], selfId: string): string[] {
290 let player = 1
291 return members.map(one => (one.sessionId === selfId ? '1UP' : `${(player += 1)}P`))
292}
293
294/** The roster as plain text: the `/party` reply where no pane draws. */
295export function describeRoster(members: readonly PartyMember[], selfId: string, now: number): string {
296 const lines = [statusLine(members)]
297 const tags = playerTags(members, selfId)
298 members.forEach((one, index) => {
299 const tag = tags[index] ?? ''
300 const tool = one.lastTool !== '' ? ` · LAST ${one.lastTool}` : ''
301 lines.push(`${tag} ${displayName(one)} · ${place(one)} · ${stateText(one)} ${formatAge(now - one.since)}${tool}`)
302 })
303 return lines.join('\n')
304}
305hooks/pixels.ts 131 lines1// Pixel art: the PICO-8 palette, party's class icons and a half-block renderer.
2import type { PartyState } from '../types'
3import type { BarColor } from './party'
4
5export const PICO = {
6 black: '#000000',
7 navy: '#1D2B53',
8 plum: '#7E2553',
9 green: '#008751',
10 brown: '#AB5236',
11 darkGrey: '#5F574F',
12 lightGrey: '#C2C3C7',
13 white: '#FFF1E8',
14 red: '#FF004D',
15 orange: '#FFA300',
16 yellow: '#FFEC27',
17 lime: '#00E436',
18 blue: '#29ADFF',
19 lavender: '#83769C',
20 pink: '#FF77A8',
21 peach: '#FFCCAA',
22} as const
23
24/** party's signature colour. */
25export const SIGNATURE = PICO.pink
26
27/** One letter per palette colour; `.` is transparent. */
28export const KEYS: Readonly<Record<string, string>> = {
29 k: PICO.black,
30 n: PICO.navy,
31 m: PICO.plum,
32 e: PICO.green,
33 b: PICO.brown,
34 g: PICO.darkGrey,
35 s: PICO.lightGrey,
36 w: PICO.white,
37 r: PICO.red,
38 o: PICO.orange,
39 y: PICO.yellow,
40 l: PICO.lime,
41 u: PICO.blue,
42 v: PICO.lavender,
43 i: PICO.pink,
44 p: PICO.peach,
45}
46
47/** The class icon per state, 5 x 4 pixels (2 terminal rows). */
48export const CLASS_ICON: Record<PartyState, readonly string[]> = {
49 // A blue knight with a raised sword: busy.
50 working: ['.uu.w', 'uppuw', '.uuo.', '.u.u.'],
51 // A pink mage with a red "!": needs you.
52 'waiting-on-you': ['.ii.r', 'ippir', '.ii..', '.i.ir'],
53 // A grey sleeper, eyes shut, a lavender "z".
54 idle: ['.ss.v', 'sggs.', '.ss.v', '.s.s.'],
55 // A gold star: quest complete.
56 done: ['..y..', 'yyyyy', '.yyy.', '.y.y.'],
57}
58
59/** The banner: three party members side by side, 14 x 4 pixels. */
60export const BANNER: readonly string[] = [
61 '.uu...ii...ll.',
62 'uppu.ippi.lppl',
63 '.uu...ii...ll.',
64 'u..u.i..i.l..l',
65]
66
67export const STATE_COLOR: Record<PartyState, string> = {
68 working: PICO.blue,
69 'waiting-on-you': PICO.pink,
70 idle: PICO.lightGrey,
71 done: PICO.yellow,
72}
73
74export const BAR_COLOR: Record<BarColor, string> = {
75 lime: PICO.lime,
76 yellow: PICO.yellow,
77 red: PICO.red,
78}
79
80/** One run of same-styled cells in a terminal row. */
81export type PixelRun = { text: string; color?: string; backgroundColor?: string }
82
83function colorAt(grid: readonly string[], row: number, column: number): string | undefined {
84 const key = grid[row]?.[column] ?? '.'
85 return key === '.' ? undefined : KEYS[key]
86}
87
88/**
89 * Two pixel rows per terminal row: `▀` with the top pixel as `color` and the
90 * bottom as `backgroundColor`; `▄` when only the bottom is set; a space when
91 * neither. Adjacent cells with the same style merge into one run.
92 */
93export function halfBlockRows(grid: readonly string[]): PixelRun[][] {
94 const width = Math.max(0, ...grid.map(line => line.length))
95 const rows: PixelRun[][] = []
96 for (let top = 0; top < grid.length; top += 2) {
97 const runs: PixelRun[] = []
98 for (let column = 0; column < width; column += 1) {
99 const upper = colorAt(grid, top, column)
100 const lower = colorAt(grid, top + 1, column)
101 const cell: PixelRun =
102 upper !== undefined
103 ? lower !== undefined
104 ? { text: '▀', color: upper, backgroundColor: lower }
105 : { text: '▀', color: upper }
106 : lower !== undefined
107 ? { text: '▄', color: lower }
108 : { text: ' ' }
109 const last = runs[runs.length - 1]
110 if (last && last.color === cell.color && last.backgroundColor === cell.backgroundColor && last.text[0] === cell.text) {
111 last.text += cell.text
112 } else {
113 runs.push(cell)
114 }
115 }
116 rows.push(runs)
117 }
118 return rows
119}
120
121/** The plain-text capture of a sprite (what a monochrome terminal shows). */
122export function plainRows(grid: readonly string[]): string[] {
123 return halfBlockRows(grid).map(runs => runs.map(run => run.text).join(''))
124}
125
126/** The HP-style bar as text: `filled` full cells, the rest light shade. */
127export function barText(filled: number, cells: number): { full: string; empty: string } {
128 const clamped = Math.max(0, Math.min(cells, filled))
129 return { full: '█'.repeat(clamped), empty: '░'.repeat(cells - clamped) }
130}
131types/index.d.ts 50 lines1/** What a session is doing, as the raid frame shows it. */
2export type PartyState = 'working' | 'waiting-on-you' | 'idle' | 'done'
3
4/** What a waiting session waits for: a permission prompt, a question, or a plan to approve. */
5export type PartyWait = 'permission' | 'question' | 'plan'
6
7/** One PR action a session took: the lock other sessions check. */
8export type PartyTouch = {
9 /** `owner/name` from the remote or the PR URL, or the repository root when there is no GitHub remote. */
10 repo: string
11 pr: number
12 /** When it ran, in ms since the epoch. */
13 at: number
14}
15
16/** One session's heartbeat, kept in the plugin store under `session:<id>`. */
17export type PartyMember = {
18 sessionId: string
19 /** The session's first prompt, cut short; empty until the first turn. */
20 title: string
21 cwd: string
22 branch: string
23 /** The repository key PR numbers without a URL are read against, or null outside a repository. */
24 repo: string | null
25 state: PartyState
26 /** When the session entered `state`, in ms since the epoch. */
27 since: number
28 /** What it waits for while `waiting-on-you`; null otherwise. */
29 waitingFor: PartyWait | null
30 /** The last tool the session called, or an empty string. */
31 lastTool: string
32 /** When this entry was last written, in ms since the epoch. */
33 beatAt: number
34 /** PR actions inside the lock window. */
35 touches: PartyTouch[]
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 party: {
41 /** This session's own entry, as the next heartbeat will write it. */
42 self: PartyMember
43 /** Every live session (this one included), as the last heartbeat read them. */
44 roster: PartyMember[]
45 /** `<sessionId>@<since>` of every wait already toasted. */
46 nagged: string[]
47 }
48 }
49}
50