Skip to content

Commit 64731e9

Browse files
committed
feat(ai): extract shared multi-agent backend registry + routing
New @socketsecurity/lib/ai/backends: the CLI registry (claude/codex/kimi/ opencode), detectAvailableBackends, and resolveBackendForRole — the override-then-preference policy with hybrid backends (opencode) excluded from auto-pick. Lifted from reviewing-code/run.mts, which now imports it instead of carrying an inline copy; the policy doc flagged this extraction. The resolver is pure and returns a structured decision so callers log.
1 parent 7d11337 commit 64731e9

4 files changed

Lines changed: 374 additions & 178 deletions

File tree

.claude/skills/fleet/reviewing-code/run.mts

Lines changed: 25 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -17,147 +17,27 @@ import os from 'node:os'
1717
import path from 'node:path'
1818
import process from 'node:process'
1919

20-
import { which } from '@socketsecurity/lib/bin/which'
20+
import {
21+
BACKENDS,
22+
detectAvailableBackends,
23+
isBackendName,
24+
resolveBackendForRole,
25+
} from '@socketsecurity/lib/ai/backends'
2126
import { safeDelete } from '@socketsecurity/lib/fs/safe'
2227
import { getDefaultLogger } from '@socketsecurity/lib/logger/default'
2328
import { isSpawnError } from '@socketsecurity/lib/process/spawn/errors'
2429
import { spawn } from '@socketsecurity/lib/process/spawn/child'
2530

31+
import type { BackendName } from '@socketsecurity/lib/ai/backends'
32+
2633
const logger = getDefaultLogger()
2734

2835
type Role = 'discovery' | 'discovery-secondary' | 'remediation' | 'verify'
2936

30-
type BackendName = 'codex' | 'claude' | 'opencode' | 'kimi'
31-
32-
type BackendDescriptor = {
33-
readonly bin: string
34-
readonly hybrid: boolean
35-
readonly name: BackendName
36-
// Build the CLI argv given a prompt-file path and the temp output
37-
// path the runner will read after the process exits. Backends that
38-
// emit to stdout instead of an output file return outMode: 'stdout'
39-
// so the runner captures stdout into the output path itself.
40-
readonly run: (
41-
promptFile: string,
42-
outFile: string,
43-
) => { argv: readonly string[]; outMode: 'file' | 'stdout' }
44-
}
45-
46-
const BACKENDS: Readonly<Record<BackendName, BackendDescriptor>> = {
47-
__proto__: null,
48-
codex: {
49-
bin: 'codex',
50-
hybrid: false,
51-
name: 'codex',
52-
run(promptFile, outFile) {
53-
const model = process.env['CODEX_MODEL'] ?? 'gpt-5.4'
54-
const reasoning = process.env['CODEX_REASONING'] ?? 'xhigh'
55-
return {
56-
argv: [
57-
'exec',
58-
'--model',
59-
model,
60-
'-c',
61-
`model_reasoning_effort=${reasoning}`,
62-
'--full-auto',
63-
'--ephemeral',
64-
'-o',
65-
outFile,
66-
'-',
67-
],
68-
outMode: 'file',
69-
}
70-
},
71-
},
72-
claude: {
73-
bin: 'claude',
74-
hybrid: false,
75-
name: 'claude',
76-
run(_promptFile, _outFile) {
77-
const model = process.env['CLAUDE_MODEL'] ?? 'opus'
78-
// Pair the model with a reasoning effort (claude `--effort`) — see
79-
// _shared/multi-agent-backends.md. Review is judgment-heavy, so the
80-
// default is `high`; codex's sibling knob is CODEX_REASONING.
81-
const effort = process.env['CLAUDE_EFFORT'] ?? 'high'
82-
// Programmatic-Claude lockdown — all four flags per CLAUDE.md
83-
// (tools / allowedTools / disallowedTools / permission-mode).
84-
// The official permission flow is hooks → deny → mode → allow →
85-
// canUseTool; in dontAsk mode the last step is skipped, so any
86-
// tool not listed in `tools` is invisible to the model and any
87-
// tool in `disallowedTools` is denied even on bypass. Verify
88-
// pass is read-only by design: tools is the same set as
89-
// allowedTools (read + git introspection only), with Edit /
90-
// Write / destructive Bash explicitly denied.
91-
return {
92-
argv: [
93-
'--print',
94-
'--model',
95-
model,
96-
'--effort',
97-
effort,
98-
'--no-session-persistence',
99-
'--permission-mode',
100-
'dontAsk',
101-
'--tools',
102-
'Read',
103-
'Glob',
104-
'Grep',
105-
'Bash(git:*)',
106-
'--allowedTools',
107-
'Read',
108-
'Glob',
109-
'Grep',
110-
'Bash(git:*)',
111-
'--disallowedTools',
112-
'Edit',
113-
'Write',
114-
'Bash(rm:*)',
115-
'Bash(mv:*)',
116-
],
117-
outMode: 'stdout',
118-
}
119-
},
120-
},
121-
opencode: {
122-
bin: 'opencode',
123-
hybrid: true,
124-
name: 'opencode',
125-
run(_promptFile, _outFile) {
126-
// opencode reads the prompt from stdin and writes to stdout in its
127-
// non-interactive `run` form. It is hybrid — it dispatches to whatever
128-
// provider its own config selects — so by default model selection lives
129-
// outside this runner (opencode's config / its `recent` model).
130-
//
131-
// `OPENCODE_MODEL` lets a caller pin a `provider/model` slug for this run
132-
// — the way the Fireworks + Synthetic providers are reached (e.g.
133-
// `fireworks-ai/accounts/fireworks/models/glm-5p1`,
134-
// `synthetic/hf:moonshotai/Kimi-K2.5`); see
135-
// _shared/multi-agent-backends.md for the provider-slug catalog. Absent
136-
// the env, opencode picks per its own config.
137-
const model = process.env['OPENCODE_MODEL']
138-
const argv = model ? ['run', '--model', model] : ['run']
139-
return {
140-
argv,
141-
outMode: 'stdout',
142-
}
143-
},
144-
},
145-
kimi: {
146-
bin: 'kimi',
147-
hybrid: false,
148-
name: 'kimi',
149-
run(_promptFile, _outFile) {
150-
const model = process.env['KIMI_MODEL'] ?? 'kimi-latest'
151-
// Tentative shape: kimi reads prompt from stdin, writes to stdout.
152-
// Adjust when the actual CLI surface is known.
153-
return {
154-
argv: ['chat', '--model', model, '--no-stream'],
155-
outMode: 'stdout',
156-
}
157-
},
158-
},
159-
} as const
160-
37+
// The CLI registry, detection, and role resolution live in
38+
// @socketsecurity/lib/ai/backends — shared across the fleet's multi-agent
39+
// skills. This skill keeps only its own role table (prompts + per-role
40+
// preference order + timeouts) below and passes the order into the lib.
16141
type RoleSpec = {
16242
readonly buildPrompt: (ctx: ReviewContext) => string
16343
readonly headingForVerify?: string | undefined
@@ -372,22 +252,6 @@ export function capitalize(s: string): string {
372252
return s.charAt(0).toUpperCase() + s.slice(1)
373253
}
374254

375-
export async function detectAvailableBackends(): Promise<
376-
ReadonlySet<BackendName>
377-
> {
378-
// Fan out the `which` lookups instead of awaiting one at a time.
379-
// Cheap parallelism — N filesystem stats run concurrently rather
380-
// than serially.
381-
const names = Object.keys(BACKENDS) as BackendName[]
382-
const results = await Promise.all(
383-
names.map(async name => ({
384-
name,
385-
available: await isCommandAvailable(BACKENDS[name].bin),
386-
})),
387-
)
388-
return new Set(results.filter(r => r.available).map(r => r.name))
389-
}
390-
391255
export async function git(
392256
args: readonly string[],
393257
cwd?: string,
@@ -400,18 +264,6 @@ export async function git(
400264
return String(result.stdout ?? '').trim()
401265
}
402266

403-
export function isBackendName(s: string): s is BackendName {
404-
return s in BACKENDS
405-
}
406-
407-
export async function isCommandAvailable(bin: string): Promise<boolean> {
408-
// Use `which` from @socketsecurity/lib/bin instead of spawning
409-
// `command -v` with shell: true. The shell:true variant invokes
410-
// cmd.exe on Windows and mangles `command -v`; `which` is
411-
// cross-platform and avoids the shell entirely.
412-
return (await which(bin)) !== null
413-
}
414-
415267
export function isRole(s: string): s is Role {
416268
return s in ROLES
417269
}
@@ -512,25 +364,20 @@ export function pickBackend(
512364
available: ReadonlySet<BackendName>,
513365
override: BackendName | undefined,
514366
): BackendName | undefined {
515-
if (override) {
516-
if (!available.has(override)) {
517-
logger.warn(
518-
`${role}: requested backend "${override}" is not installed; falling back to preference order`,
519-
)
520-
} else {
521-
return override
522-
}
523-
}
524-
for (const candidate of ROLES[role].preferenceOrder) {
525-
// opencode is hybrid — only used when explicitly selected via --pass.
526-
if (BACKENDS[candidate].hybrid) {
527-
continue
528-
}
529-
if (available.has(candidate)) {
530-
return candidate
531-
}
367+
// Routing policy (override → preference → skip; hybrid only via override)
368+
// lives in the shared lib; this wrapper supplies the role's preference order
369+
// and logs the one human-facing case the pure resolver reports structurally.
370+
const resolution = resolveBackendForRole({
371+
available,
372+
override,
373+
preferenceOrder: ROLES[role].preferenceOrder,
374+
})
375+
if (resolution.reason === 'preference' && 'overrideMissing' in resolution) {
376+
logger.warn(
377+
`${role}: requested backend "${resolution.overrideMissing}" is not installed; falling back to preference order`,
378+
)
532379
}
533-
return undefined
380+
return resolution.backend
534381
}
535382

536383
export function printHelp(): void {

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@
157157
"types": "./dist/abort/signal.d.ts",
158158
"default": "./dist/abort/signal.js"
159159
},
160+
"./ai/backends": {
161+
"source": "./src/ai/backends.mts",
162+
"types": "./dist/ai/backends.d.mts",
163+
"default": "./dist/ai/backends.js"
164+
},
160165
"./ai/discover": {
161166
"source": "./src/ai/discover.mts",
162167
"types": "./dist/ai/discover.d.mts",

0 commit comments

Comments
 (0)