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

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.
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.
plugin/hooks/register.tsx, loaded by Claude Code's mods API. No MCP server, no shell commands, no package launchers, no postinstall.$.env, $.process, $.fs.write or $.model./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.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.
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:
| When | What |
|---|---|
| Every call except pairing and the interface text | Your session token |
| Session start | Nothing 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 starts | The surface. Never the content of the turn |
| While a turn runs, every 5 seconds | The turn's id, to get the waiting count and your dealt card |
| A turn ends | The turn's id |
| You post a card | The card text (up to 80 characters) |
| You press hi | The card's id. A hello carries no text |
| You write in a thread | The text you typed, and whether you confirmed it after a "hold on" check |
| You block | The other person's handle |
| You report | The card or thread id, and the category |
| You post a photo | The 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().
$.fs.read.$.session.surfaces), to draw the band or the status line in the right place.$.session.version), sent once when you pair.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.
Every rule runs on Dooryard's server. The mod on your machine only shows them, because anyone can edit a mod they run.
What Dooryard cannot promise:
Intimate image of you posted without consent? Report it at https://<app domain>/takedown. We remove it within 48 hours of a valid request.
Dooryard is not made or endorsed by Anthropic.
Privacy policy: https://<app domain>/privacy.
Write to security@<app domain>. Please do not open a public issue for a security problem. We acknowledge within 2 business days.
MIT. See LICENSE.
plugin/: the mod (manifest, hooks module, types, tests).apps/web/: the web app and API.contracts/: the API contract and the data map.hooks/register.tsx 1150 lines1import { 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}
1150hooks/band.ts 40 lines1import 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}
40hooks/backend.ts 106 lines1import 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}
106hooks/copy.ts 154 lines1// 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}
154hooks/photo.ts 21 lines1// 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}
21hooks/api.ts 226 lines1import 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}
226hooks/fake.ts 262 lines1import 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}
262types/index.d.ts 166 lines1// 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