SLOPSHOPPER

room

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

newpaneguardcommandtoasttool
v0.7.0MITupdated 2026-10-11bbarney7/claude-code-mods/plugins/room
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · room
│ ┃ Second opinion ✕ › fix the failing auth test and add an audit ╭─────────────────────╮ │ ┃ Cursor · review · failed · 0s │ room │ │ ┃ review uncommitted changes ⏺ Read(src/auth.ts) │ Codex review failed │ │ ┃ (no output) ⎿ Read 6 lines ╰─────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) ╭──────────────────────╮ │ ┃ [ Clear finished ] ⎿ Added 2 lines, removed 1 line │ room │ │ ⏺ Bash(bun test) │ Cursor review failed │ │ ⎿ 3 pass, 1 fail ╰──────────────────────╯ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /second-opinion │ ⎿ room: Codex and Cursor reviewing uncommitted changes. Results la │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Second opinion
Cursor · review · failed · 0s review uncommitted changes (no output) [ Clear finished ]
Pane · Agent room
● Codex ● Cursor ● Claude asks first · like main Agent room Type @ to pick who answers ╭──────────────────────────────────────────────────────────╮ │ Message Codex… ⏎ send │ │ [ Codex ] [ Cursor ] [ Claude ] │ ╰──────────────────────────────────────────────────────────╯
README

room

Bring OpenAI Codex, Cursor and a second Claude into Claude Code.

  • Agent room: one chat in a side pane with Codex, Cursor and a separate Claude agent. @mention an agent to pick who answers. Agents can @mention each other to hand work off, and @main brings in your main Claude session.
  • One-shot reviews: /second-opinion has Codex and/or Cursor review your uncommitted changes (or the last commit). One button sends their findings to Claude.
  • Delegation: /delegate has Codex or Cursor implement a task in your repo.
  • Tools for Claude: your main Claude can talk to the room (ask_agent) or hand off a task (delegate_task) on its own.

Install

/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).

Requirements

  • Claude Code 2.1.291 or newer. Mods shipped in 2.1.287. This mod is developed and tested on 2.1.291.
  • Node.js 18 or newer on PATH. The room runs a small Node bridge.
  • The agents you want in the room. Each is optional; a missing one shows an install hint in the room.
AgentRoom adapterOne-shot commands use
Codexnpm i -g @agentclientprotocol/codex-acp, then codex logincodex CLI
Cursor`curl https://cursor.com/install -fsS \bash, then cursor-agent login`cursor-agent CLI
Claudenpm i -g @agentclientprotocol/claude-agent-acp—

Every agent uses its own subscription or login. Nothing here asks for an API key.

Commands

CommandWhat it does
/roomOpen 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
/opinionsReopen 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).

How the room works

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
  • Long-lived sessions. The bridge keeps one adapter process per agent and talks to it over the Agent Client Protocol. Follow-ups don't start a new process, agents remember the conversation, and replies stream into the pane along with the tools each agent uses.
  • Routing happens in code. A message goes to the agents it @mentions. With no mention it goes to whoever you addressed last. Mentions inside backticks or code fences don't count.
  • Hand-offs. When an agent's reply @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.
  • Permissions follow your session. The room's agents get the permission mode of the session you opened the room from, and it changes when that mode changes (it's picked up from your next prompt, tool call or turn end):
Main sessionCodexCursorRoom's Claude
bypass permissionsagent-full-access; the room answers anything it still asksagent; the room answers its questionsbypassPermissions
autoagent (auto review)agentauto
accept editsworkspace-write (asks outside the repo)agentacceptEdits
defaultread-only (asks before edits)ask (only reads)default
planread-only, told to only planaskplan

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.

  • Pick each agent's model. The model button in the message box (desktop) or /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.
  • Short replies. Agents are asked to answer first and briefly. Their narration between tool calls ("I'll check the tests…") is folded away under the answer, which is also all that ask_agent hands back to Claude.
  • Something is always visibly happening. While an agent works, a line under the transcript shows a spinner, how long it has been working and what it's running, with Interrupt. Pressed buttons change at once (Stopping…, Allow · telling Codex…). On the desktop these controls are drawn by the mod's own surface modules, so a click is never lost to a redraw while agents stream.
  • The room's Claude is separate from your main session. It's its own Claude Code session and loads your plugins. This mod notices when it's running there and stays inactive, so there's no room inside the room.

Context settings (/config)

How much crosses between your main session and the room:

SettingValuesDefault
Room: context from the main Claude sessionnone · 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 recentnumber10
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 recentnumber8

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).

Limits

  • The room lasts as long as the session. Agent processes run until Claude Code exits or you click New chat. The room isn't saved across restarts.
  • The room Claude is a full Claude Code session: expect roughly 35k tokens of standing context and about 600 MB of memory. It only starts the first time someone mentions @claude.
  • The full and recent seeds are snapshots taken when an agent's session starts. Use New chat to re-seed after a lot has changed.

Development

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.

Source 4 files
hooks/register.tsx 1419 lines
1import { 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 lines
1import 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
144
hooks/composer.tsx 200 lines
1import 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
200
types/index.d.ts 66 lines
1// 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