Codex, Cursor and a second Claude inside Claude Code: an agent room (one chat, @mentions, handoffs) plus one-shot reviews and delegation

Bring OpenAI Codex, Cursor and a second Claude into Claude Code.
@mention an agent to pick who answers. Agents can @mention each other to hand work off, and @main brings in your main Claude session./second-opinion has Codex and/or Cursor review your uncommitted changes (or the last commit). One button sends their findings to Claude./delegate has Codex or Cursor implement a task in your repo.ask_agent) or hand off a task (delegate_task) on its own./plugin install room --marketplace bbarney7/claude-code-mods
The install may say "4 userConfig options not yet set". That's fine: the defaults apply, and you can change them any time in /config (see Context settings).
PATH. The room runs a small Node bridge.| Agent | Room adapter | One-shot commands use | |
|---|---|---|---|
| Codex | npm i -g @agentclientprotocol/codex-acp, then codex login | codex CLI | |
| Cursor | `curl https://cursor.com/install -fsS \ | bash, then cursor-agent login` | cursor-agent CLI |
| Claude | npm i -g @agentclientprotocol/claude-agent-acp | — |
Every agent uses its own subscription or login. Nothing here asks for an API key.
| Command | What it does | |||
|---|---|---|---|---|
/room | Open the agent room (also /room:room, which the desktop app lists from the start) | |||
/room model [agent] [model] [effort] | Show each agent's model, or pick one (/room model codex gpt-6-luna low); /room model codex reset forgets the pick | |||
| `/ask [@codex\ | @cursor\ | @claude\ | @main] <message>` | Say something in the room (opens the pane if needed) |
| `/second-opinion [codex\ | cursor\ | both] [focus]` | Review the current changes; results in the Second opinion pane | |
| `/delegate <codex\ | cursor> <task>` | Have an outside agent implement a task | ||
/opinions | Reopen the Second opinion pane |
Tools Claude can call: ask_agent (talk to Codex, Cursor or the room's Claude, and get the reply) and delegate_task (one-shot implementation).
Claude Code ── mod (panes, routing, @mentions) ──spawn── node bridge ──ACP/stdio── codex-acp
▲ events on stdout ├──────── cursor-agent acp
└─ commands over a Unix socket ───────────────────└──────── claude-agent-acp
@mentions. With no mention it goes to whoever you addressed last. Mentions inside backticks or code fences don't count.@mentions another participant, that participant gets a turn. Chains stop at 3 hops, and a bare "thanks" ends one. A reply to whoever asked is just an answer, not a hand-off.| Main session | Codex | Cursor | Room's Claude |
|---|---|---|---|
| bypass permissions | agent-full-access; the room answers anything it still asks | agent; the room answers its questions | bypassPermissions |
| auto | agent (auto review) | agent | auto |
| accept edits | workspace-write (asks outside the repo) | agent | acceptEdits |
| default | read-only (asks before edits) | ask (only reads) | default |
| plan | read-only, told to only plan | ask | plan |
The room's header shows the level in use. Set Room: what agents may do in /config to allow (always edit) or ask (always ask first) to pin it. When an agent asks, the question appears above the message box with short buttons (Codex's "Yes, and don't ask again for commands that start with npm test" becomes Always allow npm test); your answer is noted in the room. Codex needs @agentclientprotocol/codex-acp 2.x for the gpt-6 models.
/room model lists the selected agent's models and effort levels, as the agent reports them; the pick is remembered across sessions. Without one, Codex and Claude use their own default model at medium effort.ask_agent hands back to Claude./config)How much crosses between your main session and the room:
| Setting | Values | Default |
|---|---|---|
| Room: context from the main Claude session | none · recent (last N turns) · summary (a briefing your main Claude writes, from its cached transcript) · full (capped at 60,000 characters) | recent |
Room: turns to share when seeding recent | number | 10 |
Room: context sent back to the main session (on @main or Send room to main) | message · recent · summary (Haiku) · full (capped at 30,000 characters) | message |
Room: messages to send back for recent | number | 8 |
Privacy note: in summary mode, the briefing is written by your main Claude, so it can include anything that Claude sees, including your instruction files (CLAUDE.md). Codex and Cursor also load their own configuration (~/.codex/config.toml, Cursor rules, the project's AGENTS.md).
@claude.full and recent seeds are snapshots taken when an agent's session starts. Use New chat to re-seed after a lot has changed.claude --plugin-dir plugins/room
claude plugin validate plugins/room
claude plugin test plugins/room
To test the bridge on its own: node bridge/acp-bridge.mjs --socket /tmp/x.sock --cwd <repo>, then curl --unix-socket /tmp/x.sock -X POST http://b/prompt -d '{"agent":"cursor","turn":"t1","text":"hi"}'. Events print to stdout. See the header of bridge/acp-bridge.mjs for the protocol.
hooks/register.tsx 1419 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Access, Agent, AgentOption, AgentState, Choice, External, Mode, ModelPrefs, PermissionCard, PermissionOption, RoomMessage, Run, Speaker } from '../types'
5
6const PANE = 'second-opinion'
7const ROOM_PANE = 'agent-room'
8const MAX_DIFF = 150_000
9const TAIL = 4_000
10const HOP_BUDGET = 3
11const HISTORY_ITEMS = 12
12const HISTORY_CHARS = 8_000
13
14const runs = atom({ plugin: 'room', key: 'runs' } as const, [])
15const now = atom({ plugin: 'room', key: 'now' } as const, 0)
16const room = atom({ plugin: 'room', key: 'room' } as const, {
17 messages: [],
18 status: { codex: 'idle', cursor: 'idle', claude: 'idle' },
19 detail: '',
20 permissions: [],
21 defaultAgent: 'codex',
22 access: 'ask',
23 models: {},
24 prefs: {},
25})
26
27const EXTERNAL: External[] = ['codex', 'cursor']
28const AGENTS: Agent[] = ['codex', 'cursor', 'claude']
29const ALL_IDLE: Record<Agent, AgentState> = { codex: 'idle', cursor: 'idle', claude: 'idle' }
30const LABEL: Record<Speaker, string> = { codex: 'Codex', cursor: 'Cursor', claude: 'Claude', main: 'Main session', you: 'You', system: '·' }
31const COLOR: Record<Speaker, string> = { codex: 'green', cursor: 'magenta', claude: 'yellow', main: 'blue', you: 'cyan', system: 'gray' }
32
33// ---------- how much context crosses between Claude's session and the room (/config) ----------
34
35export type SeedMode = 'none' | 'recent' | 'summary' | 'full'
36export type ReturnMode = 'message' | 'recent' | 'summary' | 'full'
37// inherit: room agents get the main session's permission mode (bypass included); allow: they edit
38// the repository on their own; ask: each asks first (Cursor, which cannot ask, only reads).
39export type RoomEdits = 'inherit' | 'allow' | 'ask'
40export type ContextConfig = { roomSeed: SeedMode; roomSeedTurns: number; claudeReturn: ReturnMode; claudeReturnMessages: number; roomEdits: RoomEdits }
41export const DEFAULT_CONFIG: ContextConfig = { roomSeed: 'recent', roomSeedTurns: 10, claudeReturn: 'message', claudeReturnMessages: 8, roomEdits: 'inherit' }
42
43const SEED_MODES: readonly SeedMode[] = ['none', 'recent', 'summary', 'full']
44const RETURN_MODES: readonly ReturnMode[] = ['message', 'recent', 'summary', 'full']
45
46function count(value: unknown, fallback: number): number {
47 return typeof value === 'number' && Number.isFinite(value) ? Math.min(200, Math.max(1, Math.round(value))) : fallback
48}
49
50// The manifest's userConfig values, as register() receives them; anything off falls back to the default.
51export function readConfig(options: Readonly<Record<string, unknown>>): ContextConfig {
52 const seed = options.roomSeed as SeedMode
53 const back = options.claudeReturn as ReturnMode
54 return {
55 roomSeed: SEED_MODES.includes(seed) ? seed : DEFAULT_CONFIG.roomSeed,
56 roomSeedTurns: count(options.roomSeedTurns, DEFAULT_CONFIG.roomSeedTurns),
57 claudeReturn: RETURN_MODES.includes(back) ? back : DEFAULT_CONFIG.claudeReturn,
58 claudeReturnMessages: count(options.claudeReturnMessages, DEFAULT_CONFIG.claudeReturnMessages),
59 roomEdits: options.roomEdits === 'ask' || options.roomEdits === 'allow' ? options.roomEdits : DEFAULT_CONFIG.roomEdits,
60 }
61}
62
63// ---------- what the room's agents may do ----------
64
65// The main session's permission mode, as Claude Code names it, to the room's access level.
66const ACCESS_OF_MODE: Record<string, Access> = {
67 plan: 'read',
68 default: 'ask',
69 dontAsk: 'ask',
70 acceptEdits: 'edit',
71 auto: 'auto',
72 bypassPermissions: 'full',
73}
74
75export function accessFor(setting: RoomEdits, mainMode: string | undefined): Access {
76 if (setting === 'allow') return 'edit'
77 if (setting === 'ask') return 'ask'
78 return ACCESS_OF_MODE[mainMode ?? ''] ?? 'ask'
79}
80
81// Each agent's own ACP mode for an access level. Cursor cannot ask, so below edit it only reads;
82// at full access the bridge answers anything an agent still asks.
83export const AGENT_MODE: Record<Access, Record<Agent, string>> = {
84 read: { codex: 'read-only', cursor: 'ask', claude: 'plan' },
85 ask: { codex: 'read-only', cursor: 'ask', claude: 'default' },
86 edit: { codex: 'workspace-write', cursor: 'agent', claude: 'acceptEdits' },
87 auto: { codex: 'agent', cursor: 'agent', claude: 'auto' },
88 full: { codex: 'agent-full-access', cursor: 'agent', claude: 'bypassPermissions' },
89}
90
91export const ACCESS_LABEL: Record<Access, string> = {
92 read: 'read-only',
93 ask: 'asks first',
94 edit: 'edits the repo',
95 auto: 'auto',
96 full: 'full access',
97}
98
99// What the agent is told it may do, in its first prompt and again when the level changes.
100const ACCESS_RULE: Record<Access, (cwd: string) => string> = {
101 read: cwd => `You can read the repository at ${cwd}. This is planning only: do not edit files or run commands that change anything; propose changes instead.`,
102 ask: cwd => `You can read the repository at ${cwd}. Edits and commands need the human's approval, so propose a change before making it.`,
103 edit: cwd =>
104 `You work in the repository at ${cwd}: edit files and run commands there when that is what's asked, and say briefly what you changed. Anything outside it may need the human's approval.`,
105 auto: cwd =>
106 `You work in the repository at ${cwd}: edit files and run commands there when that is what's asked, and say briefly what you changed. Risky actions may need the human's approval.`,
107 full: cwd => `You work in the repository at ${cwd} with full access: edit files and run commands when that is what's asked, and say briefly what you changed.`,
108}
109
110// Codex's approval options are long sentences; the room shows them as short buttons.
111const CHOICE_LABEL: Record<string, string> = {
112 'Yes, proceed': 'Allow',
113 'Yes, just this once': 'Allow once',
114 "Yes, and don't ask again for this command in this session": 'Allow for session',
115 'Yes, and allow this host for this conversation': 'Allow host for session',
116 'Yes, and allow these permissions for this session': 'Allow for session',
117 "Yes, and don't ask again for these files": 'Allow these files',
118 'Yes, grant these permissions for this turn': 'Allow this turn',
119 'Yes, grant for this turn with strict auto review': 'Allow, auto-review',
120 'Yes, grant these permissions for this session': 'Allow for session',
121 'Yes, and allow this host in the future': 'Always allow host',
122 'No, and block this host in the future': 'Always block host',
123 'No, continue without running it': 'Skip',
124 'No, continue without permissions': 'Skip',
125 'No, and tell Codex what to do differently': 'Deny',
126 'Allow for this session': 'Allow for session',
127 Cancel: 'Deny',
128}
129const KIND_LABEL: Record<string, string> = { allow_once: 'Allow', allow_always: 'Always allow', reject_once: 'Deny', reject_always: 'Never' }
130
131function clip(text: string, max: number): string {
132 return text.length > max ? `${text.slice(0, max - 1)}…` : text
133}
134
135// The buttons a question shows: short labels, each once, and a Deny where the agent offered none.
136// An optionId of '' answers "cancelled".
137export function permissionChoices(options: readonly PermissionOption[]): { id: string; label: string; kind: string }[] {
138 const used = new Set<string>()
139 const choices = options.map(option => {
140 const name = option.name.trim()
141 const prefix = /^Yes, and don't ask again for commands that start with `(.+)`$/.exec(name)
142 let label = prefix ? `Always allow ${clip(prefix[1] ?? '', 24)}` : (CHOICE_LABEL[name] ?? (name.length <= 24 ? name : (KIND_LABEL[option.kind] ?? clip(name, 24))))
143 if (used.has(label)) label = clip(name, 32)
144 used.add(label)
145 return { id: option.optionId, label, kind: option.kind }
146 })
147 if (!options.some(o => o.kind.startsWith('reject'))) choices.push({ id: '', label: 'Deny', kind: 'reject_once' })
148 return choices
149}
150
151// "Codex wants to run `npm test`": what the question is about, in a line.
152export function questionText(card: Pick<PermissionCard, 'title' | 'detail'>): string {
153 if (card.detail && /^run command$/i.test(card.title)) return `run \`${clip(card.detail, 200)}\``
154 return card.detail ? `${card.title}: ${clip(card.detail, 200)}` : card.title
155}
156
157const SEED_SUMMARY_PROMPT = [
158 'Another coding agent (OpenAI Codex, Cursor, or a second Claude) is joining a side chat about this work and has not seen this conversation.',
159 'Write it a briefing of at most 250 words: the goal, what has been decided or done, the current state, open questions, and the files that matter.',
160 'Short prose and bullets. Reply with the briefing only.',
161].join(' ')
162
163const RETURN_SUMMARY_SYSTEM =
164 'You summarize a group chat between a developer and coding agents (Codex, Cursor, a separate Claude) for the main Claude Code session, which was not in it. ' +
165 'At most 200 words: what was asked, what each agent concluded, disagreements, and anything still open. Reply with the summary only.'
166
167// ---------- one-shot runs: /second-opinion reviews and /delegate implementations ----------
168
169export function argvFor(agent: External, mode: Mode, prompt: string): string[] {
170 if (agent === 'codex') {
171 const sandbox = mode === 'review' ? 'read-only' : 'workspace-write'
172 return ['codex', 'exec', prompt, '-s', sandbox, '--skip-git-repo-check']
173 }
174 const access = mode === 'review' ? ['--mode', 'ask'] : ['--force']
175 return ['cursor-agent', '-p', prompt, ...access, '--trust', '--output-format', 'text']
176}
177
178export function reviewPrompt(diff: string, focus: string): string {
179 const cut = diff.length > MAX_DIFF ? `${diff.slice(0, MAX_DIFF)}\n[diff cut at ${MAX_DIFF} characters]` : diff
180 return [
181 'You are reviewing a code change another AI coding agent (Claude Code) just made.',
182 'Read the surrounding files in this repository when you need context. Do not modify any files.',
183 'Report only real problems: bugs, regressions, security issues, unhandled edge cases, missing tests for risky logic.',
184 'For each one give file:line, what is wrong, and a concrete fix. Skip style nits. If the change is sound, say so in one line.',
185 focus ? `\nExtra focus from the developer: ${focus}` : '',
186 '\n```diff',
187 cut,
188 '```',
189 ].join('\n')
190}
191
192export function implementPrompt(task: string): string {
193 return [
194 task,
195 '',
196 'Make the change directly in this repository. Keep it scoped to the task and match the surrounding code style.',
197 'When you finish, reply with a short summary: the files you changed and anything you could not do.',
198 ].join('\n')
199}
200
201// "/second-opinion", "/second-opinion codex", "/second-opinion both focus on auth" -> agents + focus text
202export function parseReview(args: string): { agents: External[]; focus: string } {
203 const [first = '', ...rest] = args.trim().split(/\s+/)
204 const word = first.toLowerCase()
205 if (word === 'codex' || word === 'cursor') return { agents: [word], focus: rest.join(' ') }
206 if (word === 'both') return { agents: EXTERNAL, focus: rest.join(' ') }
207 return { agents: EXTERNAL, focus: args.trim() }
208}
209
210// "/delegate cursor add a --verbose flag" -> agent + task
211export function parseDelegate(args: string): { agent: External; task: string } | { error: string } {
212 const [first = '', ...rest] = args.trim().split(/\s+/)
213 const word = first.toLowerCase()
214 if (word !== 'codex' && word !== 'cursor') return { error: 'Usage: /delegate <codex|cursor> <task>' }
215 const task = rest.join(' ').trim()
216 if (!task) return { error: 'Usage: /delegate <codex|cursor> <task>' }
217 return { agent: word, task }
218}
219
220// ---------- the room: who a message is for, and what each agent is told ----------
221
222// @mentions of room members, in order, once each. Code spans and fences are quotes, not
223// mentions: writing `@codex` talks about Codex rather than to it (Syntaur's rule).
224export function parseMentions(text: string): Speaker[] {
225 const prose = text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ')
226 const found: Speaker[] = []
227 for (const match of prose.matchAll(/(^|\s)@(codex|cursor|claude|main)\b/gi)) {
228 const who = (match[2] ?? '').toLowerCase() as Speaker
229 if (!found.includes(who)) found.push(who)
230 }
231 return found
232}
233
234// "/ask codex hi" reads as "@codex hi"; anything else is the message as typed.
235export function normalizeAsk(args: string): string {
236 const trimmed = args.trim()
237 const [first = '', ...rest] = trimmed.split(/\s+/)
238 const word = first.toLowerCase()
239 if (word === 'codex' || word === 'cursor' || word === 'claude') return `@${word} ${rest.join(' ')}`.trim()
240 return trimmed
241}
242
243// A message's targets: the room agents it mentions (else the room's default agent), and
244// whether it also @mentions the main session.
245export function targetsOf(text: string, fallback: Agent): { agents: Agent[]; toMain: boolean } {
246 const mentioned = parseMentions(text)
247 const agents = mentioned.filter((who): who is Agent => who === 'codex' || who === 'cursor' || who === 'claude')
248 const toMain = mentioned.includes('main')
249 return { agents: agents.length || toMain ? agents : [fallback], toMain }
250}
251
252// What the others said since this agent last spoke, newest kept: 12 items, 8,000 characters.
253// A fresh session (first turn, or after a reload restarted the adapters) remembers nothing,
254// so it gets the whole room, its own earlier replies included.
255export function historyFor(messages: RoomMessage[], agent: Agent, triggerId: string, isFresh = false): string {
256 let lastOwn = -1
257 messages.forEach((message, i) => {
258 if (message.from === agent) lastOwn = i
259 })
260 const others = messages
261 .slice(isFresh ? 0 : lastOwn + 1)
262 .filter(m => m.id !== triggerId && (isFresh || m.from !== agent) && m.from !== 'system' && !m.isStreaming && m.text.trim())
263 const kept: string[] = []
264 let size = 0
265 for (const message of [...others].reverse()) {
266 const line = `<chat-event author="${authorTag(message.from)}">${message.text}</chat-event>`
267 if (kept.length >= HISTORY_ITEMS || size + line.length > HISTORY_CHARS) break
268 kept.unshift(line)
269 size += line.length
270 }
271 const dropped = others.length - kept.length
272 if (kept.length === 0) return ''
273 return ['<chat-history>', dropped > 0 ? `(${dropped} earlier messages left out)` : '', ...kept, '</chat-history>']
274 .filter(Boolean)
275 .join('\n')
276}
277
278export type SessionTurn = { role: 'user' | 'assistant'; text: string; toolUses: readonly { tool: string }[] }
279
280// The tail of the Claude Code conversation the room was opened from, for a fresh agent session:
281// what you and Claude said and which tools Claude used, newest kept within maxTurns / maxChars.
282// Tool output, system reminders and the room's own relays to Claude stay out.
283export function sessionExcerpt(messages: readonly SessionTurn[], maxTurns = 10, maxChars = 6_000, clipEach = 1_500): string {
284 const lines: string[] = []
285 let size = 0
286 for (const message of [...messages].reverse()) {
287 const words = message.text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
288 const tools = [...new Set(message.toolUses.map(use => use.tool))]
289 if (!words && tools.length === 0) continue
290 if (words.includes('said in the agent room')) continue
291 const clipped = words.length > clipEach ? `${words.slice(0, clipEach)}…` : words
292 const used = tools.length ? `${clipped ? '\n' : ''}[tools used: ${tools.join(', ')}]` : ''
293 const line = `${message.role === 'user' ? 'Human' : 'Claude'}: ${clipped}${used}`
294 if (lines.length >= maxTurns || size + line.length > maxChars) break
295 lines.unshift(line)
296 size += line.length
297 }
298 if (lines.length === 0) return ''
299 return ['<claude-session>', '(the recent Claude Code conversation this room was opened from)', ...lines, '</claude-session>'].join('\n')
300}
301
302// The room as plain lines for Claude, newest kept within limit / maxChars; routing rows and
303// replies still streaming stay out.
304export function roomTranscript(messages: readonly RoomMessage[], limit: number, maxChars: number): string {
305 const lines: string[] = []
306 let size = 0
307 for (const message of [...messages].reverse()) {
308 if (message.from === 'system' || message.isStreaming || !message.text.trim()) continue
309 const text = message.text.length > 3_000 ? `${message.text.slice(0, 3_000)}…` : message.text
310 const line = `${LABEL[message.from]}: ${text}`
311 if (lines.length >= limit || size + line.length > maxChars) break
312 lines.unshift(line)
313 size += line.length
314 }
315 return lines.join('\n\n')
316}
317
318function authorTag(from: Speaker): string {
319 if (from === 'you') return 'human'
320 return `agent:${from}`
321}
322
323const BREVITY =
324 'Keep replies short: lead with the answer in a few sentences (under about 120 words), no preamble and no step-by-step account of what you did. ' +
325 'Use a short list only for several findings. Go longer only when asked.'
326
327export function framePrompt(
328 agent: Agent,
329 isFirst: boolean,
330 history: string,
331 trigger: RoomMessage,
332 hop: number,
333 cwd: string,
334 sessionContext = '',
335 access: Access = 'edit',
336 accessChanged = false,
337): string {
338 const system = isFirst
339 ? [
340 '<system>',
341 `You are @${agent} (${LABEL[agent]}) in a group chat that lives inside Claude Code.`,
342 'Participants: @you is the human developer; @main is the main Claude Code session that opened this room;',
343 '@claude is a separate Claude agent in the room; @codex is OpenAI Codex; @cursor is the Cursor agent. You are one of them.',
344 ACCESS_RULE[access](cwd),
345 'Your reply goes back to whoever addressed you on its own; do not @mention them to answer.',
346 'To bring in a different participant, @mention them in your reply and they get a turn.',
347 'Only @mention someone when you want them to act.',
348 BREVITY,
349 '</system>',
350 '',
351 ].join('\n')
352 : ''
353 const rule = !isFirst && accessChanged ? `<system>Your permissions changed. ${ACCESS_RULE[access](cwd)}</system>\n` : ''
354 const handoff = hop > 0 ? `(@${trigger.from} handed this to you: hop ${hop} of ${HOP_BUDGET})\n` : ''
355 const session = isFirst && sessionContext ? `${sessionContext}\n` : ''
356 const reminder = isFirst ? '' : '\n(Room reply: answer first, briefly.)'
357 return `${system}${rule}${session}${history ? `${history}\n` : ''}${handoff}<chat-event author="${authorTag(trigger.from)}">${trigger.text}</chat-event>${reminder}`
358}
359
360// Who a finished reply wakes: the participants it @mentions, never its own author, the human
361// (who reads the room anyway) or whoever asked (the reply is already their answer).
362export function handoffTargets(reply: RoomMessage, askedBy: Speaker | undefined): Speaker[] {
363 return parseMentions(reply.text).filter(who => who !== reply.from && who !== 'you' && who !== 'system' && who !== askedBy)
364}
365
366// A handed-off reply that only thanks or acknowledges ends the chain instead of looping.
367export function isBareAck(text: string): boolean {
368 const words = text.replace(/@\w+/g, '').trim()
369 return words.length < 40 && /^(thanks|thank you|ok|okay|got it|sounds good|agreed|will do|👍)/i.test(words)
370}
371
372// ---------- module state (starts over on a reload; the room's messages are the host's) ----------
373
374let seq = 0
375let ticker: { cancel: () => void } | undefined
376let bridge: { socket: string; ready: Promise<boolean>; markReady: (ok: boolean) => void } | undefined
377let cwd = ''
378let bridgeError = ''
379const primed = new Set<Agent>()
380const hops = new Map<string, number>()
381const askedBy = new Map<string, Speaker>()
382const buffered = new Map<string, { segment: number; text: string }[]>()
383let flushTimer: { cancel: () => void } | undefined
384let config: ContextConfig = DEFAULT_CONFIG
385let seedSummary: { turns: number; text: string } | undefined
386// The main session's permission mode: its settings' default until a prompt, a tool call or a turn's
387// end says what it is now (Claude Code reports it there, not on a slash command).
388let mainMode: string | undefined
389const lastAccess = new Map<Agent, Access>()
390let lastConfigError = ''
391
392// ---------- one-shot runs (unchanged from 0.1) ----------
393
394function seconds(run: Run, at: number): string {
395 const s = Math.max(0, Math.round(((run.endedAt ?? at) - run.startedAt) / 1000))
396 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
397}
398
399function patch($: EngineInterface, id: string, fn: (run: Run) => Run) {
400 return update($, runs, list => list.map(run => (run.id === id ? fn(run) : run)))
401}
402
403// Redraw the elapsed timers once a second while a run is going.
404async function tick($: EngineInterface) {
405 await update($, now, () => 0)
406 if (ticker) return
407 ticker = $.clock.every(1000, async () => {
408 const list = await read($, runs)
409 const at = await $.clock.now()
410 await update($, now, () => at)
411 if (!list.some(run => run.status === 'running')) {
412 ticker?.cancel()
413 ticker = undefined
414 }
415 })
416}
417
418async function runAgent($: EngineInterface, agent: External, mode: Mode, task: string, prompt: string): Promise<Run> {
419 seq += 1
420 const id = `${agent}-run-${seq}`
421 const run: Run = { id, agent, mode, task, status: 'running', startedAt: await $.clock.now(), tail: '' }
422 await update($, runs, list => [run, ...list].slice(0, 8))
423 await tick($)
424
425 let stdout = ''
426 let ended: { code: number | null } = { code: null }
427 try {
428 const child = $.process.spawn({ argv: argvFor(agent, mode, prompt) })
429 while (true) {
430 const step = await child.next()
431 if (step.done) {
432 ended = step.value
433 break
434 }
435 const { stream, text } = step.value
436 if (stream === 'stdout') stdout += text
437 await patch($, id, one => ({ ...one, tail: (one.tail + text).slice(-TAIL) }))
438 }
439 } catch (error) {
440 stdout = `Could not start ${argvFor(agent, mode, '…')[0]}: ${String(error)}`
441 }
442
443 const ok = ended.code === 0 && stdout.trim() !== ''
444 const result = stdout.trim() || lastLines(await read($, runs), id) || '(no output)'
445 const endedAt = await $.clock.now()
446 await patch($, id, one => ({ ...one, status: ok ? 'done' : 'failed', endedAt, result }))
447 $.ui.toast(`${LABEL[agent]} ${mode === 'review' ? 'review' : 'implementation'} ${ok ? 'finished' : 'failed'}`)
448 return { ...run, status: ok ? 'done' : 'failed', endedAt, result }
449}
450
451async function changesToReview($: EngineInterface): Promise<{ diff: string; what: string }> {
452 const uncommitted = await $.process.run(['git', 'diff', 'HEAD'])
453 if (uncommitted.exitCode === 0 && uncommitted.stdout.trim()) {
454 return { diff: uncommitted.stdout, what: 'uncommitted changes' }
455 }
456 const last = await $.process.run(['git', 'show', 'HEAD', '--format=commit %H%n%s%n'])
457 if (last.exitCode === 0 && last.stdout.trim()) return { diff: last.stdout, what: 'the last commit' }
458 return { diff: '', what: '' }
459}
460
461function lastLines(list: Run[], id: string): string {
462 return list.find(run => run.id === id)?.tail.trim().split('\n').slice(-15).join('\n') ?? ''
463}
464
465// ---------- the bridge: one node process holding the ACP adapters for this session ----------
466
467async function ensureBridge($: EngineInterface): Promise<string | undefined> {
468 if (!bridge) {
469 let markReady: (ok: boolean) => void = () => {}
470 const ready = new Promise<boolean>(resolve => (markReady = resolve))
471 const socket = `/tmp/room-${crypto.randomUUID().slice(0, 8)}.sock`
472 bridge = { socket, ready, markReady }
473 cwd = await $.session.root()
474 bridgeError = ''
475 primed.clear()
476 void readBridge($, socket)
477 $.clock.after(15_000, () => bridge?.markReady(false))
478 }
479 return (await bridge.ready) ? bridge.socket : undefined
480}
481
482// The bridge's stdout is its event stream; this loop lives as long as the bridge does.
483async function readBridge($: EngineInterface, socket: string) {
484 const argv = ['node', `${$.plugin.root}/bridge/acp-bridge.mjs`, '--socket', socket, '--cwd', cwd]
485 let pending = ''
486 let stderr = ''
487 try {
488 for await (const piece of $.process.spawn({ argv })) {
489 if (piece.stream === 'stderr') {
490 stderr = (stderr + piece.text).slice(-2000)
491 continue
492 }
493 pending += piece.text
494 const lines = pending.split('\n')
495 pending = lines.pop() ?? ''
496 for (const line of lines) {
497 if (!line.trim()) continue
498 try {
499 await onBridgeEvent($, JSON.parse(line))
500 } catch (error) {
501 $.ui.log(`room: bad bridge line: ${String(error)}`, { to: 'debug' })
502 }
503 }
504 }
505 } catch (error) {
506 // A refusal from another mod (a firewall) or a missing node lands here: keep it for the message.
507 bridgeError = String(error).replace(/^HooksError:\s*/, '')
508 stderr += bridgeError
509 }
510 bridge?.markReady(false)
511 bridge = undefined
512 await update($, room, r => ({
513 ...r,
514 status: ALL_IDLE,
515 permissions: [],
516 models: {},
517 detail: stderr.trim() ? `bridge stopped: ${stderr.trim().split('\n').pop()}` : r.detail,
518 messages: r.messages.map(m => (m.isStreaming ? { ...m, isStreaming: false, isError: true, text: m.text || '(the bridge stopped)' } : m)),
519 }))
520}
521
522async function bridgePost($: EngineInterface, path: string, body: Record<string, unknown>) {
523 const socket = await ensureBridge($)
524 if (!socket) throw new Error(`the agent bridge did not start: ${bridgeError || 'is node on PATH?'}`)
525 const response = await $.http.fetch(`http://bridge${path}`, {
526 method: 'POST',
527 headers: { 'content-type': 'application/json' },
528 body: JSON.stringify(body),
529 socketPath: socket,
530 })
531 if (!response.ok) throw new Error(`bridge ${path}: ${response.status} ${response.text}`)
532 return JSON.parse(response.text || '{}') as Record<string, unknown>
533}
534
535type BridgeOption = { id: string; category: string; current: string; values: Choice[] }
536type BridgeEvent =
537 | { type: 'listening' }
538 | { type: 'status'; agent: Agent; state: AgentState; detail?: string }
539 | { type: 'chunk'; agent: Agent; turn: string; segment?: number; text: string }
540 | { type: 'tool'; agent: Agent; turn: string; id: string; title?: string; status?: string }
541 | { type: 'turn_end'; agent: Agent; turn: string; stopReason: string }
542 | { type: 'turn_error'; agent: Agent; turn: string; message: string }
543 | { type: 'permission'; agent: Agent; id: string; title: string; detail?: string; options: PermissionOption[] }
544 | { type: 'permission_closed'; id: string }
545 | { type: 'permission_auto'; agent: Agent; turn?: string; id?: string; title: string; option: string }
546 | { type: 'options'; agent: Agent; mode?: string; options: BridgeOption[] }
547 | { type: 'config_error'; agent: Agent; message: string }
548 | { type: 'log'; message: string }
549
550const OPTION_KEY: Record<string, 'model' | 'effort'> = { model: 'model', thought_level: 'effort' }
551
552async function onBridgeEvent($: EngineInterface, event: BridgeEvent) {
553 switch (event.type) {
554 case 'listening':
555 bridge?.markReady(true)
556 return
557 case 'status':
558 await update($, room, r => ({
559 ...r,
560 status: { ...r.status, [event.agent]: event.state },
561 detail: event.state === 'error' ? `${LABEL[event.agent]}: ${event.detail ?? 'failed'}` : r.detail,
562 // An agent that failed to start lists no models: the picker stops waiting for them.
563 models: event.state === 'error' ? { ...(r.models ?? {}), [event.agent]: {} } : r.models,
564 }))
565 return
566 case 'chunk':
567 buffered.set(event.turn, [...(buffered.get(event.turn) ?? []), { segment: event.segment ?? 0, text: event.text }])
568 // Codex streams token-sized pieces: draw them in batches, not one redraw per token.
569 flushTimer ??= $.clock.after(250, () => {
570 flushTimer = undefined
571 void flushChunks($)
572 })
573 return
574 case 'tool':
575 await patchMessage($, event.turn, m => {
576 const tools = [...(m.tools ?? [])]
577 const at = tools.findIndex(t => t.id === event.id)
578 const title = event.title ?? tools[at]?.title ?? 'tool'
579 const status = event.status ?? tools[at]?.status ?? ''
580 if (at >= 0) tools[at] = { id: event.id, title, status }
581 else tools.push({ id: event.id, title, status })
582 return { ...m, tools }
583 })
584 return
585 case 'turn_end':
586 case 'turn_error': {
587 await flushChunks($)
588 const isError = event.type === 'turn_error'
589 const isStopped = event.type === 'turn_end' && event.stopReason === 'cancelled'
590 const at = await $.clock.now()
591 await patchMessage($, event.turn, m => finishMessage(m, { isError, isStopped, error: isError ? event.message : undefined, at }))
592 const done = (await read($, room)).messages.find(m => m.id === event.turn)
593 if (done && !isError && !isStopped) await routeReply($, done)
594 return
595 }
596 case 'permission':
597 await update($, room, r => ({ ...r, permissions: [...r.permissions.filter(p => p.id !== event.id), { id: event.id, agent: event.agent, title: event.title, detail: event.detail, options: event.options }] }))
598 $.ui.toast(`${LABEL[event.agent]} wants to ${questionText(event)}`)
599 return
600 case 'permission_closed':
601 await update($, room, r => ({ ...r, permissions: r.permissions.filter(p => p.id !== event.id) }))
602 return
603 case 'permission_auto':
604 // Full access: the bridge said yes on the person's behalf; the reply's tool lines show it.
605 if (event.turn) {
606 await patchMessage($, event.turn, m => ({
607 ...m,
608 tools: [...(m.tools ?? []), { id: `auto-${event.id ?? (m.tools?.length ?? 0)}`, title: clip(event.title, 80), status: 'allowed (full access)' }],
609 }))
610 }
611 return
612 case 'options': {
613 const got: { model?: AgentOption; effort?: AgentOption } = {}
614 for (const option of event.options) {
615 const key = OPTION_KEY[option.category]
616 if (key) got[key] = { current: option.current, values: option.values }
617 }
618 await update($, room, r => ({ ...r, models: { ...(r.models ?? {}), [event.agent]: got } }))
619 return
620 }
621 case 'config_error': {
622 const text = `${LABEL[event.agent]} refused ${event.message}`
623 if (text === lastConfigError) return
624 lastConfigError = text
625 await addMessage($, { from: 'system', text })
626 return
627 }
628 case 'log':
629 $.ui.log(`room bridge: ${event.message}`, { to: 'debug' })
630 }
631}
632
633// A turn's end: the answer is the last segment with text; what came before stays as narration.
634export function finishMessage(
635 m: RoomMessage,
636 end: { isError: boolean; isStopped: boolean; error?: string; at: number },
637): RoomMessage {
638 const notes = [...(m.notes ?? [])]
639 let text = m.text.trim()
640 if (!text && notes.length > 0) text = notes.pop() ?? ''
641 if (end.isError) text = `${text}${text ? '\n\n' : ''}⚠ ${end.error ?? 'failed'}`
642 else if (!text) text = end.isStopped ? '(stopped)' : '(no reply)'
643 return { ...m, text, notes, isStreaming: false, isError: end.isError, isStopped: end.isStopped, endedAt: end.at }
644}
645
646// Streamed text by segment: a new segment moves what was written into the narration.
647export function applyChunks(m: RoomMessage, pieces: readonly { segment: number; text: string }[]): RoomMessage {
648 let { text } = m
649 let segment = m.segment ?? 0
650 const notes = [...(m.notes ?? [])]
651 for (const piece of pieces) {
652 if (piece.segment > segment) {
653 if (text.trim()) notes.push(text.trim())
654 text = ''
655 segment = piece.segment
656 }
657 text += piece.text
658 }
659 return { ...m, text, notes, segment }
660}
661
662async function flushChunks($: EngineInterface) {
663 if (buffered.size === 0) return
664 const batch = new Map(buffered)
665 buffered.clear()
666 await update($, room, r => ({
667 ...r,
668 messages: r.messages.map(m => (batch.has(m.id) ? applyChunks(m, batch.get(m.id) ?? []) : m)),
669 }))
670}
671
672function patchMessage($: EngineInterface, id: string, fn: (message: RoomMessage) => RoomMessage) {
673 return update($, room, r => ({ ...r, messages: r.messages.map(m => (m.id === id ? fn(m) : m)) }))
674}
675
676async function addMessage($: EngineInterface, message: Omit<RoomMessage, 'id'> & { id?: string }): Promise<RoomMessage> {
677 seq += 1
678 const full: RoomMessage = { ...message, id: message.id ?? `m${seq}` }
679 await update($, room, r => ({ ...r, messages: [...r.messages, full].slice(-200) }))
680 return full
681}
682
683// A message from you or from Claude: say it in the room, then give each target a turn.
684async function post($: EngineInterface, from: 'you' | 'main', text: string): Promise<string[]> {
685 const state = await read($, room)
686 const { agents, toMain } = targetsOf(text, state.defaultAgent)
687 const first = agents[0]
688 if (from === 'you' && first && parseMentions(text).includes(first)) {
689 await update($, room, r => ({ ...r, defaultAgent: first }))
690 }
691 const message = await addMessage($, { from, text, to: [...agents, ...(toMain && from !== 'main' ? (['main'] as const) : [])] })
692 if (toMain && from === 'you') await tellMain($, message)
693 const turns: string[] = []
694 for (const agent of agents) turns.push(await startTurn($, agent, message, 0))
695 return turns
696}
697
698async function startTurn($: EngineInterface, agent: Agent, trigger: RoomMessage, hop: number): Promise<string> {
699 seq += 1
700 const turn = `${agent}-turn-${seq}`
701 const state = await read($, room)
702 const history = historyFor(state.messages, agent, trigger.id, !primed.has(agent))
703 await addMessage($, { id: turn, from: agent, text: '', hop, isStreaming: true, startedAt: await $.clock.now() })
704 hops.set(turn, hop)
705 askedBy.set(turn, trigger.from)
706 try {
707 await ensureBridge($)
708 const isFresh = !primed.has(agent)
709 const sessionContext = isFresh ? await seedFor($) : ''
710 // The main session's permission mode decides this turn's, so a switch to bypass reaches the room.
711 const access = accessFor(config.roomEdits, mainMode)
712 const accessChanged = lastAccess.has(agent) && lastAccess.get(agent) !== access
713 lastAccess.set(agent, access)
714 if (state.access !== access) await update($, room, r => ({ ...r, access }))
715 const text = framePrompt(agent, isFresh, history, trigger, hop, cwd, sessionContext, access, accessChanged)
716 primed.add(agent)
717 await bridgePost($, '/prompt', { agent, turn, text, settings: settingsFor(agent, access, state.prefs ?? {}) })
718 } catch (error) {
719 await patchMessage($, turn, m => ({ ...m, isStreaming: false, isError: true, text: `⚠ ${String(error)}` }))
720 }
721 return turn
722}
723
724// What the bridge sets on an agent before a turn: its mode for the access level, the person's picks.
725export function settingsFor(agent: Agent, access: Access, prefs: ModelPrefs) {
726 const picked = prefs[agent] ?? {}
727 return {
728 mode: AGENT_MODE[access][agent],
729 autoApprove: access === 'full',
730 ...(picked.model ? { model: picked.model } : {}),
731 ...(picked.effort ? { effort: picked.effort } : {}),
732 }
733}
734
735// A finished reply that @mentions someone hands the conversation to them, within the budget.
736async function routeReply($: EngineInterface, reply: RoomMessage) {
737 const hop = hops.get(reply.id) ?? 0
738 const targets = handoffTargets(reply, askedBy.get(reply.id))
739 if (targets.length === 0) return
740 if (hop > 0 && isBareAck(reply.text)) return
741 if (hop + 1 > HOP_BUDGET) {
742 await addMessage($, { from: 'system', text: `Hop budget (${HOP_BUDGET}) reached; nobody else was woken. Reply to keep going.` })
743 return
744 }
745 for (const target of targets) {
746 await addMessage($, { from: 'system', text: `@${reply.from} → @${target} · hop ${hop + 1} of ${HOP_BUDGET}` })
747 if (target === 'main') await tellMain($, reply)
748 else await startTurn($, target as Agent, reply, hop + 1)
749 }
750}
751
752// What a fresh room agent learns about the Claude session, per the roomSeed setting.
753async function seedFor($: EngineInterface): Promise<string> {
754 if (config.roomSeed === 'none') return ''
755 const rows = await $.session.messages()
756 if (!Array.isArray(rows)) return ''
757 if (config.roomSeed === 'full') return sessionExcerpt(rows, Number.POSITIVE_INFINITY, 60_000, 6_000)
758 const recent = sessionExcerpt(rows, config.roomSeedTurns, Math.min(60_000, config.roomSeedTurns * 1_500))
759 if (config.roomSeed === 'recent') return recent
760
761 // summary: Claude writes it from its own cached transcript; one per session state, shared by both agents.
762 const turns = await $.session.turns()
763 if (seedSummary?.turns === turns) return seedSummary.text
764 const reply = await $.model.fork({ prompt: SEED_SUMMARY_PROMPT })
765 if (!reply.isAnswered) return recent
766 const text = ['<claude-session>', '(a briefing Claude wrote about the conversation this room was opened from)', reply.text.trim(), '</claude-session>'].join('\n')
767 seedSummary = { turns, text }
768 return text
769}
770
771// What goes with a room message to Claude, per the claudeReturn setting. `atLeast` lets the
772// Send room to Claude button treat 'message' as 'recent'.
773async function returnContext($: EngineInterface, except: string | undefined, atLeast?: ReturnMode): Promise<string> {
774 const mode = config.claudeReturn === 'message' && atLeast ? atLeast : config.claudeReturn
775 if (mode === 'message') return ''
776 const messages = (await read($, room)).messages.filter(m => m.id !== except)
777 if (mode === 'recent') return roomTranscript(messages, config.claudeReturnMessages, 12_000)
778 const whole = roomTranscript(messages, Number.POSITIVE_INFINITY, 30_000)
779 if (mode === 'full' || !whole) return whole
780 const reply = await $.model.complete({ model: 'haiku', system: RETURN_SUMMARY_SYSTEM, prompt: whole, maxTokens: 600 })
781 return reply.isAnswered ? `(summary) ${reply.text.trim()}` : roomTranscript(messages, config.claudeReturnMessages, 12_000)
782}
783
784// The main session is reachable from the room: an @main mention arrives there as a prompt.
785async function tellMain($: EngineInterface, message: RoomMessage) {
786 const who = message.from === 'you' ? 'I' : `@${message.from}`
787 const answerHint =
788 message.from === 'you'
789 ? 'Answer me here in the main chat.'
790 : `To answer in the room, call the ask_agent tool with agent "${message.from}".`
791 const context = await returnContext($, message.id)
792 const before = context ? `Earlier in the room:\n\n${context}\n\n---\n\n` : ''
793 void $.prompt.submit({ text: `${before}${who} said in the agent room (the Agent room pane):\n\n${message.text}\n\n${answerHint}` })
794}
795
796async function sendRoomToMain($: EngineInterface) {
797 const context = await returnContext($, undefined, 'recent')
798 if (!context) return
799 await addMessage($, { from: 'system', text: 'Sent the room to the main session.' })
800 void $.prompt.submit({
801 text: `Here is the agent room conversation (Codex, Cursor and the room's Claude, in the Agent room pane). Take it into account and tell me where you agree or disagree.\n\n${context}`,
802 })
803}
804
805// The answer only: the narration before it stays in the room.
806async function waitForTurn($: EngineInterface, turn: string): Promise<{ text: string; error?: string }> {
807 for (let i = 0; i < 40; i += 1) {
808 const result = await bridgePost($, '/wait', { turn, timeoutMs: 45_000 })
809 if (result.done) return { text: String(result.text ?? ''), error: result.error ? String(result.error) : undefined }
810 }
811 return { text: '', error: 'still running after 30 minutes' }
812}
813
814async function resetRoom($: EngineInterface) {
815 if (bridge) {
816 for (const agent of AGENTS) await bridgePost($, '/reset', { agent }).catch(() => undefined)
817 }
818 primed.clear()
819 hops.clear()
820 askedBy.clear()
821 lastAccess.clear()
822 seedSummary = undefined
823 await update($, room, r => ({ ...r, messages: [], permissions: [], detail: '', status: ALL_IDLE, models: {} }))
824}
825
826// The person answered a question: it leaves the room's cards and the transcript says what was chosen.
827async function answerPermission($: EngineInterface, id: string, optionId: string | null) {
828 const card = (await read($, room)).permissions.find(p => p.id === id)
829 await update($, room, r => ({ ...r, permissions: r.permissions.filter(p => p.id !== id) }))
830 if (!card) return
831 const choice = permissionChoices(card.options).find(c => c.id === (optionId ?? ''))
832 const verb = choice?.kind.startsWith('allow') ? 'allowed' : 'declined'
833 await addMessage($, { from: 'system', text: `You ${verb} ${LABEL[card.agent]}: ${questionText(card)}${choice ? ` (${choice.label})` : ''}` })
834 try {
835 const answer = await bridgePost($, '/permission', { id, optionId })
836 if (answer.found === false) await addMessage($, { from: 'system', text: `${LABEL[card.agent]} had already stopped waiting for that answer.` })
837 } catch (error) {
838 await addMessage($, { from: 'system', text: `Could not answer ${LABEL[card.agent]}: ${String(error)}` })
839 }
840}
841
842async function setDefaultAgent($: EngineInterface, agent: Agent) {
843 await update($, room, r => ({ ...r, defaultAgent: agent }))
844}
845
846async function interrupt($: EngineInterface) {
847 const state = await read($, room)
848 const busy = AGENTS.filter(agent => state.status[agent] === 'busy' || state.status[agent] === 'starting')
849 if (busy.length === 0) return
850 await addMessage($, { from: 'system', text: `Stopping ${busy.map(a => LABEL[a]).join(' and ')}…` })
851 for (const agent of busy) await bridgePost($, '/cancel', { agent }).catch(() => undefined)
852}
853
854// The person's model and effort picks: kept across sessions, applied to the agent's session now
855// when it is up (else before its next turn).
856async function pickModel($: EngineInterface, agent: Agent, pick: { model?: string; effort?: string }) {
857 const prefs: ModelPrefs = { ...((await $.store.get('models')) as ModelPrefs | undefined) }
858 const mine = { ...prefs[agent], ...pick }
859 // A new model starts from its own default effort, unless one was picked with it.
860 if (pick.model && !pick.effort) delete mine.effort
861 prefs[agent] = mine
862 await $.store.set('models', prefs)
863 await update($, room, r => ({ ...r, prefs }))
864 if (bridge) await bridgePost($, '/configure', { agent, settings: pick }).catch(() => undefined)
865 const what = [pick.model && `model ${pick.model}`, pick.effort && `effort ${pick.effort}`].filter(Boolean).join(', ')
866 await addMessage($, { from: 'system', text: `${LABEL[agent]} now uses ${what}.` })
867}
868
869// The model picker needs the agent's own list: start its session (no model call) to learn it.
870async function loadModels($: EngineInterface, agent: Agent) {
871 const known = (await read($, room)).models?.[agent]
872 if (known?.model || known?.isLoading) return
873 await update($, room, r => ({ ...r, models: { ...(r.models ?? {}), [agent]: { ...known, isLoading: true } } }))
874 const prefs = ((await $.store.get('models')) as ModelPrefs | undefined) ?? {}
875 await bridgePost($, '/configure', { agent, settings: prefs[agent] ?? {}, start: true }).catch(async error => {
876 await update($, room, r => ({ ...r, models: { ...(r.models ?? {}), [agent]: {} } }))
877 await addMessage($, { from: 'system', text: `Could not load ${LABEL[agent]}'s models: ${String(error)}` })
878 })
879}
880
881// Every hook event that carries the main session's permission mode keeps it current.
882async function trackMode($: EngineInterface, mode: string | undefined) {
883 if (!mode || mode === mainMode) return
884 mainMode = mode
885 const access = accessFor(config.roomEdits, mainMode)
886 if ((await read($, room)).access !== access) await update($, room, r => ({ ...r, access }))
887}
888
889// ---------- drawing ----------
890
891// Green while an agent is up (connected or working), grey while it has not started, red when it failed.
892const STATUS_COLOR: Record<AgentState, string> = { idle: 'gray', starting: 'yellow', ready: 'green', busy: 'green', error: 'red' }
893const STATUS_WORD: Record<AgentState, string> = { idle: 'idle', starting: 'starting…', ready: 'ready', busy: 'working…', error: 'error' }
894
895export function isLive(status: AgentState): boolean {
896 return status === 'ready' || status === 'busy'
897}
898
899export function duration(ms: number): string {
900 const s = Math.max(0, Math.round(ms / 1000))
901 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
902}
903
904// An agent's model in a few characters: the picked or reported model, and its effort.
905export function modelLabel(options: { model?: AgentOption; effort?: AgentOption } | undefined, picked: { model?: string; effort?: string } | undefined): string {
906 const model = picked?.model ?? options?.model?.current
907 const effort = picked?.effort ?? options?.effort?.current
908 const name = options?.model?.values.find(v => v.value === model)?.name ?? model
909 return [name, effort].filter(Boolean).join(' · ')
910}
911
912// What an agent is doing now, for the working line: its latest unfinished tool, else its narration.
913function doingOf(m: RoomMessage): string {
914 const tool = [...(m.tools ?? [])].reverse().find(t => t.status !== 'completed' && t.status !== 'failed')
915 if (tool) return clip(tool.title, 60)
916 const said = m.text.trim() || m.notes?.at(-1) || ''
917 return said ? clip(said.split('\n')[0] ?? '', 60) : 'thinking'
918}
919
920async function renderRoom($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], bodyRows: number) {
921 const elements = $.ui.resolve(e)
922 const { Box, Text, Markdown, Button } = elements
923 // The desktop draws the room's controls in its own surface modules (controls.tsx, composer.tsx):
924 // a Button drawn here keeps its handle only until the next redraw, and the room redraws as
925 // agents stream, so a click there can land on a stale handle and do nothing.
926 const Client = e.surface === 'desktop' && 'Client' in elements ? elements.Client : undefined
927 const Input = e.surface !== 'mobile' && 'Input' in elements ? elements.Input : undefined
928 const state = await read($, room)
929 const models = state.models ?? {}
930 const prefs = state.prefs ?? {}
931 const access = state.access ?? 'ask'
932 const at = await $.clock.now()
933 const to = state.defaultAgent
934 const working = state.messages.filter(m => m.isStreaming && m.from !== 'you' && m.from !== 'main' && m.from !== 'system')
935 const isBusy = working.length > 0 || AGENTS.some(agent => state.status[agent] === 'busy')
936 const questions = state.permissions.map(card => ({
937 id: card.id,
938 who: LABEL[card.agent],
939 color: COLOR[card.agent],
940 question: questionText(card),
941 choices: permissionChoices(card.options),
942 }))
943
944 // A chat transcript: the person's messages as bubbles on the right, the agents' replies on
945 // the left under their name, newest at the foot, and the message box beneath.
946 return (
947 <Box flexDirection="column" width="100%" minHeight={bodyRows > 0 ? bodyRows : undefined}>
948 <Box width="100%" justifyContent="space-between" flexWrap="wrap" paddingBottom={1}>
949 <Box gap={2} flexWrap="wrap">
950 {AGENTS.map(agent => (
951 <Box key={`status-${agent}`} gap={1}>
952 <Text color={STATUS_COLOR[state.status[agent]]}>●</Text>
953 <Text dimColor={!isLive(state.status[agent])}>{LABEL[agent]}</Text>
954 {modelLabel(models[agent], prefs[agent]) ? <Text dimColor>{modelLabel(models[agent], prefs[agent])}</Text> : null}
955 {state.status[agent] === 'starting' || state.status[agent] === 'error' ? <Text dimColor>{STATUS_WORD[state.status[agent]]}</Text> : null}
956 </Box>
957 ))}
958 </Box>
959 <Text dimColor>{`${ACCESS_LABEL[access]}${config.roomEdits === 'inherit' ? ' · like main' : ''}`}</Text>
960 </Box>
961 <Box flexDirection="column" flexGrow={1} justifyContent="flex-end">
962 {state.messages.length === 0 && (
963 <Box flexDirection="column" alignItems="center" paddingX={2} marginBottom={1}>
964 <Text bold>Agent room</Text>
965 <Text dimColor>Type @ to pick who answers</Text>
966 </Box>
967 )}
968 {state.messages.slice(-30).map(m =>
969 m.from === 'system' ? (
970 <Box key={m.id} justifyContent="center" marginTop={1}>
971 <Text dimColor>{m.text}</Text>
972 </Box>
973 ) : m.from === 'you' ? (
974 <Box key={m.id} flexDirection="column" alignItems="flex-end" marginTop={1}>
975 <Box borderStyle="round" borderColor="gray" paddingX={1}>
976 <Text>{m.text}</Text>
977 </Box>
978 </Box>
979 ) : (
980 <Box key={m.id} flexDirection="column" marginTop={1}>
981 <Box gap={1}>
982 <Text bold color={m.isError ? 'red' : COLOR[m.from]}>{`● ${LABEL[m.from]}`}</Text>
983 <Text dimColor>{m.to?.length ? `→ ${m.to.map(t => `@${t}`).join(' ')}` : ''}</Text>
984 </Box>
985 <Box flexDirection="column" paddingLeft={2}>
986 {m.isStreaming ? (
987 <Box flexDirection="column">
988 {(m.notes ?? []).length > 0 ? <Text dimColor>{`… ${clip(m.notes?.at(-1) ?? '', 100)}`}</Text> : null}
989 {(m.tools ?? []).slice(-2).map(tool => (
990 <Text key={`${m.id}-${tool.id}`} dimColor>
991 {`↳ ${clip(tool.title, 80)}${tool.status ? ` · ${tool.status}` : ''}`}
992 </Text>
993 ))}
994 {m.text ? <Markdown text={m.text} /> : <Text dimColor>thinking…</Text>}
995 </Box>
996 ) : (
997 <Box flexDirection="column">
998 {m.from === 'main' ? <Text>{m.text}</Text> : <Markdown text={m.text} />}
999 {m.from !== 'main' && (m.tools?.length || m.notes?.length || m.endedAt) ? (
1000 <Text dimColor>
1001 {[
1002 m.isStopped ? 'stopped' : '',
1003 m.tools?.length ? `${m.tools.length} tool${m.tools.length > 1 ? 's' : ''}` : '',
1004 m.notes?.length ? `${m.notes.length} progress note${m.notes.length > 1 ? 's' : ''} hidden` : '',
1005 m.startedAt && m.endedAt ? duration(m.endedAt - m.startedAt) : '',
1006 ]
1007 .filter(Boolean)
1008 .join(' · ')}
1009 </Text>
1010 ) : null}
1011 </Box>
1012 )}
1013 </Box>
1014 </Box>
1015 ),
1016 )}
1017 {state.detail ? <Text color="red">{state.detail}</Text> : null}
1018 {!Client &&
1019 questions.map(q => (
1020 <Box key={q.id} flexDirection="column" marginTop={1} borderStyle="round" borderColor="yellow" paddingX={1}>
1021 <Text>
1022 <Text bold color={q.color}>{q.who}</Text>
1023 {` wants to ${q.question}`}
1024 </Text>
1025 <Box gap={1} flexWrap="wrap">
1026 {q.choices.map(choice => (
1027 <Button
1028 key={`${q.id}-${choice.id || 'deny'}`}
1029 label={choice.label}
1030 variant={choice.kind === 'allow_once' ? 'primary' : 'secondary'}
1031 onPress={() => answerPermission($, q.id, choice.id || null)}
1032 />
1033 ))}
1034 </Box>
1035 </Box>
1036 ))}
1037 {!Client &&
1038 working.map(m => (
1039 <Text key={`working-${m.id}`} dimColor>
1040 {`${LABEL[m.from]} working · ${duration(at - (m.startedAt ?? at))} · ${doingOf(m)}`}
1041 </Text>
1042 ))}
1043 </Box>
1044
1045 <Box flexDirection="column" width="100%" marginTop={1}>
1046 {Client ? (
1047 <>
1048 <Client
1049 key="room-controls"
1050 module="./controls.tsx"
1051 width="100%"
1052 props={{
1053 questions,
1054 working: working.map(m => ({
1055 id: m.id,
1056 who: LABEL[m.from],
1057 color: COLOR[m.from],
1058 elapsedMs: at - (m.startedAt ?? at),
1059 doing: doingOf(m),
1060 })),
1061 canInterrupt: isBusy,
1062 canShare: state.messages.length > 0,
1063 canReset: state.messages.length > 0,
1064 }}
1065 />
1066 <Client
1067 key="room-composer"
1068 module="./composer.tsx"
1069 width="100%"
1070 props={{
1071 placeholder: `Message ${LABEL[to]}…`,
1072 color: COLOR[to],
1073 chips: AGENTS.map(agent => ({ id: agent, label: LABEL[agent], selected: agent === to })),
1074 mentions: [
1075 ...AGENTS.map(agent => ({ id: agent, label: LABEL[agent], hint: `${LABEL[agent]} · ${state.status[agent]}`, color: COLOR[agent] })),
1076 { id: 'main', label: 'Main session', hint: 'your main Claude Code session', color: COLOR.main },
1077 ],
1078 model: {
1079 agent: to,
1080 label: modelLabel(models[to], prefs[to]) || 'default model',
1081 isLoading: Boolean(models[to]?.isLoading),
1082 hasFailed: state.status[to] === 'error',
1083 models: (models[to]?.model?.values ?? []).map(v => ({ value: v.value, name: v.name })),
1084 efforts: (models[to]?.effort?.values ?? []).map(v => ({ value: v.value, name: v.name })),
1085 model: prefs[to]?.model ?? models[to]?.model?.current ?? '',
1086 effort: prefs[to]?.effort ?? models[to]?.effort?.current ?? '',
1087 },
1088 }}
1089 />
1090 </>
1091 ) : Input ? (
1092 <Box flexDirection="column" width="100%" borderStyle="round" borderColor={COLOR[to]} paddingX={1}>
1093 <Input
1094 key={`room-${state.messages.length}`}
1095 placeholder={`Message ${LABEL[to]}…`}
1096 submitLabel="send"
1097 autoFocus
1098 onSubmit={(value: string) => {
1099 if (value.trim()) void post($, 'you', value.trim())
1100 }}
1101 />
1102 <Box gap={1}>
1103 {AGENTS.map(agent => (
1104 <Button
1105 key={`to-${agent}`}
1106 label={LABEL[agent]}
1107 variant={agent === to ? 'primary' : 'secondary'}
1108 dimColor={agent !== to}
1109 onPress={() => setDefaultAgent($, agent)}
1110 />
1111 ))}
1112 </Box>
1113 </Box>
1114 ) : (
1115 <Text dimColor>{`Send with /ask @${to} <message>`}</Text>
1116 )}
1117 </Box>
1118 {!Client && (isBusy || state.messages.length > 0) && (
1119 <Box width="100%" justifyContent="flex-end" gap={1}>
1120 {isBusy && <Button key="interrupt" label="Interrupt" variant="secondary" onPress={() => interrupt($)} />}
1121 {state.messages.length > 0 && <Button key="share" label="Send to main" variant="secondary" dimColor onPress={() => sendRoomToMain($)} />}
1122 {state.messages.length > 0 && <Button key="new" label="New chat" variant="secondary" dimColor onPress={() => resetRoom($)} />}
1123 </Box>
1124 )}
1125 </Box>
1126 )
1127}
1128
1129// `/room model`: every agent's model; `/room model codex gpt-6-luna low` picks one (the terminal's
1130// and the phone's picker; the desktop has one in the message box).
1131export function parseModelArgs(words: readonly string[]): { agent?: Agent; model?: string; effort?: string } | { error: string } {
1132 if (words.length === 0) return {}
1133 const agent = words[0]?.replace(/^@/, '').toLowerCase() as Agent
1134 if (!AGENTS.includes(agent)) return { error: `Unknown agent "${words[0]}". Usage: /room model [codex|cursor|claude] [model] [effort]` }
1135 return { agent, ...(words[1] ? { model: words[1] } : {}), ...(words[2] ? { effort: words[2] } : {}) }
1136}
1137
1138async function modelCommand($: EngineInterface, words: readonly string[]): Promise<string> {
1139 const parsed = parseModelArgs(words)
1140 if ('error' in parsed) return parsed.error
1141 if (parsed.agent && parsed.model === 'reset') {
1142 const prefs: ModelPrefs = { ...((await $.store.get('models')) as ModelPrefs | undefined) }
1143 delete prefs[parsed.agent]
1144 await $.store.set('models', prefs)
1145 await update($, room, r => ({ ...r, prefs }))
1146 return `${LABEL[parsed.agent]} is back on its own default model from its next session.`
1147 }
1148 if (parsed.agent && (parsed.model || parsed.effort)) {
1149 await pickModel($, parsed.agent, { ...(parsed.model ? { model: parsed.model } : {}), ...(parsed.effort ? { effort: parsed.effort } : {}) })
1150 return `${LABEL[parsed.agent]} will use ${[parsed.model, parsed.effort].filter(Boolean).join(' at ')} from its next message.`
1151 }
1152 const state = await read($, room)
1153 const lines = AGENTS.filter(agent => !parsed.agent || agent === parsed.agent).map(agent => {
1154 const known = state.models?.[agent]
1155 const label = modelLabel(known, state.prefs?.[agent]) || 'its default model'
1156 const choices = known?.model ? `\n models: ${known.model.values.map(v => v.value).join(', ')}${known.effort ? `\n effort: ${known.effort.values.map(v => v.value).join(', ')}` : ''}` : ''
1157 return `${LABEL[agent]}: ${label}${choices}`
1158 })
1159 const hint = AGENTS.some(agent => !state.models?.[agent]?.model) ? '\n\nAn agent lists its models once it has started in the room.' : ''
1160 return `${lines.join('\n')}${hint}\n\nPick with /room model <agent> <model> [effort]; /room model <agent> reset forgets the pick.`
1161}
1162
1163// ---------- registration ----------
1164
1165export const register: Register = (on, options) => {
1166 config = readConfig(options)
1167
1168 on('session.start', async ($, e, next) => {
1169 // Inside the room's own Claude (a Claude Code session the bridge started), stay out of the way:
1170 // no commands, no tools, so it cannot open a room inside the room.
1171 if (await $.env.get('ROOM_MOD_IN_ROOM')) return next(e)
1172 // The room starts from the settings' default mode until the session says which it is in.
1173 const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
1174 const defaultMode = (settings.permissions as { defaultMode?: unknown } | undefined)?.defaultMode
1175 if (!mainMode && typeof defaultMode === 'string') mainMode = defaultMode
1176 const prefs = ((await $.store.get('models').catch(() => undefined)) as ModelPrefs | undefined) ?? {}
1177 await update($, room, r => ({ ...r, prefs, access: accessFor(config.roomEdits, mainMode) }))
1178 // Each registration stands alone: one the engine refuses (a built-in's name) must not take the rest down.
1179 const registered = await Promise.allSettled([
1180 $.command.register({
1181 name: 'second-opinion',
1182 description: 'Have Codex and/or Cursor review the current changes',
1183 argumentHint: '[codex|cursor|both] [focus]',
1184 }),
1185 $.command.register({
1186 name: 'delegate',
1187 description: 'Have Codex or Cursor implement a task in this repo',
1188 argumentHint: '<codex|cursor> <task>',
1189 }),
1190 $.command.register({ name: 'opinions', description: 'Open the Codex/Cursor review runs pane' }),
1191 $.command.register({
1192 name: 'ask',
1193 description: 'Say something in the agent room (Codex, Cursor and a separate Claude in one chat)',
1194 argumentHint: '[@codex|@cursor|@claude|@main] <message>',
1195 }),
1196 $.command.register({
1197 name: 'room',
1198 description: 'Open the agent room: one chat with Codex, Cursor and a separate Claude',
1199 argumentHint: '[model [agent] [model] [effort]]',
1200 }),hooks/controls.tsx 144 lines1import type { ClientModule } from 'claude-code'
2
3// The agent room's controls on the desktop, drawn by this surface module: an agent's questions
4// and their answers, who is working (a spinner and a clock that run here, with no redraw from the
5// hooks), and the room's actions. A Button the room's render hook draws keeps its handle only
6// until the next redraw, and the room redraws as agents stream, so a click there can be lost;
7// a Button here keeps its own handler, and what it does reaches the hooks as a post.
8
9export type ControlsChoice = { id: string; label: string; kind: string }
10export type ControlsQuestion = { id: string; who: string; color: string; question: string; choices: ControlsChoice[] }
11export type ControlsWorking = { id: string; who: string; color: string; elapsedMs: number; doing: string }
12export type ControlsProps = { questions: ControlsQuestion[]; working: ControlsWorking[]; canInterrupt: boolean; canShare: boolean; canReset: boolean }
13// answered: what was pressed on each question still showing; base: the tick each working clock was
14// last set from props, so it counts on between redraws; sharedAt: the tick Send to main was pressed.
15export type ControlsState = {
16 tick: number
17 answered: Record<string, string>
18 stopping: boolean
19 sharedAt?: number
20 base: Record<string, { elapsedMs: number; tick: number }>
21}
22
23export const TICK_MS = 150
24const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
25const SHARED_TICKS = 20
26const EMPTY: ControlsState = { tick: 0, answered: {}, stopping: false, base: {} }
27
28export function clock(ms: number): string {
29 const s = Math.max(0, Math.floor(ms / 1000))
30 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
31}
32
33// The next frame: clocks re-based on new props; answers, a stop and a "sent" forgotten once done.
34export function advance(state: ControlsState, props: ControlsProps): ControlsState {
35 const tick = state.tick + 1
36 const base: ControlsState['base'] = {}
37 for (const w of props.working) {
38 const was = state.base[w.id]
39 base[w.id] = was && was.elapsedMs === w.elapsedMs ? was : { elapsedMs: w.elapsedMs, tick: state.tick }
40 }
41 const answered = Object.fromEntries(Object.entries(state.answered).filter(([id]) => props.questions.some(q => q.id === id)))
42 const stopping = state.stopping && props.working.length > 0
43 const sharedAt = state.sharedAt !== undefined && tick - state.sharedAt < SHARED_TICKS ? state.sharedAt : undefined
44 return { tick, base, answered, stopping, ...(sharedAt !== undefined ? { sharedAt } : {}) }
45}
46
47// How long an agent has worked: what the hooks last drew, plus the ticks counted here since.
48export function elapsed(state: ControlsState, w: ControlsWorking): number {
49 const b = state.base[w.id]
50 return b && b.elapsedMs === w.elapsedMs ? w.elapsedMs + (state.tick - b.tick) * TICK_MS : w.elapsedMs
51}
52
53function isMoving(state: ControlsState, props: ControlsProps): boolean {
54 return props.working.length > 0 || state.stopping || state.sharedAt !== undefined || Object.keys(state.answered).length > 0
55}
56
57// The timer is set once; it reads the props of the latest draw from here.
58let latest: ControlsProps = { questions: [], working: [], canInterrupt: false, canShare: false, canReset: false }
59
60const Controls: ClientModule<ControlsProps, ControlsState> = (props, surface) => {
61 const { Box, Text, Button } = surface.elements
62 latest = props
63 if (surface.state === undefined) {
64 surface.setState(EMPTY)
65 surface.every(TICK_MS, () => {
66 const now = surface.state ?? EMPTY
67 if (isMoving(now, latest)) surface.setState(advance(now, latest))
68 })
69 }
70 const state = surface.state ?? EMPTY
71 const spin = SPINNER[state.tick % SPINNER.length] ?? '•'
72 const answer = (question: ControlsQuestion, choice: ControlsChoice) => () => {
73 const now = surface.state ?? EMPTY
74 surface.setState({ ...now, answered: { ...now.answered, [question.id]: choice.label } })
75 surface.post({ type: 'answer', id: question.id, optionId: choice.id })
76 }
77 const stop = () => {
78 const now = surface.state ?? EMPTY
79 if (now.stopping) return
80 surface.setState({ ...now, stopping: true })
81 surface.post({ type: 'interrupt' })
82 }
83 const share = () => {
84 const now = surface.state ?? EMPTY
85 surface.setState({ ...now, sharedAt: now.tick })
86 surface.post({ type: 'share' })
87 }
88 const interruptButton = (
89 <Button key="interrupt" label={state.stopping ? 'Stopping…' : 'Interrupt'} variant="secondary" dimColor={state.stopping} onPress={stop} />
90 )
91
92 return (
93 <Box flexDirection="column" width="100%">
94 {props.questions.map(q =>
95 state.answered[q.id] ? (
96 <Box key={`q-${q.id}`} width="100%" marginBottom={1}>
97 <Text color="green">{`${spin} ${state.answered[q.id]} · telling ${q.who}…`}</Text>
98 </Box>
99 ) : (
100 <Box key={`q-${q.id}`} flexDirection="column" width="100%" borderStyle="round" borderColor="yellow" paddingX={1} marginBottom={1}>
101 <Text>
102 <Text bold color={q.color}>
103 {q.who}
104 </Text>
105 {` wants to ${q.question}`}
106 </Text>
107 <Box width="100%" gap={1} flexWrap="wrap" marginTop={1}>
108 {q.choices.map(choice => (
109 <Button
110 key={`${q.id}-${choice.id || 'deny'}`}
111 label={choice.label}
112 variant={choice.kind === 'allow_once' ? 'primary' : 'secondary'}
113 onPress={answer(q, choice)}
114 />
115 ))}
116 </Box>
117 </Box>
118 ),
119 )}
120 {props.working.map((w, i) => (
121 <Box key={`w-${w.id}`} width="100%" justifyContent="space-between" alignItems="center">
122 <Text wrap="truncate-end">
123 <Text color={w.color}>{spin}</Text>
124 <Text bold color={w.color}>{` ${w.who}`}</Text>
125 <Text dimColor>{` · ${clock(elapsed(state, w))} · ${w.doing}`}</Text>
126 </Text>
127 {i === 0 && props.canInterrupt ? interruptButton : null}
128 </Box>
129 ))}
130 {props.working.length === 0 && (props.canInterrupt || props.canShare || props.canReset) ? (
131 <Box width="100%" justifyContent="flex-end" gap={1}>
132 {props.canInterrupt ? interruptButton : null}
133 {props.canShare ? (
134 <Button key="share" label={state.sharedAt !== undefined ? 'Sent to main ✓' : 'Send to main'} variant="secondary" dimColor onPress={share} />
135 ) : null}
136 {props.canReset ? <Button key="reset" label="New chat" variant="secondary" dimColor onPress={() => surface.post({ type: 'reset' })} /> : null}
137 </Box>
138 ) : null}
139 </Box>
140 )
141}
142
143export default Controls
144hooks/composer.tsx 200 lines1import type { ClientKeyEvent, ClientModule } from 'claude-code'
2
3// The agent room's message box on the desktop, drawn by this surface module: the desktop's
4// own text field keeps a fixed width, so this one is drawn and edited here, full width.
5// Click it to type; Enter sends, Shift+Enter starts a new line, Escape leaves it.
6// Typing @ opens a list of who can be mentioned: arrows pick, Tab or Enter completes.
7// The model button beside the agents opens the picked agent's models and effort levels.
8
9export type ComposerChip = { id: string; label: string; selected: boolean }
10export type ComposerMention = { id: string; label: string; hint: string; color: string }
11export type ComposerChoice = { value: string; name: string }
12// The picked agent's model: what it uses now and what it offers (empty until its session started).
13export type ComposerModel = {
14 agent: string
15 label: string
16 isLoading: boolean
17 hasFailed: boolean
18 models: ComposerChoice[]
19 efforts: ComposerChoice[]
20 model: string
21 effort: string
22}
23export type ComposerProps = { placeholder: string; color: string; chips: ComposerChip[]; mentions: ComposerMention[]; model?: ComposerModel }
24// closedAt: where the @ sits whose list Escape closed, so it stays closed while that word is typed.
25// menu: the model picker is open.
26export type ComposerState = { text: string; cursor: number; focused: boolean; pick: number; closedAt?: number; menu?: boolean }
27
28const EMPTY: ComposerState = { text: '', cursor: 0, focused: false, pick: 0 }
29
30// Keys the surface names rather than types; anything else longer than one character is a paste.
31const NAMED = new Set(['up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete', 'pageup', 'pagedown', 'home', 'end', 'escape', 'insert'])
32
33// The @word the caret is in, if any: where its @ is and what follows it so far.
34export function mentionQuery(text: string, cursor: number): { start: number; query: string } | undefined {
35 const m = /(^|\s)@([a-z]*)$/i.exec(text.slice(0, cursor))
36 if (!m) return undefined
37 const query = m[2] ?? ''
38 return { start: cursor - query.length - 1, query: query.toLowerCase() }
39}
40
41// The mentions the list shows for this state: none when no @word is open or Escape closed it.
42export function openMentions(state: ComposerState, mentions: readonly ComposerMention[]): ComposerMention[] {
43 const q = mentionQuery(state.text, state.cursor)
44 if (!q || q.start === state.closedAt) return []
45 return mentions.filter(m => m.id.startsWith(q.query) || m.label.toLowerCase().startsWith(q.query))
46}
47
48export function complete(state: ComposerState, id: string): ComposerState {
49 const q = mentionQuery(state.text, state.cursor)
50 if (!q) return state
51 const word = `@${id} `
52 return { ...state, text: state.text.slice(0, q.start) + word + state.text.slice(state.cursor), cursor: q.start + word.length, pick: 0, closedAt: undefined }
53}
54
55export function edit(state: ComposerState, key: ClientKeyEvent, mentions: readonly ComposerMention[] = []): { state: ComposerState; send?: string } {
56 const { text, cursor } = state
57 const list = openMentions(state, mentions)
58 if (list.length > 0) {
59 const pick = Math.min(state.pick, list.length - 1)
60 if (key.key === 'down') return { state: { ...state, pick: (pick + 1) % list.length } }
61 if (key.key === 'up') return { state: { ...state, pick: (pick - 1 + list.length) % list.length } }
62 if (key.key === 'tab' || ((key.key === 'return' || key.key === 'enter') && !key.shift)) return { state: complete(state, list[pick]!.id) }
63 if (key.key === 'escape') return { state: { ...state, closedAt: mentionQuery(text, cursor)?.start } }
64 }
65 const at = (next: string, move: number) => ({ state: { ...state, text: next, cursor: move, pick: 0 } })
66 if (key.key === 'escape') return { state: { ...state, focused: false } }
67 if (key.key === 'return' || key.key === 'enter') {
68 if (key.shift || key.meta) return at(`${text.slice(0, cursor)}\n${text.slice(cursor)}`, cursor + 1)
69 return text.trim() ? { state: { ...EMPTY, focused: state.focused }, send: text.trim() } : { state }
70 }
71 if (key.key === 'backspace') return cursor > 0 ? at(text.slice(0, cursor - 1) + text.slice(cursor), cursor - 1) : { state }
72 if (key.key === 'delete') return at(text.slice(0, cursor) + text.slice(cursor + 1), cursor)
73 if (key.key === 'left') return { state: { ...state, cursor: Math.max(0, cursor - 1) } }
74 if (key.key === 'right') return { state: { ...state, cursor: Math.min(text.length, cursor + 1) } }
75 if (key.key === 'home' || (key.ctrl && key.key === 'a')) return { state: { ...state, cursor: 0 } }
76 if (key.key === 'end' || (key.ctrl && key.key === 'e')) return { state: { ...state, cursor: text.length } }
77 if (key.ctrl && key.key === 'u') return at(text.slice(cursor), 0)
78 if (key.ctrl || key.meta || NAMED.has(key.key)) return { state }
79 const typed = key.key === 'space' ? ' ' : key.key.replace(/\r\n?/g, '\n')
80 return at(text.slice(0, cursor) + typed + text.slice(cursor), cursor + typed.length)
81}
82
83// The listeners are set once; they read the props of the latest draw from here.
84let latest: ComposerProps = { placeholder: '', color: 'gray', chips: [], mentions: [] }
85
86const Composer: ClientModule<ComposerProps, ComposerState> = (props, surface) => {
87 const { Box, Text, Button } = surface.elements
88 latest = props
89 if (surface.state === undefined) {
90 surface.onPointer(event => {
91 if (event.type !== 'down') return
92 const now = surface.state ?? EMPTY
93 // The list's rows sit just under the box's top border: a click on one completes it.
94 const list = now.menu ? [] : openMentions(now, latest.mentions)
95 const row = list[event.y - 1]
96 surface.setState(row ? complete({ ...now, focused: true }, row.id) : { ...now, focused: true })
97 })
98 surface.onKey(key => {
99 // Typing closes the model picker, so the mention list has the box's top rows to itself.
100 const result = edit({ ...(surface.state ?? { ...EMPTY, focused: true }), menu: false }, key, latest.mentions)
101 if (result.send) surface.post({ type: 'send', text: result.send })
102 surface.setState(result.state)
103 })
104 }
105 const state = surface.state ?? EMPTY
106 const list = state.focused && !state.menu ? openMentions(state, props.mentions) : []
107 const model = props.model
108 const toggleMenu = () => {
109 const now = surface.state ?? EMPTY
110 // An agent lists its models once its session is up: ask for that when the picker opens empty.
111 if (!now.menu && model && model.models.length === 0) surface.post({ type: 'models', agent: model.agent })
112 surface.setState({ ...now, menu: !now.menu })
113 }
114 const pickModel = (type: 'model' | 'effort', value: string) => () => {
115 if (!model) return
116 surface.post({ type, agent: model.agent, value })
117 surface.setState({ ...(surface.state ?? EMPTY), menu: false })
118 }
119 const pick = Math.min(state.pick, Math.max(0, list.length - 1))
120 const shown = state.focused ? `${state.text.slice(0, state.cursor)}▏${state.text.slice(state.cursor)}` : state.text
121 const send = () => {
122 if (!state.text.trim()) return
123 surface.post({ type: 'send', text: state.text.trim() })
124 surface.setState({ ...EMPTY, focused: state.focused })
125 }
126
127 return (
128 <Box flexDirection="column" width="100%">
129 <Box flexDirection="column" width="100%" borderStyle="round" borderColor={state.focused ? props.color : 'gray'} paddingX={1}>
130 {list.map((mention, i) => (
131 <Box key={`mention-${mention.id}`} width="100%" gap={1}>
132 <Text color={props.color}>{i === pick ? '▸' : ' '}</Text>
133 <Text bold={i === pick} dimColor={i !== pick} color={mention.color}>{`@${mention.id}`}</Text>
134 <Text dimColor>{mention.hint}</Text>
135 </Box>
136 ))}
137 {state.menu && model ? (
138 <Box flexDirection="column" width="100%" marginBottom={1}>
139 {model.models.length === 0 ? (
140 <Text dimColor>
141 {model.hasFailed ? 'No models listed: the agent did not start (see the room).' : 'Starting the agent to list its models…'}
142 </Text>
143 ) : (
144 <Box flexDirection="column" width="100%">
145 <Box width="100%" gap={1} flexWrap="wrap" alignItems="center">
146 <Text dimColor>Model</Text>
147 {model.models.map(choice => (
148 <Button
149 key={`model-${choice.value}`}
150 label={choice.name}
151 variant={choice.value === model.model ? 'primary' : 'secondary'}
152 dimColor={choice.value !== model.model}
153 onPress={pickModel('model', choice.value)}
154 />
155 ))}
156 </Box>
157 {model.efforts.length > 0 ? (
158 <Box width="100%" gap={1} flexWrap="wrap" alignItems="center">
159 <Text dimColor>Effort</Text>
160 {model.efforts.map(choice => (
161 <Button
162 key={`effort-${choice.value}`}
163 label={choice.name}
164 variant={choice.value === model.effort ? 'primary' : 'secondary'}
165 dimColor={choice.value !== model.effort}
166 onPress={pickModel('effort', choice.value)}
167 />
168 ))}
169 </Box>
170 ) : null}
171 </Box>
172 )}
173 </Box>
174 ) : null}
175 <Box minHeight={2} width="100%" marginTop={list.length ? 1 : 0}>
176 {state.text || state.focused ? <Text>{shown}</Text> : <Text dimColor>{props.placeholder}</Text>}
177 </Box>
178 <Box width="100%" justifyContent="space-between" alignItems="center" flexWrap="wrap">
179 <Box gap={1} alignItems="center" flexWrap="wrap">
180 {props.chips.map(chip => (
181 <Button
182 key={`to-${chip.id}`}
183 label={chip.label}
184 variant={chip.selected ? 'primary' : 'secondary'}
185 dimColor={!chip.selected}
186 // With the picker open, the newly picked agent's models are wanted too.
187 onPress={() => surface.post({ type: 'to', agent: chip.id, ...(state.menu ? { models: true } : {}) })}
188 />
189 ))}
190 {model ? <Button key="model" label={`${model.label} ${state.menu ? '▴' : '▾'}`} variant="secondary" dimColor={!state.menu} onPress={toggleMenu} /> : null}
191 </Box>
192 <Button key="send" label="Send ↵" variant="primary" onPress={send} />
193 </Box>
194 </Box>
195 </Box>
196 )
197}
198
199export default Composer
200types/index.d.ts 66 lines1// One-shot reviews and delegation run the outside CLIs; the room also holds its own Claude.
2export type External = 'codex' | 'cursor'
3export type Agent = External | 'claude'
4export type Mode = 'review' | 'implement'
5export type Run = {
6 id: string
7 agent: External
8 mode: Mode
9 task: string
10 status: 'running' | 'done' | 'failed'
11 startedAt: number
12 endedAt?: number
13 tail: string
14 result?: string
15 isSent?: boolean
16}
17
18// 'main' is the Claude Code session the room was opened from; 'claude' is the room's own Claude.
19export type Speaker = 'you' | 'main' | 'system' | Agent
20export type RoomTool = { id: string; title: string; status: string }
21export type RoomMessage = {
22 id: string
23 from: Speaker
24 // While streaming, the segment being written; once done, the answer (the last segment with text).
25 text: string
26 // The agent's narration between tool calls ("I'll check the tests…"), folded away under the answer.
27 notes?: string[]
28 segment?: number
29 to?: Speaker[]
30 hop?: number
31 isStreaming?: boolean
32 isError?: boolean
33 isStopped?: boolean
34 tools?: RoomTool[]
35 startedAt?: number
36 endedAt?: number
37}
38export type PermissionOption = { optionId: string; name: string; kind: string }
39export type PermissionCard = { id: string; agent: Agent; title: string; detail?: string; options: PermissionOption[] }
40export type AgentState = 'idle' | 'starting' | 'ready' | 'busy' | 'error'
41// What the room's agents may do: read only (plan), ask first, edit the repository, decide with an
42// automatic review, or everything (bypass). Inherited from the main session's permission mode.
43export type Access = 'read' | 'ask' | 'edit' | 'auto' | 'full'
44export type Choice = { value: string; name: string }
45// A pickable config option an agent reported (its model, its effort), with what is set now.
46export type AgentOption = { current: string; values: Choice[] }
47export type AgentOptions = { model?: AgentOption; effort?: AgentOption; isLoading?: boolean }
48// The person's picks, kept across sessions in $.store.
49export type ModelPrefs = Partial<Record<Agent, { model?: string; effort?: string }>>
50export type Room = {
51 messages: RoomMessage[]
52 status: Record<Agent, AgentState>
53 detail: string
54 permissions: PermissionCard[]
55 defaultAgent: Agent
56 access: Access
57 models: Partial<Record<Agent, AgentOptions>>
58 prefs: ModelPrefs
59}
60
61declare module 'claude-code' {
62 interface PluginState {
63 'room': { runs: Run[]; now: number; room: Room }
64 }
65}
66