Git-native project plans and agent workflows for any repository

Projector is a Git-native framework for getting work done in any repository. It gives every project a permanent plan under docs/projects/, derives status and priority views from frontmatter, and supplies one CLI for people and coding agents.
Projects never move when their status or priority changes. Two branches working on different projects therefore edit different files instead of contending on a shared now.md, next.md, or later.md queue.
One command installs the project command from the newest release and the plugin for Claude Code and Codex, whichever you have. It needs Python 3.11 or newer, and no git:
curl -fsSL https://projector.bot/install.sh | bash
project upgrade moves everything to the newest release later, and every project command warns you when a newer release is out. To install only the CLI, pin a release, or install each plugin yourself, see Install Projector.
Run init from anywhere inside a Git repository:
project init
project create cool-new-feature --status ready --priority next --no-edit
project check
init writes the convention to docs/projects/README.md and Projector's conventions into AGENTS.md, linked as CLAUDE.md, so every agent session in the repository follows them. Run at a terminal, it adds rules to your own Claude Code settings, ~/.claude/settings.json, and to your Codex rules, ~/.codex/rules/projector.rules, that let an agent run project commands, such as publishing a review or a pull request summary, without being asked or refused. project init, project upgrade, project config set, and a site build that runs a --prepare command still ask. It sets those up outside a repository too. When origin is on GitHub, it also sets up the Projector site. create writes the plan at docs/projects/cool-new-feature/readme.md. A project can hold supporting documents and nested projects:
docs/projects/cool-new-feature/
├── readme.md
├── design.md
└── sub-feature/
└── readme.md
Each project entry point carries two independent fields. status is the lifecycle: draft, ready, in-progress, or completed. priority is the schedule: now, next, or later. Run project list to group projects at query time. Projector never writes a tracked status index.
The plugin gives Claude Code and Codex the same skills: spec, implement, and finish a project, where implement also builds a small change from a description; review-pr to review a pull request once; fix-pr to fix its findings and merge conflicts once; start-review-loop and start-watch-loop to run those two on every pull request continuously; summarize-pr, which summarizes a pull request's changes as a guided review page; and write, which writes or revises any prose so that it follows Projector's writing guide. Claude invokes a skill as /projector:<skill> and Codex as $<skill>. See Install Projector for how the skills work together.
In Claude Code, the plugin also shows Projector's panel above the prompt. It lists your loops' pull requests and your inbox: the questions and notices that agents post with the ask and tell tools or with project inbox. See Read your inbox in Projector's panel and Use the inbox.
Every repository can serve a site on GitHub Pages with its projects, its pull request summaries, and its docs. project init sets it up, and project site serve shows it locally without Pages. See Set up the Projector site.
project list [--status <status>] [--priority now|next|later]
project show <project>
project create <project> [--status draft] [--priority later]
project status <project> draft|ready|in-progress|completed
project priority <project> now|next|later
project done <project>
project check
project upgrade
Add --json when an agent or script consumes output. See the CLI reference for every command, and the project convention for how plans are laid out.
See Develop Projector for running the CLI from a clone, trying unreleased skills, the validation gate, and releases.
hooks/register.tsx 1693 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, FsEntry, Register, RenderChildren } from 'claude-code'
3
4import type { Checks, Item, LoopKind, Notice, PullRequest, Question, Row, SeenLoop, Sending, Snapshot } from './types'
5import { buildQuery, parseResponse, type Fetched } from './github'
6import { findWatchers, keyOf, parseTracked, type Env, type TrackedPr } from './parse'
7import {
8 ASK_DESCRIPTION,
9 ASK_SCHEMA,
10 askArguments,
11 forMainLoop,
12 isShownAt,
13 isSnoozedAt,
14 letterAt,
15 liveAgent,
16 needsDelivery,
17 proposalOf,
18 quoteOf,
19 readPrompt,
20 relayNote,
21 replyNote,
22 RESTORE_DESCRIPTION,
23 RESTORE_SCHEMA,
24 restoreArguments,
25 REVISE_DESCRIPTION,
26 REVISE_SCHEMA,
27 reviseArguments,
28 signatureOf,
29 span,
30 storeOf,
31 TELL_DESCRIPTION,
32 TELL_SCHEMA,
33 tellArguments,
34 typedLetter,
35 WITHDRAW_DESCRIPTION,
36 WITHDRAW_SCHEMA,
37 withdrawArguments,
38 wokeBetween,
39 type Proposal,
40} from './inbox'
41
42type Engine = EngineInterface
43type Ran = { exitCode: number; stdout: string; stderr: string }
44
45const TICK_MS = 5_000
46const POLL_MS = 30_000
47const EMPTY: Snapshot = { rows: [], viewer: null, fetchedAt: null, error: null }
48const ASK_TOOL = 'mcp__projector__ask'
49const TELL_TOOL = 'mcp__projector__tell'
50const WITHDRAW_TOOL = 'mcp__projector__withdraw'
51const REVISE_TOOL = 'mcp__projector__revise'
52const RESTORE_TOOL = 'mcp__projector__restore'
53const TAGLINE = 'Our minds are projectors, casting ideas onto the world.'
54
55// Watching first: a pull request both loops track is listed once, where its
56// watch loop puts it.
57const KINDS: readonly LoopKind[] = ['watching', 'reviewing']
58
59const CHECK_COLORS: Record<Checks, 'success' | 'error' | 'warning'> = {
60 passing: 'success',
61 failing: 'error',
62 pending: 'warning',
63}
64
65/**
66 * The theme color of an open question's line: the one Claude Code gives its
67 * own permission prompts, since an agent's question usually asks leave to do
68 * something.
69 */
70const QUESTION_COLOR = 'permission'
71
72/**
73 * The color of an open notice's line: the theme's mid gray, which stays
74 * apart from the `subtle` of a resolved or snoozed item. A plain `gray` would
75 * draw in the default color.
76 */
77const NOTICE_COLOR = 'inactive'
78
79/**
80 * The color of a pull request's branch on its drawer's facts line: the
81 * terminal palette's own cyan, slot 6, so it follows the person's palette as
82 * a named color would. Claude Code draws a plain name such as `cyan` in the
83 * default color and refuses `ansi:cyan` on a plugin's Text, but takes
84 * `ansi256()`.
85 */
86const BRANCH_COLOR = 'ansi256(6)'
87
88/**
89 * The color of what a loop does with a pull request, on its drawer's facts
90 * line: watching in the terminal palette's blue, slot 4, in the same form as
91 * `BRANCH_COLOR`, and reviewing in the theme's `claude` orange.
92 */
93const LOOP_COLORS: Record<LoopKind, string> = { watching: 'ansi256(4)', reviewing: 'claude' }
94
95/** The key that closes an open item's drawer, from its line at the drawer's top. */
96const CLOSE_HOTKEY = '0'
97
98/**
99 * How far an open item's drawer is indented: the width of the `0: ` the
100 * engine draws before the item's line, so the drawer's lines start under the
101 * item's text.
102 */
103const DRAWER_INDENT = `${CLOSE_HOTKEY}: `.length
104
105/**
106 * In a terminal the panel is inset `PANEL_INSET` columns in the terminal's
107 * own background, then padded `PANEL_PADDING` columns at each side in its
108 * own, where its header and key hint start. The lines between them, the pull
109 * requests, the items and their drawers, sit `LINE_PADDING` columns further
110 * in at each side.
111 */
112const PANEL_INSET = 1
113const PANEL_PADDING = 1
114const LINE_PADDING = 1
115
116/**
117 * The panel's last line names its keys, at its right, so it ends where the
118 * ages do: the chord that takes the keyboard into the panel, and once the
119 * panel has it, the keys that work there. The arrows stay out, because which
120 * of them moves between lines depends on whether the panel scrolls.
121 */
122const FOCUS_HINT = 'ctrl+x tab to focus'
123const FOCUSED_HINT = 'esc to return · enter to open'
124
125/** What the panel's header starts with, at its left: the command that shows and hides the panel. */
126const HEADER_NAME = '/projector'
127
128/**
129 * The panel's background by Claude Code theme: a step darker than the
130 * terminal's own, so the panel reads as a sunken surface. Any other theme,
131 * an ANSI one included, takes `OTHER_BACKGROUND`.
132 */
133const THEME_BACKGROUNDS: Record<string, string> = {
134 dark: 'rgb(20,20,20)',
135 'dark-daltonized': 'rgb(20,20,20)',
136 light: 'rgb(240,240,240)',
137 'light-daltonized': 'rgb(240,240,240)',
138}
139const OTHER_BACKGROUND = 'inverseText'
140
141/** The setting that colors the panel, and its value that leaves the color to the theme. */
142const BACKGROUND_KEY = 'panel.background'
143const AUTO = 'auto'
144
145/**
146 * The names `panel.background` takes besides `auto` and a literal color, and
147 * what each draws. Claude Code draws any theme key by name, but the panel
148 * refuses the others by choice: most are text colors that look wrong as a
149 * background, and a misspelled name, which is no key, draws nothing at all.
150 * `subtle` is refused too, as the color of the panel's own muted text, which
151 * would vanish on it. `black` and `white` are literal colors: `inverseText`
152 * flips between the two with the theme, and `ansi256(0)` is the terminal
153 * palette's black, often the terminal's own background, which would make the
154 * panel vanish.
155 */
156const BACKGROUND_ALIASES = new Map([
157 ['sidebar', 'composerSidebarBackground'],
158 ['black', 'rgb(0,0,0)'],
159 ['white', 'rgb(255,255,255)'],
160])
161
162/**
163 * The literal colors `panel.background` takes, written as Claude Code reads
164 * them: `#rgb` or `#rrggbb`, and `rgb(r,g,b)` or `ansi256(n)`, with a space
165 * allowed after `(`, each comma, and before `)`. Each number is at most 255.
166 */
167const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
168const NUMBERED_COLORS = [/^rgb\( ?(\d{1,3}), ?(\d{1,3}), ?(\d{1,3}) ?\)$/, /^ansi256\( ?(\d{1,3}) ?\)$/]
169
170/** What the panel draws for a `panel.background` value other than `auto`, or undefined when it takes none. */
171function backgroundFor(value: string): string | undefined {
172 const alias = BACKGROUND_ALIASES.get(value)
173 if (alias !== undefined) return alias
174 if (HEX_COLOR.test(value)) return value
175 const numbers = NUMBERED_COLORS.map(pattern => pattern.exec(value)).find(match => match !== null)
176 return numbers?.slice(1).every(number => Number(number) <= 255) === true ? value : undefined
177}
178
179/** The lengths a drawer offers to snooze an item for, within the CLI's cap of a day. */
180const SNOOZES = [
181 { label: '15 min', minutes: 15 },
182 { label: '1 hour', minutes: 60 },
183 { label: '4 hours', minutes: 240 },
184 { label: '1 day', minutes: 1440 },
185] as const
186
187const seen = atom({ plugin: 'projector', key: 'seenLoops' } as const, [])
188const snapshot = atom({ plugin: 'projector', key: 'panelSnapshot' } as const, EMPTY)
189const now = atom({ plugin: 'projector', key: 'panelNow' } as const, 0)
190const isShown = atom({ plugin: 'projector', key: 'isPanelShown' } as const, false)
191const drawer = atom({ plugin: 'projector', key: 'panelDrawer' } as const, null)
192const asked = atom({ plugin: 'projector', key: 'panelAsked' } as const, null)
193const items = atom({ plugin: 'projector', key: 'panelInbox' } as const, [])
194const sending = atom({ plugin: 'projector', key: 'panelSending' } as const, {})
195const themeBackground = atom({ plugin: 'projector', key: 'panelThemeBackground' } as const, OTHER_BACKGROUND)
196const background = atom({ plugin: 'projector', key: 'panelBackground' } as const, AUTO)
197const isFocused = atom({ plugin: 'projector', key: 'isPanelFocused' } as const, false)
198/**
199 * The deliveries the release before the panel's rename recorded, under the
200 * key it gave them. A submit it made can still wait behind a running turn
201 * when the session updates, so the panel takes them over rather than letting
202 * their claims lapse and delivering those answers a second time.
203 */
204const oldSending = atom({ plugin: 'projector', key: 'bandSending' } as const, {})
205
206// The session keeps its state across a reload or an update of the plugin,
207// so a value an earlier version wrote can have another shape.
208const isKind = (kind: unknown): kind is LoopKind => KINDS.some(one => one === kind)
209const current = (value: Snapshot): Snapshot =>
210 Array.isArray(value.rows) ? { ...value, viewer: value.viewer ?? null } : EMPTY
211
212const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
213
214let envLoad: Promise<Env> | undefined
215let inFlight: Promise<void> | undefined
216let isQueued = false
217let attemptedAt = 0
218const unreadable = new Set<string>()
219
220/** The store's directory as the panel last read it, so it reads again only after a change. */
221let listed: string | undefined
222let isChecking = false
223/** The answers this copy of the module is claiming or delivering, by reference. */
224const delivering = new Set<string>()
225/** The items and pull requests the panel is dismissing or snoozing, so a second press does nothing. */
226const acting = new Set<string>()
227/** When the last tick ran, so an item whose snooze ends between two ticks wakes once. */
228let tickedAt: number | undefined
229/**
230 * The answers questions' drawers put in the prompt, oldest first, so a sent
231 * quote answers the question you chose when another shares its text. A
232 * revision marks its question's proposals stale, so an answer chosen from the
233 * old text answers nothing. A proposal lasts until a prompt you send quotes
234 * its question or holds the quote it was chosen under.
235 */
236let proposed: Proposal[] = []
237/**
238 * Where the panel's focus ring last landed, as `ui.focus` reported it: the
239 * panel's site, and the key of the panel's element under the ring, absent when
240 * the ring is on none of them. The engine says nothing when the panel hands
241 * the keyboard back, so the ring can be stale, and the engine refuses a move
242 * once the panel no longer holds the keyboard.
243 */
244let ring: { requestId: string; key: string | undefined } | undefined
245
246/**
247 * Records what a question's drawer put in the prompt: an option, or an empty
248 * answer for discuss. It takes the place of the question's last fresh pick,
249 * and of any stale pick that put the same text in the prompt, since a sent
250 * prompt cannot tell the two apart.
251 */
252function propose(question: Question, answer: string): void {
253 const quote = quoteOf(question)
254 const isKept = (one: Proposal) =>
255 one.isStale === true ? one.quote !== quote || one.answer !== answer : one.ref !== question.ref
256 proposed = [...proposed.filter(isKept), { ref: question.ref, quote, answer }]
257}
258
259/** The few variables the panel's paths are written with, read once per load. */
260function envOf($: Engine): Promise<Env> {
261 envLoad ??= (async () => {
262 const values: Record<string, string | undefined> = {
263 HOME: await $.env.get('HOME'),
264 TMPDIR: await $.env.get('TMPDIR'),
265 XDG_CONFIG_HOME: await $.env.get('XDG_CONFIG_HOME'),
266 XDG_DATA_HOME: await $.env.get('XDG_DATA_HOME'),
267 XDG_STATE_HOME: await $.env.get('XDG_STATE_HOME'),
268 CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'),
269 }
270 return (name: string) => (values[name] === '' ? undefined : values[name])
271 })()
272 return envLoad
273}
274
275async function canonical($: Engine, path: string): Promise<string | undefined> {
276 try {
277 const stat = await $.fs.stat(path, { resolve: true })
278 return stat.realPath ?? path
279 } catch {
280 return undefined
281 }
282}
283
284/** Runs the `project` CLI; rejects when it cannot start or is still running at the 15-second timeout. */
285const project = ($: Engine, args: readonly string[]): Promise<Ran> =>
286 $.process.run(['project', ...args], { timeoutMs: 15_000 })
287
288/** The CLI's error, as its last line of standard error says it. */
289const errorOf = (ran: Ran) =>
290 (ran.stderr.trim().split('\n').pop() ?? '').replace(/^project: /, '') || `project exited ${ran.exitCode}`
291
292/**
293 * Records a loop, answering whether the session had not seen it before. An
294 * entry an earlier version kept with no kind gives way to the watcher's.
295 */
296async function remember($: Engine, loop: SeenLoop): Promise<boolean> {
297 let isNew = false
298 await update($, seen, list => {
299 isNew = !list.some(one => one.tracked === loop.tracked && isKind(one.kind))
300 return isNew ? [...list.filter(one => one.tracked !== loop.tracked), loop] : list
301 })
302 return isNew
303}
304
305/**
306 * Records the tracked file of each watcher a command starts, and shows the
307 * panel when one is new. A loop restarts its watcher with the same command,
308 * as a Monitor does each time it expires, so a known one shows nothing and
309 * a path it cannot read is said once.
310 */
311async function adopt($: Engine, command: string): Promise<void> {
312 if (!command.includes('watch-')) return
313 const env = await envOf($)
314 let isAdded = false
315 for (const watcher of findWatchers(command, env)) {
316 if (watcher.tracked === undefined) {
317 if (!unreadable.has(watcher.written)) {
318 unreadable.add(watcher.written)
319 $.ui.toast(`projector: cannot tell which file ${watcher.written} names, so the panel does not list that loop`)
320 }
321 continue
322 }
323 const tracked = (await canonical($, watcher.tracked)) ?? watcher.tracked
324 isAdded = (await remember($, { kind: watcher.kind, tracked })) || isAdded
325 }
326 if (isAdded) show($)
327}
328
329async function fetchPrs($: Engine, prs: readonly TrackedPr[]): Promise<Fetched> {
330 if (prs.length === 0) return { prs: new Map(), viewer: null, error: null }
331 const query = buildQuery(prs)
332 try {
333 const result = await $.process.run(['gh', 'api', 'graphql', '-f', `query=${query.text}`], {
334 timeoutMs: 30_000,
335 })
336 return parseResponse(query, result)
337 } catch (error) {
338 return { prs: new Map(), viewer: null, error: messageOf(error) }
339 }
340}
341
342/**
343 * The panel's background for a Claude Code theme: by the theme's name, or
344 * for a custom theme, `custom:<slug>`, by the theme its file builds on,
345 * `dark` unless its `base` says otherwise. A custom theme whose file cannot
346 * be read takes `OTHER_BACKGROUND`.
347 */
348async function backgroundOf($: Engine, theme: unknown): Promise<string> {
349 if (typeof theme !== 'string') return OTHER_BACKGROUND
350 if (!theme.startsWith('custom:')) return THEME_BACKGROUNDS[theme] ?? OTHER_BACKGROUND
351 const env = await envOf($)
352 const home = env('HOME')
353 const config = env('CLAUDE_CONFIG_DIR') ?? (home === undefined ? undefined : `${home}/.claude`)
354 if (config === undefined) return OTHER_BACKGROUND
355 try {
356 const path = `${config}/themes/${theme.slice('custom:'.length)}.json`
357 const base = (JSON.parse(await $.fs.read(path)) as { base?: unknown }).base ?? 'dark'
358 return typeof base === 'string' ? (THEME_BACKGROUNDS[base] ?? OTHER_BACKGROUND) : OTHER_BACKGROUND
359 } catch {
360 return OTHER_BACKGROUND
361 }
362}
363
364/** Colors the panel for a theme ahead of the render, which then waits on no read. */
365async function paint($: Engine, theme: unknown): Promise<void> {
366 const color = await backgroundOf($, theme)
367 await update($, themeBackground, () => color)
368}
369
370/** The `panel.background` values the panel said it does not take, so it says so once for each. */
371const unpaintable = new Set<string>()
372
373/**
374 * Reads `panel.background` through the CLI, which merges the `.projector.toml`
375 * files. The panel alone decides which values it takes, so the CLI stores any
376 * string. A value it does not take leaves the color to the theme, and the
377 * panel says so once. A read that fails keeps the value the panel last read,
378 * and the next reload reads it again.
379 */
380async function readBackground($: Engine): Promise<void> {
381 try {
382 const ran = await project($, ['config', 'get', BACKGROUND_KEY, '--default', AUTO, '--json'])
383 if (ran.exitCode !== 0) return
384 const value = (JSON.parse(ran.stdout) as { value?: unknown }).value ?? AUTO
385 const color = value === AUTO ? AUTO : typeof value === 'string' ? backgroundFor(value) : undefined
386 const written = JSON.stringify(value)
387 if (color === undefined && !unpaintable.has(written)) {
388 unpaintable.add(written)
389 void $.ui.toast(
390 `projector: ${BACKGROUND_KEY} is ${written}, which is not rgb(r,g,b), #rrggbb, ansi256(n), sidebar, black or white, so the panel takes its color from the theme`,
391 )
392 }
393 await update($, background, () => color ?? AUTO)
394 } catch {
395 // The next reload reads it again.
396 }
397}
398
399/** The session's own loops, the watchers it started, each with the pull requests its tracked file lists. */
400async function trackedSets($: Engine): Promise<(SeenLoop & { entries: TrackedPr[] })[]> {
401 const loops = (await read($, seen)).filter(loop => isKind(loop.kind))
402 // A tracked file that is gone belongs to a loop that ended.
403 return Promise.all(
404 loops.map(async loop => ({
405 ...loop,
406 entries: await $.fs.read(loop.tracked).then(parseTracked, () => [] as TrackedPr[]),
407 })),
408 )
409}
410
411async function load($: Engine): Promise<void> {
412 const sets = await trackedSets($)
413
414 const kindsOf = new Map<string, LoopKind[]>()
415 const tracked = new Map<string, TrackedPr>()
416 for (const kind of KINDS) {
417 for (const entry of sets.filter(set => set.kind === kind).flatMap(set => set.entries)) {
418 const key = keyOf(entry)
419 tracked.set(key, tracked.get(key) ?? entry)
420 const kinds = kindsOf.get(key) ?? []
421 if (!kinds.includes(kind)) kindsOf.set(key, [...kinds, kind])
422 }
423 }
424
425 // Each reload reads the panel's setting too, so an edit to it shows without a restart.
426 const [fetched] = await Promise.all([fetchPrs($, [...tracked.values()]), readBackground($)])
427
428 // When GitHub cannot be reached, keep drawing what it said last time.
429 const last = current(await read($, snapshot))
430 const previous = new Map(last.rows.map(row => [keyOf(row.pr), row.pr]))
431 const isFetched = fetched.error === null
432
433 const rows: Row[] = [...kindsOf].flatMap(([key, kinds]) => {
434 const pr = fetched.prs.get(key) ?? (isFetched ? undefined : previous.get(key))
435 return pr !== undefined && pr.state === 'OPEN' ? [{ pr, kinds }] : []
436 })
437
438 const time = await $.clock.now()
439 await update($, snapshot, () => ({
440 rows,
441 viewer: fetched.viewer ?? last.viewer,
442 fetchedAt: isFetched ? time : last.fetchedAt,
443 error: fetched.error,
444 }))
445 await update($, now, () => time)
446 await closeLeftDrawer($, time)
447}
448
449/**
450 * Reloads the loops and their pull requests, only while the panel is shown
451 * unless forced. A request that arrives mid-load runs once more after it,
452 * so a change it was told about is never missed.
453 */
454function refresh($: Engine, isForced = false): Promise<void> {
455 if (inFlight !== undefined) {
456 isQueued = true
457 return inFlight
458 }
459 inFlight = (async () => {
460 try {
461 if (isForced || (await read($, isShown))) {
462 attemptedAt = await $.clock.now()
463 await load($)
464 }
465 } catch (error) {
466 const message = messageOf(error)
467 await update($, snapshot, last => ({ ...current(last), error: message })).catch(() => undefined)
468 }
469 })().finally(() => {
470 inFlight = undefined
471 if (isQueued) {
472 isQueued = false
473 void refresh($)
474 }
475 })
476 return inFlight
477}
478
479/** Puts an item the CLI just changed in the panel's list, in place of what it showed. */
480async function replace($: Engine, item: Item): Promise<void> {
481 await update($, items, list => list.map(one => (one.ref === item.ref && one.session === item.session ? item : one)))
482 await forgetAsked($, [item])
483}
484
485/**
486 * Forgets what an item's drawer was asking once the question no longer fits
487 * the item, wherever that happened: either question once the item is
488 * resolved, and how long to snooze once it is snoozed. The drawer then opens
489 * on its actions when the item comes back. A snoozed item can still be
490 * dismissed, so its dismiss confirmation stands.
491 */
492async function forgetAsked($: Engine, changed: readonly Item[]): Promise<void> {
493 const was = await read($, asked)
494 const item = changed.find(one => `item:${one.ref}` === was?.drawer)
495 if (was === null || item === undefined) return
496 const isMoot =
497 item.answer !== undefined || (was.about === 'snooze' && isSnoozedAt(item, await $.clock.now()))
498 if (!isMoot) return
499 await update($, asked, now => (now?.drawer === was.drawer && now.about === was.about ? null : now))
500}
501
502/** Closes a drawer when it is the one open, and with it anything the drawer was asking. */
503async function closeDrawer($: Engine, key: string): Promise<void> {
504 await update($, drawer, was => (was === key ? null : was))
505 await update($, asked, was => (was?.drawer === key ? null : was))
506}
507
508/**
509 * Closes the open drawer once its line has left the panel, so it does not
510 * open again by itself when the line comes back: a pull request a loop
511 * tracks again, or an item restored or woken from its snooze.
512 */
513async function closeLeftDrawer($: Engine, time: number): Promise<void> {
514 const opened = await read($, drawer)
515 if (opened === null) return
516 const isListed = opened.startsWith('item:')
517 ? (await read($, items)).some(item => `item:${item.ref}` === opened && isShownAt(item, time))
518 : current(await read($, snapshot)).rows.some(row => keyOf(row.pr) === opened)
519 if (!isListed) await closeDrawer($, opened)
520}
521
522/**
523 * An item as the CLI prints it, without the session the panel adds. Each kind
524 * is its own `Omit`, because one over the union would merge their `kind`s.
525 */
526type Printed = Omit<Question, 'session'> | Omit<Notice, 'session'>
527
528/** The item a CLI command printed with `--json`, in the session it belongs to. */
529const itemOf = (stdout: string, session: string): Item => ({
530 ...(JSON.parse(stdout) as { item: Printed }).item,
531 session,
532})
533
534/**
535 * Takes the panel's list from the store, and shows the panel for an item that
536 * is open and not snoozed when the panel last held it otherwise: a new item,
537 * or one restored or unsnoozed since.
538 */
539async function setItems($: Engine, list: Item[]): Promise<void> {
540 const time = await $.clock.now()
541 let isNew = false
542 await update($, items, before => {
543 const isAwake = (item: Item) => item.answer === undefined && !isSnoozedAt(item, time)
544 const awake = new Set(before.filter(isAwake).map(one => one.ref))
545 isNew = list.some(one => isAwake(one) && !awake.has(one.ref))
546 return list
547 })
548 await forgetAsked($, list)
549 if (isNew && !(await read($, isShown))) show($)
550}
551
552/**
553 * Reads the session's inbox when the store's directory changed since the
554 * last read that succeeded. Listing a directory starts no process, so an idle
555 * session, panel shown or hidden, starts none. The signature is recorded only
556 * once the read succeeds, so a read that fails runs again on the next tick.
557 */
558async function readStore($: Engine, session: string): Promise<void> {
559 const store = storeOf(await envOf($), session)
560 if (store === undefined) return
561 const entries = await $.fs.list(store).catch(() => [] as FsEntry[])
562 const files = signatureOf(entries, new Set(Object.keys(await read($, sending))))
563 const signature = `${session}\n${files}`
564 if (signature === listed) return
565 if (files === '') {
566 await setItems($, [])
567 } else {
568 const ran = await project($, ['inbox', 'list', '--all', '--json', '--session', session])
569 if (ran.exitCode !== 0) return
570 const { items: list } = JSON.parse(ran.stdout) as { items: Item[] }
571 await setItems($, list)
572 }
573 listed = signature
574}
575
576/** Remembers an answer the panel is delivering, in the session's state so a reloaded module finds it. */
577const track = ($: Engine, entry: Sending) => update($, sending, all => ({ ...all, [entry.ref]: entry }))
578
579/**
580 * Ends a delivery: marks the answer delivered, or reopens its question and
581 * says why. Whichever of the submit's own answer and the prompt hook comes
582 * first takes the delivery out of the state, and the other finds it gone.
583 */
584async function settle($: Engine, ref: string, outcome: 'delivered' | 'reopen', reason?: string): Promise<void> {
585 let entry: Sending | undefined
586 await update($, sending, all => {
587 entry = all[ref]
588 return Object.fromEntries(Object.entries(all).filter(([key]) => key !== ref))
589 })
590 if (entry === undefined) return
591 const { session, token, text } = entry
592 const ran = await project($, ['inbox', outcome, ref, '--claim', token, '--session', session, '--json']).catch(
593 () => undefined,
594 )
595 if (ran?.exitCode === 0) await replace($, itemOf(ran.stdout, session))
596 if (outcome === 'reopen') {
597 // You never see an item's number, so the toast names the question by its
598 // subject. A restore's line is not sent again, and its question stays open.
599 const question = (await read($, items)).find(one => one.ref === ref && one.session === session)
600 const quoted = question === undefined ? undefined : quoteOf(question)
601 const why = reason ? ` (${reason})` : ''
602 void $.ui.toast(
603 text.startsWith('RESTORED ')
604 ? `projector: word that ${quoted ?? 'a question'} is open again did not reach its agent${why}`
605 : `projector: ${quoted === undefined ? 'an answer' : `the answer to ${quoted}`} did not reach its agent${why}; answer it again`,
606 )
607 }
608}
609
610/**
611 * Claims a recorded answer and delivers it: to the subagent that asked while
612 * it can still read a message, otherwise to the main loop as a prompt of its
613 * own, which waits for the session to be idle.
614 */
615async function deliver($: Engine, question: Question): Promise<void> {
616 const { ref, session } = question
617 delivering.add(ref)
618 try {
619 const ran = await project($, ['inbox', 'claim', ref, '--session', session, '--json'])
620 // Another deliverer holds it, or it was delivered since the panel read the store.
621 if (ran.exitCode !== 0) return
622 const claimed = JSON.parse(ran.stdout) as { token: string; text: string }
623 await replace($, itemOf(ran.stdout, session))
624
625 const agents = question.agentId === undefined ? [] : await $.agent.list().catch(() => [])
626 const agentId = liveAgent(question, agents)
627 if (agentId !== undefined) {
628 await track($, { ref, session, token: claimed.token, route: 'send', text: claimed.text })
629 const sent = await $.session.send({ to: { agentId }, text: claimed.text }).catch(() => undefined)
630 if (sent?.isDelivered === true) return await settle($, ref, 'delivered')
631 }
632
633 const text = forMainLoop(claimed.text, question, agents)
634 await track($, { ref, session, token: claimed.token, route: 'submit', text })
635 const submitted = await $.prompt.submit({ text }).catch((error: unknown) => ({ drop: messageOf(error) }))
636 await settle($, ref, submitted.drop === undefined ? 'delivered' : 'reopen', submitted.drop)
637 } catch (error) {
638 await settle($, ref, 'reopen', messageOf(error))
639 } finally {
640 delivering.delete(ref)
641 }
642}
643
644/** The panel's takeover of `oldSending`, which runs once per load of the module. */
645let adoption: Promise<void> | undefined
646
647/** Moves the deliveries under `oldSending` into `sending`, once per load, and again after a failure. */
648function adoptOldSending($: Engine): Promise<void> {
649 adoption ??= (async () => {
650 const old = await read($, oldSending)
651 if (Object.keys(old).length === 0) return
652 await update($, sending, all => ({ ...old, ...all }))
653 await update($, oldSending, () => ({}))
654 })().catch(() => {
655 adoption = undefined
656 })
657 return adoption
658}
659
660/**
661 * Keeps each delivery's claim live while it waits, as a submit behind a long
662 * turn does. A submit that an earlier copy of the module made still waits in
663 * the session's queue, so its claim is kept until the prompt hook sees it
664 * arrive. A send settles at once, so one an earlier copy left is let go, and
665 * its claim lapses for the panel to deliver again.
666 */
667async function renewClaims($: Engine): Promise<void> {
668 await adoptOldSending($)
669 for (const entry of Object.values(await read($, sending))) {
670 if (entry.route === 'send' && !delivering.has(entry.ref)) {
671 await update($, sending, all => Object.fromEntries(Object.entries(all).filter(([key]) => key !== entry.ref)))
672 continue
673 }
674 await project($, ['inbox', 'claim', entry.ref, '--renew', entry.token, '--session', entry.session]).catch(
675 () => undefined,
676 )
677 }
678}
679
680/**
681 * Reads the store when it changed, renews the panel's claims, and delivers
682 * each answer nobody is delivering. A store read that fails, as one still
683 * running at the CLI's timeout does, skips nothing after it, so a waiting
684 * answer's claim never lapses because of it.
685 */
686async function checkInbox($: Engine): Promise<void> {
687 if (isChecking) return
688 isChecking = true
689 try {
690 const session = await $.session.id()
691 await readStore($, session).catch(() => undefined)
692 await renewClaims($)
693 const time = await $.clock.now()
694 const pending = await read($, sending)
695 for (const item of await read($, items)) {
696 if (item.session !== session || delivering.has(item.ref) || item.ref in pending) continue
697 if (needsDelivery(item, time)) void deliver($, item)
698 }
699 } catch {
700 // The next tick tries again.
701 } finally {
702 isChecking = false
703 }
704}
705
706/** Checks the inbox now rather than at the next tick. */
707const checkSoon = ($: Engine) => $.clock.after(1, () => void checkInbox($))
708
709/**
710 * Dismisses, snoozes for `minutes`, or restores an item from the panel; a
711 * second press finds it changed and does nothing. Dismissing or snoozing
712 * closes the item's drawer, and the item stays listed, muted, for 30 seconds,
713 * so its line opens the drawer again to restore it. Restoring keeps the
714 * drawer open on the item, open again.
715 */
716async function act(
717 $: Engine,
718 item: Item,
719 command: 'dismiss' | 'snooze' | 'restore',
720 minutes?: number,
721): Promise<void> {
722 const { ref, session } = item
723 if (acting.has(ref)) return
724 acting.add(ref)
725 try {
726 const length = minutes === undefined ? [] : ['--for', String(minutes)]
727 const ran = await project($, ['inbox', command, ref, ...length, '--session', session, '--json'])
728 if (ran.exitCode === 0) {
729 await replace($, itemOf(ran.stdout, session))
730 if (command !== 'restore') await closeDrawer($, `item:${ref}`)
731 } else if (ran.exitCode === 65) {
732 // Resolved elsewhere since the panel read the store: read it again.
733 listed = undefined
734 } else {
735 void $.ui.toast(`projector: ${errorOf(ran)}`)
736 }
737 checkSoon($)
738 } catch (error) {
739 void $.ui.toast(`projector: ${messageOf(error)}`)
740 } finally {
741 acting.delete(ref)
742 }
743}
744
745/**
746 * Dismisses a pull request from the session's loops: `project loop drop`
747 * keeps it out of each tracked file that lists it, and the panel reloads
748 * without it. A failure says why, and the line stays.
749 */
750async function dismissPr($: Engine, pr: PullRequest): Promise<void> {
751 const key = keyOf(pr)
752 const named = `${pr.repo}#${pr.number}`
753 if (acting.has(key)) return
754 acting.add(key)
755 const stopped: LoopKind[] = []
756 try {
757 for (const loop of await trackedSets($)) {
758 if (!loop.entries.some(entry => keyOf(entry) === key)) continue
759 const ran = await project($, ['loop', 'drop', named, '--tracked', loop.tracked])
760 if (ran.exitCode !== 0) {
761 void $.ui.toast(`projector: ${errorOf(ran)}`)
762 return
763 }
764 stopped.push(loop.kind)
765 }
766 await closeDrawer($, key)
767 } catch (error) {
768 void $.ui.toast(`projector: ${messageOf(error)}`)
769 } finally {
770 acting.delete(key)
771 const kinds = KINDS.filter(kind => stopped.includes(kind))
772 if (kinds.length > 0) void $.ui.toast(`projector: stopped ${kinds.join(' and ')} ${named}`)
773 await refresh($)
774 }
775}
776
777/**
778 * Records an answer a sent prompt gave and marks it delivered, because the
779 * prompt itself carries it to the main loop. The panel's own deliverer leaves
780 * the question alone meanwhile, so the agent hears the answer once.
781 */
782async function answerFromPrompt($: Engine, question: Question, letter: string): Promise<void> {
783 const { ref, session } = question
784 delivering.add(ref)
785 try {
786 const answered = await project($, ['inbox', 'answer', `${ref}.${letter}`, '--session', session, '--json'])
787 if (answered.exitCode !== 0) return
788 const claimed = await project($, ['inbox', 'claim', ref, '--session', session, '--json'])
789 if (claimed.exitCode !== 0) return
790 const { token } = JSON.parse(claimed.stdout) as { token: string }
791 const ran = await project($, ['inbox', 'delivered', ref, '--claim', token, '--session', session, '--json'])
792 if (ran.exitCode === 0) await replace($, itemOf(ran.stdout, session))
793 } catch {
794 // An answer recorded but not marked delivered is the panel's to deliver on a later tick.
795 } finally {
796 delivering.delete(ref)
797 }
798}
799
800/**
801 * Reads the inbox whether or not the panel is shown, closes the drawer of an
802 * item that left the panel, and brings a hidden panel back for an item whose
803 * snooze ended since the last tick. A shown panel moves
804 * its clock on, which returns that item to the list, and polls GitHub once
805 * the last load is old enough.
806 */
807async function tick($: Engine): Promise<void> {
808 // Read before the store, so a snooze that ends while the store is read
809 // falls in the next tick's window.
810 const time = await $.clock.now()
811 await checkInbox($)
812 await closeLeftDrawer($, time).catch(() => undefined)
813 const since = tickedAt ?? time
814 tickedAt = time
815 if (!(await read($, isShown))) {
816 if (wokeBetween(await read($, items), since, time).length > 0) show($)
817 return
818 }
819 await update($, now, () => time)
820 if (time - attemptedAt >= POLL_MS) await refresh($)
821}
822
823/**
824 * Shows the panel and loads it, from a timer so no hook waits on GitHub. The
825 * panel's clock moves on before it shows, so an item whose snooze ended while
826 * it was hidden is drawn at once rather than after GitHub answers.
827 */
828function show($: Engine): void {
829 $.clock.after(1, () => {
830 void $.clock
831 .now()
832 .then(time => update($, now, () => time))
833 .then(() => update($, isShown, () => true))
834 .then(() => refresh($, true))
835 })
836}
837
838/**
839 * Puts the panel's key hint back on the chord into the panel once the prompt
840 * has the keyboard again. Only a change of hint redraws the panel.
841 */
842async function unfocus($: Engine): Promise<void> {
843 if (!(await read($, isFocused))) return
844 await update($, isFocused, () => false)
845 $.ui.invalidate('ui.render')
846}
847
848/**
849 * Moves the panel's focus ring onto one of its elements, which the engine
850 * waits for when the panel has yet to draw it, and writes why the ring stayed
851 * to the debug log when the engine refuses the move.
852 */
853async function focusOn($: Engine, requestId: string, key: string): Promise<void> {
854 const moved = await $.ui.focus({ requestId, key }).catch((error: unknown) => ({ deny: messageOf(error) }))
855 if (moved.deny !== undefined) $.ui.log(`projector: the focus stayed off ${key}: ${moved.deny}`, { to: 'debug' })
856}
857
858/** Refreshes soon, from a timer so the hook that noticed a change returns at once. */
859const soon = ($: Engine) => $.clock.after(1, () => void refresh($))
860
861/**
862 * Names a pull request for the prompt: `#12` when the panel shows one
863 * repository, `owner/repo#12` otherwise, so the reference is never ambiguous.
864 */
865function referencer(prs: readonly PullRequest[]): (pr: PullRequest) => string {
866 const isOneRepo = new Set(prs.map(pr => pr.repo)).size <= 1
867 return pr => (isOneRepo ? `#${pr.number}` : `${pr.repo}#${pr.number}`)
868}
869
870type Note = { text: string; color: 'error' | 'warning' | 'success' }
871
872/** What a row says after the title: what holds the pull request back, or its approval. */
873function notesOf(pr: PullRequest): Note[] {
874 const notes: Note[] = []
875 if (pr.isConflicting) notes.push({ text: 'conflict', color: 'error' })
876 if (pr.reviewDecision === 'CHANGES_REQUESTED') notes.push({ text: 'changes requested', color: 'error' })
877 if (pr.reviewDecision === 'APPROVED') notes.push({ text: 'approved', color: 'success' })
878 // The open threads' count comes last, beside the age.
879 if (pr.openThreads > 0) notes.push({ text: `${pr.openThreads}*`, color: 'warning' })
880 return notes
881}
882
883/** Whether the account the panel's `gh` is signed in as wrote a pull request. */
884const isOwn = (pr: PullRequest, viewer: string | null) => viewer !== null && pr.author === viewer
885
886/**
887 * The panel's pull requests in the order it lists them: yours first, then
888 * everyone else's, each author's together and the authors by login. Within
889 * each, they keep the order they came in.
890 */
891function byAuthor(rows: readonly Row[], viewer: string | null): Row[] {
892 const loginOf = (row: Row) => (row.pr.author ?? '').toLowerCase()
893 const others = rows.filter(row => !isOwn(row.pr, viewer))
894 return [
895 ...rows.filter(row => isOwn(row.pr, viewer)),
896 ...others.sort((one, other) => loginOf(one).localeCompare(loginOf(other))),
897 ]
898}
899
900const secondsSince = (time: number, then: number) => Math.max(0, Math.round((time - then) / 1000))
901
902/** `1 question` or `3 questions`, or nothing for none. */
903const countOf = (count: number, noun: string) =>
904 count === 0 ? undefined : `${count} ${noun}${count === 1 ? '' : 's'}`
905
906/** Labels a pull request `#12` when the panel shows one repository, else by its repository's name. */
907function labeler(prs: readonly PullRequest[]): (pr: PullRequest) => string {
908 const repos = [...new Set(prs.map(pr => pr.repo))]
909 if (repos.length <= 1) return pr => `#${pr.number}`
910 const names = repos.map(repo => repo.split('/')[1])
911 const isNameEnough = new Set(names).size === names.length
912 return pr => `${isNameEnough ? pr.repo.split('/')[1] : pr.repo}#${pr.number}`
913}
914
915/**
916 * Runs the `project inbox` command one of the tools makes: what it printed,
917 * or why the tool call is refused. `instead` says where the agent turns when
918 * the command does not finish.
919 */
920async function runTool(
921 $: Engine,
922 args: string[] | { error: string },
923 instead: string,
924): Promise<{ stdout: string } | { deny: string }> {
925 if (!Array.isArray(args)) return { deny: args.error }
926 let ran: Ran
927 try {
928 ran = await project($, args)
929 } catch (error) {
930 return { deny: `project did not finish (${messageOf(error)}); ${instead}` }
931 }
932 // 65 refuses the call, 66 finds no such item, and 75 finds the store busy;
933 // any other failure is a CLI too old to know the command.
934 if (ran.exitCode === 65 || ran.exitCode === 66 || ran.exitCode === 75) return { deny: errorOf(ran) }
935 if (ran.exitCode !== 0) return { deny: `${errorOf(ran)}; run project upgrade` }
936 checkSoon($)
937 return { stdout: ran.stdout }
938}
939
940export const register: Register = on => {
941 on('session.start', async ($, e, next) => {
942 await $.command.register({
943 name: 'projector',
944 description: 'Show or hide Projector’s panel of your loops’ pull requests and your inbox',
945 })
946 // A session whose engine cannot serve the tools keeps the panel.
947 for (const tool of [
948 { name: 'ask', description: ASK_DESCRIPTION, inputSchema: ASK_SCHEMA },
949 { name: 'tell', description: TELL_DESCRIPTION, inputSchema: TELL_SCHEMA },
950 { name: 'withdraw', description: WITHDRAW_DESCRIPTION, inputSchema: WITHDRAW_SCHEMA },
951 { name: 'revise', description: REVISE_DESCRIPTION, inputSchema: REVISE_SCHEMA },
952 { name: 'restore', description: RESTORE_DESCRIPTION, inputSchema: RESTORE_SCHEMA },
953 ]) {
954 await $.tool.register({ ...tool, isDeferred: false }).catch(() => undefined)
955 }
956 // The panel takes its color from the theme, and from its setting once a
957 // timer has read it, so the start waits on no process.
958 await $.config
959 .list()
960 .then(rows => paint($, rows.find(row => row.key === 'theme')?.value))
961 .catch(() => undefined)
962 $.clock.after(1, () => void readBackground($))
963 $.clock.every(TICK_MS, () => void tick($))
964 soon($)
965 checkSoon($)
966 return next(e)
967 })
968
969 // A theme picked in `/theme` recolors the panel at once.
970 on('config.set', { key: 'theme' }, async ($, e, next) => {
971 const set = await next(e)
972 if (set.deny === undefined) await paint($, set.value)
973 return set
974 }).catch(($, e, next) => next(e))
975
976 // The tools run the CLI, so what they post, withdraw, or revise and what
977 // the shell does land in the same store.
978 on('tool.call', { tool: ASK_TOOL }, async ($, e) => {
979 const posted = await runTool($, askArguments(e, await $.session.id(), e.agentId), 'ask in the conversation instead')
980 if ('deny' in posted) return posted
981 const ref = posted.stdout.trim()
982 void $.ui.notify(String(e.question), { title: 'Projector question' }).catch(() => undefined)
983 return {
984 result: `Asked ${ref}. Keep working on anything the answer does not block; it arrives as a message that starts ANSWER ${ref} or DISMISSED ${ref}. Projector’s panel never shows this number, so name the question to the person by what it asks.`,
985 }
986 }).catch(() => ({ deny: 'Projector could not record the question; ask it in the conversation instead' }))
987
988 on('tool.call', { tool: TELL_TOOL }, async ($, e) => {
989 const args = tellArguments(e, await $.session.id(), e.agentId)
990 const posted = await runTool($, args, 'tell the person in the conversation instead')
991 if ('deny' in posted) return posted
992 const ref = posted.stdout.trim()
993 void $.ui.notify(String(e.notice), { title: 'Projector notice' }).catch(() => undefined)
994 return {
995 result: `Posted ${ref}. It waits in Projector’s panel until the person dismisses it; nothing comes back to you. Projector’s panel never shows this number, so name the notice to the person by what it tells.`,
996 }
997 }).catch(() => ({ deny: 'Projector could not record the notice; tell the person in the conversation instead' }))
998
999 // The panel takes the withdrawn item from the CLI at once, so it leaves the
1000 // panel before the next read of the store.
1001 on('tool.call', { tool: WITHDRAW_TOOL }, async ($, e) => {
1002 const session = await $.session.id()
1003 const instead = 'tell the person in the conversation that it no longer needs them'
1004 const ran = await runTool($, withdrawArguments(e, session), instead)
1005 if ('deny' in ran) return ran
1006 const item = itemOf(ran.stdout, session)
1007 await replace($, item)
1008 await closeDrawer($, `item:${item.ref}`)
1009 return { result: `Withdrew ${item.ref}. It has left Projector’s panel, and nothing comes back to you.` }
1010 }).catch(() => ({
1011 deny: 'Projector could not withdraw the item; tell the person in the conversation that it no longer needs them',
1012 }))
1013
1014 // The panel takes the revised question from the CLI at once, so it redraws
1015 // before the next read of the store. An answer a drawer put in the prompt
1016 // was chosen from the old text, so it turns stale: sent, it answers no
1017 // question, this one or another that shares the old text.
1018 on('tool.call', { tool: REVISE_TOOL }, async ($, e) => {
1019 const session = await $.session.id()
1020 const ran = await runTool($, reviseArguments(e, session), 'ask the revised question in the conversation instead')
1021 if ('deny' in ran) return ran
1022 const item = itemOf(ran.stdout, session)
1023 await replace($, item)
1024 proposed = proposed.map(proposal => (proposal.ref === item.ref ? { ...proposal, isStale: true } : proposal))
1025 return {
1026 result: `Revised ${item.ref}. Projector’s panel shows the new text, and its answer still arrives as a message that starts ANSWER ${item.ref} or DISMISSED ${item.ref}.`,
1027 }
1028 }).catch(() => ({
1029 deny: 'Projector could not revise the question; ask the revised question in the conversation instead',
1030 }))
1031
1032 // The panel takes the restored item from the CLI at once, so it is open in
1033 // the panel before the next read of the store, and comes back as a new item
1034 // does when the person hid it.
1035 on('tool.call', { tool: RESTORE_TOOL }, async ($, e) => {
1036 const session = await $.session.id()
1037 const instead = 'tell the person in the conversation that it could not be restored'
1038 const ran = await runTool($, restoreArguments(e, session), instead)
1039 if ('deny' in ran) return ran
1040 const item = itemOf(ran.stdout, session)
1041 await replace($, item)
1042 if (!(await read($, isShown))) show($)
1043 const { ref } = item
1044 return {
1045 result:
1046 item.kind === 'notice'
1047 ? `Restored ${ref}. It is back in Projector’s panel until the person dismisses it; nothing comes back to you.`
1048 : `Restored ${ref}. It is open in Projector’s panel again, and its answer arrives as a message that starts ANSWER ${ref} or DISMISSED ${ref}. An agent that already heard DISMISSED ${ref} hears RESTORED ${ref} first.`,
1049 }
1050 }).catch(() => ({
1051 deny: 'Projector could not restore the item; tell the person in the conversation that it could not be restored',
1052 }))
1053
1054 // These hooks only watch: the catch hands back what the event settled to,
1055 // so a failure here never runs a tool twice or refuses anything.
1056 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1057 const ran = await next(e)
1058 if (ran.deny === undefined) await adopt($, e.command)
1059 return ran
1060 }).catch(($, e, next) => next(e))
1061
1062 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
1063 const ran = await next(e)
1064 if (ran.deny === undefined) await adopt($, e.command ?? '')
1065 return ran
1066 }).catch(($, e, next) => next(e))
1067
1068 on('prompt.submit', async ($, e, next) => {
1069 // A watcher's line reaches the session as a task notification: the moment
1070 // a pull request it tracks has changed.
1071 if (e.origin.kind === 'task-notification') soon($)
1072
1073 // A prompt sent from the terminal means the prompt has the keyboard, even
1074 // when no keystroke edited it since Esc, as with one a drawer filled.
1075 if (e.origin.kind === 'composer') void unfocus($).catch(() => undefined)
1076
1077 // An answer the panel submitted for the main loop has arrived. Seeing it
1078 // here, rather than only where the panel submitted it, settles it after a
1079 // reload of the module too.
1080 if (e.origin.kind === 'plugin' && e.origin.name === 'projector') {
1081 const entered = await next(e)
1082 if (entered.drop === undefined) {
1083 await adoptOldSending($)
1084 const entry = Object.values(await read($, sending)).find(one => one.route === 'submit' && one.text === e.text)
1085 if (entry !== undefined) await settle($, entry.ref, 'delivered')
1086 }
1087 return entered
1088 }
1089
1090 // An option the panel put in the prompt answers its question: the prompt
1091 // carries the answer to the main loop, and once the prompt has entered,
1092 // the panel records it. Any other text under a quoted question replies to
1093 // it, and the prompt carries the reply while the question stays open.
1094 // Either way the prompt is done with the question's drawer. A subagent's
1095 // question gets a note naming it, so the main loop can pass the answer or
1096 // the reply on. Only a prompt you sent, at the terminal or through Remote
1097 // Control, counts: an agent's message or a notification can quote a
1098 // question it is still waiting on.
1099 const session = await $.session.id()
1100 const isYours = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
1101 const mine = isYours ? (await read($, items)).filter(item => item.session === session) : []
1102 const { answers, replies } = readPrompt(e.text, mine, isYours ? proposed : [])
1103 // An open question is done only when the prompt answers or replies to
1104 // it, so a quote two questions share closes one drawer, not both.
1105 const isOpenQuestion = (item: Item) => item.kind === 'question' && item.answer === undefined
1106 const done = new Set([
1107 ...mine.filter(item => !isOpenQuestion(item) && e.text.includes(quoteOf(item))).map(item => item.ref),
1108 ...answers.map(one => one.question.ref),
1109 ...replies.map(question => question.ref),
1110 ])
1111 if (isYours) proposed = proposed.filter(proposal => !done.has(proposal.ref) && !e.text.includes(proposal.quote))
1112 const answered = answers.map(one => one.question).filter(question => question.agentId !== undefined)
1113 const replied = replies.filter(question => question.agentId !== undefined)
1114 const agents = answered.length + replied.length === 0 ? [] : await $.agent.list().catch(() => [])
1115 const notes = [
1116 ...answered.map(question => relayNote(question, agents)),
1117 ...replied.map(question => replyNote(question, agents)),
1118 ]
1119 const entered = await next(notes.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...notes] })
1120 if (entered.drop === undefined) {
1121 for (const ref of done) void closeDrawer($, `item:${ref}`).catch(() => undefined)
1122 for (const one of answers) void answerFromPrompt($, one.question, one.letter)
1123 }
1124 return entered
1125 }).catch(($, e, next) => next(e))
1126
1127 // Enter presses the panel's element under the ring, so where the ring is
1128 // tells a press from the keyboard from a click. A ring that lands also
1129 // means the panel holds the keyboard, so its hint names the keys that work
1130 // there.
1131 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
1132 const moved = await next(e)
1133 if (moved.deny === undefined) {
1134 ring = { requestId: e.requestId, key: e.plugin === 'projector' ? e.element : undefined }
1135 if (!(await read($, isFocused))) {
1136 await update($, isFocused, () => true)
1137 $.ui.invalidate('ui.render')
1138 }
1139 }
1140 return moved
1141 }).catch(($, e, next) => next(e))
1142
1143 // Claude Code reports no event when Esc gives the keyboard back to the
1144 // prompt, so the panel's focused hint stays until the next keystroke in the
1145 // prompt, or until a prompt is sent from it. Only that first keystroke
1146 // redraws the panel; it waits on nothing.
1147 on('prompt.edit', ($, e, next) => {
1148 void unfocus($).catch(() => undefined)
1149 return next(e)
1150 })
1151
1152 // A turn may have pushed, replied or resolved threads.
1153 on('turn.complete', ($, e, next) => {
1154 soon($)
1155 return next(e)
1156 })
1157
1158 // `/projector` shows or hides the panel.
1159 on('command.run', { command: 'projector' }, async $ => {
1160 if (await read($, isShown)) {
1161 await update($, isShown, () => false)
1162 return { text: 'Hid Projector’s panel.' }
1163 }
1164 show($)
1165 return { text: 'Showing Projector’s panel above the prompt.' }
1166 })
1167
1168 // The panel above the prompt spans the terminal and is as tall as its rows,
1169 // so each pull request and each item takes one line, and a drawer more.
1170 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1171 if (e.props.hasSurvey || !(await read($, isShown))) return next(e)
1172
1173 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
1174 const shown = current(await read($, snapshot))
1175 const time = await read($, now)
1176 const opened = await read($, drawer)
1177 const asking = await read($, asked)
1178 const shownItems = (await read($, items)).filter(item => isShownAt(item, time))
1179 const counts = [
1180 countOf(shown.rows.length, 'pull request'),
1181 ...(['question', 'notice'] as const).map(kind =>
1182 countOf(
1183 shownItems.filter(item => item.kind === kind && item.answer === undefined && !isSnoozedAt(item, time))
1184 .length,
1185 kind,
1186 ),
1187 ),
1188 ]
1189 const label = labeler(shown.rows.map(row => row.pr))
1190 const reference = referencer(shown.rows.map(row => row.pr))
1191 const labelWidth = Math.max(0, ...shown.rows.map(row => label(row.pr).length))
1192 // Another person's line names its author after `draft`, in a column as
1193 // wide as the longest of those logins, so their titles start in one
1194 // column too.
1195 const authorWidth = Math.max(
1196 0,
1197 ...shown.rows.filter(row => !isOwn(row.pr, shown.viewer)).map(row => (row.pr.author ?? '').length),
1198 )
1199 const tally = counts.filter(part => part !== undefined).join(' · ')
1200 const reloaded = shown.fetchedAt === null ? '' : span(secondsSince(time, shown.fetchedAt))hooks/github.ts 148 lines1import type { Checks, PullRequest } from './types'
2import { keyOf, type TrackedPr } from './parse'
3
4/** Where one pull request's node sits in the response. */
5type Slot = { pr: TrackedPr; repoAlias: string; prAlias: string }
6
7export type Query = { text: string; slots: Slot[] }
8
9export type Fetched = {
10 prs: Map<string, PullRequest>
11 /** The login of the account `gh` is signed in as; null when GitHub did not say. */
12 viewer: string | null
13 error: string | null
14}
15
16const FIELDS = `number title url state isDraft reviewDecision mergeable body
17 author { login } headRefName additions deletions changedFiles updatedAt
18 reviewThreads(first: 100) { nodes { isResolved } }
19 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`
20
21// The line `project summary publish` leaves at the bottom of a description.
22const SUMMARY = /^<!-- projector-summary v=1 sha=[0-9a-f]{40} -->\r?\n\S* ?\*\*Projector summary\*\* [^\r\n]*?(https?:\/\/\S+)\s*$/m
23
24const CHECKS: Record<string, Checks> = {
25 SUCCESS: 'passing',
26 FAILURE: 'failing',
27 ERROR: 'failing',
28 PENDING: 'pending',
29 EXPECTED: 'pending',
30}
31
32/**
33 * Builds one GraphQL query for every pull request, grouped by repository,
34 * and for the signed-in account's login. Repository names come from
35 * `parseTracked`, which admits only GitHub's name characters, so they are
36 * safe inside a string literal.
37 */
38export function buildQuery(prs: readonly TrackedPr[]): Query {
39 const byRepo = new Map<string, TrackedPr[]>()
40 for (const pr of prs) {
41 byRepo.set(pr.repo, [...(byRepo.get(pr.repo) ?? []), pr])
42 }
43
44 const slots: Slot[] = []
45 const repos = [...byRepo.entries()].map(([repo, list], index) => {
46 const [owner, name] = repo.split('/')
47 const repoAlias = `r${index}`
48 const pulls = list.map(pr => {
49 const prAlias = `p${pr.number}`
50 slots.push({ pr, repoAlias, prAlias })
51 return `${prAlias}: pullRequest(number: ${pr.number}) { ${FIELDS} }`
52 })
53 return `${repoAlias}: repository(owner: "${owner}", name: "${name}") { ${pulls.join(' ')} }`
54 })
55
56 return { text: `query { viewer { login } ${repos.join(' ')} }`, slots }
57}
58
59const isRecord = (value: unknown): value is Record<string, unknown> =>
60 typeof value === 'object' && value !== null && !Array.isArray(value)
61
62const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
63
64function messageOf(body: unknown): string | undefined {
65 if (!isRecord(body) || !Array.isArray(body.errors)) return undefined
66 const [first] = body.errors as unknown[]
67 return isRecord(first) && typeof first.message === 'string' ? first.message : undefined
68}
69
70const nodesOf = (value: unknown): unknown[] =>
71 isRecord(value) && Array.isArray(value.nodes) ? (value.nodes as unknown[]) : []
72
73const numberOf = (value: unknown) => (typeof value === 'number' ? value : 0)
74
75function checksOf(node: Record<string, unknown>): Checks | null {
76 const [last] = nodesOf(node.commits)
77 const commit = isRecord(last) ? last.commit : undefined
78 const rollup = isRecord(commit) ? commit.statusCheckRollup : undefined
79 return isRecord(rollup) && typeof rollup.state === 'string' ? (CHECKS[rollup.state] ?? null) : null
80}
81
82function toPullRequest(pr: TrackedPr, node: Record<string, unknown>): PullRequest {
83 const threads = nodesOf(node.reviewThreads)
84 const state = node.state === 'MERGED' || node.state === 'CLOSED' ? node.state : 'OPEN'
85 const decision = node.reviewDecision
86 const updatedAt = typeof node.updatedAt === 'string' ? Date.parse(node.updatedAt) : Number.NaN
87 const summary = typeof node.body === 'string' ? SUMMARY.exec(node.body) : null
88 return {
89 repo: pr.repo,
90 number: pr.number,
91 title: typeof node.title === 'string' ? node.title : '',
92 url: typeof node.url === 'string' ? node.url : `https://github.com/${pr.repo}/pull/${pr.number}`,
93 state,
94 isDraft: node.isDraft === true,
95 reviewDecision:
96 decision === 'APPROVED' || decision === 'CHANGES_REQUESTED' || decision === 'REVIEW_REQUIRED'
97 ? decision
98 : null,
99 isConflicting: node.mergeable === 'CONFLICTING',
100 openThreads: threads.filter(thread => isRecord(thread) && thread.isResolved === false).length,
101 author: isRecord(node.author) && typeof node.author.login === 'string' ? node.author.login : null,
102 branch: typeof node.headRefName === 'string' ? node.headRefName : '',
103 additions: numberOf(node.additions),
104 deletions: numberOf(node.deletions),
105 changedFiles: numberOf(node.changedFiles),
106 checks: checksOf(node),
107 updatedAt: Number.isNaN(updatedAt) ? null : updatedAt,
108 summaryUrl: summary?.[1] ?? null,
109 }
110}
111
112/**
113 * Reads what `gh api graphql` printed. A pull request that does not exist
114 * leaves its slot null and adds an error, but the rest still answer, so
115 * errors fail the fetch only when no data came back at all.
116 */
117export function parseResponse(
118 query: Query,
119 result: { exitCode: number; stdout: string; stderr: string },
120): Fetched {
121 let body: unknown
122 try {
123 body = JSON.parse(result.stdout)
124 } catch {
125 const reason = firstLine(result.stderr) || `gh exited with ${result.exitCode}`
126 return { prs: new Map(), viewer: null, error: reason }
127 }
128
129 const data = isRecord(body) && isRecord(body.data) ? body.data : undefined
130 if (data === undefined) {
131 return {
132 prs: new Map(),
133 viewer: null,
134 error: messageOf(body) ?? (firstLine(result.stderr) || 'GitHub returned no data'),
135 }
136 }
137
138 const prs = new Map<string, PullRequest>()
139 for (const slot of query.slots) {
140 const repo = data[slot.repoAlias]
141 const node = isRecord(repo) ? repo[slot.prAlias] : undefined
142 if (isRecord(node)) prs.set(keyOf(slot.pr), toPullRequest(slot.pr, node))
143 }
144
145 const viewer = isRecord(data.viewer) && typeof data.viewer.login === 'string' ? data.viewer.login : null
146 return { prs, viewer, error: null }
147}
148hooks/parse.ts 224 lines1import type { LoopKind } from './types'
2
3/** Looks up an environment variable; undefined when it is unset or unknown. */
4export type Env = (name: string) => string | undefined
5
6/** A watcher found in a command line, and the tracked path it was given. */
7export type Watcher = {
8 kind: LoopKind
9 /** The path, expanded; undefined when it could not be expanded. */
10 tracked: string | undefined
11 /** The `--tracked` argument as written, for messages. */
12 written: string
13}
14
15/** One `owner/repo#number` entry of a tracked file. */
16export type TrackedPr = { repo: string; number: number }
17
18const SCRIPTS: Record<string, LoopKind> = {
19 'watch-prs.sh': 'reviewing',
20 'watch-threads.sh': 'watching',
21}
22
23const OPERATORS = ['&&', '||', ';', '&', '|', '(', ')', '\n']
24
25type Part = { text: string; isLiteral: boolean }
26type Token = { parts: Part[] } | { operator: string }
27
28/**
29 * Splits a shell command into words and operators, keeping which parts of a
30 * word were quoted so that expansion leaves single-quoted text alone.
31 */
32export function tokenize(command: string): Token[] {
33 const tokens: Token[] = []
34 let parts: Part[] = []
35 let isInWord = false
36
37 const add = (text: string, isLiteral: boolean) => {
38 isInWord = true
39 const last = parts[parts.length - 1]
40 if (last !== undefined && last.isLiteral === isLiteral) {
41 last.text += text
42 } else {
43 parts.push({ text, isLiteral })
44 }
45 }
46 const end = () => {
47 if (isInWord) tokens.push({ parts })
48 parts = []
49 isInWord = false
50 }
51
52 let i = 0
53 while (i < command.length) {
54 const char = command[i] as string
55 const operator = OPERATORS.find(op => command.startsWith(op, i))
56 if (char === ' ' || char === '\t') {
57 end()
58 i += 1
59 } else if (operator !== undefined) {
60 end()
61 tokens.push({ operator })
62 i += operator.length
63 } else if (char === '#' && !isInWord) {
64 while (i < command.length && command[i] !== '\n') i += 1
65 } else if (char === "'") {
66 const close = command.indexOf("'", i + 1)
67 const stop = close === -1 ? command.length : close
68 add(command.slice(i + 1, stop), true)
69 i = stop + 1
70 } else if (char === '"') {
71 i += 1
72 while (i < command.length && command[i] !== '"') {
73 const inner = command[i] as string
74 const next = command[i + 1]
75 if (inner === '\\' && next !== undefined && '"\\$`'.includes(next)) {
76 add(next, true)
77 i += 2
78 } else {
79 add(inner, false)
80 i += 1
81 }
82 }
83 if (!isInWord) add('', true)
84 i += 1
85 } else if (char === '\\') {
86 const next = command[i + 1]
87 if (next !== undefined && next !== '\n') add(next, true)
88 i += 2
89 } else {
90 add(char, false)
91 i += 1
92 }
93 }
94 end()
95
96 return tokens
97}
98
99const VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g
100
101/**
102 * Expands a word the way the shell would for a path: a leading `~` and
103 * `$NAME` or `${NAME}` outside single quotes. Undefined when the word holds
104 * something only the running shell knows, such as an unset variable or a
105 * command substitution.
106 */
107export function expand(parts: readonly Part[], env: Env): string | undefined {
108 let out = ''
109 for (const [index, part] of parts.entries()) {
110 if (part.isLiteral) {
111 out += part.text
112 continue
113 }
114 let text = part.text
115 if (text.includes('$(') || text.includes('`')) return undefined
116 if (index === 0 && (text === '~' || text.startsWith('~/'))) {
117 const home = env('HOME')
118 if (home === undefined) return undefined
119 text = home + text.slice(1)
120 }
121 let isKnown = true
122 text = text.replace(VARIABLE, (_, braced: string | undefined, bare: string | undefined) => {
123 const value = env(braced ?? bare ?? '')
124 if (value === undefined) isKnown = false
125 return value ?? ''
126 })
127 if (!isKnown || text.includes('$')) return undefined
128 out += text
129 }
130 return out
131}
132
133const raw = (parts: readonly Part[]) => parts.map(part => part.text).join('')
134
135const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=/
136
137/**
138 * Finds each `watch-prs.sh` or `watch-threads.sh` invocation in a command
139 * line and the `--tracked` path given to it. A variable the command assigns
140 * before the watcher, as in `D=~/loops; watch-prs.sh --tracked $D/tracked`,
141 * expands ahead of the environment.
142 */
143export function findWatchers(command: string, env: Env): Watcher[] {
144 const tokens = tokenize(command)
145 const found: Watcher[] = []
146 const assigned = new Map<string, string | undefined>()
147 const lookup: Env = name => (assigned.has(name) ? assigned.get(name) : env(name))
148 let isCommandStart = true
149
150 for (const [index, token] of tokens.entries()) {
151 if (!('parts' in token)) {
152 isCommandStart = true
153 continue
154 }
155 const [first, ...rest] = token.parts
156 const assignment =
157 isCommandStart && first !== undefined && !first.isLiteral ? ASSIGNMENT.exec(first.text) : null
158 if (assignment !== null && first !== undefined) {
159 const [written, name] = assignment as unknown as [string, string]
160 assigned.set(name, expand([{ ...first, text: first.text.slice(written.length) }, ...rest], lookup))
161 continue
162 }
163 if (isCommandStart && raw(token.parts) === 'export') continue
164 isCommandStart = false
165
166 const name = raw(token.parts).split('/').pop() ?? ''
167 const kind = SCRIPTS[name]
168 if (kind === undefined) continue
169
170 for (let at = index + 1; at < tokens.length; at += 1) {
171 const arg = tokens[at] as Token
172 if (!('parts' in arg)) break
173 const text = raw(arg.parts)
174 if (text === '--tracked') {
175 const value = tokens[at + 1]
176 if (value === undefined || !('parts' in value)) break
177 found.push({ kind, tracked: expand(value.parts, lookup), written: raw(value.parts) })
178 break
179 }
180 if (text.startsWith('--tracked=')) {
181 const [first, ...rest] = arg.parts
182 if (first === undefined) break
183 const value = [{ ...first, text: first.text.slice('--tracked='.length) }, ...rest]
184 found.push({ kind, tracked: expand(value, lookup), written: raw(value) })
185 break
186 }
187 }
188 }
189
190 return found
191}
192
193const ENTRY = /^(!?)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)$/
194
195/** Names a pull request without regard to case, as GitHub and the watchers do. */
196export const keyOf = (pr: TrackedPr) => `${pr.repo}#${pr.number}`.toLowerCase()
197
198/**
199 * Reads a tracked file: one `owner/repo#number` per line, where a line
200 * `!owner/repo#number` keeps that pull request out. Lines of any other
201 * shape are skipped; the watcher reports them itself.
202 */
203export function parseTracked(text: string): TrackedPr[] {
204 const kept = new Map<string, TrackedPr>()
205 const excluded = new Set<string>()
206
207 for (const line of text.split('\n')) {
208 const match = ENTRY.exec(line.trim())
209 if (match === null) continue
210 const [, bang, repo, number] = match as unknown as [string, string, string, string]
211 const pr = { repo, number: Number(number) }
212 const key = keyOf(pr)
213 if (bang === '!') {
214 excluded.add(key)
215 } else if (!kept.has(key)) {
216 kept.set(key, pr)
217 }
218 }
219
220 return [...kept.entries()]
221 .filter(([key]) => !excluded.has(key))
222 .map(([, pr]) => pr)
223}
224hooks/inbox.ts 437 lines1import type { AgentInfo, FsEntry } from 'claude-code'
2
3import type { Item, Question } from './types'
4import type { Env } from './parse'
5
6/** How long a delivered item stays in the panel. */
7export const RESOLVED_MS = 30_000
8
9/** How long a claim lasts without renewal, as `project inbox claim` counts it. */
10export const CLAIM_MS = 60_000
11
12const LIVE: ReadonlySet<AgentInfo['status']> = new Set(['pending', 'running', 'waiting', 'idle'])
13
14export const letterAt = (index: number) => String.fromCharCode('a'.charCodeAt(0) + index)
15
16/** The letter of a typed answer: the one after the last choice. */
17export const typedLetter = (question: Question) => letterAt(question.choices.length)
18
19/** Whether an item is snoozed at a time: put off until its `snoozedUntil`. */
20export const isSnoozedAt = (item: Item, time: number) => item.snoozedUntil !== undefined && time < item.snoozedUntil
21
22/** Whether the agent that posted an item withdrew it. */
23const isWithdrawn = (item: Item) => item.answer !== undefined && 'withdrawn' in item.answer
24
25/**
26 * Whether the panel shows an item: not withdrawn, not snoozed or snoozed
27 * within the last 30 seconds, and open, being delivered, or delivered within
28 * the last 30 seconds.
29 */
30export const isShownAt = (item: Item, time: number) =>
31 !isWithdrawn(item) &&
32 (!isSnoozedAt(item, time) || (item.snoozedAt !== undefined && time - item.snoozedAt < RESOLVED_MS)) &&
33 (item.deliveredAt === undefined || time - item.deliveredAt < RESOLVED_MS)
34
35/** The open items whose snooze ended after `since` and by `time`, which bring a hidden panel back. */
36export const wokeBetween = (items: readonly Item[], since: number, time: number) =>
37 items.filter(
38 item =>
39 item.answer === undefined && item.snoozedUntil !== undefined && since < item.snoozedUntil && item.snoozedUntil <= time,
40 )
41
42/**
43 * Whether a question's line waits for a deliverer: an answer recorded and
44 * undelivered, or word of a restore, and claimed by none that is live.
45 */
46export const needsDelivery = (item: Item, time: number): item is Question =>
47 item.kind === 'question' &&
48 (item.restoredAt !== undefined || (item.answer !== undefined && item.deliveredAt === undefined)) &&
49 (item.claim === undefined || time - item.claim.renewedAt >= CLAIM_MS)
50
51/** What the panel puts in the prompt to discuss an item: its subject, in quotes. */
52export const quoteOf = (item: Item) => `"${item.subject}"`
53
54/** What the panel puts in the prompt for an answer: the quoted question, a blank line, and the answer's text. */
55export const proposalOf = (question: Question, answer: string) => `${quoteOf(question)}\n\n${answer}`
56
57/** One answer a sent prompt gives: the letter of the option it picks. */
58export type Given = { question: Question; letter: string }
59
60/**
61 * What a question's drawer put in the prompt: the question's quote as it read
62 * then, and the option chosen, or an empty answer for discuss. `isStale`
63 * marks one put there before the question was revised.
64 */
65export type Proposal = { ref: string; quote: string; answer: string; isStale?: boolean }
66
67/** What a sent prompt says to the open questions: the answers it gives, and the questions it replies to. */
68export type Read = { answers: Given[]; replies: Question[] }
69
70/**
71 * Reads a sent prompt as the panel writes it, each quote speaking to one
72 * question. A quoted open question takes an answer only when its quote ends
73 * its line and the text after it, up to the next quoted item or the prompt's
74 * end, is one of its options, whatever blank lines and spaces surround it:
75 * what pressing an option puts in the prompt. A quote that a drawer put in
76 * the prompt, with that answer or alone through discuss, speaks to the
77 * question of the drawer that did so last, `proposed` holding them oldest
78 * first: one of its options answers it, and any other text replies to it.
79 * A drawer's pick from before
80 * its question was revised only replies, and one whose question was resolved
81 * before the prompt was sent does nothing, so a drawer's pick never falls
82 * through to another question with the same words.
83 * A quote no drawer put there goes to the oldest question with that text that
84 * offers the option, and otherwise replies to the one whose drawer last put
85 * that quote in the prompt, or else to the oldest with that text. Only
86 * questions take answers and replies, snoozed ones included.
87 */
88export function readPrompt(text: string, items: readonly Item[], proposed: readonly Proposal[] = []): Read {
89 const open = items
90 .filter((item): item is Question => item.kind === 'question' && item.answer === undefined)
91 .sort((one, other) => one.postedAt - other.postedAt)
92 const quotes = [...new Set([...items.map(quoteOf), ...proposed.map(one => one.quote)])]
93 .flatMap(quote => {
94 const found: { at: number; end: number; quote: string }[] = []
95 for (let at = text.indexOf(quote); at !== -1; at = text.indexOf(quote, at + 1)) {
96 found.push({ at, end: at + quote.length, quote })
97 }
98 return found
99 })
100 .sort((one, other) => one.at - other.at)
101 const answers: Given[] = []
102 const answered = new Set<string>()
103 const replied = new Set<string>()
104 const isOpen = (ref: string) => open.some(question => question.ref === ref) && !answered.has(ref)
105 const isFree = (ref: string) => isOpen(ref) && !replied.has(ref)
106 const answer = (question: Question, said: string) => {
107 answered.add(question.ref)
108 answers.push({ question, letter: letterAt(question.choices.indexOf(said)) })
109 }
110 for (const { end, quote } of quotes) {
111 const after = text.slice(end, quotes.find(next => next.at >= end)?.at)
112 const said = /^[ \t]*\n/.test(after) ? after.trim() : undefined
113 // The drawer that put this text in the prompt: the last to put this
114 // quote there with this answer or alone, through discuss, leaving out a
115 // question an earlier quote in this prompt answered.
116 const source = proposed.findLast(
117 one => one.quote === quote && (one.answer === said || one.answer === '') && !answered.has(one.ref),
118 )
119 if (source !== undefined) {
120 // A question resolved since its drawer put the quote there takes
121 // nothing, and the quote falls through to no other question.
122 const question = open.find(one => one.ref === source.ref)
123 if (question === undefined) continue
124 if (source.isStale !== true && said !== undefined && question.choices.includes(said)) {
125 answer(question, said)
126 } else if (isFree(source.ref)) {
127 replied.add(source.ref)
128 }
129 continue
130 }
131 const offering = open.filter(
132 question =>
133 quoteOf(question) === quote && isOpen(question.ref) && said !== undefined && question.choices.includes(said),
134 )
135 if (offering[0] !== undefined && said !== undefined) {
136 answer(offering[0], said)
137 continue
138 }
139 const intended = proposed.findLast(one => one.quote === quote && isFree(one.ref))
140 const replying = intended?.ref ?? open.find(one => quoteOf(one) === quote && isFree(one.ref))?.ref
141 if (replying !== undefined) replied.add(replying)
142 }
143 return { answers, replies: open.filter(question => replied.has(question.ref) && !answered.has(question.ref)) }
144}
145
146/** Names the subagent that asked, by its description and id when the session still lists it. */
147function whoAsked(question: Question, agents: readonly AgentInfo[]): string {
148 const agent = agents.find(one => one.id === question.agentId)
149 return agent === undefined ? (question.agentId ?? '') : `${agent.description} (${agent.id})`
150}
151
152/** What the main loop reads beside a prompt that answers a subagent's question: whom to send it to. */
153export const relayNote = (question: Question, agents: readonly AgentInfo[]) =>
154 `The subagent ${whoAsked(question, agents)} asked ${quoteOf(question)}; send it the answer this prompt gives.`
155
156/** What the main loop reads beside a prompt that replies to a subagent's question: whom to send the reply to. */
157export const replyNote = (question: Question, agents: readonly AgentInfo[]) =>
158 `The subagent ${whoAsked(question, agents)} asked ${quoteOf(question)}; send it the reply this prompt gives, which leaves the question open.`
159
160/** The subagent that asked, while it can still read a message; undefined sends the answer to the main loop. */
161export function liveAgent(question: Question, agents: readonly AgentInfo[]): string | undefined {
162 const agent = agents.find(one => one.id === question.agentId)
163 return agent !== undefined && LIVE.has(agent.status) ? agent.id : undefined
164}
165
166/** What the main loop receives: the answer, and the subagent to relay it to when one asked. */
167export function forMainLoop(text: string, question: Question, agents: readonly AgentInfo[]): string {
168 if (question.agentId === undefined) return text
169 return `${text}\n\nThe subagent ${whoAsked(question, agents)} asked this, and the answer could not reach it; send it this answer, or act on it yourself.`
170}
171
172/** Where a session's items live: `project inbox`'s store, found by the CLI's own rule. */
173export function storeOf(env: Env, session: string): string | undefined {
174 const home = env('HOME')
175 const base = env('XDG_STATE_HOME') ?? (home === undefined ? undefined : `${home}/.local/state`)
176 return base === undefined ? undefined : `${base}/projector/inbox/${session}`
177}
178
179/**
180 * What the store's directory holds: each item file's name, size and
181 * time, leaving out the lock and any file being written. A file whose claim
182 * the panel itself renews counts by name alone, so the panel's own renewal
183 * never makes it read the store again.
184 */
185export function signatureOf(entries: readonly FsEntry[], renewing: ReadonlySet<string>): string {
186 return entries
187 .filter(entry => entry.kind === 'file' && !entry.name.startsWith('.'))
188 .map(entry => (renewing.has(refOf(entry.name)) ? entry.name : `${entry.name} ${entry.size} ${entry.mtimeMs}`))
189 .sort()
190 .join('\n')
191}
192
193/** The reference an item file is named for, the CLI having quoted it; empty for any other file. */
194function refOf(name: string): string {
195 if (!name.endsWith('.json')) return ''
196 try {
197 return decodeURIComponent(name.slice(0, -'.json'.length))
198 } catch {
199 return ''
200 }
201}
202
203/** A short age: `40s`, `5m`, `2h`, `3d`. */
204export const span = (seconds: number) =>
205 seconds < 60 ? `${seconds}s` : seconds < 3600 ? `${Math.floor(seconds / 60)}m`
206 : seconds < 86_400 ? `${Math.floor(seconds / 3600)}h` : `${Math.floor(seconds / 86_400)}d`
207
208const UNSEEN_NUMBER =
209 'The panel never shows an item’s number, so name the item to the person by what it asks or tells, never by its number.'
210
211/** A tool's description: its own lines, then the sentence every inbox tool ends with about an item's number. */
212const described = (lines: readonly string[]) => [...lines, UNSEEN_NUMBER].join(' ')
213
214export const ASK_DESCRIPTION = described([
215 'Ask the person a question without stopping: it appears in Projector’s panel above their prompt, and you keep working.',
216 'Ask in one short line of at most 120 characters, and put what they need to decide in `detail`.',
217 'Always recommend an answer, and write it and each option as a full answer they can pick as it stands.',
218 'Name the pull request the question is about in `pr`, as owner/repo#number.',
219 'Keep working on anything the answer does not block. The call returns the question’s number, such as 3.',
220 'When the person picks an option, a message that starts `ANSWER 3.<letter>` arrives later, or `DISMISSED 3` when they dismiss it.',
221 'When they restore a question after you heard `DISMISSED 3`, `RESTORED 3` arrives, and the question is open again.',
222 'When they reply in their own words instead, the reply arrives as their prompt, quoting the question, and the question stays open:',
223 'answer what they asked, then withdraw the question if the reply settles it, revise it if it was unclear or the choice has changed,',
224 'or leave it open while it still needs them.',
225])
226
227export const ASK_SCHEMA = {
228 type: 'object',
229 properties: {
230 question: { type: 'string', description: 'The question, in one line of at most 120 characters.' },
231 recommendation: { type: 'string', description: 'The answer you recommend, written in full; it is answer a.' },
232 options: {
233 type: 'array',
234 items: { type: 'string' },
235 maxItems: 5,
236 description: 'Up to five other answers, each written in full, lettered b, c, and on in this order.',
237 },
238 detail: {
239 type: 'string',
240 description: 'What the person needs to decide, as Markdown; leave it out when the question says it all.',
241 },
242 pr: { type: 'string', description: 'The pull request the question is about, as owner/repo#number.' },
243 },
244 required: ['question', 'recommendation'],
245 additionalProperties: false,
246}
247
248const isText = (value: unknown): value is string => typeof value === 'string'
249
250/**
251 * The arguments of `project inbox ask` for one call of the tool, or why
252 * the call cannot be one. Each value rides in its flag's `=` form and the
253 * question after `--`, so text that starts with `-` stays text.
254 */
255export function askArguments(
256 input: Readonly<Record<string, unknown>>,
257 session: string,
258 agentId: string | undefined,
259): string[] | { error: string } {
260 const { question, recommendation, options = [], detail, pr } = input
261 if (!isText(question)) return { error: '`question` must be a string' }
262 if (!isText(recommendation)) return { error: '`recommendation` must be a string' }
263 if (!Array.isArray(options) || !options.every(isText)) return { error: '`options` must be a list of strings' }
264 if (detail !== undefined && !isText(detail)) return { error: '`detail` must be a string' }
265 if (pr !== undefined && !isText(pr)) return { error: '`pr` must be a string such as owner/repo#123' }
266 return [
267 'inbox',
268 'ask',
269 `--session=${session}`,
270 `--recommend=${recommendation}`,
271 ...options.map(option => `--option=${option}`),
272 ...postedFlags(detail, pr, agentId),
273 '--',
274 question,
275 ]
276}
277
278export const TELL_DESCRIPTION = described([
279 'Tell the person something without stopping: it appears in Projector’s panel above their prompt until they dismiss it.',
280 'Use it for the answer to a question they asked you, or for something you did that they should know about.',
281 'Write one short line of at most 120 characters, and put the rest in `detail`.',
282 'Name the pull request it is about in `pr`, as owner/repo#number.',
283 'Nothing comes back to you; when you need the person to decide something, use `ask` instead.',
284])
285
286export const TELL_SCHEMA = {
287 type: 'object',
288 properties: {
289 notice: { type: 'string', description: 'What to tell the person, in one line of at most 120 characters.' },
290 detail: {
291 type: 'string',
292 description: 'The rest of what they should read, as Markdown; leave it out when the line says it all.',
293 },
294 pr: { type: 'string', description: 'The pull request the notice is about, as owner/repo#number.' },
295 },
296 required: ['notice'],
297 additionalProperties: false,
298}
299
300/** The arguments of `project inbox tell` for one call of the tool, or why the call cannot be one. */
301export function tellArguments(
302 input: Readonly<Record<string, unknown>>,
303 session: string,
304 agentId: string | undefined,
305): string[] | { error: string } {
306 const { notice, detail, pr } = input
307 if (!isText(notice)) return { error: '`notice` must be a string' }
308 if (detail !== undefined && !isText(detail)) return { error: '`detail` must be a string' }
309 if (pr !== undefined && !isText(pr)) return { error: '`pr` must be a string such as owner/repo#123' }
310 return ['inbox', 'tell', `--session=${session}`, ...postedFlags(detail, pr, agentId), '--', notice]
311}
312
313export const WITHDRAW_DESCRIPTION = described([
314 'Withdraw a question or notice you posted that no longer needs the person,',
315 'such as a question their reply settled or news that has gone stale.',
316 'Name it by the number `ask` or `tell` returned. It leaves Projector’s panel at once.',
317 'Nothing comes back to you: a withdrawn question sends no `DISMISSED` message.',
318])
319
320/** The schema of a tool that takes one item, by the number `ask` or `tell` returned. */
321const ITEM_SCHEMA = {
322 type: 'object',
323 properties: {
324 number: {
325 type: 'integer',
326 minimum: 1,
327 description: 'The item’s number, as `ask` or `tell` returned it, such as 3.',
328 },
329 },
330 required: ['number'],
331 additionalProperties: false,
332}
333
334export const WITHDRAW_SCHEMA = ITEM_SCHEMA
335
336/** Whether a tool's `number` names an item: a whole number from 1. */
337const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 1
338
339/** The arguments of a `project inbox` command on one item for one call of its tool, or why the call cannot be one. */
340const itemArguments =
341 (command: 'withdraw' | 'restore') =>
342 (input: Readonly<Record<string, unknown>>, session: string): string[] | { error: string } => {
343 const { number } = input
344 if (!isNumber(number)) return { error: '`number` must be the whole number `ask` or `tell` returned, such as 3' }
345 return ['inbox', command, String(number), '--session', session, '--json']
346 }
347
348/** The arguments of `project inbox withdraw` for one call of the tool, or why the call cannot be one. */
349export const withdrawArguments = itemArguments('withdraw')
350
351export const RESTORE_DESCRIPTION = described([
352 'Restore a question or notice you posted that the person dismissed or snoozed: it reopens in Projector’s panel',
353 'under the same number, with the same text. Use it only when the person says a dismissal was a mistake or asks',
354 'for the item back, never on your own judgement, and never ask a dismissed question again as a new one.',
355 'Name it by the number `ask` or `tell` returned. A restored question’s answer arrives as before, as a message',
356 'that starts `ANSWER 3.<letter>` or `DISMISSED 3`.',
357])
358
359export const RESTORE_SCHEMA = ITEM_SCHEMA
360
361/** The arguments of `project inbox restore` for one call of the tool, or why the call cannot be one. */
362export const restoreArguments = itemArguments('restore')
363
364export const REVISE_DESCRIPTION = described([
365 'Revise a question you asked that is still open, keeping its number, when the person’s reply shows it was unclear',
366 'or the choice has changed. Name it by the number `ask` returned, and give only what changes:',
367 'the question, its detail, the recommendation, or the other options, which `options` replaces as a whole.',
368 'Projector’s panel redraws it at once, and its answer still arrives as a message that starts',
369 '`ANSWER 3.<letter>` or `DISMISSED 3`.',
370])
371
372export const REVISE_SCHEMA = {
373 type: 'object',
374 properties: {
375 number: { type: 'integer', minimum: 1, description: 'The question’s number, as `ask` returned it, such as 3.' },
376 question: { type: 'string', description: 'The new question, in one line of at most 120 characters.' },
377 recommendation: {
378 type: 'string',
379 description: 'The new answer you recommend, written in full; it is answer a.',
380 },
381 options: {
382 type: 'array',
383 items: { type: 'string' },
384 maxItems: 5,
385 description:
386 'Every other answer, up to five, each written in full and lettered b, c, and on in this order; an empty list leaves only the recommendation.',
387 },
388 detail: {
389 type: 'string',
390 description: 'The new detail, as Markdown; an empty string clears it.',
391 },
392 },
393 required: ['number'],
394 additionalProperties: false,
395}
396
397/**
398 * The arguments of `project inbox revise` for one call of the tool, or why
399 * the call cannot be one. Each value rides in its flag's `=` form, so text
400 * that starts with `-` stays text, and an empty `detail` clears the detail.
401 */
402export function reviseArguments(
403 input: Readonly<Record<string, unknown>>,
404 session: string,
405): string[] | { error: string } {
406 const { number, question, recommendation, options, detail } = input
407 if (!isNumber(number)) return { error: '`number` must be the whole number `ask` returned, such as 3' }
408 if (question !== undefined && !isText(question)) return { error: '`question` must be a string' }
409 if (recommendation !== undefined && !isText(recommendation)) return { error: '`recommendation` must be a string' }
410 if (options !== undefined && (!Array.isArray(options) || !options.every(isText))) {
411 return { error: '`options` must be a list of strings' }
412 }
413 if (detail !== undefined && !isText(detail)) return { error: '`detail` must be a string' }
414 if ([question, recommendation, options, detail].every(value => value === undefined)) {
415 return { error: 'give what changes: `question`, `detail`, `recommendation`, or `options`' }
416 }
417 return [
418 'inbox',
419 'revise',
420 String(number),
421 '--session',
422 session,
423 ...(question === undefined ? [] : [`--question=${question}`]),
424 ...(recommendation === undefined ? [] : [`--recommend=${recommendation}`]),
425 ...(options === undefined ? [] : options.length === 0 ? ['--no-options'] : options.map(option => `--option=${option}`)),
426 ...(detail === undefined ? [] : [`--detail=${detail}`]),
427 '--json',
428 ]
429}
430
431/** The flags both posting tools pass when they have a value: the detail, the pull request, and the subagent. */
432const postedFlags = (detail: string | undefined, pr: string | undefined, agentId: string | undefined) => [
433 ...(detail === undefined || detail === '' ? [] : [`--detail=${detail}`]),
434 ...(pr === undefined || pr === '' ? [] : [`--pr=${pr}`]),
435 ...(agentId === undefined ? [] : [`--agent=${agentId}`]),
436]
437hooks/types.d.ts 133 lines1/** What a loop does with its pull requests: `watch-threads.sh` watches, `watch-prs.sh` reviews. */
2export type LoopKind = 'watching' | 'reviewing'
3
4/** A tracked file this session started a watcher on. */
5export type SeenLoop = { kind: LoopKind; tracked: string }
6
7export type Checks = 'passing' | 'failing' | 'pending'
8
9export type PullRequest = {
10 repo: string
11 number: number
12 title: string
13 url: string
14 state: 'OPEN' | 'MERGED' | 'CLOSED'
15 isDraft: boolean
16 reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null
17 isConflicting: boolean
18 openThreads: number
19 author: string | null
20 branch: string
21 additions: number
22 deletions: number
23 changedFiles: number
24 /** The head commit's combined check state; null when it has no checks. */
25 checks: Checks | null
26 /** When the pull request last changed, in milliseconds; null when GitHub did not say. */
27 updatedAt: number | null
28 /** The Projector summary the description links, when one was published. */
29 summaryUrl: string | null
30}
31
32/** One line of the panel: an open pull request and what the loops do with it. */
33export type Row = { pr: PullRequest; kinds: LoopKind[] }
34
35export type Snapshot = {
36 rows: Row[]
37 /** The login of the account the panel's `gh` is signed in as; null until GitHub says. */
38 viewer: string | null
39 fetchedAt: number | null
40 error: string | null
41}
42
43/** A dismissal, `withdrawn` when the agent that posted the item took it back. */
44export type Dismissal = { dismissed: true; withdrawn?: true; answeredAt: number }
45
46/** How an item was resolved: dismissed, or a question answered with a lettered choice or typed text. */
47export type Answer = { letter: string; text: string; answeredAt: number } | Dismissal
48
49/** What every item has, as `project inbox list --json` gives it, with the session whose store holds it. */
50export type Posted = {
51 /** The item's number in its session, as a string such as `"3"`: what the CLI prints and takes. */
52 ref: string
53 number: number
54 /** The pull request it is about, as `owner/repo#number`; null when none. */
55 pr: string | null
56 /** When the agent posted it, in milliseconds. */
57 postedAt: number
58 /** The question or the notice, in one line. */
59 subject: string
60 detail: string
61 /** The subagent that posted it. */
62 agentId?: string
63 /** When a snoozed item returns, in milliseconds; absent when it is not snoozed. */
64 snoozedUntil?: number
65 /** When the item was last snoozed, in milliseconds; absent when it is not snoozed. */
66 snoozedAt?: number
67 deliveredAt?: number
68 session: string
69}
70
71/** A question, whose answer or dismissal goes back to the agent that asked. */
72export type Question = Posted & {
73 kind: 'question'
74 /** The answers in letter order, the recommendation first; the letter after the last is a typed answer. */
75 choices: string[]
76 answer?: Answer
77 claim?: { token: string; renewedAt: number }
78 /** When the person restored the question after its agent may have heard `DISMISSED`, until `RESTORED` is delivered. */
79 restoredAt?: number
80}
81
82/** A notice, which you read and dismiss, and which sends nothing back; dismissing it delivers it. */
83export type Notice = Posted & {
84 kind: 'notice'
85 answer?: Dismissal
86}
87
88export type Item = Question | Notice
89
90/**
91 * An answer the panel claimed and is delivering: the claim's token, the route
92 * it took, and the text sent, so a later copy of the module keeps renewing
93 * the claim and knows the prompt when it arrives.
94 */
95export type Sending = {
96 ref: string
97 session: string
98 token: string
99 route: 'send' | 'submit'
100 text: string
101}
102
103declare module 'claude-code' {
104 interface PluginState {
105 projector: {
106 seenLoops: SeenLoop[]
107 panelSnapshot: Snapshot
108 panelNow: number
109 isPanelShown: boolean
110 /** The drawer that is open: a pull request's by `keyOf`, or `item:` and an item's reference. */
111 panelDrawer: string | null
112 /**
113 * What an open drawer asks before it acts, in its actions' place: how
114 * long to snooze, or whether to dismiss. `drawer` is the key
115 * `panelDrawer` holds.
116 */
117 panelAsked: { drawer: string; about: 'snooze' | 'dismiss' } | null
118 /** The session's inbox, as the panel last read it. */
119 panelInbox: Item[]
120 /** The answers the panel is delivering, by reference. */
121 panelSending: Record<string, Sending>
122 /** What the release before the rename kept as `panelSending`, which the panel takes over. */
123 bandSending: Record<string, Sending>
124 /** The panel's background for the session's Claude Code theme, which `panel.background = "auto"` takes. */
125 panelThemeBackground: string
126 /** `panel.background` as the panel last read it: `auto`, or the color it draws for the value. */
127 panelBackground: string
128 /** Whether the panel took the keyboard since the last keystroke in the prompt, so its hint names its own keys. */
129 isPanelFocused: boolean
130 }
131 }
132}
133