SLOPSHOPPER

playpen

The sessions you are working in right now as small cards above the prompt: a status lamp, a short label, what just happened, and one press to answer or jump…

newbandguardcommandtoastprompt
v0.9.3-dev.3MITupdated 2026-10-08KytioisaCat/playpen
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · playpen
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /board ╭──────────────────────────────╮ │ ● Untitled session ✦ ► × │ │ I updated src/auth.ts… │ ╰──────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────╮ │ ● Untitled session ✦ ► × │ │ I updated src/auth.ts… │ ╰──────────────────────────────╯
README

playpen

Who needs attention? Your other Claude Code sessions as small cards above the prompt: a lamp for the state, a short label, what just happened, and one press to answer or jump there.

A pixel-art figure points at a Claude Code window with three session cards above the prompt: one green, one yellow, one red

A mod for Claude Code, built on the plugin hooks the desktop app's Code tab and the terminal share. The name is the playpen: the sessions play on their own, and you look up when one of them calls. MIT, one file, nothing leaves your machine except the few words on each card.

Why

Run four or five sessions at once and the question is always the same: which one is waiting for me? The sidebar says which sessions exist, not which one just asked something. playpen keeps the sessions you are actually working in in view, right where you type, and turns red when one of them needs you. You answer from where you are, or jump there.

What you see

The playpen band above the prompt in the Claude desktop app: six cards in two rows, one red with an open question

| | | | :- | :- | | Lamp | yellow while Claude works · green when the turn is done · red when the session needs you (a permission dialog or a question) · grey while the session rests (its process stopped) | | Label | what the session is about, in a few words | | Gist | the state of its latest reply, in a few words; red while a question is open | | ✦ | hand the session off to a fresh one with a brief of its work (press twice: the first press arms it) | | ► | answer from here: the open question with its options, or the suggested next prompt with a send button. A permission dialog is the exception: see below | | × | hide the card until you write in that session again, in every session's band |

A small model writes the label and gist in the language of the reply; each pair is written once and cached.

  • A card exists because you wrote in that session. It appears at your first prompt there and stays for 8 hours after you last wrote in it, until you press × or archive the session. When the app stops an idle session's process, or you restart the app, the card rests: grey, with its last summary, and wakes up when you open that session again. A red card stays until you have dealt with it.
  • Click the label to jump to that session.
  • Permission dialogs are answered in their own session. A red card for a permission shows what it asks to run (Bash: git push origin main) and an open session button. Claude Code draws that dialog itself and lets no plugin answer it, since the answer authorises an action; playpen tells you where to go, and you approve or deny there. Questions (AskUserQuestion) are plain answers and can be given from any card.
  • Places belong to projects. The first card from a folder takes the next free place; a later session in the same folder sits with it. Nothing moves when activity changes, and nothing moves under the pointer.
  • Three per row, two rows at most by default: six sessions in two lines above the prompt. With three or fewer it is one line.
  • /board collapses or expands the band.

Install

From any Claude Code session:

/plugin marketplace add KytioisaCat/playpen
/plugin install playpen@kytioisacat
/reload-plugins

The install dialog asks for four settings; the defaults are fine.

| Setting | Default | What it does | | :- | :- | :- | | Hours on the board | 8 | A card stays this many hours after you last wrote in its session. A red card stays until handled. | | Maximum cards | 6 | How many cards the band shows at most, three per row. | | Short labels | on | Let a small model write the label and gist. Off, the card shows the title and the latest reply cut short. | | Label model | haiku | Model alias or id for the labels. | | Red after, in auto mode | 30 s | How long a call may wait for a permission in auto mode before the card turns red. See Permissions in auto mode. |

To try it without installing, clone the repository and start a session with claude --plugin-dir /path/to/playpen.

Hand-off

When a session's context has grown long, press ✦ twice on its card. The session writes a brief of its work — the goal, what was done, the decisions and why, the state of the files, the open problems, the next steps — and saves it to <project>/.claude/playpen/handoff-<stamp>-<title>.md. The app's new-session page opens with a short opener filled in; the app creates the session when you send that prompt, so one Enter is yours. The new session's own playpen then gives the brief to the model as hidden context of that first prompt, moves the session to the project folder if the app opened it elsewhere (you approve the folder once), and keeps the old session's place in the sidebar: its pin and its custom group. The old card retires; the old session stays as it was.

The model is the one thing it cannot carry. The app starts every new session on the model last picked in its menu, whichever session asked for the hand-off, and nothing a session does from inside changes that choice or what the app shows. So when the two differ, playpen answers the first reply with the previous session's model and tells you, in a toast and a transcript line, to pick that model in the menu if you want to go on with it; otherwise the next messages use the app's choice. When the models are the same, nothing is said. This stays until the app lets a session be started on a model.

Permissions in auto mode

In auto mode the app's classifier decides each permission itself, usually in a second or two, sometimes in ten or more, and only now and then leaves it to you as a dialog. A plugin is told that a call needs a permission and when the call is done, but not whether a dialog is up in between: the app keeps that to itself. So in auto mode playpen turns a card red when a call is waiting for a permission and the session has not moved for a set time, 30 seconds by default (Red after, in auto mode): no call has started or ended. Calls the classifier lets through one by one keep the session moving, so a queue stays yellow. That keeps the classifier's thinking from flashing cards red, at two prices: a real dialog shows red up to that long after it appeared, and a single approved command that runs longer than the wait (a long build, a big copy) looks the same as a dialog and may show red until it ends. In the other modes a permission is always a dialog, and the card turns red within a few seconds.

Requirements and limits

  • Claude Code 2.1.286 or later. The mod API is early access and may change between releases; a release that breaks the band gets a fix here.
  • The desktop app gives the cards their titles and links and makes the jump work. In a plain terminal a card shows "Untitled session" and the jump copies a link.
  • macOS for the jump (open claude://…); elsewhere the link is copied to the clipboard.
  • The hand-off cannot choose the new session's model (above).
  • A permission dialog cannot be answered from another session: playpen shows what it asks and takes you there.
  • In auto mode a real permission dialog shows red only after the set wait (30 s by default), since the app does not tell plugins when its classifier hands a call to you.

How it works

The mod runs in every session. Each session writes one JSON file about itself to ~/.claude/playpen/sessions/: its title and link from the desktop app, its state from the mod's hooks (a tool call the engine put to a dialog, an AskUserQuestion with its options, the end of a turn), and the prompt suggestion. The hidden cards and the places on the board are one shared file, ~/.claude/playpen/board.json, so every band shows the same board. Every session's band reads that folder every few seconds; a file whose heartbeat is older than 45 seconds counts as ended, so a crashed session disappears on its own.

Answering from another session appends the text to that session's inbox, ~/.claude/playpen/inbox/<id>.json. The receiving mod reads its inbox every second, ends the turn that is waiting on the dialog, and submits the text as your own prompt. The hand-off goes the same way, with a marker the new session reads at start.

Development

The plugin loads in place from a clone registered as a local marketplace (claude plugin marketplace add /path/to/playpen), so an edit to hooks/register.tsx takes effect at /reload-plugins or the next session. claude plugin validate . reads the marketplace file; to see what the hooks module hooks and calls, validate a copy without .claude-plugin/marketplace.json.

The engine writes its type declarations into .claude-plugin/types/ the first time it loads the mod from this folder, and tsconfig.json extends them, so an editor or npx tsc -p . type-checks the module against the exact build you run.

Ideas, not planned

Things that would fit the board and may come if someone wants them:

  • A toast, and maybe a sound, when a card turns red or green, as an option off by default.
  • Digit hotkeys to jump to a card's session from the keyboard.
  • Tests under claude plugin test, and a listing in the Claude Directory.

Privacy

Everything stays on your machine except the label and gist, which come from one small model call per changed card on your own Claude plan. The call sees the session title and the start of the latest reply. Treat that like any other text you send to Claude.

License

MIT

Source 1 files
hooks/register.tsx 1575 lines
1import type { Register, EngineInterface, PluginOptions } from 'claude-code'
2
3// playpen: a band above the prompt with one small card per session you
4// are working in right now.
5//
6// The mod runs in every session, and each session writes one JSON file about
7// itself to ~/.claude/playpen/sessions/<id>.json: its title and link from
8// the desktop app, its exact state from the hooks below, the open question
9// with its options, the prompt suggestion, and the engine session id that
10// $.session.send addresses. Every session's band reads that folder.
11//
12// A card exists because you wrote in that session. It appears at your first
13// prompt there, stays while the session lives and you have written in it
14// within the window (red ones stay until handled), and goes when you press ×,
15// when the session ends, or when its heartbeat stops.
16//
17// A place on the board belongs to a project, not a session: the first card of
18// a folder takes the next free place, and a later session in the same folder
19// sits with it. So a hand-off, which starts a fresh session in the same folder
20// with a brief the old one wrote, lands where the old card was.
21
22type State = 'working' | 'done' | 'waiting' | 'ended'
23
24type Question = { text: string; options: string[] }
25
26type Own = {
27  id: string // the app's session id (local_...), the card's identity
28  engineId: string // what $.session.id() answers; the send address
29  title: string
30  link: string
31  cwd: string
32  state: State
33  snippet: string
34  question: Question | null
35  permission: string | null // what a permission dialog asks to do, when one is up
36  suggestion: string | null
37  lastPromptAt: number
38  updatedAt: number
39  isRetired?: boolean // handed off to a new session; off the board for good
40}
41
42type Card = Own & { label: string; gist: string }
43
44type Mini = { key: string; label: string; gist: string }
45
46const POLL_MS = 3_000
47const INBOX_MS = 1_000
48const HEARTBEAT_MS = 10_000
49const STALE_MS = 45_000
50// An ask still open after this is a dialog. In auto mode an ask goes to the
51// app's classifier first, a model call that takes seconds (13 s seen), and a
52// dialog only follows when it cannot decide; a real dialog waits for you, so
53// a long bound there costs nothing but a late red.
54const ASK_MS = 2_500
55let askAutoMs = 30_000 // the person's setting: how long auto mode's classifier gets before red
56let sessionMode: string | null = null // the app's permission mode for this session
57const LABEL_MAX = 18
58const GIST_MAX = 22
59const LABEL_ASK = 16
60const GIST_ASK = 20
61const SNIPPET_MAX = 400 // of the latest reply, kept for the expanded card
62const PER_ROW = 3
63const MIN_CARD = 24
64const MAX_CARD = 40
65// a message from another playpen: consumed by the receiving mod, and
66// either submitted as the person's own prompt there or, for HANDOFF, acted on
67const RELAY = '[playpen] '
68const HANDOFF = 'handoff'
69
70const HANDOFF_ASK =
71  'Summarize our work and conversation so far so it can be handed to a new session with a fresh context: ' +
72  'the goal, what has been done, the decisions made and why, the current state of the code or files, ' +
73  'open problems, and the next steps. Write it as a brief for someone who has not seen this conversation. ' +
74  'Reply with the brief only.'
75
76let home = ''
77let ownDir = ''
78let me: Own | null = null
79let hasPrompted = false
80let heldTurnId: string | null = null
81let isHandingOff = false
82let handoffTurnId: string | null = null // the turn that writes the brief
83// the previous session's model, for a session the hand-off link opened. The
84// app starts the session on the model last picked, and nothing from in here
85// changes that choice or what the app shows. So the first turn alone, the
86// reply to the brief, is answered by the previous model, and the person is
87// told to pick it in the model menu to go on with it. Until the app lets a
88// session be started on a model, nothing more is tried.
89let wantedModel: string | null = null
90let isBriefGiven = false
91let isMachineTurn = false // the latest prompt came from a routine or a task, not you
92// ✦ takes two presses: the first arms it for a few seconds, the second fires
93let armedId: string | null = null
94const ARM_MS = 4_000
95// ► opens the card's answer on its second row: the question's options, or
96// the prompt suggestion with a send button; it closes on its own
97let openId: string | null = null
98const OPEN_MS = 12_000
99let deck: Card[] = []
100let lastDeckJson = ''
101let isCollapsed = false
102let isPolling = false
103let isSummarizing = false
104let hidden: Record<string, number> = {}
105let minis: Record<string, Mini> = {}
106let slots: string[] = [] // folders, in the order their first card appeared
107let order: string[] = [] // session ids, in the order they first appeared
108
109// Tool calls in flight and which of them have a dialog up. Several tools can
110// run at once, so one call ending must not clear another call's wait.
111const inFlight = new Map<string, { tool: string; input: string }>()
112const waitingIds = new Set<string>()
113let isPromptUp = false
114
115// --- options -----------------------------------------------------------------
116
117let windowMs = 8 * 60 * 60 * 1000
118let maxCards = 6
119let shouldSummarize = true
120let summaryModel = 'haiku'
121
122function readOptions(options: PluginOptions) {
123  const num = (v: unknown, fallback: number) => (typeof v === 'number' && v > 0 ? v : fallback)
124  windowMs = num(options.window_hours, 8) * 60 * 60 * 1000
125  maxCards = Math.min(12, num(options.max_cards, 6))
126  shouldSummarize = options.summarize !== false
127  summaryModel = typeof options.model === 'string' && options.model ? options.model : 'haiku'
128  askAutoMs = Math.min(300, Math.max(3, num(options.auto_red_seconds, 30))) * 1000
129}
130
131// --- small helpers -------------------------------------------------------------
132
133const trim = (s: string, n: number) => {
134  const flat = s.replace(/\s+/g, ' ').trim()
135  return flat.length > n ? flat.slice(0, Math.max(1, n - 1)) + '…' : flat
136}
137
138const lampColor = (state: State) =>
139  state === 'working' ? 'yellow' : state === 'waiting' ? 'red' : state === 'done' ? 'green' : 'gray'
140
141const stateWord = (state: State) =>
142  state === 'working' ? 'working' : state === 'waiting' ? 'needs you' : state === 'done' ? 'done' : 'ended'
143
144const mcpText = (r: { content: { type: string; text?: string }[] }) =>
145  r.content.map(b => (b.type === 'text' ? (b.text ?? '') : '')).join('')
146
147// The AskUserQuestion input, cut down to the first question and its labels.
148function toQuestion(input: unknown): Question | null {
149  const q = (input as { questions?: { question?: string; options?: { label?: string }[] }[] })?.questions?.[0]
150  if (!q?.question) return null
151  const options = (q.options ?? [])
152    .map(o => o.label ?? '')
153    .filter(Boolean)
154    .slice(0, 4)
155  return { text: q.question, options }
156}
157
158async function init($: EngineInterface) {
159  if (home) return
160  home = (await $.env.get('HOME')) ?? '/tmp'
161  ownDir = `${home}/.claude/playpen/sessions`
162  minis = ((await $.store.get('minis-v2')) as Record<string, Mini> | undefined) ?? {}
163  if (!(await readBoard($))) {
164    // the first band on the shared board brings what its own store kept
165    await writeBoard($, {
166      hidden: ((await $.store.get('hidden')) as Record<string, number> | undefined) ?? {},
167      slots: ((await $.store.get('slots')) as string[] | undefined) ?? [],
168      order: ((await $.store.get('order')) as string[] | undefined) ?? [],
169    })
170  }
171  await syncBoard($)
172}
173
174// --- the board every session shares ----------------------------------------------
175
176// Hidden cards and the places on the board belong to the person, not to one
177// session: every band reads the same file each poll, and a write merges into
178// what is there, so a card closed in one session is closed in all of them.
179type Board = { hidden: Record<string, number>; slots: string[]; order: string[] }
180
181const boardPath = () => `${home}/.claude/playpen/board.json`
182
183async function readBoard($: EngineInterface): Promise<Board | null> {
184  try {
185    if (!(await $.fs.exists(boardPath()))) return null
186    const b = JSON.parse(await $.fs.read(boardPath())) as Partial<Board>
187    return { hidden: b.hidden ?? {}, slots: b.slots ?? [], order: b.order ?? [] }
188  } catch {
189    return null // half-written: this round keeps what it had
190  }
191}
192
193// The sessions the app still lists (not archived, not deleted), asked now and
194// then: a card whose process stopped rests on the board while its session is
195// in this list, and goes when it is archived. Unknown, every card rests.
196let appIds: Set<string> | null = null
197let appIdsAt = 0
198const APP_IDS_MS = 30_000
199
200async function syncAppIds($: EngineInterface) {
201  const now = await $.clock.now()
202  if (now - appIdsAt < APP_IDS_MS) return
203  appIdsAt = now
204  try {
205    const rows = await listApp($, 100)
206    if (rows.length > 0) appIds = new Set(rows.map(r => r.sessionId ?? ''))
207  } catch {
208    // keep what was known
209  }
210}
211
212const union = (a: string[], b: string[]) => [...a, ...b.filter(x => !a.includes(x))]
213
214async function syncBoard($: EngineInterface) {
215  const b = await readBoard($)
216  if (b) {
217    hidden = b.hidden
218    slots = b.slots
219    order = b.order
220  }
221}
222
223async function writeBoard($: EngineInterface, patch: Partial<Board>) {
224  const b = (await readBoard($)) ?? { hidden: {}, slots: [], order: [] }
225  const next: Board = {
226    hidden: { ...b.hidden, ...(patch.hidden ?? {}) },
227    slots: union(b.slots, patch.slots ?? []).slice(-32),
228    order: union(b.order, patch.order ?? []).slice(-64),
229  }
230  hidden = next.hidden
231  slots = next.slots
232  order = next.order
233  try {
234    await $.fs.write(boardPath(), JSON.stringify(next))
235  } catch {
236    // unwritable: this band still keeps it in memory
237  }
238}
239
240// --- this session's own file --------------------------------------------------
241
242// The app knows this session's id, title and link; the engine's own id is the
243// transcript's name and differs after a resume.
244type AppSession = {
245  sessionId?: string
246  title?: string
247  link?: string
248  cwd?: string
249  model?: string
250  isArchived?: boolean
251  pinned?: boolean
252  permissionMode?: string
253  group?: { id: string; name: string } | null
254}
255
256async function selfApp($: EngineInterface): Promise<AppSession | null> {
257  try {
258    const r = await $.mcp.call('ccd_session_mgmt', 'get_session', { session_id: 'self' })
259    if (r.isError) return null
260    return JSON.parse(mcpText(r)) as AppSession
261  } catch {
262    return null
263  }
264}
265
266async function writeMe($: EngineInterface, patch: Partial<Own> = {}) {
267  if (!me || !hasPrompted) return
268  me = { ...me, ...patch, updatedAt: await $.clock.now() }
269  try {
270    await $.fs.write(`${ownDir}/${me.id}.json`, JSON.stringify(me))
271  } catch {
272    // unwritable folder: the band still knows this session from memory
273  }
274}
275
276// What a permission dialog asks to do, in a line: the tool and the part of
277// its input a person decides on.
278function describeCall(tool: string, input: string): string {
279  let args: Record<string, unknown> = {}
280  try {
281    args = JSON.parse(input) as Record<string, unknown>
282  } catch {
283    // the tool's name alone
284  }
285  const pick = ['command', 'file_path', 'path', 'url', 'pattern', 'query'].map(k => args[k]).find(v => typeof v === 'string')
286  const name = tool.startsWith('mcp__') ? tool.split('__').slice(1).join(' ') : tool
287  return trim(pick ? `${name}: ${pick as string}` : name, 160)
288}
289
290function pendingPermission(): string | null {
291  for (const id of waitingIds) {
292    const call = inFlight.get(id)
293    if (call && call.tool !== 'AskUserQuestion') return describeCall(call.tool, call.input)
294  }
295  return isPromptUp ? 'a permission dialog' : null
296}
297
298// Calls that asked for a permission and have not ended, and when the session
299// last moved: a call started or ended. The app does not tell a plugin when a
300// dialog is up, so a session counts as waiting on you when a call is asking
301// and nothing has moved for the set time; a queue of calls the classifier
302// lets through one by one keeps moving, and stays yellow.
303const asking = new Set<string>()
304const timedIds = new Set<string>() // the asks this wait turned red
305let movedAt = 0
306let isCheckSet = false
307
308const askWaitMs = () => (sessionMode === 'auto' ? askAutoMs : ASK_MS)
309
310async function moved($: EngineInterface) {
311  movedAt = await $.clock.now()
312  if (timedIds.size > 0) {
313    for (const id of timedIds) waitingIds.delete(id)
314    timedIds.clear()
315    await settle($)
316  }
317  scheduleCheck($, askWaitMs())
318}
319
320function scheduleCheck($: EngineInterface, ms: number) {
321  if (isCheckSet || asking.size === 0) return
322  isCheckSet = true
323  $.clock.after(ms, () => {
324    isCheckSet = false
325    void checkStill($)
326  })
327}
328
329async function checkStill($: EngineInterface) {
330  const live = [...asking].filter(id => inFlight.has(id))
331  if (live.length === 0) return
332  const still = (await $.clock.now()) - movedAt
333  if (still < askWaitMs()) {
334    scheduleCheck($, askWaitMs() - still)
335    return
336  }
337  for (const id of live) {
338    if (!waitingIds.has(id)) {
339      waitingIds.add(id)
340      timedIds.add(id)
341    }
342  }
343  await settle($)
344}
345
346const settle = ($: EngineInterface) =>
347  writeMe($, {
348    state: waitingIds.size > 0 || isPromptUp ? 'waiting' : 'working',
349    permission: pendingPermission(),
350  })
351
352async function lastReply($: EngineInterface) {
353  const last = [...(await $.session.messages())].reverse().find(m => m.role === 'assistant' && m.text.trim())
354  return last ? last.text.trim() : ''
355}
356
357const mySnippet = async ($: EngineInterface) => trim(await lastReply($), SNIPPET_MAX)
358
359// The heartbeat: proves the session is alive, and picks up a title the app
360// gave the session after its first prompt.
361async function heartbeat($: EngineInterface) {
362  if (!me) return
363  const isMadeUp = me.id === `local_${me.engineId}`
364  if (!hasPrompted && !isMadeUp) return
365  const self = await selfApp($)
366  if (self?.permissionMode) sessionMode = self.permissionMode
367  // The app may not have answered at start, and the card then carries a
368  // made-up id whose link the app does not know: take the app's own once it
369  // answers, and retire the file under the made-up one.
370  if (me && self?.sessionId && self.sessionId !== me.id) {
371    const stale = me
372    me = { ...me, id: self.sessionId, link: self.link ?? `claude://claude.ai/epitaxy/${self.sessionId}` }
373    if (hasPrompted) {
374      try {
375        await $.fs.write(`${ownDir}/${stale.id}.json`, JSON.stringify({ ...stale, isRetired: true }))
376      } catch {
377        // its heartbeat stops, so it ends on its own
378      }
379    } else {
380      await resumeCard($)
381    }
382  } else if (me && self?.link && self.link !== me.link) {
383    me = { ...me, link: self.link }
384  }
385  if (!hasPrompted) return
386  await writeMe($, self?.title ? { title: self.title } : {})
387}
388
389// A session whose card is still on the board takes it back when it starts:
390// the app stops idle sessions' processes and starts them again when opened,
391// and that is the same session you were working in, not a look at an old one.
392async function resumeCard($: EngineInterface) {
393  if (!me || hasPrompted) return
394  try {
395    const path = `${ownDir}/${me.id}.json`
396    if (!(await $.fs.exists(path))) return
397    const was = JSON.parse(await $.fs.read(path)) as Own
398    const now = await $.clock.now()
399    const hiddenAt = hidden[me.id]
400    if (was.isRetired || now - was.lastPromptAt > windowMs) return
401    if (hiddenAt !== undefined && was.lastPromptAt <= hiddenAt) return
402    hasPrompted = true
403    await writeMe($, { lastPromptAt: was.lastPromptAt, state: 'done' })
404    void refresh($)
405  } catch {
406    // an unreadable file: the card comes back at your next prompt
407  }
408}
409
410// --- every session's file -----------------------------------------------------
411
412async function readAll($: EngineInterface): Promise<Own[]> {
413  const all = new Map<string, Own>()
414  let entries: { name: string; kind: string }[] = []
415  try {
416    entries = await $.fs.list(ownDir)
417  } catch {
418    return me && hasPrompted ? [me] : []
419  }
420  const now = await $.clock.now()
421  for (const entry of entries) {
422    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
423    try {
424      const o = JSON.parse(await $.fs.read(`${ownDir}/${entry.name}`)) as Own
425      // a process that died mid-turn never wrote 'ended'; the heartbeat tells
426      if (o.state !== 'ended' && now - o.updatedAt > STALE_MS) o.state = 'ended'
427      all.set(o.id, o)
428    } catch {
429      // half-written file: skip it this round
430    }
431  }
432  if (me && hasPrompted) all.set(me.id, me)
433  return [...all.values()]
434}
435
436// --- short labels from a small model ----------------------------------------
437
438// the label and gist are made for this title, reply and open question; a
439// change in any of them is a new pair to summarize
440const miniKey = (card: Pick<Card, 'title' | 'snippet' | 'question' | 'permission'>) =>
441  `${card.title}\u0000${card.snippet}\u0000${card.question?.text ?? ''}\u0000${card.permission ?? ''}`
442
443// One card at a time, so a burst of activity costs one small call per poll
444// and never blocks the band.
445async function summarizeNext($: EngineInterface) {
446  if (!shouldSummarize || isSummarizing) return
447  const card = deck.find(c => c.state !== 'working' && minis[c.id]?.key !== miniKey(c))
448  if (!card) return
449  isSummarizing = true
450  try {
451    const folder = card.cwd.split('/').pop() ?? ''
452    const r = await $.model.complete({
453      model: summaryModel,
454      effort: 'low',
455      maxTokens: 80,
456      timeoutMs: 10_000,
457      system:
458        'You write labels for small cards that show what is going on in several coding sessions at once. ' +
459        'Reply with exactly one JSON line: {"label": "...", "gist": "..."}. ' +
460        `"label" (max ${LABEL_ASK} characters): the topic of the session, so the reader knows which chat it is. Prefer the concrete subject (feature, bug, component, document) over generic words. ` +
461        `"gist" (max ${GIST_ASK} characters): the current status from the latest reply. Lead with what matters: done, needs a decision or input, error, blocked, in progress, or a question asked. Telegram style, no trailing period. ` +
462        'Write in the language of the latest reply; if there is none, the language of the title. Keep proper nouns and technical terms as they are. No other text.',
463      prompt:
464        `Project folder: ${folder}\nSession title: ${card.title}\nLatest reply from Claude: ${card.snippet || '(nothing yet)'}` +
465        (card.question ? `\nOpen question to the person right now (the gist should say what is asked): ${card.question.text}` : '') +
466        (card.permission ? `\nA permission dialog is waiting for the person right now (the gist should say what it asks to do): ${card.permission}` : ''),
467    })
468    if (r.isAnswered) {
469      const match = r.text.match(/\{[\s\S]*\}/)
470      const parsed = match ? (JSON.parse(match[0]) as { label?: string; gist?: string }) : {}
471      const mini: Mini = {
472        key: miniKey(card),
473        label: trim(parsed.label || card.title, LABEL_MAX),
474        gist: trim(parsed.gist || card.snippet, GIST_MAX),
475      }
476      minis = { ...minis, [card.id]: mini }
477      await $.store.set('minis-v2', minis)
478      lastDeckJson = '' // force a redraw on the next poll
479    }
480  } catch {
481    // a model that refuses or answers oddly: the card keeps its plain text
482  } finally {
483    isSummarizing = false
484  }
485}
486
487// --- the deck -------------------------------------------------------------------
488
489async function buildDeck($: EngineInterface): Promise<Card[]> {
490  const now = await $.clock.now()
491  await syncBoard($)
492  await syncAppIds($)
493  const cards: Card[] = []
494  for (const o of await readAll($)) {
495    if (o.isRetired) continue
496    // a stopped process is not a closed session: its card rests until the
497    // session is archived, you press ×, or the window passes
498    if (o.state === 'ended' && appIds && !appIds.has(o.id) && o.id !== me?.id) continue
499    const hiddenAt = hidden[o.id]
500    if (hiddenAt !== undefined && o.lastPromptAt <= hiddenAt) continue
501    // a red card stays until handled; the others fall off after the window
502    if (o.state !== 'waiting' && now - o.lastPromptAt > windowMs) continue
503    const mini = minis[o.id]
504    cards.push({
505      ...o,
506      label: mini?.label ?? trim(o.title, LABEL_MAX),
507      gist: mini?.gist ?? trim(o.snippet, GIST_MAX),
508    })
509  }
510
511  // a folder keeps its place; within it, sessions keep the order they came in
512  const newSlots = cards.map(c => c.cwd).filter((cwd, i, all) => !slots.includes(cwd) && all.indexOf(cwd) === i)
513  const fresh = cards.map(c => c.id).filter(id => !order.includes(id))
514  if (newSlots.length > 0 || fresh.length > 0) await writeBoard($, { slots: newSlots, order: fresh })
515  // a full board makes room from its resting cards, the longest unwritten first
516  const resting = cards.filter(c => c.state === 'ended').sort((a, b) => a.lastPromptAt - b.lastPromptAt)
517  const drop = new Set(resting.slice(0, Math.max(0, cards.length - maxCards)).map(c => c.id))
518  const shown = cards.filter(c => !drop.has(c.id))
519  shown.sort(
520    (a, b) => slots.indexOf(a.cwd) - slots.indexOf(b.cwd) || order.indexOf(a.id) - order.indexOf(b.id),
521  )
522  return shown.slice(0, maxCards)
523}
524
525async function refresh($: EngineInterface) {
526  if (isPolling) return
527  isPolling = true
528  try {
529    deck = await buildDeck($)
530    const json = JSON.stringify(deck)
531    if (json !== lastDeckJson) {
532      lastDeckJson = json
533      $.ui.invalidate('ui.render')
534    }
535    void summarizeNext($)
536  } finally {
537    isPolling = false
538  }
539}
540
541// --- actions on another session --------------------------------------------
542
543async function jump($: EngineInterface, card: Card) {
544  try {
545    const r = await $.process.run(['open', card.link])
546    if (r.exitCode !== 0) throw new Error(r.stderr)
547  } catch {
548    await $.ui.copy({ text: card.link })
549    $.ui.toast('Could not open the session; its link is on the clipboard')
550  }
551}
552
553// × hides the card until you write in that session again
554async function hide($: EngineInterface, card: Card) {
555  await writeBoard($, { hidden: { [card.id]: card.lastPromptAt } })
556  await refresh($)
557}
558
559// Leaves text in the inbox of a session that runs this mod. Its own poll
560// picks it up within a second and acts on it: a hand-off, or an answer it
561// submits as the person's own prompt. A file, not $.session.send, because a
562// send from a mod has no model request behind it for auto mode's permission
563// classifier to judge, and it is refused.
564const inboxPath = (id: string) => `${home}/.claude/playpen/inbox/${id}.json`
565
566async function readInbox($: EngineInterface, id: string): Promise<string[]> {
567  try {
568    if (!(await $.fs.exists(inboxPath(id)))) return []
569    const parsed = JSON.parse(await $.fs.read(inboxPath(id))) as unknown
570    return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === 'string') : []
571  } catch {
572    return []
573  }
574}
575
576async function relay($: EngineInterface, card: Card, text: string) {
577  try {
578    const waiting = await readInbox($, card.id)
579    await $.fs.write(inboxPath(card.id), JSON.stringify([...waiting, text]))
580    $.ui.toast(`Sent to ${card.label}: ${trim(text, 40)}`)
581  } catch (error) {
582    $.ui.toast(`Not delivered: ${trim(String(error), 60)}`)
583  }
584}
585
586// This session's side: whatever another playpen left for it.
587let isReadingInbox = false
588async function pollInbox($: EngineInterface) {
589  if (!me || isReadingInbox) return
590  isReadingInbox = true
591  try {
592    const texts = await readInbox($, me.id)
593    if (texts.length === 0) return
594    await $.fs.write(inboxPath(me.id), '[]')
595    for (const text of texts) {
596      if (text === HANDOFF) {
597        void startHandoff($)
598      } else {
599        await applyAnswer($, text)
600        $.ui.toast(`Playpen: ${trim(text, 40)}`)
601      }
602    }
603  } finally {
604    isReadingInbox = false
605  }
606}
607
608// What a relay does on arrival, also run directly for this session's own card:
609// end a turn that is waiting on a dialog, then submit the text as the person's
610// own prompt, so it answers the question instead of queueing behind it.
611async function applyAnswer($: EngineInterface, text: string) {
612  if (heldTurnId && (me?.question || me?.state === 'waiting')) {
613    try {
614      await $.turn.abort({ turnId: heldTurnId })
615    } catch {
616      // the turn had already ended; the prompt below runs when idle
617    }
618  }
619  void $.prompt.submit({ text, asUser: true })
620}
621
622// Sends an answer to a card's session: through the relay for another
623// session, directly for this one.
624async function answer($: EngineInterface, card: Card, isMe: boolean, text: string) {
625  openId = null
626  $.ui.invalidate('ui.render')
627  if (isMe) {
628    await applyAnswer($, text)
629    $.ui.toast(`Playpen: ${trim(text, 40)}`)
630  } else {
631    await relay($, card, text)
632  }
633}
634
635// ►: opens the answer row when the card has something to answer with.
636function pressAnswer($: EngineInterface, card: Card) {
637  if (card.state === 'ended') {
638    $.ui.toast('That session is resting; open it, and its card wakes up')
639    return
640  }
641  if (card.state === 'waiting' && card.permission) {
642    $.ui.toast(`Asks to run ${card.permission}. A permission is answered in its own session; open it to approve or deny.`)
643    return
644  }
645  const hasOptions = card.question !== null && card.question.options.length > 0
646  if (!hasOptions && !card.suggestion) {
647    $.ui.toast(card.question ? 'That question has no options; open the session' : 'Nothing to answer there yet')
648    return
649  }
650  openId = openId === card.id ? null : card.id
651  $.ui.invalidate('ui.render')
652  if (openId === card.id) {
653    $.clock.after(OPEN_MS, () => {
654      if (openId === card.id) {
655        openId = null
656        $.ui.invalidate('ui.render')
657      }
658    })
659  }
660}
661
662// When the engine's own suggestion service says nothing within a moment of
663// the turn ending, and the reply ends on a question or an offer, write the
664// reply the person would most likely send to accept it, so ► has something.
665async function suggestFallback($: EngineInterface, reply: string) {
666  if (!shouldSummarize || !me || !hasPrompted) return
667  const tail = reply.slice(-400)
668  if (!/\?/.test(tail)) return
669  const turnEndedAt = me.updatedAt
670  $.clock.after(2_500, () => {
671    void (async () => {
672      if (!me || me.suggestion || me.state !== 'done' || me.lastPromptAt > turnEndedAt) return
673      try {
674        const r = await $.model.complete({
675          model: summaryModel,
676          effort: 'low',
677          maxTokens: 60,
678          timeoutMs: 10_000,
679          system:
680            'An assistant just ended its reply with a question or an offer. Write the one short message the person ' +
681            'would most likely send back to accept it and let the work continue. Same language as the reply. ' +
682            'One line, no quotes, no explanation.',
683          prompt: tail,
684        })
685        if (r.isAnswered && r.text.trim() && me.state === 'done' && !me.suggestion) {
686          await writeMe($, { suggestion: trim(r.text, 200) })
687        }
688      } catch {
689        // no suggestion then; ► says so
690      }
691    })()
692  })
693}
694
695// --- hand-off: this session writes a brief, a fresh one starts from it -------
696
697// Step one, in the session being handed off: ask for the brief. The turn
698// that answers it finishes the job in finishHandoff.
699async function startHandoff($: EngineInterface) {
700  if (isHandingOff) return
701  isHandingOff = true
702  handoffTurnId = null
703  $.ui.log('hand-off: asking for the brief')
704  $.ui.toast('Hand-off: writing the brief…')
705  if (heldTurnId) {
706    try {
707      // ending the running turn raises its turn.complete; the brief's own
708      // turn is the next one to start, and only that one finishes the hand-off
709      await $.turn.abort({ turnId: heldTurnId })
710    } catch {
711      // the turn had already ended; the prompt below runs when idle
712    }
713  }
714  void $.prompt.submit({ text: HANDOFF_ASK, asUser: true })
715}
716
717// A hand-off started through the app's link leaves this marker; the new
718// session in that folder picks it up at start and takes the previous
719// session's model. The opener itself arrives through the link, sent by the
720// person's Enter: the app creates the session only then, so the mod cannot
721// send it first, and must not send it again.
722const pendingPath = () => `${home}/.claude/playpen/handoff/pending.json`
723// A session with no folder runs in a scratch workspace the app makes, and
724// the app may make a fresh one for the session the link opens; two scratch
725// workspaces count as the same place.
726const isScratch = (p: string) => p.includes('/scratch-workspaces/')
727const sameWorkspace = (a: string, b: string) => a === b || (isScratch(a) && isScratch(b))
728const PENDING_MS = 10 * 60 * 1000
729
730type Pending = {
731  cwd?: string
732  model?: string
733  briefPath?: string
734  title?: string
735  at?: number
736  pinned?: boolean
737  group?: { id: string; name: string } | null
738}
739
740// The marker is consumed once, by whichever of the start hooks reads it first.
741let pendingRead: Promise<Pending | null> | null = null
742
743function readPending($: EngineInterface): Promise<Pending | null> {
744  pendingRead ??= (async () => {
745    try {
746      await init($)
747      if (!(await $.fs.exists(pendingPath()))) return null
748      const pending = JSON.parse(await $.fs.read(pendingPath())) as Pending
749      const now = await $.clock.now()
750      if (!pending.at || now - pending.at > PENDING_MS) return null
751      await $.fs.write(pendingPath(), '{}')
752      return pending
753    } catch {
754      return null
755    }
756  })()
757  return pendingRead
758}
759
760// The brief, for the first turn's context: the new session reads it here,
761// whatever folder the app put it in, so no file read is asked of the model.
762let briefRead: Promise<string | null> | null = null
763
764function briefContext($: EngineInterface): Promise<string | null> {
765  briefRead ??= (async () => {
766    const pending = await readPending($)
767    if (!pending?.briefPath) return null
768    try {
769      const text = await $.fs.read(pending.briefPath)
770      return `The playpen mod handed this session off from the session "${pending.title ?? ''}". Its brief, also saved at ${pending.briefPath}:\n\n${text}`
771    } catch (error) {
772      $.ui.log(`hand-off: the brief at ${pending.briefPath} could not be read: ${trim(String(error), 160)}`)
773      return null
774    }
775  })()
776  return briefRead
777}
778
779async function takePendingHandoff($: EngineInterface, cwd: string) {
780  try {
781    const pending = await readPending($)
782    if (!pending) return
783    // the previous session's model, where the app started this one on another;
784    // first, before anything that waits, so the first request already goes there
785    const appModel = await $.session.model()
786    if (pending.model && pending.model !== appModel) {
787      wantedModel = pending.model
788      $.ui.log(
789        `hand-off: the previous session ran on ${pending.model}; the app started this one on the model last picked, ${appModel}. ` +
790        `This first reply is answered by ${pending.model}. To go on with ${pending.model}, pick it in the model menu; otherwise the next messages use ${appModel}.`,
791      )
792      $.ui.toast(
793        `MODEL: pick ${pending.model} in the model menu to go on as before. The previous session ran on it; this one started on ${appModel}. Only this first reply is answered by ${pending.model}.`,
794        { timeoutMs: 15_000 },
795      )
796    }
797    // the project folder, where the app opened the session elsewhere: the
798    // app's own move, which the person approves
799    if (pending.cwd && !isScratch(pending.cwd) && pending.cwd !== cwd) {
800      try {
801        const r = await $.mcp.call('ccd_directory', 'change_directory', { path: pending.cwd })
802        $.ui.log(`hand-off: folder ${r.isError ? 'not moved' : 'moved'} to ${pending.cwd} (${trim(mcpText(r), 160)})`)
803      } catch (error) {
804        $.ui.log(`hand-off: folder not moved to ${pending.cwd}: ${trim(String(error), 160)}`)
805      }
806    }
807    // the previous session's place in the sidebar: its group, then its pin
808    // (a move into a group takes a pin off). On "self", the app asks no one.
809    const placed: string[] = []
810    try {
811      if (pending.group?.id) {
812        const r = await $.mcp.call('ccd_sidebar', 'move_sessions', { session_ids: ['self'], group_id: pending.group.id })
813        if (!r.isError) placed.push(`filed under ${pending.group.name}`)
814        else $.ui.log(`hand-off: not filed under ${pending.group.name} (${trim(mcpText(r), 120)})`)
815      }
816      if (pending.pinned) {
817        const r = await $.mcp.call('ccd_sidebar', 'set_pinned', { session_id: 'self', pinned: true })
818        if (!r.isError) placed.push('pinned')
819        else $.ui.log(`hand-off: not pinned (${trim(mcpText(r), 120)})`)
820      }
821    } catch (error) {
822      $.ui.log(`hand-off: sidebar place not taken: ${trim(String(error), 120)}`)
823    }
824    if (placed.length) $.ui.log(`hand-off: this session is ${placed.join(' and ')}, as the previous one was`)
825    $.ui.log('hand-off: this session continues the one that wrote the brief')
826  } catch {
827    // an unreadable marker: the model stays the app's choice
828  }
829}
830
831// The app starts a session from the link in the folder and on the model of
832// its own last choices, whatever the link names. The new session's own
833// playpen sets the model and folder from the marker; from here, the
834// first session the app lists after the link opened is named once in the
835// log, with where it opened. (Setting its model from here through the app
836// asks the person each time; the marker needs no one.)
837const WATCH_MS = 5 * 60 * 1000
838
839async function listApp($: EngineInterface, limit = 20): Promise<AppSession[]> {
840  const r = await $.mcp.call('ccd_session_mgmt', 'list_sessions', { limit })
841  if (r.isError) return []
842  const rows = JSON.parse(mcpText(r))
843  return Array.isArray(rows) ? (rows as AppSession[]) : []
844}
845
846function watchNewSession($: EngineInterface, cwd: string, known: Set<string>, since: number) {
847  let isBusy = false
848  const timer = $.clock.every(POLL_MS, () => void tick())
849  async function tick() {
850    if (isBusy) return
851    isBusy = true
852    try {
853      if ((await $.clock.now()) - since > WATCH_MS) {
854        timer.cancel()
855        $.ui.log(`hand-off: no new session seen within ${WATCH_MS / 60_000} minutes`)
856        return
857      }
858      const fresh = (await listApp($)).find(s => s.sessionId && !s.isArchived && !known.has(s.sessionId))
859      if (!fresh?.sessionId) return
860      timer.cancel()
861      const where = fresh.cwd && !sameWorkspace(fresh.cwd, cwd) ? ` in ${fresh.cwd}, not this folder` : ' in this folder'
862      $.ui.log(`hand-off: the new session ${fresh.sessionId} opened${where}`)
863      // its own pin goes, now that the new session (which pins itself) exists
864      if ((await selfApp($))?.pinned) {
865        const r = await $.mcp.call('ccd_sidebar', 'set_pinned', { session_id: 'self', pinned: false })
866        $.ui.log(r.isError ? `hand-off: could not unpin this session (${trim(mcpText(r), 120)})` : 'hand-off: this session is unpinned; the new one takes the pin')
867      }
868    } catch (error) {
869      timer.cancel()
870      $.ui.log(`hand-off: could not watch for the new session: ${trim(String(error), 160)}`)
871    } finally {
872      isBusy = false
873    }
874  }
875}
876
877// Step two: save the brief as a file, start a fresh session in the same
878// folder that reads it first, and retire this card. The new session's card
879// takes this folder's place on the board. Each step leaves a line in the
880// transcript, so a failure can be read afterwards.
881//
882// Two ways to start the session. The app's `start_session` tool inherits
883// model, effort and permission mode, but it is behind a feature flag and not
884// offered to every session. The app's own deep link,
885// `claude://code/new?folder=…&q=…`, opens the new-session flow with the
886// folder chosen and the prompt filled in, and works everywhere the app does.
887async function finishHandoff($: EngineInterface) {
888  if (!me) return
889  const brief = await lastReply($)
890  if (!brief) {
891    isHandingOff = false
892    $.ui.log('hand-off: the turn ended without a brief; nothing started')
893    $.ui.toast('Hand-off: no brief was written')
894    return
895  }
896  const title = me.title.replace(/ \(continued\)$/, '')
897  const stamp = new Date(await $.clock.now()).toISOString().replace(/[:.]/g, '-').slice(0, 19)
898  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'session'
899  // inside the project, under its .claude folder: a file there is read
900  // without a permission prompt, one outside the working directory is not
901  const briefPath = `${me.cwd}/.claude/playpen/handoff-${stamp}-${slug}.md`
902  try {
903    await $.fs.write(
904      briefPath,
905      `# Hand-off brief: ${title}\n\nFrom a Claude Code session in \`${me.cwd}\`, written by that session on ${stamp.slice(0, 10)} so a fresh session can continue its work.\n\n${brief}\n`,
906    )
907  } catch (error) {
908    $.ui.log(`hand-off: could not save the brief: ${trim(String(error), 200)}`)
909  }
910  $.ui.log(`hand-off: brief of ${brief.length} characters saved to ${briefPath}`)
911  // short, because it rides in the link that opens the new session
912  const opener =
913    `Continue the work handed off from the session "${title}". ` +
914    'Its brief is in your context; it is the whole history. Pick up from its next steps.' +
915    (isScratch(me.cwd) ? '' : ` The work is in ${me.cwd}: if this session is not in that folder, move there first with change_directory.`)
916
917  let started = ''
918  try {
919    const r = await $.mcp.call('ccd_session', 'start_session', {
920      initiation: 'user_asked',
921      context: 'fresh',
922      title: trim(title, 50) + ' (continued)',
923      prompt: opener,
924      background:
925        `Started by the playpen mod as a hand-off from the session "${title}" in ${me.cwd}, ` +
926        'whose context was getting long. The brief that session wrote about its own work is at ' +
927        `${briefPath}; treat it as the whole history.`,
928      use_worktree: false,
929    })
930    const text = mcpText(r)
931    if (r.isError) throw new Error(text)
932    const newId = text.match(/local_[0-9a-f-]+/)?.[0]
933    $.ui.log(`hand-off: start_session answered: ${trim(text, 200)}`)
934    if (newId) {
935      // off the parent's thread in the sidebar: it is a continuation, not a side task
936      try {
937        await $.mcp.call('ccd_session_mgmt', 'detach_session', { session_id: newId })
938      } catch {
939        // stays nested; the board does not care
940      }
941      void jumpTo($, newId)
942    }
943    started = 'through start_session'
944  } catch (error) {
945    $.ui.log(`hand-off: start_session is not available here (${trim(String(error), 120)}); opening the app's new-session link`)
946    // The link opens the app's new-session page on this folder with the
947    // opener filled in. The app creates the session only when that prompt is
948    // sent, so one Enter is the person's; nothing else is. The new session's
949    // own playpen finds this marker at start and sets this session's
950    // model there, where the app started it on another.
951    // the model as the app names it, which its picker and set_session_model take
952    const self = await selfApp($)
953    let model = self?.model ?? ''
954    if (!model) {
955      try {
956        model = await $.session.model()
957      } catch {
958        // the new session keeps the app's default
959      }
960    }
961    const known = new Set((await listApp($).catch(() => [])).map(s => s.sessionId ?? ''))
962    known.add(me.id)
963    const at = await $.clock.now()
964    try {
965      await $.fs.write(pendingPath(), JSON.stringify({ cwd: me.cwd, model, briefPath, title, at, pinned: self?.pinned, group: self?.group } satisfies Pending))
966    } catch {
967      // the model is then the app's choice
968    }
969    // a scratch folder named in the link is taken as "no folder" and a fresh
970    // one is made, after a trust dialog about the old; so it is left out
971    const folder = isScratch(me.cwd) ? '' : `folder=${encodeURIComponent(me.cwd)}&`
972    const url = `claude://code/new?${folder}q=${encodeURIComponent(opener)}`
973    try {
974      const r = await $.process.run(['open', url])
975      if (r.exitCode !== 0) throw new Error(r.stderr || `open exited ${r.exitCode}`)
976      started = 'through the new-session link'
977      watchNewSession($, me.cwd, known, at)
978    } catch (error2) {
979      $.ui.log(`hand-off: could not open the new-session link: ${trim(String(error2), 200)}`)
980    }
981  }
982
983  if (started) {
984    await writeMe($, { isRetired: true })
985    $.ui.log(`hand-off: new session started ${started}; this card retires`)
986    $.ui.toast('Handed off: new session opening with the brief; this card retires')
987  } else {
988    try {
989      await $.ui.copy({ text: `Read the hand-off brief at ${briefPath} first, then continue from its next steps.` })
990    } catch {
991      // the path is in this transcript, just above
992    }
993    $.ui.toast('Hand-off: could not start a session; the brief is saved and its path copied')
994  }
995  isHandingOff = false
996  handoffTurnId = null
997}
998
999// The first press on ✦ arms the card's hand-off and shows it plainly; the
1000// second, within ARM_MS, runs it. A press elsewhere, or time, disarms.
1001function pressHandoff($: EngineInterface, card: Card, isMe: boolean) {
1002  if (armedId !== card.id) {
1003    armedId = card.id
1004    $.ui.invalidate('ui.render')
1005    $.clock.after(ARM_MS, () => {
1006      if (armedId === card.id) {
1007        armedId = null
1008        $.ui.invalidate('ui.render')
1009      }
1010    })
1011    return
1012  }
1013  armedId = null
1014  $.ui.invalidate('ui.render')
1015  void (isMe ? startHandoff($) : relay($, card, HANDOFF))
1016}
1017
1018async function jumpTo($: EngineInterface, appId: string | undefined) {
1019  if (!appId) return
1020  try {
1021    await $.process.run(['open', `claude://claude.ai/epitaxy/${appId}`])
1022  } catch {
1023    // the new session is in the sidebar either way
1024  }
1025}
1026
1027// --- the band -----------------------------------------------------------------
1028
1029export const register: Register = (on, options) => {
1030  readOptions(options)
1031
1032  on('session.start', async ($, e, next) => {
1033    await init($)
1034    const self = await selfApp($)
1035    sessionMode = self?.permissionMode ?? null
1036    const engineId = await $.session.id()
1037    const id = self?.sessionId ?? `local_${engineId}`
1038    const now = await $.clock.now()
1039    // Nothing is written until you prompt here: opening an old session to
1040    // look something up wakes its process, and that alone is not working in it.
1041    hasPrompted = false
1042    me = {
1043      id,
1044      engineId,
1045      title: self?.title ?? 'Untitled session',
1046      link: self?.link ?? `claude://claude.ai/epitaxy/${id}`,
1047      cwd: e.cwd,
1048      state: 'done',
1049      snippet: await mySnippet($),
1050      question: null,
1051      permission: null,
1052      suggestion: null,
1053      lastPromptAt: now,
1054      updatedAt: now,
1055    }
1056
1057    await $.command.register({ name: 'board', description: 'Show or hide the playpen band', immediate: true })
1058
1059    await resumeCard($)
1060
1061    // a session the hand-off link opened: send its opener without an Enter
1062    void takePendingHandoff($, e.cwd)
1063
1064    $.clock.every(POLL_MS, () => void refresh($))
1065    $.clock.every(INBOX_MS, () => void pollInbox($))
1066    $.clock.every(HEARTBEAT_MS, () => void heartbeat($))
1067    void refresh($)
1068
1069    return next(e)
1070  })
1071
1072  on('command.run', { command: 'board' }, async $ => {
1073    isCollapsed = !isCollapsed
1074    $.ui.invalidate('ui.render')
1075    return {}
1076  })
1077
1078  // Your first prompt is what puts this session on the board.
1079  on('prompt.submit', async ($, e, next) => {
1080    // a routine firing or a background task's notice is not you writing here
1081    isMachineTurn = e.origin.kind === 'scheduled-trigger' || e.origin.kind === 'task-notification'
1082    if (isMachineTurn) {
1083      if (hasPrompted) await writeMe($, { state: 'working', suggestion: null })
1084      return next(e)
1085    }
1086    hasPrompted = true
1087    await writeMe($, { state: 'working', suggestion: null, lastPromptAt: await $.clock.now() })
1088    if (isBriefGiven) return next(e)
1089    isBriefGiven = true
1090    const brief = await briefContext($)
1091    if (!brief) return next(e)
1092    $.ui.log(`hand-off: the brief, ${brief.length} characters, goes with this prompt`)
1093    return next({ ...e, context: [...(e.context ?? []), brief] })
1094  })
1095
1096  // A turn that starts without prompt.submit (a session started with a brief)
1097  // still means someone wrote here.
1098  on('turn.start', async ($, e, next) => {
1099    heldTurnId = e.turnId
1100    // the first turn to start after a hand-off was asked for is the brief's
1101    if (isHandingOff && handoffTurnId === null) handoffTurnId = e.turnId
1102    if (!hasPrompted && isMachineTurn) return next(e)
1103    const isFirst = !hasPrompted
1104    hasPrompted = true
1105    await writeMe($, { state: 'working', suggestion: null, ...(isFirst ? { lastPromptAt: await $.clock.now() } : {}) })
1106    return next(e)
1107  })
1108
1109  // The dim suggestion in the prompt box: what another playpen can send
1110  // on your behalf with one press.
1111  on('prompt.suggest', async ($, e, next) => {
1112    // recorded whether or not the box could show it: in the desktop app the
1113    // box is the app's own, and the engine may answer that it did not show
1114    if (e.text.trim()) await writeMe($, { suggestion: e.text.trim() })
1115    return next(e)
1116  })
1117
1118  // Fires only when a permission dialog is actually put to the person; in
1119  // auto mode the classifier's own decisions never reach here.
1120  on('classic.PermissionRequest', async ($, e, next) => {
1121    const want = JSON.stringify(e.tool_input ?? {})
1122    const hit =
1123      [...inFlight].find(([, c]) => c.tool === e.tool_name && c.input === want) ??
1124      [...inFlight].find(([, c]) => c.tool === e.tool_name)
1125    if (hit) waitingIds.add(hit[0])
1126    else isPromptUp = true
1127    await settle($)
1128    return next(e)
1129  })
1130
1131  // The engine's own verdict on a real call: an `ask` goes to the mode's
1132  // decider, a dialog or auto mode's classifier. The classifier answers in
1133  // seconds; a call still in flight after that has a dialog up, whichever way
1134  // the app shows it.
1135  on('tool.check', async ($, e, next) => {
1136    const r = await next(e)
1137    const id = e.tool_use_id
1138    if (id && r.decision === 'ask') {
1139      asking.add(id)
1140      await moved($)
1141    }
1142    return r
1143  })
1144
1145  // The app's own notice that a permission prompt is showing: a second signal
1146  // for prompts the engine routes to the desktop app's UI.
1147  on('classic.Notification', async ($, e, next) => {
1148    if (e.notification_type === 'permission_prompt' && waitingIds.size === 0) {
1149      isPromptUp = true
1150      await settle($)
1151    }
1152    return next(e)
1153  })
1154
1155  // The first turn's requests go to the previous session's model.
1156  on('turn.step', async function* ($, e, next) {
1157    if (!wantedModel || e.agentId !== undefined) return yield* next(e)
1158    return yield* next({ ...e, model: wantedModel })
1159  })
1160
1161  // A session the hand-off link opened: the brief is a block of the
1162  // conversation's first message, however the opener was sent in.
1163  on('prompt.context', async ($, e, next) => {
1164    const r = await next(e)
1165    if (isBriefGiven) return r
1166    const brief = await briefContext($)
1167    if (!brief) return r
1168    isBriefGiven = true
1169    $.ui.log(`hand-off: the brief, ${brief.length} characters, opens this conversation's context`)
1170    return { ...r, blocks: [...r.blocks, { name: 'playpenHandoff', text: brief }] }
1171  })
1172
1173  // Every model change in the transcript with who made it, so a hand-off's
1174  // own switch and one the app makes afterwards can be told apart.
1175  on('classic.PostModelSwitch', async ($, e, next) => {
1176    $.ui.log(`model: ${e.from_model} → ${e.to_model} (${e.source})`)
1177    return next(e)
1178  })
1179
1180  // A question to the person is a wait too, and its options go on the card.
1181  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
1182    const question = toQuestion(e)
1183    waitingIds.add(e.tool_use_id)
1184    await writeMe($, { state: 'waiting', question })
1185    try {
1186      return await next(e)
1187    } finally {
1188      waitingIds.delete(e.tool_use_id)
1189      await writeMe($, { state: waitingIds.size > 0 ? 'waiting' : 'working', question: null })
1190    }
1191  })
1192
1193  // The dialog is answered inside the tool call, so this call's end is the end
1194  // of this call's wait, and no other's.
1195  on('tool.call', async ($, e, next) => {
1196    const {
1197      tool,
1198      tool_use_id,
1199      agentId: _agent,
1200      ...input