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…

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 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.
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.

| | | | :- | :- | | 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.
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./board collapses or expands the band.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.
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.
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.
open claude://…); elsewhere the link is copied to the clipboard.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.
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.
Things that would fit the board and may come if someone wants them:
claude plugin test, and a listing in the Claude Directory.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.
MIT
hooks/register.tsx 1575 lines1import 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