clew-code 0.4.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +118 -16
  2. package/dist/main.js +2616 -2260
  3. package/package.json +164 -162
  4. package/scripts/agent-room.ts +741 -0
@@ -0,0 +1,741 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * agent-room — free multi-agent conversation room
4
+ *
5
+ * Spawns local AI CLIs so they can talk to each other with a shared transcript:
6
+ * Claude Code · OpenCode · Clew Code · Codex
7
+ *
8
+ * Examples:
9
+ * bun run scripts/agent-room.ts "How should we design peer messaging?"
10
+ * bun run agent-room --agents claude,codex --rounds 6 "Review our tool API"
11
+ * bun run agent-room --mode free --max-passes 2 "Debate monorepo vs multi-repo"
12
+ * bun run agent-room --dry-run "Smoke test without calling models"
13
+ *
14
+ * Modes:
15
+ * round-robin each agent speaks once per round, in order (default)
16
+ * free same order, but agents may PASS; stop early when enough PASSes
17
+ * parallel all agents reply to the same snapshot each round (faster, less dialogue)
18
+ */
19
+
20
+ import { spawn } from 'node:child_process'
21
+ import * as fs from 'node:fs'
22
+ import * as path from 'node:path'
23
+ import * as readline from 'node:readline'
24
+
25
+ // ─── Types ───────────────────────────────────────────────────────────────────
26
+
27
+ type AgentId = 'claude' | 'opencode' | 'clew' | 'codex'
28
+
29
+ type RoomMode = 'round-robin' | 'free' | 'parallel'
30
+
31
+ type Message = {
32
+ id: number
33
+ ts: string
34
+ from: AgentId | 'human' | 'system'
35
+ text: string
36
+ durationMs?: number
37
+ exitCode?: number | null
38
+ error?: string
39
+ }
40
+
41
+ type AgentDef = {
42
+ id: AgentId
43
+ label: string
44
+ color: string
45
+ /** Resolve command + args for a single turn. Prompt is always last or via stdin. */
46
+ build: (prompt: string, cwd: string) => { command: string; args: string[]; stdin?: string }
47
+ formatOutput?: (raw: string) => string
48
+ }
49
+
50
+ type RoomOptions = {
51
+ topic: string
52
+ agents: AgentId[]
53
+ mode: RoomMode
54
+ rounds: number
55
+ maxPasses: number
56
+ cwd: string
57
+ timeoutMs: number
58
+ transcriptPath: string
59
+ dryRun: boolean
60
+ interactive: boolean
61
+ tools: boolean
62
+ maxHistoryChars: number
63
+ }
64
+
65
+ // ─── ANSI ────────────────────────────────────────────────────────────────────
66
+
67
+ const RESET = '\x1b[0m'
68
+ const DIM = '\x1b[2m'
69
+ const BOLD = '\x1b[1m'
70
+ const COLORS: Record<string, string> = {
71
+ claude: '\x1b[38;5;208m', // orange
72
+ opencode: '\x1b[38;5;39m', // blue
73
+ clew: '\x1b[38;5;120m', // green
74
+ codex: '\x1b[38;5;213m', // pink
75
+ human: '\x1b[38;5;229m', // yellow
76
+ system: '\x1b[38;5;245m', // gray
77
+ }
78
+
79
+ function paint(who: string, text: string): string {
80
+ const c = COLORS[who] ?? COLORS.system
81
+ return `${c}${text}${RESET}`
82
+ }
83
+
84
+ // ─── Windows command resolution ──────────────────────────────────────────────
85
+
86
+ function resolveCommand(command: string): string {
87
+ if (process.platform !== 'win32') return command
88
+ if (/\.(exe|com|cmd|bat)$/i.test(command)) return command
89
+ const pathDirs = (process.env.PATH || '').split(path.delimiter)
90
+ for (const ext of ['.cmd', '.exe', '.bat', '.com']) {
91
+ for (const dir of pathDirs) {
92
+ const fp = path.join(dir, command + ext)
93
+ try {
94
+ if (fs.existsSync(fp)) return fp
95
+ } catch {
96
+ /* skip */
97
+ }
98
+ }
99
+ }
100
+ return command
101
+ }
102
+
103
+ function commandOnPath(command: string): boolean {
104
+ if (process.platform === 'win32') {
105
+ return resolveCommand(command) !== command || fs.existsSync(command)
106
+ }
107
+ const pathDirs = (process.env.PATH || '').split(path.delimiter)
108
+ return pathDirs.some(dir => {
109
+ try {
110
+ return fs.existsSync(path.join(dir, command))
111
+ } catch {
112
+ return false
113
+ }
114
+ })
115
+ }
116
+
117
+ function shouldUseShell(command: string): boolean {
118
+ if (process.platform !== 'win32') return false
119
+ // .cmd/.bat need shell; bare .exe usually does not
120
+ return /\.(cmd|bat)$/i.test(command)
121
+ }
122
+
123
+ // ─── Output formatters ───────────────────────────────────────────────────────
124
+
125
+ function formatCodexJsonl(raw: string): string {
126
+ const messages: string[] = []
127
+ for (const line of raw.split('\n')) {
128
+ if (!line.trim()) continue
129
+ try {
130
+ const event = JSON.parse(line)
131
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message' && event.item.text) {
132
+ messages.push(event.item.text)
133
+ }
134
+ } catch {
135
+ /* keep going */
136
+ }
137
+ }
138
+ return messages.join('\n').trim() || stripNoise(raw)
139
+ }
140
+
141
+ function stripNoise(raw: string): string {
142
+ return raw
143
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
144
+ .replace(/\r/g, '')
145
+ .trim()
146
+ }
147
+
148
+ // ─── Agent definitions ───────────────────────────────────────────────────────
149
+
150
+ const AGENTS: Record<AgentId, AgentDef> = {
151
+ claude: {
152
+ id: 'claude',
153
+ label: 'Claude Code',
154
+ color: COLORS.claude,
155
+ build(prompt, cwd) {
156
+ const command = resolveCommand(process.env.AGENT_ROOM_CLAUDE || 'claude')
157
+ // Conversation-only by default: max 1 turn, text out, no permission prompts hang
158
+ const args = [
159
+ '-p',
160
+ '--output-format',
161
+ 'text',
162
+ '--max-turns',
163
+ '1',
164
+ '--permission-mode',
165
+ 'dontAsk',
166
+ prompt,
167
+ ]
168
+ return { command, args, stdin: undefined }
169
+ },
170
+ },
171
+ clew: {
172
+ id: 'clew',
173
+ label: 'Clew Code',
174
+ color: COLORS.clew,
175
+ build(prompt, _cwd) {
176
+ const command = resolveCommand(process.env.AGENT_ROOM_CLEW || 'clew')
177
+ const args = [
178
+ '-p',
179
+ '--output-format',
180
+ 'text',
181
+ '--max-turns',
182
+ '1',
183
+ '--permission-mode',
184
+ 'dontAsk',
185
+ prompt,
186
+ ]
187
+ return { command, args }
188
+ },
189
+ },
190
+ opencode: {
191
+ id: 'opencode',
192
+ label: 'OpenCode',
193
+ color: COLORS.opencode,
194
+ build(prompt, cwd) {
195
+ const command = resolveCommand(process.env.AGENT_ROOM_OPENCODE || 'opencode')
196
+ // `opencode run` takes message positionals; --dir sets workspace
197
+ const args = ['run', '--format', 'default', '--dir', cwd, prompt]
198
+ return { command, args }
199
+ },
200
+ },
201
+ codex: {
202
+ id: 'codex',
203
+ label: 'Codex',
204
+ color: COLORS.codex,
205
+ formatOutput: formatCodexJsonl,
206
+ build(prompt, cwd) {
207
+ const command = resolveCommand(process.env.AGENT_ROOM_CODEX || process.env.CLEW_CODEX_COMMAND || 'codex')
208
+ const args = ['exec', '-C', cwd, '--color', 'never', '--json', prompt]
209
+ return { command, args }
210
+ },
211
+ },
212
+ }
213
+
214
+ const ALL_AGENT_IDS = Object.keys(AGENTS) as AgentId[]
215
+
216
+ // ─── Spawn one turn ──────────────────────────────────────────────────────────
217
+
218
+ async function runAgentTurn(
219
+ agent: AgentDef,
220
+ prompt: string,
221
+ opts: { cwd: string; timeoutMs: number; dryRun: boolean },
222
+ ): Promise<{ text: string; durationMs: number; exitCode: number | null; error?: string }> {
223
+ const started = Date.now()
224
+ const inv = agent.build(prompt, opts.cwd)
225
+
226
+ if (opts.dryRun) {
227
+ return {
228
+ text: `[dry-run] ${agent.label} would respond to a ${prompt.length}-char prompt.`,
229
+ durationMs: Date.now() - started,
230
+ exitCode: 0,
231
+ }
232
+ }
233
+
234
+ return await new Promise(resolve => {
235
+ let stdout = ''
236
+ let stderr = ''
237
+ let settled = false
238
+ const maxBytes = 512 * 1024
239
+
240
+ const child = spawn(inv.command, inv.args, {
241
+ cwd: opts.cwd,
242
+ env: process.env,
243
+ windowsHide: true,
244
+ shell: shouldUseShell(inv.command),
245
+ })
246
+
247
+ const timer = setTimeout(() => {
248
+ child.kill('SIGTERM')
249
+ }, opts.timeoutMs)
250
+
251
+ child.stdout?.on('data', (chunk: Buffer) => {
252
+ if (stdout.length < maxBytes) stdout += chunk.toString('utf8')
253
+ })
254
+ child.stderr?.on('data', (chunk: Buffer) => {
255
+ if (stderr.length < maxBytes) stderr += chunk.toString('utf8')
256
+ })
257
+
258
+ const finish = (exitCode: number | null, error?: string) => {
259
+ if (settled) return
260
+ settled = true
261
+ clearTimeout(timer)
262
+ const raw = stdout || stderr
263
+ const formatted = agent.formatOutput ? agent.formatOutput(raw) : stripNoise(raw)
264
+ const text = (formatted || stripNoise(stderr) || error || '(empty response)').trim()
265
+ resolve({
266
+ text,
267
+ durationMs: Date.now() - started,
268
+ exitCode,
269
+ error,
270
+ })
271
+ }
272
+
273
+ child.on('error', err => {
274
+ finish(null, `${agent.label} failed to start (${inv.command}): ${err.message}`)
275
+ })
276
+ child.on('close', (code, signal) => {
277
+ if (signal === 'SIGTERM') {
278
+ finish(code, `${agent.label} timed out after ${opts.timeoutMs}ms`)
279
+ return
280
+ }
281
+ finish(code)
282
+ })
283
+
284
+ if (inv.stdin !== undefined) {
285
+ child.stdin?.end(inv.stdin)
286
+ } else {
287
+ child.stdin?.end()
288
+ }
289
+ })
290
+ }
291
+
292
+ // ─── Prompt construction ─────────────────────────────────────────────────────
293
+
294
+ function isPass(text: string): boolean {
295
+ const t = text.trim()
296
+ if (!t) return true
297
+ if (/^pass\.?$/i.test(t)) return true
298
+ if (/^(i\s+)?pass\.?$/i.test(t)) return true
299
+ // Short "nothing to add" variants
300
+ if (/^pass\b.{0,40}$/i.test(t) && /nothing|no\s+(more|further)|skip/i.test(t)) return true
301
+ return false
302
+ }
303
+
304
+ function buildTurnPrompt(
305
+ agent: AgentDef,
306
+ others: AgentDef[],
307
+ topic: string,
308
+ history: Message[],
309
+ mode: RoomMode,
310
+ maxHistoryChars: number,
311
+ tools: boolean,
312
+ ): string {
313
+ const lines: string[] = []
314
+ lines.push(`You are ${agent.label} (${agent.id}) in a multi-agent free-talk room.`)
315
+ lines.push(`Other participants: ${others.map(o => `${o.label} (${o.id})`).join(', ')}.`)
316
+ lines.push(`Topic / seed: ${topic}`)
317
+ lines.push('')
318
+ lines.push('Rules:')
319
+ lines.push('1. Talk freely with the other agents. Address them by name when useful.')
320
+ lines.push('2. Be concise (about 2–8 sentences) unless a deeper technical dive is needed.')
321
+ lines.push('3. You may agree, disagree, build on ideas, ask questions, or propose next steps.')
322
+ if (!tools) {
323
+ lines.push(
324
+ '4. This is CONVERSATION ONLY. Do not edit files, run shell commands, or use tools. Discuss only.',
325
+ )
326
+ } else {
327
+ lines.push('4. Prefer discussion; only use tools if essential to answer a factual question.')
328
+ }
329
+ lines.push('5. If you have nothing useful to add this turn, reply with exactly: PASS')
330
+ if (mode === 'free') {
331
+ lines.push('6. Free mode: only speak if you have a real contribution; otherwise PASS.')
332
+ }
333
+ lines.push('')
334
+ lines.push('Conversation so far:')
335
+ lines.push('---')
336
+
337
+ let body = ''
338
+ for (const m of history) {
339
+ const chunk = `[${m.from}] ${m.text}\n\n`
340
+ if (body.length + chunk.length > maxHistoryChars) {
341
+ body = `…(earlier messages truncated)…\n\n` + body.slice(-(maxHistoryChars - 80))
342
+ }
343
+ body += chunk
344
+ }
345
+ lines.push(body.trim() || '(no messages yet — open the discussion)')
346
+ lines.push('---')
347
+ lines.push(`Your turn as ${agent.label}. Respond now.`)
348
+ return lines.join('\n')
349
+ }
350
+
351
+ // ─── Transcript I/O ──────────────────────────────────────────────────────────
352
+
353
+ function ensureDir(filePath: string): void {
354
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
355
+ }
356
+
357
+ function appendTranscript(filePath: string, msg: Message): void {
358
+ ensureDir(filePath)
359
+ fs.appendFileSync(filePath, JSON.stringify(msg) + '\n', 'utf8')
360
+ }
361
+
362
+ function writeMarkdownMirror(filePath: string, topic: string, messages: Message[]): void {
363
+ const mdPath = filePath.replace(/\.jsonl$/i, '.md')
364
+ const lines = [`# Agent Room Transcript`, ``, `**Topic:** ${topic}`, ``, `---`, ``]
365
+ for (const m of messages) {
366
+ lines.push(`### ${m.from} · ${m.ts}`)
367
+ lines.push('')
368
+ lines.push(m.text)
369
+ lines.push('')
370
+ }
371
+ ensureDir(mdPath)
372
+ fs.writeFileSync(mdPath, lines.join('\n'), 'utf8')
373
+ }
374
+
375
+ // ─── CLI parsing ─────────────────────────────────────────────────────────────
376
+
377
+ function printHelp(): void {
378
+ console.log(`
379
+ ${BOLD}agent-room${RESET} — free multi-agent conversation (Claude · OpenCode · Clew · Codex)
380
+
381
+ ${BOLD}Usage${RESET}
382
+ bun run scripts/agent-room.ts [options] "<topic or opening message>"
383
+ bun run agent-room [options] "<topic>"
384
+
385
+ ${BOLD}Options${RESET}
386
+ --agents <list> Comma list: claude,opencode,clew,codex (default: all found on PATH)
387
+ --mode <mode> round-robin | free | parallel (default: free)
388
+ --rounds <n> Max rounds (default: 4)
389
+ --max-passes <n> Stop free mode after N consecutive all-PASS rounds (default: 2)
390
+ --cwd <dir> Working directory (default: cwd)
391
+ --timeout <sec> Per-turn timeout seconds (default: 180)
392
+ --transcript <path> JSONL transcript path (default: .clew/agent-room/<ts>.jsonl)
393
+ --interactive After each round, allow human to inject a message (or quit)
394
+ --tools Allow tool use (default: conversation-only)
395
+ --max-history <n> Max chars of history injected per turn (default: 12000)
396
+ --dry-run Simulate replies without spawning CLIs
397
+ -h, --help Show help
398
+
399
+ ${BOLD}Examples${RESET}
400
+ bun run agent-room "How should peer messaging work across LAN agents?"
401
+ bun run agent-room --agents claude,codex --rounds 6 "Design a rule system"
402
+ bun run agent-room --mode parallel --dry-run "Smoke test"
403
+ bun run agent-room --interactive "Brainstorm product name ideas"
404
+
405
+ ${BOLD}Env overrides${RESET}
406
+ AGENT_ROOM_CLAUDE / AGENT_ROOM_OPENCODE / AGENT_ROOM_CLEW / AGENT_ROOM_CODEX
407
+ CLEW_CODEX_COMMAND
408
+ `.trim())
409
+ }
410
+
411
+ function parseArgs(argv: string[]): RoomOptions | { help: true } | { error: string } {
412
+ const agentsDefault: AgentId[] | null = null
413
+ let agents: AgentId[] | null = agentsDefault
414
+ let mode: RoomMode = 'free'
415
+ let rounds = 4
416
+ let maxPasses = 2
417
+ let cwd = process.cwd()
418
+ let timeoutMs = 180_000
419
+ let transcriptPath = ''
420
+ let dryRun = false
421
+ let interactive = false
422
+ let tools = false
423
+ let maxHistoryChars = 12_000
424
+ const topicParts: string[] = []
425
+
426
+ for (let i = 0; i < argv.length; i++) {
427
+ const a = argv[i]!
428
+ if (a === '-h' || a === '--help') return { help: true }
429
+ if (a === '--dry-run') {
430
+ dryRun = true
431
+ continue
432
+ }
433
+ if (a === '--interactive') {
434
+ interactive = true
435
+ continue
436
+ }
437
+ if (a === '--tools') {
438
+ tools = true
439
+ continue
440
+ }
441
+ if (a === '--agents') {
442
+ // Accept comma-separated and/or multiple tokens until next flag/topic.
443
+ // PowerShell turns `a,b,c` into separate args, so gather consecutive non-flag tokens.
444
+ const collected: string[] = []
445
+ while (i + 1 < argv.length && !argv[i + 1]!.startsWith('-')) {
446
+ const next = argv[++i]!
447
+ // Stop gathering once we hit something that looks like the topic sentence
448
+ // (has spaces from prior join? no — argv is already split). Keep tokens that
449
+ // are agent ids or comma-lists thereof; break on first non-agent word if we already have some.
450
+ const pieces = next.split(/[,\s]+/).map(s => s.trim().toLowerCase()).filter(Boolean)
451
+ const allAgents = pieces.every(p => ALL_AGENT_IDS.includes(p as AgentId))
452
+ if (!allAgents && collected.length > 0) {
453
+ i-- // put topic token back
454
+ break
455
+ }
456
+ if (!allAgents) {
457
+ return { error: `Unknown agent "${next}". Valid: ${ALL_AGENT_IDS.join(', ')}` }
458
+ }
459
+ collected.push(...pieces)
460
+ }
461
+ if (collected.length === 0) return { error: '--agents requires a list (e.g. claude,codex or claude codex)' }
462
+ agents = [...new Set(collected)] as AgentId[]
463
+ continue
464
+ }
465
+ if (a === '--mode') {
466
+ const m = argv[++i] as RoomMode
467
+ if (!m || !['round-robin', 'free', 'parallel'].includes(m)) {
468
+ return { error: '--mode must be round-robin | free | parallel' }
469
+ }
470
+ mode = m
471
+ continue
472
+ }
473
+ if (a === '--rounds') {
474
+ rounds = Number(argv[++i])
475
+ if (!Number.isFinite(rounds) || rounds < 1) return { error: '--rounds must be >= 1' }
476
+ continue
477
+ }
478
+ if (a === '--max-passes') {
479
+ maxPasses = Number(argv[++i])
480
+ if (!Number.isFinite(maxPasses) || maxPasses < 1) return { error: '--max-passes must be >= 1' }
481
+ continue
482
+ }
483
+ if (a === '--cwd') {
484
+ cwd = path.resolve(argv[++i] || cwd)
485
+ continue
486
+ }
487
+ if (a === '--timeout') {
488
+ const sec = Number(argv[++i])
489
+ if (!Number.isFinite(sec) || sec <= 0) return { error: '--timeout must be positive seconds' }
490
+ timeoutMs = Math.round(sec * 1000)
491
+ continue
492
+ }
493
+ if (a === '--transcript') {
494
+ transcriptPath = path.resolve(argv[++i] || '')
495
+ continue
496
+ }
497
+ if (a === '--max-history') {
498
+ maxHistoryChars = Number(argv[++i])
499
+ if (!Number.isFinite(maxHistoryChars) || maxHistoryChars < 500) {
500
+ return { error: '--max-history must be >= 500' }
501
+ }
502
+ continue
503
+ }
504
+ if (a.startsWith('-')) {
505
+ return { error: `Unknown flag: ${a}` }
506
+ }
507
+ topicParts.push(a)
508
+ }
509
+
510
+ const topic = topicParts.join(' ').trim()
511
+ if (!topic) return { error: 'Missing topic / opening message. Pass it as the final argument.' }
512
+
513
+ if (!transcriptPath) {
514
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
515
+ transcriptPath = path.join(cwd, '.clew', 'agent-room', `${stamp}.jsonl`)
516
+ }
517
+
518
+ // Auto-detect agents on PATH if not specified
519
+ if (!agents) {
520
+ agents = ALL_AGENT_IDS.filter(id => {
521
+ const cmd =
522
+ process.env[`AGENT_ROOM_${id.toUpperCase()}`] ||
523
+ (id === 'codex' ? process.env.CLEW_CODEX_COMMAND : undefined) ||
524
+ (id === 'clew' ? 'clew' : id)
525
+ return commandOnPath(cmd)
526
+ })
527
+ }
528
+
529
+ if (agents.length < 2) {
530
+ return {
531
+ error: `Need at least 2 agents on PATH (found: ${agents.join(', ') || 'none'}). Install claude / opencode / clew / codex, or pass --agents.`,
532
+ }
533
+ }
534
+
535
+ return {
536
+ topic,
537
+ agents,
538
+ mode,
539
+ rounds,
540
+ maxPasses,
541
+ cwd,
542
+ timeoutMs,
543
+ transcriptPath,
544
+ dryRun,
545
+ interactive,
546
+ tools,
547
+ maxHistoryChars,
548
+ }
549
+ }
550
+
551
+ // ─── Interactive human inject ────────────────────────────────────────────────
552
+
553
+ async function promptHuman(rl: readline.Interface): Promise<string | null> {
554
+ return await new Promise(resolve => {
555
+ rl.question(
556
+ `${paint('human', 'you')}${DIM} (message, or empty=continue, /quit=stop): ${RESET}`,
557
+ answer => {
558
+ const t = (answer ?? '').trim()
559
+ if (t === '/quit' || t === '/exit' || t === '/q') resolve(null)
560
+ else resolve(t)
561
+ },
562
+ )
563
+ })
564
+ }
565
+
566
+ // ─── Room loop ───────────────────────────────────────────────────────────────
567
+
568
+ function printBanner(opts: RoomOptions, roster: AgentDef[]): void {
569
+ console.log('')
570
+ console.log(`${BOLD}┌─ agent-room ─────────────────────────────────────────┐${RESET}`)
571
+ console.log(`${BOLD}│${RESET} mode: ${opts.mode.padEnd(12)} rounds: ${String(opts.rounds).padEnd(4)} dry: ${opts.dryRun}`)
572
+ console.log(`${BOLD}│${RESET} agents: ${roster.map(a => paint(a.id, a.id)).join(' · ')}`)
573
+ console.log(`${BOLD}│${RESET} topic: ${opts.topic.slice(0, 60)}${opts.topic.length > 60 ? '…' : ''}`)
574
+ console.log(`${BOLD}│${RESET} log: ${opts.transcriptPath}`)
575
+ console.log(`${BOLD}└──────────────────────────────────────────────────────┘${RESET}`)
576
+ console.log('')
577
+ }
578
+
579
+ function printMessage(msg: Message): void {
580
+ const label = paint(msg.from, msg.from.padEnd(9))
581
+ const meta =
582
+ msg.durationMs !== undefined
583
+ ? `${DIM}${(msg.durationMs / 1000).toFixed(1)}s${msg.exitCode && msg.exitCode !== 0 ? ` exit=${msg.exitCode}` : ''}${RESET}`
584
+ : ''
585
+ console.log(`${label} ${meta}`)
586
+ for (const line of msg.text.split('\n')) {
587
+ console.log(` ${line}`)
588
+ }
589
+ if (msg.error) console.log(` ${DIM}error: ${msg.error}${RESET}`)
590
+ console.log('')
591
+ }
592
+
593
+ async function runRoom(opts: RoomOptions): Promise<number> {
594
+ const roster = opts.agents.map(id => AGENTS[id])
595
+ const messages: Message[] = []
596
+ let nextId = 1
597
+ let consecutiveAllPass = 0
598
+
599
+ const push = (from: Message['from'], text: string, extra?: Partial<Message>): Message => {
600
+ const msg: Message = {
601
+ id: nextId++,
602
+ ts: new Date().toISOString(),
603
+ from,
604
+ text,
605
+ ...extra,
606
+ }
607
+ messages.push(msg)
608
+ appendTranscript(opts.transcriptPath, msg)
609
+ writeMarkdownMirror(opts.transcriptPath, opts.topic, messages)
610
+ printMessage(msg)
611
+ return msg
612
+ }
613
+
614
+ printBanner(opts, roster)
615
+ push('system', `Room opened. Mode=${opts.mode}. Conversation-only=${!opts.tools}.`)
616
+ push('human', opts.topic)
617
+
618
+ const rl = opts.interactive
619
+ ? readline.createInterface({ input: process.stdin, output: process.stdout })
620
+ : null
621
+
622
+ try {
623
+ for (let round = 1; round <= opts.rounds; round++) {
624
+ console.log(`${DIM}── round ${round}/${opts.rounds} ──${RESET}`)
625
+
626
+ if (opts.mode === 'parallel') {
627
+ // Snapshot history so all agents see the same state
628
+ const snapshot = [...messages]
629
+ const results = await Promise.all(
630
+ roster.map(async agent => {
631
+ const others = roster.filter(a => a.id !== agent.id)
632
+ const prompt = buildTurnPrompt(
633
+ agent,
634
+ others,
635
+ opts.topic,
636
+ snapshot,
637
+ opts.mode,
638
+ opts.maxHistoryChars,
639
+ opts.tools,
640
+ )
641
+ const result = await runAgentTurn(agent, prompt, {
642
+ cwd: opts.cwd,
643
+ timeoutMs: opts.timeoutMs,
644
+ dryRun: opts.dryRun,
645
+ })
646
+ return { agent, result }
647
+ }),
648
+ )
649
+ let passes = 0
650
+ for (const { agent, result } of results) {
651
+ const text = result.text
652
+ if (isPass(text)) passes++
653
+ push(agent.id, text, {
654
+ durationMs: result.durationMs,
655
+ exitCode: result.exitCode,
656
+ error: result.error,
657
+ })
658
+ }
659
+ if (opts.mode === 'parallel' && passes === roster.length) {
660
+ consecutiveAllPass++
661
+ } else {
662
+ consecutiveAllPass = 0
663
+ }
664
+ } else {
665
+ // round-robin / free: sequential so later agents see earlier replies
666
+ let passCount = 0
667
+ for (const agent of roster) {
668
+ const others = roster.filter(a => a.id !== agent.id)
669
+ const prompt = buildTurnPrompt(
670
+ agent,
671
+ others,
672
+ opts.topic,
673
+ messages,
674
+ opts.mode,
675
+ opts.maxHistoryChars,
676
+ opts.tools,
677
+ )
678
+ process.stdout.write(`${DIM}… waiting on ${agent.label}${RESET}\n`)
679
+ const result = await runAgentTurn(agent, prompt, {
680
+ cwd: opts.cwd,
681
+ timeoutMs: opts.timeoutMs,
682
+ dryRun: opts.dryRun,
683
+ })
684
+ if (isPass(result.text)) passCount++
685
+ push(agent.id, result.text, {
686
+ durationMs: result.durationMs,
687
+ exitCode: result.exitCode,
688
+ error: result.error,
689
+ })
690
+ }
691
+ if (opts.mode === 'free' && passCount === roster.length) {
692
+ consecutiveAllPass++
693
+ } else {
694
+ consecutiveAllPass = 0
695
+ }
696
+ }
697
+
698
+ if (opts.mode !== 'round-robin' && consecutiveAllPass >= opts.maxPasses) {
699
+ push('system', `All agents PASS for ${consecutiveAllPass} round(s) — closing room.`)
700
+ break
701
+ }
702
+
703
+ if (rl) {
704
+ const human = await promptHuman(rl)
705
+ if (human === null) {
706
+ push('system', 'Human ended the room.')
707
+ break
708
+ }
709
+ if (human) push('human', human)
710
+ }
711
+ }
712
+
713
+ push('system', `Room closed. Transcript: ${opts.transcriptPath}`)
714
+ console.log(`${BOLD}Done.${RESET} Markdown: ${opts.transcriptPath.replace(/\.jsonl$/i, '.md')}`)
715
+ return 0
716
+ } finally {
717
+ rl?.close()
718
+ }
719
+ }
720
+
721
+ // ─── main ────────────────────────────────────────────────────────────────────
722
+
723
+ async function main(): Promise<void> {
724
+ const parsed = parseArgs(process.argv.slice(2))
725
+ if ('help' in parsed) {
726
+ printHelp()
727
+ process.exit(0)
728
+ }
729
+ if ('error' in parsed) {
730
+ console.error(`${BOLD}error:${RESET} ${parsed.error}`)
731
+ console.error(`Run with --help for usage.`)
732
+ process.exit(1)
733
+ }
734
+ const code = await runRoom(parsed)
735
+ process.exit(code)
736
+ }
737
+
738
+ main().catch(err => {
739
+ console.error(err)
740
+ process.exit(1)
741
+ })