SLOPSHOPPER

agent-workshop

A workshop where Claude and its subagents walk between tool stations: a pixel-art pane in the terminal, and a 3D window with a room for every session, to…

newpaneguardcommandtoastmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-workshop
│ ┃ Workshop ✕ › fix the failing auth test and add an audit log call │ ┃ agent workshop · 0 subagents · no hooks yet │ ┃ ● main idle done ✓ 42s ⏺ 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 │ │ › /workshop │ ⎿ agent-workshop: Workshop opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workshop
agent workshop · 0 subagents · no hooks yet f: room h: hook ● main idle done ✓ 42s
README

agent-workshop

Watch Claude and its subagents work, and use all your sessions from one window.

agent-workshop: every Claude Code session as a room in a 3D building

  • /workshop opens a pixel-art pane in the terminal: Claude and its subagents walk between tool stations (a desk with a monitor for Bash, a bookshelf for reading and searching, a writing desk for edits, a radio for the web, a server rack for MCP tools, a round table for subagents), with a bubble saying what each is doing. The header names the last hook event that fired; press h for a table of every hook event with its count and when it fires.
  • /workshop 3d opens a 3D window with a room for every Claude Code session running this mod, in any project, in a building that grows from one corridor to several floors. Click a room to prompt that session, run its slash commands, stop it, answer its permission requests, plans and questions, or end it; start new sessions in a folder you pick. Each room shows the answer as it streams, the model's thinking, finished answers, edits and the context window.
  • English and German: /workshop lang de or the EN/DE switch in the window.
claude plugin marketplace add MarkMerk/markmerk-mods
claude plugin install agent-workshop@markmerk-mods

Commands: /workshop, /workshop hooks|floor|close|clear|auto|3d|3d off|lang de|lang en|help.

Full docs: <https://github.com/MarkMerk/markmerk-mods#agent-workshop>

The terminal pane sends nothing anywhere. The 3D window needs python3 to send commands; it writes the sessions' state into ~/.claude/agent-workshop/ (closed to other users), is served on 127.0.0.1 behind a random token, and loads three.js, marked and DOMPurify from a CDN; titles, when switched on, use Haiku. Full details: <https://github.com/MarkMerk/markmerk-mods#privacy-and-security>

Source 3 files
hooks/register.js 1536 lines
1import { MAIN, SUB, aisleY, doorSpot, drawing, layout, paint, rasterCells, spotAt, stationOf } from './scene.js'
2import { GERMAN, HELP } from './words.js'
3
4const PANE = 'agent-workshop'
5const TITLE = 'Workshop'
6const MAIN_COLOR = '#D97757'
7const SUB_COLORS = ['#6A9BCC', '#7FB069', '#B48EAD', '#E3B341', '#4FB3BF']
8// Milliseconds between ticks
9const TICK_MS = 140
10// Ticks a short bubble stays: an alert, a failed call, the end of a turn
11const ALERT_TICKS = 8
12const OOPS_TICKS = 14
13const DONE_TICKS = 40
14// Ticks between the main agent's strolls while nothing is happening
15const STROLL_TICKS = 45
16// Ticks of idling before the main agent falls asleep (about 40 seconds)
17const SLEEP_TICKS = 285
18const SNORES = ['z', 'z Z', 'z Z z']
19// How far apart agents working at one station stand, in pixels
20const SPACING = MAIN.w + 1
21const NAME_MAX = 12
22// Milliseconds between writes of the 3D window's state
23const STATE_MS = 250
24const BUBBLE_MAX = 18
25const EVENT_WIDTH = 19
26
27// When each settings hook fires, in the order a turn meets them
28const HOOKS = [
29  ['SessionStart', 'the session starts, resumes or is cleared'],
30  ['UserPromptSubmit', 'you sent a prompt, before Claude reads it'],
31  ['PreToolUse', 'before a tool runs: can allow, block or rewrite it'],
32  ['PermissionRequest', 'a permission dialog is about to show'],
33  ['PermissionDenied', 'a tool call was refused'],
34  ['PostToolUse', 'after a tool call succeeded'],
35  ['PostToolUseFailure', 'after a tool call failed'],
36  ['PostToolBatch', 'after a batch of parallel tool calls'],
37  ['SubagentStart', 'a subagent was spawned'],
38  ['SubagentStop', 'a subagent finished its work'],
39  ['Notification', 'Claude wants your attention'],
40  ['PreCompact', 'before the context is compacted'],
41  ['PostCompact', 'after the context was compacted'],
42  ['Stop', 'Claude finished answering'],
43  ['StopFailure', 'a turn ended on an API error'],
44  ['SessionEnd', 'the session ends'],
45]
46
47// The agents in the room, by id: 'main' for Claude's own loop, the agent id for a subagent
48let agents = new Map()
49// How many tool calls each station is working on now
50let busy = {}
51// How often each hook event fired, and the last one, for the header
52let counts = {}
53let lastHook = null
54// What the pane shows: the room, or the table of hooks
55let view = 'floor'
56let ticks = 0
57// The room as the last draw laid it out, or null when it had no room for one
58let room = null
59let subagentsSeen = 0
60let isAutoOpen = false
61// A small generator of its own, so a test strolls the same way each time
62let seed = 11
63
64// The language the workshop speaks, here and in the window: 'en' or 'de', kept in the shared
65// settings so every session and the window agree
66let lang = 'en'
67function tr(text, vars = {}) {
68  const said = lang === 'de' ? (GERMAN[text] ?? text) : text
69  return said.replace(/\{(\w+)\}/g, (all, name) => (name in vars ? String(vars[name]) : all))
70}
71function readLang(settings) {
72  lang = settings?.lang === 'de' ? 'de' : 'en'
73}
74
75function roll(below) {
76  seed = (seed * 1103515245 + 12345) % 2147483648
77  return Math.floor(seed / 65536) % below
78}
79
80function clip(text, max) {
81  const line = String(text ?? '').replace(/\s+/g, ' ').trim()
82  return line.length > max ? line.slice(0, max - 1) + '…' : line
83}
84
85function basename(path) {
86  return String(path ?? '').split('/').pop()
87}
88
89function newAgent(id, type) {
90  const isMain = id === 'main'
91  if (!isMain) subagentsSeen += 1
92  const agent = {
93    id,
94    isMain,
95    size: isMain ? MAIN : SUB,
96    // A subagent's short name: its type and a number
97    name: isMain ? 'main' : clip(String(type ?? 'agent').replace(/-purpose$/, ''), NAME_MAX - 3) + '#' + subagentsSeen,
98    color: isMain ? MAIN_COLOR : SUB_COLORS[(subagentsSeen - 1) % SUB_COLORS.length],
99    // Placed once the room is known: the main agent in the middle, a subagent at the door
100    x: null,
101    y: null,
102    facing: 'down',
103    isWalking: false,
104    step: 0,
105    stroll: null,
106    station: null,
107    calls: 0,
108    isWaiting: false,
109    isLeaving: false,
110    bubble: '',
111    bubbleColor: undefined,
112    bubbleTicks: 0,
113  }
114  agents.set(id, agent)
115  return agent
116}
117
118function agentFor(id, type) {
119  const key = id ?? 'main'
120  return agents.get(key) ?? newAgent(key, type)
121}
122
123function say(agent, text, forTicks = 0, background = undefined) {
124  agent.bubble = clip(text, BUBBLE_MAX)
125  agent.bubbleTicks = forTicks
126  agent.bubbleColor = background
127}
128
129// The bubble's words for a tool call
130function labelFor(e) {
131  const tool = String(e.tool)
132  if (tool === 'Bash') return '$ ' + (e.command ?? '')
133  if (tool === 'Read' || tool === 'Edit' || tool === 'Write') return basename(e.file_path)
134  if (tool === 'Grep' || tool === 'Glob') return String(e.pattern ?? tr('searching'))
135  if (tool === 'WebFetch') return String(e.url ?? tr('fetching')).replace(/^https?:\/\//, '')
136  if (tool === 'WebSearch') return String(e.query ?? tr('searching'))
137  if (tool === 'Agent' || tool === 'Task') return String(e.description ?? tr('delegating'))
138  if (tool.startsWith('mcp__')) return tool.split('__').pop()
139  return tool
140}
141
142// Where an agent is heading, and which way it faces once there
143function goalOf(agent) {
144  if (agent.isLeaving) return { ...doorSpot(room, agent.size), facing: 'up' }
145  if (agent.station !== null) {
146    const spot = spotAt(room, agent.station, agent.size)
147    // Several agents at one station stand side by side, the first in the middle
148    const sharing = [...agents.values()].filter((other) => other.station === agent.station && !other.isLeaving)
149    const place = sharing.indexOf(agent)
150    const shift = place === 0 ? 0 : Math.ceil(place / 2) * SPACING * (place % 2 === 1 ? 1 : -1)
151    return { ...spot, x: spot.x + shift }
152  }
153  return agent.stroll ?? { x: agent.x, y: agent.y, facing: 'down' }
154}
155
156// One step toward the goal: out to the aisle, along it, then in to the spot, so nobody walks
157// through a station
158function walk(agent) {
159  const goal = goalOf(agent)
160  const gx = Math.max(1, Math.min(room.width - agent.size.w - 1, Math.round(goal.x)))
161  const gy = Math.max(0, Math.min(room.height - agent.size.h, Math.round(goal.y)))
162  const aisle = aisleY(room, agent.size)
163  const dx = gx - agent.x
164  let heading = null
165  if (dx !== 0 && agent.y !== aisle) {
166    agent.y += Math.sign(aisle - agent.y)
167    heading = aisle > agent.y ? 'down' : 'up'
168  } else if (dx !== 0) {
169    agent.x += Math.sign(dx) * Math.min(Math.abs(dx), Math.abs(dx) > 16 ? 2 : 1)
170    heading = dx > 0 ? 'right' : 'left'
171  } else if (gy !== agent.y) {
172    agent.y += Math.sign(gy - agent.y)
173    heading = gy > agent.y ? 'down' : 'up'
174  }
175  agent.isWalking = heading !== null
176  if (agent.isWalking) agent.step += 1
177  agent.facing = heading ?? goal.facing
178  return agent.isWalking
179}
180
181// One tick of the clock: bubbles fade, and everyone walks a step
182function tick() {
183  ticks += 1
184  for (const agent of [...agents.values()]) {
185    if (agent.bubbleTicks > 0) {
186      agent.bubbleTicks -= 1
187      if (agent.bubbleTicks === 0) say(agent, '')
188    }
189    // A main agent idle long enough falls asleep where it stands, and wakes at the first work
190    if (agent.isMain) {
191      const isIdle = !isWorking && agent.calls === 0 && agent.station === null && !agent.isWaiting && agent.bubbleTicks === 0
192      agent.idleTicks = isIdle ? (agent.idleTicks ?? 0) + 1 : 0
193      const wasAsleep = agent.isAsleep === true
194      agent.isAsleep = agent.idleTicks > SLEEP_TICKS
195      if (agent.isAsleep) agent.bubble = SNORES[Math.floor(ticks / 8) % SNORES.length]
196      else if (wasAsleep && SNORES.includes(agent.bubble)) agent.bubble = ''
197    }
198    // With no room drawn there is no door to walk to: a leaving subagent is just gone
199    if (room === null) {
200      if (agent.isLeaving) agents.delete(agent.id)
201      continue
202    }
203    if (agent.x === null) {
204      const start = agent.isMain ? { x: room.width / 2 - MAIN.w / 2, y: aisleY(room, MAIN) } : doorSpot(room, agent.size)
205      agent.x = Math.round(start.x)
206      agent.y = start.y
207    }
208    // With nothing to do, the main agent strolls about the middle of the room now and then
209    if (agent.isMain && agent.station === null && !agent.isWaiting && !agent.isAsleep && ticks % STROLL_TICKS === 0) {
210      agent.stroll = { x: 4 + roll(Math.max(1, room.width - MAIN.w - 8)), y: aisleY(room, MAIN) - 2 + roll(5), facing: 'down' }
211    }
212    const isMoving = walk(agent)
213    if (!isMoving && agent.isLeaving) agents.delete(agent.id)
214  }
215}
216
217// A hook event: counted, and named in the header
218function record(event, e) {
219  counts[event] = (counts[event] ?? 0) + 1
220  const agentId = e.agent_id ?? e.agentId
221  const who = agentId === undefined ? agents.get('main') : agents.get(agentId)
222  lastHook = { event, who: who?.name ?? 'agent', color: who?.color ?? MAIN_COLOR }
223}
224
225// The color of each kind of hook event
226function eventColor(event) {
227  if (event.includes('Failure') || event.includes('Denied')) return '#E5484D'
228  if (event.startsWith('Permission') || event === 'Notification') return '#F0883E'
229  if (event.includes('ToolU') || event === 'PostToolBatch') return '#E3B341'
230  if (event.startsWith('Subagent') || event.startsWith('Task') || event === 'TeammateIdle') return '#B48EAD'
231  if (event === 'UserPromptSubmit' || event === 'Stop') return '#3FB950'
232  return '#6A9BCC'
233}
234
235function pad(text, size) {
236  const line = clip(text, size)
237  return line + ' '.repeat(Math.max(0, size - line.length))
238}
239
240// The 3D window shows every session that runs this mod, a room each along one corridor. Each
241// session publishes its state into one shared folder, but only while a window is open: the
242// session that opened it writes a heartbeat there, and the others check for it now and then
243const SHARED = '/.claude/agent-workshop'
244// How often a session looks for the heartbeat, how old one may be, and how old a session's
245// state may be before the window takes its room away
246const WATCH_MS = 2000
247const FRESH_MS = 8000
248const GONE_MS = 15000
249
250// How long a permission request waits for an answer from the window before the terminal asks,
251// and how many recent tool calls and hook events the window's panel lists
252const PERMISSION_WAIT_S = 25
253const LONG_WAIT_S = 120
254const HISTORY_MAX = 14
255
256// This session as the window knows it: its id, its project and folder, when it started, and the
257// shared folder
258let self = { id: null, project: '', cwd: '', startedAt: 0, base: null }
259// Whether a turn is running, and the main loop's turn, so the window can stop it
260let isWorking = false
261let turnId = null
262// The latest tool calls and hook events, newest last, for the window's panel
263let recentCalls = []
264let recentHooks = []
265// A permission request waiting for an answer from the window
266let permission = null
267// What the model is writing in this turn, and what it is thinking, as it streams; how much of each
268// the window gets
269let liveText = ''
270let liveThinking = ''
271const LIVE_MAX = 3000
272// The prompt this turn answers, the answers so far, and the latest edits with their diffs
273let turnPrompt = ''
274let answers = []
275let edits = []
276const ANSWERS_MAX = 12
277const EDITS_MAX = 10
278// Bumped when answers or edits change, so the window loads them again
279let moreVersion = 0
280let moreWritten = -1
281// How full the context window is, and what the session has cost
282let context = null
283let costUsd = null
284// What the session is doing, in a few words a small model wrote; only while the window turns
285// titles on, since each costs a request
286let title = null
287let titlesOn = false
288// The model, the todo list as the model last wrote it, the conversation as the window shows it,
289// and what the slash commands the window ran answered
290let model = null
291// The permission mode, as the last hook event said
292let permissionMode = null
293// True once the window asked this session to end: its Claude says goodbye, then it exits
294let isEnding = false
295const ENDING_MS = 3500
296// Where in the window's building this session's room stands, when the person chose: from the
297// window's new-session spot (passed in AGENT_WORKSHOP_SLOT), or a move; kept in the store by
298// session, so it survives a reload
299let slot = null
300const SLOT_MAX = 24 * 9
301// What the window should tell the person, from the session that runs it: a dialog that would not
302// open, a Terminal that did not start
303let windowNotices = []
304// Messages to and from other sessions, newest last: for the window to send a courier between rooms
305let mailOut = []
306let mailIn = []
307const MAIL_MAX = 10
308let todos = []
309let chat = []
310let commandLog = []
311const CHAT_MAX = 60
312// The prompts the window sent that have not started a turn yet, to know them again at turn.start
313let windowPrompts = []
314// What the window sent lately, to mark those prompts in its chat: sent as the person's own words,
315// they carry no frame to tell them by
316let sentFromWindow = []
317// When the slash commands and the project's files were last published for the window
318let listedAt = 0
319const LIST_MS = 60000
320// The window's server, while this session runs it: its port, its token and the loop reading it
321let server = null
322// Whether the window can send commands: true while its server runs, as the heartbeat says
323let canControl = false
324// The window server's port, as its heartbeat says: the guard refuses tools aimed at it
325let viewerPort = null
326// Whether a window is open somewhere, whether this session opened it, and what was last written
327let isPublishing = false
328let isWindowOwner = false
329let lastWritten = ''
330let lastWrittenAt = 0
331let sinceWatch = 0
332
333// What the window draws for this session: who is where, doing what, and the last hook event.
334// Subagents on their way out are left out, so the window walks them to the door itself
335function snapshot() {
336  return {
337    id: self.id,
338    project: self.project,
339    cwd: self.cwd,
340    startedAt: self.startedAt,
341    isWorking,
342    canControl,
343    permission,
344    recentCalls,
345    recentHooks,
346    live: { text: liveText.slice(-LIVE_MAX), thinking: liveThinking.slice(-300) },
347    context,
348    costUsd,
349    answersMeta: answers.map((answer) => ({ at: answer.at, chars: answer.answer.length })),
350    lastEdit: edits.length === 0 ? null : { file: edits.at(-1).file, lines: edits.at(-1).diff.split('\n').slice(0, 12), at: edits.at(-1).at },
351    title,
352    titlesOn,
353    moreVersion,
354    model,
355    permissionMode,
356    isEnding,
357    slot,
358    windowNotices: isWindowOwner ? windowNotices : [],
359    mailOut,
360    mailIn,
361    todos,
362    busy: Object.fromEntries(Object.entries(busy).map(([id, n]) => [id, n > 0])),
363    agents: [...agents.values()]
364      .filter((agent) => !agent.isLeaving)
365      .map(({ id, name, color, isMain, station, calls, isWaiting, isThinking, isAsleep, bubble, bubbleColor }) => ({
366        id,
367        isThinking: isThinking === true,
368        // The window draws sleep its own way, so it gets the fact, not the pane's snore bubble
369        isAsleep: isAsleep === true,
370        name,
371        color,
372        isMain,
373        station,
374        calls,
375        isWaiting,
376        bubble: isAsleep === true && SNORES.includes(bubble) ? '' : bubble,
377        bubbleColor: bubbleColor ?? null,
378      })),
379    lastHook,
380  }
381}
382
383function sessionFile() {
384  return self.base + '/sessions/' + self.id + '.js'
385}
386
387// Lists the sessions whose state is fresh, for the window to load
388async function writeIndex($, now) {
389  const entries = await $.fs.list(self.base + '/sessions').catch(() => [])
390  const ids = entries
391    .filter((entry) => /^[A-Za-z0-9_-]+\.js$/.test(entry.name) && now - entry.mtimeMs < GONE_MS)
392    .map((entry) => entry.name.slice(0, -3))
393  if (!ids.includes(self.id)) ids.push(self.id)
394  await $.fs.write(self.base + '/index.js', 'window.workshopIndex(' + JSON.stringify(ids) + ')\n')
395}
396
397// One beat of publishing: every WATCH_MS look for the heartbeat (and beat it, in the session
398// that opened the window); while there is one, write this session's state when it changes,
399// and at least every WATCH_MS so the window knows it is still here
400async function publish($, isNow = false) {
401  if (self.base === null) return
402  const now = Date.now()
403  sinceWatch += STATE_MS
404  if (isNow || sinceWatch >= WATCH_MS) {
405    sinceWatch = 0
406    self.id = await $.session.id()
407    // The heartbeat is the server's, beaten while a page uses it; a window opened as a file (no
408    // python3) has its session beat it, to watch only
409    if (isWindowOwner && server === null) await $.fs.write(self.base + '/viewer', JSON.stringify({ at: now, canControl: false }))
410    const beat = await $.fs
411      .read(self.base + '/viewer')
412      .then((text) => JSON.parse(text))
413      .catch(() => ({ at: 0, canControl: false }))
414    // A beat from the future is no beat: a forged one could otherwise keep sharing on for good
415    const age = now - Number(beat.at)
416    isPublishing = age >= -2000 && age < FRESH_MS
417    canControl = isPublishing && beat.canControl === true
418    viewerPort = Number.isInteger(beat.port) ? beat.port : null
419    // What the sessions say is the person's alone: the folder is kept closed to other users
420    if (isPublishing) await $.process.run(['chmod', '700', self.base, self.base + '/sessions']).catch(() => {})
421    // The mark of a session that runs the mod, which the guest finder never writes: so a session
422    // is never taken for a guest, whatever its state file says at the moment
423    if (isPublishing) await $.fs.write(self.base + '/sessions/' + self.id + '.mod', String(now))
424    if (isPublishing) {
425      const settings = await $.fs
426        .read(self.base + '/settings.json')
427        .then((text) => JSON.parse(text))
428        .catch(() => ({}))
429      titlesOn = settings.titles === true
430      readLang(settings)
431      if (!titlesOn) title = null
432      model = await $.session.model().catch(() => model)
433      if (now - listedAt > LIST_MS) {
434        listedAt = now
435        await publishLists($)
436        if (isWindowOwner) await publishProjects($).catch(() => {})
437      }
438      const usage = await $.session.usage().catch(() => null)
439      if (usage !== null) {
440        context = { percent: usage.context.percent ?? null, tokens: usage.context.tokens ?? null, window: usage.context.window }
441        costUsd = usage.cost?.usd ?? null
442      }
443    }
444  }
445  if (!isPublishing) return
446  if (canControl) await readInbox($)
447  if (isWindowOwner && now - sweptAt > SWEEP_MS) {
448    sweptAt = now
449    await sweep($, now).catch(() => {})
450  }
451  if (isWindowOwner && now - guestsAt > GUEST_MS) {
452    guestsAt = now
453    await publishGuests($, now).catch(() => {})
454  }
455  const body = JSON.stringify(snapshot())
456  if (!isNow && body === lastWritten && now - lastWrittenAt < WATCH_MS) return
457  lastWritten = body
458  lastWrittenAt = now
459  await $.fs.write(sessionFile(), 'window.workshopSession(' + JSON.stringify({ ...snapshot(), at: now }) + ')\n')
460  if (moreWritten !== moreVersion) {
461    moreWritten = moreVersion
462    chat = await readChat($)
463    const more = { id: self.id, version: moreVersion, answers, edits, chat, commandLog }
464    await $.fs.write(self.base + '/sessions/' + self.id + '.more.js', 'window.workshopMore(' + JSON.stringify(more) + ')\n')
465  }
466  if (isNow || sinceWatch === 0) await writeIndex($, now)
467}
468
469// A prompt the window sent reaches the model wrapped in words saying a plugin sent it; the
470// window shows what was typed
471const FRAME_START = / plugin sent a message: /
472const FRAME_END = 'This is how Claude Code surfaces a prompt a plugin submits'
473function cleanPrompt(text) {
474  // Found by position, not by a pattern that backtracks: long text stays quick
475  const raw = String(text ?? '').slice(0, 20000)
476  const head = FRAME_START.exec(raw.slice(0, 200))
477  const tail = raw.lastIndexOf(FRAME_END)
478  if (!raw.startsWith('The ') || head === null || tail < 0) return { text: raw, isFromWindow: false }
479  return { text: raw.slice(head.index + head[0].length, tail).trim(), isFromWindow: true }
480}
481
482// The conversation as the window shows it. The engine keeps its own notes among the person's
483// messages (a background task's notification, a slash command's record and output, reminders);
484// each is turned into a short line of its own kind, or left out, and the person's own words kept
485const NOTE_TAGS = ['system-reminder', 'local-command-caveat', 'command-message']
486function tagged(text, tag) {
487  const found = new RegExp('<' + tag + '>([\\s\\S]*?)</' + tag + '>').exec(text)
488  return found === null ? null : found[1].trim()
489}
490function tidyUserMessage(raw) {
491  const rows = []
492  let text = String(raw ?? '').slice(0, 20000)
493  for (const tag of NOTE_TAGS) text = text.replace(new RegExp('<' + tag + '>[\\s\\S]*?</' + tag + '>', 'g'), '')
494  // A background task's notification: what finished, and how
495  text = text.replace(/<task-notification>[\s\S]*?<\/task-notification>/g, (block) => {
496    const summary = tagged(block, 'summary') ?? 'A background task changed'
497    const status = tagged(block, 'status')
498    // Only an unusual end is worth saying beside the summary: failed, killed, stopped
499    rows.push({ kind: 'notice', text: summary + (status && status !== 'completed' && !summary.toLowerCase().includes(status) ? ' (' + status + ')' : '') })
500    return ''
501  })
502  // A slash command the person ran in the terminal, and what it printed
503  const name = tagged(text, 'command-name')
504  if (name !== null) {
505    const args = tagged(text, 'command-args') ?? ''
506    rows.push({ kind: 'command', text: name + (args ? ' ' + args : '') })
507    text = text.replace(/<command-(name|args)>[\s\S]*?<\/command-\1>/g, '')
508  }
509  text = text.replace(/<local-command-(stdout|stderr)>([\s\S]*?)<\/local-command-\1>/g, (_, stream, output) => {
510    if (output.trim()) rows.push({ kind: 'output', text: output.trim().slice(0, 2000) })
511    return ''
512  })
513  const cleaned = cleanPrompt(text.trim())
514  const words = cleaned.text.trim()
515  if (words) rows.push({ kind: 'prompt', text: words, isFromWindow: cleaned.isFromWindow || sentFromWindow.includes(words) })
516  return rows
517}
518
519async function readChat($) {
520  const messages = await $.session.messages().catch(() => [])
521  return messages.slice(-CHAT_MAX).flatMap((message) => {
522    if (message.role === 'user') {
523      return tidyUserMessage(message.text).map((row) => ({ role: 'user', kind: row.kind, text: row.text.slice(0, 8000), isFromWindow: row.isFromWindow === true, tools: [] }))
524    }
525    return [
526      {
527        role: 'assistant',
528        kind: 'answer',
529        text: message.text.slice(0, 8000),
530        isFromWindow: false,
531        tools: (message.toolUses ?? []).slice(0, 12).map((use) => ({ tool: use.tool, label: clip(labelFor({ tool: use.tool, ...use.input }), 60) })),
532      },
533    ]
534  })
535}
536
537// The window's menu leaves out the MCP servers' prompts and what plugins add under their own name
538// (`/engineering:debug`, `/data:analyze`); a plugin's command without one, like /workshop, stays.
539// Typed in full, a left-out one still runs
540function isListed(command) {
541  if (command.source === 'mcp' || / \(MCP\)$/.test(command.name)) return false
542  return !(command.source === 'plugin' && command.name.includes(':'))
543}
544
545// The slash commands this session can run, and the project's files, for the window's typeahead
546async function publishLists($) {
547  const commands = (await $.command.list().catch(() => [])).filter(isListed)
548  // Early in a session the list can still be empty; then try again soon rather than in a minute
549  if (commands.length === 0) listedAt = Date.now() - LIST_MS + 5000
550  const listed = commands.map((one) => ({ name: one.name, description: clip(one.description, 120), source: one.source, plugin: one.plugin ?? null }))
551  await $.fs.write(self.base + '/sessions/' + self.id + '.cmds.js', 'window.workshopCommands(' + JSON.stringify({ id: self.id, commands: listed }) + ')\n')
552  let files = []
553  // A repository's own settings could have git run a program of its choosing (core.fsmonitor)
554  const git = await $.process.run(['git', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', 'ls-files'], { cwd: self.cwd }).catch(() => null)
555  if (git !== null && git.exitCode === 0) files = git.stdout.split('\n').filter(Boolean)
556  else {
557    const found = await $.process
558      .run(['find', '.', '-maxdepth', '4', '-type', 'f', '-not', '-path', '*/node_modules/*', '-not', '-path', '*/.git/*'], { cwd: self.cwd })
559      .catch(() => null)
560    if (found !== null) files = found.stdout.split('\n').filter(Boolean).map((path) => path.replace(/^\.\//, ''))
561  }
562  await $.fs.write(self.base + '/sessions/' + self.id + '.files.js', 'window.workshopFiles(' + JSON.stringify({ id: self.id, files: files.slice(0, 5000) }) + ')\n')
563}
564
565// The commands the window left for this session, oldest first: each read, removed, then done
566let isReadingInbox = false
567const obeyed = new Set()
568async function readInbox($) {
569  // One reading at a time, and each command once, though beats overlap
570  if (isReadingInbox) return
571  isReadingInbox = true
572  try {
573    await readBoxes($)
574  } finally {
575    isReadingInbox = false
576  }
577}
578
579async function readBoxes($) {
580  // `_window` holds the commands for the window as a whole, which its owner does
581  const boxes = [self.id, ...(isWindowOwner ? ['_window'] : [])]
582  for (const box of boxes) {
583    const folder = self.base + '/inbox/' + box
584    const entries = await $.fs.list(folder).catch(() => [])
585    const names = entries.map((entry) => entry.name).filter((name) => /^\d+\.json$/.test(name)).sort()
586    for (const name of names) {
587      const path = folder + '/' + name
588      if (obeyed.has(path)) continue
589      obeyed.add(path)
590      const text = await $.fs.read(path).catch(() => null)
591      await $.process.run(['rm', '-f', path]).catch(() => {})
592      if (text === null) continue
593      try {
594        await obey($, JSON.parse(text), box === '_window')
595      } catch {
596        // A command that cannot be read or done is dropped
597      }
598    }
599  }
600}
601
602// Does one command from the window: a prompt, a stop, or a new session
603let lastLaunchAt = 0
604async function obey($, command, fromWindow = false) {
605  if (command.type === 'prompt' && typeof command.text === 'string' && command.text.trim() !== '') {
606    $.ui.toast(tr('Prompt from the workshop window'))
607    windowPrompts = [...windowPrompts, command.text.slice(0, 20000)].slice(-5)
608    sentFromWindow = [...sentFromWindow, command.text.slice(0, 20000).trim()].slice(-50)
609    // Typed by the person in the window: their own words, which the model reads bare, not framed
610    // as a message from this plugin
611    void $.prompt.submit({ text: command.text.slice(0, 20000), asUser: true }).catch(() => {})
612  } else if (command.type === 'command' && typeof command.command === 'string') {
613    // A slash command, run as if typed here; what it answers goes to the window's log
614    const name = command.command.replace(/^\//, '')
615    const args = typeof command.args === 'string' ? command.args : ''
616    $.ui.toast(tr('{command} from the workshop window', { command: '/' + name }))
617    void $.command
618      .run({ command: name, args })
619      .then((ran) => logCommand($, name, args, ran?.text ?? ''))
620      .catch((error) => logCommand($, name, args, 'failed: ' + (error?.message ?? 'unknown command')))
621  } else if (command.type === 'stop' && turnId !== null) {
622    await $.turn.abort({ turnId })
623  } else if (command.type === 'new-session' && typeof command.dir === 'string' && fromWindow && Date.now() - lastLaunchAt > 5000) {
624    lastLaunchAt = Date.now()
625    await startSession($, command.dir.replace(/\/+$/, '') || '/', command.isContinue === true, isSlot(command.slot) ? command.slot : null)
626  } else if (command.type === 'end' && !isEnding) {
627    // Goodbye first, so the window can show it, then /exit as if typed here
628    isEnding = true
629    say(agentFor(), tr('bye!'), 0)
630    $.ui.toast(tr('The workshop window is ending this session'))
631    if (turnId !== null) await $.turn.abort({ turnId }).catch(() => {})
632    await publish($, true)
633    $.clock.after(ENDING_MS, () => {
634      void $.command.run({ command: 'exit' }).catch(() => {
635        isEnding = false
636        say(agentFor(), '')
637        $.ui.toast(tr('The workshop window could not end this session; type /exit here to end it'))
638      })
639    })
640  } else if (command.type === 'set-titles' && typeof command.on === 'boolean') {
641    await saveSetting($, { titles: command.on })
642  } else if (command.type === 'set-lang' && (command.value === 'en' || command.value === 'de')) {
643    lang = command.value
644    await saveSetting($, { lang })
645    $.ui.invalidate('ui.render')
646  } else if (command.type === 'set-tour' && (command.value === 'never' || command.value === 'show')) {
647    await saveSetting($, { tour: command.value })
648  } else if (command.type === 'set-slot' && isSlot(command.slot)) {
649    slot = command.slot
650    await $.store.set('slot:' + self.id, slot)
651    moreVersion += 1
652  } else if (command.type === 'pick-folder' && fromWindow) {
653    // The dialog waits on the person, so the inbox goes on without it
654    void pickAndStart($, command.isContinue === true, isSlot(command.slot) ? command.slot : null).catch(() => {})
655  }
656}
657
658function isSlot(value) {
659  return Number.isInteger(value) && value >= 0 && value < SLOT_MAX
660}
661
662// Guests: the Claude Code sessions running on this machine without this mod (started before it
663// was set up), shown in the window to watch, not to use. The session running the window finds
664// them by their processes and folders, reads the end of each one's transcript, and writes a state
665// for it as a session with the mod would, marked `guest`
666const GUEST_MS = 3000
667let guestsAt = 0
668let guestIds = new Set()
669let guestChats = new Map()
670let lastPublishing = new Set()
671async function publishGuests($, now) {
672  const found = await findGuests($, now).catch(() => [])
673  const ids = new Set(found.map((guest) => guest.state.id))
674  for (const guest of found) {
675    await $.fs.write(self.base + '/sessions/' + guest.state.id + '.js', 'window.workshopSession(' + JSON.stringify(guest.state) + ')\n')
676    const chat = JSON.stringify(guest.more.chat)
677    if (guestChats.get(guest.state.id) !== chat) {
678      guestChats.set(guest.state.id, chat)
679      await $.fs.write(self.base + '/sessions/' + guest.state.id + '.more.js', 'window.workshopMore(' + JSON.stringify(guest.more) + ')\n')
680    }
681  }
682  // A guest whose session ended goes; one that now runs the mod keeps its files, which are its own
683  const gone = [...guestIds].filter((id) => !ids.has(id) && !lastPublishing.has(id))
684  if (gone.length > 0) {
685    await $.process.run(['rm', '-f', ...gone.flatMap((id) => [self.base + '/sessions/' + id + '.js', self.base + '/sessions/' + id + '.more.js'])]).catch(() => {})
686    for (const id of gone) guestChats.delete(id)
687  }
688  guestIds = ids
689}
690
691async function findGuests($, now) {
692  const listed = await $.process.run(['pgrep', '-x', 'claude'])
693  const pids = listed.stdout.split('\n').filter(Boolean)
694  if (pids.length === 0) return []
695  // Each process's working folder, and how many sessions run in each
696  const folders = await $.process.run(['lsof', '-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')])
697  const counts = new Map()
698  for (const line of folders.stdout.split('\n')) if (line.startsWith('n/')) counts.set(line.slice(1), (counts.get(line.slice(1)) ?? 0) + 1)
699  // The sessions that publish for themselves, which are no guests
700  const entries = await $.fs.list(self.base + '/sessions').catch(() => [])
701  const publishing = new Set(entries.filter((entry) => /^[\w-]+\.mod$/.test(entry.name) && now - entry.mtimeMs < GONE_MS).map((entry) => entry.name.slice(0, -4)))
702  lastPublishing = publishing
703  const home = await $.env.get('HOME')
704  const guests = []
705  for (const [cwd, count] of counts) {
706    const folder = home + '/.claude/projects/' + cwd.replace(/[^a-zA-Z0-9]/g, '-')
707    // The newest transcripts there, one a process; those that publish for themselves are no guests
708    const newest = await $.process.run(['sh', '-c', 'ls -t "$1"/*.jsonl 2>/dev/null | head -n "$2"', 'newest', folder, String(count)])
709    for (const file of newest.stdout.split('\n').filter(Boolean)) {
710      const id = file.split('/').pop().replace(/\.jsonl$/, '')
711      if (publishing.has(id) || id === self.id) continue
712      const stat = await $.fs.stat(file).catch(() => null)
713      if (stat === null || now - stat.mtimeMs > 6 * 3600 * 1000) continue
714      const tail = await $.process.run(['tail', '-c', '400000', file])
715      guests.push(guestOf(id, cwd, tail.stdout, stat.mtimeMs, now))
716    }
717  }
718  return guests
719}
720
721// A guest's state, read from the end of its transcript: what it says, what it is doing now
722function guestOf(id, cwd, tail, mtime, now) {
723  const entries = []
724  for (const line of tail.split('\n')) {
725    try {
726      entries.push(JSON.parse(line))
727    } catch {
728      // The first line of a tail is cut; a line being written is not whole yet
729    }
730  }
731  const chat = []
732  const calls = []
733  const answered = new Set()
734  let model = null
735  let title = null
736  let lastText = ''
737  let todos = []
738  let lastEdit = null
739  let started = mtime
740  for (const entry of entries) {
741    if (entry.type === 'ai-title' && entry.aiTitle) title = clip(entry.aiTitle, 48)
742    if (entry.timestamp && Date.parse(entry.timestamp) < started) started = Date.parse(entry.timestamp)
743    if (entry.isSidechain || !entry.message) continue
744    const content = entry.message.content
745    const blocks = typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? content : []
746    if (entry.type === 'user') {
747      for (const block of blocks) if (block.type === 'tool_result') answered.add(block.tool_use_id)
748      const text = blocks.filter((block) => block.type === 'text' && typeof block.text === 'string').map((block) => block.text).join('\n')
749      if (text.trim() && !entry.isMeta) {
750        for (const row of tidyUserMessage(text)) chat.push({ role: 'user', kind: row.kind, text: row.text.slice(0, 8000), isFromWindow: false, tools: [] })
751      }
752    } else if (entry.type === 'assistant') {
753      model = entry.message.model ?? model
754      // One response comes as several entries, a block each: they make one row
755      const last = chat.at(-1)
756      const row = last && last.role === 'assistant' && last.messageId === entry.message.id ? last : { role: 'assistant', kind: 'answer', text: '', isFromWindow: false, tools: [], messageId: entry.message.id }
757      if (row !== last) chat.push(row)
758      for (const block of blocks) {
759        if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
760          row.text = (row.text ? row.text + '\n\n' : '') + block.text
761          lastText = block.text
762        } else if (block.type === 'tool_use') {
763          const label = labelFor({ tool: block.name, ...block.input })
764          row.tools.push({ tool: block.name, label: clip(label, 60) })
765          calls.push({ id: block.id, tool: block.name, label: clip(label, 80), who: 'main', at: Date.parse(entry.timestamp) || mtime, input: block.input })
766          if (block.name === 'TodoWrite' && Array.isArray(block.input?.todos)) todos = block.input.todos.slice(0, 40)
767          const diff = diffOf({ tool: block.name, ...block.input })
768          if (diff !== null) lastEdit = { file: basename(block.input.file_path ?? block.input.notebook_path), lines: diff.split('\n').slice(0, 12), at: Date.parse(entry.timestamp) || mtime }
769        }
770      }
771    }
772  }
773  // Working: its transcript grew just now, or a tool call has no result yet
774  const pending = calls.filter((call) => !answered.has(call.id)).at(-1) ?? null
775  const isWorking = now - mtime < 8000 || pending !== null
776  const station = pending ? stationOf(pending.tool) : null
777  const main = { id: 'main', name: 'main', color: MAIN_COLOR, isMain: true, station, calls: pending ? 1 : 0, isWaiting: false, isThinking: false, isAsleep: !isWorking && now - mtime > SLEEP_TICKS * TICK_MS, bubble: pending ? pending.label.slice(0, BUBBLE_MAX) : '', bubbleColor: null }
778  const recentCalls = calls.slice(-HISTORY_MAX).map(({ tool, label, who, at, id: callId }) => ({ tool, label, who, at, result: answered.has(callId) ? 'ok' : 'running' }))
779  const version = chat.length + ':' + (chat.at(-1)?.text.length ?? 0)
780  return {
781    state: {
782      id, guest: true, project: cwd.split('/').filter(Boolean).pop() ?? '/', cwd, startedAt: started, at: now, isWorking, canControl: false,
783      permission: null, recentCalls, recentHooks: [], live: { text: isWorking ? lastText.slice(-LIVE_MAX) : '', thinking: '' }, context: null, costUsd: null,
784      answersMeta: [], lastEdit, title, titlesOn: false, moreVersion: version, model, permissionMode: null, isEnding: false, slot: null,
785      windowNotices: [], mailOut: [], mailIn: [], todos, busy: station ? { [station]: true } : {}, agents: [main], lastHook: null,
786    },
787    more: { id, version, answers: [], edits: [], chat: chat.slice(-CHAT_MAX).map(({ messageId, ...row }) => row), commandLog: [] },
788  }
789}
790
791// The window's settings, shared by every session: read, changed, written whole
792async function saveSetting($, change) {
793  const settings = await $.fs
794    .read(self.base + '/settings.json')
795    .then((text) => JSON.parse(text))
796    .catch(() => ({}))
797  await $.fs.write(self.base + '/settings.json', JSON.stringify({ ...settings, ...change }))
798}
799
800function logCommand($, name, args, text) {
801  commandLog = [...commandLog, { at: Date.now(), command: '/' + name + (args ? ' ' + args : ''), text: clip(text, 2000) }].slice(-20)
802  moreVersion += 1
803}
804
805// Opens a new Terminal window running Claude Code in a folder, or continuing the last
806// conversation there; macOS only
807async function startSession($, dir, isContinue = false, at = null) {
808  const stat = await $.fs.stat(dir).catch(() => null)
809  if (!dir.startsWith('/') || stat === null || stat.kind !== 'dir') {
810    tellWindow($, dir + ' is not a folder, so no session was started there')
811    return
812  }
813  // A small script Terminal runs in a window of its own. Opening a file asks nothing of macOS,
814  // where telling Terminal what to do takes Apple Events, which an app like Claude's desktop app
815  // may not be allowed to send. The spot chosen in the window rides along in the environment
816  const quoted = "'" + dir.replace(/'/g, "'\\''") + "'"
817  const script = ['#!/bin/zsh -l', 'cd -- ' + quoted + ' || exit 1']
818  if (at !== null) script.push('export AGENT_WORKSHOP_SLOT=' + at)
819  script.push('exec claude' + (isContinue ? ' --continue' : ''))
820  const file = self.base + '/launch/' + randomHex(16) + '.command'
821  await $.fs.write(file, script.join('\n') + '\n')
822  await $.process.run(['chmod', '700', file])
823  const opened = await $.process.run(['open', '-a', 'Terminal', file]).catch((error) => ({ exitCode: 1, stderr: String(error?.message ?? error) }))
824  if (opened.exitCode !== 0) tellWindow($, tr('Terminal did not open: {error}', { error: clip(opened.stderr, 160) }))
825  else $.ui.toast(tr('Started a new session in {dir}', { dir }))
826}
827
828// Asks for a folder in the system's own dialog, then starts a session there; macOS only. Where
829// the dialog cannot open (an app not allowed to drive Finder), the window says so and offers the
830// recent projects and a path instead
831async function pickAndStart($, isContinue, at) {
832  const script = [
833    'tell application "Finder"',
834    'activate',
835    'set chosen to choose folder with prompt "' + tr('Start a Claude Code session in:') + '"',
836    'end tell',
837    'POSIX path of chosen',
838  ]
839  const ran = await $.process.run(['osascript', ...script.flatMap((line) => ['-e', line])], { timeoutMs: 600000 })
840  if (ran.exitCode !== 0) {
841    // -128 is the person pressing Cancel; anything else is the dialog failing
842    if (!/-128/.test(ran.stderr)) tellWindow($, tr('The folder dialog could not open from here ({error}). Pick a recent project or type a folder instead.', { error: clip(ran.stderr.trim(), 120) }), 'pick-failed')
843    return
844  }
845  const dir = ran.stdout.trim().replace(/\/$/, '')
846  if (dir !== '') await startSession($, dir, isContinue, at)
847}
848
849// A message for the window, from the session that runs it, shown there once
850function tellWindow($, text, kind = 'error') {
851  windowNotices = [...windowNotices, { id: Date.now() + Math.random(), at: Date.now(), kind, text }].slice(-5)
852  $.ui.toast(text)
853}
854
855// The folders Claude Code sessions ran in lately, newest first, from its own history: for the
856// window's new-session form
857async function publishProjects($) {
858  const find =
859    'for d in "$HOME"/.claude/projects/*/; do f=$(ls -t "$d"*.jsonl 2>/dev/null | head -1); ' +
860    '[ -n "$f" ] && printf "%s %s\\n" "$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")" "$(head -c 200000 "$f" | grep -o \'"cwd":"[^"]*"\' | head -1)"; done'
861  const ran = await $.process.run(['sh', '-c', find]).catch(() => null)
862  if (ran === null) return
863  const seen = new Set()
864  const projects = []
865  for (const line of ran.stdout.split('\n').map((one) => /^(\d+) "cwd":"([^"]+)"$/.exec(one)).filter(Boolean).sort((a, b) => Number(b[1]) - Number(a[1]))) {
866    const dir = line[2].replace(/\\\\/g, '\\')
867    if (seen.has(dir) || projects.length >= 30) continue
868    seen.add(dir)
869    const stat = await $.fs.stat(dir).catch(() => null)
870    if (stat?.kind === 'dir') projects.push({ dir, at: Number(line[1]) * 1000 })
871  }
872  await $.fs.write(self.base + '/projects.js', 'window.workshopProjects(' + JSON.stringify(projects) + ')\n')
873}
874
875// Waits for the window to answer a permission request, without holding up this hook's own time:
876// one small shell loop looks for the answer the window's server leaves, until it gives up
877const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
878// A permission suggestion in words: what it adds, for whom, and where it is kept
879function describeRule(update) {
880  const known = { session: 'this session', localSettings: 'this project, for you', projectSettings: 'this project, for everyone', userSettings: 'all your projects' }[update?.destination]
881  const where = known === undefined ? String(update?.destination ?? '') : tr(known)
882  if (update?.type === 'addRules' || update?.type === 'replaceRules') {
883    const rules = (update.rules ?? []).map((rule) => rule.toolName + (rule.ruleContent ? '(' + rule.ruleContent + ')' : '')).join(', ')
884    return tr(update.behavior === 'deny' ? 'deny ' : update.behavior === 'ask' ? 'ask for ' : 'allow ') + rules + ' — ' + where
885  }
886  if (update?.type === 'addDirectories') return tr('allow work in ') + (update.directories ?? []).join(', ') + ' — ' + where
887  if (update?.type === 'setMode') return tr('switch to ') + update.mode + ' — ' + where
888  return JSON.stringify(update)
889}
890const SHOWN_MAX = 20000
891// Permission requests waiting on the window, by id: several may wait at once (subagents)
892const pending = new Map()
893async function askWindow($, e) {
894  const id = [...crypto.getRandomValues(new Uint8Array(12))].map((b) => b.toString(16).padStart(2, '0')).join('')
895  const input = e.tool_input ?? {}
896  const tool = String(e.tool_name)
897  // A plan or a question takes reading, so the window has longer to answer those
898  const isLong = tool === 'ExitPlanMode' || tool === 'AskUserQuestion'
899  const waitS = isLong ? LONG_WAIT_S : PERMISSION_WAIT_S
900  // What the tool will really do, never what the model wrote about it: the command, or the whole
901  // input; when too long to show whole, the window may only deny
902  const shown = tool === 'Bash' ? String(input.command ?? '') : isLong ? '' : JSON.stringify(input, null, 2)
903  const diff = diffWhole({ tool, ...input }, SHOWN_MAX)
904  const plan = typeof input.plan === 'string' ? input.plan : null
905  // The flags a shell command carries, shown with it: leaving the sandbox above all
906  const flags = tool === 'Bash' ? Object.entries(input).filter(([name]) => name !== 'command' && name !== 'description').map(([name, value]) => name + ': ' + JSON.stringify(value)) : []
907  const request = {
908    id,
909    tool,
910    filePath: typeof (input.file_path ?? input.notebook_path) === 'string' ? input.file_path ?? input.notebook_path : null,
911    detail: tool === 'Bash' || diff !== null ? '' : shown.slice(0, SHOWN_MAX),
912    command: tool === 'Bash' ? shown.slice(0, SHOWN_MAX) : null,
913    flags,
914    // Allowed from the window only when all of it is shown, and it stays in the sandbox
915    isCut: shown.length > SHOWN_MAX || diff?.isCut === true || (plan?.length ?? 0) > 20000,
916    leavesSandbox: input.dangerouslyDisableSandbox === true,
917    diff: diff?.text ?? null,
918    plan: plan?.slice(0, 20000) ?? null,
919    questions: tool === 'AskUserQuestion' && Array.isArray(input.questions) ? input.questions : null,
920    canAlways: Array.isArray(e.permission_suggestions) && e.permission_suggestions.length > 0,
921    // What "always allow" would add, in words, as the terminal would name it
922    rules: (Array.isArray(e.permission_suggestions) ? e.permission_suggestions : []).map(describeRule),
923    canAcceptEdits: EDIT_TOOLS.includes(tool) || tool === 'ExitPlanMode',
924    agent: e.agent_id === undefined ? 'main' : (agents.get(e.agent_id)?.name ?? 'agent'),
925    since: Date.now(),
926    until: Date.now() + waitS * 1000,
927  }
928  pending.set(id, request)
929  // The oldest request first, with how many wait behind it
930  permission = { ...[...pending.values()][0], waiting: pending.size - 1 }
931  await publish($, true)
932  $.ui.toast(tool === 'AskUserQuestion' ? tr('A question is waiting in the workshop window; it comes here in {s} s', { s: waitS }) : tr('Permission for {tool} is waiting in the workshop window; it comes here in {s} s', { tool, s: waitS }))
933  const wait = 'f=$1; n=$(( $2 * 4 )); while [ $n -gt 0 ]; do if [ -f "$f" ]; then cat "$f"; rm -f "$f"; exit 0; fi; sleep 0.25; n=$((n - 1)); done; exit 1'
934  try {
935    const ran = await $.process.run(['sh', '-c', wait, 'wait', self.base + '/decisions/' + id, String(waitS)], {
936      timeoutMs: (waitS + 5) * 1000,
937    })
938    if (ran.exitCode !== 0) return null
939    const answer = JSON.parse(ran.stdout)
940    if (answer.decision === 'deny') return { decision: { behavior: 'deny', message: 'Denied from the agent workshop window' } }
941    if (answer.decision !== 'allow' || request.isCut || request.leavesSandbox) return null
942    const decision = { behavior: 'allow' }
943    const updates = []
944    if (answer.always === true && request.canAlways) updates.push(...e.permission_suggestions)
945    // The one way a plugin changes the permission mode: with an answer, as the terminal's
946    // "Yes, and auto-accept edits" does; for an edit or a plan alone
947    if (answer.mode === 'acceptEdits' && request.canAcceptEdits) updates.push({ type: 'setMode', mode: 'acceptEdits', destination: 'session' })
948    if (updates.length > 0) decision.updatedPermissions = updates
949    if (request.questions !== null && answer.answers && typeof answer.answers === 'object') decision.updatedInput = { ...input, answers: answer.answers }
950    return { decision }
951  } catch {
952    return null
953  } finally {
954    pending.delete(id)
955    const next = [...pending.values()][0]
956    permission = next ? { ...next, waiting: pending.size - 1 } : null
957    void publish($, true).catch(() => {})
958  }
959}
960
961// The shared folder and the window's server are the window's, not Claude's: a tool call aimed at
962// either is refused, so a prompt slipped into one session cannot read the others or command them.
963// Best effort: paths are put in plain form (home spelled out, `.` and `..` and `//` folded,
964// letters in one case, as macOS compares them, links followed where the file exists), commands
965// and other tools' text scanned for the folder's name or the server's address. The settings'
966// own deny rules, which the engine enforces, are the stronger fence (see the README)
967const PATH_FIELDS = ['file_path', 'notebook_path', 'path']
968function plainPath(path, home) {
969  let full = String(path).replace(/^~(?=\/|$)/, home).replace(/^\$\{?HOME\}?(?=\/|$)/, home)
970  if (!full.startsWith('/')) full = (self.cwd || home) + '/' + full
971  const parts = []
972  for (const part of full.split('/')) {
973    if (part === '' || part === '.') continue
974    if (part === '..') parts.pop()
975    else parts.push(part)
976  }
977  return ('/' + parts.join('/')).toLowerCase()
978}
979function strings(value, found = []) {
980  if (typeof value === 'string') found.push(value)
981  else if (Array.isArray(value)) value.forEach((one) => strings(one, found))
982  else if (value && typeof value === 'object') Object.values(value).forEach((one) => strings(one, found))
983  return found
984}
985async function touchesShared($, e) {
986  if (self.base === null) return false
987  const home = self.base.slice(0, -SHARED.length)
988  const shared = self.base.toLowerCase()
989  const inside = (path) => path === shared || path.startsWith(shared + '/')
990  // Paths: in plain form, and where the file is there, wherever its links lead
991  for (const field of PATH_FIELDS) {
992    if (typeof e[field] !== 'string' || e[field] === '') continue
993    const plain = plainPath(e[field], home)
994    if (inside(plain)) return true
995    const real = await $.fs.stat(plain, { resolve: true }).then((stat) => stat.realPath).catch(() => null)
996    if (typeof real === 'string' && inside(real.toLowerCase())) return true
997  }
998  // Text anywhere in the call: commands, scripts, MCP arguments, URLs. The folder's name however
999  // split or quoted, a path into it, a wildcard under ~/.claude, a `cd` there followed by more,
1000  // or the window server's address
1001  const text = strings(e).join('\n').toLowerCase()
1002  const rules = [
1003    /agent[^a-z\n]{0,8}workshop/,
1004    /\.claude\/+["']?agent/,
1005    /\.claude\/[^\s;|&]*[*?[]/,
1006    /cd\s+["']?[^;&|\n]*\.claude\/?["']?\s*(&&|;|\n)[^\n]*(agent|[*?])/,
1007  ]
1008  if (rules.some((rule) => rule.test(text))) return true
1009  if (viewerPort !== null && new RegExp('(127\\.0\\.0\\.1|localhost|\\[::1\\]|0\\.0\\.0\\.0)(:|%3a)' + viewerPort + '\\b').test(text)) return true
1010  return false
1011}
1012
1013// A diff of what an edit changes, as `- ` and `+ ` lines, short enough to show
1014function diffOf(e, max = 4000) {
1015  return diffWhole(e, max)?.text ?? null
1016}
1017// The diff, and whether showing it took cutting: a permission card must not show a part as whole
1018function diffWhole(e, max = 4000) {
1019  const tool = String(e.tool)
1020  const lines = (text, mark) => String(text ?? '').split('\n').map((line) => mark + line)
1021  let diff = []
1022  if (tool === 'Edit') diff = [...lines(e.old_string, '- '), ...lines(e.new_string, '+ ')]
1023  else if (tool === 'MultiEdit') diff = (e.edits ?? []).flatMap((one) => [...lines(one.old_string, '- '), ...lines(one.new_string, '+ '), ''])
1024  else if (tool === 'Write') diff = lines(e.content, '+ ')
1025  else return null
1026  const whole = diff.join('\n')
1027  return { text: whole.slice(0, max), isCut: whole.length > max }
1028}
1029
1030// Asks a small model what this turn is about, in a few words, for the window's sign
1031async function nameTurn($, text) {
1032  const asked = await $.model.complete({
1033    model: 'haiku',
1034    prompt:
1035      'In 3 to 6 words, say what this request to a coding assistant is about, as a short phrase ' +
1036      "starting with a verb in -ing, like 'fixing the failing tests'. Reply with the phrase only.\n\nRequest:\n" +
1037      text.slice(0, 2000),
1038    maxTokens: 24,
1039    timeoutMs: 15000,
1040  })
1041  if (asked.isAnswered && titlesOn) title = clip(asked.text.replace(/["'\n.]/g, ' '), 48)
1042}
1043
1044function randomHex(bytes) {
1045  return [...crypto.getRandomValues(new Uint8Array(bytes))].map((b) => b.toString(16).padStart(2, '0')).join('')
1046}
1047
1048function remember(list, item) {
1049  return [...list, item].slice(-HISTORY_MAX)
1050}
1051
1052// Starts the window's server: a small Python program serving the shared folder on 127.0.0.1,
1053// with a token only the page gets. Answers its port, or null where it cannot run
1054async function startServer($) {
1055  if (server !== null) return server.port
1056  try {
1057    // The server makes its token itself and says it with its port, so no command line shows it
1058    const stream = $.process.spawn({ argv: ['python3', $.plugin.root + '/web/server.py', self.base] })
1059    const pieces = stream[Symbol.asyncIterator]()
1060    const first = await pieces.next()
1061    const port = first.done ? NaN : parseInt(first.value.text.trim().split(/\s+/)[0], 10)
1062    if (!Number.isInteger(port)) return null
1063    server = { port, pieces }
1064    // Read on for the server's life; when it ends, the window can no longer send commands
1065    void (async () => {
1066      try {
1067        while (!(await pieces.next()).done) {
1068          // The server prints nothing more
1069        }
1070      } catch {
1071        // It stopped
1072      }
1073      if (server?.pieces === pieces) server = null
1074    })()
1075    return port
1076  } catch {
1077    return null
1078  }
1079}
1080
1081function stopServer() {
1082  void server?.pieces.return?.()
1083  server = null
1084}
1085
1086// Opens the 3D window. One server serves every window: a session finding one running (its
1087// heartbeat fresh, its process alive) uses it; else it starts its own and owns the window. The
1088// browser gets a launcher file (in the private folder) that takes it to the page with a one-time
1089// key in the address's fragment, which the page swaps for the token: no command line and no page
1090// anyone can fetch carries the token. Without python3 the page opens as a file, to watch only
1091async function open3d($) {
1092  if (self.base === null) return tr('The 3D window needs a home folder to share between sessions, and found none.')
1093  await $.process.run(['mkdir', '-p', '-m', '700', self.base]).catch(() => {})
1094  await $.process.run(['chmod', '700', self.base]).catch(() => {})
1095  await $.fs.write(self.base + '/index.html', await $.fs.read($.plugin.root + '/web/workshop.html'))
1096  let port = await runningPort($)
1097  if (port === null) {
1098    isWindowOwner = true
1099    listedAt = 0
1100    port = await startServer($)
1101  }
1102  await publish($, true)
1103  let page = 'file://' + self.base + '/index.html'
1104  if (port !== null) {
1105    const key = randomHex(32)
1106    await $.fs.write(self.base + '/claims/' + key, '')
1107    const launcher = self.base + '/open/' + randomHex(16) + '.html'
1108    const address = `http://127.0.0.1:${port}/#k=${key}`
1109    await $.fs.write(launcher, `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><title>Agent workshop</title><script>location.replace(${JSON.stringify(address)})</script>\n`)
1110    page = 'file://' + launcher
1111  }
1112  const attempts = [
1113    ['open', '-na', 'Google Chrome', '--args', '--app=' + page, '--window-size=1400,860'],
1114    ['open', page],
1115    ['xdg-open', page],
1116  ]
1117  for (const argv of attempts) {
1118    const ran = await $.process.run(argv).catch(() => null)
1119    if (ran !== null && ran.exitCode === 0) {
1120      return port === null
1121        ? tr('3D workshop opened to watch only (python3 was not found, so it cannot send commands).')
1122        : tr('3D workshop opened: every session running this mod gets a room, and you can prompt them from it.')
1123    }
1124  }
1125  return tr('Could not open a browser. Open this in one: {page}', { page })
1126}
1127
1128// The port of a window server already running for the shared folder, when its heartbeat is fresh
1129// and its process alive
1130async function runningPort($) {
1131  if (server !== null) return server.port
1132  const beat = await $.fs
1133    .read(self.base + '/viewer')
1134    .then((text) => JSON.parse(text))
1135    .catch(() => null)
1136  if (!beat || !Number.isInteger(beat.port) || !Number.isInteger(beat.pid) || Date.now() - beat.at > FRESH_MS) return null
1137  const alive = await $.process.run(['kill', '-0', String(beat.pid)]).catch(() => null)
1138  return alive && alive.exitCode === 0 ? beat.port : null
1139}
1140
1141// This session stops serving the window; every session stops sharing once the heartbeat goes
1142// stale, and the guests' rooms go with it
1143function close3d($) {
1144  isWindowOwner = false
1145  stopServer()
1146  const files = [...guestIds].flatMap((id) => [self.base + '/sessions/' + id + '.js', self.base + '/sessions/' + id + '.more.js'])
1147  guestIds = new Set()
1148  if (files.length > 0) void $.process.run(['rm', '-f', ...files]).catch(() => {})
1149}
1150
1151// What the window leaves behind, cleared now and then by the session running it: rooms of
1152// sessions gone, answers nobody waited for, used launchers and keys, inboxes of sessions gone
1153const SWEEP_MS = 60000
1154let sweptAt = 0
1155async function sweep($, now) {
1156  const old = (entry, ms) => now - entry.mtimeMs > ms
1157  const gone = []
1158  const sessions = await $.fs.list(self.base + '/sessions').catch(() => [])
1159  for (const entry of sessions) if (entry.kind === 'file' && old(entry, 10 * 60000)) gone.push(self.base + '/sessions/' + entry.name)
1160  for (const [folder, ms] of [['decisions', (LONG_WAIT_S + 60) * 1000], ['launch', 120000], ['open', 120000], ['claims', 300000]]) {
1161    const entries = await $.fs.list(self.base + '/' + folder).catch(() => [])
1162    for (const entry of entries) if (entry.kind === 'file' && old(entry, ms)) gone.push(self.base + '/' + folder + '/' + entry.name)
1163  }
1164  if (gone.length > 0) await $.process.run(['rm', '-f', '--', ...gone]).catch(() => {})
1165  const boxes = await $.fs.list(self.base + '/inbox').catch(() => [])
1166  const live = new Set(sessions.filter((entry) => !old(entry, 10 * 60000)).map((entry) => entry.name.split('.')[0]))
1167  const empty = boxes.filter((entry) => entry.kind === 'dir' && entry.name !== '_window' && !live.has(entry.name)).map((entry) => self.base + '/inbox/' + entry.name)
1168  if (empty.length > 0) await $.process.run(['rm', '-rf', '--', ...empty]).catch(() => {})
1169}
1170
1171async function open($) {
1172  await $.ui.open({ id: PANE, title: TITLE, rows: 24, columns: 110 })
1173}
1174
1175export function register(on) {
1176  // Runs before your first prompt, and again after a reload
1177  on('session.start', async ($, e, next) => {
1178    await $.command.register({
1179      name: 'workshop',
1180      description: 'Open the agent workshop: Claude and its subagents at their tool stations; /workshop 3d opens it in 3D in a window of its own',
1181      argumentHint: '[close|floor|hooks|clear|auto|3d|3d off|lang en|lang de|help]',
1182      immediate: true,
1183    })
1184    isAutoOpen = (await $.store.get('isAutoOpen')) === true
1185    agentFor('main')
1186    $.clock.every(TICK_MS, () => {
1187      tick()
1188      $.ui.invalidate('ui.render')
1189    })
1190    // The 3D window's side; without a home folder or a session id the pane still works
1191    try {
1192      const home = await $.env.get('HOME')
1193      self = {
1194        id: await $.session.id(),
1195        project: String(e.cwd).split('/').filter(Boolean).pop() ?? '/',
1196        cwd: String(e.cwd),
1197        startedAt: Date.now(),
1198        base: home === undefined ? null : home + SHARED,
1199      }
1200      const kept = await $.store.get('slot:' + self.id).catch(() => undefined)
hooks/scene.js 488 lines
1// The workshop in a three-quarter view: the back wall stands up, the floor runs toward you,
2// and the furniture and agents have height, so whatever is nearer is drawn over what is behind.
3// Each terminal cell holds two pixels, one above the other, drawn as '▀' in two colors. The
4// first and the last cell rows are the tops of the back and front walls, where labels sit.
5
6// The widest room, in cells: one pixel per column
7const MAX_WIDTH = 160
8// How tall the back wall's face is, in pixels, and where the door is in it
9const WALL_FACE = 10
10const DOOR_X = 3
11const DOOR_W = 7
12// A station's width, and the room a row of them needs
13const STATION_W = 12
14const SIDE = 2
15// The agents: Claude's own loop, and a subagent
16export const MAIN = { w: 11, h: 7 }
17export const SUB = { w: 9, h: 5 }
18// How far in front of the back wall the back row stands
19const BACK_FOOT = WALL_FACE + 5
20// The floor needed for a front row of stations, and the width six stations in one row need
21const FRONT_ROW_HEIGHT = 50
22const SINGLE_ROW_WIDTH = DOOR_X + DOOR_W + 2 + 6 * (STATION_W + 1) + SIDE
23
24const WALL_TOP = 0x23222b
25const WALL_FACE_COLOR = 0x3a3846
26const WALL_PANEL = 0x35333f
27const BASEBOARD = 0x26252e
28const SIDE_WALL = 0x2b2a33
29const PLANKS = [0x5a4a3a, 0x5f4f3e, 0x564636]
30const SEAM = 0x52432f
31const RUG = 0x5a3a3f
32const RUG_EDGE = 0x7a5258
33const DOOR = 0x6b4e33
34const DOOR_FRAME = 0x4a3524
35const DOOR_OPEN = 0xf3d9a4
36const WINDOW_FRAME = 0x26252e
37const SKY = [0x7fa7d9, 0x9cc0e8]
38const SHADOW = 0x000000
39const BUBBLE = 0xe8e2d0
40const INK = 0x16161c
41const LABEL = 0x8b8b96
42const LABEL_LIT = 0xf2f0ea
43
44const PALETTE = {
45  K: 0x16161c,
46  d: 0x20302a,
47  G: 0x3fb950,
48  W: 0x7a5a3a,
49  w: 0x5c4329,
50  T: 0x9a7a55,
51  F: 0x6b5038,
52  r: 0xe5484d,
53  b: 0x6a9bcc,
54  y: 0xe3b341,
55  g: 0x7fb069,
56  p: 0xb48ead,
57  P: 0xe8e2d0,
58  k: 0x55555f,
59  s: 0xc9ced6,
60  S: 0x9aa3ae,
61  M: 0x6b6f78,
62  N: 0x8a8f99,
63  O: 0x9f86e0,
64  L: 0xd6c8ff,
65}
66
67// The stations, each drawn as you see it from the front and a little above: a top in a lighter
68// shade, a front face in a darker one. `c`, `z` and `l` are the moving parts: `swap` says what
69// each is when the station is idle, and in its two busy frames. The first three stand against
70// the back wall, the other three in the room, with their agents behind them.
71export const STATIONS = [
72  {
73    id: 'shell',
74    label: 'shell',
75    sprite: [
76      '..KKKKKKKK..',
77      '..KddddddK..',
78      '..KdGGGddK..',
79      '..KdGcdddK..',
80      '..KddddddK..',
81      '..KKKKKKKK..',
82      '.....KK.....',
83      'TTTTTTTTTTTT',
84      'TTkkkkkkTTTT',
85      'TTTTTTTTTkTT',
86      'FFFFFFFFFFFF',
87      'F..........F',
88      'F..........F',
89    ],
90    swap: { c: ['d', 'G', 'd'] },
91  },
92  {
93    id: 'files',
94    label: 'files',
95    sprite: [
96      'WWWWWWWWWWWW',
97      'WrbyWgrpbbgW',
98      'WrbyWgrpbbgW',
99      'WrbyWgrpcbgW',
100      'WWWWWWWWWWWW',
101      'WpgbbWyrbgpW',
102      'WpgbbWyrbgpW',
103      'WpgbbWyzbgpW',
104      'WWWWWWWWWWWW',
105      'WbbyrrgWpbyW',
106      'WbbyrrgWpbyW',
107      'wwwwwwwwwwww',
108    ],
109    swap: { c: ['b', 'W', 'b'], z: ['b', 'b', 'W'] },
110  },
111  {
112    id: 'edit',
113    label: 'edit',
114    sprite: [
115      '.........ll.',
116      '.........kl.',
117      '..........k.',
118      '..........k.',
119      '..........k.',
120      '..........k.',
121      '.........kkk',
122      'TTTTTTTTTTTT',
123      'TPPPPPPTTTTT',
124      'TPkkzkPTTyTT',
125      'FFFFFFFFFFFF',
126      'F..........F',
127      'F..........F',
128    ],
129    swap: { l: ['k', 'y', 'y'], z: ['k', 'y', 'P'] },
130  },
131  {
132    id: 'web',
133    label: 'web',
134    sprite: [
135      '....sSSs..c.',
136      '...sSssSs.k.',
137      '...sSssSs.k.',
138      '....sSSs..k.',
139      '.....MM...k.',
140      'TTTTTTTTTTTT',
141      'TTkkkTTzkkTT',
142      'FFFFFFFFFFFF',
143      'F..........F',
144    ],
145    swap: { c: ['N', 'y', 'N'], z: ['k', 'r', 'k'] },
146  },
147  {
148    id: 'mcp',
149    label: 'mcp',
150    sprite: [
151      'NNNNNNNNNNNN',
152      'MMMMMMMMMMMM',
153      'MKcKgKbKyKzM',
154      'MKKKKKKKKKKM',
155      'MKgKzKyKcKbM',
156      'MKKKKKKKKKKM',
157      'MMMMMMMMMMMM',
158      'M..........M',
159    ],
160    swap: { c: ['r', 'L', 'r'], z: ['b', 'b', 'L'] },
161  },
162  {
163    id: 'agents',
164    label: 'agents',
165    sprite: [
166      '.....cc.....',
167      '....cOOc....',
168      '...wwwwww...',
169      '.wTTTTTTTTw.',
170      'wTTTTTTTTTTw',
171      '.wTTTTTTTTw.',
172      '...FFFFFF...',
173      '.....FF.....',
174      '....FFFF....',
175    ],
176    swap: { c: ['O', 'L', 'O'] },
177  },
178]
179
180// Which station a tool is worked at
181export function stationOf(tool) {
182  const name = String(tool)
183  if (name === 'Bash' || name === 'BashOutput' || name === 'KillShell' || name === 'Monitor') return 'shell'
184  if (name === 'Read' || name === 'Grep' || name === 'Glob' || name === 'LS') return 'files'
185  if (name === 'Edit' || name === 'Write' || name === 'MultiEdit' || name === 'NotebookEdit') return 'edit'
186  if (name === 'WebFetch' || name === 'WebSearch') return 'web'
187  if (name.startsWith('mcp__')) return 'mcp'
188  return 'agents'
189}
190
191// Where everything is in a room of this many cells, or null when there is no room for one.
192// Pixel rows count from just under the top of the back wall. A tall room puts three stations
193// against the back wall and three in front; a short, wide one puts all six against the wall.
194export function layout(columns, rows) {
195  const width = Math.min(columns, MAX_WIDTH)
196  const height = (rows - 2) * 2
197  const hasFrontRow = height >= FRONT_ROW_HEIGHT && width >= 60
198  if (!hasFrontRow && (width < SINGLE_ROW_WIDTH || height < WALL_FACE + 6 + MAIN.h + 8)) return null
199  const backLeft = DOOR_X + DOOR_W + 2
200  const backCount = hasFrontRow ? 3 : 6
201  const backSlot = (width - SIDE - backLeft) / backCount
202  const frontSlot = (width - 2 * SIDE) / 3
203  const stations = STATIONS.map((station, index) => {
204    const isBack = index < backCount
205    const tall = station.sprite.length
206    if (isBack) {
207      const x = Math.round(backLeft + backSlot * index + (backSlot - STATION_W) / 2)
208      return { ...station, isBack, x, y: BACK_FOOT - tall, foot: BACK_FOOT }
209    }
210    const x = Math.round(SIDE + frontSlot * (index - 3) + (frontSlot - STATION_W) / 2)
211    return { ...station, isBack, x, y: height - 2 - tall, foot: height - 2 }
212  })
213  // Windows in the back wall, in the gaps between the stations there
214  const backs = stations.filter((one) => one.isBack)
215  const windows = backs.slice(1).map((one, i) => Math.round((backs[i].x + STATION_W + one.x) / 2))
216  return { width, rows, height, stations, windows, hasFrontRow }
217}
218
219// The row agents walk along between stations, for an agent this tall
220export function aisleY(room, size) {
221  const back = BACK_FOOT + 1 + size.h
222  if (!room.hasFrontRow) return Math.round((back + room.height) / 2 - size.h / 2)
223  const front = Math.min(...room.stations.filter((one) => !one.isBack).map((one) => one.y)) - size.h
224  return Math.round((back + front) / 2 - size.h / 2)
225}
226
227// Where an agent of this size stands to work at a station, and which way it faces: in front of
228// a station at the back wall with its back to you, behind one in the room facing you
229export function spotAt(room, id, size) {
230  const station = room.stations.find((one) => one.id === id)
231  const x = station.x + STATION_W / 2 - size.w / 2
232  return station.isBack
233    ? { x, y: station.foot + 1, facing: 'up' }
234    : { x, y: station.y - size.h + 2, facing: 'down' }
235}
236
237// Where the door is: in the back wall, at the left
238export function doorSpot(room, size) {
239  return { x: DOOR_X + DOOR_W / 2 - size.w / 2, y: WALL_FACE + 1 }
240}
241
242// An agent seen from the front: a lighter top, eyes on the side it faces and none from behind,
243// arms out that drop in turn while it types, and legs that walk
244function agentRows(size, facing, step, isTyping) {
245  const isMain = size === MAIN
246  const left = 2
247  const right = size.w - 3
248  const eyes = { down: [left + 1, right - 1], left: [left, left + 2], right: [right - 2, right], up: [] }[facing]
249  const row = (cells) => Array.from({ length: size.w }, (_, x) => cells(x)).join('')
250  const body = row((x) => (x >= left && x <= right ? 'X' : '.'))
251  const top = body.replace(/X/g, 'H')
252  const face = row((x) => (eyes.includes(x) ? 'e' : x >= left && x <= right ? 'X' : '.'))
253  // Typing, one arm drops a row and then the other
254  const isDown = (x) => isTyping && (step % 2 === 0 ? x < left : x > right)
255  const arms = row((x) => (x >= left && x <= right ? 'X' : isDown(x) ? '.' : 'X'))
256  const dropped = row((x) => (x >= left && x <= right ? 'X' : isDown(x) && (x === 1 || x === size.w - 2) ? 'X' : '.'))
257  const feet = step % 2 === 0 ? [left, left + 2, right - 2, right] : [left - 1, left + 1, right - 1, right + 1]
258  const legs = row((x) => (feet.includes(x) ? 'X' : '.'))
259  return isMain ? [top, body, face, arms, dropped, body, legs] : [top, face, arms, dropped, legs]
260}
261
262function parseHex(hex) {
263  return parseInt(hex.slice(1), 16)
264}
265
266function mix(color, toward, by) {
267  const channel = (shift) => {
268    const from = (color >> shift) & 0xff
269    const to = (toward >> shift) & 0xff
270    return Math.round(from + (to - from) * by) << shift
271  }
272  return channel(16) | channel(8) | channel(0)
273}
274
275// Paints the room into a grid of pixels plus text overlays, by cell index.
276// `world`: { room, ticks, busy: { [station]: true }, isDoorOpen, agents: [...] }, each agent
277// { x, y, isMain, color ('#rrggbb'), facing, step, isTyping, bubble?: { text, background? } }
278export function paint(world) {
279  const { room, ticks } = world
280  const { width, height } = room
281  const pixels = new Array(width * height)
282  const overlays = new Map()
283  const put = (x, y, color) => {
284    if (x >= 0 && x < width && y >= 0 && y < height && color !== undefined) pixels[y * width + x] = color
285  }
286  const get = (x, y) => pixels[y * width + x]
287  const sprite = (rows, left, top, colorOf) => {
288    for (const [dy, row] of rows.entries()) {
289      for (const [dx, char] of [...row].entries()) {
290        if (char !== '.') put(left + dx, top + dy, colorOf(char))
291      }
292    }
293  }
294  const text = (row, from, line, color, background) => {
295    for (const [i, char] of [...line].entries()) {
296      if (from + i >= 0 && from + i < width) overlays.set(row * width + from + i, { char, color, background })
297    }
298  }
299  // A soft shadow on whatever is under it
300  const shadow = (left, right, y) => {
301    for (let x = left; x <= right; x++) if (x >= 0 && x < width && y >= 0 && y < height) put(x, y, mix(get(x, y), SHADOW, 0.35))
302  }
303
304  // The back wall's face in panels, its baseboard, the windows and the door
305  for (let y = 0; y < WALL_FACE; y++) {
306    for (let x = 0; x < width; x++) put(x, y, y === WALL_FACE - 1 ? BASEBOARD : x % 16 === 0 ? WALL_PANEL : WALL_FACE_COLOR)
307  }
308  for (const middle of room.windows) {
309    for (let y = 1; y < 7; y++) {
310      for (let x = middle - 4; x <= middle + 4; x++) {
311        const isFrame = y === 1 || y === 6 || x === middle - 4 || x === middle + 4 || x === middle
312        put(x, y, isFrame ? WINDOW_FRAME : y < 4 ? SKY[1] : SKY[0])
313      }
314    }
315  }
316  for (let y = 1; y < WALL_FACE; y++) {
317    for (let x = DOOR_X - 1; x <= DOOR_X + DOOR_W; x++) {
318      const isFrame = x === DOOR_X - 1 || x === DOOR_X + DOOR_W || y === 1
319      put(x, y, isFrame ? DOOR_FRAME : world.isDoorOpen ? DOOR_OPEN : y === 5 && x === DOOR_X + DOOR_W - 2 ? PALETTE.y : DOOR)
320    }
321  }
322  // Wooden planks running across, each a little different, their seams staggered
323  for (let y = WALL_FACE; y < height; y++) {
324    const plank = Math.floor((y - WALL_FACE) / 3)
325    for (let x = 0; x < width; x++) {
326      const isSeam = (y - WALL_FACE) % 3 === 2 || (x + plank * 7) % 18 === 0
327      put(x, y, isSeam ? SEAM : PLANKS[plank % PLANKS.length])
328    }
329  }
330  // Light falling through the open door
331  if (world.isDoorOpen) {
332    for (let y = WALL_FACE; y < WALL_FACE + 4; y++) {
333      for (let x = DOOR_X - (y - WALL_FACE); x <= DOOR_X + DOOR_W - 1 + (y - WALL_FACE); x++) {
334        if (x >= 0) put(x, y, mix(get(x, y), DOOR_OPEN, 0.35 - (y - WALL_FACE) * 0.07))
335      }
336    }
337  }
338  // A rug in the middle of the floor, where there is room for one
339  const aisle = aisleY(room, MAIN)
340  const rugTop = aisle - 3
341  const rugBottom = aisle + MAIN.h + 3
342  if (rugTop > WALL_FACE + 4) {
343    const rugLeft = Math.round(width * 0.32)
344    const rugRight = Math.round(width * 0.68)
345    for (let y = rugTop; y <= rugBottom; y++) {
346      for (let x = rugLeft; x <= rugRight; x++) {
347        const isEdge = y === rugTop || y === rugBottom || x === rugLeft || x === rugRight
348        put(x, y, isEdge ? RUG_EDGE : (x + y) % 4 === 0 ? mix(RUG, RUG_EDGE, 0.3) : RUG)
349      }
350    }
351  }
352  // The side walls, seen edge on
353  for (let y = 0; y < height; y++) {
354    put(0, y, SIDE_WALL)
355    put(width - 1, y, SIDE_WALL)
356  }
357
358  // Everything that stands on the floor, the farthest first, so nearer things cover it
359  const frame = 1 + (Math.floor(ticks / 3) % 2)
360  const things = [
361    ...room.stations.map((station) => ({ foot: station.foot, draw: () => drawStation(station) })),
362    ...world.agents.map((agent) => {
363      const size = agent.isMain ? MAIN : SUB
364      return { foot: Math.round(agent.y) + size.h, draw: () => drawAgent(agent, size) }
365    }),
366  ].sort((a, b) => a.foot - b.foot)
367  function drawStation(station) {
368    const isBusy = world.busy[station.id] === true
369    shadow(station.x + 1, station.x + STATION_W, station.foot)
370    sprite(station.sprite, station.x, station.y, (char) => {
371      const swapped = station.swap[char]
372      const actual = swapped === undefined ? char : swapped[isBusy ? frame : 0]
373      return actual === '.' ? undefined : PALETTE[actual]
374    })
375    // A station's label sits on the wall top it stands by: the back one, or the front one
376    const from = station.x + Math.floor((STATION_W - station.label.length) / 2)
377    text(station.isBack ? 0 : room.rows - 1, from, station.label, isBusy ? LABEL_LIT : LABEL, WALL_TOP)
378  }
379  function drawAgent(agent, size) {
380    const body = parseHex(agent.color)
381    const left = Math.round(agent.x)
382    // A working agent bobs a pixel now and then
383    const top = Math.round(agent.y) - (agent.isTyping && Math.floor(ticks / 2) % 4 === 0 ? 1 : 0)
384    shadow(left + 1, left + size.w - 2, Math.round(agent.y) + size.h)
385    // Asleep, it sits a pixel lower with its eyes shut, its top rising and falling as it breathes
386    const rows = agentRows(size, agent.isAsleep ? 'down' : agent.facing, agent.step, agent.isTyping)
387    const settled = agent.isAsleep ? (Math.floor(ticks / 12) % 2 === 0 ? rows.slice(1) : rows.slice(1).map((row, i) => (i === 0 ? row.replace(/X/g, 'H') : row))) : rows
388    sprite(settled, left, top + rows.length - settled.length, (char) =>
389      char === 'e' ? (agent.isAsleep ? mix(body, INK, 0.45) : INK) : char === 'H' ? mix(body, 0xffffff, 0.22) : body,
390    )
391  }
392  for (const thing of things) thing.draw()
393
394  // The bubbles over everything: under an agent working at the back wall, over the others
395  for (const agent of world.agents) {
396    if (agent.bubble === undefined || agent.bubble.text === '') continue
397    const size = agent.isMain ? MAIN : SUB
398    const top = Math.round(agent.y)
399    const isBelow = agent.facing === 'up' && top < aisle
400    const row = isBelow ? 1 + Math.floor((top + size.h) / 2) + 1 : 1 + Math.floor(top / 2) - 1
401    const line = ` ${agent.bubble.text} `
402    const from = Math.max(0, Math.min(width - line.length, Math.round(agent.x + size.w / 2 - line.length / 2)))
403    const background = agent.bubble.background === undefined ? BUBBLE : parseHex(agent.bubble.background)
404    text(Math.max(0, Math.min(room.rows - 1, row)), from, line, INK, background)
405  }
406  return { room, pixels, overlays }
407}
408
409// The cells of a painted room, as a Raster wants them: [codePoint, foreground, background] each
410export function rasterCells(painted) {
411  const { room, pixels, overlays } = painted
412  const { width, rows } = room
413  const words = new Uint32Array(width * rows * 3)
414  for (let row = 0; row < rows; row++) {
415    for (let col = 0; col < width; col++) {
416      const at = (row * width + col) * 3
417      const overlay = overlays.get(row * width + col)
418      if (overlay !== undefined) {
419        words[at] = overlay.char.codePointAt(0)
420        words[at + 1] = overlay.color
421        words[at + 2] = overlay.background
422      } else if (row === 0 || row === rows - 1) {
423        words[at] = 0x20
424        words[at + 1] = WALL_TOP
425        words[at + 2] = WALL_TOP
426      } else {
427        const y = (row - 1) * 2
428        words[at] = 0x2580
429        words[at + 1] = pixels[y * width + col]
430        words[at + 2] = pixels[(y + 1) * width + col]
431      }
432    }
433  }
434  return toBase64(new Uint8Array(words.buffer))
435}
436
437function toBase64(bytes) {
438  if (typeof bytes.toBase64 === 'function') return bytes.toBase64()
439  let binary = ''
440  for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
441  return btoa(binary)
442}
443
444// The same room as an SVG, for surfaces that draw pictures rather than cells
445const CELL_W = 8
446const CELL_H = 16
447
448function hex(color) {
449  return '#' + color.toString(16).padStart(6, '0')
450}
451
452export function drawing(painted) {
453  const { room, pixels, overlays } = painted
454  const { width, rows, height } = room
455  const shapes = []
456  // The two wall tops, then each pixel row cut into runs of one color
457  shapes.push(`<rect x="0" y="0" width="${width * CELL_W}" height="${CELL_H}" fill="${hex(WALL_TOP)}"/>`)
458  shapes.push(`<rect x="0" y="${(rows - 1) * CELL_H}" width="${width * CELL_W}" height="${CELL_H}" fill="${hex(WALL_TOP)}"/>`)
459  for (let y = 0; y < height; y++) {
460    let from = 0
461    while (from < width) {
462      const color = pixels[y * width + from]
463      let to = from + 1
464      while (to < width && pixels[y * width + to] === color) to += 1
465      shapes.push(
466        `<rect x="${from * CELL_W}" y="${CELL_H + y * (CELL_H / 2)}" width="${(to - from) * CELL_W}" height="${CELL_H / 2}" fill="${hex(color)}"/>`,
467      )
468      from = to
469    }
470  }
471  for (const [index, overlay] of overlays) {
472    const left = (index % width) * CELL_W
473    const top = Math.floor(index / width) * CELL_H
474    const letter = overlay.char.replace(/&/g, '&amp;').replace(/</g, '&lt;')
475    shapes.push(`<rect x="${left}" y="${top}" width="${CELL_W}" height="${CELL_H}" fill="${hex(overlay.background)}"/>`)
476    if (overlay.char !== ' ') {
477      shapes.push(
478        `<text x="${left + CELL_W / 2}" y="${top + CELL_H - 4}" font-family="ui-monospace, Menlo, monospace" font-size="12" text-anchor="middle" fill="${hex(overlay.color)}">${letter}</text>`,
479      )
480    }
481  }
482  const size = { width: width * CELL_W, height: rows * CELL_H }
483  return {
484    ...size,
485    source: `<svg xmlns="http://www.w3.org/2000/svg" width="${size.width}" height="${size.height}" viewBox="0 0 ${size.width} ${size.height}" shape-rendering="crispEdges">${shapes.join('')}</svg>`,
486  }
487}
488
hooks/words.js 122 lines
1// The terminal side's words in German, by their English; `{name}` marks a part filled in. What is
2// not here stays English. The 3D window keeps its own list, in web/workshop.html
3export const GERMAN = {
4  // Bubbles over the agents
5  'hi!': 'hallo!',
6  bye: 'tschüss',
7  'bye!': 'tschüss!',
8  'tidying up…': 'räumt auf…',
9  blocked: 'blockiert',
10  oops: 'hoppla',
11  'done ✓ {s}s': 'fertig ✓ {s}s',
12  stopped: 'gestoppt',
13  refused: 'abgelehnt',
14  error: 'Fehler',
15  'may I': 'darf ich',
16  searching: 'sucht',
17  fetching: 'lädt',
18  delegating: 'delegiert',
19  // The pane
20  '· {n} subagent': '· {n} Subagent',
21  '· {n} subagents': '· {n} Subagenten',
22  '· no hooks yet': '· noch keine Hooks',
23  '· hook': '· Hook',
24  room: 'Raum',
25  hooks: 'Hooks',
26  idle: 'untätig',
27  ' idle': ' untätig',
28  ' at {station}': ' bei {station}',
29  ' (waiting for you)': ' (wartet auf dich)',
30  'The agent workshop: ': 'Die Agenten-Werkstatt: ',
31  'Settings hooks fire at these points. Each count is this session’s.': 'Hooks aus den Einstellungen feuern an diesen Stellen. Die Zahlen gelten für diese Sitzung.',
32  shell: 'Shell',
33  files: 'Dateien',
34  edit: 'Bearbeiten',
35  web: 'Web',
36  mcp: 'MCP',
37  agents: 'Agenten',
38  // When each hook fires
39  'the session starts, resumes or is cleared': 'die Sitzung startet, wird fortgesetzt oder geleert',
40  'you sent a prompt, before Claude reads it': 'du hast einen Prompt gesendet, bevor Claude ihn liest',
41  'before a tool runs: can allow, block or rewrite it': 'bevor ein Tool läuft: kann es erlauben, blockieren oder umschreiben',
42  'a permission dialog is about to show': 'gleich erscheint ein Berechtigungsdialog',
43  'a tool call was refused': 'ein Tool-Aufruf wurde abgelehnt',
44  'after a tool call succeeded': 'nach einem erfolgreichen Tool-Aufruf',
45  'after a tool call failed': 'nach einem fehlgeschlagenen Tool-Aufruf',
46  'after a batch of parallel tool calls': 'nach einer Reihe paralleler Tool-Aufrufe',
47  'a subagent was spawned': 'ein Subagent wurde gestartet',
48  'a subagent finished its work': 'ein Subagent ist fertig',
49  'Claude wants your attention': 'Claude braucht deine Aufmerksamkeit',
50  'before the context is compacted': 'bevor der Kontext verdichtet wird',
51  'after the context was compacted': 'nachdem der Kontext verdichtet wurde',
52  'Claude finished answering': 'Claude hat fertig geantwortet',
53  'a turn ended on an API error': 'ein Turn endete mit einem API-Fehler',
54  'the session ends': 'die Sitzung endet',
55  // Toasts and answers to /workshop
56  'Prompt from the workshop window': 'Prompt aus dem Werkstatt-Fenster',
57  '{command} from the workshop window': '{command} aus dem Werkstatt-Fenster',
58  'The workshop window is ending this session': 'Das Werkstatt-Fenster beendet diese Sitzung',
59  'The workshop window could not end this session; type /exit here to end it': 'Das Werkstatt-Fenster konnte diese Sitzung nicht beenden; gib hier /exit ein, um sie zu beenden',
60  'Terminal did not open: {error}': 'Terminal hat sich nicht geöffnet: {error}',
61  'Started a new session in {dir}': 'Neue Sitzung gestartet in {dir}',
62  'Start a Claude Code session in:': 'Claude-Code-Sitzung starten in:',
63  'The folder dialog could not open from here ({error}). Pick a recent project or type a folder instead.': 'Der Ordnerdialog ließ sich von hier nicht öffnen ({error}). Wähle stattdessen ein letztes Projekt oder gib einen Ordner ein.',
64  'A question is waiting in the workshop window; it comes here in {s} s': 'Eine Frage wartet im Werkstatt-Fenster; in {s} s kommt sie hierher',
65  'Permission for {tool} is waiting in the workshop window; it comes here in {s} s': 'Die Berechtigung für {tool} wartet im Werkstatt-Fenster; in {s} s kommt sie hierher',
66  'The 3D window needs a home folder to share between sessions, and found none.': 'Das 3D-Fenster braucht einen Home-Ordner, den die Sitzungen teilen, und hat keinen gefunden.',
67  '3D workshop opened to watch only (python3 was not found, so it cannot send commands).': '3D-Werkstatt nur zum Zuschauen geöffnet (python3 wurde nicht gefunden, daher kann sie keine Befehle senden).',
68  '3D workshop opened: every session running this mod gets a room, and you can prompt them from it.': '3D-Werkstatt geöffnet: Jede Sitzung mit diesem Mod bekommt einen Raum, und du kannst ihr von dort Prompts schicken.',
69  'Could not open a browser. Open this in one: {page}': 'Konnte keinen Browser öffnen. Öffne dies in einem: {page}',
70  'Workshop opened.': 'Werkstatt geöffnet.',
71  'Workshop closed.': 'Werkstatt geschlossen.',
72  'The 3D window stops updating in a few seconds; close it when you like.': 'Das 3D-Fenster hört in ein paar Sekunden auf, sich zu aktualisieren; schließ es, wann du willst.',
73  'Hook counts cleared.': 'Hook-Zähler zurückgesetzt.',
74  'The workshop opens with each session (on terminals 144 columns or wider).': 'Die Werkstatt öffnet sich mit jeder Sitzung (in Terminals ab 144 Spalten).',
75  'The workshop opens only on /workshop.': 'Die Werkstatt öffnet sich nur mit /workshop.',
76  'The workshop speaks English now.': 'Die Werkstatt spricht jetzt Englisch.',
77  'The workshop speaks German now.': 'Die Werkstatt spricht jetzt Deutsch.',
78  'The language needs a home folder to keep it in, and found none.': 'Die Sprache braucht einen Home-Ordner zum Speichern und hat keinen gefunden.',
79  'usage: /workshop [close|floor|hooks|clear|auto|3d|3d off|lang en|lang de|help]': 'Verwendung: /workshop [close|floor|hooks|clear|auto|3d|3d off|lang en|lang de|help]',
80  // A permission suggestion in words
81  'this session': 'diese Sitzung',
82  'this project, for you': 'dieses Projekt, für dich',
83  'this project, for everyone': 'dieses Projekt, für alle',
84  'all your projects': 'alle deine Projekte',
85  'deny ': 'ablehnen: ',
86  'ask for ': 'nachfragen: ',
87  'allow ': 'erlauben: ',
88  'allow work in ': 'Arbeit erlauben in ',
89  'switch to ': 'wechseln zu ',
90}
91
92export const HELP = {
93  en: [
94    'agent workshop: watch Claude and its subagents work, and use your sessions from one window.',
95    '',
96    "/workshop          a pane here: this session's room, agents at their tool stations",
97    '/workshop hooks    the hook events, how often each fired, and when each fires',
98    '/workshop 3d       a 3D window with a room for every session running this mod, where you can',
99    '                   chat with a session, run its slash commands, answer its permission',
100    '                   requests, plans and questions, start new sessions and end them',
101    '/workshop 3d off   stop the 3D window',
102    '/workshop auto     open the pane with every session',
103    '/workshop lang de  the workshop in German (lang en for English), here and in the window',
104    '',
105    'In the 3D window, the ? button at the top takes you on a short tour.',
106  ].join('\n'),
107  de: [
108    'agent workshop: Sieh Claude und seinen Subagenten bei der Arbeit zu und nutze deine Sitzungen aus einem Fenster.',
109    '',
110    '/workshop          ein Bereich hier: der Raum dieser Sitzung, die Agenten an ihren Tool-Stationen',
111    '/workshop hooks    die Hook-Ereignisse, wie oft jedes gefeuert hat und wann es feuert',
112    '/workshop 3d       ein 3D-Fenster mit einem Raum für jede Sitzung mit diesem Mod: chatte mit',
113    '                   einer Sitzung, führe ihre Slash-Befehle aus, beantworte Berechtigungen,',
114    '                   Pläne und Fragen, starte neue Sitzungen und beende sie',
115    '/workshop 3d off   das 3D-Fenster stoppen',
116    '/workshop auto     den Bereich mit jeder Sitzung öffnen',
117    '/workshop lang en  die Werkstatt auf Englisch (lang de für Deutsch), hier und im Fenster',
118    '',
119    'Im 3D-Fenster führt dich der ?-Knopf oben durch eine kurze Tour.',
120  ].join('\n'),
121}
122