@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/AGENTS.md +107 -0
- package/README.md +41 -4
- package/SKILL.md +61 -5
- package/docs/PROTOCOL.md +541 -0
- package/package.json +4 -2
- package/public/control.html +226 -0
- package/public/index.html +116 -15
- package/src/bridge.mjs +199 -49
- package/src/cli.mjs +160 -13
- package/src/room.mjs +144 -12
- package/src/scene.mjs +21 -0
- package/src/server.mjs +89 -22
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) =>
|
|
32
|
-
|
|
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
|
-
|
|
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 {
|
|
108
|
+
return { kind: classifyPane(j), state: j?.state ?? 'unknown' };
|
|
40
109
|
} catch {
|
|
41
|
-
return {
|
|
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
|
-
|
|
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
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 (
|
|
87
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
ev
|
|
123
|
-
|
|
124
|
-
|
|
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 (
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
- 直接开火(speak):发现漏洞立刻 \`arena speak --seq
|
|
144
|
-
-
|
|
145
|
-
3. 被抢话:\`arena speak\`
|
|
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.
|
|
148
|
-
6.
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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),
|