SLOPSHOPPER

Projector

Git-native project plans and agent workflows for any repository

newbandguardcommandtoastprompt
★ 3v0.8.0MITupdated 2026-10-14ninjudd/projector
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · projector
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /projector ⎿ projector: Showing Projector’s panel above the prompt. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Projector

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.

Install

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.

Get started

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.

Work with coding agents

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.

Publish a site

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.

Use the CLI

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.

Develop Projector

See Develop Projector for running the CLI from a clone, trying unreleased skills, the validation gate, and releases.

Source 5 files
hooks/register.tsx 1693 lines
1import { 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 lines
1import 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}
148
hooks/parse.ts 224 lines
1import 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}
224
hooks/inbox.ts 437 lines
1import 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]
437
hooks/types.d.ts 133 lines
1/** 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