@lythos/skill-arena 0.17.11 → 0.18.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 +12 -12
- package/package.json +7 -7
- package/src/cli.test.ts +7 -3
- package/src/cli.ts +51 -2
- package/src/host.test.ts +174 -0
- package/src/host.ts +52 -0
- package/src/player.test.ts +17 -1
- package/src/player.ts +14 -0
package/README.md
CHANGED
|
@@ -16,20 +16,20 @@
|
|
|
16
16
|
```bash
|
|
17
17
|
bun add -d @lythos/skill-arena
|
|
18
18
|
# or use directly
|
|
19
|
-
bunx @lythos/skill-arena@0.
|
|
19
|
+
bunx @lythos/skill-arena@0.18.0 <command>
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
## Quick Start
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
# single — test one deck (most common)
|
|
26
|
-
bunx @lythos/skill-arena@0.
|
|
26
|
+
bunx @lythos/skill-arena@0.18.0 single \
|
|
27
27
|
--deck ./examples/decks/scout.toml \
|
|
28
28
|
--brief "Generate auth flow diagram" \
|
|
29
29
|
--out ./output
|
|
30
30
|
|
|
31
31
|
# single with explicit player
|
|
32
|
-
bunx @lythos/skill-arena@0.
|
|
32
|
+
bunx @lythos/skill-arena@0.18.0 single \
|
|
33
33
|
--deck ./examples/decks/scout.toml \
|
|
34
34
|
--brief "Generate auth flow diagram" \
|
|
35
35
|
--player kimi \
|
|
@@ -37,10 +37,10 @@ bunx @lythos/skill-arena@0.17.11 single \
|
|
|
37
37
|
|
|
38
38
|
# cross-deck vs — compare two decks (agent-orchestrated)
|
|
39
39
|
# Create arena.toml declaring sides with different decks, then:
|
|
40
|
-
bunx @lythos/skill-arena@0.
|
|
40
|
+
bunx @lythos/skill-arena@0.18.0 vs --config ./arena.toml
|
|
41
41
|
|
|
42
42
|
# cross-player vs — compare kimi vs codex (CLI only)
|
|
43
|
-
bunx @lythos/skill-arena@0.
|
|
43
|
+
bunx @lythos/skill-arena@0.18.0 vs --config ./arena.toml --player kimi
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
**What happens**: Agent creates isolated `/tmp` workdir per side, `deck link` skills, spawns parallel subagents, collects artifacts, judge scores outputs. Parent deck restored after.
|
|
@@ -50,14 +50,14 @@ bunx @lythos/skill-arena@0.17.11 vs --config ./arena.toml --player kimi
|
|
|
50
50
|
### `single` — one deck, one task
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
bunx @lythos/skill-arena@0.
|
|
53
|
+
bunx @lythos/skill-arena@0.18.0 single \
|
|
54
54
|
--deck ./deck.toml \
|
|
55
55
|
--brief "Produce a .docx report with radar chart" \
|
|
56
56
|
--timeout 600000 \
|
|
57
57
|
--out ./output
|
|
58
58
|
|
|
59
59
|
# with explicit player
|
|
60
|
-
bunx @lythos/skill-arena@0.
|
|
60
|
+
bunx @lythos/skill-arena@0.18.0 single \
|
|
61
61
|
--deck ./deck.toml \
|
|
62
62
|
--brief "Produce a .docx report with radar chart" \
|
|
63
63
|
--player kimi \
|
|
@@ -67,14 +67,14 @@ bunx @lythos/skill-arena@0.17.11 single \
|
|
|
67
67
|
### `vs` — multi-deck comparison
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
bunx @lythos/skill-arena@0.
|
|
71
|
-
bunx @lythos/skill-arena@0.
|
|
70
|
+
bunx @lythos/skill-arena@0.18.0 vs --config ./arena.toml
|
|
71
|
+
bunx @lythos/skill-arena@0.18.0 vs --config ./arena.toml --dry-run
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
### `prepare-workdir` — isolate + link skills (agent-orchestrated)
|
|
75
75
|
|
|
76
76
|
```bash
|
|
77
|
-
bunx @lythos/skill-arena@0.
|
|
77
|
+
bunx @lythos/skill-arena@0.18.0 prepare-workdir \
|
|
78
78
|
--deck ./skill-deck.toml \
|
|
79
79
|
--out /tmp/arena-side-a \
|
|
80
80
|
--brief "task description"
|
|
@@ -85,7 +85,7 @@ Creates `/tmp`-isolated workdir with deck copied, AGENTS.md written, and `deck l
|
|
|
85
85
|
### `archive` — collect agent outputs (agent-orchestrated)
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
|
-
bunx @lythos/skill-arena@0.
|
|
88
|
+
bunx @lythos/skill-arena@0.18.0 archive \
|
|
89
89
|
--from /tmp/arena-side-a \
|
|
90
90
|
--to ./playground/output \
|
|
91
91
|
--sides side-a
|
|
@@ -96,7 +96,7 @@ Copies agent artifacts from workdir(s) to output, skipping internal files (`.cla
|
|
|
96
96
|
### `viz` — render results (WIP — HTML report generation pending)
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
|
-
bunx @lythos/skill-arena@0.
|
|
99
|
+
bunx @lythos/skill-arena@0.18.0 viz runs/arena-<id>/
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
## Parameters
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/skill-arena",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Skill Arena — benchmark skill effectiveness with controlled-variable comparison",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -42,15 +42,15 @@
|
|
|
42
42
|
"bun": ">=1.0.0"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@lythos/cold-pool": "^0.
|
|
46
|
-
"@lythos/infra": "^0.
|
|
47
|
-
"@lythos/test-utils": "^0.
|
|
45
|
+
"@lythos/cold-pool": "^0.18.0",
|
|
46
|
+
"@lythos/infra": "^0.18.0",
|
|
47
|
+
"@lythos/test-utils": "^0.18.0",
|
|
48
48
|
"zod": "^3.24.0",
|
|
49
49
|
"zod-to-json-schema": "^3.25.2"
|
|
50
50
|
},
|
|
51
51
|
"optionalDependencies": {
|
|
52
|
-
"@lythos/agent-adapter-claude-sdk": "^0.
|
|
53
|
-
"@lythos/agent-adapter-deepseek-serve": "^0.
|
|
54
|
-
"@lythos/agent-adapter-codex": "^0.
|
|
52
|
+
"@lythos/agent-adapter-claude-sdk": "^0.18.0",
|
|
53
|
+
"@lythos/agent-adapter-deepseek-serve": "^0.18.0",
|
|
54
|
+
"@lythos/agent-adapter-codex": "^0.18.0"
|
|
55
55
|
}
|
|
56
56
|
}
|
package/src/cli.test.ts
CHANGED
|
@@ -74,10 +74,14 @@ describe('main', () => {
|
|
|
74
74
|
})
|
|
75
75
|
|
|
76
76
|
describe('singleRun', () => {
|
|
77
|
+
// Validation-order tests pin --player to force external mode: since
|
|
78
|
+
// TASK-20260828141622671, mode resolution (host handoff vs no-player error)
|
|
79
|
+
// precedes flag validation, and the host env of the machine running the
|
|
80
|
+
// tests would otherwise change which error fires (CI has no host markers).
|
|
77
81
|
it('errors when --deck is missing', async () => {
|
|
78
82
|
const { io, logs, errors } = mockIO()
|
|
79
83
|
const exitCode = await catchExitAsync(async () => {
|
|
80
|
-
await main(['single', '--brief', 'test task'], io)
|
|
84
|
+
await main(['single', '--brief', 'test task', '--player', 'kimi'], io)
|
|
81
85
|
})
|
|
82
86
|
expect(exitCode).toBe(1)
|
|
83
87
|
expect(errors.length).toBeGreaterThan(0)
|
|
@@ -88,7 +92,7 @@ describe('singleRun', () => {
|
|
|
88
92
|
it('errors when both --task and --brief are missing', async () => {
|
|
89
93
|
const { io, logs, errors } = mockIO()
|
|
90
94
|
const exitCode = await catchExitAsync(async () => {
|
|
91
|
-
await main(['single', '--deck', './some-deck.toml'], io)
|
|
95
|
+
await main(['single', '--deck', './some-deck.toml', '--player', 'kimi'], io)
|
|
92
96
|
})
|
|
93
97
|
expect(exitCode).toBe(1)
|
|
94
98
|
expect(errors.length).toBeGreaterThan(0)
|
|
@@ -99,7 +103,7 @@ describe('singleRun', () => {
|
|
|
99
103
|
it('errors when --brief is empty string', async () => {
|
|
100
104
|
const { io, logs, errors } = mockIO()
|
|
101
105
|
const exitCode = await catchExitAsync(async () => {
|
|
102
|
-
await main(['single', '--deck', './some-deck.toml', '--brief', ''], io)
|
|
106
|
+
await main(['single', '--deck', './some-deck.toml', '--brief', '', '--player', 'kimi'], io)
|
|
103
107
|
})
|
|
104
108
|
expect(exitCode).toBe(1)
|
|
105
109
|
expect(errors.length).toBeGreaterThan(0)
|
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { formatPlanOutput, type ArenaResult, buildArenaPrompt } from './runner'
|
|
|
7
7
|
import { parseArenaToml, buildExecutionPlan } from './arena-toml'
|
|
8
8
|
import { buildArchiveSidePlan, buildCopyPlan, buildPreparePlan, parseDeckSkills } from './preflight'
|
|
9
9
|
import { checkSkillExistence, formatSkillWarnings, resolveColdPoolDir } from './preflight'
|
|
10
|
+
import { resolveSingleMode } from './host'
|
|
10
11
|
|
|
11
12
|
// ─── ArenaCliIO interface (injected for testability) ───────────────────────
|
|
12
13
|
|
|
@@ -111,6 +112,10 @@ Commands:
|
|
|
111
112
|
vs Compare decks via arena.toml (declarative, Pareto-optimal)
|
|
112
113
|
viz Visualize a completed arena run (HTML + chart)
|
|
113
114
|
|
|
115
|
+
Modes (single):
|
|
116
|
+
Inside an agent session → host-handoff guidance (default; no external spawn)
|
|
117
|
+
Anywhere else → pass --player <name> (kimi|kimi-code|codex|claude|deepseek)
|
|
118
|
+
|
|
114
119
|
Examples:
|
|
115
120
|
lythoskill-arena single --brief "find and research" --deck ./decks/scout.toml
|
|
116
121
|
lythoskill-arena single --brief "find and research" --deck https://raw.githubusercontent.com/lythos-labs/lythoskill/main/examples/decks/scout.toml
|
|
@@ -154,6 +159,24 @@ async function singleRun(args: string[], io: ArenaCliIO) {
|
|
|
154
159
|
else if (args[i] === '--timeout') opts.timeout = args[++i]
|
|
155
160
|
}
|
|
156
161
|
|
|
162
|
+
if (opts.player !== undefined && !opts.player) {
|
|
163
|
+
error(`❌ --player requires a non-empty name (e.g. --player kimi).
|
|
164
|
+
Player setup: skills/lythoskill-arena/references/player-setup.md`)
|
|
165
|
+
exit(1)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Mode resolution comes FIRST (ADR-20260828004129143): with no --player, the
|
|
169
|
+
// relevant failure/guidance is about execution mode, not about flags.
|
|
170
|
+
const mode = resolveSingleMode(opts.player, process.env)
|
|
171
|
+
if (mode.mode === 'no-player') {
|
|
172
|
+
error(`❌ No --player given and no agent host detected.
|
|
173
|
+
arena single runs in one of two modes:
|
|
174
|
+
1. Inside an agent session → host-handoff is the default (the host orchestrates the run).
|
|
175
|
+
2. Anywhere else → pass --player <name> to spawn an external CLI.
|
|
176
|
+
Player setup: skills/lythoskill-arena/references/player-setup.md`)
|
|
177
|
+
exit(1)
|
|
178
|
+
}
|
|
179
|
+
|
|
157
180
|
if (!opts.deck) {
|
|
158
181
|
error(`❌ --deck <path|url> is required.
|
|
159
182
|
--deck accepts local paths and http/https URLs (auto-fetched).
|
|
@@ -218,6 +241,30 @@ async function singleRun(args: string[], io: ArenaCliIO) {
|
|
|
218
241
|
}
|
|
219
242
|
}
|
|
220
243
|
|
|
244
|
+
// Host-handoff: print HATEOAS guidance and return BEFORE any fetch/spawn —
|
|
245
|
+
// no-spawn is structural (adapter dynamic imports below are never reached,
|
|
246
|
+
// no agent-output-* dir is ever created).
|
|
247
|
+
if (mode.mode === 'handoff') {
|
|
248
|
+
// Fail fast on a bogus LOCAL deck path — the host agent would discover it
|
|
249
|
+
// later otherwise. URLs are passed through (fetch validation is the host's).
|
|
250
|
+
if (!/^https?:\/\//.test(opts.deck!) && !existsSync(resolve(opts.deck!))) {
|
|
251
|
+
error(`❌ Deck file not found: ${resolve(opts.deck!)}
|
|
252
|
+
Fix the path (or pass a URL) before handing off — the host agent needs a real deck.`)
|
|
253
|
+
exit(1)
|
|
254
|
+
}
|
|
255
|
+
log(`🤝 Host-handoff mode — agent session detected (${mode.host.host}${mode.host.marker ? ` via ${mode.host.marker}` : ''})
|
|
256
|
+
|
|
257
|
+
What: arena single is running inside an agent session and hands this run back to the host.
|
|
258
|
+
Why: host-orchestrated runs are the default (ADR-20260828004129143) — the host agent
|
|
259
|
+
spawns subagents per deck and judges outputs itself; no external player CLI needed.
|
|
260
|
+
How: 1. In a subagent workdir, link the deck: bunx @lythos/skill-deck link --deck ${opts.deck}
|
|
261
|
+
2. Spawn a subagent with that workdir and this ${opts.task ? `task: ${opts.task}` : `brief: ${opts.brief}`}
|
|
262
|
+
3. Judge the subagent output(s) against the brief.
|
|
263
|
+
Docs: skills/lythoskill-arena/references/arena-runtime.md
|
|
264
|
+
Override: pass --player <name> to force an external CLI run.`)
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
|
|
221
268
|
const { existsSync: deckExists, writeFileSync: deckWrite } = await import('node:fs')
|
|
222
269
|
let deckPath: string
|
|
223
270
|
if (opts.deck.startsWith('http://') || opts.deck.startsWith('https://')) {
|
|
@@ -271,10 +318,12 @@ async function singleRun(args: string[], io: ArenaCliIO) {
|
|
|
271
318
|
try { await import('@lythos/agent-adapter-claude-sdk') } catch { /* package not installed */ }
|
|
272
319
|
try { await import('@lythos/agent-adapter-deepseek-serve') } catch { /* package not installed */ }
|
|
273
320
|
try { await import('@lythos/agent-adapter-codex') } catch { /* package not installed */ }
|
|
274
|
-
const { resolvePlayer } = await import('./player')
|
|
321
|
+
const { resolvePlayer, playerAliasNote } = await import('./player')
|
|
275
322
|
|
|
276
|
-
const player = resolvePlayer(
|
|
323
|
+
const player = resolvePlayer(mode.player)
|
|
277
324
|
const agent = useAgent(player)
|
|
325
|
+
const aliasNote = playerAliasNote(mode.player)
|
|
326
|
+
if (aliasNote) log(`ℹ️ ${aliasNote}`)
|
|
278
327
|
const outDir = opts.out ? resolve(opts.out) : join(process.cwd(), `agent-output-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`)
|
|
279
328
|
mkdirSync(outDir, { recursive: true })
|
|
280
329
|
|
package/src/host.test.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
import { detectHost, resolveSingleMode } from './host'
|
|
6
|
+
import { main, type ArenaCliIO } from './cli'
|
|
7
|
+
|
|
8
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const HOST_VARS = ['CLAUDECODE', 'CLAUDE_CODE_SSE_PORT'] as const
|
|
11
|
+
|
|
12
|
+
// Tests run inside real agent sessions (kimi-code sets CLAUDE_CODE_SSE_PORT) —
|
|
13
|
+
// every test pins the host markers explicitly instead of inheriting them.
|
|
14
|
+
function withEnv<T>(vars: Record<string, string | undefined>, fn: () => T): T {
|
|
15
|
+
const saved = new Map<string, string | undefined>()
|
|
16
|
+
for (const k of HOST_VARS) { saved.set(k, process.env[k]); delete process.env[k] }
|
|
17
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
18
|
+
if (v === undefined) delete process.env[k]
|
|
19
|
+
else process.env[k] = v
|
|
20
|
+
}
|
|
21
|
+
try { return fn() } finally {
|
|
22
|
+
for (const [k, v] of saved.entries()) {
|
|
23
|
+
if (v === undefined) delete process.env[k]
|
|
24
|
+
else process.env[k] = v
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function catchExitAsync(fn: () => Promise<void>): Promise<number | undefined> {
|
|
30
|
+
let exitCode: number | undefined
|
|
31
|
+
try { await fn() } catch (e: any) {
|
|
32
|
+
if (!String(e).includes('EXIT:')) throw e
|
|
33
|
+
const m = String(e).match(/EXIT:(\d+)/)
|
|
34
|
+
if (m) exitCode = parseInt(m[1], 10)
|
|
35
|
+
}
|
|
36
|
+
return exitCode
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mockIO() {
|
|
40
|
+
const logs: string[] = []
|
|
41
|
+
const errors: string[] = []
|
|
42
|
+
const io: Required<ArenaCliIO> = {
|
|
43
|
+
log: (msg: string) => logs.push(msg),
|
|
44
|
+
error: (msg: string) => errors.push(msg),
|
|
45
|
+
warn: () => {},
|
|
46
|
+
exit: (code: number) => { throw new Error(`EXIT:${code}`) },
|
|
47
|
+
}
|
|
48
|
+
return { io, logs, errors }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const NO_HOST = { CLAUDECODE: undefined, CLAUDE_CODE_SSE_PORT: undefined } as const
|
|
52
|
+
const QUICK_START_DECK = resolve(import.meta.dir, '..', '..', '..', 'examples', 'decks', 'quick-start.toml')
|
|
53
|
+
|
|
54
|
+
// ─── detectHost ─────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe('detectHost', () => {
|
|
57
|
+
it('detects Claude Code via CLAUDECODE', () => {
|
|
58
|
+
const d = detectHost({ CLAUDECODE: '1' } as NodeJS.ProcessEnv)
|
|
59
|
+
expect(d.detected).toBe(true)
|
|
60
|
+
expect(d.host).toBe('Claude Code')
|
|
61
|
+
expect(d.marker).toBe('CLAUDECODE')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('detects claude-code forks (incl. kimi-code) via CLAUDE_CODE_SSE_PORT as unidentified', () => {
|
|
65
|
+
const d = detectHost({ CLAUDE_CODE_SSE_PORT: '12345' } as NodeJS.ProcessEnv)
|
|
66
|
+
expect(d.detected).toBe(true)
|
|
67
|
+
expect(d.host).toBe('agent host (unidentified)')
|
|
68
|
+
expect(d.marker).toBe('CLAUDE_CODE_SSE_PORT')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('prefers the specific CLAUDECODE marker when both are set', () => {
|
|
72
|
+
const d = detectHost({ CLAUDECODE: '1', CLAUDE_CODE_SSE_PORT: '1' } as NodeJS.ProcessEnv)
|
|
73
|
+
expect(d.host).toBe('Claude Code')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('reports no host when no markers are present', () => {
|
|
77
|
+
const d = detectHost({} as NodeJS.ProcessEnv)
|
|
78
|
+
expect(d.detected).toBe(false)
|
|
79
|
+
expect(d.host).toBe('none')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// ─── resolveSingleMode: 4-cell env/flag matrix ─────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe('resolveSingleMode (4-cell matrix)', () => {
|
|
86
|
+
it('host detected + --player given → external (explicit player always wins)', () => {
|
|
87
|
+
const m = resolveSingleMode('codex', { CLAUDE_CODE_SSE_PORT: '1' } as NodeJS.ProcessEnv)
|
|
88
|
+
expect(m).toEqual({ mode: 'external', player: 'codex' })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('no host + --player given → external', () => {
|
|
92
|
+
const m = resolveSingleMode('kimi', {} as NodeJS.ProcessEnv)
|
|
93
|
+
expect(m).toEqual({ mode: 'external', player: 'kimi' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('host detected + no --player → handoff', () => {
|
|
97
|
+
const m = resolveSingleMode(undefined, { CLAUDECODE: '1' } as NodeJS.ProcessEnv)
|
|
98
|
+
expect(m.mode).toBe('handoff')
|
|
99
|
+
if (m.mode === 'handoff') expect(m.host.host).toBe('Claude Code')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('no host + no --player → no-player (loud error upstream)', () => {
|
|
103
|
+
const m = resolveSingleMode(undefined, {} as NodeJS.ProcessEnv)
|
|
104
|
+
expect(m.mode).toBe('no-player')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// ─── CLI integration ────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe('singleRun host-handoff integration', () => {
|
|
111
|
+
let workCwd: string
|
|
112
|
+
let origCwd: string
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
origCwd = process.cwd()
|
|
116
|
+
workCwd = mkdtempSync(join(tmpdir(), 'arena-handoff-'))
|
|
117
|
+
process.chdir(workCwd)
|
|
118
|
+
})
|
|
119
|
+
afterEach(() => { process.chdir(origCwd) })
|
|
120
|
+
|
|
121
|
+
it('host detected, no --player → prints handoff guidance, spawns nothing, exits 0', async () => {
|
|
122
|
+
await withEnv({ CLAUDE_CODE_SSE_PORT: '1' }, async () => {
|
|
123
|
+
const { io, logs, errors } = mockIO()
|
|
124
|
+
const exitCode = await catchExitAsync(async () => {
|
|
125
|
+
await main(['single', '--deck', QUICK_START_DECK, '--brief', 'test'], io)
|
|
126
|
+
})
|
|
127
|
+
// clean return (no exit throw) = exit 0
|
|
128
|
+
expect(exitCode).toBeUndefined()
|
|
129
|
+
expect(errors.length).toBe(0)
|
|
130
|
+
const out = logs.join('\n')
|
|
131
|
+
expect(out).toContain('Host-handoff mode')
|
|
132
|
+
expect(out).toContain('arena-runtime.md')
|
|
133
|
+
expect(out).toContain(QUICK_START_DECK)
|
|
134
|
+
// Structural no-spawn: branch returns before outDir creation
|
|
135
|
+
expect(readdirSync(workCwd).filter(e => e.startsWith('agent-output-'))).toEqual([])
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('no host, no --player → loud error pointing at player-setup.md, exit 1', async () => {
|
|
140
|
+
await withEnv(NO_HOST, async () => {
|
|
141
|
+
const { io, errors } = mockIO()
|
|
142
|
+
const exitCode = await catchExitAsync(async () => {
|
|
143
|
+
await main(['single', '--brief', 'x'], io)
|
|
144
|
+
})
|
|
145
|
+
expect(exitCode).toBe(1)
|
|
146
|
+
expect(errors[0]).toContain('player-setup.md')
|
|
147
|
+
expect(errors[0]).toContain('--player')
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('handoff mode fails fast on a bogus LOCAL deck path (no guidance, exit 1)', async () => {
|
|
152
|
+
await withEnv({ CLAUDE_CODE_SSE_PORT: '1' }, async () => {
|
|
153
|
+
const { io, logs, errors } = mockIO()
|
|
154
|
+
const exitCode = await catchExitAsync(async () => {
|
|
155
|
+
await main(['single', '--deck', './no-such-deck.toml', '--brief', 'x'], io)
|
|
156
|
+
})
|
|
157
|
+
expect(exitCode).toBe(1)
|
|
158
|
+
expect(errors[0]).toContain('Deck file not found')
|
|
159
|
+
expect(logs.join('\n')).not.toContain('Host-handoff mode')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('empty --player "" is rejected loudly, never falls into handoff', async () => {
|
|
164
|
+
await withEnv({ CLAUDE_CODE_SSE_PORT: '1' }, async () => {
|
|
165
|
+
const { io, logs, errors } = mockIO()
|
|
166
|
+
const exitCode = await catchExitAsync(async () => {
|
|
167
|
+
await main(['single', '--deck', QUICK_START_DECK, '--brief', 'x', '--player', ''], io)
|
|
168
|
+
})
|
|
169
|
+
expect(exitCode).toBe(1)
|
|
170
|
+
expect(errors[0]).toContain('--player requires a non-empty name')
|
|
171
|
+
expect(logs.join('\n')).not.toContain('Host-handoff mode')
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
})
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Host detection + single-mode resolution for arena host-handoff mode.
|
|
2
|
+
// Implements ADR-20260828004129143 (Option B): inside an agent session,
|
|
3
|
+
// `arena single` without --player hands off to the host agent instead of
|
|
4
|
+
// shelling out to a hardcoded external player.
|
|
5
|
+
//
|
|
6
|
+
// Pure + injectable: always pass `env` explicitly in tests.
|
|
7
|
+
|
|
8
|
+
export interface HostDetection {
|
|
9
|
+
detected: boolean
|
|
10
|
+
/** Display name — a known host, or a generic label when the fork is unidentified. */
|
|
11
|
+
host: string
|
|
12
|
+
/** The env var that fired (for diagnostics). Undefined when not detected. */
|
|
13
|
+
marker?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Known agent-host env markers, most specific first.
|
|
17
|
+
// Ground truth (verified live 2026-08-28): kimi-code exports NO KIMI* variable;
|
|
18
|
+
// it is a claude-code fork and only inherits CLAUDE_CODE_SSE_PORT. Claude Code
|
|
19
|
+
// proper sets CLAUDECODE=1 (plus CLAUDE_CODE_SSE_PORT). So SSE_PORT alone means
|
|
20
|
+
// "an agent host we cannot name" — and the handoff guidance is host-agnostic
|
|
21
|
+
// by design, so identification never blocks the handoff.
|
|
22
|
+
const KNOWN_MARKERS: ReadonlyArray<{ env: string; host: string }> = [
|
|
23
|
+
{ env: 'CLAUDECODE', host: 'Claude Code' },
|
|
24
|
+
{ env: 'CLAUDE_CODE_SSE_PORT', host: 'agent host (unidentified)' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export function detectHost(env: NodeJS.ProcessEnv = process.env): HostDetection {
|
|
28
|
+
for (const { env: key, host } of KNOWN_MARKERS) {
|
|
29
|
+
if (env[key]) return { detected: true, host, marker: key }
|
|
30
|
+
}
|
|
31
|
+
return { detected: false, host: 'none' }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SingleMode =
|
|
35
|
+
| { mode: 'external'; player: string }
|
|
36
|
+
| { mode: 'handoff'; host: HostDetection }
|
|
37
|
+
| { mode: 'no-player' }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default execution-mode resolution for `arena single`:
|
|
41
|
+
* explicit --player → external spawn (current behavior, unchanged)
|
|
42
|
+
* host detected, no player → host-handoff guidance
|
|
43
|
+
* no host, no player → loud error (callers point at player-setup.md)
|
|
44
|
+
*/
|
|
45
|
+
export function resolveSingleMode(
|
|
46
|
+
player: string | undefined,
|
|
47
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
48
|
+
): SingleMode {
|
|
49
|
+
if (player) return { mode: 'external', player }
|
|
50
|
+
const host = detectHost(env)
|
|
51
|
+
return host.detected ? { mode: 'handoff', host } : { mode: 'no-player' }
|
|
52
|
+
}
|
package/src/player.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'bun:test'
|
|
2
|
-
import { resolvePlayer, resolveSides, groupBySide, totalRuns } from './player'
|
|
2
|
+
import { resolvePlayer, playerAliasNote, resolveSides, groupBySide, totalRuns } from './player'
|
|
3
3
|
import { parseArenaToml } from './arena-toml'
|
|
4
4
|
|
|
5
5
|
const toml = parseArenaToml(`
|
|
@@ -32,6 +32,22 @@ describe('resolvePlayer', () => {
|
|
|
32
32
|
expect(resolvePlayer('kimi')).toBe('kimi')
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
+
it('maps kimi-code → kimi (upstream rename alias)', () => {
|
|
36
|
+
expect(resolvePlayer('kimi-code')).toBe('kimi')
|
|
37
|
+
expect(resolvePlayer('Kimi-Code')).toBe('kimi')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('playerAliasNote reports alias mappings only', () => {
|
|
41
|
+
expect(playerAliasNote('kimi-code')).toContain("'kimi-code' = 'kimi'")
|
|
42
|
+
expect(playerAliasNote('kimi')).toBeNull()
|
|
43
|
+
expect(playerAliasNote('expert-architect')).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('playerAliasNote covers the other built-in mappings too (claude family)', () => {
|
|
47
|
+
expect(playerAliasNote('claude')).toContain("'claude' = 'claude-sdk'")
|
|
48
|
+
expect(playerAliasNote('claude-cli')).toContain("'claude-cli' = 'claude'")
|
|
49
|
+
})
|
|
50
|
+
|
|
35
51
|
it('passes through unknown player names', () => {
|
|
36
52
|
expect(resolvePlayer('expert-architect')).toBe('expert-architect')
|
|
37
53
|
})
|
package/src/player.ts
CHANGED
|
@@ -16,12 +16,26 @@ const BUILTIN_PLAYERS: Record<string, string> = {
|
|
|
16
16
|
'claude-code': 'claude-sdk',
|
|
17
17
|
'claude-cli': 'claude', // legacy CLI spawn — explicitly opt-in
|
|
18
18
|
'kimi': 'kimi',
|
|
19
|
+
'kimi-code': 'kimi', // renamed upstream — users guess the new name (ADR-20260828004129233)
|
|
19
20
|
'deepseek': 'deepseek',
|
|
20
21
|
'codex': 'codex',
|
|
21
22
|
'cursor': 'cursor',
|
|
22
23
|
'gemini': 'gemini',
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* One-line note when a player reference resolves through a built-in mapping
|
|
28
|
+
* (e.g. 'kimi-code' → 'kimi'), else null. Pure — callers decide whether to print.
|
|
29
|
+
*/
|
|
30
|
+
export function playerAliasNote(name: string): string | null {
|
|
31
|
+
const normalized = name.toLowerCase().trim()
|
|
32
|
+
const resolved = BUILTIN_PLAYERS[normalized]
|
|
33
|
+
if (resolved && resolved !== normalized) {
|
|
34
|
+
return `player '${normalized}' = '${resolved}' (built-in alias)`
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
/**
|
|
26
40
|
* Resolve a player reference to its platform identifier.
|
|
27
41
|
* - Built-in names (claude, kimi, cursor) map directly
|