@b1anyu/arena 0.1.0 → 0.2.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/src/bridge.mjs CHANGED
@@ -18,30 +18,101 @@ ${body}
18
18
  请在当前沙箱权限内回应。要说话就 \`arena speak --seq <最新seq> "..."\`,要吃瓜就 \`arena pass\`,要取证就 \`arena scene ...\`,然后继续 \`arena listen\`。`;
19
19
  };
20
20
 
21
+ // spawn reports a missing binary as an `error` event, not a throw. Without
22
+ // this flag a missing `hc` looks like an ordinary failed send and the caller
23
+ // retries it forever.
24
+ const SPAWN_FAILURES = new Set(['ENOENT', 'EACCES', 'EISDIR', 'ENOTDIR']);
25
+
21
26
  const hc = (args, { input = null } = {}) =>
22
27
  new Promise((resolve) => {
23
28
  const p = spawn('hc', args, { stdio: [input ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
24
29
  let out = '';
25
30
  let err = '';
31
+ let spawnError = null;
26
32
  p.stdout.on('data', (d) => (out += d));
27
33
  p.stderr.on('data', (d) => (err += d));
28
34
  if (input) {
29
35
  p.stdin.end(input);
30
36
  }
31
- p.on('error', (e) => resolve({ code: -1, out, err: e.message }));
32
- p.on('close', (code) => resolve({ code, out, err }));
37
+ p.on('error', (e) => {
38
+ spawnError = e.code || 'SPAWN';
39
+ err = e.message;
40
+ });
41
+ p.on('close', (code) => resolve({ code, out, err, spawnError, missing: SPAWN_FAILURES.has(spawnError) }));
33
42
  });
34
43
 
35
- async function paneStatus(pane) {
44
+ /**
45
+ * Normalise what `hc get <pane> --json` reports.
46
+ *
47
+ * The status is a nested object (or null), never a bare string:
48
+ * working {"state":"live","status":{"app":"claude-code","state":"working"}}
49
+ * idle {"state":"live","status":{"app":"claude-code","state":"idle"}}
50
+ * done {"state":"live","status":{"app":"claude-code","state":"done"}}
51
+ * shell {"state":"live","status":null}
52
+ *
53
+ * `hc ls --json` flattens the same field to a bare string. We read `get`
54
+ * only, but the flattened shape is accepted defensively so the two can never
55
+ * be silently mixed up.
56
+ *
57
+ * Returns 'gone' | 'working' | 'idle' | 'done' | 'unknown'.
58
+ */
59
+ export function classifyPane(json) {
60
+ if (!json) return 'unknown';
61
+ if (json.state && json.state !== 'live') return 'gone';
62
+ const s = json.status;
63
+ if (s === null || s === undefined) return 'unknown';
64
+ const inner = typeof s === 'string' ? s : typeof s === 'object' ? s.state : null;
65
+ if (inner === 'working' || inner === 'idle' || inner === 'done') return inner;
66
+ return 'unknown';
67
+ }
68
+
69
+ /**
70
+ * hc refuses `--if idle` on a pane whose status is `done` — "t1 is done, not
71
+ * idle", exit 10, nothing typed. So the guard only goes on a real idle.
72
+ */
73
+ export function sendGuard(mode, unsafe) {
74
+ return mode === 'idle' && !unsafe ? ['--if', 'idle'] : [];
75
+ }
76
+
77
+ /**
78
+ * Classify an hc failure from its JSON error envelope.
79
+ *
80
+ * `missing` and `not_found` are terminal: retrying cannot fix either, and
81
+ * retrying `not_found` is what produced a 2-second infinite loop against a
82
+ * pane name that does not exist. `status_mismatch` is the only recoverable
83
+ * one — the agent went back to working while we were typing, so the batch is
84
+ * worth resending.
85
+ */
86
+ export function hcErrorKind(result) {
87
+ if (result?.missing) return 'missing';
88
+ const text = `${result?.err || ''}\n${result?.out || ''}`.trim();
89
+ if (!text) return null;
90
+ let kind = null;
91
+ try {
92
+ kind = JSON.parse(text.trim())?.error?.kind ?? null;
93
+ } catch {
94
+ // hc prints a plain sentence when the failure never reached the daemon.
95
+ }
96
+ if (kind === 'not_found' || kind === 'status_mismatch') return kind;
97
+ if (/status_mismatch|not idle|is silent/.test(text)) return 'status_mismatch';
98
+ return null;
99
+ }
100
+
101
+ async function readPane(pane) {
36
102
  const r = await hc(['get', pane, '--json']);
103
+ if (r.missing) return { kind: 'missing', state: r.spawnError };
104
+ if (hcErrorKind(r) === 'not_found') return { kind: 'gone', state: `no terminal "${pane}"` };
105
+ if (!r.out.trim()) return { kind: 'unknown', state: 'no-output' };
37
106
  try {
38
107
  const j = JSON.parse(r.out);
39
- return { state: j.state, status: j.status };
108
+ return { kind: classifyPane(j), state: j?.state ?? 'unknown' };
40
109
  } catch {
41
- return { state: 'unknown', status: null };
110
+ return { kind: 'unknown', state: 'unreadable' };
42
111
  }
43
112
  }
44
113
 
114
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
115
+
45
116
  /**
46
117
  * Follow a room's SSE stream and inject each new batch into a pane.
47
118
  * Injections are serialized and only happen when the pane is idle; anything
@@ -57,13 +128,30 @@ export async function runBridge({ base, code, token, agentId, pane, once = false
57
128
  sinceSeq = Number(st?.seq || 0);
58
129
  }
59
130
  const url = `${base.replace(/\/$/, '')}/r/${code}/stream?since=${sinceSeq}&token=${encodeURIComponent(token)}`;
60
- const res = await fetch(url, { headers: { accept: 'text/event-stream' } });
131
+ // Aborting the fetch is the only reliable way to interrupt an in-flight
132
+ // SSE read: calling cancel() on the body after `for await` has taken the
133
+ // stream throws, and a locked stream can't be cancelled at all.
134
+ const ctrl = new AbortController();
135
+ const res = await fetch(url, { headers: { accept: 'text/event-stream' }, signal: ctrl.signal });
61
136
  if (!res.ok) throw new Error(`stream refused: ${res.status}`);
62
137
  process.stderr.write(`[bridge] ${code} → hc pane ${pane}${dryRun ? ' (dry-run)' : ''}\n`);
63
138
 
64
139
  const buf = [];
65
140
  let busy = false;
66
- let closed = false;
141
+ let retryAt = 0;
142
+ // Set when the pane is gone, hc is missing, or the harness reports no
143
+ // status at all: none of those recover, so the stream is torn down instead
144
+ // of sitting forever.
145
+ let stop = null;
146
+
147
+ const stopBridge = (why) => {
148
+ if (stop) return;
149
+ stop = why;
150
+ process.stderr.write(`[bridge] ${why}\n`);
151
+ // Make stop take effect now: an idle SSE stream only yields on its 15s
152
+ // ping, so without this the process lingers long after deciding to quit.
153
+ ctrl.abort();
154
+ };
67
155
 
68
156
  const flush = async () => {
69
157
  if (busy || !buf.length) return;
@@ -75,34 +163,89 @@ export async function runBridge({ base, code, token, agentId, pane, once = false
75
163
  process.stdout.write(`\n── would send to ${pane} at seq ${msgs[msgs.length - 1].seq} ──\n${text}\n`);
76
164
  return;
77
165
  }
166
+ if (Date.now() < retryAt) {
167
+ buf.unshift(...msgs);
168
+ setTimeout(flush, retryAt - Date.now());
169
+ return;
170
+ }
78
171
  // Wait for the agent to finish its current turn, then type.
172
+ //
173
+ // `done` is ready too, but it is NOT idle: hc answers `--if idle` on a
174
+ // done pane with "t1 is done, not idle" (exit 10, nothing typed). So
175
+ // done is sent unguarded instead of waiting for the pane to drift back
176
+ // to idle, which may not happen for a long time. That is safe because
177
+ // done means the agent has already handed control back and is parked on
178
+ // its prompt — exactly the moment a human would type into it.
179
+ let mode = 'unknown';
79
180
  for (let i = 0; i < 120; i++) {
80
- const st = await paneStatus(pane);
81
- if (st.state !== 'live') {
82
- process.stderr.write(`[bridge] pane ${pane} is ${st.state}; stopping.\n`);
83
- closed = true;
181
+ const st = await readPane(pane);
182
+ mode = st.kind;
183
+ if (mode === 'missing') {
184
+ stopBridge(`没找到 hc(${st.state})。先装 herdr-core 并确认 PATH 里有 hc,再运行 arena bridge。`);
185
+ return;
186
+ }
187
+ if (mode === 'gone') {
188
+ stopBridge(`找不到终端 ${pane}(${st.state})。用 hc ls 确认名字后重跑 arena bridge。`);
84
189
  return;
85
190
  }
86
- if (unsafe || st.status !== 'working') break;
87
- await new Promise((r) => setTimeout(r, 1000));
191
+ if (i > 0 && i % 10 === 0) {
192
+ process.stderr.write(`[bridge] 正在等 ${pane} 空闲,已等 ${i} 秒…\n`);
193
+ }
194
+ // unsafe: send regardless of status. unknown: this harness reports no
195
+ // OSC 7501 status (a plain shell), so waiting can never succeed —
196
+ // fall through and let hc's own guard be the judge.
197
+ if (unsafe || mode !== 'working') break;
198
+ await sleep(1000);
199
+ }
200
+ if (!unsafe && mode === 'working') {
201
+ buf.unshift(...msgs);
202
+ retryAt = Date.now() + 5000;
203
+ process.stderr.write(`[bridge] ${pane} 忙了 120 秒还没回到空闲,这批 ${msgs.length} 条退回队列,5 秒后再试。\n`);
204
+ return;
205
+ }
206
+
207
+ const r = await hc(['send', ...sendGuard(mode, unsafe), pane, text]);
208
+ const kind = hcErrorKind(r);
209
+ if (kind === 'missing') {
210
+ // Checked before the exit code: a missing binary makes spawn report
211
+ // `error` and then `close(-2)`, so `code` can never be 0.
212
+ stopBridge(`没找到 hc(${r.spawnError})。先装 herdr-core 并确认 PATH 里有 hc,再运行 arena bridge。`);
213
+ return;
214
+ }
215
+ if (kind === 'not_found') {
216
+ // The pane name is wrong, or the terminal died after we started.
217
+ // Retrying can never fix it.
218
+ stopBridge(`找不到终端 ${pane}(not_found)。用 hc ls 确认名字后重跑 arena bridge。`);
219
+ return;
88
220
  }
89
- const r = await hc(['send', ...(unsafe ? [] : ['--if', 'idle']), pane, text]);
90
221
  if (r.code !== 0) {
91
222
  const err = r.err.trim();
92
- if (/status_mismatch|not idle|is silent/.test(err)) {
93
- process.stderr.write(
94
- `[bridge] ${pane} 没有报 agent 状态(普通 shell,或这个 harness 不发 OSC 7501)。\n` +
95
- ` 这是好事:说明 hc 不会在它干活时乱塞。如果你确定它此刻空闲,加 --no-if-idle 再跑。\n`,
223
+ // Only a real null status means "this harness has no OSC 7501 to
224
+ // report". Anything else was a genuine race — most likely the agent
225
+ // went from done/idle back to working while we were sending — and
226
+ // must not be reported as a missing-harness problem.
227
+ if (mode === 'unknown' && kind === 'status_mismatch') {
228
+ stopBridge(
229
+ `${pane} 没有报 agent 状态(普通 shell,或这个 harness 不发 OSC 7501)。` +
230
+ `这是好事:说明 hc 不会在它干活时乱塞。如果你确定它此刻空闲,加 --no-if-idle 再跑。`,
96
231
  );
97
- closed = true;
98
232
  return;
99
233
  }
100
- process.stderr.write(`[bridge] hc send failed: ${err || r.code}\n`);
234
+ // Put the batch back so the next flush carries it; a lost batch would
235
+ // mean the agent silently misses part of the argument.
236
+ buf.unshift(...msgs);
237
+ retryAt = Date.now() + 2000;
238
+ process.stderr.write(
239
+ `[bridge] 发送被拒(pane 状态 ${mode},退出码 ${r.code}),这批 ${msgs.length} 条已退回队列,2 秒后重试。${err ? ' ' + err : ''}\n`,
240
+ );
241
+ return;
101
242
  }
102
243
  else process.stderr.write(`[bridge] woke ${pane} with ${msgs.length} msg(s), up to #${msgs[msgs.length - 1].seq}\n`);
103
244
  } finally {
104
245
  busy = false;
105
- if (buf.length && !closed) setTimeout(flush, 500);
246
+ // A failed send deliberately does not stop a retry: the batch is back in
247
+ // buf and there is still work to do.
248
+ if (buf.length && !stop) setTimeout(flush, Math.max(500, retryAt - Date.now()));
106
249
  }
107
250
  };
108
251
 
@@ -110,39 +253,46 @@ export async function runBridge({ base, code, token, agentId, pane, once = false
110
253
  let acc = '';
111
254
  let mySince = sinceSeq;
112
255
 
113
- for await (const chunk of res.body) {
114
- acc += dec.decode(chunk, { stream: true });
115
- const parts = acc.split('\n\n');
116
- acc = parts.pop() || '';
117
- for (const part of parts) {
118
- const line = part.split('\n').find((l) => l.startsWith('data: '));
119
- if (!line) continue;
120
- let ev;
121
- try {
122
- ev = JSON.parse(line.slice(6));
123
- } catch {
124
- continue;
125
- }
126
- if (ev.hello) continue;
127
- if (ev.type === 'finished' || ev.type === 'finish') {
128
- process.stderr.write('[bridge] room finished\n');
129
- return;
130
- }
131
- if (ev.type === 'message' && ev.message) {
132
- const m = ev.message;
133
- if (m.from === agentId) {
134
- mySince = Math.max(mySince, m.seq);
256
+ try {
257
+ for await (const chunk of res.body) {
258
+ if (stop) return;
259
+ acc += dec.decode(chunk, { stream: true });
260
+ const parts = acc.split('\n\n');
261
+ acc = parts.pop() || '';
262
+ for (const part of parts) {
263
+ const line = part.split('\n').find((l) => l.startsWith('data: '));
264
+ if (!line) continue;
265
+ let ev;
266
+ try {
267
+ ev = JSON.parse(line.slice(6));
268
+ } catch {
135
269
  continue;
136
270
  }
137
- if (m.seq <= mySince || m.kind === 'whisper') continue; // whispers are for the owner's eyes
138
- buf.push(m);
139
- mySince = m.seq;
140
- flush();
141
- if (once) {
142
- await new Promise((r) => setTimeout(r, 400));
271
+ if (ev.hello) continue;
272
+ if (ev.type === 'finished' || ev.type === 'finish') {
273
+ process.stderr.write('[bridge] room finished\n');
143
274
  return;
144
275
  }
276
+ if (ev.type === 'message' && ev.message) {
277
+ const m = ev.message;
278
+ if (m.from === agentId) {
279
+ mySince = Math.max(mySince, m.seq);
280
+ continue;
281
+ }
282
+ if (m.seq <= mySince || m.kind === 'whisper') continue; // whispers are for the owner's eyes
283
+ buf.push(m);
284
+ mySince = m.seq;
285
+ flush();
286
+ if (once) {
287
+ await sleep(400);
288
+ return;
289
+ }
290
+ }
145
291
  }
292
+ if (stop) return;
146
293
  }
294
+ } catch (e) {
295
+ // An abort is how stopBridge unwinds; anything else is a real failure.
296
+ if (!stop) throw e;
147
297
  }
148
298
  }
package/src/cli.mjs CHANGED
@@ -137,15 +137,24 @@ function lanAddress() {
137
137
 
138
138
  const ETIQUETTE = `
139
139
  【自由混战房间规则】(进房自动生效)
140
- 1. 盯屏:调用 \`arena listen\` 阻塞等待。它是长轮询,返回自从你上次看到的最新消息(1.5 秒防抖后成批给你)。
140
+
141
+ ⚠️ 最重要的一条:这是一场"待机"游戏,不是"问答"游戏。
142
+ \`arena listen --loop\` 会一直阻塞到有人说话或房间收官。它返回一次之后,
143
+ 你必须立刻再跑一次 —— 房间不会因为你读完了就去通知你。
144
+ 如果你结束了这一轮却不再 listen,你就退出了这场辩论,后面的消息全部错过。
145
+ 所以除了下面两种情况,你的每个回合都应以 \`arena listen --loop\` 收尾:
146
+ a) 收到房间收官(finished),去写复盘;
147
+ b) 主人明确让你停止。
148
+
149
+ 1. 盯屏:\`arena listen --loop\`。长轮询,1.5 秒防抖后成批给你,返回最新 seq + 全场状态。
141
150
  2. 收到消息后,三选一:
142
- - 沉默吃瓜(pass):别人的话不涉及你的立场、或者队友已替你说 → 调 \`arena pass\`,然后立刻再 listen。绝不发"说得有道理"这种水贴。
143
- - 直接开火(speak):发现漏洞立刻 \`arena speak --seq <你看到的最新seq> "..."\`,200 字以内,犀利,说完马上 listen。
144
- - 蓄力放大招:需要翻代码/查日志拿证据 → 先 \`arena status "正在查 xxx"\`,取证后 \`arena speak --seq ... --evidence "..." "..."\`。带证据的发言不扣精力,还能回 1 点。
145
- 3. 被抢话:\`arena speak\` 若返回 Interrupted,说明你打字时别人插话了。读了 missed 再决定改口合并,或加 --force 原样发射。
151
+ - 沉默吃瓜(pass):不涉及你的立场、或队友已替你说 → \`arena pass\`,然后 \`arena listen --loop\`。绝不发"说得有道理"这种水贴。
152
+ - 直接开火(speak):发现漏洞立刻 \`arena speak --seq <最新seq> "..."\`,200 字以内,说完回到 listen。
153
+ - 蓄力放大招:要翻代码/查日志 → 先 \`arena status "正在查 xxx"\`,取证后 \`arena speak --seq ... --evidence "文件:行" "..."\`。带证据不扣精力,还回 1 点。
154
+ 3. 被抢话:\`arena speak\` 返回 Interrupted 说明你打字时别人插话了。读 missed 后改口合并,或加 --force 原样发射。
146
155
  4. 精力(MP)默认 8 点,纯观点扣 1,带证据不扣。耗尽后只能取证或 pass。
147
- 5. 可以看共享的"案发现场"(只读):\`arena scene list|read|grep|git|logs\`。
148
- 6. 结束:房主吹哨后你会收到 finished 事件,写一份你自己的《交锋复盘》给主人。`;
156
+ 5. 共享"案发现场"(只读):\`arena scene list|read|grep|git|logs\`。
157
+ 6. 收官:收到 finished 后,写给主人的《交锋复盘》,不再 listen。`;
149
158
 
150
159
  /**
151
160
  * The invite travels to a different machine, so it must not name a path that
@@ -178,7 +187,7 @@ function invitePrompt({ base, code, token, topic, scene, hostName, role }) {
178
187
  1. 进房(凭证会存到本机 ~/.arena,之后的命令自动读取):
179
188
  ${runner} join --url ${base} --room ${code} --token ${token} --name "<你的名字>-Agent" --role "${role || '你的立场角色'}"
180
189
  2. 进房后立刻进入盯屏循环(长轮询,等别人说话;有消息就直接开始干):
181
- ${runner} listen
190
+ ${runner} listen --loop
182
191
  ${sceneLine}
183
192
  ${ETIQUETTE}
184
193
 
@@ -222,7 +231,10 @@ function formatMessage(m) {
222
231
  export async function cmdHost(flags) {
223
232
  const port = Number(flags.port || 8787);
224
233
  const host = flags.ssh ? '127.0.0.1' : flags.bind || '0.0.0.0';
225
- const pageHtml = await fs.readFile(path.join(HERE, '..', 'public', 'index.html'), 'utf8');
234
+ const pageHtml = {
235
+ index: await fs.readFile(path.join(HERE, '..', 'public', 'index.html'), 'utf8').catch(() => null),
236
+ control: await fs.readFile(path.join(HERE, '..', 'public', 'control.html'), 'utf8').catch(() => null),
237
+ };
226
238
  const { server, url } = await startHub({ port, host, pageHtml });
227
239
  const shown = flags.publicUrl || (host === '127.0.0.1' ? url : `http://<this-machine-ip>:${port}`);
228
240
  print(paint ? C.green(`arena hub 已启动:${shown}`) : `arena hub 已启动:${shown}`);
@@ -254,7 +266,10 @@ async function ensureHub({ port, bind, advertise, tunnel, foreground }) {
254
266
  if (await ping()) return { base: advertise || `http://${lanAddress()}:${port}` };
255
267
 
256
268
  if (foreground) {
257
- const pageHtml = await fs.readFile(path.join(HERE, '..', 'public', 'index.html'), 'utf8').catch(() => null);
269
+ const pageHtml = {
270
+ index: await fs.readFile(path.join(HERE, '..', 'public', 'index.html'), 'utf8').catch(() => null),
271
+ control: await fs.readFile(path.join(HERE, '..', 'public', 'control.html'), 'utf8').catch(() => null),
272
+ };
258
273
  const started = await startHub({ port, host: bind, pageHtml });
259
274
  let base = advertise || (bind === '0.0.0.0' ? `http://${lanAddress()}:${port}` : started.url);
260
275
  if (tunnel) base = await startTunnel(started.url, tunnel, (line) => print(paint ? C.dim(line) : line));
@@ -289,6 +304,25 @@ export async function cmdCreate(flags) {
289
304
  if (pid) print(paint ? C.dim(`中枢已在后台运行(pid ${pid},端口 ${port})。`) : '');
290
305
 
291
306
  const scene = flags.scene ? path.resolve(flags.scene) : null;
307
+ if (!scene) {
308
+ // Without a scene the agents can only trade opinions. That is the single
309
+ // biggest gap between this product and a couple of chatbots arguing, so
310
+ // say it at the moment the decision is made — not in the README.
311
+ const warn =
312
+ '⚠ 没有挂载现场(--scene)。\n' +
313
+ ' Agent 只能靠通用知识吵,--evidence「带证据不扣精力」等于没有输入源。\n' +
314
+ ' 想让它们真刀真枪,重开一次并加上:--scene <代码或日志目录>';
315
+ print(paint ? C.yellow(warn) : warn);
316
+ print('');
317
+ } else {
318
+ // A scene that exists but holds three files makes --evidence just as
319
+ // hollow; the failure is quieter, so check rather than assume.
320
+ const thin = await sceneLooksThin(scene);
321
+ if (thin) {
322
+ print(paint ? C.yellow(`⚠ 现场很小(${thin})。证据不够,辩论会退化成对喷。`) : `⚠ 现场很小(${thin})`);
323
+ print('');
324
+ }
325
+ }
292
326
  const created = await fetch(`${base.replace(/\/$/, '')}/rooms`, {
293
327
  method: 'POST',
294
328
  headers: { 'content-type': 'application/json' },
@@ -327,6 +361,8 @@ export async function cmdCreate(flags) {
327
361
  print(paint ? C.green(`\n房间 ${code} 已开。议题:${topic}`) : `\n房间 ${code} 已开。议题:${topic}`);
328
362
  if (scene) print(paint ? C.dim(`现场(只读):${scene}`) : `现场(只读):${scene}`);
329
363
  print(paint ? C.dim(`中枢:${base} 观战页:${base}/`) : `中枢:${base}`);
364
+ print(paint ? C.yellow(`房主控制台:${base}/r/${code}/control?host=${hostToken}`) : `控制台:${base}/r/${code}/control?host=${hostToken}`);
365
+ print(paint ? C.dim(` (这个 URL 含房主令牌,别外发。随时用 arena console 重新打印)`) : '');
330
366
  print(paint ? C.dim(`你是 ${join.agentId}。房主令牌已保存在 ~/.arena/config.json(close 房间用)`) : '');
331
367
  print(paint ? C.magenta('\n──────────── 复制下面整段发给朋友 ────────────\n') : '\n---- 复制下面整段发给朋友 ----\n');
332
368
  print(invite);
@@ -338,6 +374,24 @@ export async function cmdCreate(flags) {
338
374
  }
339
375
  }
340
376
 
377
+ /**
378
+ * Rough evidence budget of a scene. Cheap on purpose: an agent that cannot
379
+ * cite a file, a log line or a diff has nothing to put in --evidence.
380
+ */
381
+ async function sceneLooksThin(root) {
382
+ const { Scene } = await import('./scene.mjs');
383
+ try {
384
+ const { files } = await new Scene(root).list('.', { depth: 3 });
385
+ const real = files.filter((f) => !f.endsWith('/'));
386
+ if (real.length === 0) return '一个文件都没有';
387
+ // Two files of log/code is enough to argue over; one is not an arena.
388
+ if (real.length < 2) return `只有 ${real.length} 个文件`;
389
+ return null;
390
+ } catch {
391
+ return null;
392
+ }
393
+ }
394
+
341
395
  async function ask(q) {
342
396
  print(q);
343
397
  return new Promise((resolve) => {
@@ -440,7 +494,7 @@ export async function cmdJoin(flags, positional) {
440
494
  print('');
441
495
  print(ETIQUETTE.trim());
442
496
  print('');
443
- print(paint ? C.cyan('现在运行:arena listen') : '现在运行:arena listen');
497
+ print(paint ? C.cyan('现在运行:arena listen --loop') : '现在运行:arena listen --loop');
444
498
  }
445
499
 
446
500
  export async function cmdListen(flags) {
@@ -476,14 +530,38 @@ export async function cmdListen(flags) {
476
530
  }
477
531
 
478
532
  if (j.timeout) {
479
- print(paint ? C.dim('(冷场中… 没有人说话。可以 pass,继续等,或者主动抛个靶子。)') : '(冷场)');
533
+ // A bare "nothing happened" is what teaches an agent to stop listening.
534
+ // Name who is silent and why it might be — an idle peer and a peer that
535
+ // crashed look identical in the member list otherwise.
536
+ const peers = (j.state?.members || []).filter((m) => m.id !== c.agentId && !m.removed);
537
+ const siblings = peers.filter((m) => m.status !== 'offline');
538
+ const stale = siblings.filter((m) => m.stale);
539
+ if (siblings.length && stale.length === siblings.length) {
540
+ const names = siblings.map((m) => m.name).join('、');
541
+ const mins = Math.max(1, Math.round((stale[0].lastSeenAgoMs || 0) / 60000));
542
+ const msg =
543
+ `(冷场:${names} 已经 ${mins} 分钟没有任何请求,多半没在监听。)\n` +
544
+ ` 可以 arena say "..." 递纸条,或让对方重新跑 arena listen --loop。`;
545
+ print(paint ? C.yellow(msg) : msg);
546
+ } else if (j.state?.members?.some((m) => m.status === 'working')) {
547
+ print(paint ? C.dim('(冷场:有人在取证,等它。继续挂:arena listen --loop)') : '(冷场,对方在忙)');
548
+ } else {
549
+ print(paint ? C.dim('(冷场:在场的人都还活着但没开口。继续挂:arena listen --loop)') : '(冷场)');
550
+ }
551
+ if (!flags.once && flags.loop) {
552
+ print(paint ? C.dim(` …继续挂在 listen(本次等了 ${timeout}s)`) : '');
553
+ await new Promise((r) => setTimeout(r, 500));
554
+ return cmdListen({ ...flags, since: String(q.since), loop: true, once: false });
555
+ }
480
556
  return;
481
557
  }
482
558
 
483
559
  const msgs = (j.messages || []).filter((m) => m.from !== c.agentId);
484
560
  const maxSeq = j.seq ?? since;
485
561
  const entry = await recall(c.code);
486
- await remember(c.code, { ...entry, seenSeq: Math.max(entry?.seenSeq || 0, since) });
562
+ // Advance to what we were just handed, not to where we started: storing
563
+ // `since` would replay the same batch on every subsequent listen.
564
+ await remember(c.code, { ...entry, seenSeq: Math.max(entry?.seenSeq || 0, maxSeq) });
487
565
 
488
566
  print(paint ? C.bold(`\n━━━ 房间里来了 ${msgs.length} 条新消息(当前 #${maxSeq})━━━`) : `\n--- 新消息 ${msgs.length} 条(#${maxSeq})---`);
489
567
  for (const m of msgs) print(formatMessage(m));
@@ -500,6 +578,61 @@ export async function cmdListen(flags) {
500
578
  print(` arena listen ← 吃瓜,继续等`);
501
579
  }
502
580
 
581
+ /**
582
+ * Non-blocking snapshot. `listen` blocks for a minute and `watch` never
583
+ * returns; neither is usable when a human just wants to see what happened
584
+ * before deciding whether to keep going.
585
+ */
586
+ export async function cmdPeek(flags) {
587
+ const c = await clientFor(flags);
588
+ const entry = await recall(c.code);
589
+ const seen = Number(entry?.seenSeq || 0);
590
+ const r = await fetch(c.url('/state'), {
591
+ method: 'POST',
592
+ headers: { 'content-type': 'application/json', 'x-arena-token': c.token },
593
+ body: JSON.stringify({}),
594
+ }).then((x) => x.json());
595
+ if (r.error) throw new Error(r.error);
596
+
597
+ const unread = [];
598
+ if (flags.tail) {
599
+ const all = await fetch(c.url('/read', { since: 0 }), { headers: { 'x-arena-token': c.token } }).then((x) => x.json());
600
+ unread.push(...(all.messages || []).slice(-Number(flags.tail)));
601
+ }
602
+
603
+ print(paint ? C.bold(`房间 ${r.code} ${r.closed ? '【已收官】' : ''}`) : `房间 ${r.code}`);
604
+ print(`议题:${r.topic}`);
605
+ print(`进度:#${r.seq} 你已读到:#${seen} 未读:${Math.max(0, r.seq - seen)} 条`);
606
+ if (r.scene?.root) print(`现场:${r.scene.root}`);
607
+ print('');
608
+
609
+ for (const m of r.members || []) {
610
+ const tags = [
611
+ m.isHost ? '★房主' : '',
612
+ m.muted ? '禁言' : '',
613
+ m.removed ? '已移出' : '',
614
+ m.stale ? `失联 ${Math.round((m.lastSeenAgoMs || 0) / 60000)}m` : '',
615
+ ].filter(Boolean);
616
+ const you = m.id === c.agentId ? ' ← 你' : '';
617
+ print(` ${m.name.padEnd(18)} [${m.role}] MP ${String(m.mp).padStart(2)} ${m.status}${m.note ? ' · ' + m.note : ''}${tags.length ? ' · ' + tags.join(' ') : ''}${you}`);
618
+ }
619
+ if (r.board?.length) {
620
+ print('');
621
+ print(paint ? C.dim(`黑板:${r.board.map((b) => `${b.key} v${b.version}`).join(' ')}`) : '');
622
+ }
623
+ if (unread.length) {
624
+ print('');
625
+ print(paint ? C.dim(`最后 ${unread.length} 条:`) : '');
626
+ for (const m of unread) print(formatMessage(m));
627
+ }
628
+ if (r.conclusion) {
629
+ print('');
630
+ print(paint ? C.yellow(`收官结论:${r.conclusion}`) : `结论:${r.conclusion}`);
631
+ }
632
+ print('');
633
+ print(paint ? C.dim('继续盯屏:arena listen --loop') : '');
634
+ }
635
+
503
636
  export async function cmdSpeak(flags, positional) {
504
637
  const c = await clientFor(flags);
505
638
  const text = flags.text || positional.join(' ');
@@ -704,6 +837,14 @@ export async function cmdBridge(flags, positional) {
704
837
  });
705
838
  }
706
839
 
840
+ export async function cmdConsole(flags) {
841
+ const entry = await recall(flags.room?.toUpperCase());
842
+ if (!entry) throw new Error('还没进房:先 arena create 或 arena join');
843
+ if (!entry.hostToken) throw new Error('你不是房主,没有控制台');
844
+ print(`${entry.base.replace(/\/$/, '')}/r/${entry.code}/control?host=${entry.hostToken}`);
845
+ print(paint ? C.dim('(含房主令牌,不要发给别人)') : '');
846
+ }
847
+
707
848
  export async function cmdClose(flags) {
708
849
  const c = await clientFor(flags, { host: true });
709
850
  const r = await c.post('/finish', { agent: c.agentId, reason: flags.reason || '房主关房', conclusion: flags.conclusion || flags.text });
@@ -764,6 +905,9 @@ export const HELP = `arena — 多人 Agent 实时擂台(房主开房,朋友
764
905
 
765
906
  非回合制循环(每个参赛 Agent)
766
907
  arena listen [--since N] [--timeout 60] 阻塞等新消息;返回最新 seq 与全场状态
908
+ arena listen --loop 冷场自动重挂,直到有消息或房间收官
909
+ arena listen --once 超时就直接返回(不提示继续)
910
+ arena peek [--tail 5] 不阻塞看一眼:谁在、多少 MP、未读多少、最后几条
767
911
  arena bridge --pane t3 [--dry-run] 装了 herdr-core 时:房间消息自动敲进空闲终端唤醒你
768
912
  arena speak --seq N "..." 开火;seq 是你看到的最新序号(乐观锁)
769
913
  arena speak --seq N --evidence "..." "..." 带实证开火,不扣精力、回 1 MP
@@ -782,6 +926,7 @@ export const HELP = `arena — 多人 Agent 实时擂台(房主开房,朋友
782
926
  收官
783
927
  arena finish [--reason "..."] [--conclusion "..."] [--report battle.md] # 房主吹哨
784
928
  arena close [--reason "..."] 房主关房(host token)
929
+ arena console 打印房主控制台 URL(改规则/禁言/移人/收官)
785
930
  arena rooms 列出中枢上的房间
786
931
  arena watch 人类教练实时观战(终端)
787
932
  arena say "..." 场外递纸条
@@ -805,8 +950,10 @@ export async function main(argv) {
805
950
  host: () => cmdHost(flags),
806
951
  create: () => cmdCreate(flags),
807
952
  close: () => cmdClose(flags),
953
+ console: () => cmdConsole(flags),
808
954
  join: () => cmdJoin(flags, positional),
809
955
  listen: () => cmdListen(flags),
956
+ peek: () => cmdPeek(flags),
810
957
  bridge: () => cmdBridge(flags, positional),
811
958
  speak: () => cmdSpeak(flags, positional),
812
959
  pass: () => cmdPass(flags, positional),