A voxel office in your browser where the main agent and its subagents work, hand off tasks and take way too many coffee breaks.

Beta. A Claude Code mod (installed like a plugin) that shows your session as a voxel office in the browser: the main agent and every subagent walk to the bookshelf, the server rack or the whiteboard as they use tools, hand tasks and results to each other in envelopes — and spend way too long at the coffee machine.
▶️ Watch it with sound (the sound effects are half the fun)
| In the session | In the office |
|---|---|
Read, Grep, Glob… | 📚 walks to the bookshelf |
Edit, Write | ⌨️ types at the desk |
Bash, PowerShell | 🖥️ operates the server rack |
WebSearch, WebFetch | 🌐 spins the globe |
| MCP tools | 📞 picks up the red phone |
TodoWrite, plan mode, long thinking | 📝 writes on the whiteboard |
| A subagent is spawned | it walks in through the door and receives a sealed envelope |
| A subagent finishes | it delivers a green (success) or crumpled (failure) envelope |
| Nothing happens for a while | everyone naps and the lights dim |
Time at the coffee machine is blocked time:
The page speaks Português (Brasil) and English (US) — switch with the flags in the top-right corner.
When Claude Code would ask permission for a tool call, the agent raises its hand in the office and its panel shows Allow / Deny. Answer there, or wait 60 s and the usual terminal dialog appears. With no office page open, nothing changes: the terminal asks right away. Questions (AskUserQuestion) and plan approval stay in the terminal — Claude Code only lets mods tighten those.
Click an agent and use its panel the way you would use the terminal:
Only the page opened by /office can answer: the link carries a per-session secret key (removed from the address bar on load). The server also refuses requests from other web sites, foreign host names (DNS rebinding) and browsers on the routes reserved for the mod.
Requirements: Claude Code 2.1.29x or newer (the mod uses the early-access function hooks API: a hooks.json module that runs inside Claude Code) and Node.js 20+ on your PATH (the office server runs on Node). Nothing else: no npm install needed.
claude plugin marketplace add natansalvadorligabo/agents-at-work
claude plugin install agents-at-work@agents-at-work
Start a new session and run /office: the office opens in your browser. The mod starts a small server on 127.0.0.1:47821 that only listens on your machine.
To try a local clone instead: claude --plugin-dir /path/to/agents-at-work.
npm install
npm test # web + server tests (node --test) and hook tests (claude plugin test)
npm run check # prettier + typecheck + tests
npm start # run the office server alone
npm run demo:coffee # fake a 2-minute session against the running server (open the printed URL)
npm run demo:permission # raise a fake permission request and wait for Allow / Deny
npm run demo:showcase # everything at once: hires, every station, coffee, a raised hand, a desk punch
| Path | What lives there |
|---|---|
hooks/ | The Claude Code hooks module (TypeScript): turns session events into office events |
shared/ | The wire protocol shared by hooks, server and browser |
server/ | Node server: event intake, session store, server-sent events, static files |
web/src/ | The browser app (plain ES modules + JSDoc types, three.js r169 vendored) |
test/ | node --test suites and named fakes |
tools/ | Session simulators for demos |
All code is type-checked (tsc --checkJs, strict) and formatted with Prettier.
hooks/register.ts 452 lines1import type { EngineInterface, Hook, Register } from 'claude-code'
2import { DEFAULT_PORT, EventType, MAIN_AGENT_ID } from '../shared/protocol.js'
3import { EventQueue } from './lib/event-queue.js'
4import type { OutgoingEvent } from './lib/event-queue.js'
5import {
6 COMMAND_POLL_WAIT_MS,
7 COMMAND_RETRY_MS,
8 RunningTurns,
9 failure,
10 readCommands,
11} from './lib/office-commands.js'
12import type { CommandOutcome, OfficeCommand } from './lib/office-commands.js'
13import {
14 CONTROL_REGISTER_URL,
15 EVENTS_URL,
16 HEALTH_URL,
17 PERMISSIONS_URL,
18 SERVER_PORT_ENV,
19 browserOpenCommands,
20 commandResultUrl,
21 commandsUrl,
22 officePageUrl,
23 permissionUrl,
24} from './lib/office-config.js'
25import {
26 POLL_WAIT_MS,
27 WEB_APPROVAL_WINDOW_MS,
28 createControlKey,
29 readPollAnswer,
30 shouldAskOffice,
31 watchersOf,
32} from './lib/permission-policy.js'
33import type { FinalDecision } from './lib/permission-policy.js'
34import { ThinkingRelay } from './lib/thinking-relay.js'
35import { folderName, shorten, summarizeToolInput } from './lib/tool-summary.js'
36
37// Every function that touches `$` lives in this file: the engine follows `$` only into functions declared
38// in the hooks module itself, never across an import (see `claude plugin validate`). Pure logic is in lib/.
39
40export const OFFICE_COMMAND = 'office'
41
42const SERVER_CHECK_INTERVAL_MS = 5000
43const READY_POLL_ATTEMPTS = 20
44const READY_POLL_INTERVAL_MS = 150
45const LIVE_AGENT_STATUSES = new Set(['running', 'pending', 'waiting'])
46
47const JSON_HEADERS = { 'content-type': 'application/json' }
48const WAITING_FOR_OFFICE = `Waiting for approval in the office (web)… the terminal asks in ${WEB_APPROVAL_WINDOW_MS / 1000} s`
49
50const queue = new EventQueue(() => Date.now())
51// The office page must present this key to act on the session; it travels only in the /office link.
52const controlKey = createControlKey(crypto)
53let sessionId = ''
54let launchingServer = false
55let pollingCommands = false
56let sessionOver = false
57const runningTurns = new RunningTurns()
58
59function publish($: EngineInterface, event: OutgoingEvent): void {
60 queue.enqueue(event)
61 if (queue.beginFlush()) void flushQueue($)
62}
63
64function publishAll($: EngineInterface, events: OutgoingEvent[]): void {
65 for (const event of events) publish($, event)
66}
67
68async function flushQueue($: EngineInterface): Promise<void> {
69 try {
70 for (let batch = queue.takeBatch(); batch; batch = queue.takeBatch()) await postEventsQuietly($, batch)
71 } finally {
72 queue.endFlush()
73 }
74}
75
76// Re-announces subagents that were already running when the plugin (re)loaded, so the office is not empty.
77async function publishRunningAgents($: EngineInterface): Promise<void> {
78 for (const agent of await $.agent.list()) {
79 if (!LIVE_AGENT_STATUSES.has(agent.status)) continue
80 publish($, {
81 type: EventType.AGENT_SPAWNED,
82 agentId: agent.id,
83 parentId: agent.parentId ?? MAIN_AGENT_ID,
84 agentType: agent.type,
85 description: agent.description,
86 name: agent.name ?? null,
87 restored: true,
88 })
89 }
90}
91
92// With no server up the events are dropped: the browser rebuilds the scene from the next snapshot.
93async function postEventsQuietly($: EngineInterface, batch: OutgoingEvent[]): Promise<void> {
94 try {
95 const body = JSON.stringify(batch)
96 await $.http.fetch(EVENTS_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body })
97 } catch {
98 // Dropped on purpose, see above.
99 }
100}
101
102async function isOfficeServerHealthy($: EngineInterface): Promise<boolean> {
103 try {
104 const response = await $.http.fetch(HEALTH_URL)
105 return response.ok
106 } catch {
107 return false
108 }
109}
110
111// Spawns the Node server that serves the office page unless one already answers; resolves when it exits.
112async function ensureOfficeServer($: EngineInterface): Promise<void> {
113 if (launchingServer || (await isOfficeServerHealthy($))) return
114 launchingServer = true
115 try {
116 const argv = ['node', `${$.plugin.root}/server/main.js`]
117 const child = $.process.spawn({ argv, env: { [SERVER_PORT_ENV]: String(DEFAULT_PORT) } })
118 for await (const output of child) $.ui.log(output.text, { to: 'debug' })
119 } catch (error) {
120 $.ui.log(`agents-at-work server did not start: ${String(error)}`, { to: 'debug' })
121 } finally {
122 launchingServer = false
123 }
124}
125
126// Waits briefly for a fresh server so the browser opens on a live page rather than an error.
127async function waitForOfficeServer($: EngineInterface): Promise<void> {
128 if (await isOfficeServerHealthy($)) return
129 void ensureOfficeServer($)
130 for (let attempt = 0; attempt < READY_POLL_ATTEMPTS; attempt++) {
131 await $.clock.sleep(READY_POLL_INTERVAL_MS)
132 if (await isOfficeServerHealthy($)) return
133 }
134}
135
136async function runsSuccessfully($: EngineInterface, argv: string[]): Promise<boolean> {
137 try {
138 const { exitCode, stderr } = await $.process.run(argv)
139 if (exitCode !== 0) $.ui.log(`agents-at-work: ${argv[0]} exited ${exitCode}: ${stderr}`, { to: 'debug' })
140 return exitCode === 0
141 } catch (error) {
142 $.ui.log(`agents-at-work: ${argv[0]} did not run: ${String(error)}`, { to: 'debug' })
143 return false
144 }
145}
146
147async function openInBrowser($: EngineInterface, url: string): Promise<boolean> {
148 for (const argv of browserOpenCommands(url)) {
149 if (await runsSuccessfully($, argv)) return true
150 }
151 return false
152}
153
154// Re-sent before every use: the server keeps keys in memory and may have restarted since.
155async function registerControlKey($: EngineInterface): Promise<void> {
156 try {
157 const body = JSON.stringify({ sessionId, key: controlKey })
158 await $.http.fetch(CONTROL_REGISTER_URL, { method: 'POST', headers: JSON_HEADERS, body })
159 } catch {
160 // No server: nobody can answer on the web anyway.
161 }
162}
163
164async function openOffice($: EngineInterface): Promise<{ text: string }> {
165 await waitForOfficeServer($)
166 // The session.start of a new session goes out while the server is still starting, and is dropped.
167 publish($, { type: EventType.SESSION_START })
168 await registerControlKey($)
169 const url = officePageUrl(sessionId, controlKey)
170 const opened = await openInBrowser($, url)
171 return { text: opened ? `Office opened at ${url}` : `Open ${url} in your browser` }
172}
173
174type PermissionQuestion = {
175 requestId: string
176 agentId: string
177 tool: string
178 summary: string
179 reason: string
180}
181
182// Raises the agent's hand on the office page; answers how many pages are open to see it.
183async function openPermission($: EngineInterface, question: PermissionQuestion): Promise<number> {
184 try {
185 const body = JSON.stringify({ sessionId, ...question })
186 const response = await $.http.fetch(PERMISSIONS_URL, { method: 'POST', headers: JSON_HEADERS, body })
187 return response.ok ? watchersOf(response.text) : 0
188 } catch {
189 return 0
190 }
191}
192
193async function pollPermission(
194 $: EngineInterface,
195 requestId: string,
196): Promise<FinalDecision | 'wait' | 'give-up'> {
197 try {
198 const response = await $.http.fetch(permissionUrl(requestId, POLL_WAIT_MS))
199 return response.ok ? readPollAnswer(response.text) : 'give-up'
200 } catch {
201 return 'give-up'
202 }
203}
204
205async function withdrawPermission($: EngineInterface, requestId: string): Promise<void> {
206 try {
207 await $.http.fetch(permissionUrl(requestId), { method: 'DELETE' })
208 } catch {
209 // The server is gone, and the request with it.
210 }
211}
212
213// Long-polls instead of sleeping: the hook's 10 s budget pauses during `$.http.fetch`, not `$.clock.sleep`.
214async function waitForOfficeDecision($: EngineInterface, requestId: string): Promise<FinalDecision | null> {
215 const deadline = Date.now() + WEB_APPROVAL_WINDOW_MS
216 while (Date.now() < deadline) {
217 const answer = await pollPermission($, requestId)
218 if (answer === 'give-up') return null
219 if (answer !== 'wait') return answer
220 }
221 return null
222}
223
224// Null means nobody answered on the web: the engine's own dialog then asks in the terminal.
225async function askOffice($: EngineInterface, question: PermissionQuestion): Promise<FinalDecision | null> {
226 await registerControlKey($)
227 if ((await openPermission($, question)) === 0) {
228 await withdrawPermission($, question.requestId)
229 return null
230 }
231 $.ui.status(WAITING_FOR_OFFICE)
232 try {
233 return await waitForOfficeDecision($, question.requestId)
234 } finally {
235 $.ui.status(undefined)
236 await withdrawPermission($, question.requestId)
237 }
238}
239
240// The office page drives the session through these, the same calls the terminal makes: Claude Code is
241// only the engine.
242
243async function runCommand($: EngineInterface, command: OfficeCommand): Promise<CommandOutcome> {
244 switch (command.kind) {
245 case 'prompt': {
246 const entered = await $.prompt.submit({ text: command.text, asUser: true })
247 return 'drop' in entered && entered.drop !== undefined ? failure(entered.drop) : { ok: true }
248 }
249 case 'message': {
250 const sent = await $.session.send({ to: { agentId: command.agentId }, text: command.text })
251 return sent.isDelivered ? { ok: true } : failure(sent.reason)
252 }
253 case 'stop': {
254 const turnId = runningTurns.of(command.agentId)
255 if (turnId === undefined) return failure(`No running turn for agent ${command.agentId}`)
256 await $.turn.abort({ turnId })
257 return { ok: true }
258 }
259 case 'spawn':
260 return spawnFromOffice($, command)
261 }
262}
263
264// `$.agent.spawn` skips this plugin's own `agent.spawn` hook, so the office hears about the hire here.
265async function spawnFromOffice(
266 $: EngineInterface,
267 command: Extract<OfficeCommand, { kind: 'spawn' }>,
268): Promise<CommandOutcome> {
269 const { text: prompt, description, subagentType } = command
270 const started = await $.agent.spawn({ prompt, description, subagentType })
271 if ('deny' in started && started.deny !== undefined) return failure(started.deny)
272 if (started.agentId === undefined) return { ok: true }
273 publish($, {
274 type: EventType.AGENT_SPAWNED,
275 agentId: started.agentId,
276 parentId: MAIN_AGENT_ID,
277 agentType: subagentType,
278 description,
279 name: null,
280 prompt,
281 background: true,
282 })
283 return { ok: true }
284}
285
286async function answerCommand($: EngineInterface, command: OfficeCommand): Promise<void> {
287 let outcome: CommandOutcome
288 try {
289 outcome = await runCommand($, command)
290 } catch (error) {
291 outcome = failure(error)
292 }
293 try {
294 const body = JSON.stringify(outcome)
295 await $.http.fetch(commandResultUrl(command.id), { method: 'POST', headers: JSON_HEADERS, body })
296 } catch {
297 // The page gives up waiting and says the session did not answer.
298 }
299}
300
301async function takeCommands($: EngineInterface): Promise<OfficeCommand[] | null> {
302 try {
303 const response = await $.http.fetch(commandsUrl(sessionId, COMMAND_POLL_WAIT_MS))
304 return response.ok ? readCommands(response.text) : null
305 } catch {
306 return null
307 }
308}
309
310// Long-polls the office server for the page's commands while the session lives; each runs on its own,
311// so a prompt waiting for the session to go idle does not hold up a stop.
312async function pollCommands($: EngineInterface): Promise<void> {
313 if (pollingCommands) return
314 pollingCommands = true
315 try {
316 while (!sessionOver) {
317 const commands = await takeCommands($)
318 if (commands === null) await $.clock.sleep(COMMAND_RETRY_MS)
319 for (const command of commands ?? []) void answerCommand($, command)
320 }
321 } catch (error) {
322 $.ui.log(`agents-at-work stopped taking office commands: ${String(error)}`, { to: 'debug' })
323 } finally {
324 pollingCommands = false
325 }
326}
327
328const onToolCheck: Hook<'tool.check'> = async ($, e, next) => {
329 const verdict = await next(e)
330 if (!shouldAskOffice(verdict.decision, e.tool, e.tool_use_id)) return verdict
331 const question = {
332 requestId: e.tool_use_id ?? '',
333 agentId: e.agentId ?? MAIN_AGENT_ID,
334 tool: e.tool,
335 summary: summarizeToolInput((e.input ?? {}) as Record<string, unknown>),
336 reason: verdict.reason ?? '',
337 }
338 const decision = await askOffice($, question)
339 if (decision === null) return verdict
340 const reason = decision === 'allow' ? 'Allowed in the office (web)' : 'Denied in the office (web)'
341 return { ...verdict, decision, reason }
342}
343
344const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
345 const started = await next(e)
346 sessionId = await $.session.id()
347 queue.identify(sessionId, folderName(e.cwd))
348 await $.command.register({
349 name: OFFICE_COMMAND,
350 description: "Opens this session's voxel office in the browser",
351 })
352 void ensureOfficeServer($)
353 $.clock.every(SERVER_CHECK_INTERVAL_MS, () => void ensureOfficeServer($))
354 publish($, { type: EventType.SESSION_START })
355 void publishRunningAgents($)
356 sessionOver = false
357 void pollCommands($)
358 return started
359}
360
361const onSessionEnd: Hook<'session.end'> = async ($, e, next) => {
362 sessionOver = true
363 publish($, { type: EventType.SESSION_END, reason: e.reason })
364 return next(e)
365}
366
367const onTurnStart: Hook<'turn.start'> = async ($, e, next) => {
368 runningTurns.started(undefined, e.turnId)
369 publish($, { type: EventType.TURN_START, agentId: MAIN_AGENT_ID })
370 return next(e)
371}
372
373const onTurnComplete: Hook<'turn.complete'> = async ($, e, next) => {
374 runningTurns.ended(e.agentId, e.turnId)
375 if (e.agentId === undefined) {
376 publish($, { type: EventType.TURN_END, agentId: MAIN_AGENT_ID, reason: e.reason })
377 } else {
378 const answer = shorten(e.answer)
379 publish($, {
380 type: EventType.AGENT_FINISHED,
381 agentId: e.agentId,
382 reason: e.reason,
383 answer,
384 durationMs: e.durationMs,
385 })
386 }
387 return next(e)
388}
389
390const onAgentSpawn: Hook<'agent.spawn'> = async ($, e, next) => {
391 const result = await next(e)
392 if (result.agentId === undefined) return result
393 publish($, {
394 type: EventType.AGENT_SPAWNED,
395 agentId: result.agentId,
396 parentId: e.parentAgentId ?? MAIN_AGENT_ID,
397 agentType: e.subagentType,
398 description: e.description,
399 name: e.name ?? null,
400 prompt: e.prompt,
401 background: e.background,
402 })
403 return result
404}
405
406const onToolCall: Hook<'tool.call'> = async ($, e, next) => {
407 const agentId = e.agentId ?? MAIN_AGENT_ID
408 const toolUseId = e.tool_use_id ?? `${agentId}-${Date.now()}`
409 const summary = summarizeToolInput(e as unknown as Record<string, unknown>)
410 publish($, { type: EventType.TOOL_START, agentId, toolUseId, tool: String(e.tool), summary })
411 const result = await next(e)
412 const failed = result.deny !== undefined || result.isError === true
413 publish($, { type: EventType.TOOL_END, agentId, toolUseId, failed })
414 return result
415}
416
417const onTurnStep: Hook<'turn.step'> = async function* ($, e, next) {
418 runningTurns.started(e.agentId, e.turnId)
419 const relay = new ThinkingRelay(e.agentId ?? MAIN_AGENT_ID, () => Date.now())
420 try {
421 for await (const chunk of next(e)) {
422 yield chunk
423 publishAll(
424 $,
425 relay.onChunk(chunk.kind, 'text' in chunk && typeof chunk.text === 'string' ? chunk.text : ''),
426 )
427 }
428 } finally {
429 publishAll($, relay.finish())
430 }
431}
432
433/**
434 * Plugin entry point: mirrors the session's agents, tools and thinking to the office server.
435 * @example { "modules": ["./register.ts"] } // hooks/hooks.json
436 */
437export const register: Register = on => {
438 on('session.start', onSessionStart)
439 on('command.run', { command: OFFICE_COMMAND }, async $ => openOffice($)).catch(() => ({
440 text: `Open ${officePageUrl(sessionId, controlKey)} in your browser`,
441 }))
442 on('session.end', onSessionEnd)
443 on('turn.start', onTurnStart)
444 on('turn.complete', onTurnComplete)
445 // Observers only: if publishing throws, the spawn / tool call proceeds untouched.
446 on('agent.spawn', onAgentSpawn).catch(($, e, next) => next(e))
447 on('tool.call', onToolCall).catch(($, e, next) => next(e))
448 // On failure, fall back to the engine's own verdict (its dialog), never to an allow.
449 on('tool.check', onToolCheck).catch(($, e, next) => next(e))
450 on('turn.step', onTurnStep)
451}
452shared/protocol.js 206 lines1// Wire protocol shared by the hooks module, the local server and the browser.
2// Plain JS + JSDoc so all three runtimes (engine sandbox, Node, browser) load it without a build step.
3
4/** Port the hooks module and the server agree on. */
5export const DEFAULT_PORT = 47821
6
7/** Id the hooks module uses for the session's own (non-subagent) agent. */
8export const MAIN_AGENT_ID = 'main'
9
10/** Agent type reported for the main agent; subagents carry their `subagent_type`. */
11export const MAIN_AGENT_TYPE = 'main'
12
13export const EventType = Object.freeze({
14 SESSION_START: 'session.start',
15 SESSION_END: 'session.end',
16 TURN_START: 'turn.start',
17 TURN_END: 'turn.end',
18 AGENT_SPAWNED: 'agent.spawned',
19 AGENT_FINISHED: 'agent.finished',
20 TOOL_START: 'tool.start',
21 TOOL_END: 'tool.end',
22 THINKING_START: 'thinking.start',
23 THINKING_DELTA: 'thinking.delta',
24 THINKING_END: 'thinking.end',
25 PERMISSION_REQUESTED: 'permission.requested',
26 PERMISSION_RESOLVED: 'permission.resolved',
27})
28
29/** Event types the server raises itself; hooks may not post them to /events. */
30export const SERVER_EVENT_TYPES = Object.freeze([
31 EventType.PERMISSION_REQUESTED,
32 EventType.PERMISSION_RESOLVED,
33])
34
35/**
36 * What the office can answer to a permission request; `expired` means nobody answered on the web and the
37 * terminal dialog takes over.
38 */
39export const PermissionDecision = Object.freeze({
40 ALLOW: 'allow',
41 DENY: 'deny',
42 PENDING: 'pending',
43 EXPIRED: 'expired',
44})
45
46/**
47 * What the office page can ask the session to do, as the terminal would: prompt the main agent, message a
48 * subagent (resuming it when it already finished), stop an agent's running turn, or spawn a subagent.
49 */
50export const CommandKind = Object.freeze({
51 PROMPT: 'prompt',
52 MESSAGE: 'message',
53 STOP: 'stop',
54 SPAWN: 'spawn',
55})
56
57/** Header carrying the session's control key on requests that act on the session. */
58export const CONTROL_KEY_HEADER = 'x-agents-at-work-key'
59
60/** URL query parameter the /office link uses to hand the control key to the page. */
61export const CONTROL_KEY_PARAM = 'key'
62
63export const AgentStatus = Object.freeze({
64 WORKING: 'working',
65 WAITING: 'waiting',
66 DONE: 'done',
67 FAILED: 'failed',
68})
69
70/** The reason `turn.complete` reports when an agent answered normally. */
71export const ANSWER_REASON = 'answer'
72
73/** HTTP routes served by the local server. */
74export const Route = Object.freeze({
75 EVENTS: '/events',
76 STREAM: '/stream',
77 HEALTH: '/health',
78 CONTROL_REGISTER: '/control/register',
79 PERMISSIONS: '/permissions',
80 COMMANDS: '/commands',
81})
82
83/** Server-sent event names on the `/stream` route. */
84export const StreamMessage = Object.freeze({
85 SNAPSHOT: 'snapshot',
86 UPDATE: 'update',
87})
88
89/**
90 * @typedef {(typeof EventType)[keyof typeof EventType]} EventTypeName
91 * @typedef {(typeof AgentStatus)[keyof typeof AgentStatus]} AgentStatusName
92 * @typedef {(typeof PermissionDecision)[keyof typeof PermissionDecision]} PermissionDecisionName
93 * @typedef {(typeof CommandKind)[keyof typeof CommandKind]} CommandKindName
94 */
95
96/**
97 * A command from the office page, as the hooks module receives it. `text` is the prompt or message (the
98 * spawned subagent's task for `spawn`); `description` and `subagentType` only travel with `spawn`.
99 * @typedef {object} OfficeCommand
100 * @property {string} id
101 * @property {CommandKindName} kind
102 * @property {string} agentId
103 * @property {string} [text]
104 * @property {string} [description]
105 * @property {string} [subagentType]
106 */
107
108/**
109 * How the session took a command: `ok`, or the reason it could not.
110 * @typedef {{ ok: true } | { ok: false, error: string }} CommandOutcome
111 */
112
113/**
114 * A tool call waiting for someone to allow or deny it.
115 * @typedef {object} PermissionRequest
116 * @property {string} id
117 * @property {string} tool
118 * @property {string} summary
119 * @property {string} reason Why the engine asks, as its verdict said.
120 * @property {number} requestedAt Epoch milliseconds.
121 */
122
123/**
124 * One event as posted by the hooks module. Fields beyond `type` depend on the type.
125 * @typedef {object} OfficeEvent
126 * @property {EventTypeName} type
127 * @property {string} sessionId
128 * @property {string} project
129 * @property {number} timestamp Epoch milliseconds.
130 * @property {string} [agentId]
131 * @property {string | null} [parentId]
132 * @property {string} [agentType]
133 * @property {string} [description]
134 * @property {string | null} [name]
135 * @property {string} [prompt]
136 * @property {boolean} [background]
137 * @property {boolean} [restored] True when re-announcing an agent that was already running.
138 * @property {string} [reason]
139 * @property {string} [answer]
140 * @property {number} [durationMs]
141 * @property {string} [toolUseId]
142 * @property {string} [tool]
143 * @property {string} [summary]
144 * @property {boolean} [failed]
145 * @property {string} [text]
146 * @property {string} [requestId]
147 * @property {PermissionDecisionName} [decision]
148 */
149
150/**
151 * @typedef {object} ToolRecord
152 * @property {string} id
153 * @property {string} tool
154 * @property {string} summary
155 * @property {number} startedAt Epoch milliseconds.
156 * @property {number | null} endedAt
157 * @property {boolean} failed
158 */
159
160/**
161 * @typedef {object} AgentSnapshot
162 * @property {string} id
163 * @property {string | null} parentId
164 * @property {string} agentType
165 * @property {string} description
166 * @property {string | null} name
167 * @property {string} prompt
168 * @property {boolean} background
169 * @property {AgentStatusName} status
170 * @property {Record<string, ToolRecord>} activeTools
171 * @property {boolean} thinking
172 * @property {string} thoughts
173 * @property {ToolRecord[]} history
174 * @property {string} answer
175 * @property {number} createdAt
176 * @property {PermissionRequest | null} pendingPermission
177 */
178
179/**
180 * @typedef {object} SessionInfo
181 * @property {string} id
182 * @property {string} project
183 * @property {boolean} ended
184 */
185
186/**
187 * @typedef {SessionInfo & { agents: AgentSnapshot[] }} SessionSnapshot
188 */
189
190/**
191 * @typedef {object} StreamUpdate
192 * @property {OfficeEvent} event
193 * @property {AgentSnapshot | null} agent
194 * @property {SessionInfo} session
195 */
196
197/**
198 * Tells whether an agent snapshot belongs to the session's main agent.
199 * @param {{ id: string }} agent
200 * @returns {boolean}
201 * @example isMainAgent({ id: MAIN_AGENT_ID }) // true
202 */
203export function isMainAgent(agent) {
204 return agent.id === MAIN_AGENT_ID
205}
206hooks/lib/event-queue.ts 62 lines1import type { OfficeEvent } from '../../shared/protocol.js'
2
3const MAX_BATCH_SIZE = 50
4
5/** An event before the queue stamps the session envelope on it. */
6export type OutgoingEvent = Omit<OfficeEvent, 'sessionId' | 'project' | 'timestamp'>
7
8/**
9 * Ordered queue of hook events waiting to be posted, with a single-flusher guard.
10 * It never touches `$`: the engine only lets hook code pass `$` to plain functions.
11 * @example
12 * queue.enqueue({ type: 'turn.start', agentId: 'main' })
13 * if (queue.beginFlush()) for (let b = queue.takeBatch(); b; b = queue.takeBatch()) await send(b)
14 */
15export class EventQueue {
16 #events: OfficeEvent[] = []
17 #flushing = false
18 #sessionId = ''
19 #project = ''
20 readonly #now: () => number
21
22 constructor(now: () => number) {
23 this.#now = now
24 }
25
26 /** Sets the session envelope stamped on every later event. */
27 identify(sessionId: string, project: string): void {
28 this.#sessionId = sessionId
29 this.#project = project
30 }
31
32 enqueue(event: OutgoingEvent): void {
33 this.#events.push({
34 ...event,
35 sessionId: this.#sessionId,
36 project: this.#project,
37 timestamp: this.#now(),
38 })
39 }
40
41 /** Claims the flusher role; false when another flush is already draining the queue. */
42 beginFlush(): boolean {
43 if (this.#flushing) return false
44 this.#flushing = true
45 return true
46 }
47
48 endFlush(): void {
49 this.#flushing = false
50 }
51
52 /** Removes and returns the next batch in order, or null when the queue is empty. */
53 takeBatch(): OfficeEvent[] | null {
54 if (this.#events.length === 0) return null
55 return this.#events.splice(0, MAX_BATCH_SIZE)
56 }
57
58 get pendingCount(): number {
59 return this.#events.length
60 }
61}
62hooks/lib/office-commands.ts 74 lines1import { CommandKind, MAIN_AGENT_ID } from '../../shared/protocol.js'
2
3/** How long each long-poll for the office page's commands may hang. */
4export const COMMAND_POLL_WAIT_MS = 25_000
5/** How long to wait before polling again when the office server is not answering. */
6export const COMMAND_RETRY_MS = 5_000
7
8export type OfficeCommand =
9 | { id: string; kind: 'prompt'; agentId: string; text: string }
10 | { id: string; kind: 'message'; agentId: string; text: string }
11 | { id: string; kind: 'stop'; agentId: string }
12 | { id: string; kind: 'spawn'; agentId: string; text: string; description: string; subagentType: string }
13
14export type CommandOutcome = { ok: true } | { ok: false; error: string }
15
16const KINDS: ReadonlySet<string> = new Set(Object.values(CommandKind))
17
18/**
19 * Reads the office server's answer to a commands long-poll, skipping anything malformed.
20 * @example readCommands('{"commands":[{"id":"c1","kind":"stop","agentId":"main"}]}') // [{ id: 'c1', kind: 'stop', agentId: 'main' }]
21 */
22export function readCommands(text: string): OfficeCommand[] {
23 let value: unknown
24 try {
25 value = JSON.parse(text)
26 } catch {
27 return []
28 }
29 const list = (value as { commands?: unknown } | null)?.commands
30 return Array.isArray(list) ? list.filter(isCommand) : []
31}
32
33function isCommand(value: unknown): value is OfficeCommand {
34 if (typeof value !== 'object' || value === null) return false
35 const command = value as Record<string, unknown>
36 const strings = (...fields: string[]) => fields.every(field => typeof command[field] === 'string')
37 if (!strings('id', 'kind', 'agentId') || !KINDS.has(String(command.kind))) return false
38 if (command.kind === CommandKind.STOP) return true
39 if (command.kind === CommandKind.SPAWN) return strings('text', 'description', 'subagentType')
40 return strings('text')
41}
42
43/**
44 * The model turn each agent is running, so the office can stop it by agent: `turn.start` and
45 * `turn.step` say when one runs, `turn.complete` when it is over.
46 * @example
47 * turns.started('main', 'turn_1')
48 * turns.of('main') // 'turn_1'
49 */
50export class RunningTurns {
51 readonly #turns = new Map<string, string>()
52
53 started(agentId: string | undefined, turnId: string): void {
54 this.#turns.set(agentId ?? MAIN_AGENT_ID, turnId)
55 }
56
57 ended(agentId: string | undefined, turnId: string): void {
58 const key = agentId ?? MAIN_AGENT_ID
59 if (this.#turns.get(key) === turnId) this.#turns.delete(key)
60 }
61
62 of(agentId: string): string | undefined {
63 return this.#turns.get(agentId)
64 }
65}
66
67/**
68 * Words an engine refusal for the person reading the office page.
69 * @example failure(new Error('boom')) // { ok: false, error: 'boom' }
70 */
71export function failure(error: unknown): CommandOutcome {
72 return { ok: false, error: error instanceof Error ? error.message : String(error) }
73}
74hooks/lib/office-config.ts 58 lines1import { CONTROL_KEY_PARAM, DEFAULT_PORT, Route } from '../../shared/protocol.js'
2
3export const OFFICE_URL = `http://127.0.0.1:${DEFAULT_PORT}`
4export const HEALTH_URL = `${OFFICE_URL}${Route.HEALTH}`
5export const EVENTS_URL = `${OFFICE_URL}${Route.EVENTS}`
6export const CONTROL_REGISTER_URL = `${OFFICE_URL}${Route.CONTROL_REGISTER}`
7export const PERMISSIONS_URL = `${OFFICE_URL}${Route.PERMISSIONS}`
8export const COMMANDS_URL = `${OFFICE_URL}${Route.COMMANDS}`
9export const SERVER_PORT_ENV = 'AGENTS_AT_WORK_PORT'
10
11/**
12 * Builds the address that opens the office for one session, carrying the key that lets the page act on it.
13 * @example officePageUrl('abc 1', 'k') // 'http://127.0.0.1:47821/?session=abc%201&key=k'
14 */
15export function officePageUrl(sessionId: string, controlKey: string): string {
16 const query = new URLSearchParams({ session: sessionId, [CONTROL_KEY_PARAM]: controlKey })
17 return `${OFFICE_URL}/?${query.toString().replace(/\+/g, '%20')}`
18}
19
20/**
21 * The long-poll address for one permission request.
22 * @example permissionUrl('toolu_1', 25000) // 'http://127.0.0.1:47821/permissions/toolu_1?waitMs=25000'
23 */
24export function permissionUrl(requestId: string, waitMs?: number): string {
25 const wait = waitMs === undefined ? '' : `?waitMs=${waitMs}`
26 return `${PERMISSIONS_URL}/${encodeURIComponent(requestId)}${wait}`
27}
28
29/**
30 * The long-poll address for the office page's commands to one session.
31 * @example commandsUrl('s 1', 25000) // 'http://127.0.0.1:47821/commands?session=s%201&waitMs=25000'
32 */
33export function commandsUrl(sessionId: string, waitMs: number): string {
34 const query = new URLSearchParams({ session: sessionId, waitMs: String(waitMs) })
35 return `${COMMANDS_URL}?${query.toString().replace(/\+/g, '%20')}`
36}
37
38/**
39 * Where the hooks module says how one command went.
40 * @example commandResultUrl('c1') // 'http://127.0.0.1:47821/commands/c1/result'
41 */
42export function commandResultUrl(commandId: string): string {
43 return `${COMMANDS_URL}/${encodeURIComponent(commandId)}/result`
44}
45
46/**
47 * The command lines that open a URL in the default browser on Windows, macOS and Linux, in the order to try them.
48 * @example browserOpenCommands('http://x')[0] // ['rundll32', 'url.dll,FileProtocolHandler', 'http://x']
49 */
50export function browserOpenCommands(url: string): string[][] {
51 // Not `cmd /c start`: cmd reads the `&` between query parameters as a command separator.
52 return [
53 ['rundll32', 'url.dll,FileProtocolHandler', url],
54 ['open', url],
55 ['xdg-open', url],
56 ]
57}
58hooks/lib/permission-policy.ts 71 lines1import { PermissionDecision } from '../../shared/protocol.js'
2
3/** How long a tool call may wait for an answer on the web before the terminal dialog takes over. */
4export const WEB_APPROVAL_WINDOW_MS = 60_000
5/** How long each long-poll to the office server may hang. */
6export const POLL_WAIT_MS = 25_000
7
8// The engine only lets a hook tighten these: its `allow` would not dismiss the person's dialog.
9const PERSON_ONLY_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode'])
10// A subagent hands its report back through this; only the auto-mode classifier may allow it, so an allow
11// from the office is refused and the report never arrives.
12const ENGINE_ONLY_TOOLS = new Set(['SubagentHandback'])
13
14export type FinalDecision = 'allow' | 'deny'
15
16/**
17 * Whether a permission verdict should be offered to the office page instead of the terminal dialog.
18 * Only real calls (with a tool_use_id) that the engine would ask about qualify.
19 * @example shouldAskOffice('ask', 'Bash', 'toolu_1') // true
20 */
21export function shouldAskOffice(decision: string, tool: string, toolUseId: string | undefined): boolean {
22 return (
23 decision === 'ask' &&
24 toolUseId !== undefined &&
25 !PERSON_ONLY_TOOLS.has(tool) &&
26 !ENGINE_ONLY_TOOLS.has(tool)
27 )
28}
29
30/**
31 * Reads the office server's answer to a long-poll.
32 * @returns the final decision, `wait` to poll again, or `give-up` to fall back to the terminal.
33 * @example readPollAnswer('{"decision":"allow","watchers":1}') // 'allow'
34 */
35export function readPollAnswer(text: string): FinalDecision | 'wait' | 'give-up' {
36 const answer = parseAnswer(text)
37 if (answer.decision === PermissionDecision.ALLOW || answer.decision === PermissionDecision.DENY)
38 return answer.decision
39 if (answer.decision === PermissionDecision.PENDING && answer.watchers > 0) return 'wait'
40 return 'give-up'
41}
42
43/**
44 * Reads how many office pages are open from the reply to a new permission request.
45 * @example watchersOf('{"watchers":2}') // 2
46 */
47export function watchersOf(text: string): number {
48 return parseAnswer(text).watchers
49}
50
51/**
52 * Generates a session's control key: 256 random bits as hex.
53 * @example createControlKey(crypto).length // 64
54 */
55export function createControlKey(random: {
56 getRandomValues<T extends ArrayBufferView>(array: T): T
57}): string {
58 const bytes = random.getRandomValues(new Uint8Array(32))
59 return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
60}
61
62function parseAnswer(text: string): { decision: string; watchers: number } {
63 try {
64 const value = JSON.parse(text) as { decision?: unknown; watchers?: unknown }
65 const watchers = typeof value.watchers === 'number' ? value.watchers : 0
66 return { decision: typeof value.decision === 'string' ? value.decision : '', watchers }
67 } catch {
68 return { decision: '', watchers: 0 }
69 }
70}
71hooks/lib/thinking-relay.ts 59 lines1import { EventType } from '../../shared/protocol.js'
2import type { OutgoingEvent } from './event-queue.js'
3
4const SEND_INTERVAL_MS = 300
5
6/**
7 * Turns a step's streamed thinking chunks into start / delta / end events, throttling the deltas.
8 * Returns the events instead of sending them, so hook code stays the only place that touches `$`.
9 * @example
10 * const relay = new ThinkingRelay('main', Date.now)
11 * for (const event of relay.onChunk('thinking', 'Let me check…')) publish($, event)
12 */
13export class ThinkingRelay {
14 #thinking = false
15 #pending = ''
16 #lastSentAt = 0
17 readonly #agentId: string
18 readonly #now: () => number
19
20 constructor(agentId: string, now: () => number) {
21 this.#agentId = agentId
22 this.#now = now
23 }
24
25 /** Feeds one streamed chunk; any non-thinking, non-engine chunk closes the thinking block. */
26 onChunk(kind: string, text: string): OutgoingEvent[] {
27 if (kind === 'thinking') return this.#appendThinking(text)
28 return kind === 'engine' ? [] : this.finish()
29 }
30
31 /** Flushes pending text and closes the thinking block if one is open. */
32 finish(): OutgoingEvent[] {
33 if (!this.#thinking) return []
34 this.#thinking = false
35 return [...this.#flushPending(), { type: EventType.THINKING_END, agentId: this.#agentId }]
36 }
37
38 #appendThinking(text: string): OutgoingEvent[] {
39 const events: OutgoingEvent[] = []
40 if (!this.#thinking) events.push({ type: EventType.THINKING_START, agentId: this.#agentId })
41 this.#thinking = true
42 this.#pending += text
43 if (this.#now() - this.#lastSentAt >= SEND_INTERVAL_MS) events.push(...this.#flushPending())
44 return events
45 }
46
47 #flushPending(): OutgoingEvent[] {
48 if (this.#pending === '') return []
49 const delta: OutgoingEvent = {
50 type: EventType.THINKING_DELTA,
51 agentId: this.#agentId,
52 text: this.#pending,
53 }
54 this.#pending = ''
55 this.#lastSentAt = this.#now()
56 return [delta]
57 }
58}
59hooks/lib/tool-summary.ts 53 lines1const SUMMARY_MAX_LENGTH = 60
2
3const PATH_FIELDS = ['file_path', 'notebook_path'] as const
4const TEXT_FIELDS = ['command', 'pattern', 'query', 'description', 'prompt'] as const
5
6/**
7 * Returns the last segment of a Windows or POSIX path.
8 * @example folderName('C:/projects/shop') // 'shop'
9 */
10export function folderName(path: string): string {
11 const parts = path.split(/[\\/]/).filter(Boolean)
12 return parts[parts.length - 1] ?? path
13}
14
15/**
16 * Collapses whitespace and cuts long text with an ellipsis so it fits in a speech bubble.
17 * @example shorten('npm run\nbuild') // 'npm run build'
18 */
19export function shorten(text: string): string {
20 const singleLine = text.replace(/\s+/g, ' ').trim()
21 if (singleLine.length <= SUMMARY_MAX_LENGTH) return singleLine
22 return `${singleLine.slice(0, SUMMARY_MAX_LENGTH - 1)}…`
23}
24
25/**
26 * Picks the one argument that best tells what a tool call is doing (file, host, command…).
27 * @example summarizeToolInput({ tool: 'Read', file_path: 'src/app.ts' }) // 'app.ts'
28 */
29export function summarizeToolInput(input: Record<string, unknown>): string {
30 const path = firstString(input, PATH_FIELDS)
31 if (path) return folderName(path)
32 const url = firstString(input, ['url'])
33 if (url) return hostOf(url)
34 const text = firstString(input, TEXT_FIELDS)
35 return text ? shorten(text) : ''
36}
37
38function firstString(input: Record<string, unknown>, fields: readonly string[]): string | undefined {
39 for (const field of fields) {
40 const value = input[field]
41 if (typeof value === 'string' && value !== '') return value
42 }
43 return undefined
44}
45
46function hostOf(url: string): string {
47 try {
48 return new URL(url).host
49 } catch {
50 return shorten(url)
51 }
52}
53