SLOPSHOPPER

dooryard

While your agent works, see who else is waiting and one card from one of them. Say hi with one key. Adults only.

newpanebandcommandtoaststatus
v0.1.1MITupdated 2026-10-24stanhoody/dooryard/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dooryard
│ ┃ dooryard ✕ › fix the failing auth test and add an audit log call │ ┃ cards show while your agent works │ ● dooryard: dooryard: fake backend │ ⏺ 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 │ │ › /dooryard │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · dooryard
cards show while your agent works
README

Dooryard

Dooryard. See who else is waiting on their agent, and one card from one of them. Say hi with one key. Adults only. Sends to Dooryard's domain: your handle, a heartbeat at turn start and end, your card, hellos and replies, and a photo only if you post one. Reads no prompts, transcripts, environment or files except that photo.

Dooryard is a Claude Code mod. It runs inside Claude Code, unsandboxed, with your permissions, as every mod does. This file names everything it runs, sends and reads, so you can check it before you install.

Status: pre-release. The mod's public repository is https://github.com/stanhoody/dooryard. Dooryard's server is not online yet. The mod connects to the server only when Backend is set to live; the default is an in-memory demo with no network calls. Photo cards are built and stay off until the server's age check says pass.

Install

One row, in a Claude Code session (v2.1.275 or later):

/plugin install dooryard --marketplace stanhoody/dooryard

From your shell (v2.1.292 or later):

claude plugin install dooryard --marketplace stanhoody/dooryard

On older versions, two commands: /plugin marketplace add stanhoody/dooryard, then /plugin install dooryard@dooryard. In the desktop app: the + button next to the prompt, Add plugins, then search for Dooryard. Then run /dooryard on, sign in on the page the pane links to, and you are in.

What runs

  • One hooks module, plugin/hooks/register.tsx, loaded by Claude Code's mods API. No MCP server, no shell commands, no package launchers, no postinstall.
  • Hooks it registers: session start, the start and end of each turn, its own command, and the drawing and closing of its own band and pane. It passes every event through untouched.
  • No hook on prompts, tool calls or the transcript. Its code calls no $.env, $.process, $.fs.write or $.model.
  • Two settings in /plugin: Backend, fake (the default: demo cards kept in memory, no network) or live; and API URL, the one https host the mod talks to when Backend is live.
  • Surfaces: the band above the prompt in the terminal and the desktop app, the status line in VS Code, the pane on all three. Nothing draws in claude -p, cloud sessions or Desktop WSL sessions.

What claude plugin validate --strict ./plugin prints for the module, verbatim, Claude Code 2.1.299, commit bb9e058 (0.1.1):

❯ ./register.tsx hooks: session.start, command.run{command=dooryard}, turn.start, turn.complete, ui.close{id=yard}, ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=yard}
❯ ./register.tsx answers its own command: command.run{command=dooryard}
❯ ./register.tsx gating hook without .catch: ui.close{id=yard}
❯ ./register.tsx calls: $.clock.after, $.clock.every, $.clock.now, $.command.register, $.fs.read (via uploadPhoto), $.http.fetch, $.session.surfaces, $.session.version (via startSignIn), $.state.get, $.state.set, $.store.delete (via signOut, signedOut), $.store.get, $.store.keys (via signOut), $.store.set (via blockHandle, pollPairing, postCard, runCommand), $.ui.close (via finish, openBand), $.ui.log, $.ui.open (via openBand, openPane), $.ui.resolve, $.ui.status (via refreshStatus), $.ui.toast
❯ ./register.tsx state writes: dooryard.ageGateUrl, dooryard.card, dooryard.countdown, dooryard.hello, dooryard.hidden, dooryard.messages, dooryard.mode, dooryard.notices, dooryard.nudge, dooryard.pairing, dooryard.phase, dooryard.photo, dooryard.photoPath, dooryard.problem, dooryard.server, dooryard.thread
❯ ./register.tsx state reads: dooryard.ageGateUrl, dooryard.card, dooryard.countdown, dooryard.hello, dooryard.hidden, dooryard.messages, dooryard.mode, dooryard.notices, dooryard.nudge, dooryard.pairing, dooryard.phase, dooryard.photo, dooryard.photoPath, dooryard.problem, dooryard.server, dooryard.thread

Run the command yourself on the commit you install and compare. $.http.fetch goes only to the API URL. $.fs.read reads only the photo you choose to post. $.session.version reads the Claude Code version string, sent once when you pair. $.session.surfaces, $.state.* and $.store.* stay on your machine.

What it sends, and to whom

With Backend set to live, every network call goes to https://<app domain> and nowhere else. Text JSON only. Polling runs only while a turn of your agent runs. Outside a turn, the mod calls Dooryard only at session start, to fetch the interface text, while the pane shows a sign-in code, and when you act in the pane.

This is the complete list of fields the mod sends:

WhenWhat
Every call except pairing and the interface textYour session token
Session startNothing but the request for the interface text
You pair the mod (/dooryard on, first time)The surface (terminal, desktop or VS Code) and the Claude Code version string; then a secret only the mod knows, until you approve the device on the sign-in page
A turn of your agent startsThe surface. Never the content of the turn
While a turn runs, every 5 secondsThe turn's id, to get the waiting count and your dealt card
A turn endsThe turn's id
You post a cardThe card text (up to 80 characters)
You press hiThe card's id. A hello carries no text
You write in a threadThe text you typed, and whether you confirmed it after a "hold on" check
You blockThe other person's handle
You reportThe card or thread id, and the category
You post a photoThe image from the one path you typed, its type, and your confirmation that photos are permanent. Dooryard strips its metadata on the server

Your handle is the name you choose at sign-in. Dooryard keeps your GitHub id on its server for sign-in and bans, and never shows it to anyone.

Dooryard never asks for Claude or Anthropic credentials and never calls $.session.identity().

What it reads on your machine

  • One file, once: the photo at the path you type when you post a photo card. That is the only $.fs.read.
  • Its own plugin store: your on/off switch, your local blocklist and, once you sign in, your session token.
  • Which Claude Code surfaces are open ($.session.surfaces), to draw the band or the status line in the right place.
  • The Claude Code version string ($.session.version), sent once when you pair.
  • Nothing else. No prompts, no transcript, no other files, no environment variables, no settings beyond its own two.

Dependencies

None. plugin/package.json declares no dependencies and holds only the test script; there is no lockfile in plugin/, so nothing is installed with the mod (checked at commit bb9e058). No package launcher (npx, uvx) runs at install or at runtime.

Safety, and what Dooryard cannot promise

Every rule runs on Dooryard's server. The mod on your machine only shows them, because anyone can edit a mod they run.

  • Adults only. Everyone confirms they are 18 or older at sign-in. Photo cards need a one-time age check at <age-check vendor>. Dooryard keeps pass or fail, never the images.
  • Hello first. A hello is one key and carries no text. A thread opens only when both people said hi. One hello per card, one per person per 24 hours. Unopened hellos expire after 24 hours.
  • What is not allowed. Nothing sexual, hateful or threatening, in cards or threads. No links, selling or recruiting in cards. Full rules: https://&lt;app domain>/rules.
  • Filter and review. A word filter hides some messages in a folder you open on purpose, and holds others for a human. A card or thread reported by two people comes down until a human looks. A human looks at every report within 24 hours.
  • Block and leave. b blocks a person on both sides at once. x ends a thread and blocks.
  • Photos. Checked for nudity on upload. Location data stripped. Hosted by Dooryard.

What Dooryard cannot promise:

  • Photos are permanent. Anyone who sees your card can save it. Dooryard cannot recall copies on other machines.
  • Threads are not private from other mods. Claude Code runs mods in a chain. Any mod that runs before Dooryard, on your machine or on the other person's, can read and change what Dooryard sends and receives, message text included. That covers your organization's mods and other mods you installed.
  • Your organization can turn mods off. If it does, Dooryard stays off. We will not help anyone get around it.

Intimate image of you posted without consent? Report it at https://&lt;app domain>/takedown. We remove it within 48 hours of a valid request.

Dooryard is not made or endorsed by Anthropic.

Privacy

Privacy policy: https://<app domain>/privacy.

Report a vulnerability

Write to security@<app domain>. Please do not open a public issue for a security problem. We acknowledge within 2 business days.

License

MIT. See LICENSE.

Repository layout

  • plugin/: the mod (manifest, hooks module, types, tests).
  • apps/web/: the web app and API.
  • contracts/: the API contract and the data map.
Source 8 files
hooks/register.tsx 1150 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
3
4import type { YardMessage, YardPaneMode, YardReportCategory, YardServerState, YardThread } from '../types'
5import { bandLead, STATUS_MAX } from './band'
6import { createBackend } from './backend'
7import type { Backend, Surface } from './backend'
8import { copy, copyOn, errorLine, isCopyKey } from './copy'
9import { MAX_PHOTO_BASE64, photoBox, photoMime } from './photo'
10
11// Dooryard (codename yard): a band above the prompt while the agent works. The mod handles only
12// turn.start, turn.complete, interface events and its own command; it reads
13// no prompt, transcript, environment or file. Every rule lives on the server.
14
15/** The band opens 2 seconds into a turn and hands back 3 seconds after it (intermission). */
16const OPEN_AFTER_MS = 2000
17const HANDBACK_SECONDS = 3
18const PANE = 'yard'
19/** The rows the pane asks for while it sits inline above the prompt. */
20const PANE_ROWS = 9
21/** A failed turn/start is tried again this often while the agent works. */
22const RETRY_MS = 5000
23const CATEGORIES: readonly YardReportCategory[] = ['spam', 'harassment', 'sexual', 'hate', 'other']
24
25const phase = atom({ plugin: 'dooryard', key: 'phase' } as const, 'idle')
26const countdown = atom({ plugin: 'dooryard', key: 'countdown' } as const, 0)
27const server = atom({ plugin: 'dooryard', key: 'server' } as const, null)
28const card = atom({ plugin: 'dooryard', key: 'card' } as const, null)
29const problem = atom({ plugin: 'dooryard', key: 'problem' } as const, null)
30const mode = atom({ plugin: 'dooryard', key: 'mode' } as const, 'card')
31const thread = atom({ plugin: 'dooryard', key: 'thread' } as const, null)
32const messages = atom({ plugin: 'dooryard', key: 'messages' } as const, [])
33const hidden = atom({ plugin: 'dooryard', key: 'hidden' } as const, 0)
34const hello = atom({ plugin: 'dooryard', key: 'hello' } as const, null)
35const nudge = atom({ plugin: 'dooryard', key: 'nudge' } as const, null)
36const pairing = atom({ plugin: 'dooryard', key: 'pairing' } as const, null)
37const photo = atom({ plugin: 'dooryard', key: 'photo' } as const, null)
38const photoPath = atom({ plugin: 'dooryard', key: 'photoPath' } as const, null)
39const ageGateUrl = atom({ plugin: 'dooryard', key: 'ageGateUrl' } as const, null)
40const notices = atom({ plugin: 'dooryard', key: 'notices' } as const, [])
41
42// The module's own variables: a reload starts them over, as it drops the timers.
43let options: PluginOptions = {}
44let backend: Backend | undefined
45let configProblem: string | null = null
46let surfaces: readonly string[] = []
47let isEnabled = true
48let isWorking = false
49/** Bumped at every turn start and end, so late answers of an old turn are dropped. */
50let generation = 0
51let turnId: string | null = null
52let openTimer: Timer | undefined
53let pollTimer: Timer | undefined
54let tickTimer: Timer | undefined
55let isPolling = false
56let isStarting = false
57let isOpeningHello = false
58let lastStatus: string | undefined
59/** What the last drawing said: does this surface dock a pane beside the transcript? */
60let isFullscreen: boolean | undefined
61/** The person closed the pane by hand (ctrl+x x): no more opening it unasked this session. */
62let closedForSession = false
63let seenPostRules = false
64let blocked: readonly string[] = []
65/** What the report picker reports: the card shown, or the open thread. */
66let reportTarget: 'card' | 'thread' = 'card'
67/** The newest message fetched per thread: the `after` of the next fetch. */
68const lastSeen = new Map<string, string>()
69const helloInFlight = new Set<string>()
70/** api.md §3: the pairing's secret stays in the mod, in memory, never shown or stored. */
71let deviceSecret: string | undefined
72let pairTimer: Timer | undefined
73let isPairPolling = false
74let pairRestarts = 0
75/** My card in this turn, once posted: the only card a photo can go on. */
76let myCardId: string | null = null
77/** Who runs the age check, as the server's `age_gate_required` names it (api.md 1.2). */
78let ageGateVendor = ''
79
80/** The surfaces that draw Yard: the band, the status line or the pane. */
81function isDrawable(name: string): name is Surface {
82  return name === 'terminal' || name === 'desktop' || name === 'vscode'
83}
84
85/** Toasts reach every attached surface: hints go only where some surface draws hotkeys. */
86function toastSurface(): string {
87  const drawing = surfaces.filter(isDrawable)
88  return drawing.length > 0 && drawing.every(name => name === 'vscode') ? 'vscode' : 'terminal'
89}
90
91function surface(): Surface {
92  if (surfaces.includes('terminal')) return 'terminal'
93  if (surfaces.includes('desktop')) return 'desktop'
94  return 'vscode'
95}
96
97/** Desktop and VS Code draw a Link only for https or localhost with no `@` (mods interface docs). */
98function isLinkable(url: string): boolean {
99  return (url.startsWith('https://') || url.startsWith('http://localhost')) && !url.includes('@') && url.length <= 2048
100}
101
102/** The appeal page (api.md 1.3): on Dooryard's own host; the fake's example host for the demo. */
103function appealUrl(): string {
104  const base = typeof options.api_url === 'string' ? options.api_url.replace(/\/+$/, '') : ''
105  return backend?.kind === 'live' && base !== '' ? `${base}/appeal` : 'https://example.com/appeal'
106}
107
108function isCategory(value: string): value is YardReportCategory {
109  return (CATEGORIES as readonly string[]).includes(value)
110}
111
112async function leadText($: EngineInterface, withCard = true) {
113  return bandLead({
114    phase: await read($, phase),
115    countdown: await read($, countdown),
116    server: await read($, server),
117    card: await read($, card),
118    problem: await read($, problem),
119    withCard,
120  })
121}
122
123/** VS Code has no band: the same line rides the status line there, without hotkeys. */
124async function refreshStatus($: EngineInterface) {
125  const text = surfaces.includes('vscode') ? (await leadText($))?.slice(0, STATUS_MAX) : undefined
126  if (text === lastStatus) return
127  lastStatus = text
128  $.ui.status(text)
129}
130
131async function finish($: EngineInterface) {
132  tickTimer?.cancel()
133  await update($, phase, () => 'idle')
134  await update($, countdown, () => 0)
135  await update($, server, () => null)
136  await update($, card, () => null)
137  await update($, problem, now => (now === copy('yard.signed_out') ? now : configProblem))
138  await update($, hello, () => null)
139  await update($, nudge, () => null)
140  await update($, photo, () => null)
141  await update($, photoPath, () => null)
142  await update($, ageGateUrl, () => null)
143  myCardId = null
144  await refreshStatus($)
145  // A sign-in in progress keeps its pane past the turn's end
146  if (deviceSecret !== undefined) return
147  await update($, mode, () => 'card')
148  await $.ui.close({ id: PANE })
149}
150
151/** Stops this turn's work at once: timers, the poll, the server's turn. */
152async function goQuiet($: EngineInterface) {
153  isWorking = false
154  generation += 1
155  openTimer?.cancel()
156  pollTimer?.cancel()
157  if (backend !== undefined && turnId !== null) void backend.turnEnd(turnId).catch(() => undefined)
158  turnId = null
159  await finish($)
160}
161
162/** api.md §1: a 401 clears the token; sign-in shows on the next `/dooryard` (E02-S5). */
163async function signedOut($: EngineInterface) {
164  await $.store.delete('token')
165  await update($, problem, () => copy('yard.signed_out'))
166  await refreshStatus($)
167}
168
169async function hasToken($: EngineInterface) {
170  const token = await $.store.get('token')
171  return typeof token === 'string' && token.length > 0
172}
173
174async function stopPairing($: EngineInterface) {
175  pairTimer?.cancel()
176  pairTimer = undefined
177  deviceSecret = undefined
178  await update($, pairing, () => null)
179}
180
181/** api.md §3: a code and an https link; the person signs in and attests 18+ on the web page. */
182async function startSignIn($: EngineInterface) {
183  if (backend === undefined) return
184  await stopPairing($)
185  surfaces = await $.session.surfaces()
186  const version = (await $.session.version()).version.slice(0, 32)
187  const answer = await backend.pairStart(surface(), version)
188  if (!answer.ok) {
189    $.ui.toast(errorLine(answer))
190    await update($, mode, now => (now === 'signin' ? 'card' : now))
191    return
192  }
193  const { deviceSecret: secret, pollMs, ...shown } = answer.value
194  deviceSecret = secret
195  await update($, pairing, () => shown)
196  await update($, mode, () => 'signin')
197  // Two sign-ins may overlap while the server is slow: only the last one polls
198  pairTimer?.cancel()
199  pairTimer = $.clock.every(pollMs, () => void pollPairing($))
200}
201
202/** The code expired (410, or past `expires_at`): a fresh one, twice at most, then stop. */
203async function pairingExpired($: EngineInterface) {
204  await stopPairing($)
205  if (pairRestarts < 2) {
206    pairRestarts += 1
207    await startSignIn($)
208  } else {
209    await update($, mode, now => (now === 'signin' ? 'card' : now))
210  }
211}
212
213async function pollPairing($: EngineInterface) {
214  const secret = deviceSecret
215  if (backend === undefined || secret === undefined || isPairPolling) return
216  isPairPolling = true
217  try {
218    const shown = await read($, pairing)
219    if (shown !== null && (await $.clock.now()) > Date.parse(shown.expiresAt)) {
220      await pairingExpired($)
221      return
222    }
223    const answer = await backend.pairPoll(secret)
224    // An answer for a code that is no longer the one shown changes nothing
225    if (deviceSecret !== secret) return
226    if (answer.ok && answer.value.status === 'approved') {
227      await $.store.set('token', answer.value.token)
228      await stopPairing($)
229      pairRestarts = 0
230      await update($, problem, () => configProblem)
231      await update($, mode, () => 'card')
232      $.ui.toast(copy('yard.signin.done', { handle: answer.value.handle }))
233      // Signed in mid-turn: the turn joins the yard now
234      if (isWorking && turnId === null) void beginTurn($, generation)
235      return
236    }
237    if (!answer.ok && answer.code === 'gone') await pairingExpired($)
238  } finally {
239    isPairPolling = false
240  }
241}
242
243/** api.md §3: revoke the token, then clear what this mod keeps on the machine. */
244async function signOut($: EngineInterface) {
245  // End the live turn while the token still works, so the card retires at once (api.md §4)
246  const live = turnId
247  turnId = null
248  if (backend !== undefined && live !== null) await backend.turnEnd(live).catch(() => undefined)
249  if (backend !== undefined && (await hasToken($))) await backend.signout().catch(() => undefined)
250  for (const key of await $.store.keys()) await $.store.delete(key)
251  blocked = []
252  seenPostRules = false
253  isEnabled = true
254  await stopPairing($)
255  await goQuiet($)
256  await update($, problem, () => (backend?.kind === 'live' ? copy('yard.signed_out') : configProblem))
257  return { text: copy('yard.signed_out') }
258}
259
260/**
261 * Opens the pane on the person's press or command: `$.ui.open` goes out first,
262 * inside their input, so the engine places it at any width (mods interface docs).
263 */
264async function openPane($: EngineInterface, next: YardPaneMode, wantsFocus: boolean) {
265  const pane = { id: PANE, title: 'dooryard', rows: PANE_ROWS }
266  const opening = $.ui.open(wantsFocus ? { ...pane, focus: true } : pane)
267  await update($, mode, () => next)
268  await opening
269}
270
271/** Deals until a card from someone not blocked on this machine comes, or none. */
272async function dealVisible($: EngineInterface) {
273  const mine = generation
274  if (backend === undefined || turnId === null) return
275  for (let tries = 0; tries < 3; tries += 1) {
276    const dealt = await backend.deal(turnId)
277    // A deal that lands after the turn ended stays out of the next one
278    if (mine !== generation || !dealt.ok) return
279    if (dealt.value === null || !blocked.includes(dealt.value.handle)) {
280      const value = dealt.value
281      await update($, card, () => value)
282      await update($, photo, () => null)
283      // Only the terminal draws an Image (mods reference, elements): fetch it only there
284      if (value?.hasPhoto === true && surfaces.includes('terminal')) void loadPhoto($, value.id)
285      await refreshStatus($)
286      return
287    }
288  }
289  await update($, card, () => null)
290  await refreshStatus($)
291}
292
293async function loadPhoto($: EngineInterface, cardId: string) {
294  const mine = generation
295  if (backend === undefined) return
296  const answer = await backend.photo(cardId)
297  if (mine !== generation || !answer.ok || (await read($, card))?.id !== cardId) return
298  const fetched = answer.value
299  await update($, photo, () => fetched)
300}
301
302/** The first Enter takes the path and shows `photo.permanent`; the second posts. */
303async function photoSubmit($: EngineInterface, value: string) {
304  const path = value.trim()
305  if (path.length === 0) return
306  if (photoMime(path) === undefined) {
307    $.ui.toast(copy('photo.placeholder'))
308    return
309  }
310  if ((await read($, photoPath)) !== path) {
311    await update($, photoPath, () => path)
312    return
313  }
314  await uploadPhoto($, path)
315}
316
317/** The one file the mod reads: the path the person typed, once (README, What it reads). */
318async function uploadPhoto($: EngineInterface, path: string) {
319  const mime = photoMime(path)
320  if (backend === undefined || myCardId === null || mime === undefined) return
321  let base64: string
322  try {
323    base64 = (await $.fs.read(path, { as: 'bytes' })).base64
324  } catch {
325    $.ui.toast(copy('photo.unreadable'))
326    return
327  }
328  if (base64.length > MAX_PHOTO_BASE64) {
329    $.ui.toast(copy('photo.too_big'))
330    return
331  }
332  const answer = await backend.postPhoto(myCardId, base64, mime)
333  if (answer.ok) {
334    $.ui.toast(answer.value.status === 'held' ? copy('photo.held') : copy('post.live'))
335    await update($, photoPath, () => null)
336    await update($, mode, () => 'card')
337    return
338  }
339  const url = answer.params?.url
340  if (answer.code === 'age_gate_required' && url !== undefined && isLinkable(url)) {
341    // api.md 1.2: the one-time check before a first photo; its page is the link
342    ageGateVendor = answer.params?.vendor ?? ''
343    await update($, ageGateUrl, () => url)
344    return
345  }
346  $.ui.toast(answer.code === 'too_large' ? copy('photo.too_big') : errorLine(answer))
347}
348
349async function addMessages($: EngineInterface, fresh: readonly YardMessage[]) {
350  await update($, messages, list => {
351    const known = new Set(list.map(one => one.id))
352    return [...list, ...fresh.filter(one => !known.has(one.id))].slice(-50)
353  })
354}
355
356/** The thread the pane shows, fresh: its own messages, hidden count and no nudge left over. */
357async function openThread($: EngineInterface, next: YardThread | null) {
358  await update($, thread, () => next)
359  await update($, messages, () => [])
360  await update($, hidden, () => 0)
361  await update($, nudge, () => null)
362}
363
364async function switchThread($: EngineInterface, threadId: string, handle: string) {
365  await openThread($, { id: threadId, handle, city: '', localTime: '' })
366  await update($, mode, () => 'reply')
367  await fetchMessages($, threadId, handle)
368}
369
370/** A 410 on a thread: the server's key says who ended it (api.md 1.1), `by_them` when it says none. */
371async function threadEnded($: EngineInterface, threadId: string, handle: string, copyKey?: string) {
372  if ((await read($, thread))?.id !== threadId) return
373  await openThread($, null)
374  await update($, mode, now => (now === 'reply' ? 'card' : now))
375  $.ui.toast(copy(isCopyKey(copyKey) ? copyKey : 'thread.ended.by_them', { handle }))
376}
377
378/** api.md §7: fetch a thread while the pane shows it, or once when it has unread. */
379async function fetchMessages($: EngineInterface, threadId: string, handle: string) {
380  if (backend === undefined) return
381  const answer = await backend.messages(threadId, lastSeen.get(threadId))
382  if (!answer.ok) {
383    if (answer.code === 'gone') await threadEnded($, threadId, handle, answer.copyKey)
384    return
385  }
386  const fresh = answer.value.messages
387  const newest = fresh.at(-1)
388  if (newest === undefined) return
389  lastSeen.set(threadId, newest.id)
390  if ((await read($, thread))?.id === threadId) {
391    await addMessages($, fresh)
392    await update($, hidden, () => answer.value.hidden)
393  }
394  const theirs = fresh.filter(one => one.from === 'them').at(-1)
395  // A DM arrival: a toast, 4 seconds by default (mods reference, limits)
396  if (theirs !== undefined) $.ui.toast(copy('dm.toast', { handle, preview: theirs.text.slice(0, 40) }))
397}
398
399/**
400 * api.md §7: a fetch marks messages read, so only the open thread is fetched;
401 * the card view lists the others with their unread count, and a press opens one.
402 */
403async function syncThreads($: EngineInterface, state: YardServerState) {
404  if (state.notices.length > 0) {
405    // Returned once by the server: kept until the pane has shown them and closed
406    const fresh = state.notices.filter(one => isCopyKey(one.copyKey))
407    await update($, notices, list => [...list, ...fresh].slice(-5))
408  }
409  let open = await read($, thread)
410  const first = state.threads.find(one => !blocked.includes(one.handle))
411  // A thread my earlier hi opened on their side shows up in the State first
412  if (open === null && first !== undefined) {
413    open = { id: first.id, handle: first.handle, city: '', localTime: '' }
414    await openThread($, open)
415  }
416  const listed = state.threads.find(one => one.id === open?.id)
417  if (listed === undefined) return
418  await update($, hidden, () => listed.hidden)
419  const isShowing = (await read($, mode)) === 'reply'
420  if (listed.unread > 0 || isShowing) await fetchMessages($, listed.id, listed.handle)
421}
422
423async function poll($: EngineInterface, mine: number) {
424  if (isPolling || backend === undefined || turnId === null || mine !== generation) return
425  isPolling = true
426  try {
427    const answer = await backend.state(turnId)
428    if (mine !== generation) return
429    if (answer.ok) {
430      const before = await read($, server)
431      if (JSON.stringify(before) !== JSON.stringify(answer.value)) {
432        await update($, server, () => answer.value)
433        await refreshStatus($)
434      }
435      await syncThreads($, answer.value)
436    } else if (answer.code === 'gone' && isWorking) {
437      // The server timed the turn out; the agent still works, so start again
438      pollTimer?.cancel()
439      turnId = null
440      void beginTurn($, mine)
441    }
442  } catch {
443    // Keep the last state drawn; the next period asks again
444  } finally {
445    isPolling = false
446  }
447}
448
449/** A failed turn/start leaves a retry running while the agent works; sign-in ends the need. */
450function armRetry($: EngineInterface, mine: number) {
451  pollTimer?.cancel()
452  pollTimer = $.clock.every(RETRY_MS, () => void retryTurn($, mine))
453}
454
455async function retryTurn($: EngineInterface, mine: number) {
456  if (mine !== generation || !isWorking || turnId !== null || isStarting) return
457  pollTimer?.cancel()
458  await beginTurn($, mine)
459}
460
461async function beginTurn($: EngineInterface, mine: number) {
462  if (backend === undefined || isStarting) return
463  isStarting = true
464  try {
465    const started = await backend.turnStart(surface())
466    if (mine !== generation) {
467      if (started.ok) void backend.turnEnd(started.value.turnId).catch(() => undefined)
468      return
469    }
470    if (!started.ok) {
471      const isSignedOut = started.code === 'unauthorized'
472      await update($, problem, () => (isSignedOut ? copy('yard.signed_out') : copy('error.unavailable')))
473      if (!isSignedOut) armRetry($, mine)
474      await refreshStatus($)
475      return
476    }
477    turnId = started.value.turnId
478    await update($, problem, now => (now === copy('error.unavailable') ? configProblem : now))
479    await update($, server, () => started.value.state)
480    await dealVisible($)
481    if (mine !== generation) return
482    pollTimer?.cancel()
483    pollTimer = $.clock.every(started.value.pollMs, () => void poll($, mine))
484    await syncThreads($, started.value.state)
485    await refreshStatus($)
486  } catch {
487    if (mine === generation) {
488      await update($, problem, () => copy('error.unavailable'))
489      armRetry($, mine)
490    }
491  } finally {
492    isStarting = false
493  }
494}
495
496async function openBand($: EngineInterface, mine: number) {
497  if (mine !== generation) return
498  await update($, phase, () => 'band')
499  await refreshStatus($)
500  // Unasked, a pane only as a sidebar: the engine places it from 144 columns,
501  // 110 once the person opened it; below that the band is the product
502  if (isFullscreen !== true || closedForSession) return
503  const opened = await $.ui.open({ id: PANE, title: 'dooryard', rows: PANE_ROWS })
504  if (!opened.isPlaced) await $.ui.close({ id: PANE })
505}
506
507async function tick($: EngineInterface, mine: number) {
508  if (mine !== generation) return
509  const left = (await read($, countdown)) - 1
510  // A queued prompt may have started a turn during that read
511  if (mine !== generation) return
512  if (left <= 0) {
513    await finish($)
514    return
515  }
516  await update($, countdown, () => left)
517  await refreshStatus($)
518}
519
520async function sayHi($: EngineInterface) {
521  const shown = await read($, card)
522  if (backend === undefined || turnId === null || shown === null || !shown.canHi) return
523  if (helloInFlight.has(shown.id)) return
524  helloInFlight.add(shown.id)
525  try {
526    const answer = await backend.hello(shown.id, turnId)
527    if (answer.ok || answer.code === 'already_said_hi') {
528      await update($, card, now => (now !== null && now.id === shown.id ? { ...now, canHi: false } : now))
529    }
530    if (!answer.ok) {
531      $.ui.toast(errorLine(answer, { handle: shown.handle }))
532      return
533    }
534    const opened = answer.value.thread
535    if (answer.value.mutual && opened !== undefined) {
536      await openThread($, opened)
537      $.ui.toast(copyOn(toastSurface(), 'hi.mutual', { handle: opened.handle }))
538    } else {
539      $.ui.toast(copyOn(toastSurface(), 'hi.sent', { handle: shown.handle }))
540    }
541  } finally {
542    helloInFlight.delete(shown.id)
543  }
544}
545
546/** api.md §6: the sender is revealed only here, one hello at a time. */
547async function openHello($: EngineInterface) {
548  // Each open uses up one hello on the server: never two at once, never over one shown
549  if (backend === undefined || isOpeningHello || (await read($, hello)) !== null) return
550  isOpeningHello = true
551  try {
552    const answer = await backend.helloOpen()
553    if (!answer.ok) {
554      $.ui.toast(errorLine(answer))
555      return
556    }
557    if (answer.value === null || blocked.includes(answer.value.handle)) return
558    const opened = answer.value
559    await update($, hello, () => opened)
560    await update($, mode, () => 'hello')
561    await update($, server, now => (now === null ? now : { ...now, hiWaiting: Math.max(0, now.hiWaiting - 1) }))
562  } finally {
563    isOpeningHello = false
564  }
565}
566
567async function hiBack($: EngineInterface) {
568  const opened = await read($, hello)
569  if (backend === undefined || opened === null || !opened.canHiBack) return
570  const answer = await backend.helloBack(opened.id)
571  await update($, hello, () => null)
572  if (!answer.ok) {
573    $.ui.toast(errorLine(answer, { handle: opened.handle }))
574    await update($, mode, () => 'card')
575    return
576  }
577  const fresh = answer.value
578  await openThread($, fresh)
579  await update($, mode, () => 'reply')
580  $.ui.toast(copyOn(toastSurface(), 'hi.mutual', { handle: fresh.handle }))
581}
582
583async function postCard($: EngineInterface, text: string) {
584  const line = text.trim()
585  if (line.length === 0) return
586  if (line.length > 80) {
587    $.ui.toast(copy('post.too_long'))
588    return
589  }
590  if (backend === undefined || turnId === null) {
591    $.ui.toast(copy('pane.idle'))
592    return
593  }
594  const answer = await backend.post(turnId, line)
595  if (!answer.ok) {
596    $.ui.toast(errorLine(answer))
597    return
598  }
599  myCardId = answer.value.id
600  if (!seenPostRules) {
601    seenPostRules = true
602    await $.store.set('seenPostRules', true)
603  }
604  $.ui.toast(copy('post.live'))
605  await update($, mode, () => 'card')
606}
607
608async function startReport($: EngineInterface, target: 'card' | 'thread') {
609  reportTarget = target
610  await update($, mode, () => 'report')
611}
612
613async function reportPicked($: EngineInterface, value: string) {
614  if (backend === undefined || !isCategory(value)) return
615  const back: YardPaneMode = reportTarget === 'thread' ? 'reply' : 'card'
616  const open = await read($, thread)
617  const shown = await read($, card)
618  const theirs = (await read($, messages)).filter(one => one.from === 'them').at(-1)
619  const answer =
620    reportTarget === 'thread' && open !== null
621      ? await backend.report(value, { threadId: open.id, ...(theirs === undefined ? {} : { messageId: theirs.id }) })
622      : shown !== null
623        ? await backend.report(value, { cardId: shown.id })
624        : undefined
625  if (answer?.ok === true) $.ui.toast(isCopyKey(answer.value.copyKey) ? copy(answer.value.copyKey) : copy('report.sent'))
626  else if (answer?.ok === false) $.ui.toast(errorLine(answer))
627  await update($, mode, () => back)
628}
629
630/** api.md §8: the local blocklist first, at once; the server's block is the rule. */
631async function blockHandle($: EngineInterface, handle: string, toast = copy('block.done', { handle })) {
632  blocked = [...new Set([...blocked, handle])]
633  await $.store.set('blocked', blocked)
634  await update($, card, now => (now?.handle === handle ? null : now))
635  await update($, hello, now => (now?.handle === handle ? null : now))
636  if ((await read($, thread))?.handle === handle) await openThread($, null)
637  await update($, mode, () => 'card')
638  $.ui.toast(toast)
639  if (backend !== undefined) void backend.block(handle).catch(() => undefined)
640  if ((await read($, card)) === null) await dealVisible($)
641}
642
643async function blockShown($: EngineInterface) {
644  const shown = await read($, card)
645  if (shown !== null) await blockHandle($, shown.handle)
646}
647
648async function blockHello($: EngineInterface) {
649  const opened = await read($, hello)
650  if (opened !== null) await blockHandle($, opened.handle)
651}
652
653async function blockThread($: EngineInterface) {
654  const open = await read($, thread)
655  if (open !== null) await blockHandle($, open.handle)
656}
657
658/** The x key: the server ends the thread for both and blocks; this machine blocks too. */
659async function endThread($: EngineInterface) {
660  const open = await read($, thread)
661  if (backend === undefined || open === null) return
662  const answer = await backend.end(open.id)
663  if (!answer.ok) {
664    $.ui.toast(errorLine(answer, { handle: open.handle }))
665    return
666  }
667  await blockHandle($, open.handle, copy('thread.ended.by_you', { handle: open.handle }))
668}
669
670async function showHidden($: EngineInterface) {
671  const open = await read($, thread)
672  if (backend === undefined || open === null) return
673  const answer = await backend.hidden(open.id)
674  if (!answer.ok) {
675    $.ui.toast(errorLine(answer, { handle: open.handle }))
676    return
677  }
678  await addMessages($, answer.value)
679  await update($, hidden, () => 0)
680}
681
682async function sendReply($: EngineInterface, text: string) {
683  const open = await read($, thread)
684  const line = text.trim()
685  if (backend === undefined || open === null || line.length === 0) return
686  if (line.length > 500) {
687    $.ui.toast(copy('dm.too_long'))
688    return
689  }
690  // api.md §7: a nudged message goes again with `confirm` on Enter
691  const held = await read($, nudge)
692  const answer = await backend.send(open.id, line, held !== null && held.text === line)
693  if (answer.ok) {
694    // `lastSeen` stays at the last fetch: their message sent meanwhile still comes
695    await update($, nudge, () => null)
696    await addMessages($, [answer.value])
697    return
698  }
699  if (answer.code === 'nudge') {
700    const key = isCopyKey(answer.copyKey) ? answer.copyKey : 'nudge.generic'
701    await update($, nudge, () => ({ text: line, copyKey: key }))
702    return
703  }
704  if (answer.code === 'gone') await threadEnded($, open.id, open.handle, answer.copyKey)
705  else $.ui.toast(errorLine(answer, { handle: open.handle }))
706}
707
708async function runCommand($: EngineInterface, args: string) {
709  const word = args.trim().toLowerCase()
710  if (word === 'on') {
711    isEnabled = true
712    await $.store.set('enabled', true)
713    return { text: copy('yard.on') }
714  }
715  if (word === 'off') {
716    isEnabled = false
717    await $.store.set('enabled', false)
718    await stopPairing($)
719    await goQuiet($)
720    return { text: copy('yard.off') }
721  }
722  if (word === 'signout') return signOut($)
723  if (word === 'appeal') {
724    // api.md 1.3: appeals are a web page, one per decision; the answer comes back as a notice
725    await openPane($, 'appeal', true)
726    return {}
727  }
728  if (word === 'signin') {
729    await openPane($, 'signin', true)
730    await startSignIn($)
731    return {}
732  }
733  if (word !== '') return { text: 'usage: /dooryard, /dooryard on, /dooryard off, /dooryard signin, /dooryard signout, /dooryard appeal' }
734  if (!isEnabled) return { text: copy('yard.off') }
735  if (backend?.kind === 'live' && !(await hasToken($))) {
736    await openPane($, 'signin', true)
737    await startSignIn($)
738    return {}
739  }
740  await openPane($, 'card', true)
741  return {}
742}
743
744export const register: Register = (on, given) => {
745  options = given
746
747  on('session.start', async ($, e, next) => {
748    const choice = createBackend(options, {
749      now: () => $.clock.now(),
750      fetch: (url, init) => $.http.fetch(url, init),
751      sleep: ms => new Promise<void>(resolve => void $.clock.after(ms, resolve)),
752      token: async () => {
753        const token = await $.store.get('token')
754        return typeof token === 'string' && token.length > 0 ? token : undefined
755      },
756      signedOut: () => signedOut($),
757    })
758    backend = 'backend' in choice ? choice.backend : undefined
759    configProblem = 'problem' in choice ? choice.problem : null
760    await $.command.register({
761      name: 'dooryard',
762      description: 'Dooryard: open the card pane while your agent works',
763      argumentHint: '[on|off|signin|signout|appeal]',
764      immediate: true,
765    })
766    isEnabled = (await $.store.get('enabled')) !== false
767    seenPostRules = (await $.store.get('seenPostRules')) === true
768    const saved = await $.store.get('blocked')
769    blocked = Array.isArray(saved) ? saved.filter(one => typeof one === 'string') : []
770    // A reload loses the module's timers, so start from idle
771    await finish($)
772    $.ui.log(backend !== undefined ? `dooryard: ${backend.kind} backend` : `dooryard: ${configProblem}`, {
773      to: 'debug',
774    })
775    return next(e)
776  })
777
778  on('command.run', { command: 'dooryard' }, async ($, e) => runCommand($, e.args))
779
780  on('turn.start', async ($, e, next) => {
781    if (e.agentId !== undefined) return next(e)
782    // Another session on this machine may have run /dooryard off or on
783    isEnabled = (await $.store.get('enabled')) !== false
784    if (!isEnabled) return next(e)
785    surfaces = await $.session.surfaces()
786    // claude -p, cloud sessions, a phone alone and hosts that attach no surface draw nothing: stay out
787    if (!surfaces.some(isDrawable)) return next(e)
788
789    isWorking = true
790    generation += 1
791    const mine = generation
792    tickTimer?.cancel()
793    openTimer?.cancel()
794    if ((await read($, phase)) === 'handback') {
795      // A queued prompt started at once: keep the band up
796      await update($, phase, () => 'band')
797    } else {
798      await update($, phase, () => 'armed')
799      openTimer = $.clock.after(OPEN_AFTER_MS, () => void openBand($, mine))
800    }
801    void beginTurn($, mine)
802    return next(e)
803  })
804
805  on('turn.complete', async ($, e, next) => {
806    if (e.agentId !== undefined || !isWorking) return next(e)
807    isWorking = false
808    generation += 1
809    openTimer?.cancel()
810    pollTimer?.cancel()
811    if (backend !== undefined && turnId !== null) void backend.turnEnd(turnId).catch(() => undefined)
812    turnId = null
813
814    if ((await read($, phase)) === 'band' && !e.isAborted) {
815      const mine = generation
816      await update($, countdown, () => HANDBACK_SECONDS)
817      await update($, phase, () => 'handback')
818      await refreshStatus($)
819      tickTimer = $.clock.every(1000, () => void tick($, mine))
820    } else {
821      // Under 2 seconds, nothing was shown; interrupted, the person is at the keys
822      await finish($)
823    }
824    return next(e)
825  })
826
827  on('ui.close', { id: PANE }, async ($, e, next) => {
828    // Nothing polls the pairing once its pane is gone; its notices have been shown;
829    // a photo path typed but not confirmed is dropped, so closing never posts it
830    await stopPairing($)
831    await update($, notices, () => [])
832    await update($, photoPath, () => null)
833    if (e.origin.kind === 'person') {
834      closedForSession = true
835      $.ui.toast(copy('yard.closed_pane'))
836    }
837    return next(e)
838  })
839
840  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
841    if (e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
842    const now = await read($, phase)
843    const isShown = !e.props.hasSurvey && (now === 'handback' || (now === 'band' && e.props.isWorking))
844    if (!isShown) return next(e)
845
846    const { Box, Text, Button } = $.ui.resolve(e)
847    const lead = await leadText($)
848    const shown = await read($, card)
849    const open = await read($, thread)
850    const others = await next(e)
851    const isBand = now === 'band'
852
853    return (
854      <Box flexDirection="column">
855        <Box flexDirection="row">
856          <Box flexShrink={1}>
857            <Text wrap="truncate-end">{lead}</Text>
858          </Box>
859          {isBand && shown !== null && shown.canHi && (
860            <Box flexShrink={0}>
861              <Text dimColor> · </Text>
862              <Button
863                key="hi"
864                plain
865                hotkey="1"
866                label="hi"
867                onPress={() => void openPane($, 'card', false).then(() => sayHi($))}
868              />
869            </Box>
870          )}
871          {isBand && shown !== null && (
872            <Box flexShrink={0}>
873              <Text dimColor> · </Text>
874              <Button
875                key="next"
876                plain
877                hotkey="2"
878                label="next"
879                onPress={() => void openPane($, 'card', false).then(() => dealVisible($))}
880              />
881            </Box>
882          )}
883          {isBand && (
884            <Box flexShrink={0}>
885              <Text dimColor> · </Text>
886              <Button key="post" plain hotkey="3" label="post" onPress={() => void openPane($, 'post', true)} />
887            </Box>
888          )}
889          {isBand && open !== null && (
890            // On the band, so 4 from an empty prompt opens the thread (copy.md hi.sent, hi.mutual):
891            // a digit in the prompt reaches only band buttons (mods interface docs, keyboard focus)
892            <Box flexShrink={0}>
893              <Text dimColor> · </Text>
894              <Button key="reply" plain hotkey="4" label="reply" onPress={() => void openPane($, 'reply', true)} />
895            </Box>
896          )}
897        </Box>
898        {others}
899      </Box>
900    )
901  })
902
903  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
904    if (e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
905    if (e.surface === 'mobile') {
906      const { Text } = $.ui.resolve(e)
907      return <Text dimColor>Dooryard draws on the terminal, Desktop and VS Code</Text>
908    }
909    const { Box, Text, Button, Input, Select, Link } = $.ui.resolve(e)
910    const now = await read($, phase)
911    const view = await read($, mode)
912    const shown = await read($, card)
913    const open = await read($, thread)
914    const toCard = () => void update($, mode, () => 'card')
915    const back = <Button key="back" plain label="back" onPress={toCard} />
916
917    if (view === 'signin') {
918      const shownPairing = await read($, pairing)
919      if (shownPairing === null) return <Text dimColor>dooryard</Text>
920      const [before, after] = copy('yard.signin', { code: shownPairing.code }).split('{url}')
921      return (
922        <Box flexDirection="column">
923          <Box flexDirection="row" flexWrap="wrap">
924            <Text>{before}</Text>
925            {isLinkable(shownPairing.url) ? <Link href={shownPairing.url} /> : <Text>{shownPairing.url}</Text>}
926            <Text>{after}</Text>
927          </Box>
928          <Button key="back" plain label="back" onPress={() => void stopPairing($).then(toCard)} />
929        </Box>
930      )
931    }
932
933    if (view === 'appeal') {
934      return (
935        <Box flexDirection="column">
936          <Link href={appealUrl()} />
937          <Button key="back" plain label="back" onPress={toCard} />
938        </Box>
939      )
940    }
941
942    if (!isEnabled) return <Text dimColor>{copy('yard.off')}</Text>
943    if (now === 'handback') {
944      return <Text dimColor>{copy('pane.handback', { seconds: await read($, countdown) })}</Text>
945    }
946
947    if (view === 'photo') {
948      const held = await read($, photoPath)
949      const gate = await read($, ageGateUrl)
950      return (
951        <Box flexDirection="column">
952          <Text dimColor>{copy('photo.rule')}</Text>
953          {held !== null && <Text>{copy('photo.permanent')}</Text>}
954          {held !== null && <Text dimColor>{copy('photo.permanent.confirm')}</Text>}
955          {gate !== null && ageGateVendor !== '' && <Text>{copy('photo.age_gate', { vendor: ageGateVendor })}</Text>}
956          {gate !== null && <Link href={gate} />}
957          <Input
958            key="photo"
959            placeholder={copy('photo.placeholder')}
960            value={held ?? ''}
961            submitLabel={held === null ? 'next' : 'post'}
962            autoFocus
963            onSubmit={value => void photoSubmit($, value)}
964          />
965          <Button
966            key="back"
967            plain
968            label="back"
969            onPress={() => void update($, photoPath, () => null).then(toCard)}
970          />
971        </Box>
972      )
973    }
974
975    if (view === 'post') {
976      return (
977        <Box flexDirection="column">
978          {!seenPostRules && <Text dimColor>{copy('post.first_time')}</Text>}
979          <Input
980            key="post"
981            placeholder={copy('post.placeholder')}
982            value=""
983            submitLabel="post"
984            autoFocus
985            onSubmit={value => void postCard($, value)}
986          />
987          {back}
988        </Box>
989      )
990    }
991
992    if (view === 'report') {
993      const handle = reportTarget === 'thread' ? open?.handle : shown?.handle
994      return (
995        <Box flexDirection="column">
996          <Select
997            key="report"
998            label={copy('report.title', { handle: handle ?? '', what: reportTarget === 'thread' ? 'message' : 'card' })}
999            options={CATEGORIES.map(value => ({ value }))}
1000            autoFocus
1001            onSelect={value => void reportPicked($, value)}
1002          />
1003          <Button
1004            key="back"
1005            plain
1006            label="back"
1007            onPress={() => void update($, mode, () => (reportTarget === 'thread' ? 'reply' : 'card'))}
1008          />
1009        </Box>
1010      )
1011    }
1012
1013    if (view === 'hello') {
1014      const opened = await read($, hello)
1015      if (opened !== null) {
1016        return (
1017          <Box flexDirection="column">
1018            <Text>{copy('hi.received.head', { handle: opened.handle })}</Text>
1019            <Text dimColor>
1020              {copy('card.meta', { handle: opened.handle, city: opened.city, local_time: opened.localTime })}
1021            </Text>
1022            <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1023              {opened.canHiBack && <Button key="hi-back" plain hotkey="1" label="hi back" onPress={() => void hiBack($)} />}
1024              <Button key="next" plain hotkey="2" label="next" onPress={() => void update($, hello, () => null).then(toCard)} />
1025              <Button key="block" plain hotkey="b" label="block" onPress={() => void blockHello($)} />
1026            </Box>
1027          </Box>
1028        )
1029      }
1030    }
1031
1032    if (view === 'reply' && open !== null) {
1033      const list = await read($, messages)
1034      const held = await read($, nudge)
1035      const tucked = await read($, hidden)
1036      const theirs = list.filter(one => one.from === 'them').at(-1)
1037      const room = Math.max(1, e.props.scroll.bodyRows - 6)
1038      const head = open.city === '' ? open.handle : copy('card.meta', { handle: open.handle, city: open.city, local_time: open.localTime })
1039      return (
1040        <Box flexDirection="column">
1041          <Text dimColor>{head}</Text>
1042          {list.length === 0 && <Text dimColor>{copyOn(e.surface, 'dm.first_open.1')}</Text>}
1043          {list.length === 0 && <Text dimColor>{copy('dm.first_open.2')}</Text>}
1044          {list.slice(-room).map(message => (
1045            <Text wrap="wrap">
1046              <Text dimColor>{message.from === 'me' ? 'you' : open.handle}: </Text>
1047              {message.text}
1048            </Text>
1049          ))}
1050          {theirs?.checkin === true && list.at(-1)?.id === theirs.id && (
1051            <Text dimColor>{copyOn(e.surface, 'checkin.bother')}</Text>
1052          )}
1053          {tucked > 0 && (
1054            <Text dimColor>
1055              {tucked === 1 ? copyOn(e.surface, 'hidden.one') : copyOn(e.surface, 'hidden.many', { n: tucked })}
1056            </Text>
1057          )}
1058          {held !== null && (
1059            <Text color="warning">{copy(isCopyKey(held.copyKey) ? held.copyKey : 'nudge.generic')}</Text>
1060          )}
1061          <Input
1062            key="reply"
1063            placeholder={copy('dm.placeholder')}
1064            value={held?.text ?? ''}
1065            submitLabel="send"
1066            autoFocus
1067            onSubmit={value => void sendReply($, value)}
1068          />
1069          <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1070            {tucked > 0 && <Button key="hidden" plain hotkey="h" label="show" onPress={() => void showHidden($)} />}
1071            <Button key="report" plain hotkey="r" label="report" onPress={() => void startReport($, 'thread')} />
1072            <Button key="block" plain hotkey="b" label="block" onPress={() => void blockThread($)} />
1073            <Button key="end" plain hotkey="x" label="end" onPress={() => void endThread($)} />
1074            {back}
1075          </Box>
1076        </Box>
1077      )
1078    }
1079
1080    const lead = await leadText($, false)
1081    const state = await read($, server)
1082    const waiting = state?.hiWaiting ?? 0
1083    const lines = await read($, notices)
1084    const others = (state?.threads ?? []).filter(
1085      one => one.unread > 0 && one.id !== open?.id && !blocked.includes(one.handle),
1086    )
1087    // The age gate is live (api.md 1.2): a first photo post answers with the
1088    // check's link; after a failed check the key stays hidden for the 30 days
1089    const canPhoto = myCardId !== null && state !== null && state.ageGate !== 'fail'
1090    const picture = shown?.hasPhoto === true ? await read($, photo) : null
1091    const image =
1092      e.surface === 'terminal' && picture !== null && picture.cardId === shown?.id
1093        ? (() => {
1094            const { Image } = $.ui.resolve(e)
1095            const box = photoBox(picture.columns, picture.rows, e.props.bodyColumns)
1096            return <Image key="photo" source={{ png: picture.png }} columns={box.columns} rows={box.rows} alt={picture.alt} />
1097          })()
1098        : null
1099    return (
1100      <Box flexDirection="column">
1101        {lines.map(line => (
1102          <Text color="warning" wrap="wrap">
1103            {isCopyKey(line.copyKey) ? copy(line.copyKey, line.params) : ''}
1104          </Text>
1105        ))}
1106        <Text dimColor wrap="truncate-end">
1107          {lead ?? copy('pane.idle')}
1108        </Text>
1109        {shown !== null && <Text wrap="wrap">{`"${shown.text}"`}</Text>}
1110        {shown !== null && (
1111          <Text dimColor>
1112            {copy('card.meta', { handle: shown.handle, city: shown.city, local_time: shown.localTime })}
1113          </Text>
1114        )}
1115        {image}
1116        {shown?.hasPhoto === true && image === null && <Text dimColor>{copy('card.photo.not_drawn')}</Text>}
1117        {open !== null && <Text dimColor>{copyOn(e.surface, 'hi.mutual', { handle: open.handle })}</Text>}
1118        {others.map(one => (
1119          <Button
1120            key={`thread-${one.id}`}
1121            plain
1122            label={copy('thread.unread', { handle: one.handle, n: one.unread })}
1123            onPress={() => void switchThread($, one.id, one.handle)}
1124          />
1125        ))}
1126        {now === 'band' && (
1127          <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1128            {shown !== null && shown.canHi && (
1129              <Button key="hi" plain hotkey="1" label="hi" onPress={() => void sayHi($)} />
1130            )}
1131            {shown !== null && <Button key="next" plain hotkey="2" label="next" onPress={() => void dealVisible($)} />}
1132            <Button key="post" plain hotkey="3" label="post" onPress={() => void update($, mode, () => 'post')} />
1133            {open !== null && (
1134              <Button key="reply" plain hotkey="4" label="reply" onPress={() => void update($, mode, () => 'reply')} />
1135            )}
1136            {waiting > 0 && <Button key="see" plain hotkey="o" label="see who" onPress={() => void openHello($)} />}
1137            {canPhoto && (
1138              <Button key="photo" plain hotkey="p" label="add photo" onPress={() => void update($, mode, () => 'photo')} />
1139            )}
1140            {shown !== null && (
1141              <Button key="report" plain hotkey="r" label="report" onPress={() => void startReport($, 'card')} />
1142            )}
1143            {shown !== null && <Button key="block" plain hotkey="b" label="block" onPress={() => void blockShown($)} />}
1144          </Box>
1145        )}
1146      </Box>
1147    )
1148  })
1149}
1150
hooks/band.ts 40 lines
1import type { YardCard, YardPhase, YardServerState } from '../types'
2import { copy } from './copy'
3
4/** The VS Code panel cuts a status line at 200 characters (mods interface docs). */
5export const STATUS_MAX = 200
6
7export type BandInput = {
8  phase: YardPhase
9  countdown: number
10  server: YardServerState | null
11  card: YardCard | null
12  problem: string | null
13  /** False for the pane's head line, which draws the card below in full. */
14  withCard?: boolean
15}
16
17/**
18 * The band's text before its hotkeys, which is also the VS Code status line
19 * (`band.line.vscode`); undefined while nothing should draw.
20 */
21export function bandLead({ phase, countdown, server, card, problem, withCard = true }: BandInput): string | undefined {
22  if (phase === 'handback') return `dooryard · ${copy('pane.handback', { seconds: countdown })}`
23  if (phase !== 'band') return undefined
24  if (problem !== null) return copy('band.notice', { notice: problem })
25  if (server === null) return 'dooryard'
26  // An incident switch's line stands in for the count until it is lifted (api.md 1.3)
27  if (server.bandNotice !== null) return copy('band.notice', { notice: server.bandNotice })
28
29  const parts = [
30    server.waiting === 0 && card === null
31      ? copy('band.alone')
32      : copy('band.count', { waiting: server.waiting }),
33  ]
34  if (server.hiWaiting > 0) {
35    parts.push(server.hiWaiting === 1 ? copy('band.hi.one') : copy('band.hi.many', { n: server.hiWaiting }))
36  }
37  if (card !== null && withCard) parts.push(`"${card.text}, ${card.city}, ${card.localTime}"`)
38  return parts.join(' · ')
39}
40
hooks/backend.ts 106 lines
1import type { HttpInit, HttpResponse, PluginOptions } from 'claude-code'
2
3import type {
4  YardCard,
5  YardHello,
6  YardMessage,
7  YardPairing,
8  YardPhoto,
9  YardReportCategory,
10  YardServerState,
11  YardThread,
12} from '../types'
13import { createLive } from './api'
14import { createFake } from './fake'
15
16/** An answer from the server: the value, or the error's code and copy key (api.md §2). */
17export type ApiResult<T> =
18  | { ok: true; value: T }
19  | { ok: false; code: string; copyKey?: string; params?: Readonly<Record<string, string>> }
20
21export type Surface = 'terminal' | 'desktop' | 'vscode'
22
23/**
24 * What the mod asks of the server, one method per endpoint of api.md v1.0.
25 * The mod draws and calls; every rule is the server's.
26 */
27export type Backend = {
28  kind: 'fake' | 'live'
29  /** POST /pair/start, no token: a code and a link; the secret stays in the mod. */
30  pairStart: (
31    surface: Surface,
32    clientVersion: string,
33  ) => Promise<ApiResult<YardPairing & { deviceSecret: string; pollMs: number }>>
34  /** POST /pair/poll, no token: pending until the person approves on the web page. */
35  pairPoll: (
36    deviceSecret: string,
37  ) => Promise<ApiResult<{ status: 'pending' } | { status: 'approved'; token: string; handle: string }>>
38  /** POST /signout: revokes this token. */
39  signout: () => Promise<ApiResult<null>>
40  /** POST /turn/start: a main-conversation turn began. */
41  turnStart: (surface: Surface) => Promise<ApiResult<{ turnId: string; pollMs: number; state: YardServerState }>>
42  /** GET /state: the poll while the turn runs; it keeps the turn live. */
43  state: (turnId: string) => Promise<ApiResult<YardServerState>>
44  /** POST /turn/end: the turn ended; my card retires. */
45  turnEnd: (turnId: string) => Promise<ApiResult<null>>
46  /** POST /deal: one card for this wait, or null when nobody else has one live. */
47  deal: (turnId: string) => Promise<ApiResult<YardCard | null>>
48  /** POST /cards: my card for this turn. */
49  post: (turnId: string, text: string) => Promise<ApiResult<{ id: string }>>
50  /** POST /hellos: the fixed hello to a card dealt to me; mutual opens a thread. */
51  hello: (cardId: string, turnId: string) => Promise<ApiResult<{ mutual: boolean; thread?: YardThread }>>
52  /** POST /hellos/open: my oldest unopened hello, and who sent it. */
53  helloOpen: () => Promise<ApiResult<YardHello | null>>
54  /** POST /hellos/{id}/back: hi back, which makes it mutual. */
55  helloBack: (helloId: string) => Promise<ApiResult<YardThread>>
56  /** GET /threads/{id}/messages: what came after `after`, oldest first. */
57  messages: (threadId: string, after?: string) => Promise<ApiResult<{ messages: YardMessage[]; hidden: number }>>
58  /** POST /threads/{id}/messages: one DM; `confirm` answers a nudge. */
59  send: (threadId: string, text: string, confirm: boolean) => Promise<ApiResult<YardMessage>>
60  /** POST /threads/{id}/hidden: the h key, the hidden folder moved into the thread. */
61  hidden: (threadId: string) => Promise<ApiResult<YardMessage[]>>
62  /** POST /threads/{id}/end: the x key, ends the thread for both and blocks. */
63  end: (threadId: string) => Promise<ApiResult<null>>
64  /** GET /cards/{id}/photo: the PNG of a dealt card's live photo. */
65  photo: (cardId: string) => Promise<ApiResult<YardPhoto>>
66  /** POST /cards/{id}/photo: my card's photo, once the person saw that photos are permanent. */
67  postPhoto: (cardId: string, imageBase64: string, mime: 'image/jpeg' | 'image/png') => Promise<ApiResult<{ status: 'live' | 'held' }>>
68  /** POST /blocks: both directions, for good. */
69  block: (handle: string) => Promise<ApiResult<null>>
70  /** POST /reports: a category and the card or thread, no free text. */
71  report: (
72    category: YardReportCategory,
73    target: { cardId: string } | { threadId: string; messageId?: string },
74  ) => Promise<ApiResult<{ copyKey: string }>>
75}
76
77/** What the backends reach the world through: all of it the host's, through `$`. */
78export type Io = {
79  now: () => Promise<number>
80  fetch: (url: string, init: HttpInit) => Promise<HttpResponse>
81  /** Resolves after `ms`; the 8-second cap races it (api.md §1). */
82  sleep: (ms: number) => Promise<void>
83  /** The sign-in token from `$.store`, when there is one. */
84  token: () => Promise<string | undefined>
85  /** api.md §1: a 401 clears the token and shows sign-in. */
86  signedOut: () => Promise<void>
87}
88
89export type BackendChoice = { backend: Backend } | { problem: string }
90
91/** https, or plain http to this machine for local development (api.md §1). */
92export function isApiBase(url: string): boolean {
93  return /^https:\/\/[^/@\s]+$/.test(url) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(url)
94}
95
96/**
97 * Picks the backend the `backend` option names: `fake` keeps cards and
98 * counts in memory; `live` talks to the one host in `api_url`.
99 */
100export function createBackend(options: PluginOptions, io: Io): BackendChoice {
101  if (options.backend !== 'live') return { backend: createFake(io.now) }
102  const url = typeof options.api_url === 'string' ? options.api_url.replace(/\/+$/, '') : ''
103  if (!isApiBase(url)) return { problem: 'live backend needs an https API URL' }
104  return { backend: createLive(url, io) }
105}
106
hooks/copy.ts 154 lines
1// The baked-in strings the pane shows when the server's copy (GET /copy,
2// E03-S5) is not there. Keys and text from E04 copy.md, 2026-10-23 draft.
3// E01 settled the short form, 2026-10-23: `dooryard` in the band line and
4// `/dooryard` as the command; Dooryard where copy.md names the product Yard.
5// Copy keys stay as E04 wrote them (`yard.on`, ...): the server sends them.
6// The band's hotkeys are Buttons, so the band strings stop before them.
7
8export const COPY = {
9  /** The head of `band.line` and `band.line.vscode`: `dooryard · {waiting} waiting`. */
10  'band.count': 'dooryard · {waiting} waiting',
11  'band.hi.one': '1 hi waiting',
12  'band.hi.many': '{n} hi waiting',
13  'band.alone': "dooryard · you're the only one waiting",
14  'band.notice': 'dooryard · {notice}',
15  'card.meta': '{handle} · {city} · {local_time}',
16  'pane.handback': 'turn done · closing in {seconds}',
17  /** Not in copy.md yet: the pane's line between turns. Flagged to E04. */
18  'pane.idle': 'cards show while your agent works',
19  'hi.sent': 'hi sent to {handle}. If they say hi back, 4 opens a thread.',
20  'hi.limit.card': 'one hi per card',
21  'hi.limit.person': 'you said hi to {handle} in the last 24 hours',
22  'hi.limit.day': '20 hi a day is the cap · back tomorrow',
23  'cooldown.new': 'New here. First 24 hours: 5 hi a day, stricter filters, no photos.',
24  'cooldown.hi_wait': 'first hi opens 1 hour after sign-in',
25  'hi.mutual': 'mutual with {handle} · 4 reply · Tab, then x ends it and blocks',
26  'post.placeholder': "one line about what you're waiting on · work, city, time · 80 chars",
27  'post.first_time':
28    'Everyone in the dooryard sees your card while your agent runs. Adults only. No links, no selling, nothing sexual.',
29  'post.live': 'your card is live while this turn runs',
30  'post.too_long': '80 characters max',
31  'post.rate': '6 card changes an hour is the cap',
32  'post.rejected.link': 'no links or handles in cards',
33  'post.rejected.spam': 'no selling or recruiting in cards',
34  'post.rejected.sexual': 'cards stay about work. Nothing sexual.',
35  'post.rejected.harassment': "cards aren't for insults",
36  'post.rejected.hate': "that word isn't allowed here",
37  'post.watch_only': 'posting is off until {until} · /dooryard appeal',
38  'report.title': "Report {handle}'s {what}",
39  'report.sent': 'Reported. A human looks within 24 hours. Two reports from different people pull it at once.',
40  'block.done': "Blocked. {handle} is gone from this machine and can't reach you here again.",
41  'dm.first_open.1': 'Text only. Deleted when the thread ends, unless someone reported it. Tab, then x ends it and blocks.',
42  'dm.first_open.2': "A mod loaded before Dooryard on either machine can read this text. Dooryard can't stop that.",
43  'dm.placeholder': 'reply · 500 chars',
44  'dm.too_long': '500 characters max',
45  'dm.toast': '{handle}: {preview}',
46  'dm.rate.fast': 'slow down · 1 message every 2 seconds',
47  'dm.rate.hour': '60 messages an hour per thread is the cap',
48  'dm.rate.day': '300 messages a day is the cap',
49  /** The head of `hi.received`; its keys are the hello view's Buttons. */
50  'hi.received.head': '{handle} said hi',
51  'nudge.generic': 'Hold on. This can land wrong. Enter sends it as is, or edit it first.',
52  'nudge.sexual': 'Hold on. This reads as sexual. Dooryard removes accounts for that. Enter sends it as is, or edit it first.',
53  'nudge.harassment': 'Hold on. This reads as an insult. Enter sends it as is, or edit it first.',
54  'nudge.hate': 'Hold on. That word reads as a slur. Enter sends it as is, or edit it first.',
55  'hidden.one': '1 hidden message · Tab, then h shows it',
56  'hidden.many': '{n} hidden messages · Tab, then h shows them',
57  'hidden.toast': '{handle}: 1 hidden message',
58  'checkin.bother': 'Did that bother you? Tab, then r report · b block',
59  'thread.ended.by_you': 'Thread ended and {handle} blocked.',
60  'thread.ended.by_them': '{handle} ended the thread.',
61  'report.limit': '10 reports a day is the cap',
62  'report.pulled.poster': 'Your {what} is on hold after reports. A human looks within 24 hours.',
63  'report.outcome.none': 'We looked at your report on {handle}. No rule broken this time. Thanks for flagging.',
64  'report.outcome.action': 'We looked at your report on {handle}. Action taken. Thanks.',
65  'decision.warning': 'Warning: your {what} broke the rule on {category}. The next one switches you to watch-only.',
66  'decision.watch_only': 'Watch-only for {days} days: {category}. You can read, not post. /dooryard appeal',
67  'appeal.upheld': 'Appeal reviewed. The decision stands.',
68  'appeal.reversed': 'Appeal reviewed. The decision is reversed and the strike removed.',
69  'photo.removed.takedown':
70    'Your photo was removed after a request about an intimate image shared without consent. Text cards still work.',
71  'decision.permanent': 'Your Dooryard account is closed: {category}. /dooryard appeal',
72  /** Not in copy.md yet: the line for an error the server sent no string for. Flagged to E04. */
73  'error.unavailable': 'server unavailable',
74  'error.generic': "that didn't go through",
75  /** Not in copy.md yet: another thread with new messages, in the card view. Flagged to E04. */
76  'thread.unread': '{handle} · {n} new',
77  /** Not in copy.md yet: the line a 401 leaves until sign-in (E02-S5). Flagged to E04. */
78  'yard.signed_out': 'signed out · /dooryard signs you in',
79  'yard.on': 'dooryard on. Your card posts only while your agent runs. /dooryard off stops it.',
80  'yard.off': 'dooryard off. /dooryard on brings it back.',
81  'yard.closed_pane': 'pane closed for this session · /dooryard reopens it',
82  'card.photo.not_drawn': 'photo not shown here: pictures draw only in kitty, Ghostty and iTerm2',
83  'photo.rule': "Desk, screen, city, coffee. No nudity. No one else's face without their ok. Checked on upload.",
84  'photo.permanent': 'Photos are permanent. Anyone who sees your card can save it. Dooryard strips location data, nothing else.',
85  'photo.permanent.confirm': 'Enter posts it · Ctrl+X then X closes without posting',
86  'photo.age_gate': 'One-time age check before your first photo: {vendor}. Dooryard keeps pass or fail, never the images.',
87  'photo.age_gate.link': '{url}',
88  'photo.age_gate.failed': "Age check didn't pass. Text cards still work. Try again in 30 days.",
89  'photo.rejected.nudity': 'Photo not posted: it reads as nudity. Desk, screen, city, coffee.',
90  'photo.rejected.blocked': 'Photo not posted: it matches an image Dooryard removed.',
91  'photo.held': 'Photo on hold while a human looks. Your text card is live.',
92  'photo.too_big': '2 MiB max',
93  'photo.cooldown': 'photos open 7 days after sign-in',
94  /** Not in copy.md yet: the photo field's placeholder. Flagged to E04. */
95  'photo.placeholder': 'path to a .png or .jpg on this machine',
96  /** Not in copy.md yet: a photo path that cannot be read. Flagged to E04. */
97  'photo.unreadable': "can't read that file",
98  'yard.signin': 'Sign in at {url} · code {code} · the pane picks it up on its own',
99  'yard.signin.done': 'signed in as {handle}',
100} as const
101
102export type CopyKey = keyof typeof COPY
103
104export function isCopyKey(key: string | undefined): key is CopyKey {
105  return key !== undefined && Object.hasOwn(COPY, key)
106}
107
108/** What the server answered on an error (api.md §2): the code, and a copy key when it sent one. */
109export type ErrorAnswer = { code: string; copyKey?: string; params?: Readonly<Record<string, string>> }
110
111/**
112 * The one line an error shows: the server's string when the mod has it, else a
113 * plain line by code, so no press ends in silence.
114 */
115export function errorLine(answer: ErrorAnswer, params: Readonly<Record<string, string | number>> = {}): string {
116  if (isCopyKey(answer.copyKey)) return copy(answer.copyKey, { ...params, ...answer.params })
117  if (answer.code === 'unavailable' || answer.code === 'server_error') return copy('error.unavailable')
118  return copy('error.generic')
119}
120
121/**
122 * VS Code draws no hotkeys (mods interface docs, "Keyboard focus and
123 * hotkeys"), so there the key hints are cut from these strings, nothing else
124 * changed (copy.md rule: E02 drops the key hints there); `hi.sent` has its own
125 * line in copy.md, `hi.sent.vscode`.
126 */
127const BARE: Partial<Record<CopyKey, string>> = {
128  'hi.sent': 'hi sent to {handle}. If they say hi back, a thread opens.',
129  'hi.mutual': 'mutual with {handle}',
130  'dm.first_open.1': 'Text only. Deleted when the thread ends, unless someone reported it.',
131  'hidden.one': '1 hidden message',
132  'hidden.many': '{n} hidden messages',
133  'checkin.bother': 'Did that bother you?',
134}
135
136/** `copy`, with the key hints cut where the surface draws no hotkeys. */
137export function copyOn(
138  surface: string,
139  key: CopyKey,
140  params: Readonly<Record<string, string | number>> = {},
141): string {
142  const bare = surface === 'vscode' ? BARE[key] : undefined
143  return bare === undefined ? copy(key, params) : fill(bare, params)
144}
145
146function fill(text: string, params: Readonly<Record<string, string | number>>): string {
147  return text.replace(/\{(\w+)\}/g, (whole, name: string) => (name in params ? String(params[name]) : whole))
148}
149
150/** The string under `key` with each `{name}` filled from `params`. */
151export function copy(key: CopyKey, params: Readonly<Record<string, string | number>> = {}): string {
152  return fill(COPY[key], params)
153}
154
hooks/photo.ts 21 lines
1// Photo cards (api.md §11): what the mod checks before it sends one. The
2// server checks it all again, strips metadata and runs the nudity filter.
3
4/** api.md §11: the decoded image is at most 2 MiB; its base64 at most this many characters. */
5export const MAX_PHOTO_BASE64 = 2_796_204
6
7/** The two types the server takes, by the file's extension. */
8export function photoMime(path: string): 'image/jpeg' | 'image/png' | undefined {
9  const lower = path.trim().toLowerCase()
10  if (lower.endsWith('.png')) return 'image/png'
11  if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'
12  return undefined
13}
14
15/** An Image box that fits the pane's width, keeping the server's shape. */
16export function photoBox(columns: number, rows: number, bodyColumns: number): { columns: number; rows: number } {
17  const wide = Math.max(1, Math.min(255, columns, bodyColumns))
18  const tall = Math.max(1, Math.min(255, Math.round((rows * wide) / Math.max(1, columns))))
19  return { columns: wide, rows: tall }
20}
21
hooks/api.ts 226 lines
1import type { HttpInit } from 'claude-code'
2
3import type { YardCard, YardHello, YardMessage, YardPhoto, YardServerState, YardThread } from '../types'
4import type { ApiResult, Backend, Io } from './backend'
5
6// The live backend: contracts/api.md v1.0 over $.http.fetch, text JSON only,
7// to the one host the `api_url` option names and to nothing else. Every
8// answer is read field by field; a field it does not know is ignored.
9
10/** api.md §1: no answer within 8 seconds is a failure; the last state stays drawn. */
11const CAP_MS = 8000
12/** api.md §13: the server says how often to poll; never faster than this. */
13const MIN_POLL_MS = 2000
14
15type Raw = Record<string, unknown>
16
17function record(value: unknown): Raw {
18  return typeof value === 'object' && value !== null ? (value as Raw) : {}
19}
20
21function text(value: unknown): string {
22  return typeof value === 'string' ? value : ''
23}
24
25function count(value: unknown): number {
26  return typeof value === 'number' && Number.isFinite(value) ? value : 0
27}
28
29function strings(value: unknown): Readonly<Record<string, string>> {
30  const out: Record<string, string> = {}
31  for (const [key, one] of Object.entries(record(value))) {
32    if (typeof one === 'string' || typeof one === 'number') out[key] = String(one)
33  }
34  return out
35}
36
37function toCard(value: unknown): YardCard | null {
38  const raw = record(value)
39  if (typeof raw.id !== 'string') return null
40  return {
41    id: raw.id,
42    text: text(raw.text),
43    handle: text(raw.handle),
44    city: text(raw.city),
45    localTime: text(raw.local_time),
46    hasPhoto: raw.has_photo === true,
47    canHi: raw.can_hi !== false,
48  }
49}
50
51function toState(value: unknown): YardServerState {
52  const raw = record(value)
53  const mine = record(raw.my_card)
54  const gate = record(raw.me).age_gate
55  const incident = text(record(record(raw.band_notice).params).notice)
56  return {
57    waiting: count(raw.waiting),
58    hiWaiting: count(raw.hi_waiting),
59    myCard: typeof mine.id === 'string' ? { id: mine.id, hellos: count(mine.hellos) } : null,
60    threads: (Array.isArray(raw.threads) ? raw.threads : []).map(record).map(one => ({
61      id: text(one.id),
62      handle: text(one.handle),
63      unread: count(one.unread),
64      hidden: count(one.hidden),
65    })),
66    ageGate: gate === 'pending' || gate === 'pass' || gate === 'fail' ? gate : 'none',
67    notices: (Array.isArray(raw.notices) ? raw.notices : [])
68      .map(record)
69      .filter(one => typeof one.copy_key === 'string')
70      .map(one => ({ copyKey: text(one.copy_key), params: { ...strings(one.params) } })),
71    bandNotice: incident === '' ? null : incident,
72  }
73}
74
75function toPhoto(cardId: string, raw: Raw): YardPhoto {
76  return {
77    cardId,
78    png: text(raw.png_base64),
79    columns: Math.min(255, Math.max(1, count(raw.columns) || 40)),
80    rows: Math.min(255, Math.max(1, count(raw.rows) || 20)),
81    alt: text(raw.alt) || ' ',
82  }
83}
84
85function toThread(value: unknown): YardThread {
86  const raw = record(value)
87  return { id: text(raw.id), handle: text(raw.handle), city: text(raw.city), localTime: text(raw.local_time) }
88}
89
90function toMessage(value: unknown): YardMessage {
91  const raw = record(value)
92  return {
93    id: text(raw.id),
94    from: raw.from === 'me' ? 'me' : 'them',
95    text: text(raw.text),
96    sentAt: text(raw.sent_at),
97    checkin: raw.checkin === true,
98  }
99}
100
101function toHello(value: unknown): YardHello | null {
102  const raw = record(value)
103  if (typeof raw.id !== 'string') return null
104  return {
105    id: raw.id,
106    handle: text(raw.handle),
107    city: text(raw.city),
108    localTime: text(raw.local_time),
109    canHiBack: raw.can_hi_back === true,
110  }
111}
112
113const messageList = (value: unknown) => (Array.isArray(value) ? value.map(toMessage) : [])
114
115export function createLive(base: string, io: Io): Backend {
116  const root = `${base}/api/v1`
117
118  /** One request; pairing goes without the token (api.md §1, Auth). */
119  async function call(method: 'GET' | 'POST', path: string, body?: unknown, isSigned = true): Promise<ApiResult<Raw>> {
120    const headers: Record<string, string> = {}
121    if (isSigned) {
122      const token = await io.token()
123      if (token === undefined) return { ok: false, code: 'unauthorized' }
124      headers.Authorization = `Bearer ${token}`
125    }
126    const init: HttpInit = { method, headers }
127    if (body !== undefined) {
128      headers['Content-Type'] = 'application/json'
129      init.body = JSON.stringify(body)
130    }
131    try {
132      const answer = await Promise.race([io.fetch(`${root}${path}`, init), io.sleep(CAP_MS).then(() => null)])
133      if (answer === null) return { ok: false, code: 'unavailable' }
134      let parsed: unknown
135      try {
136        parsed = JSON.parse(answer.text)
137      } catch {
138        return { ok: false, code: answer.ok ? 'server_error' : 'unavailable' }
139      }
140      if (answer.ok) return { ok: true, value: record(parsed) }
141      const error = record(record(parsed).error)
142      const code = typeof error.code === 'string' ? error.code : 'server_error'
143      if (code === 'unauthorized') await io.signedOut()
144      const copyKey = typeof error.copy_key === 'string' ? error.copy_key : undefined
145      return { ok: false, code, ...(copyKey === undefined ? {} : { copyKey }), params: strings(error.params) }
146    } catch {
147      return { ok: false, code: 'unavailable' }
148    }
149  }
150
151  /** The same answer with its value read into the mod's shape. */
152  async function read<T>(answer: Promise<ApiResult<Raw>>, shape: (raw: Raw) => T): Promise<ApiResult<T>> {
153    const settled = await answer
154    return settled.ok ? { ok: true, value: shape(settled.value) } : settled
155  }
156
157  const id = (value: string) => encodeURIComponent(value)
158
159  return {
160    kind: 'live',
161    pairStart: (surface, clientVersion) =>
162      read(call('POST', '/pair/start', { surface, client_version: clientVersion }, false), raw => ({
163        code: text(raw.code),
164        deviceSecret: text(raw.device_secret),
165        url: text(raw.url),
166        expiresAt: text(raw.expires_at),
167        pollMs: Math.max(MIN_POLL_MS, count(raw.poll_ms) || 2000),
168      })),
169    pairPoll: deviceSecret =>
170      read(call('POST', '/pair/poll', { device_secret: deviceSecret }, false), raw =>
171        raw.status === 'approved' && typeof raw.token === 'string'
172          ? { status: 'approved' as const, token: raw.token, handle: text(raw.handle) }
173          : { status: 'pending' as const },
174      ),
175    signout: () => read(call('POST', '/signout'), () => null),
176    turnStart: surface =>
177      read(call('POST', '/turn/start', { surface }), raw => ({
178        turnId: text(raw.turn_id),
179        pollMs: Math.max(MIN_POLL_MS, count(raw.poll_ms) || 5000),
180        state: toState(raw.state),
181      })),
182    state: turnId => read(call('GET', `/state?turn_id=${id(turnId)}`), toState),
183    turnEnd: turnId => read(call('POST', '/turn/end', { turn_id: turnId }), () => null),
184    deal: turnId => read(call('POST', '/deal', { turn_id: turnId }), raw => toCard(raw.card)),
185    post: (turnId, line) =>
186      read(call('POST', '/cards', { turn_id: turnId, text: line }), raw => ({ id: text(record(raw.card).id) })),
187    hello: (cardId, turnId) =>
188      read(call('POST', '/hellos', { card_id: cardId, turn_id: turnId }), raw =>
189        raw.mutual === true && raw.thread !== undefined
190          ? { mutual: true, thread: toThread(raw.thread) }
191          : { mutual: false },
192      ),
193    helloOpen: () => read(call('POST', '/hellos/open'), raw => toHello(raw.hello)),
194    helloBack: helloId => read(call('POST', `/hellos/${id(helloId)}/back`), raw => toThread(raw.thread)),
195    messages: (threadId, after) =>
196      read(
197        call('GET', `/threads/${id(threadId)}/messages${after === undefined ? '' : `?after=${id(after)}`}`),
198        raw => ({ messages: messageList(raw.messages), hidden: count(raw.hidden) }),
199      ),
200    send: (threadId, line, confirm) =>
201      read(call('POST', `/threads/${id(threadId)}/messages`, { text: line, confirm }), raw =>
202        toMessage(raw.message),
203      ),
204    hidden: threadId => read(call('POST', `/threads/${id(threadId)}/hidden`), raw => messageList(raw.messages)),
205    end: threadId => read(call('POST', `/threads/${id(threadId)}/end`), () => null),
206    photo: cardId => read(call('GET', `/cards/${id(cardId)}/photo`), raw => toPhoto(cardId, raw)),
207    postPhoto: (cardId, imageBase64, mime) =>
208      read(
209        call('POST', `/cards/${id(cardId)}/photo`, { image_base64: imageBase64, mime, ack_permanent: true }),
210        raw => ({ status: raw.status === 'held' ? ('held' as const) : ('live' as const) }),
211      ),
212    block: handle => read(call('POST', '/blocks', { handle }), () => null),
213    report: (category, target) =>
214      read(
215        call(
216          'POST',
217          '/reports',
218          'cardId' in target
219            ? { category, card_id: target.cardId }
220            : { category, thread_id: target.threadId, ...(target.messageId === undefined ? {} : { message_id: target.messageId }) },
221        ),
222        raw => ({ copyKey: text(raw.copy_key) || 'report.sent' }),
223      ),
224  }
225}
226
hooks/fake.ts 262 lines
1import type { YardCard, YardMessage, YardServerState, YardThread } from '../types'
2import type { ApiResult, Backend } from './backend'
3
4// The fake backend for the G0 demo: made-up posters, kept in memory, gone on
5// reload. It answers in the shapes of contracts/api.md v1.0 and mimics the
6// few rules the pane shows: one hello per card, a card lives only while its
7// turn is live, the poll answers 410 for a turn that is not live, a mutual
8// hello opens a thread, the other side writes back.
9
10type Poster = {
11  handle: string
12  text: string
13  city: string
14  utcOffsetMin: number
15  /** The poster said hi to me first, so my hi makes it mutual. */
16  saidHiFirst?: true
17  /** The poster says hi back a few seconds after my hi, and writes one line. */
18  saysHiBack?: true
19}
20
21// The first two cards are the storyboard copy of E05 video.md §2, for the G0
22// cut, which never goes public: card one, 2 skips to card two, 1 says hi, the
23// hi comes back with one line.
24const POSTERS: readonly Poster[] = [
25  { handle: 'mira', text: 'rewriting the same migration for the third time, send coffee', city: 'Lisbon', utcOffsetMin: 60 },
26  { handle: 'tomas', text: "shipping a mod tonight, agent's on the tests", city: 'Berlin', utcOffsetMin: 120, saysHiBack: true },
27  { handle: 'aiko', text: 'porting a shader to WebGPU', city: 'Osaka', utcOffsetMin: 540, saidHiFirst: true },
28  { handle: 'devon', text: 'fixing the flaky e2e test, again', city: 'Austin', utcOffsetMin: -300 },
29  { handle: 'lena', text: 'letting it write the tests I skipped', city: 'Warsaw', utcOffsetMin: 120, saidHiFirst: true },
30  { handle: 'ravi', text: 'bisecting a memory leak', city: 'Pune', utcOffsetMin: 330 },
31  { handle: 'noor', text: 'generating 400 fixtures', city: 'Cairo', utcOffsetMin: 180 },
32  { handle: 'kai', text: 'teaching it our deploy script', city: 'Vancouver', utcOffsetMin: -420 },
33]
34
35/** The poster whose hello reaches my card once it has been live a while. */
36const ADMIRER = POSTERS[7]!
37const DRIFT = [0, 1, 2, -1, 3, -2, 1, 0, -3, 2] as const
38const HELLO_AFTER_MS = 10_000
39const REPLY_AFTER_MS = 6_000
40const REPLY = 'same here. what is it building?'
41const HI_BACK_AFTER_MS = 6_000
42const HI_BACK_LINE = 'ha, same. what are you shipping?'
43
44function localTime(nowMs: number, utcOffsetMin: number): string {
45  const minutes = Math.floor(nowMs / 60_000) + utcOffsetMin
46  const inDay = ((minutes % 1440) + 1440) % 1440
47  const hh = String(Math.floor(inDay / 60)).padStart(2, '0')
48  const mm = String(inDay % 60).padStart(2, '0')
49  return `${hh}:${mm}`
50}
51
52const ok = <T>(value: T): ApiResult<T> => ({ ok: true, value })
53
54type FakeThread = {
55  poster: Poster
56  list: YardMessage[]
57  /** Index into `list` of the first message I have not fetched. */
58  read: number
59  replyAt: number | null
60}
61
62export function createFake(now: () => Promise<number>): Backend {
63  let waiting = 37
64  let polls = 0
65  let turns = 0
66  let dealt = 0
67  let sent = 0
68  const live = new Set<string>()
69  const greeted = new Set<string>()
70  const blocked = new Set<string>()
71  const threads = new Map<string, FakeThread>()
72  let myCard: { id: string; turnId: string; postedAt: number } | null = null
73  let hello: 'none' | 'waiting' | 'opened' = 'none'
74  let pairPolls = 0
75  let hiBack: { poster: Poster; at: number } | null = null
76
77  const message = (from: 'me' | 'them', line: string, nowMs: number): YardMessage => {
78    sent += 1
79    return { id: `fake-message-${sent}`, from, text: line, sentAt: new Date(nowMs).toISOString(), checkin: false }
80  }
81
82  /** The other side writes back a few seconds after my first message. */
83  const settle = (nowMs: number) => {
84    for (const one of threads.values()) {
85      if (one.replyAt !== null && nowMs >= one.replyAt) {
86        one.list.push(message('them', REPLY, nowMs))
87        one.replyAt = null
88      }
89    }
90    if (myCard !== null && hello === 'none' && nowMs - myCard.postedAt >= HELLO_AFTER_MS) hello = 'waiting'
91    if (hiBack !== null && nowMs >= hiBack.at && !blocked.has(hiBack.poster.handle)) {
92      // Their hi back makes it mutual; the thread opens with their one line
93      const id = `fake-thread-${hiBack.poster.handle}`
94      threads.set(id, { poster: hiBack.poster, list: [message('them', HI_BACK_LINE, nowMs)], read: 0, replyAt: null })
95      hiBack = null
96    }
97  }
98
99  const threadOf = (poster: Poster, nowMs: number): YardThread => {
100    const id = `fake-thread-${poster.handle}`
101    if (!threads.has(id)) threads.set(id, { poster, list: [], read: 0, replyAt: null })
102    return { id, handle: poster.handle, city: poster.city, localTime: localTime(nowMs, poster.utcOffsetMin) }
103  }
104
105  const state = async (): Promise<YardServerState> => {
106    const nowMs = await now()
107    settle(nowMs)
108    waiting = Math.max(8, waiting + (DRIFT[polls % DRIFT.length] ?? 0))
109    polls += 1
110    return {
111      waiting,
112      hiWaiting: hello === 'waiting' ? 1 : 0,
113      myCard: myCard === null ? null : { id: myCard.id, hellos: hello === 'none' ? 0 : 1 },
114      threads: [...threads.entries()].map(([id, one]) => ({
115        id,
116        handle: one.poster.handle,
117        unread: one.list.slice(one.read).filter(m => m.from === 'them').length,
118        hidden: 0,
119      })),
120      ageGate: 'none',
121      notices: [],
122      bandNotice: null,
123    }
124  }
125
126  const cardAt = (index: number, nowMs: number): YardCard => {
127    const poster = POSTERS[index % POSTERS.length] ?? POSTERS[0]!
128    const id = `fake-card-${poster.handle}`
129    return {
130      id,
131      text: poster.text,
132      handle: poster.handle,
133      city: poster.city,
134      localTime: localTime(nowMs, poster.utcOffsetMin),
135      hasPhoto: false,
136      canHi: !greeted.has(id),
137    }
138  }
139
140  const notLive = <T>(): ApiResult<T> => ({ ok: false, code: 'no_live_turn' })
141  const ended = <T>(handle = ''): ApiResult<T> => ({
142    ok: false,
143    code: 'gone',
144    copyKey: 'thread.ended.by_them',
145    params: { handle },
146  })
147
148  return {
149    kind: 'fake',
150    // The fake pairing approves itself on the third poll, as if signed in on the web
151    pairStart: async () => {
152      pairPolls = 0
153      const nowMs = await now()
154      return ok({
155        code: 'K7QM2X',
156        deviceSecret: 'fake-secret',
157        url: 'https://example.com/pair?code=K7QM2X',
158        expiresAt: new Date(nowMs + 600_000).toISOString(),
159        pollMs: 2000,
160      })
161    },
162    pairPoll: async deviceSecret => {
163      if (deviceSecret !== 'fake-secret') return { ok: false, code: 'gone' }
164      pairPolls += 1
165      return ok(pairPolls < 3 ? { status: 'pending' } : { status: 'approved', token: 'fake-token', handle: 'you' })
166    },
167    signout: async () => ok(null),
168    turnStart: async () => {
169      turns += 1
170      const turnId = `fake-turn-${turns}`
171      live.add(turnId)
172      return ok({ turnId, pollMs: 5000, state: await state() })
173    },
174    state: async turnId => (live.has(turnId) ? ok(await state()) : { ok: false, code: 'gone' }),
175    turnEnd: async turnId => {
176      live.delete(turnId)
177      if (myCard?.turnId === turnId) myCard = null
178      return ok(null)
179    },
180    deal: async turnId => {
181      if (!live.has(turnId)) return notLive()
182      // Round-robin, never across a block
183      for (let step = 0; step < POSTERS.length; step += 1) {
184        const card = cardAt(dealt, await now())
185        dealt += 1
186        if (!blocked.has(card.handle)) return ok(card)
187      }
188      return ok(null)
189    },
190    post: async (turnId, text) => {
191      if (!live.has(turnId)) return notLive()
192      const line = text.trim()
193      if (line.length === 0 || line.length > 80) return { ok: false, code: 'invalid', copyKey: 'post.too_long' }
194      myCard = { id: `fake-card-mine-${turns}`, turnId, postedAt: await now() }
195      return ok({ id: myCard.id })
196    },
197    hello: async (cardId, turnId) => {
198      if (!live.has(turnId)) return notLive()
199      if (greeted.has(cardId)) return { ok: false, code: 'already_said_hi', copyKey: 'hi.limit.card' }
200      const poster = POSTERS.find(one => `fake-card-${one.handle}` === cardId)
201      if (poster === undefined || blocked.has(poster.handle)) return { ok: false, code: 'not_found' }
202      greeted.add(cardId)
203      if (poster.saysHiBack === true) hiBack = { poster, at: (await now()) + HI_BACK_AFTER_MS }
204      if (poster.saidHiFirst !== true) return ok({ mutual: false })
205      return ok({ mutual: true, thread: threadOf(poster, await now()) })
206    },
207    helloOpen: async () => {
208      settle(await now())
209      if (hello !== 'waiting' || blocked.has(ADMIRER.handle)) return ok(null)
210      hello = 'opened'
211      const nowMs = await now()
212      return ok({
213        id: 'fake-hello-1',
214        handle: ADMIRER.handle,
215        city: ADMIRER.city,
216        localTime: localTime(nowMs, ADMIRER.utcOffsetMin),
217        canHiBack: true,
218      })
219    },
220    helloBack: async helloId => {
221      if (helloId !== 'fake-hello-1' || blocked.has(ADMIRER.handle)) return { ok: false, code: 'not_found' }
222      return ok(threadOf(ADMIRER, await now()))
223    },
224    messages: async (threadId, after) => {
225      const one = threads.get(threadId)
226      if (one === undefined) return ended()
227      settle(await now())
228      const from = after === undefined ? 0 : one.list.findIndex(m => m.id === after) + 1
229      one.read = one.list.length
230      return ok({ messages: one.list.slice(from), hidden: 0 })
231    },
232    send: async (threadId, text) => {
233      const one = threads.get(threadId)
234      if (one === undefined) return ended()
235      const line = text.trim()
236      if (line.length === 0 || line.length > 500) return { ok: false, code: 'invalid', copyKey: 'dm.too_long' }
237      const nowMs = await now()
238      const mine = message('me', line, nowMs)
239      if (!one.list.some(m => m.from === 'me')) one.replyAt = nowMs + REPLY_AFTER_MS
240      one.list.push(mine)
241      one.read = one.list.length
242      return ok(mine)
243    },
244    hidden: async threadId => (threads.has(threadId) ? ok([]) : ended()),
245    end: async threadId => {
246      const one = threads.get(threadId)
247      if (one !== undefined) blocked.add(one.poster.handle)
248      threads.delete(threadId)
249      return ok(null)
250    },
251    // api.md §11: photos answer 501 until the age gate (E03-S7) is built
252    photo: async () => ({ ok: false, code: 'not_implemented' }),
253    postPhoto: async () => ({ ok: false, code: 'not_implemented' }),
254    block: async handle => {
255      blocked.add(handle)
256      threads.delete(`fake-thread-${handle}`)
257      return ok(null)
258    },
259    report: async () => ok({ copyKey: 'report.sent' }),
260  }
261}
262
types/index.d.ts 166 lines
1// Dooryard's contract (codename yard in paths): the values the band and the pane draw from ($.state,
2// held by the host for the session) and the keys the mod keeps on this
3// machine ($.store, shared by every session, 4 MiB in all). The shapes
4// follow contracts/api.md v1.0 in camelCase.
5
6/** A card as a viewer sees it (api.md §5): one line of text, up to 80 characters. */
7export type YardCard = {
8  id: string
9  text: string
10  /** The poster's handle, without the `@`. */
11  handle: string
12  /** The poster's own city setting. */
13  city: string
14  /** The poster's wall-clock time, `HH:MM`, from the server. */
15  localTime: string
16  hasPhoto: boolean
17  /** False once I said hi on this card or to this person in 24 hours. */
18  canHi: boolean
19}
20
21/** One open thread, as the State lists it (api.md §4). */
22export type YardThreadInfo = {
23  id: string
24  handle: string
25  unread: number
26  hidden: number
27}
28
29/** What the 5-second poll answers (api.md §4, the State object). */
30export type YardServerState = {
31  /** Other people with a live turn, me excluded. */
32  waiting: number
33  /** Hellos to me, unopened, under 24 hours old. */
34  hiWaiting: number
35  /** My live card and the hellos it got, or null. */
36  myCard: { id: string; hellos: number } | null
37  threads: YardThreadInfo[]
38  /** `me.age_gate`: the one-time age check photo cards need (api.md §11). */
39  ageGate: 'none' | 'pending' | 'pass' | 'fail'
40  /** Lines the server returns once, for the next pane open (api.md §4). */
41  notices: YardNotice[]
42  /** An incident switch's band line while it is on, else null (api.md 1.3, `band_notice`). */
43  bandNotice: string | null
44}
45
46/** One line the server returns once (api.md §4, `notices`): a copy key and its values. */
47export type YardNotice = { copyKey: string; params: Record<string, string> }
48
49/** A card's photo as the terminal draws it in an `Image` (api.md §11). */
50export type YardPhoto = {
51  cardId: string
52  /** A whole PNG, base64, at most 2 MiB decoded. */
53  png: string
54  columns: number
55  rows: number
56  alt: string
57}
58
59/**
60 * A thread opened by a mutual hello (api.md §6), as the pane shows its head.
61 * `city` and `localTime` are empty when the thread came from the State list.
62 */
63export type YardThread = {
64  id: string
65  handle: string
66  city: string
67  localTime: string
68}
69
70/** One DM (api.md §7, the Message object). Text only. */
71export type YardMessage = {
72  id: string
73  from: 'me' | 'them'
74  text: string
75  sentAt: string
76  /** Show `checkin.bother` under it: it came through a nudge or the hidden folder. */
77  checkin: boolean
78}
79
80/** An opened hello: who said hi (api.md §6, POST /hellos/open). */
81export type YardHello = {
82  id: string
83  handle: string
84  city: string
85  localTime: string
86  canHiBack: boolean
87}
88
89/** A pairing the pane shows while the person signs in on the web (api.md §3). */
90export type YardPairing = {
91  /** 6 characters, shown in the pane; alone it grants nothing. */
92  code: string
93  /** The https sign-in page, drawn as a Link. */
94  url: string
95  expiresAt: string
96}
97
98/** A DM the server nudged (409): Enter sends it again with `confirm`. */
99export type YardNudge = { text: string; copyKey: string }
100
101/**
102 * What the pane shows: the card, the post field, the report picker, an
103 * opened hello, a thread, the sign-in code, the photo path, the appeal link.
104 */
105export type YardPaneMode = 'card' | 'post' | 'report' | 'hello' | 'reply' | 'signin' | 'photo' | 'appeal'
106
107/** api.md §9: the report categories, no free text. */
108export type YardReportCategory = 'spam' | 'harassment' | 'sexual' | 'hate' | 'other'
109
110/**
111 * Where Yard stands in the turn: `idle` (nothing draws), `armed` (a turn
112 * runs, under 2 seconds in), `band` (the band shows), `handback` (the turn
113 * ended, the countdown runs).
114 */
115export type YardPhase = 'idle' | 'armed' | 'band' | 'handback'
116
117/** The keys Yard keeps in `$.store`, with their values. */
118export type YardStore = {
119  /** False after `/yard off`, until `/yard on`. Absent reads as true. */
120  enabled: boolean
121  /** Handles I blocked, without the `@`. Hidden on this machine at once. */
122  blocked: string[]
123  /** The sign-in token the pairing endpoint handed over. Absent until sign-in. */
124  token: string
125  /** True once the person has seen the posting rules (`post.first_time`). */
126  seenPostRules: boolean
127}
128
129declare module 'claude-code' {
130  interface PluginState {
131    dooryard: {
132      phase: YardPhase
133      /** Seconds left in the hand-back countdown. */
134      countdown: number
135      /** The last State the server answered this turn; null between turns. */
136      server: YardServerState | null
137      /** The card dealt to me for this wait. */
138      card: YardCard | null
139      /** One line the band shows instead of the count: a backend problem. */
140      problem: string | null
141      /** What the pane shows. */
142      mode: YardPaneMode
143      /** The thread a mutual hello opened, while this session knows it. */
144      thread: YardThread | null
145      /** The messages of that thread the pane has. */
146      messages: YardMessage[]
147      /** Messages of that thread in the hidden folder. */
148      hidden: number
149      /** The hello opened from the pane, while it shows it. */
150      hello: YardHello | null
151      /** The DM the server nudged, waiting on Enter. */
152      nudge: YardNudge | null
153      /** The sign-in code and link while the pane shows them. */
154      pairing: YardPairing | null
155      /** The photo of the card dealt to me, when it has one and the terminal draws it. */
156      photo: YardPhoto | null
157      /** The photo path typed once; the second Enter, after `photo.permanent`, posts it. */
158      photoPath: string | null
159      /** The age-check link the server asked for, while the photo field shows it. */
160      ageGateUrl: string | null
161      /** Notices kept until the pane has shown them and closed. */
162      notices: YardNotice[]
163    }
164  }
165}
166