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…

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

/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./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>
hooks/register.js 1536 lines1import { 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 lines1// 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, '&').replace(/</g, '<')
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}
488hooks/words.js 122 lines1// 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