SLOPSHOPPER

pixel-office

Every Claude Code session as a pixel character in a shared office you can click and talk to

newpanebandguardcommandprompt
★ 1v0.2.0no licenseupdated 2026-10-02itsArnavPrasad/pixel-office/pixel-office
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-office
│ ┃ Pixel Office ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /office │ ┃ ⎿ pixel-office: Pixel Office is open. Tip: /office standup · /offi │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ [ 🗣 Standup ] │ ┃ [ ✓ app (here) ] │ ┃ 📁 app · 1 here │ ┃ Start another Claude Code session in this │ ┃ repo: it walks into this room. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pixel Office
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ [ 🗣 Standup ] [ ✓ app (here) ] 📁 app · 1 here Start another Claude Code session in this repo: it walks into this room.
README

Pixel Office for Claude Code

Run several Claude Code agents at once and see them all in one place: every session is a pixel-art character at a desk, grouped into one office room per repository. You can see who is working on what, who is waiting for you and who just finished, and you can talk to any of them with a click.

<video src="previews/demo.mp4" poster="previews/editor-panel-rooms.png" controls muted playsinline width="100%"></video>

▶ Watch the 22-second demo

Rooms in the editor

What it does

Rooms per repositorySessions in the same repo (git worktrees included) share a room, named after the repo, with its branch, its own office scene and a roster.
Desk titlesEach desk shows what that agent is working on. Rename it by hand, or clear it to go back to automatic.
Plain-words activityBubbles say Running tests, Editing auth.ts, Committing. The exact command is one hover away.
SubagentsSubagents are kids on a play mat under their agent's desk: up to 10 shown, the rest as +N.
Needs youA session waiting for a permission or an answer raises its hand; a finished one keeps a hand up until its next prompt. You get a notification with Jump, a chime, a status-bar badge and a "Needs you" queue (oldest first). Ctrl+Cmd+N goes to the next one.
Agent consoleClick an agent to open it. You see what it's doing now, your task, its plan with progress, the files it changed and recent failures, then the conversation. Tool calls are folded into "N actions" strips with failures flagged; open them for input and output, or switch to Everything to open them all. End stops the session and takes it off the floor.
Talk to agentsType in the console; it becomes that session's next prompt.
New agents+ in a room asks for a name and a task, then opens a Claude Code tab or terminal in that repo without taking you away from where you were. The new session takes the name and starts the task. The top-level + can also start a new room in any folder.
Full screenEach office has a full-screen button that appears while the pointer moves; Esc exits.
StandupPer room or for everyone: each agent reports Done / Next / Blocked from its own conversation, and they take turns presenting in the scene.
Collision radarWarns when two live agents edited the same file, with a red line between their desks.
InsightsPer-agent git changes (+212 −40 · 6 files) and token use (new vs. cached).

One office in full screen

One office in full screen: desk titles (Auth fix), auth's two subagents on the play mat, the collision line between auth and ui-polish, and deploy with its hand up waiting for you.

The agent console

Two parts, one office

 each Claude Code session                 your editor (Cursor / VS Code)
 ┌──────────────────────────┐            ┌─────────────────────────────────┐
 │ pixel-office  (the mod)  │  writes →  │ Pixel Office  (the extension)   │
 │ sees every tool call,    │ ~/.claude/ │ rooms, console, alerts, jump,   │
 │ answers standups, types  │ pixel-     │ git + transcript insights,      │
 │ your messages into the   │ office/ ←  │ new agents                      │
 │ session                  │  writes    │                                 │
 └──────────────────────────┘            └─────────────────────────────────┘
  • pixel-office/: a Claude Code mod (a plugin of function hooks). It runs inside every session, writes that session's presence, delivers messages to it, answers standups and claims new-agent requests. It also draws the office inside the session itself, in the terminal and the desktop app.
  • extension/: the Cursor / VS Code extension. It reads and writes the same folder and adds what only an editor can: the canvas office, the console, native notifications, jumping to terminals, git and transcript insights, and starting new agents.

Everything stays on your machine in ~/.claude/pixel-office/.

Install

One paste: copy this into Claude Code and it sets everything up for you:

Set up Pixel Office for me (https://github.com/itsArnavPrasad/pixel-office).
1. Clone it into ~/pixel-office (or `git pull` there if it already exists).
2. Build the extension: cd ~/pixel-office/extension && npm install && npm run package
3. Install the built VSIX (extension/pixel-office-*.vsix) with `cursor --install-extension <file>`
   if the `cursor` CLI exists, otherwise `code --install-extension <file>`. If neither CLI is on
   my PATH, tell me to use Cmd+Shift+P → "Extensions: Install from VSIX…" and give me the path.
4. Tell me to reload the editor window and click **Enable** when Pixel Office asks (or run
   "Pixel Office: Enable in All Claude Code Sessions"), then open the 🏢 view in the activity bar.
   New Claude Code sessions will join the office.
If any step fails, show me the error and fix it before moving on.

Cursor / VS Code (by hand)

  1. Build the extension: cd extension && npm install && npm run package
  2. Install it: Cmd+Shift+P → Extensions: Install from VSIX… → extension/pixel-office-0.2.0.vsix (or cursor --install-extension extension/pixel-office-0.2.0.vsix), then reload the window.
  3. Click Enable when asked, or run Pixel Office: Enable in All Claude Code Sessions. This copies the mod to ~/.claude/pixel-office/mod and adds it to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (a backup is kept). New sessions join the office.
  4. Open the 🏢 view in the activity bar, or Pixel Office: Open Office.

Terminal only: claude --plugin-dir ./pixel-office, then /office in the session.

In the terminal

The mod draws the same office in the terminal (fullscreen layout), with chips, the needs-you band above the prompt, standups and a dialogue box.

The office in a terminal

Commands inside a session: /office · /office standup · /office name <name> · /office look [dev-1…dev-8] · /office alerts on|off · /office sound on|off.

Develop

./scripts/check.sh                      # the gate: everything below
claude plugin validate pixel-office     # the mod's manifest and hooks
claude plugin test pixel-office         # 98 mod tests (state machine, rooms, inbox, alerts, standup, scene, pane)
cd extension && npm test                # 37 extension unit tests (transcript, git, setup, store over a real folder)
node --test test/smoke.test.cjs         # the built extension against a fake vscode
node test/webview-harness.mjs test/out  # the webview in headless Chrome, screenshots included
node scripts/terminal-preview.mjs previews   # terminal renders from the real cell output

The README screenshots come from those last two: previews/editor-*.png are copies of the harness's test/out/panel-*.png, sidebar.png and fullscreen.png. previews/terminal-*.png are written in place.

More screenshots

StandupSidebar
Standup for a room: Done / Next / BlockedThe activity-bar mini office
Everything viewTerminal standup
The console's Everything view, every tool call openA standup in the terminal
Terminal dialogue
Talking to another agent from the terminal
Source 14 files
hooks/register.tsx 697 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderPropsOf } from 'claude-code'
3
4import type { AgentRecord, LogLine, Standup, StandupAnswer } from '../types'
5import { AWAY_MS, byUrgency, newAgent, PRUNE_MS, reduce, type Signal } from './core/agent'
6import { alertLeader, alertsDue, formatWait, notifyArgv, waitingQueue } from './core/alerts'
7import { toRasterCells, toSvg } from './core/encode'
8import { DELIVERED_KEEP, asPrompt, makeMessage, messageId, parseMessage, pendingFiles } from './core/inbox'
9import { assignDesks, hash, mergeRoster, parseRecord, type Seat } from './core/roster'
10import { drawOffice, layout, type Spotlight } from './core/scene'
11import { LOOK_IDS } from './core/sprites'
12import {
13  ANSWER_WINDOW_MS, KEEP_MS, PENDING_MS, SHOW_MS, STANDUP_PROMPT, headline, latestRequest, makeRequest, parseAnswer,
14  parseReport, parseRequest, presenter, digest,
15} from './core/standup'
16import { basename, bubble, clean, truncate } from './core/text'
17import { parseTicket, roomOf, ticketsFor, uniqueName } from './core/rooms'
18
19type $ = EngineInterface
20
21const PANE = 'pixel-office'
22const me = atom({ plugin: 'pixel-office', key: 'me' } as const, null)
23const roster = atom({ plugin: 'pixel-office', key: 'roster' } as const, [])
24const selected = atom({ plugin: 'pixel-office', key: 'selected' } as const, null)
25const logs = atom({ plugin: 'pixel-office', key: 'logs' } as const, {})
26const alerted = atom({ plugin: 'pixel-office', key: 'alerted' } as const, {})
27const standup = atom({ plugin: 'pixel-office', key: 'standup' } as const, null)
28
29const HEARTBEAT_MS = 2000
30const POLL_MS = 1000
31const FRAME_MS = 125
32const SVG_FRAME_MS = 400
33const LOG_KEEP = 30
34const MAX_AGENTS = 64
35const GC_EVERY = 60 // polls
36const CHIME = 'sounds/chime.wav'
37const UI_FRESH_MS = 10_000 // an editor window heartbeating this recently does the chime + notification
38
39const ICON: Record<AgentRecord['state'], string> = {
40  arriving: '🚶', idle: '○', thinking: '💭', typing: '⌨', reading: '📖', writing: '✎', browsing: '🌐',
41  delegating: '👥', working: '●', 'needs-you': '❗', stressed: '💦', done: '✓', leaving: '👋',
42}
43
44// Module state: rebuilt on every (re)load, which also drops the old timers.
45const rt = {
46  root: '',
47  myId: '',
48  lastStatus: '',
49  lastWritten: '',
50  mounted: null as { w: number; h: number; n: number } | null,
51  blitting: null as { cancel: () => void } | null,
52  svgTicking: null as { cancel: () => void } | null,
53  isDelivering: false,
54  isAnswering: false,
55  claimedStandup: '',
56  cwd: '',
57  isAlertsOn: true,
58  isSoundOn: true,
59  polls: 0,
60}
61
62const agentsDir = () => `${rt.root}/agents`
63const inboxDir = (id: string) => `${rt.root}/inbox/${id}`
64const standupDir = () => `${rt.root}/standup`
65const uiDir = () => `${rt.root}/ui`
66const spawnDir = () => `${rt.root}/spawn`
67const titlesDir = () => `${rt.root}/titles`
68
69async function log($: $, id: string, who: string, text: string) {
70  const line: LogLine = { at: await $.clock.now(), who, text: truncate(clean(text), 500) }
71  if (!line.text) return
72  await update($, logs, all => {
73    const list = all[id] ?? []
74    if (list.at(-1)?.text === line.text && list.at(-1)?.who === who) return all
75    return { ...all, [id]: [...list, line].slice(-LOG_KEEP) }
76  })
77}
78
79async function signal($: $, s: Signal) {
80  if (!rt.myId) return
81  const now = await $.clock.now()
82  const id = rt.myId
83  await update($, me, a => reduce(a ?? newAgent(id, now), s, now))
84  await save($)
85}
86
87async function save($: $) {
88  const rec = await read($, me)
89  if (!rec || !rt.root) return
90  const body = JSON.stringify(rec)
91  if (body === rt.lastWritten) return
92  rt.lastWritten = body
93  await $.fs.write(`${agentsDir()}/${rec.id}.json`, body).catch(err => $.ui.log(`pixel-office: write failed: ${err}`))
94}
95
96async function poll($: $) {
97  const now = await $.clock.now()
98  const entries = await $.fs.list(agentsDir()).catch(() => [])
99  const files = entries.filter(f => f.kind === 'file' && f.name.endsWith('.json') && f.name !== `${rt.myId}.json`).slice(0, MAX_AGENTS)
100  const others: AgentRecord[] = []
101  const stale: string[] = []
102  for (const f of files) {
103    if (now - f.mtimeMs > PRUNE_MS) {
104      stale.push(`${agentsDir()}/${f.name}`)
105      continue
106    }
107    const rec = parseRecord(await $.fs.read(`${agentsDir()}/${f.name}`).catch(() => ''))
108    if (rec && rec.id === f.name.slice(0, -5)) others.push(rec)
109  }
110  if (stale.length) void $.process.run(['rm', '-f', '--', ...stale]).catch(() => {})
111
112  const before = new Map((await read($, roster)).map(r => [r.id, r]))
113  for (const r of others) {
114    const old = before.get(r.id)
115    if (r.lastLine && r.lastLine !== old?.lastLine) await log($, r.id, r.name, r.lastLine)
116    else if (r.bubble && r.bubble !== old?.bubble && r.state !== 'idle') await log($, r.id, r.name, `${ICON[r.state]} ${r.bubble}`)
117  }
118  const changed = others.length !== before.size || others.some(r => JSON.stringify(r) !== JSON.stringify(before.get(r.id)))
119  if (changed) await update($, roster, () => others)
120
121  const seats = mergeRoster(others, await read($, me), now)
122  const waiting = seats.filter(s => s.state === 'needs-you' && !s.isMe).length
123  const status = seats.length > 1 ? `🏢 ${seats.length} in the office${waiting ? ` · ❗ ${waiting} need${waiting === 1 ? 's' : ''} you` : ''}` : ''
124  if (status !== rt.lastStatus) $.ui.status((rt.lastStatus = status) || undefined)
125
126  await alert($, seats, now)
127  await retitle($)
128  await deliver($)
129  await pollStandup($, now)
130  if (++rt.polls % GC_EVERY === 0) await sweep($, now)
131}
132
133/** A desk title set by hand in an editor (titles/<id>.txt; gone = back to the automatic one). */
134async function retitle($: $) {
135  if (!rt.myId) return
136  const text = truncate(clean(await $.fs.read(`${titlesDir()}/${rt.myId}.txt`).catch(() => '')), 80)
137  if (text !== ((await read($, me))?.title ?? '')) await signal($, { kind: 'title', text })
138}
139
140/** Messages addressed to this session, oldest first, each delivered once. */
141async function deliver($: $) {
142  if (rt.isDelivering || !rt.myId) return
143  rt.isDelivering = true
144  try {
145    const dir = inboxDir(rt.myId)
146    const names = (await $.fs.list(dir).catch(() => [])).map(f => f.name)
147    if (!names.length) return
148    const key = `delivered:${rt.myId}`
149    const saved = await $.store.get(key)
150    const delivered = Array.isArray(saved) ? saved.filter((x): x is string => typeof x === 'string') : []
151    for (const name of pendingFiles(names, delivered)) {
152      const m = parseMessage(await $.fs.read(`${dir}/${name}`).catch(() => ''))
153      delivered.push(name.slice(0, -5))
154      await $.store.set(key, delivered.slice(-DELIVERED_KEEP))
155      void $.process.run(['rm', '-f', '--', `${dir}/${name}`]).catch(() => {})
156      if (!m) {
157        $.ui.log(`pixel-office: skipped a malformed message ${name}`)
158        continue
159      }
160      await log($, rt.myId, m.from === 'you' ? 'you' : m.fromName, m.text)
161      $.ui.toast(`📨 Message for this session${m.from === 'you' ? '' : ` from ${m.fromName}`}`)
162      // your own words read bare; another session's keep Claude Code's plugin frame
163      void $.prompt.submit(m.from === 'you' ? { text: asPrompt(m), asUser: true } : { text: asPrompt(m) })
164    }
165  } finally {
166    rt.isDelivering = false
167  }
168}
169
170/** Toasts in every session; chime and OS notification from the alert leader only. */
171async function alert($: $, seats: Seat[], now: number) {
172  const due = alertsDue(waitingQueue(seats), await read($, alerted), now)
173  if (JSON.stringify(due.memory) !== JSON.stringify(await read($, alerted))) await update($, alerted, () => due.memory)
174  if (!rt.isAlertsOn || (!due.fresh.length && !due.remind.length)) return
175  const isLeader = alertLeader(seats) === rt.myId && !(await isEditorWatching($, now))
176  for (const s of due.fresh) {
177    if (!s.isMe) $.ui.toast(`❗ ${s.name} needs you: ${s.bubble}`)
178    if (isLeader) void $.process.run(notifyArgv(`❗ ${s.name} needs you`, s.bubble || 'Waiting for you')).catch(() => {})
179  }
180  for (const s of due.remind) {
181    const waited = formatWait(now - s.since)
182    if (!s.isMe) $.ui.toast(`❗ ${s.name} is still waiting (${waited})`)
183    if (isLeader) void $.process.run(notifyArgv(`❗ ${s.name} still waiting · ${waited}`, s.bubble || 'Waiting for you')).catch(() => {})
184  }
185  if (isLeader && rt.isSoundOn) void $.audio.play({ asset: CHIME }).catch(() => {})
186}
187
188/**
189 * Whether a Pixel Office editor window has this covered: one that is focused and shows
190 * notifications. A background or minimised window would not be seen, so the mod still rings.
191 */
192async function isEditorWatching($: $, now: number): Promise<boolean> {
193  for (const f of await $.fs.list(uiDir()).catch(() => [])) {
194    if (f.kind !== 'file' || !f.name.endsWith('.json') || now - f.mtimeMs >= UI_FRESH_MS) continue
195    try {
196      const peer = JSON.parse(await $.fs.read(`${uiDir()}/${f.name}`)) as { isFocused?: unknown; isNotifying?: unknown }
197      if (peer.isFocused === true && peer.isNotifying === true) return true
198    } catch {}
199  }
200  return false
201}
202
203async function openOn($: $, id: string) {
204  await update($, selected, () => id)
205  await $.ui.open({ id: PANE, title: 'Pixel Office' })
206}
207
208async function requestStandup($: $) {
209  const now = await $.clock.now()
210  const id = messageId(now, Math.random())
211  const by = (await read($, me))?.name ?? 'someone'
212  await $.fs.write(`${standupDir()}/${id}.json`, JSON.stringify(makeRequest(id, by, now)))
213  $.ui.toast('🗣 Standup! Everyone is reporting in…')
214  await pollStandup($, now)
215}
216
217/** Loads the latest standup into state, and answers it once if it is fresh. */
218async function pollStandup($: $, now: number) {
219  const names = (await $.fs.list(standupDir()).catch(() => [])).map(f => f.name)
220  const id = latestRequest(names, now, SHOW_MS)
221  const shown = await read($, standup)
222  if (!id) {
223    if (shown) await update($, standup, () => null)
224    return
225  }
226  const request = parseRequest(await $.fs.read(`${standupDir()}/${id}.json`).catch(() => ''))
227  if (!request) return
228  const answers: StandupAnswer[] = []
229  for (const f of await $.fs.list(`${standupDir()}/${id}`).catch(() => [])) {
230    const a = parseAnswer(await $.fs.read(`${standupDir()}/${id}/${f.name}`).catch(() => ''))
231    if (a && `${a.id}.json` === f.name) answers.push(a)
232  }
233  const next = { request, answers: answers.sort((a, b) => a.at - b.at) }
234  if (JSON.stringify(next) !== JSON.stringify(shown)) await update($, standup, () => next)
235
236  const key = `standup:${rt.myId}`
237  const room = (await read($, me))?.room
238  if (request.room && request.room !== room) return // another room's standup
239  if (rt.isAnswering || rt.claimedStandup === id || now - request.requestedAt > ANSWER_WINDOW_MS) return
240  rt.claimedStandup = id // claimed before any await, so two polls in flight never both fork
241  if ((await $.store.get(key)) === id) return
242  await $.store.set(key, id)
243  rt.isAnswering = true
244  void answerStandup($, id).finally(() => (rt.isAnswering = false))
245}
246
247async function answerStandup($: $, requestId: string) {
248  let r = await $.model.fork({ prompt: STANDUP_PROMPT }).catch(() => null)
249  let isFresh = false
250  if (r && !r.isAnswered && r.reason === 'nothing-to-fork') {
251    // a resumed session has history but nothing to fork until its next turn: summarise what it holds
252    const history = await $.session.messages().catch(() => [])
253    const text = Array.isArray(history) ? digest(history) : ''
254    isFresh = !text
255    if (text) {
256      const prompt = `${STANDUP_PROMPT}\n\nThe conversation so far:\n${text}`
257      r = await $.model.complete({ model: await $.session.model(), prompt, maxTokens: 300 }).catch(() => null)
258    }
259  }
260  const report =
261    isFresh ? { done: 'Just got here, no work yet.', next: '', blocked: '' }
262    : r && r.isAnswered ? parseReport(r.text)
263    : { done: "(couldn't report)", next: '', blocked: '' }
264  const self = await read($, me)
265  const answer: StandupAnswer = {
266    v: 1, id: rt.myId, name: self?.name ?? 'claude', character: self?.character ?? 'dev-1', ...report, at: await $.clock.now(),
267  }
268  await $.fs.write(`${standupDir()}/${requestId}/${rt.myId}.json`, JSON.stringify(answer))
269}
270
271/**
272 * Housekeeping, once a minute: old standups, the inboxes and store keys of sessions that are
273 * gone, and dialogue logs of agents no longer in the office. Only names that parse are touched.
274 */
275async function sweep($: $, now: number) {
276  await sweepStandups($, now)
277  const live = new Set([rt.myId, ...(await read($, roster)).map(r => r.id)])
278  const deadInboxes = (await $.fs.list(`${rt.root}/inbox`).catch(() => []))
279    .filter(f => f.kind === 'dir' && /^[A-Za-z0-9._-]{1,100}$/.test(f.name) && !live.has(f.name))
280    .map(f => `${rt.root}/inbox/${f.name}`)
281  if (deadInboxes.length) void $.process.run(['rm', '-rf', '--', ...deadInboxes]).catch(() => {})
282  for (const key of await $.store.keys().catch(() => [])) {
283    const m = /^(cursor|delivered|standup):(.+)$/.exec(key)
284    if (m && !live.has(m[2]!)) await $.store.delete(key).catch(() => {})
285  }
286  await update($, logs, all => {
287    const kept = Object.fromEntries(Object.entries(all).filter(([id]) => live.has(id)))
288    return Object.keys(kept).length === Object.keys(all).length ? all : kept
289  })
290}
291
292/** Removes standups older than KEEP_MS; only names that parse as request ids. */
293async function sweepStandups($: $, now: number) {
294  const old = (await $.fs.list(standupDir()).catch(() => []))
295    .map(f => f.name.replace(/\.json$/, ''))
296    .filter(id => /^\d{13}-[0-9a-z]{6}$/.test(id) && now - Number(id.slice(0, 13)) > KEEP_MS)
297  const paths = [...new Set(old)].flatMap(id => [`${standupDir()}/${id}`, `${standupDir()}/${id}.json`])
298  if (paths.length) void $.process.run(['rm', '-rf', '--', ...paths]).catch(() => {})
299}
300
301async function send($: $, to: string, text: string) {
302  const m = makeMessage('you', 'you', text, await $.clock.now(), Math.random())
303  if (!m) return
304  await log($, to, 'you', m.text)
305  if (to === rt.myId) {
306    void $.prompt.submit({ text: m.text, asUser: true })
307    return
308  }
309  await $.fs.write(`${inboxDir(to)}/${m.id}.json`, JSON.stringify(m))
310  $.ui.toast('📨 Sent')
311}
312
313/** The scene shows this session's room; attention (queue, band, status) stays office-wide. */
314function myRoom(seats: Seat[]): Seat[] {
315  const room = seats.find(s => s.isMe)?.room
316  return room ? seats.filter(s => s.room === room) : seats
317}
318
319async function seatsNow($: $): Promise<{ seats: Seat[]; now: number }> {
320  const now = await $.clock.now()
321  return { seats: mergeRoster(await read($, roster), await read($, me), now), now }
322}
323
324function scene(seats: Seat[], columns: number, now: number, spot: Spotlight | null = null) {
325  const L = layout(columns, seats.length, seats.some(s => s.interns > 0))
326  const desk = assignDesks(seats.map(s => s.id), L.desks.length)
327  return { L, f: drawOffice(L, seats, desk, now, undefined, spot) }
328}
329
330async function spotlightNow($: $, seats: Seat[], now: number): Promise<Spotlight | null> {
331  const st = await read($, standup)
332  if (!st) return null
333  const here = st.answers.filter(a => seats.some(s => s.id === a.id))
334  const id = presenter(here, st.request.requestedAt, now)
335  const a = here.find(x => x.id === id)
336  return a ? { id: a.id, text: headline(a), isBlocked: !!a.blocked } : null
337}
338
339function startBlitting($: $) {
340  rt.blitting ??= $.clock.every(FRAME_MS, () => blitFrame($))
341}
342
343async function blitFrame($: $) {
344  const m = rt.mounted
345  if (!m) return
346  const all = await seatsNow($)
347  const seats = myRoom(all.seats)
348  const now = all.now
349  const { L, f } = scene(seats, m.w, now, await spotlightNow($, seats, now))
350  // more or fewer desk rows is a new mount: redraw instead of blitting
351  if (L.h !== m.h || seats.length !== m.n) return $.ui.invalidate('ui.render')
352  const res = await $.ui.blit({ requestId: PANE, key: 'office', cells: toRasterCells(f) })
353  if ('deny' in res && res.deny) stopAnimating()
354}
355
356function startSvgTicking($: $) {
357  rt.svgTicking ??= $.clock.every(SVG_FRAME_MS, () => $.ui.invalidate('ui.render'))
358}
359
360function stopAnimating() {
361  rt.blitting?.cancel()
362  rt.svgTicking?.cancel()
363  rt.blitting = rt.svgTicking = null
364  rt.mounted = null
365}
366
367async function setIdentity($: $, field: 'name' | 'character', value: string) {
368  await $.store.set(`${field === 'name' ? 'name' : 'look'}:session:${rt.myId}`, value)
369  await update($, me, a => (a ? { ...a, [field]: value } : a))
370  await save($)
371}
372
373/** After /clear: the old desk is cleared and this session sits down again under its new id. */
374async function rejoin($: $) {
375  const id = await $.session.id().catch(() => rt.myId)
376  if (!id || id === rt.myId) return
377  const old = rt.myId
378  void $.process.run(['rm', '-f', '--', `${agentsDir()}/${old}.json`]).catch(() => {})
379  const prev = await read($, me)
380  rt.myId = id
381  rt.lastWritten = ''
382  await update($, me, () => null)
383  await identify($, rt.cwd, prev ? { name: prev.name, character: prev.character } : null)
384}
385
386async function start($: $, cwd: string) {
387  rt.root = `${(await $.env.get('HOME')) ?? '~'}/.claude/pixel-office`
388  rt.myId = await $.session.id()
389  rt.cwd = cwd
390  rt.isAlertsOn = (await $.store.get('alerts')) !== 'off'
391  rt.isSoundOn = (await $.store.get('sound')) !== 'off'
392  await identify($, cwd, null)
393  await $.command.register({ name: 'office', description: 'Open the Pixel Office: every Claude session as a character you can talk to' })
394  $.clock.every(HEARTBEAT_MS, async () => {
395    await signal($, { kind: 'tick' })
396    // subagents running now, read each beat: start/stop hooks miss background and stopped ones
397    const running = await $.agent.list().then(l => l.filter(a => a.status === 'running' && a.type !== 'teammate').length, () => null)
398    if (running !== null) await signal($, { kind: 'interns', count: running })
399  })
400  $.clock.every(POLL_MS, () => poll($))
401}
402
403/** Works out this session's room, name and look, and walks it in. */
404async function identify($: $, cwd: string, keep: { name: string; character: string } | null) {
405  const { room, roomName } = roomOf(cwd, await $.session.repo().catch(() => null))
406  const ticket = keep ? null : await claimTicket($, room) // a rejoin after /clear is not a new agent
407  // a name and look belong to this session: one set earlier (it survives a resume), a ticket's, or a fresh one
408  const savedName = (await $.store.get(`name:session:${rt.myId}`)) ?? keep?.name
409  const savedLook = (await $.store.get(`look:session:${rt.myId}`)) ?? keep?.character
410  const others = await roomNames($, room)
411  const name = typeof savedName === 'string' ? savedName : uniqueName(ticket?.name || basename(cwd) || 'claude', others)
412  const character = typeof savedLook === 'string' ? savedLook
413    : ticket?.character && LOOK_IDS.includes(ticket.character) ? ticket.character
414    : LOOK_IDS[hash(rt.myId) % LOOK_IDS.length]!
415  if (ticket || keep) await $.store.set(`name:session:${rt.myId}`, name)
416  // $PPID of a child shell is this Claude process: what an editor matches its terminals against
417  const ran = await $.process.run(['sh', '-c', 'echo $PPID']).catch(() => null)
418  const pid = Number.parseInt(ran?.stdout.trim() ?? '', 10)
419  await signal($, { kind: 'start', name: truncate(name, 40), cwd, character, pid: Number.isFinite(pid) ? pid : 0, room, roomName })
420  if (ticket?.task) void $.prompt.submit({ text: ticket.task, asUser: true })
421}
422
423/** Names already used by live agents in this room, so a newcomer gets a distinct one. */
424async function roomNames($: $, room: string): Promise<string[]> {
425  const now = await $.clock.now()
426  const names: string[] = []
427  for (const f of await $.fs.list(agentsDir()).catch(() => [])) {
428    if (!f.name.endsWith('.json') || f.name === `${rt.myId}.json` || now - f.mtimeMs > AWAY_MS) continue
429    const r = parseRecord(await $.fs.read(`${agentsDir()}/${f.name}`).catch(() => ''))
430    if (r && r.room === room && r.state !== 'leaving') names.push(r.name)
431  }
432  return names
433}
434
435/**
436 * Takes the oldest fresh spawn ticket for this room, if an editor asked for a new agent here.
437 * `mv` is the claim: of two sessions starting at once, only one moves the file.
438 */
439async function claimTicket($: $, room: string) {
440  const now = await $.clock.now()
441  const tickets = []
442  for (const f of await $.fs.list(spawnDir()).catch(() => [])) {
443    if (!/^\d{13}-[0-9a-z]{6}\.json$/.test(f.name)) continue
444    const t = parseTicket(await $.fs.read(`${spawnDir()}/${f.name}`).catch(() => ''))
445    if (t && `${t.id}.json` === f.name) tickets.push(t)
446  }
447  for (const t of ticketsFor(tickets, room, now)) {
448    const from = `${spawnDir()}/${t.id}.json`
449    const moved = await $.process.run(['mv', '-n', '--', from, `${from}.claimed`]).catch(() => null)
450    const isStillThere = await $.fs.exists(from).catch(() => true)
451    if (moved?.exitCode === 0 && !isStillThere) return t
452  }
453  return null
454}
455
456async function office($: $, e: { surface: string; props: RenderPropsOf['Pane'] }) {
457  const all = await seatsNow($)
458  const seats = myRoom(all.seats)
459  const now = all.now
460  const columns = Math.max(24, e.props.bodyColumns)
461  const spot = await spotlightNow($, seats, now)
462  if (e.surface === 'terminal') {
463    const { L, f } = scene(seats, columns, now, spot)
464    rt.mounted = { w: L.w, h: L.h, n: seats.length }
465    startBlitting($)
466    return { kind: 'raster' as const, columns: L.w, rows: L.h / 2, cells: toRasterCells(f), seats, all: all.seats }
467  }
468  const { L, f } = scene(seats, Math.min(columns, 132), now, spot)
469  startSvgTicking($)
470  return { kind: 'svg' as const, source: toSvg(f, 6), width: L.w * 6, seats, all: all.seats }
471}
472
473function standupCard($: $, e: Parameters<$['ui']['resolve']>[0], st: Standup, seats: Seat[], now: number) {
474  const { Box, Text } = $.ui.resolve(e)
475  const rows = new Map<string, { name: string; a?: StandupAnswer }>()
476  for (const s of seats) rows.set(s.id, { name: s.name })
477  for (const a of st.answers) rows.set(a.id, { name: a.name, a })
478  const isLate = now - st.request.requestedAt > PENDING_MS
479  return (
480    <Box key="standup-card" flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
481      <Text bold color="green">
482        🗣 Standup <Text dimColor>· called by {st.request.by} {formatWait(now - st.request.requestedAt)} ago</Text>
483      </Text>
484      {[...rows.values()].map(({ name, a }) =>
485        a ? (
486          <Box flexDirection="column">
487            <Text bold>{truncate(name, 24)}</Text>
488            {a.done ? <Text>  ✓ {a.done}</Text> : null}
489            {a.next ? <Text dimColor>  → {a.next}</Text> : null}
490            {a.blocked ? <Text color="yellow">  ❗ {a.blocked}</Text> : null}
491          </Box>
492        ) : (
493          <Text dimColor>{truncate(name, 24)} {isLate ? '(no answer)' : '… thinking'}</Text>
494        ),
495      )}
496    </Box>
497  )
498}
499
500export const register: Register = on => {
501  on('session.start', async ($, e, next) => {
502    const ran = await next(e)
503    await start($, e.cwd).catch(err => $.ui.log(`pixel-office: could not start: ${err}`))
504    return ran
505  })
506
507  on('session.end', async ($, e, next) => {
508    // /clear and an in-session resume keep this process under a new id, with no session.start:
509    // rejoin as that id instead of walking out
510    if (e.reason === 'clear' || e.reason === 'resume') {
511      $.clock.after(500, () => rejoin($))
512      return next(e)
513    }
514    await signal($, { kind: 'end' }).catch(() => {})
515    return next(e)
516  })
517
518  on('command.run', { command: 'office' }, async ($, e) => {
519    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
520    const arg = rest.join(' ').trim()
521    if (verb === 'name' && arg) {
522      await setIdentity($, 'name', truncate(arg, 40))
523      return { text: `You are now "${truncate(arg, 40)}" in the office.` }
524    }
525    if (verb === 'standup') {
526      await requestStandup($)
527      await $.ui.open({ id: PANE, title: 'Pixel Office' })
528      return { text: 'Standup called: every session is reporting in.' }
529    }
530    if ((verb === 'alerts' || verb === 'sound') && (arg === 'on' || arg === 'off')) {
531      await $.store.set(verb, arg)
532      if (verb === 'alerts') rt.isAlertsOn = arg === 'on'
533      else rt.isSoundOn = arg === 'on'
534      return { text: `${verb === 'alerts' ? 'Alerts' : 'Chime'} ${arg}.` }
535    }
536    if (verb === 'look') {
537      const cur = (await read($, me))?.character ?? ''
538      const look = LOOK_IDS.includes(arg) ? arg : LOOK_IDS[(LOOK_IDS.indexOf(cur) + 1) % LOOK_IDS.length]!
539      await setIdentity($, 'character', look)
540      return { text: `New look: ${look}. Looks: ${LOOK_IDS.join(', ')}.` }
541    }
542    await $.ui.open({ id: PANE, title: 'Pixel Office' })
543    return { text: 'Pixel Office is open. Tip: /office standup · /office name <name> · /office look · /office alerts|sound on|off' }
544  })
545
546  on('ui.close', async ($, e, next) => {
547    if (e.id === PANE) stopAnimating()
548    return next(e)
549  })
550
551  on('prompt.submit', async ($, e, next) => {
552    await signal($, { kind: 'prompt', text: e.text }).catch(() => {})
553    return next(e)
554  })
555
556  on('tool.call', async ($, e, next) => {
557    if (e.agentId) return next(e)
558    await signal($, { kind: 'tool', tool: String(e.tool), input: e as unknown as Record<string, unknown> }).catch(() => {})
559    const ran = await next(e)
560    await signal($, { kind: 'tool-done', isError: ran.isError === true }).catch(() => {})
561    return ran
562  })
563
564  on('classic.PermissionRequest', async ($, e, next) => {
565    const input = (e.tool_input ?? {}) as Record<string, unknown>
566    const detail =
567      typeof input.command === 'string' ? `: ${input.command}` : typeof input.file_path === 'string' ? `: ${basename(input.file_path)}` : ''
568    await signal($, { kind: 'needs-you', text: bubble(`Allow ${e.tool_name}${detail}?`) }).catch(() => {})
569    return next(e)
570  })
571
572  on('classic.Notification', async ($, e, next) => {
573    if (/permission|elicitation/i.test(e.notification_type)) await signal($, { kind: 'needs-you', text: e.message }).catch(() => {})
574    return next(e)
575  })
576
577  on('turn.complete', async ($, e, next) => {
578    if (!e.agentId) {
579      await signal($, { kind: 'turn-done', text: e.answer, reason: e.reason }).catch(() => {})
580      if (e.answer) await log($, rt.myId, (await read($, me))?.name ?? 'claude', e.answer).catch(() => {})
581    }
582    return next(e)
583  })
584
585  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
586    const { Box, Text, Button } = $.ui.resolve(e)
587    const view = await office($, e)
588    const pick = await read($, selected)
589    const chosen = view.all.find(s => s.id === pick) ?? null
590    const lines = chosen ? ((await read($, logs))[chosen.id] ?? []).slice(-8) : []
591
592    let scene
593    if (view.kind === 'raster' && e.surface === 'terminal') {
594      const { Raster } = $.ui.resolve(e)
595      scene = <Raster key="office" columns={view.columns} rows={view.rows} cells={view.cells} />
596    } else if (view.kind === 'svg' && e.surface !== 'terminal') {
597      const { Svg } = $.ui.resolve(e)
598      scene = <Svg source={view.source} alt={`Pixel office with ${view.seats.length} agents`} width={view.width} />
599    }
600
601    let reply
602    if (chosen && e.surface !== 'mobile') {
603      const { Input } = $.ui.resolve(e)
604      reply = (
605        <Input
606          key="reply"
607          placeholder={chosen.isMe ? 'Prompt this session…' : `Message ${chosen.name}…`}
608          submitLabel="Send"
609          autoFocus
610          onSubmit={text => send($, chosen.id, text)}
611        />
612      )
613    }
614
615    const now = await $.clock.now()
616    const queue = waitingQueue(view.all).filter(s => !s.isMe)
617    const elsewhere = view.all.length - view.seats.length
618    const roomName = view.seats.find(s => s.isMe)?.roomName ?? 'office'
619    const st = await read($, standup)
620    const chips = [...view.seats].sort(byUrgency).slice(0, 9)
621    return (
622      <Box flexDirection="column">
623        {queue.length > 0 && (
624          <Box key="queue" flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
625            <Text bold color="yellow">❗ Needs you ({queue.length})</Text>
626            {queue.slice(0, 5).map(s => (
627              <Text>
628                <Text bold>{truncate(s.name, 20)}</Text> <Text dimColor>· waiting {formatWait(now - s.since)} ·</Text> {truncate(s.bubble, 70)}
629              </Text>
630            ))}
631          </Box>
632        )}
633        {scene}
634        <Box flexDirection="row" flexWrap="wrap" gap={1}>
635          {queue[0] && <Button key="next" hotkey="n" variant="primary" label={`❗ Next: ${truncate(queue[0].name, 14)}`} onPress={() => openOn($, queue[0]!.id)} />}
636          <Button key="standup" hotkey="s" label="🗣 Standup" onPress={() => requestStandup($)} />
637        </Box>
638        <Box flexDirection="row" flexWrap="wrap" gap={1}>
639          {chips.map((s, i) => (
640            <Button
641              key={`chip-${s.id}`}
642              hotkey={String(i + 1)}
643              variant={s.state === 'needs-you' ? 'primary' : undefined}
644              label={`${ICON[s.state]} ${truncate(s.name, 16)}${s.isMe ? ' (here)' : ''}${s.isAway ? ' · away' : ''}`}
645              onPress={() => update($, selected, cur => (cur === s.id ? null : s.id))}
646            />
647          ))}
648        </Box>
649        <Text dimColor>
650          📁 {roomName} · {view.seats.length} here{elsewhere > 0 ? ` · ${elsewhere} in other rooms` : ''}
651        </Text>
652        {view.seats.length < 2 && <Text dimColor>Start another Claude Code session in this repo: it walks into this room.</Text>}
653        {st && standupCard($, e, st, view.seats, now)}
654        {chosen && (
655          <Box flexDirection="column" borderStyle="round" paddingX={1}>
656            <Text bold>
657              {ICON[chosen.state]} {chosen.name}{' '}
658              <Text dimColor>
659                · {chosen.state}{chosen.isAway ? ' · away' : ''} · {chosen.turns} turns · {chosen.tools} tools · {basename(chosen.cwd)}
660              </Text>
661            </Text>
662            {chosen.bubble ? <Text>{chosen.bubble}</Text> : null}
663            {chosen.detail ? <Text dimColor>{truncate(chosen.detail, 120)}</Text> : null}
664            {chosen.state === 'needs-you' && !chosen.isMe && (
665              <Text color="yellow">Permission prompts are answered in {chosen.name}'s own window. A message sent here runs after it.</Text>
666            )}
667            {lines.map(l => (
668              <Text dimColor={l.who !== 'you'}>
669                <Text bold>{l.who}:</Text> {truncate(l.text, 300)}
670              </Text>
671            ))}
672            {reply ?? <Text dimColor>Replying needs a surface with text input.</Text>}
673            <Button key="close" role="dismiss" label="Close" onPress={() => update($, selected, () => null)} />
674          </Box>
675        )}
676      </Box>
677    )
678  })
679
680  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
681    if (e.props.hasSurvey || !rt.myId) return next(e)
682    const { seats, now } = await seatsNow($)
683    const queue = waitingQueue(seats).filter(s => !s.isMe)
684    if (!queue.length) return next(e)
685    const { Box, Text, Button } = $.ui.resolve(e)
686    const list = queue.slice(0, 3).map(s => `${truncate(s.name, 16)} ${formatWait(now - s.since)}`).join(' · ')
687    return (
688      <Box flexDirection="row" gap={1}>
689        <Text color="yellow" wrap="truncate-end">
690          ❗ {queue.length} waiting: {list}{queue.length > 3 ? ' …' : ''}
691        </Text>
692        <Button key="band-next" hotkey="n" variant="primary" label={`Open ${truncate(queue[0]!.name, 14)}`} onPress={() => openOn($, queue[0]!.id)} />
693      </Box>
694    )
695  })
696}
697
hooks/core/agent.ts 105 lines
1// One session's character: a pure reducer from session signals to its record.
2import type { AgentRecord, AgentState } from '../../types'
3import { activityFor } from './signals'
4import { basename, bubble, clean, firstSentence, taskOf, truncate } from './text'
5
6export type Signal =
7  | { kind: 'start'; name: string; cwd: string; character: string; pid?: number; room?: string; roomName?: string }
8  | { kind: 'prompt'; text: string }
9  | { kind: 'tool'; tool: string; input?: Record<string, unknown> }
10  | { kind: 'tool-done'; isError: boolean }
11  | { kind: 'needs-you'; text: string }
12  | { kind: 'turn-done'; text: string; reason?: 'answer' | 'aborted' | 'refusal' | 'error' }
13  | { kind: 'interns'; count: number }
14  | { kind: 'title'; text: string }
15  | { kind: 'end' }
16  | { kind: 'tick' }
17
18export const HOLD_MS = 3000 // stressed / arriving show this long; done stays until the next prompt
19export const AWAY_MS = 20000 // no heartbeat this long → away
20export const PRUNE_MS = 10 * 60 * 1000 // no heartbeat this long → gone
21
22const EDITS = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
23const MAX_FILES = 8
24
25function editedPath(input: Record<string, unknown> = {}): string {
26  const p = input.file_path ?? input.notebook_path
27  return typeof p === 'string' && p.startsWith('/') && p.length <= 4096 ? p : ''
28}
29
30const WORKING: AgentState[] = ['thinking', 'typing', 'reading', 'writing', 'browsing', 'delegating', 'working']
31
32export function isWorking(state: AgentState): boolean {
33  return WORKING.includes(state)
34}
35
36export function newAgent(id: string, now: number): AgentRecord {
37  return {
38    v: 1, id, name: 'claude', cwd: '', character: 'dev-1', state: 'arriving', bubble: 'Morning!', detail: '',
39    isBusy: false, joinedAt: now, since: now, heartbeat: now, turns: 0, tools: 0, interns: 0, lastLine: '', pid: 0, files: [], room: '', roomName: '', task: '', title: '',
40  }
41}
42
43export function reduce(a: AgentRecord, s: Signal, now: number): AgentRecord {
44  const to = (state: AgentState, text = a.bubble, extra: Partial<AgentRecord> = {}): AgentRecord =>
45    ({ ...a, state, bubble: text, since: state === a.state && text === a.bubble ? a.since : now, heartbeat: now, ...extra })
46
47  switch (s.kind) {
48    case 'start':
49      // a restart or hot reload keeps the arrival time, so seniority is stable
50      return { ...newAgent(a.id, now), name: s.name, cwd: s.cwd, character: s.character, turns: a.turns, tools: a.tools, joinedAt: a.joinedAt || now, pid: s.pid ?? a.pid ?? 0, files: a.files ?? [],
51        room: s.room || s.cwd, roomName: s.roomName || basename(s.cwd), task: a.task ?? '', title: a.title ?? '' }
52    case 'prompt':
53      return to('thinking', bubble(s.text), { isBusy: true, turns: a.turns + 1, task: taskOf(s.text) || a.task || '' })
54    case 'title':
55      return { ...a, title: truncate(clean(s.text), 80) }
56    case 'tool': {
57      const act = activityFor(s.tool, s.input)
58      const path = EDITS.includes(s.tool) ? editedPath(s.input) : ''
59      const files = path ? [path, ...(a.files ?? []).filter(f => f !== path)].slice(0, MAX_FILES) : a.files ?? []
60      return to(act.state, act.bubble, { isBusy: true, tools: a.tools + 1, files, detail: act.detail })
61    }
62    case 'tool-done':
63      if (s.isError) return to('stressed', 'Hmm, that failed…')
64      // a tool ending answers a pending question or permission, so needs-you clears
65      return a.state === 'needs-you' || isWorking(a.state) ? to('thinking', a.state === 'needs-you' ? 'Thanks!' : a.bubble) : a
66    case 'needs-you':
67      // a permission prompt raises this twice (the request, then its notification): keep the first
68      // wording and the time the wait started
69      return a.state === 'needs-you' ? { ...a, heartbeat: now } : to('needs-you', bubble(s.text || 'I need you'))
70    case 'turn-done': {
71      if (s.reason === 'aborted') return to('idle', 'Stopped.', { isBusy: false })
72      if (s.reason === 'error') return to('stressed', 'That turn failed.', { isBusy: false })
73      const line = firstSentence(s.text)
74      return to('done', bubble(line || 'Done!'), { isBusy: false, lastLine: line || a.lastLine })
75    }
76    case 'interns':
77      return s.count === a.interns ? a : { ...a, interns: Math.max(0, s.count), heartbeat: now }
78    case 'end':
79      return to('leaving', 'Bye!', { isBusy: false, interns: 0 })
80    case 'tick': {
81      const held = now - a.since >= HOLD_MS
82      if (held && a.state === 'arriving') return { ...to('idle', ''), heartbeat: now }
83      if (held && a.state === 'stressed') return { ...to(a.isBusy ? 'thinking' : 'idle', a.isBusy ? a.bubble : ''), heartbeat: now }
84      return { ...a, heartbeat: now }
85    }
86  }
87}
88
89/** What others see: a stale heartbeat is `away`; `null` means prune it. */
90export function presence(a: AgentRecord, now: number): 'here' | 'away' | null {
91  const age = now - a.heartbeat
92  if (age > PRUNE_MS || (a.state === 'leaving' && age > HOLD_MS)) return null
93  return age > AWAY_MS ? 'away' : 'here'
94}
95
96const RANK: Record<AgentState, number> = {
97  'needs-you': 0, stressed: 1, typing: 2, reading: 2, writing: 2, browsing: 2, delegating: 2, working: 2,
98  thinking: 3, done: 4, arriving: 5, idle: 6, leaving: 7,
99}
100
101/** Roster order for chips: who needs you first, then busiest, then by name. */
102export function byUrgency(a: AgentRecord, b: AgentRecord): number {
103  return RANK[a.state] - RANK[b.state] || a.name.localeCompare(b.name) || a.id.localeCompare(b.id)
104}
105
hooks/core/alerts.ts 65 lines
1// Who is waiting on you, who rings the bell, and when.
2import type { AlertMemory } from '../../types'
3import type { Seat } from './roster'
4
5export const REMIND_MS = 3 * 60 * 1000
6/** A waiting seat missing from one or two polls (a slow read) is not a new episode when it returns. */
7export const FORGET_MS = 15_000
8
9/** Seats blocked on you, longest wait first. */
10export function waitingQueue(seats: Seat[]): Seat[] {
11  return seats.filter(s => s.state === 'needs-you' && !s.isAway).sort((a, b) => a.since - b.since || a.id.localeCompare(b.id))
12}
13
14/**
15 * The one session that plays the chime and posts the OS notification, so N
16 * open sessions ring once: the earliest arrival that is not away.
17 */
18export function alertLeader(seats: Seat[]): string | null {
19  const here = seats.filter(s => !s.isAway).sort((a, b) => a.joinedAt - b.joinedAt || a.id.localeCompare(b.id))
20  return here[0]?.id ?? null
21}
22
23export type AlertsDue = { fresh: Seat[]; remind: Seat[]; memory: AlertMemory }
24
25/**
26 * Compares the queue with what was already announced. A waiting episode is
27 * keyed by the seat's `since`: a new episode fires once, gets one reminder
28 * after REMIND_MS, and is forgotten once the seat stops waiting.
29 */
30export function alertsDue(queue: Seat[], memory: AlertMemory, now: number): AlertsDue {
31  const fresh: Seat[] = []
32  const remind: Seat[] = []
33  const next: AlertMemory = {}
34  for (const s of queue) {
35    const seen = memory[s.id]
36    if (!seen || seen.since !== s.since) {
37      fresh.push(s)
38      next[s.id] = { since: s.since, isReminded: false, seenAt: now }
39    } else if (!seen.isReminded && now - s.since >= REMIND_MS) {
40      remind.push(s)
41      next[s.id] = { since: s.since, isReminded: true, seenAt: now }
42    } else next[s.id] = { ...seen, seenAt: now }
43  }
44  for (const [id, seen] of Object.entries(memory)) if (!next[id] && now - (seen.seenAt ?? 0) < FORGET_MS) next[id] = seen
45  return { fresh, remind, memory: next }
46}
47
48export function formatWait(ms: number): string {
49  const s = Math.max(0, Math.floor(ms / 1000))
50  if (s < 60) return `${s}s`
51  const m = Math.floor(s / 60)
52  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ''}`
53}
54
55/** A macOS notification; the words are argv items, never script source. */
56export function notifyArgv(title: string, body: string): string[] {
57  return [
58    'osascript',
59    '-e', 'on run argv',
60    '-e', 'display notification (item 1 of argv) with title (item 2 of argv)',
61    '-e', 'end run',
62    '--', body, title,
63  ]
64}
65
hooks/core/encode.ts 95 lines
1// A frame as the terminal's Raster cells, or as an SVG document.
2import type { Frame } from './pixels'
3
4// The environment has the TC39 base64 method; es2023's lib does not type it yet.
5declare global {
6  interface Uint8Array {
7    toBase64(): string
8  }
9}
10
11const UPPER_HALF = 0x2580
12
13/**
14 * Raster cells take one printable width-1 BMP code point; anything wider or
15 * invisible (emoji, CJK, combining marks, zero-width) is dropped from text.
16 */
17export function isCellChar(cp: number): boolean {
18  const ok =
19    (cp >= 0x20 && cp < 0x7f) ||
20    (cp >= 0xa0 && cp < 0x300) ||
21    (cp >= 0x370 && cp < 0x1100) ||
22    (cp >= 0x2010 && cp <= 0x2027) ||
23    (cp >= 0x2030 && cp < 0x2060) ||
24    (cp >= 0x2190 && cp < 0x2300) ||
25    (cp >= 0x2500 && cp < 0x2600)
26  return ok
27}
28
29/** columns = w, rows = h / 2: each cell is ▀ with the top pixel as fg, bottom as bg. */
30export function toRasterWords(f: Frame): Uint32Array {
31  const rows = f.h >> 1
32  const out = new Uint32Array(f.w * rows * 3)
33  for (let r = 0; r < rows; r++)
34    for (let c = 0; c < f.w; c++) {
35      const i = (r * f.w + c) * 3
36      out[i] = UPPER_HALF
37      out[i + 1] = f.px[2 * r * f.w + c]!
38      out[i + 2] = f.px[(2 * r + 1) * f.w + c]!
39    }
40  for (const t of f.texts) {
41    let c = t.col
42    // a dropped glyph (an emoji icon) takes no cell; the run closes up
43    for (const ch of [...t.text.replace(/\uFE0F/g, '')].filter(ch => isCellChar(ch.codePointAt(0)!))) {
44      if (c >= f.w) break
45      const i = (t.row * f.w + c) * 3
46      out[i] = ch.codePointAt(0)!
47      out[i + 1] = t.fg
48      out[i + 2] = t.bg
49      c++
50    }
51  }
52  return out
53}
54
55export function toRasterCells(f: Frame): string {
56  return new Uint8Array(toRasterWords(f).buffer).toBase64()
57}
58
59const hex = (c: number) => '#' + c.toString(16).padStart(6, '0')
60const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
61
62/** One <path> per colour of horizontal runs, then text runs on their plates. */
63export function toSvg(f: Frame, scale: number): string {
64  const runs = new Map<number, string[]>()
65  for (let y = 0; y < f.h; y++) {
66    let x = 0
67    while (x < f.w) {
68      const c = f.px[y * f.w + x]!
69      let n = 1
70      while (x + n < f.w && f.px[y * f.w + x + n] === c) n++
71      let list = runs.get(c)
72      if (!list) runs.set(c, (list = []))
73      list.push(`M${x} ${y}h${n}v1H${x}z`)
74      x += n
75    }
76  }
77  const parts = [
78    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${f.w} ${f.h}" width="${f.w * scale}" height="${f.h * scale}" shape-rendering="crispEdges">`,
79  ]
80  for (const [c, d] of runs) parts.push(`<path fill="${hex(c)}" d="${d.join('')}"/>`)
81  if (f.texts.length) {
82    parts.push('<g xml:space="preserve" font-family="ui-monospace,Menlo,monospace" font-size="1.66" shape-rendering="auto">')
83    for (const t of f.texts) {
84      const len = [...t.text].length
85      parts.push(
86        `<rect x="${t.col}" y="${t.row * 2}" width="${len}" height="2" fill="${hex(t.bg)}"/>`,
87        `<text x="${t.col}" y="${t.row * 2 + 1.5}" fill="${hex(t.fg)}" textLength="${len}" lengthAdjust="spacingAndGlyphs">${esc(t.text)}</text>`,
88      )
89    }
90    parts.push('</g>')
91  }
92  parts.push('</svg>')
93  return parts.join('')
94}
95
hooks/core/inbox.ts 54 lines
1// Messages between sessions: one file per message, ids sort by time.
2import type { InboxMessage } from '../../types'
3
4export const MAX_TEXT = 4000
5
6export function messageId(now: number, rand: number): string {
7  const r = Math.floor(rand * 36 ** 6).toString(36).padStart(6, '0')
8  return `${String(now).padStart(13, '0')}-${r}`
9}
10
11export function makeMessage(from: string, fromName: string, text: string, now: number, rand: number): InboxMessage | null {
12  const body = text.trim()
13  if (!body || body.length > MAX_TEXT) return null
14  return { v: 1, id: messageId(now, rand), from, fromName, text: body, sentAt: now }
15}
16
17const ID = /^\d{13}-[0-9a-z]{6}$/
18
19export function parseMessage(text: string): InboxMessage | null {
20  try {
21    const m: unknown = JSON.parse(text)
22    if (!m || typeof m !== 'object') return null
23    const r = m as Record<string, unknown>
24    const ok =
25      r.v === 1 && typeof r.id === 'string' && ID.test(r.id) && typeof r.from === 'string' &&
26      typeof r.fromName === 'string' && r.fromName.length <= 100 && typeof r.text === 'string' &&
27      r.text.trim().length > 0 && r.text.length <= MAX_TEXT && typeof r.sentAt === 'number'
28    return ok ? (r as InboxMessage) : null
29  } catch {
30    return null
31  }
32}
33
34/**
35 * Message files not yet delivered, oldest first. Delivery deletes the file, and the ids
36 * delivered so far are remembered too, so a file whose delete failed is never delivered twice
37 * and a message that arrives out of order is never skipped.
38 */
39export function pendingFiles(names: string[], delivered: readonly string[]): string[] {
40  const done = new Set(delivered)
41  return [...new Set(names)].filter(n => n.endsWith('.json') && ID.test(n.slice(0, -5)) && !done.has(n.slice(0, -5))).sort()
42}
43
44export const DELIVERED_KEEP = 200
45
46/** The prompt a delivered message becomes in the receiving session. */
47export function asPrompt(m: InboxMessage): string {
48  // always labelled: anything that can write the shared folder can drop a message, so the
49  // receiving session should know where the words came from
50  return m.from === 'you'
51    ? `[Sent to you through Pixel Office]\n\n${m.text}`
52    : `[Message from the Claude session "${m.fromName}" via Pixel Office]\n\n${m.text}`
53}
54
hooks/core/roster.ts 84 lines
1// Reading other sessions' presence files and seating them.
2import type { AgentRecord, AgentState } from '../../types'
3import { presence } from './agent'
4import { basename } from './text'
5
6const STATES: AgentState[] = [
7  'arriving', 'idle', 'thinking', 'typing', 'reading', 'writing', 'browsing',
8  'delegating', 'working', 'needs-you', 'stressed', 'done', 'leaving',
9]
10
11const isStr = (v: unknown, max = 500): v is string => typeof v === 'string' && v.length <= max
12const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
13
14/** Parses one presence file; anything malformed or foreign is `null`. */
15export function parseRecord(text: string): AgentRecord | null {
16  let r: Record<string, unknown>
17  try {
18    const parsed: unknown = JSON.parse(text)
19    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
20    r = parsed as Record<string, unknown>
21  } catch {
22    return null
23  }
24  const ok =
25    r.v === 1 && isStr(r.id, 100) && r.id.length > 0 && isStr(r.name, 100) && isStr(r.cwd, 4096) &&
26    isStr(r.character, 50) && STATES.includes(r.state as AgentState) && isStr(r.bubble) &&
27    typeof r.isBusy === 'boolean' && [r.joinedAt, r.since, r.heartbeat, r.turns, r.tools, r.interns].every(isNum) &&
28    isStr(r.lastLine, 2000)
29  if (!ok) return null
30  // optional since 0.2: fill what an older mod did not write
31  const pid = isNum(r.pid) ? r.pid : 0
32  const files = Array.isArray(r.files) ? r.files.filter((f): f is string => isStr(f, 4096)).slice(0, 8) : []
33  const room = isStr(r.room, 4096) && r.room ? r.room : (r.cwd as string)
34  const roomName = isStr(r.roomName, 100) && r.roomName ? r.roomName : basename(room)
35  const detail = isStr(r.detail) ? r.detail : ''
36  const task = isStr(r.task, 200) ? r.task : ''
37  const title = isStr(r.title, 200) ? r.title : ''
38  return { ...(r as AgentRecord), pid, files, room, roomName, detail, task, title }
39}
40
41export type Seat = AgentRecord & { isAway: boolean; isMe: boolean }
42
43/** Live roster from parsed files: pruned, deduped by id, own record wins. */
44export function mergeRoster(records: (AgentRecord | null)[], me: AgentRecord | null, now: number): Seat[] {
45  const byId = new Map<string, AgentRecord>()
46  for (const r of records) if (r && (!byId.has(r.id) || byId.get(r.id)!.heartbeat < r.heartbeat)) byId.set(r.id, r)
47  if (me) byId.set(me.id, { ...me, heartbeat: now })
48  const seats: Seat[] = []
49  for (const r of byId.values()) {
50    if (!r.cwd) continue // never reached 'start' (a session that died while starting): no folder, no room
51    const p = presence(r, now)
52    if (p) seats.push({ ...r, isAway: p === 'away', isMe: r.id === me?.id })
53  }
54  return seats.sort((a, b) => a.joinedAt - b.joinedAt || a.id.localeCompare(b.id))
55}
56
57/** 32-bit FNV-1a: a stable number per id, for desks and default looks. */
58export function hash(text: string): number {
59  let h = 0x811c9dc5
60  for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
61  return h >>> 0
62}
63
64/**
65 * Desk per id: each id prefers desk hash(id) % count and probes forward, ids
66 * seated in id order so the same set always lands the same way. Ids beyond
67 * `count` get no desk (-1) and stand in the overflow row.
68 */
69export function assignDesks(ids: string[], count: number): Map<string, number> {
70  const out = new Map<string, number>()
71  const taken = new Set<number>()
72  for (const id of [...new Set(ids)].sort()) {
73    if (taken.size >= count) {
74      out.set(id, -1)
75      continue
76    }
77    let d = hash(id) % count
78    while (taken.has(d)) d = (d + 1) % count
79    taken.add(d)
80    out.set(id, d)
81  }
82  return out
83}
84
hooks/core/scene.ts 213 lines
1// The office: layout from the room's width, then one frame for a moment in time.
2import { HOLD_MS } from './agent'
3import { blit, frame, plot, rect, shade, text, type Frame } from './pixels'
4import type { Seat } from './roster'
5import { hash } from './roster'
6import {
7  ACCESSORY, ACCESSORY_PALETTE, COFFEE, DESK, DOOR, FACE, FURNITURE_PALETTE, HAIR, INTERN, MONITOR, PERSON,
8  PLANT, SHELF, STYLES, WINDOW, WINDOW_PALETTE, lookPalette, withHair,
9} from './sprites'
10import { truncate, wrap } from './text'
11
12export const CELL_W = 22
13export const CELL_H = 22
14export const WALL_H = 12
15export const MAX_ROWS = 64 // effectively uncapped: every agent gets a desk
16export const IDLE_ZZZ_MS = 60_000
17export const PLAY_H = 18 // the play mat under each desk row, only while someone has subagents
18export const MAX_KIDS = 10 // subagents drawn per agent; the rest are "+N"
19const KIDS_PER_ROW = 5
20const BUBBLE_W = CELL_W - 2
21const DESK_TEXT_W = 16
22
23/** A standup presenter: stands up and says `text` (yellow when blocked, else green). */
24export type Spotlight = { id: string; text: string; isBlocked: boolean }
25
26export type Layout = { w: number; h: number; cols: number; rows: number; cellH: number; desks: { x: number; y: number }[] }
27
28/** One pixel per column; desk rows grow with the head count, and gain a play mat when `play`. */
29export function layout(columns: number, count: number, play = false): Layout {
30  const cellH = CELL_H + (play ? PLAY_H : 0)
31  const w = Math.max(CELL_W + 2, Math.min(Math.floor(columns), 512))
32  const cols = Math.max(1, Math.floor((w - 2) / CELL_W))
33  const rows = Math.max(1, Math.min(MAX_ROWS, Math.ceil(count / cols)))
34  const ox = Math.floor((w - cols * CELL_W) / 2)
35  const desks: Layout['desks'] = []
36  for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) desks.push({ x: ox + c * CELL_W, y: WALL_H + r * cellH })
37  return { w, h: WALL_H + rows * cellH + 2, cols, rows, cellH, desks }
38}
39
40export const DOOR_AT = { x: 3, y: WALL_H - DOOR.length }
41
42export type Theme = {
43  wall: number; trim: number; floorA: number; floorB: number
44  bubble: number; bubbleText: number; alert: number; spot: number; plate: number; plateText: number
45  isNight: boolean
46}
47
48export const DEFAULT_THEME: Theme = {
49  wall: 0x3b3552, trim: 0x2a2540, floorA: 0xc8a77a, floorB: 0xbf9d70,
50  bubble: 0xffffff, bubbleText: 0x1a1a1a, alert: 0xffe08a, spot: 0xc8f7c5, plate: 0x2a2540, plateText: 0xf0f0f0,
51  isNight: false,
52}
53
54const SCREEN: Partial<Record<Seat['state'], number[]>> = {
55  typing: [0x39d353, 0x1e2a35], working: [0x39d353, 0x1e2a35], thinking: [0x2f5d7c, 0x1e2a35],
56  browsing: [0x4aa3ff, 0x2f6fb5], reading: [0xe8e8e8, 0xb8b8b8], writing: [0xe8e8e8, 0x39d353],
57  delegating: [0xb48cff, 0x1e2a35], 'needs-you': [0xffd23f, 0x1e2a35], stressed: [0xd94848, 0x1e2a35],
58  done: [0x39d353, 0x39d353],
59}
60
61/** Draws the office for `now`; `seats` already know their desk (`desk` map, -1 = none). */
62export function drawOffice(
63  L: Layout, seats: Seat[], desk: Map<string, number>, now: number, theme = DEFAULT_THEME, spotlight: Spotlight | null = null,
64): Frame {
65  const f = frame(L.w, L.h, theme.floorA)
66  drawRoom(f, L, theme)
67  const tick = Math.floor(now / 250) % 2
68  const walkers: (() => void)[] = []
69  const style = styles(seats)
70
71  for (const s of seats) {
72    const d = desk.get(s.id) ?? -1
73    const at = L.desks[d]
74    if (!at) continue
75    const pal = lookPalette(s.character, hash(s.id))
76    const look = style.get(s.id)!
77    const face = FACE[Math.floor(look / HAIR.length)]
78    const dim = s.isAway ? 0.45 : 1
79    const cx = at.x + 10
80    const spot = spotlight?.id === s.id ? spotlight : null
81    const cy = at.y + 6 - (spot ? 2 : 0) // a presenter stands up
82    const walking = s.state === 'arriving' || s.state === 'leaving'
83
84    if (!walking) {
85      const blinks = (now + (hash(s.id) % 3000)) % 3000 < 160
86      blit(f, withHair(blinks ? PERSON.blink : PERSON.sit, look), cx, cy, pal, dim)
87      if (face) acc(f, face, cx, cy, pal, dim)
88      // done: both arms up for a moment, then one hand stays raised until the next prompt
89      const cheering = s.state === 'done' && now - s.since < HOLD_MS
90      if (s.state === 'needs-you' || (s.state === 'done' && !cheering)) acc(f, 'handUp', cx, cy, pal, dim)
91      if (cheering) acc(f, 'armsUpL', cx, cy, pal, dim), acc(f, 'armsUpR', cx, cy, pal, dim)
92      if (s.state === 'needs-you' && tick === 0) acc(f, 'alert', cx, cy, pal, dim)
93      if (s.state === 'stressed') acc(f, 'sweat', cx, cy + (Math.floor(now / 200) % 3), pal, dim)
94      if (s.state === 'thinking') for (let i = 0; i <= Math.floor(now / 300) % 3; i++) plot(f, cx + 2 + i * 2, at.y + 4, 0xffffff)
95    }
96
97    blit(f, DESK, at.x + 2, at.y + 14, FURNITURE_PALETTE, dim)
98    // its title, written on the desk's front panel (16 cells wide, one text row)
99    const title = truncate(s.title || s.task || '', DESK_TEXT_W)
100    if (title) text(f, at.x + 3 + Math.floor((DESK_TEXT_W - [...title].length) / 2), at.y / 2 + 8, title, s.isAway ? 0x9a9a9a : 0xf3e2c0, shade(FURNITURE_PALETTE.F!, dim))
101    blit(f, MONITOR, at.x + 3, at.y + 8, FURNITURE_PALETTE, dim)
102    const screen = SCREEN[s.state]
103    if (screen && !s.isAway) {
104      // three lines of "code" whose lengths scroll with the clock
105      const scroll = Math.floor(now / 250) + hash(s.id)
106      for (let j = 0; j < 3; j++) {
107        const len = 1 + ((scroll + j) * 7) % 4
108        for (let i = 0; i < 4; i++) plot(f, at.x + 4 + i, at.y + 9 + j, screen[i < len ? 0 : 1]!)
109      }
110    }
111
112    if (!walking) {
113      if (s.state === 'typing' || s.state === 'working') acc(f, tick ? 'hands1' : 'hands2', cx, cy, pal, dim)
114      if (s.state === 'reading') acc(f, tick ? 'book1' : 'book2', cx, cy, pal, dim)
115      if (s.state === 'writing') acc(f, 'paper', cx, cy, pal, dim), acc(f, tick ? 'pencil1' : 'pencil2', cx, cy, pal, dim)
116    }
117
118    if (s.interns > 0 && L.cellH > CELL_H) drawPlayArea(f, at, s.interns, tick, pal, dim)
119
120    if (walking) {
121      walkers.push(() => {
122        const p = Math.max(0, Math.min(1, (now - s.since) / HOLD_MS))
123        const k = s.state === 'arriving' ? p : 1 - p
124        const x = Math.round(DOOR_AT.x + (cx - DOOR_AT.x) * k)
125        const y = Math.round(WALL_H - 4 + (cy - (WALL_H - 4)) * k)
126        blit(f, withHair(tick ? PERSON.walk1 : PERSON.walk2, look), x, y, pal, dim)
127        if (face) acc(f, face, x, y, pal, dim)
128      })
129    }
130
131    drawBubble(f, s, at, now, theme, spot)
132    const label = truncate(`${s.isMe ? '• ' : ''}${s.name}`, BUBBLE_W)
133    const lcol = at.x + 1 + Math.floor((BUBBLE_W - [...label].length) / 2)
134    text(f, lcol, at.y / 2 + 10, label, s.isAway ? 0x9a9a9a : theme.plateText, theme.plate)
135  }
136  walkers.forEach(w => w())
137  return f
138}
139
140/**
141 * Hairstyle + face per id, unique within the room for each colour look: earliest
142 * arrivals pick first (hash(id) % STYLES, probing forward), so newcomers never restyle anyone.
143 */
144export function styles(seats: Seat[]): Map<string, number> {
145  const out = new Map<string, number>()
146  const taken = new Set<string>()
147  for (const s of [...seats].sort((a, b) => a.joinedAt - b.joinedAt || (a.id < b.id ? -1 : 1))) {
148    let k = hash(s.id) % STYLES
149    for (let i = 0; i < STYLES && taken.has(`${s.character}:${k}`); i++) k = (k + 1) % STYLES
150    taken.add(`${s.character}:${k}`)
151    out.set(s.id, k)
152  }
153  return out
154}
155
156/** Subagents as kids on a mat under their agent's desk: two rows of five, then "+N". */
157function drawPlayArea(f: Frame, at: { x: number; y: number }, n: number, tick: number, pal: Record<string, number>, dim: number) {
158  const y = at.y + CELL_H
159  rect(f, at.x + 1, y, CELL_W - 2, PLAY_H - 1, shade(0x6fa8c9, dim))
160  rect(f, at.x + 2, y + 1, CELL_W - 4, PLAY_H - 3, shade(0x8cc4de, dim))
161  for (let i = 0; i < Math.min(MAX_KIDS, n); i++) {
162    const col = i % KIDS_PER_ROW
163    const row = Math.floor(i / KIDS_PER_ROW)
164    blit(f, INTERN, at.x + 1 + col * 4, y + 2 + row * 7 - ((tick + i) % 2), pal, dim)
165  }
166  if (n > MAX_KIDS) {
167    const more = `+${n - MAX_KIDS}`
168    text(f, at.x + Math.floor((CELL_W - more.length) / 2), (y + 16) / 2, more, 0x1a1a1a, shade(0x8cc4de, dim))
169  }
170}
171
172function acc(f: Frame, name: keyof typeof ACCESSORY, cx: number, cy: number, pal: Record<string, number>, dim: number) {
173  const a = ACCESSORY[name]
174  blit(f, a.art, cx + a.at[0], cy + a.at[1], { ...ACCESSORY_PALETTE, s: pal.s!, c: pal.c!, h: pal.h! }, dim)
175}
176
177function drawBubble(f: Frame, s: Seat, at: { x: number; y: number }, now: number, theme: Theme, spot: Spotlight | null) {
178  let words = s.bubble
179  let bg = theme.bubble
180  let fg = theme.bubbleText
181  if (spot) (words = spot.text), (bg = spot.isBlocked ? theme.alert : theme.spot)
182  else if (s.isAway) (words = '(away)'), (bg = 0x55556a), (fg = 0xdddddd)
183  else if (s.state === 'needs-you') bg = theme.alert
184  else if (s.state === 'idle' && now - s.since > IDLE_ZZZ_MS) (words = 'z z z'), (bg = theme.wall), (fg = 0xcfd6ff)
185  if (!words) return
186  const lines = wrap(words, BUBBLE_W)
187  const shown = lines.length > 2 ? [lines[0]!, truncate(lines.slice(1).join(' '), BUBBLE_W)] : lines
188  shown.forEach((line, i) => text(f, at.x + 1, at.y / 2 + i, ` ${line} `.slice(0, BUBBLE_W + 1), fg, bg))
189}
190
191function drawRoom(f: Frame, L: Layout, t: Theme) {
192  for (let y = WALL_H; y < L.h; y++)
193    for (let x = 0; x < L.w; x++) if (((x >> 2) + (y >> 2)) % 2) f.px[y * L.w + x] = t.floorB
194  rect(f, 0, 0, L.w, WALL_H, t.wall)
195  rect(f, 0, WALL_H - 1, L.w, 1, t.trim)
196  blit(f, DOOR, DOOR_AT.x, DOOR_AT.y, FURNITURE_PALETTE)
197  const win = WINDOW_PALETTE(t.isNight)
198  for (let x = 14; x + 10 <= L.w - 30; x += 30) blit(f, WINDOW, x, 2, win)
199  if (L.w >= 60) blit(f, SHELF, L.w - 20, 6, FURNITURE_PALETTE), blit(f, COFFEE, L.w - 28, WALL_H - 5, FURNITURE_PALETTE)
200  blit(f, PLANT, L.w - 10, WALL_H - 7, FURNITURE_PALETTE)
201  if (t.isNight) for (let x = 0; x < L.w; x++) for (let y = 0; y < L.h; y++) f.px[y * L.w + x] = shade(f.px[y * L.w + x]!, 0.8)
202}
203
204/** The seat id under a cell, or null: what a click at (col, row) means. */
205export function hitTest(L: Layout, desk: Map<string, number>, col: number, row: number): string | null {
206  const x = col
207  const y = row * 2
208  const d = L.desks.findIndex(at => x >= at.x && x < at.x + CELL_W && y >= at.y && y < at.y + L.cellH)
209  if (d < 0) return null
210  for (const [id, n] of desk) if (n === d) return id
211  return null
212}
213
hooks/core/sprites.ts 171 lines
1// Built-in art. Every sprite is string art over a palette, so packs can be plain JSON.
2import type { Palette, Sprite } from './pixels'
3
4// ── people (8 × 12) ──────────────────────────────────────────────
5// h hair · s skin · e eye · c shirt · C shirt shade · p pants · k shoes
6export const PERSON = {
7  sit: [
8    '..hhhh..',
9    '.hhhhhh.',
10    '.hssssh.',
11    '.sesses.',
12    '.ssssss.',
13    '...ss...',
14    '.cccccc.',
15    'cccccccc',
16    'sccCCccs',
17    'sccccccs',
18    '.pppppp.',
19    '.pp..pp.',
20  ],
21  blink: [
22    '..hhhh..',
23    '.hhhhhh.',
24    '.hssssh.',
25    '.ssssss.',
26    '.ssssss.',
27    '...ss...',
28    '.cccccc.',
29    'cccccccc',
30    'sccCCccs',
31    'sccccccs',
32    '.pppppp.',
33    '.pp..pp.',
34  ],
35  walk1: [
36    '..hhhh..',
37    '.hhhhhh.',
38    '.hssssh.',
39    '.sesses.',
40    '.ssssss.',
41    '...ss...',
42    '.cccccc.',
43    'scccccc.',
44    'sccCCccs',
45    '.ccccccs',
46    '.pp..pp.',
47    'kk....kk',
48  ],
49  walk2: [
50    '..hhhh..',
51    '.hhhhhh.',
52    '.hssssh.',
53    '.sesses.',
54    '.ssssss.',
55    '...ss...',
56    '.cccccc.',
57    '.ccccccs',
58    'sccCCccs',
59    'scccccc.',
60    '..pppp..',
61    '..kkkk..',
62  ],
63} satisfies Record<string, Sprite>
64
65// Hairstyles: the person's top three rows, swapped in so no two desks look alike.
66export const HAIR: readonly Sprite[] = [
67  ['..hhhh..', '.hhhhhh.', '.hssssh.'], // short
68  ['.h.hh.h.', '.hhhhhh.', '.hssssh.'], // spiky
69  ['..hhhh..', '.hhhhhh.', 'hhsssshh'], // long
70  ['........', '..hhhh..', '.ssssss.'], // buzz
71  ['...hh...', '.hhhhhh.', '.hssssh.'], // bun
72  ['..hhhh..', '.hhhhhh.', '.hhhssh.'], // fringe
73  ['..cccc..', '.ccccccc', '.hssssh.'], // cap, in the shirt colour
74  ['...hh...', '..hhhh..', '.ssssss.'], // mohawk
75]
76export const FACE = [null, 'glasses', 'beard'] as const
77export const STYLES = HAIR.length * FACE.length
78
79export const withHair = (s: Sprite, style: number): Sprite => [...HAIR[style % HAIR.length]!, ...s.slice(3)]
80
81export const INTERN: Sprite = ['.hh.', 'hssh', '.ss.', 'cccc', 'cccc', '.pp.']
82
83// ── accessories, drawn relative to the person's top-left ─────────
84export const ACCESSORY = {
85  handUp: { at: [7, -2], art: ['s', 's', 's', 's', 's', 's', 's', 's', 'c', 'c'] },
86  armsUpL: { at: [-1, -1], art: ['s', 's', 's', 's', 's', 's', 's', 's', 'c'] },
87  armsUpR: { at: [8, -1], art: ['s', 's', 's', 's', 's', 's', 's', 's', 'c'] },
88  alert: { at: [9, -2], art: ['y', 'y', 'y', '.', 'y'] },
89  sweat: { at: [7, 1], art: ['b', 'b'] },
90  book1: { at: [1, 8], art: ['rrwwrr', 'rrwwrr'] },
91  book2: { at: [1, 8], art: ['rrrwwr', 'rrrwwr'] },
92  paper: { at: [1, 9], art: ['wwwww'] },
93  pencil1: { at: [2, 8], art: ['y'] },
94  pencil2: { at: [4, 8], art: ['y'] },
95  hands1: { at: [0, 9], art: ['s......s'] },
96  hands2: { at: [0, 9], art: ['.s....s.'] },
97  dot: { at: [0, 0], art: ['w'] },
98  glasses: { at: [1, 3], art: ['g.gg.g'] },
99  beard: { at: [1, 4], art: ['h....h', '.hhhh.'] },
100} satisfies Record<string, { at: readonly [number, number]; art: Sprite }>
101
102export const ACCESSORY_PALETTE: Palette = {
103  y: 0xffd23f, b: 0x5ab8ff, r: 0xd94848, w: 0xffffff, g: 0x9aa0a6,
104}
105
106// ── furniture ────────────────────────────────────────────────────
107export const DESK: Sprite = [
108  'tttttttttttttttttt',
109  'ffffffffffffffffff',
110  'fFFFFFFFFFFFFFFFFf',
111  'fFFFFFFFFFFFFFFFFf',
112  'l................l',
113  'l................l',
114]
115export const MONITOR: Sprite = ['mmmmmm', 'mSSSSm', 'mSSSSm', 'mSSSSm', 'mmmmmm', '..mm..']
116export const PLANT: Sprite = ['.g.G.', 'gGgGg', '.gGg.', 'GgGgG', '.ooo.', '.ooo.', '..o..']
117export const COFFEE: Sprite = ['kkkkk', 'kRRRk', 'kkkkk', 'k.w.k', 'kkkkk']
118export const SHELF: Sprite = ['bbbbbbbb', 'b1234123', 'bbbbbbbb', 'b3412341', 'bbbbbbbb']
119export const WINDOW: Sprite = [
120  'wwwwwwwwww',
121  'wGGGGwHGGw',
122  'wGGGGwGGGw',
123  'wwwwwwwwww',
124  'wGGHGwGGGw',
125  'wGGGGwGGGw',
126  'wwwwwwwwww',
127]
128export const DOOR: Sprite = ['dddddd', 'dDDDDd', 'dDDDDd', 'dDDDDd', 'dDDDyd', 'dDDDDd', 'dDDDDd', 'dDDDDd']
129
130// ── looks: palette swaps per character id ───────────────────────
131type Look = { h: number; s: number; c: number; C: number; p: number; k: number }
132export const LOOKS: Readonly<Record<string, Look>> = {
133  'dev-1': { h: 0x3b2417, s: 0xf1c27d, c: 0x4a7bd1, C: 0x3a62a8, p: 0x2d2d3a, k: 0x1a1a1a },
134  'dev-2': { h: 0xe8c547, s: 0xffdbac, c: 0xd9534f, C: 0xb03f3c, p: 0x34495e, k: 0x222222 },
135  'dev-3': { h: 0x111111, s: 0x8d5524, c: 0x2ecc71, C: 0x25a25a, p: 0x2c3e50, k: 0x111111 },
136  'dev-4': { h: 0xb5651d, s: 0xe0ac69, c: 0x9b59b6, C: 0x7d4592, p: 0x3d3d3d, k: 0x1a1a1a },
137  'dev-5': { h: 0xc0c0c0, s: 0xffe0bd, c: 0xf39c12, C: 0xc87f0a, p: 0x1f3a5f, k: 0x202020 },
138  'dev-6': { h: 0xd1495b, s: 0xc68642, c: 0x1abc9c, C: 0x159a80, p: 0x2b2b2b, k: 0x111111 },
139  'dev-7': { h: 0x2c1b10, s: 0xffcd94, c: 0xecf0f1, C: 0xbdc3c7, p: 0x5d4037, k: 0x3e2723 },
140  'dev-8': { h: 0x6a3d9a, s: 0xa1665e, c: 0xe84393, C: 0xc2367a, p: 0x2d3436, k: 0x111111 },
141}
142export const LOOK_IDS = Object.keys(LOOKS)
143
144export function lookPalette(character: string, fallbackIndex: number): Palette {
145  const look = LOOKS[character] ?? LOOKS[LOOK_IDS[fallbackIndex % LOOK_IDS.length]!]!
146  return { ...look, e: 0x1a1a1a }
147}
148
149export const FURNITURE_PALETTE: Palette = {
150  t: 0xa0703f, f: 0x8b5a2b, F: 0x7a4d24, l: 0x4a2e15,
151  m: 0x2b2b2b, S: 0x1e2a35,
152  g: 0x3f9b4a, G: 0x2f7a38, o: 0xb5543c,
153  k: 0x3a3a3a, R: 0xc0392b, w: 0xdddddd,
154  b: 0x6b4a2b, 1: 0xd94848, 2: 0x4a90d9, 3: 0x3fae5a, 4: 0xe0b23f,
155  d: 0x5a3a22, D: 0x7a5232, y: 0xffd23f,
156}
157export const WINDOW_PALETTE = (isNight: boolean): Palette => ({
158  w: 0x6b5a45, G: isNight ? 0x1b2a4a : 0x8fd3ff, H: isNight ? 0xfff3b0 : 0xd5f0ff,
159})
160
161/** Problems with a sprite against its palette: ragged rows or unknown keys. */
162export function spriteProblems(name: string, s: Sprite, pal: Palette): string[] {
163  const out: string[] = []
164  const w = s[0]?.length ?? 0
165  s.forEach((row, j) => {
166    if (row.length !== w) out.push(`${name}: row ${j} is ${row.length} wide, expected ${w}`)
167    for (const ch of row) if (ch !== '.' && pal[ch] === undefined) out.push(`${name}: row ${j} has unknown key '${ch}'`)
168  })
169  return out
170}
171
hooks/core/standup.ts 106 lines
1// Standup: one request, every session answers from its own transcript.
2import type { StandupAnswer, StandupRequest } from '../../types'
3import { clean, truncate } from './text'
4
5export const ANSWER_WINDOW_MS = 5 * 60 * 1000 // a session answers requests this fresh
6export const SHOW_MS = 10 * 60 * 1000 // a pane shows a standup this long
7export const PENDING_MS = 90 * 1000 // after this, a missing answer is "(no answer)"
8export const TURN_MS = 5000 // each presenter holds the floor this long
9export const KEEP_MS = 60 * 60 * 1000 // files older than this are swept
10
11export const STANDUP_PROMPT = `Standup time. Report on the work in this conversation so far, for a teammate glancing at a dashboard.
12Answer with exactly three lines and nothing else:
13Done: <what you finished, at most 12 words>
14Next: <what you are doing or will do next, at most 12 words>
15Blocked: <what you need from the user, or "nothing">`
16
17const LINE = 100
18
19export type Report = { done: string; next: string; blocked: string }
20
21/** Reads the model's three lines; tolerates bullets, bold, odd casing and missing lines. */
22export function parseReport(text: string): Report {
23  const out: Report = { done: '', next: '', blocked: '' }
24  const loose: string[] = []
25  for (const raw of text.split('\n')) {
26    const line = raw.replace(/^[\s>*\-•\d.)]+/, '').replace(/\*\*|__|`/g, '').trim()
27    const m = /^(done|next|blocked|blockers?)\s*[:\-–]\s*(.*)$/i.exec(line)
28    if (m) {
29      const key = m[1]!.toLowerCase().startsWith('block') ? 'blocked' : (m[1]!.toLowerCase() as 'done' | 'next')
30      if (!out[key]) out[key] = truncate(clean(m[2]!), LINE)
31    } else if (line) loose.push(line)
32  }
33  if (!out.done && !out.next && loose.length) out.done = truncate(clean(loose.join(' ')), LINE)
34  if (/^(nothing|none|no|n\/a|-)\.?$/i.test(out.blocked)) out.blocked = ''
35  return out
36}
37
38const ID = /^\d{13}-[0-9a-z]{6}$/
39const isStr = (v: unknown, max: number): v is string => typeof v === 'string' && v.length <= max
40
41export function makeRequest(id: string, by: string, now: number): StandupRequest {
42  return { v: 1, id, by, requestedAt: now }
43}
44
45function json(text: string): Record<string, unknown> | null {
46  try {
47    const v: unknown = JSON.parse(text)
48    return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
49  } catch {
50    return null
51  }
52}
53
54export function parseRequest(text: string): StandupRequest | null {
55  const r = json(text)
56  return r && r.v === 1 && isStr(r.id, 20) && ID.test(r.id) && isStr(r.by, 100) && typeof r.requestedAt === 'number'
57    ? (r as StandupRequest)
58    : null
59}
60
61export function parseAnswer(text: string): StandupAnswer | null {
62  const r = json(text)
63  const ok =
64    r && r.v === 1 && isStr(r.id, 100) && r.id.length > 0 && isStr(r.name, 100) && isStr(r.character, 50) &&
65    isStr(r.done, LINE + 5) && isStr(r.next, LINE + 5) && isStr(r.blocked, LINE + 5) && typeof r.at === 'number'
66  return ok ? (r as StandupAnswer) : null
67}
68
69/** The newest request file name (`<id>.json`) still inside `windowMs`, by id time. */
70export function latestRequest(names: string[], now: number, windowMs: number): string | null {
71  const ids = names.filter(n => n.endsWith('.json') && ID.test(n.slice(0, -5))).map(n => n.slice(0, -5)).sort()
72  const id = ids.at(-1)
73  return id && now - Number(id.slice(0, 13)) <= windowMs ? id : null
74}
75
76/** Who presents now: answers take TURN_MS turns in answer order, then wrap. */
77export function presenter(answers: StandupAnswer[], startedAt: number, now: number): string | null {
78  if (!answers.length) return null
79  const order = [...answers].sort((a, b) => a.at - b.at || a.id.localeCompare(b.id))
80  const turn = Math.floor(Math.max(0, now - startedAt) / TURN_MS) % order.length
81  return order[turn]!.id
82}
83
84/**
85 * The conversation as plain text for a standup asked without a fork (a resumed
86 * session has nothing to fork until its next turn): the newest messages that fit.
87 */
88export function digest(messages: { role: string; text: string; toolUses?: { tool: string }[] }[], max = 8000): string {
89  const lines: string[] = []
90  let size = 0
91  for (const m of [...messages].reverse()) {
92    const tools = m.toolUses?.length ? ` [tools: ${[...new Set(m.toolUses.map(t => t.tool))].join(', ')}]` : ''
93    const line = `${m.role}: ${truncate(clean(m.text), 600)}${tools}`
94    if (size + line.length > max) break
95    lines.unshift(line)
96    size += line.length + 1
97  }
98  return lines.join('\n')
99}
100
101/** What a presenter's bubble says. */
102export function headline(a: StandupAnswer): string {
103  if (a.blocked) return `Blocked: ${a.blocked}`
104  return a.done ? `Done: ${a.done}` : a.next ? `Next: ${a.next}` : 'Nothing to report.'
105}
106
hooks/core/text.ts 79 lines
1// Text shown in bubbles and dialogue: never trusted, always cleaned.
2
3const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*(\x07|\x1b\\)/g
4const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g
5
6export function clean(text: string): string {
7  return text.replace(ANSI, '').replace(CONTROL, ' ').replace(/\s+/g, ' ').trim()
8}
9
10export function truncate(text: string, max: number): string {
11  const chars = [...text]
12  return chars.length <= max ? text : chars.slice(0, Math.max(0, max - 1)).join('') + '…'
13}
14
15const SECRETS: [RegExp, string][] = [
16  [/\b([A-Z0-9_]*(KEY|TOKEN|SECRET|PASSWORD|PASS)[A-Z0-9_]*)=\S+/gi, '$1=***'],
17  [/\b(Bearer|Basic)\s+\S+/gi, '$1 ***'],
18  [/(--?(password|token|secret|api-key)[= ])\S+/gi, '$1***'],
19  [/\b(sk|pk|ghp|gho|xox[abp])[-_][A-Za-z0-9_-]{8,}/g, '***'],
20]
21
22export function redact(text: string): string {
23  return SECRETS.reduce((s, [re, to]) => s.replace(re, to), text)
24}
25
26/** The first sentence of a model reply, markdown stripped, for a bubble. */
27export function firstSentence(text: string): string {
28  const plain = text
29    .replace(/```[\s\S]*?```/g, ' ')
30    .replace(/[#*_`>|]/g, '')
31    .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
32  const line = plain.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''
33  const match = /^(.+?[.!?])(\s|$)/.exec(line)
34  return clean(match?.[1] ?? line)
35}
36
37/** A desk title from a prompt: its first sentence, or '' for a command or a reply too short to name the work ("yes", "go on"). */
38export function taskOf(prompt: string): string {
39  const body = prompt.replace(/^\s*\[[^\]\n]*\]\s*\n/, '') // Pixel Office's "[Sent to you…]" label
40  if (/^\s*[/<]/.test(body)) return ''
41  const s = redact(firstSentence(body))
42  // ponytail: three words = "names the work"; a real summary needs a model call
43  return s.split(' ').length >= 3 ? truncate(s, 80) : ''
44}
45
46/** A bubble: cleaned, redacted, cut to fit. */
47export function bubble(text: string, max = 60): string {
48  return truncate(redact(clean(text)), max)
49}
50
51export function basename(path: string): string {
52  return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
53}
54
55/** Wrap to lines of at most `width` chars, breaking long words. */
56export function wrap(text: string, width: number): string[] {
57  const lines: string[] = []
58  let line = ''
59  for (const word of text.split(' ').filter(Boolean)) {
60    let w = word
61    // a word longer than a line fills what is left of this one, then hard-wraps
62    while ([...w].length > width) {
63      const room = line ? width - [...line].length - 1 : width
64      if (room <= 0) {
65        lines.push(line), (line = '')
66        continue
67      }
68      lines.push(line ? `${line} ${[...w].slice(0, room).join('')}` : [...w].slice(0, room).join(''))
69      line = ''
70      w = [...w].slice(room).join('')
71    }
72    if (!line) line = w
73    else if ([...line].length + 1 + [...w].length <= width) line += ' ' + w
74    else lines.push(line), (line = w)
75  }
76  if (line) lines.push(line)
77  return lines
78}
79
hooks/core/rooms.ts 62 lines
1// Rooms: every session in one repository (worktrees included) shares an office room.
2import type { SpawnTicket } from '../../types'
3import type { Seat } from './roster'
4import { basename } from './text'
5
6/** The repository name in a remote URL: git@host:acme/web-app.git → web-app. */
7export function remoteName(remote: string | null | undefined): string {
8  if (!remote) return ''
9  const last = remote.replace(/[?#].*$/, '').replace(/\/+$/, '').split(/[/:]/).at(-1) ?? ''
10  return last.replace(/\.git$/, '')
11}
12
13/** A session's room: its repository root (a worktree's main tree), else its folder. */
14export function roomOf(cwd: string, repo: { root: string; remote: string | null } | null): { room: string; roomName: string } {
15  const room = repo?.root || cwd
16  return { room, roomName: remoteName(repo?.remote) || basename(room) || 'office' }
17}
18
19export type Room = { id: string; name: string; seats: Seat[]; waiting: number }
20
21/** Seats grouped by room, rooms ordered by who needs you, then by name. */
22export function groupRooms(seats: Seat[]): Room[] {
23  const byRoom = new Map<string, Room>()
24  for (const s of seats) {
25    const id = s.room || s.cwd || 'office'
26    let r = byRoom.get(id)
27    if (!r) byRoom.set(id, (r = { id, name: s.roomName || basename(id) || 'office', seats: [], waiting: 0 }))
28    r.seats.push(s)
29    if (s.state === 'needs-you' && !s.isAway) r.waiting++
30  }
31  return [...byRoom.values()].sort((a, b) => b.waiting - a.waiting || a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
32}
33
34/** `base`, or `base 2`, `base 3`… so two sessions in one room never share a name. */
35export function uniqueName(base: string, taken: string[]): string {
36  const used = new Set(taken.map(n => n.toLowerCase()))
37  if (!used.has(base.toLowerCase())) return base
38  for (let i = 2; ; i++) if (!used.has(`${base} ${i}`.toLowerCase())) return `${base} ${i}`
39}
40
41// ── spawn tickets ─────────────────────────────────────────────────
42export const TICKET_MS = 3 * 60 * 1000
43const TICKET_ID = /^\d{13}-[0-9a-z]{6}$/
44
45export function parseTicket(text: string): SpawnTicket | null {
46  try {
47    const t = JSON.parse(text) as Record<string, unknown>
48    const ok =
49      t && t.v === 1 && typeof t.id === 'string' && TICKET_ID.test(t.id) && typeof t.room === 'string' && t.room.startsWith('/') &&
50      typeof t.name === 'string' && t.name.length <= 40 && typeof t.task === 'string' && t.task.length <= 4000 &&
51      typeof t.character === 'string' && t.character.length <= 20 && typeof t.createdAt === 'number'
52    return ok ? (t as SpawnTicket) : null
53  } catch {
54    return null
55  }
56}
57
58/** Fresh tickets for this room, oldest first: the order a new session tries to claim them in. */
59export function ticketsFor(tickets: SpawnTicket[], room: string, now: number): SpawnTicket[] {
60  return tickets.filter(t => t.room === room && now - t.createdAt >= 0 && now - t.createdAt < TICKET_MS).sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
61}
62
hooks/core/signals.ts 95 lines
1// What a tool call looks like in the office: an activity, a bubble a person can read at a
2// glance ("Running tests", "Editing auth.ts"), and the raw detail for whoever wants it.
3import type { AgentState } from '../../types'
4import { basename, bubble } from './text'
5
6export type Activity = { state: AgentState; bubble: string; detail: string }
7
8type Input = Record<string, unknown>
9const str = (v: unknown): string => (typeof v === 'string' ? v : '')
10
11/** The step of a shell line that says what it is for: past `cd x &&`, env assignments and `sudo`. */
12function mainStep(command: string): string {
13  const steps = command.split(/&&|\|\||;|\n/).map(s => s.trim()).filter(Boolean)
14  const step = steps.find(s => !/^(cd|export|set|source|\.)\s/.test(s) && !/^[A-Z_][A-Z0-9_]*=\S*$/.test(s)) ?? steps[0] ?? ''
15  return step.replace(/^(\s*[A-Z_][A-Z0-9_]*=\S*\s+)+/, '').replace(/^(sudo|time|npx|bunx|exec)\s+/, '')
16}
17
18// order matters: installing a test or build tool is still installing
19const RULES: [RegExp, string][] = [
20  [/\b(npm|pnpm|yarn|bun)\s+(i|install|add|ci)\b|pip3? install|brew install|cargo add|go get|poetry add|uv (add|pip)/, 'Installing packages'],
21  [/\b(test|jest|vitest|pytest|mocha|rspec|phpunit|ava|tap)\b|go test|cargo test|plugin test|--test\b/, 'Running tests'],
22  [/\b(tsc|eslint|ruff|mypy|pyright|lint|prettier|biome|typecheck|clippy|check\.sh|validate)\b/, 'Checking types and lint'],
23  [/\b(build|esbuild|webpack|rollup|vite build|tsup|make|gradle|mvn|xcodebuild)\b|cargo build|go build|vsce package/, 'Building'],
24  [/^git (commit|cherry-pick|rebase|merge)/, 'Committing'],
25  [/^git push/, 'Pushing'],
26  [/^git (pull|fetch|clone)/, 'Pulling code'],
27  [/^git (diff|status|log|show|blame)/, 'Reviewing changes'],
28  [/^git /, 'Using git'],
29  [/^gh pr\b/, 'Working on a pull request'],
30  [/^gh /, 'Using GitHub'],
31  [/\b(dev|start|serve)\b|runserver|flask run|uvicorn|rails s/, 'Starting the app'],
32  [/^(curl|wget|http|xh)\b/, 'Calling a URL'],
33  [/^(docker|docker-compose|kubectl|helm|terraform)\b/, 'Working on infrastructure'],
34  [/^(ls|cat|head|tail|find|grep|rg|ag|sed -n|wc|tree|pwd|stat|file|du|less|awk)\b/, 'Looking through the code'],
35  [/^(mkdir|rm|mv|cp|touch|chmod|ln|tar|unzip|zip)\b/, 'Moving files around'],
36  [/^(node|python3?|ruby|deno|bun|go run|cargo run|java|php|sh|bash|zsh)\b/, 'Running a script'],
37  [/^(ps|kill|pkill|lsof|top)\b/, 'Checking processes'],
38]
39
40/** "npm test -- login.spec.ts" → "Running tests"; anything unknown is "Running a command". */
41export function describeCommand(command: string): string {
42  const step = mainStep(command)
43  return RULES.find(([re]) => re.test(step))?.[1] ?? 'Running a command'
44}
45
46export function activityFor(tool: string, input: Input = {}): Activity {
47  const file = basename(str(input.file_path) || str(input.notebook_path) || str(input.path))
48  const act = (state: AgentState, text: string, detail = ''): Activity => ({ state, bubble: bubble(text), detail: bubble(detail, 160) })
49  switch (tool) {
50    case 'Bash':
51      return act('typing', describeCommand(str(input.command)), `$ ${str(input.command)}`)
52    case 'Read':
53      return act('reading', file ? `Reading ${file}` : 'Reading', str(input.file_path))
54    case 'Grep':
55      return act('reading', str(input.pattern) ? `Searching for ${str(input.pattern)}` : 'Searching the code', str(input.path))
56    case 'Glob':
57    case 'LS':
58      return act('reading', 'Finding files', str(input.pattern) || str(input.path))
59    case 'Edit':
60    case 'MultiEdit':
61    case 'NotebookEdit':
62      return act('writing', file ? `Editing ${file}` : 'Editing', str(input.file_path) || str(input.notebook_path))
63    case 'Write':
64      return act('writing', file ? `Writing ${file}` : 'Writing a file', str(input.file_path))
65    case 'TodoWrite':
66      return act('writing', 'Updating the plan')
67    case 'WebSearch':
68      return act('browsing', `Searching the web: ${str(input.query)}`, str(input.query))
69    case 'WebFetch':
70      return act('browsing', `Reading ${hostOf(str(input.url))}`, str(input.url))
71    case 'Agent':
72    case 'Task':
73      return act('delegating', `Delegating: ${str(input.description) || 'a subtask'}`, str(input.prompt))
74    case 'AskUserQuestion':
75      return act('needs-you', firstQuestion(input) || 'I have a question')
76  }
77  const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
78  if (mcp) return act('working', `Using ${mcp[1]}: ${mcp[2]!.replace(/_/g, ' ')}`)
79  return act('working', `Using ${tool}`)
80}
81
82function hostOf(url: string): string {
83  try {
84    return new URL(url).host
85  } catch {
86    return url
87  }
88}
89
90function firstQuestion(input: Input): string {
91  const qs = input.questions
92  if (Array.isArray(qs) && qs[0] && typeof qs[0] === 'object') return str((qs[0] as Input).question)
93  return ''
94}
95