clew-code 0.5.0 → 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.
- package/README.md +9 -3
- package/dist/main.js +2376 -2031
- package/package.json +164 -162
- 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
|
+
})
|