Routes Agent spawns through JEV (TypeSafe)

English · Português (Brasil)
Hi! (^_^)/ This is a Claude Code MOD (plugin) that sends every Agent spawn through JEV (TypeSafe), so JEV can pick the model (haiku, sonnet, opus), the effort, and the tool profile of each subagent for you.
Getting started takes two commands. In a Claude Code session:
/plugin marketplace add hiyoku/jev-orc
/plugin install jev-orchestrator@jev-orc
The second command opens the plugin details, so just pick the scope you like (user, project, or local). Then add your JEV key (next section) and you're set! (^o^)b
You do not call JEV yourself. Once the plugin is on, every Agent spawn of the agents in scope (agentScope: Claude Code's own agents by default, or all of them) goes through JEV first, which reads the task text and picks the model, the effort and the tool profile for that subagent. If JEV is unavailable, the spawn still runs (sonnet · medium). The Flow section has the diagram, and /jev-orc turns it on or off.
The plugin also ships two skills that make the most of this: plan cuts a request into small tasks, and run spawns one subagent per task. Small, mechanical tasks are what JEV sends to the cheapest model (haiku), so the plan is where most of the savings come from.
/jev-orchestrator:plan <request> turns a request into a folder docs/dev/NNN_<ShortDescription>/ with:
prompt.md: goal, context, constraints, done criteria, the task table, and the execution waves (which tasks can run in parallel);01_<name>.md, 02_<name>.md, ...), each cut small enough for a cheap model, with exact paths, code, and the verification command.It only writes files; nothing is executed. docs/dev/ is ignored by git, so the plan stays local.
Tip: write the request with an AI. The plan is only as good as its input. Before calling /jev-orchestrator:plan, ask any AI assistant (Claude, ChatGPT, ...) to turn your idea into a complete request: goal, which files or areas are involved, what must not change, and how to check that it is done. Paste that text as the argument:
/jev-orchestrator:plan Add a --json flag to the export command. Files: src/cli/export.ts and its test. Keep the default output unchanged. Done when `npm test` passes and `export --json` prints valid JSON.
Read the generated prompt.md and the task files before running; editing them is cheap, fixing a wrong run is not.
/jev-orchestrator:run <NNN> executes the plan docs/dev/NNN_*/ (with no number, the latest one):
prompt.md and follows the Execution section, wave by wave./jev-orchestrator:run 001
flowchart LR
A["Agent (tool.call)"] --> B{"mode and type<br/>intercepted?"}
B -- no --> Z["passes untouched"]
B -- yes --> C["JEV decides"]
C -- failed --> F["fallback<br/>(sonnet · medium)"]
C --> D["tool.call applies the effort"]
F --> D
D --> E["agent.spawn applies<br/>model + tool profile"]
tool.call (Agent) queries JEV and applies the effort.agent.spawn, correlated by tool_use_id, applies the model and the tool profile.http_*), the spawn proceeds on sonnet · medium (capped by maxModel/maxEffort) and the reason goes to the log.Claude Code offers the plugin options dialog when the plugin is first enabled: fill in JEV API key (TypeSafe or OpenRouter) (typesafeApiKey). To set or change it later, open /plugin → Installed → jev-orchestrator → Configure options.
The field is sensitive: the key is kept in the system secure storage (Keychain on macOS), not in settings.json, and does not appear in /config. With the field empty, the TYPESAFE_API_KEY (or JEV_API_KEY) environment variable of whoever runs claude applies. The key never reaches the log, the status line, toasts, or error messages. With no key at all, every routed spawn falls back (with a toast).
Via OpenRouter: OpenRouter hosts JEV with the same System One API. Put an OpenRouter key in typesafeApiKey and set JEV endpoint (jevEndpoint) to https://openrouter.ai/api/v1/systemone. The endpoint must be https, since the key goes with every call.
mode | Behavior |
|---|---|
auto (default) | JEV decides every spawn of the agents in scope (agentScope) |
off | disabled; the status line shows jev OFF |
explicit | only routes spawns whose description starts with [jev] (the marker is removed) |
Toggle with the /jev-orc command:
| Command | Effect |
|---|---|
/jev-orc | toggles auto ⇄ off (from explicit it goes to off) |
/jev-orc on / off / explicit | writes auto / off / explicit |
/jev-orc status | shows the mode and the status line, without writing |
To pick model/effort by hand, turn it off with /jev-orc off; it applies from the next spawn.
Or: /config, row jev-orchestrator.mode; or in settings:
{ "pluginConfigs": { "jev-orchestrator": { "options": { "mode": "off" } } } }
All fields are in docs/configuration.md.
Note: on a routed spawn, the MOD overrides the model and effort passed by hand. To choose manually, turn it off (mode=off) or use explicit and call without the [jev] marker. maxModel/maxEffort cap what JEV may pick. maxEffort also caps the effort of your own prompts (the main agent) when mode is not off, even with mainEffort off.
By default (statusStyle: nerd) each part gets a Nerd Font icon and the parts are split by │: power, route, tokens, cost, comparison, total, and effort. It needs a Nerd Font in the terminal; without one the icons show as squares. statusStyle: plain is plain text with no icons, and is what the examples below show:
jev ON · haiku·low·t-read · 42k tok $0.08 · vs opus/high ~$0.31 (-74%) — high
jev OFF — high
Right after start, before any subagent is routed, it is shorter (that is normal):
jev ON · prompt·medium — medium
The rest appears as soon as the first subagent is routed. Not seeing it yet? Spawn an Agent and watch it grow (^_^)
haiku·low·t-read: last routing (model, effort, profile); on failure, fallback (<reason>).42k tok $0.08: tokens and actual cost of the routed agents in the session (in nerd, tokens and cost are two segments).vs opus/high: the compare model and the effort. By default (compareModel: auto) it is the user's model; the effort stays ? until the MOD sees a main-loop turn. With a fixed compareModel (haiku, sonnet, opus, fable) the label shows only the name, e.g. vs fable. It changes only the status line's comparison price, not the routing.~$0.31 (-74%): estimate. The counterfactual is the same tokens on the compare model (default: the user's model); it does not model how much the effort changes the token count. +x% means the routed run cost more. An estimate below US$ 0.0005 shows as <$0.001.— high (in nerd, the last segment): effort the main loop's last request ran with (after mainEffort, if on); subagents do not change it. Hidden until the MOD sees a main-loop turn.logLevel does not affect the status line.Prices are the list prices in src/config.ts (US$/MTok), Fable included.
Plugin messages (toasts, /jev-orc replies, and status line words) follow the Claude Code language and fall back to English; the language option (auto, en, pt-BR) forces a language.
~/.claude/jev-orchestrator/decisions.jsonl, one JSON line per decision. Fields:
| Field | Content |
|---|---|
ts | ISO 8601 |
tool_use_id | id of the Agent call |
description | description, without the [jev] marker |
outcome | routed, fallback, skip, or verifier |
models | models (the second is the verifier, if any) |
effort, profile | applied decision |
reasons | why each choice was made |
jev | raw JEV numbers (scores, *_confidence, deliberation) |
error | missing_key, timeout, network, bad_payload, or http_<status> |
jev_request | only with logLevel=debug: task truncated to 200 characters, and context |
Never written: the API key, the full prompt, messages, and tool results. Rotation: past 1 MiB, the file becomes decisions.1.jsonl (one generation). logLevel=off turns the log off.
tail -n 20 ~/.claude/jev-orchestrator/decisions.jsonl
tail -n 20 ~/.claude/jev-orchestrator/decisions.jsonl | jq -c 'del(.ts,.tool_use_id)'
The log lives outside the repo and is not versioned (.gitignore: decisions*.jsonl).
Off by default (multiModel=false). When on, a verifier reviews the answer when the complexity is opus-level and JEV's confidence is below 0.5. The verifier runs in the read-only profile jev-orchestrator:t-read. During validation, the 0.5 confidence cutoff was almost never reached (confidence ≥ 0.69 even for vague requests), so the verifier did not fire. Recalibration will be done with log data (user's decision).
Off by default (mainEffort=false). The routing above applies to subagents only; your own prompts run on the session model with your effort. When on (and mode=auto), each typed prompt goes to JEV first and its effort becomes the one JEV picked (deliberation), capped by maxEffort. The model never changes: switching it mid-conversation would throw away the prompt cache. It costs one JEV call, and up to 8 s of wait, before each prompt. The status line shows prompt·<effort> and the log an entry with "outcome":"main"; without JEV the prompt keeps your effort.
For each routed subagent spawn (and each prompt, with mainEffort on) the task text, cut to 4000 characters, is sent to the JEV endpoint (jevEndpoint: TypeSafe, or OpenRouter). Nothing else leaves your machine. The key is kept in the system secure storage. The local decision log holds the task only with logLevel=debug (cut to 200 characters); logLevel=off writes nothing. The agent's name and a short description of it (your agentHints, else the one Claude Code gives it, cut to 120 characters) go along with the task.
Any improvement is welcome: a bug fix, a clearer doc, a better question for JEV, a new idea. You do not need to ask first.
main, and make your change. Small, focused PRs are easier to review.claude plugin test . and claude plugin validate .; both should pass.main and say what changed and why. Commit messages follow the style of the history (feat(scope): summary)..claude-plugin/plugin.json: bump version there if you want a new tag and GitHub release. Without a bump nothing is released.hooks/register.ts 73 lines1import type { Register } from 'claude-code'
2import { COMMAND_NAME, changedMessage, deniedMessage, parseJevOrcArgs, statusMessage, usageMessage } from '../src/command'
3import { buildProfiles } from '../src/agents/profiles'
4import { loadConfig } from '../src/config'
5import { STRINGS, formatWarning, resolveLanguage, type Strings } from '../src/i18n'
6import { EMPTY_SAVINGS, formatStatus } from '../src/savings'
7import { routeAgentSpawns, TOTAL_KEY } from '../src/hooks/spawn'
8import type { Savings } from '../types/index'
9
10export const register: Register = (on, options) => {
11 const { config, warnings } = loadConfig(options)
12 const registered = new Set<string>()
13 // The status view the registration shares; the user's effort is not seen here yet.
14 const view = (total?: Savings) => ({ total, style: config.statusStyle, compare: config.compareModel })
15 // Set at session.start, which runs again on every module reload; English until then.
16 const text: { s: Strings } = { s: STRINGS.en }
17
18 on('session.start', async ($, e, next) => {
19 // Literal $ calls: the host scan only follows $ within this file.
20 let settingsLanguage: unknown
21 let locale: string | undefined
22 try {
23 settingsLanguage = (await $.settings.read()).language
24 } catch {} // no settings: the locale decides
25 try {
26 locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
27 } catch {} // no locale: English
28 const s = (text.s = STRINGS[resolveLanguage(options.language, settingsLanguage, locale)])
29 for (const warning of warnings) $.ui.toast(formatWarning(s, warning))
30 const specs = buildProfiles()
31 const results = await Promise.allSettled(specs.map((spec) => $.agent.register(spec)))
32 results.forEach((result, i) => {
33 if (result.status === 'fulfilled') registered.add(result.value.agent)
34 else $.ui.toast(s.toastProfile(specs[i].name, String(result.reason)))
35 })
36 try {
37 await $.command.register({
38 name: COMMAND_NAME,
39 description: s.commandDescription,
40 argumentHint: '[on|off|explicit|status]',
41 immediate: true,
42 })
43 } catch {} // the command is a convenience: the routing does not depend on it
44 // What the host kept across a module reload; the user's effort is not seen yet.
45 const { value } = await $.state.get({ plugin: 'jev-orchestrator', key: 'savings' } as const)
46 const total = (await $.store.get(TOTAL_KEY).catch(() => undefined)) as Savings | undefined
47 $.ui.status(formatStatus(value ?? EMPTY_SAVINGS, config.mode, view(total)))
48 return next(e)
49 })
50
51 on('command.run', { command: COMMAND_NAME }, async ($, e) => {
52 const action = parseJevOrcArgs(e.args, config.mode)
53 const { value } = await $.state.get({ plugin: 'jev-orchestrator', key: 'savings' } as const)
54 const savings = value ?? EMPTY_SAVINGS
55 const total = (await $.store.get(TOTAL_KEY).catch(() => undefined)) as Savings | undefined
56 if (action.kind === 'usage') return { text: usageMessage(text.s, e.args) }
57 if (action.kind === 'status') {
58 return { text: statusMessage(text.s, config.mode, formatStatus(savings, config.mode, view(total))) }
59 }
60 try {
61 const { deny } = await $.config.set({ key: 'jev-orchestrator.mode', value: action.mode })
62 if (deny !== undefined) return { text: deniedMessage(text.s, deny) }
63 } catch (error) {
64 return { text: deniedMessage(text.s, String(error)) }
65 }
66 // The reload does not rerun session.start: the line is refreshed here.
67 $.ui.status(formatStatus(savings, action.mode, view(total)))
68 return { text: changedMessage(text.s, config.mode, action.mode) }
69 })
70
71 routeAgentSpawns(on, config, registered, text)
72}
73src/command.ts 25 lines1import type { Mode } from './config'
2import type { Strings } from './i18n'
3
4export const COMMAND_NAME = 'jev-orc'
5export const MODE_KEY = 'jev-orchestrator.mode'
6
7export type JevOrcAction = { kind: 'set'; mode: Mode } | { kind: 'status' } | { kind: 'usage' }
8
9// No argument toggles auto <-> off; explicit goes to off.
10export function parseJevOrcArgs(args: string, currentMode: Mode): JevOrcAction {
11 switch (args.trim().toLowerCase()) {
12 case '': return { kind: 'set', mode: currentMode === 'off' ? 'auto' : 'off' }
13 case 'on': return { kind: 'set', mode: 'auto' }
14 case 'off': return { kind: 'set', mode: 'off' }
15 case 'explicit': return { kind: 'set', mode: 'explicit' }
16 case 'status': return { kind: 'status' }
17 default: return { kind: 'usage' }
18 }
19}
20
21export const changedMessage = (s: Strings, from: Mode, to: Mode) => s.changed(from, to, s.modeNote[to])
22export const statusMessage = (s: Strings, mode: Mode, line: string) => s.status(mode, line)
23export const deniedMessage = (s: Strings, reason: string) => s.denied(reason)
24export const usageMessage = (s: Strings, arg: string) => `${s.invalidArg(arg.trim())}\n${s.usage}`
25src/agents/profiles.ts 50 lines1import type { AgentSpec } from 'claude-code'
2import type { Decision } from '../domain/policy'
3import { GROUP_PATTERNS } from '../tools/catalog'
4import { TOOL_GROUPS, type ToolGroup } from '../tools/groups'
5
6const PLUGIN = 'jev-orchestrator'
7const FALLBACK = 'fallback'
8
9export const FALLBACK_PROFILE = `${PLUGIN}:${FALLBACK}`
10export const PROFILE_MATCHER = new RegExp(`^${PLUGIN}:`)
11
12const OPTIONAL_GROUPS = TOOL_GROUPS.filter((group) => group !== 'read')
13
14const BASE_PROMPT = [
15 'You are an agent for Claude Code. Complete the task in the prompt fully, without gold-plating.',
16 'Search broadly when you do not know where something lives, then narrow down.',
17 'In your final report, state what you found or did, cite file:line for every claim, and say how you verified it.',
18].join(' ')
19const READ_ONLY_PROMPT = 'You are read-only: you cannot modify files. Never try to; report findings instead.'
20
21// One profile per combination of tool groups, plus the fallback with every group.
22export function buildProfiles(): AgentSpec[] {
23 const spec = (name: string, description: string, tools: string[]): AgentSpec => ({ name, description, prompt: promptFor(tools), tools })
24 return [
25 ...combinations().map((groups) => spec(nameOf(groups), `Tool profile: ${groups.join(', ')}`, toolsOf(groups))),
26 spec(FALLBACK, 'Tool profile: every tool group', toolsOf(TOOL_GROUPS)),
27 ]
28}
29
30export const profileFor = (decision: Decision): string => `${PLUGIN}:${nameOf(decision.toolGroups)}`
31
32function combinations(): ToolGroup[][] {
33 return OPTIONAL_GROUPS.reduce<ToolGroup[][]>(
34 (acc, group) => acc.flatMap((groups) => [groups, [...groups, group]]),
35 [['read']],
36 )
37}
38
39function nameOf(groups: readonly ToolGroup[]): string {
40 return `t-${TOOL_GROUPS.filter((group) => groups.includes(group)).join('-')}`
41}
42
43const toolsOf = (groups: readonly ToolGroup[]): string[] => [...new Set(groups.flatMap((group) => GROUP_PATTERNS[group]))]
44
45function promptFor(tools: readonly string[]): string {
46 const writes = tools.some((tool) => GROUP_PATTERNS.edit.includes(tool))
47 return writes ? BASE_PROMPT : `${BASE_PROMPT} ${READ_ONLY_PROMPT}`
48}
49
50src/config.ts 123 lines1import type { HookBudget, PluginOptions } from 'claude-code'
2import { warning, type Warning } from './i18n'
3import { SCOPES, parseHints, type Scope } from './agents/scope'
4
5export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
6export const MODELS = ['haiku', 'sonnet', 'opus'] as const
7export const PROFILES = ['explore', 'general'] as const
8export const PRICED_MODELS = [...MODELS, 'fable'] as const
9export const COMPARE_MODELS = ['auto', ...PRICED_MODELS] as const
10export const STATUS_STYLES = ['nerd', 'plain'] as const
11const MODES = ['auto', 'off', 'explicit'] as const
12const LOG_LEVELS = ['off', 'info', 'debug'] as const
13
14export const HOOK_BUDGET_MS: HookBudget['ms'] = 10_000
15
16export type Effort = (typeof EFFORTS)[number]
17export type Model = (typeof MODELS)[number]
18export type Profile = (typeof PROFILES)[number]
19export type PricedModel = (typeof PRICED_MODELS)[number]
20export type CompareModel = (typeof COMPARE_MODELS)[number]
21export type StatusStyle = (typeof STATUS_STYLES)[number]
22export type Mode = (typeof MODES)[number]
23export type LogLevel = (typeof LOG_LEVELS)[number]
24
25export type Config = {
26 mode: Mode
27 maxModel: Model
28 maxEffort: Effort
29 // Every effort up to maxEffort, low first.
30 efforts: Effort[]
31 fallback: { model: Model; effort: Effort; agent: string }
32 multiModel: boolean
33 // JEV sets the effort of the user's own prompts; the session model stays.
34 mainEffort: boolean
35 // Secret: never put in warnings, toasts, logs or the status line.
36 apiKey?: string
37 // System One API: TypeSafe's, or OpenRouter's (https://openrouter.ai/api/v1/systemone) with an OpenRouter key.
38 endpoint: string
39 logLevel: LogLevel
40 // Model the status line prices the routed tokens against; auto: the session's own model.
41 compareModel: CompareModel
42 // nerd: Nerd Font icons; plain: text only.
43 statusStyle: StatusStyle
44 // Which agents the routing touches: builtin (Claude Code's own) or all (plus .claude/agents and plugins').
45 agentScope: Scope
46 // Per-agent text JEV reads beside the name; the host's listing line when none.
47 agentHints: Map<string, string>
48}
49
50// US$ per MTok.
51export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
52
53export type LoadedConfig = { config: Config; warnings: Warning[] }
54
55// Fixed internals: once options, now the values almost nobody changed.
56export const MAX_AGENTS = 8
57export const PARALLELISM = 4
58export const JEV_TIMEOUT_MS = 8000
59// ponytail: list prices; an option again if someone has negotiated prices
60// Anthropic's published prices for the current family (-5-5), US$ per MTok; cache write is the
61// 5-minute TTL (1.25x input) and haiku's cache read is the documented 0.1x of its input.
62// fable: claude-api skill (2026-10); its cacheWrite is derived (1.25x input), not listed there.
63export const PRICES: Record<PricedModel, Price> = {
64 haiku: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
65 sonnet: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
66 opus: { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 },
67 fable: { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
68}
69const FALLBACK = { model: 'sonnet', effort: 'medium', agent: 'general-purpose' } as const
70
71const DEFAULTS = {
72 mode: 'auto',
73 endpoint: 'https://api.typesafe.ai/v1/systemone',
74 maxModel: 'opus',
75 maxEffort: 'xhigh',
76 multiModel: false,
77 mainEffort: false,
78 logLevel: 'info',
79 compareModel: 'auto',
80 statusStyle: 'nerd',
81 agentScope: 'builtin',
82} as const
83
84// The lower of two values on an ordered scale.
85const atMost = <T>(scale: readonly T[], value: T, max: T): T => scale[Math.min(scale.indexOf(value), scale.indexOf(max))]
86
87export function loadConfig(options: PluginOptions): LoadedConfig {
88 const warnings: Warning[] = []
89 const oneOf = <T extends string>(field: string, valid: readonly T[], fallback: T): T => {
90 const value = options[field]
91 if (value === undefined) return fallback
92 if ((valid as readonly unknown[]).includes(value)) return value as T
93 warnings.push(warning('invalid', field, String(value), fallback))
94 return fallback
95 }
96 const boolean = (field: string, fallback: boolean) => (typeof options[field] === 'boolean' ? (options[field] as boolean) : fallback)
97
98 const maxModel = oneOf('maxModel', MODELS, DEFAULTS.maxModel)
99 const maxEffort = oneOf('maxEffort', EFFORTS, DEFAULTS.maxEffort)
100 const key = options.typesafeApiKey
101 // The key goes to this URL: https only, never plain http.
102 const endpoint = typeof options.jevEndpoint === 'string' ? options.jevEndpoint.trim() : ''
103 const isHttps = /^https:\/\/\S+$/.test(endpoint)
104 if (endpoint && !isHttps) warnings.push(warning('invalid', 'jevEndpoint', endpoint, DEFAULTS.endpoint))
105 const config: Config = {
106 mode: oneOf('mode', MODES, DEFAULTS.mode),
107 maxModel,
108 maxEffort,
109 efforts: EFFORTS.slice(0, EFFORTS.indexOf(maxEffort) + 1),
110 fallback: { model: atMost(MODELS, FALLBACK.model, maxModel), effort: atMost(EFFORTS, FALLBACK.effort, maxEffort), agent: FALLBACK.agent },
111 multiModel: boolean('multiModel', DEFAULTS.multiModel),
112 mainEffort: boolean('mainEffort', DEFAULTS.mainEffort),
113 apiKey: (typeof key === 'string' && key.trim()) || undefined,
114 endpoint: isHttps ? endpoint : DEFAULTS.endpoint,
115 logLevel: oneOf('logLevel', LOG_LEVELS, DEFAULTS.logLevel),
116 compareModel: oneOf('compareModel', COMPARE_MODELS, DEFAULTS.compareModel),
117 statusStyle: oneOf('statusStyle', STATUS_STYLES, DEFAULTS.statusStyle),
118 agentScope: oneOf('agentScope', SCOPES, DEFAULTS.agentScope),
119 agentHints: parseHints(options.agentHints),
120 }
121 return { config, warnings }
122}
123src/i18n.ts 80 lines1// Pure: everything the user reads, in English and pt-BR. Model names, `jev ON/OFF`, the JSONL log
2// and the profile prompts are not here on purpose (stable formats, not prose).
3export type Lang = 'en' | 'pt-BR'
4
5const isPortuguese = (v: unknown): boolean => typeof v === 'string' && /^(pt|portug)/i.test(v.trim())
6
7// An explicit option wins; in `auto`, Claude Code's `language` setting, then the process locale.
8export function resolveLanguage(option: unknown, settingsLanguage: unknown, locale: unknown): Lang {
9 if (option === 'en' || option === 'pt-BR') return option
10 const fromSettings = typeof settingsLanguage === 'string' && settingsLanguage.trim() !== ''
11 return isPortuguese(fromSettings ? settingsLanguage : locale) ? 'pt-BR' : 'en'
12}
13
14// A config warning is a code plus arguments, so loadConfig stays pure and the language is
15// applied only where the toast is shown.
16export type Warning = { code: keyof typeof STRINGS.en.warn; args: string[] }
17export const warning = (code: Warning['code'], ...args: string[]): Warning => ({ code, args })
18
19type Mode = 'auto' | 'off' | 'explicit'
20export const STRINGS = {
21 en: {
22 fallback: 'fallback',
23 commandDescription: 'Turns JEV routing on/off (on, off, explicit, status)',
24 usage: 'usage: /jev-orc [on|off|explicit|status]',
25 invalidArg: (arg: string) => `jev-orc: invalid argument "${arg}"`,
26 modeNote: {
27 auto: 'routing on',
28 off: 'routing off; manual model/effort apply from the next spawn',
29 explicit: 'only spawns marked with [jev] are routed',
30 } as Record<Mode, string>,
31 changed: (from: Mode, to: Mode, note: string) => (from === to ? `jev-orc: already ${to} (${note})` : `jev-orc: ${from} → ${to} (${note})`),
32 status: (mode: Mode, line: string) => `jev-orc: ${mode} · ${line}`,
33 denied: (reason: string) => `jev-orc: could not change the mode (${reason}); nothing was changed`,
34 toastProfile: (name: string, reason: string) => `JEV: profile ${name} not registered: ${reason}`,
35 toastMissingKey:
36 'JEV: key missing, using the fallback; set the key in /plugin → Installed → jev-orchestrator → Configure options, or in TYPESAFE_API_KEY',
37 toastLog: 'JEV: failed to write the decision log',
38 // Status line label of the effort JEV picked for the user's own prompt (mainEffort).
39 mainRoute: 'prompt',
40 toastVerifierProfile: (profile: string) => `JEV: verifier not started: profile ${profile} missing`,
41 toastVerifierMax: 'JEV: verifier not started: agent limit reached',
42 toastVerifierDenied: (deny: string) => `JEV: verifier refused: ${deny}`,
43 toastVerifierFailed: (msg: string) => `JEV: verifier failed: ${msg}`,
44 warn: {
45 invalid: (f: string, v: string, fb: string) => `${f}=${v} invalid: using ${fb}`,
46 },
47 },
48 'pt-BR': {
49 fallback: 'fallback',
50 commandDescription: 'Liga/desliga o roteamento do JEV (on, off, explicit, status)',
51 usage: 'uso: /jev-orc [on|off|explicit|status]',
52 invalidArg: (arg: string) => `jev-orc: argumento inválido "${arg}"`,
53 modeNote: {
54 auto: 'roteamento ligado',
55 off: 'roteamento desligado; model/effort manuais valem a partir do próximo spawn',
56 explicit: 'só spawns marcados com [jev] são roteados',
57 } as Record<Mode, string>,
58 changed: (from: Mode, to: Mode, note: string) => (from === to ? `jev-orc: já está em ${to} (${note})` : `jev-orc: ${from} → ${to} (${note})`),
59 status: (mode: Mode, line: string) => `jev-orc: ${mode} · ${line}`,
60 denied: (reason: string) => `jev-orc: não foi possível mudar o modo (${reason}); nada foi alterado`,
61 toastProfile: (name: string, reason: string) => `JEV: perfil ${name} não registrado: ${reason}`,
62 toastMissingKey:
63 'JEV: chave ausente, usando o fallback; defina a chave em /plugin → Installed → jev-orchestrator → Configure options, ou em TYPESAFE_API_KEY',
64 toastLog: 'JEV: falha ao gravar o log de decisões',
65 mainRoute: 'prompt',
66 toastVerifierProfile: (profile: string) => `JEV: verificador não disparado: perfil ${profile} ausente`,
67 toastVerifierMax: 'JEV: verificador não disparado: limite de agentes atingido',
68 toastVerifierDenied: (deny: string) => `JEV: verificador recusado: ${deny}`,
69 toastVerifierFailed: (msg: string) => `JEV: verificador falhou: ${msg}`,
70 warn: {
71 invalid: (f: string, v: string, fb: string) => `${f}=${v} inválido: usando ${fb}`,
72 },
73 },
74} satisfies Record<Lang, unknown>
75
76export type Strings = (typeof STRINGS)[Lang]
77
78export const formatWarning = (s: Strings, w: Warning): string =>
79 `JEV: ${(s.warn[w.code] as (...a: string[]) => string)(...w.args)}`
80src/savings.ts 96 lines1import type { CompareModel, Mode, Model, PricedModel, Price, StatusStyle } from './config'
2import { PRICED_MODELS } from './config'
3import type { Savings } from '../types/index'
4
5export type Usage = {
6 input_tokens: number
7 output_tokens: number
8 cache_read_input_tokens: number
9 cache_creation_input_tokens: number
10}
11
12export const EMPTY_SAVINGS: Savings = { agents: 0, tokens: 0, cost: 0, estimated: 0, jevCalls: 0, parent: '?', route: '' }
13
14const PER_MTOK = 1_000_000
15
16// ponytail: haiku's higher rate above 100K prompt tokens is not modelled (usage arrives summed per turn); revisit if the host exposes per-request usage
17export const costOf = (usage: Usage, price: Price): number =>
18 (usage.input_tokens * price.input +
19 usage.output_tokens * price.output +
20 usage.cache_read_input_tokens * price.cacheRead +
21 usage.cache_creation_input_tokens * price.cacheWrite) /
22 PER_MTOK
23
24// "claude-opus-5-5" -> opus; anything else is unknown.
25export const familyOf = (modelId: string | undefined): PricedModel | undefined => PRICED_MODELS.find((model) => modelId?.includes(model))
26
27// A routed agent: its tokens at the routed model's price against the same tokens at the parent's.
28// ponytail: the counterfactual does not model how much the effort changes the token count; revisit by measuring tokens per effort level in decisions.jsonl and applying a per-level factor if the gap matters.
29// A verifier is extra work the user would not have run: real cost only, no counterfactual.
30export function addAgent(
31 savings: Savings,
32 usage: Usage,
33 routed: Model,
34 parentModel: string | undefined,
35 prices: Record<PricedModel, Price>,
36 compare: CompareModel,
37 isVerifier = false,
38): Savings {
39 const reference = compare === 'auto' ? familyOf(parentModel) : compare
40 const counterfactual = isVerifier ? 0 : reference ? costOf(usage, prices[reference]) : null
41 return {
42 ...savings,
43 agents: savings.agents + 1,
44 tokens: savings.tokens + usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
45 cost: savings.cost + costOf(usage, prices[routed]),
46 estimated: savings.estimated === null || counterfactual === null ? null : savings.estimated + counterfactual,
47 parent: isVerifier ? savings.parent : (reference ?? '?'),
48 }
49}
50
51export const abbreviateTokens = (n: number): string =>
52 n < 1000 ? String(n) : n < 999_500 ? `${Math.round(n / 1000)}k` : `${(n / PER_MTOK).toFixed(1)}M`
53
54export const usd = (value: number): string =>
55 value > 0 && value < 0.0005 ? '<$0.001' : `$${value > 0 && value < 0.01 ? value.toFixed(3) : value.toFixed(2)}`
56
57// " (-74%)" against the counterfactual; empty when there is none to compare.
58function percentVs(real: number, estimated: number | null): string {
59 if (estimated === null || estimated <= 0) return ''
60 const percent = Math.round(((real - estimated) / estimated) * 100)
61 return ` (${percent > 0 ? '+' : ''}${percent}%)`
62}
63
64// Font Awesome glyphs of the Nerd Fonts, as escapes: the source never carries the characters.
65const NERD = { power: '\uf011', route: '\uf0e7', tokens: '\uf1c0', cost: '\uf0d6', compare: '\uf0ec', total: '\uf1da', effort: '\uf2db' } as const
66
67// `userEffort`: the effort the main loop's last request asked for (the counterfactual's), `?` while unknown.
68// `total`: every session's savings, kept in $.store; shown from the first routed agent ever.
69// `promptEffort`: the effort the main loop's last request ran with; hidden while unknown.
70// `style`: plain is text with dots and a dash; nerd is icon segments split by a bar.
71// `compare`: auto names the session's model with its effort; a fixed model names itself only.
72export type StatusView = { userEffort?: string; total?: Savings; promptEffort?: string; style?: StatusStyle; compare?: CompareModel }
73
74export function formatStatus(savings: Savings, mode: Mode, view: StatusView = {}): string {
75 const { userEffort = '?', total, promptEffort = '?', style = 'plain', compare = 'auto' } = view
76 const nerd = style === 'nerd'
77 // [icon, text]: plain drops the icon and the segments are joined below.
78 const parts: [keyof typeof NERD, string][] = [['power', mode === 'off' ? 'jev OFF' : 'jev ON']]
79 if (mode !== 'off') {
80 if (savings.route) parts.push(['route', savings.route])
81 if (savings.agents > 0) {
82 const real = savings.cost
83 const tokens = `${abbreviateTokens(savings.tokens)} tok`
84 if (nerd) parts.push(['tokens', tokens], ['cost', usd(real)])
85 else parts.push(['tokens', `${tokens} ${usd(real)}`])
86 const versus = `vs ${compare === 'auto' ? `${savings.parent}/${userEffort}` : compare}`
87 const estimated = savings.estimated
88 parts.push(['compare', estimated === null || estimated <= 0 ? versus : `${versus} ~${usd(estimated)}${percentVs(real, estimated)}`])
89 }
90 if (total && total.agents > 0) parts.push(['total', `total ${usd(total.cost)}${percentVs(total.cost, total.estimated)}`])
91 }
92 if (promptEffort !== '?' && nerd) parts.push(['effort', promptEffort])
93 const plainEffort = promptEffort === '?' ? '' : ` — ${promptEffort}`
94 return nerd ? parts.map(([icon, text]) => `${NERD[icon]} ${text}`).join(' │ ') : parts.map(([, text]) => text).join(' · ') + plainEffort
95}
96src/hooks/spawn.ts 310 lines1import { update, type AgentSpawnResult, type EngineInterface, type On } from 'claude-code'
2import { FALLBACK_PROFILE, PROFILE_MATCHER, profileFor } from '../agents/profiles'
3import { hintFor, intercepts, swapsProfile, type Offered } from '../agents/scope'
4import { EFFORTS, HOOK_BUDGET_MS, JEV_TIMEOUT_MS, MAX_AGENTS, PARALLELISM, PRICES, type Config, type Effort, type LogLevel, type Model } from '../config'
5import { decide, desiredEffort, projectEffort } from '../domain/policy'
6import type { Strings } from '../i18n'
7import type { JevAnswers } from '../jev/answers'
8import { askJev, JevError, type JevErrorKind } from '../jev/client'
9import { createLimiter } from '../jev/limiter'
10import { jevNumbers, logDecision, type LogEntry, type LogIo } from '../log'
11import { addAgent, EMPTY_SAVINGS, formatStatus } from '../savings'
12import type { Savings } from '../../types/index'
13
14export const MAX_TASK_CHARS = 4000
15const MARKER = /^\[jev\]\s*/
16const DEFAULT_TYPE = 'general-purpose'
17// What the hook keeps of its budget beyond the queue wait and the JEV timeout.
18const QUEUE_MARGIN_MS = 500
19const ROUTED_IDS_CAP = 1000
20const VERIFY_PROFILE = 'jev-orchestrator:t-read'
21const VERIFY_INSTRUCTION =
22 'Review the answer above against the task. Point out errors, gaps and risks, citing file:line. Do not edit anything.'
23
24// Literal, as $.state needs: the savings of this session, kept by the host across a module reload.
25const SAVINGS = { plugin: 'jev-orchestrator', key: 'savings' } as const
26// The $.store key of every session's savings summed.
27export const TOTAL_KEY = 'totalSavings'
28
29// user: what the main loop's last request asked for (the counterfactual); prompt: what it ran with.
30type Efforts = { user: string; prompt: string }
31
32// The same change folds into the session's savings and into the total across sessions.
33// The status never reaches the routing: a failed state write leaves the old line.
34async function track($: EngineInterface, config: Config, efforts: Efforts, change: (savings: Savings) => Savings) {
35 try {
36 const savings = await update($, SAVINGS, (value) => change(value ?? EMPTY_SAVINGS))
37 // ponytail: read-then-write, two sessions finishing agents at the same instant can drop one; a per-session key summed on read if that matters
38 // ponytail: one agent under an unknown parent model nulls the total's estimate for good (addAgent); keep a compared-cost sum if that shows up
39 let total: Savings | undefined
40 try {
41 total = change(((await $.store.get(TOTAL_KEY)) as Savings | undefined) ?? EMPTY_SAVINGS)
42 await $.store.set(TOTAL_KEY, total)
43 } catch {} // no store: the session's line alone
44 $.ui.status(formatStatus(savings, config.mode, { userEffort: efforts.user, total, promptEffort: efforts.prompt, style: config.statusStyle, compare: config.compareModel }))
45 } catch {}
46}
47
48type JevIo = Parameters<typeof askJev>[0]
49type HttpInit = Parameters<JevIo['http']['fetch']>[1]
50
51type JevTask = { tarefa: string; contexto: string }
52
53// The host follows $ only within this file, so askJev gets literal calls, not $ itself.
54const jevIo = ($: EngineInterface) =>
55 ({
56 env: { get: (name: string) => (name === 'JEV_API_KEY' ? $.env.get('JEV_API_KEY') : $.env.get('TYPESAFE_API_KEY')) },
57 http: { fetch: (url: string, init?: HttpInit) => $.http.fetch(url, init) },
58 clock: { sleep: (ms: number) => $.clock.sleep(ms) },
59 }) as JevIo
60
61// What JEV reads as the context of a main-loop prompt.
62const MAIN_CONTEXT = 'main session prompt: the user talking to the coding agent directly'
63
64// The host follows $ only within this file, so the log gets literal calls, not $ itself.
65// A failure toasts once per session (`warned`) and never reaches the routing.
66function logTo($: EngineInterface, s: Strings, level: LogLevel, warned: { log: boolean }, entry: LogEntry, request?: JevTask) {
67 const io: LogIo = {
68 home: () => $.env.get('HOME'),
69 exists: (path) => $.fs.exists(path),
70 read: (path) => $.fs.read(path),
71 write: (path, text) => $.fs.write(path, text),
72 }
73 logDecision(io, level, entry, request, () => {
74 if (warned.log) return
75 warned.log = true
76 $.ui.toast(s.toastLog)
77 })
78}
79
80// A spawn this plugin caused, or one already on a profile of it, is never routed again.
81export const isOwnSpawn = (originPlugin: string, self: string, type: string) =>
82 originPlugin === self || PROFILE_MATCHER.test(type)
83
84type Routed = { model: Model; verifier?: Model; effort: Effort; profile: string; reasons: string[] }
85type Verification = { tool_use_id: string; prompt: string; verifier: Model; description: string }
86
87// tool.call (Agent) decides and applies the effort; agent.spawn, correlated by tool_use_id,
88// applies model and tool profile. Fail-open: never a deny.
89// `registered` holds the profiles whose registration succeeded; any other gets the model only.
90// `text` is read at each use: the language is only known at session.start.
91export function routeAgentSpawns(on: On, config: Config, registered: ReadonlySet<string>, text: { s: Strings }) {
92 // What agent.offer said of each type (source and listing line). The host offers agents to the model before it can name one in an Agent call.
93 const offered = new Map<string, Offered>()
94 const decisions = new Map<string, Routed>()
95 // Profiles with an Agent call in flight. agent.offer also gates a rewritten subagentType, so a
96 // profile is offered only during its own call and stays hidden from the model's listing.
97 // ponytail: a listing built during that window shows the profile; a per-dispatch flag if the host adds one
98 const dispatching = new Map<string, number>()
99 let warnedMissingKey = false
100 const warned = { log: false }
101 // ponytail: oldest-first eviction past the cap; a repeat older than 1000 routings is routed again; revisit if decisions.jsonl shows a tool_use_id routed twice
102 const routedIds = new Set<string>()
103 // Routed agents running, by agentId, freed at their turn.complete.
104 const active = new Set<string>()
105 // Slots taken by Agent calls in flight, by tool_use_id: moved to `active` at agent.spawn,
106 // released when the call ends without one.
107 const reserved = new Set<string>()
108 // Primaries whose decision named a verifier, by agentId, until their turn.complete.
109 const pendingVerify = new Map<string, Verification>()
110 // Routed agents (and verifiers) whose usage is still to be counted, by agentId, until their turn.complete.
111 const costed = new Map<string, { routed: Model; parentModel?: string; isVerifier?: boolean }>()
112 // The main loop's last request: the effort it asked for and the one it ran with; unknown until one is seen.
113 const efforts: Efforts = { user: '?', prompt: '?' }
114 // mainEffort: JEV's effort for each main-loop turn, by turnId, until the turn completes.
115 const mainEfforts = new Map<string, Effort>()
116 // Waiting on the queue is the hook's own time (no $ call is in flight), and the JEV timeout is
117 // a $.clock.sleep, paid from the budget too: the wait gets what is left of the budget after them.
118 const maxQueueWaitMs = HOOK_BUDGET_MS - JEV_TIMEOUT_MS - QUEUE_MARGIN_MS
119 const limiter = createLimiter(PARALLELISM, maxQueueWaitMs)
120
121 const markRouted = (id: string) => {
122 routedIds.add(id)
123 if (routedIds.size > ROUTED_IDS_CAP) routedIds.delete(routedIds.values().next().value!)
124 }
125
126 // mainEffort: one JEV call per typed prompt; the turn waits for it (up to JEV_TIMEOUT_MS).
127 // ponytail: no semaphore, the main loop runs one turn at a time
128 on('turn.start', async ($, e, next) => {
129 if (!config.mainEffort || config.mode !== 'auto' || !e.text.trim()) return next(e)
130 const task = { tarefa: e.text.slice(0, MAX_TASK_CHARS), contexto: MAIN_CONTEXT }
131 const entry = { tool_use_id: e.turnId, description: e.text.slice(0, 80), models: [] }
132 try {
133 const answers = await askJev(jevIo($), task, config)
134 const effort = desiredEffort(answers)
135 mainEfforts.set(e.turnId, effort)
136 const reasons = [`effort ${effort}: deliberation ${answers.deliberation.noul.toFixed(2)}`]
137 logTo($, text.s, config.logLevel, warned, { ...entry, outcome: 'main', effort, reasons, jev: jevNumbers(answers) }, task)
138 await track($, config, efforts, (savings) => ({ ...savings, route: `${text.s.mainRoute}·${effort}`, jevCalls: savings.jevCalls + 1 }))
139 } catch (err) {
140 // The prompt keeps the user's effort; a missing key toasts once, as for the agents.
141 const kind = err instanceof JevError ? err.kind : undefined
142 if (kind === 'missing_key' && !warnedMissingKey) {
143 warnedMissingKey = true
144 $.ui.toast(text.s.toastMissingKey)
145 }
146 logTo($, text.s, config.logLevel, warned, { ...entry, outcome: 'skip', reasons: [String(kind ?? err)], error: kind }, task)
147 }
148 return next(e)
149 })
150
151 on('turn.step', async function* ($, e, next) {
152 const wanted = e.agentId === undefined ? mainEfforts.get(e.turnId) : undefined
153 // A model without effort keeps none; maxEffort bounds JEV's pick and, unless mode=off, the user's own effort.
154 const main = e.agentId === undefined && config.mode !== 'off'
155 const target = wanted ?? (main && (EFFORTS as readonly string[]).includes(String(e.effort)) ? (e.effort as Effort) : undefined)
156 const effort = target === undefined || e.effort === undefined ? e.effort : projectEffort(target, config.efforts)
157 if (e.agentId === undefined && e.effort !== undefined) {
158 efforts.user = String(e.effort)
159 // The line is redrawn only when the shown effort changes, not on every step.
160 if (String(effort) !== efforts.prompt) {
161 efforts.prompt = String(effort)
162 await track($, config, efforts, (savings) => savings)
163 }
164 }
165 return yield* next(effort === e.effort ? e : { ...e, effort })
166 })
167
168 on('agent.offer', { agent: PROFILE_MATCHER }, (_$, e) => ({ isOffered: (dispatching.get(e.agent) ?? 0) > 0 }))
169
170 // Observes only: the offer goes on unchanged.
171 on('agent.offer', (_$, e, next) => {
172 offered.set(e.agent, { source: e.source, description: e.description })
173 return next(e)
174 })
175
176 const fallback = (reason: string): Routed => ({
177 model: config.fallback.model,
178 effort: config.fallback.effort,
179 profile: FALLBACK_PROFILE,
180 reasons: [reason],
181 })
182
183 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
184 const type = e.subagent_type ?? DEFAULT_TYPE
185 const marked = MARKER.test(e.description)
186 const untouched =
187 config.mode === 'off' ||
188 (config.mode === 'explicit' && !marked) ||
189 type === 'fork' ||
190 // ponytail: the tool marks it deprecated and ignored; a teammate shows only at agent.spawn (isTeammate); revisit if the Agent tool exposes a non-deprecated teammate flag
191 e.team_name !== undefined ||
192 !intercepts(config.agentScope, type, offered.get(type)) ||
193 isOwnSpawn(next.origin.plugin, $.plugin.name, type) ||
194 routedIds.has(e.tool_use_id)
195 if (untouched) return next(e)
196 markRouted(e.tool_use_id)
197
198 let routed: Routed
199 let answers: JevAnswers | undefined
200 let asked: JevTask | undefined
201 let error: JevErrorKind | undefined
202 let fellBack = false
203 try {
204 if (active.size + reserved.size >= MAX_AGENTS) throw new Error('maxAgents')
205 reserved.add(e.tool_use_id)
206 if (!(await limiter.acquire((ms, signal) => $.clock.sleep(ms, { signal })))) throw new Error('parallelism')
207 const hint = hintFor(type, offered.get(type), config.agentHints)
208 const task = { tarefa: e.prompt.slice(0, MAX_TASK_CHARS), contexto: `${type}${hint ? ` (${hint})` : ''}: ${e.description}` }
209 asked = task
210 try {
211 answers = await askJev(jevIo($), task, config)
212 } finally {
213 limiter.release()
214 }
215 const decision = decide(answers, config)
216 routed = { model: decision.models[0], verifier: decision.models[1], effort: decision.effort, profile: profileFor(decision), reasons: decision.reasons }
217 } catch (err) {
218 if (err instanceof JevError && err.kind === 'missing_key' && !warnedMissingKey) {
219 warnedMissingKey = true
220 $.ui.toast(text.s.toastMissingKey)
221 }
222 if (err instanceof JevError) error = err.kind
223 fellBack = true
224 routed = fallback(error ?? (err instanceof Error ? err.message : String(err)))
225 }
226
227 const description = e.description.replace(MARKER, '')
228 const models = routed.verifier ? [routed.model, routed.verifier] : [routed.model]
229 const jev = answers && jevNumbers(answers)
230 logTo($, text.s, config.logLevel, warned, { tool_use_id: e.tool_use_id, description, outcome: fellBack ? 'fallback' : 'routed', models, effort: routed.effort, profile: routed.profile, reasons: routed.reasons, jev, error }, asked)
231 const route = fellBack ? `${text.s.fallback} (${routed.reasons[0]})` : `${routed.model}·${routed.effort}·${routed.profile.replace(PROFILE_MATCHER, '')}`
232 await track($, config, efforts, (savings) => ({ ...savings, route, jevCalls: savings.jevCalls + (answers ? 1 : 0) }))
233
234 // The engine raises agent.spawn inside this next(); the entry lives only for that call.
235 decisions.set(e.tool_use_id, routed)
236 const { profile } = routed
237 dispatching.set(profile, (dispatching.get(profile) ?? 0) + 1)
238 try {
239 return await next({ ...e, effort: routed.effort, description })
240 } finally {
241 dispatching.set(profile, dispatching.get(profile)! - 1)
242 decisions.delete(e.tool_use_id)
243 reserved.delete(e.tool_use_id)
244 }
245 })
246
247 on('agent.spawn', async ($, e, next) => {
248 const routed = decisions.get(e.tool_use_id)
249 decisions.delete(e.tool_use_id)
250 if (!routed || isOwnSpawn(next.origin.plugin, $.plugin.name, e.subagentType) || e.fork || e.isTeammate || e.workflow) return next(e)
251
252 let spawned: AgentSpawnResult | undefined
253 // Only a generic agent trades its type for a tool profile; any other keeps its own prompt and tools and gets the model alone.
254 if (swapsProfile(e.subagentType, offered.get(e.subagentType)) && registered.has(routed.profile)) {
255 try {
256 spawned = await next({ ...e, model: routed.model, subagentType: routed.profile })
257 } catch {
258 // a hook beneath refused the profile: keep the model only
259 }
260 }
261 if (spawned === undefined || spawned.deny !== undefined) spawned = await next({ ...e, model: routed.model })
262 reserved.delete(e.tool_use_id)
263 if (spawned.agentId !== undefined) {
264 active.add(spawned.agentId)
265 costed.set(spawned.agentId, { routed: routed.model, parentModel: e.parentModel })
266 if (routed.verifier) pendingVerify.set(spawned.agentId, { tool_use_id: e.tool_use_id, prompt: e.prompt, verifier: routed.verifier, description: e.description })
267 }
268 return spawned
269 })
270
271 on('turn.complete', async ($, e, next) => {
272 if (e.agentId === undefined) return (mainEfforts.delete(e.turnId), next(e))
273 // ponytail: a background agent woken again runs more turns; it is freed and costed at the first turn.complete only; revisit if the host exposes an agent-ended event
274 active.delete(e.agentId)
275 const cost = costed.get(e.agentId)
276 costed.delete(e.agentId)
277 const usage = e.usage
278 if (cost && usage) await track($, config, efforts, (savings) => addAgent(savings, usage, cost.routed, cost.parentModel, PRICES, config.compareModel, cost.isVerifier))
279 const pending = pendingVerify.get(e.agentId)
280 pendingVerify.delete(e.agentId)
281 if (!pending || e.reason !== 'answer') return next(e)
282
283 // Started unawaited: the primary's answer never waits on its verifier. Its spawn carries this
284 // plugin's origin, so both routing hooks pass it through (isOwnSpawn).
285 const verify = async ({ tool_use_id, prompt, verifier, description }: Verification, answer: string) => {
286 if (!registered.has(VERIFY_PROFILE)) return $.ui.toast(text.s.toastVerifierProfile(VERIFY_PROFILE))
287 if (active.size + reserved.size >= MAX_AGENTS) return $.ui.toast(text.s.toastVerifierMax)
288 dispatching.set(VERIFY_PROFILE, (dispatching.get(VERIFY_PROFILE) ?? 0) + 1)
289 try {
290 const spawned = await $.agent.spawn({
291 prompt: `${prompt}\n\n--- Answer to review ---\n${answer}\n\n--- Your task ---\n${VERIFY_INSTRUCTION}`,
292 model: verifier,
293 subagentType: VERIFY_PROFILE,
294 description: `verify: ${description}`,
295 })
296 if (spawned.deny !== undefined) return $.ui.toast(text.s.toastVerifierDenied(spawned.deny))
297 if (spawned.agentId !== undefined) {
298 active.add(spawned.agentId)
299 costed.set(spawned.agentId, { routed: verifier, isVerifier: true })
300 }
301 logTo($, text.s, config.logLevel, warned, { tool_use_id, description, outcome: 'verifier', models: [verifier], profile: VERIFY_PROFILE, reasons: [] })
302 } finally {
303 dispatching.set(VERIFY_PROFILE, dispatching.get(VERIFY_PROFILE)! - 1)
304 }
305 }
306 verify(pending, e.answer).catch((err) => $.ui.toast(text.s.toastVerifierFailed(err instanceof Error ? err.message : String(err))))
307 return next(e)
308 })
309}
310src/domain/policy.ts 101 lines1import { EFFORTS, MODELS, type Config, type Effort, type Model, type Profile } from '../config'
2import type { JevAnswers } from '../jev/answers'
3import type { ToolGroup } from '../tools/groups'
4
5export type Decision = {
6 models: Model[]
7 effort: Effort
8 toolGroups: ToolGroup[]
9 profile: Profile
10 reasons: string[]
11}
12
13// ponytail: cortes não calibrados; recalibrar com ≥200 linhas de decisions.jsonl (distribuição de complexity/confidence por modelo escolhido)
14const HAIKU_BELOW = 0.75
15const SONNET_BELOW = 2.25
16const LOW_CONFIDENCE = 0.4
17const WEB_RESEARCH_FLOOR = 0.5
18// ponytail: cortes não calibrados — a verifier only on opus-tier complexity with confidence below this; recalibrar pela distribuição de confidence nas decisões opus de decisions.jsonl (≥200 linhas)
19const VERIFY_CONFIDENCE_BELOW = 0.5
20// ponytail: cortes não calibrados — deliberation noul abaixo de cada limite dá o effort correspondente; acima do último, max; recalibrar pela distribuição de deliberation em decisions.jsonl (≥200 linhas)
21const EFFORT_CUTS: readonly [number, Effort][] = [
22 [0.2, 'low'],
23 [0.4, 'medium'],
24 [0.6, 'high'],
25 [0.8, 'xhigh'],
26]
27
28// ponytail: cortes não calibrados; faltar uma tool custa um agent perdido, sobrar uma custa quase nada → shell/edit mais baixos; recalibrar com o replay (docs/dev/known-limits.md)
29const GROUP_THRESHOLDS = { edit: 0.3, shell: 0.3, web: 0.5, docs: 0.5, browser: 0.5 } as const satisfies Record<
30 Exclude<ToolGroup, 'read'>,
31 number
32>
33
34const GROUP_QUESTIONS = [
35 ['edits_files', 'edit'],
36 ['runs_shell', 'shell'],
37 ['web_research', 'web'],
38 ['library_docs', 'docs'],
39 ['browser', 'browser'],
40] as const
41
42export function decide(answers: JevAnswers, config: Config): Decision {
43 const toolGroups = groupsOf(answers)
44 const writes = toolGroups.includes('edit')
45 // A writing task never goes to explore.
46 const profile: Profile = writes ? 'general' : 'explore'
47 const model = projectModel(desiredTier(answers), config)
48 const effort = projectEffort(desiredEffort(answers), config.efforts)
49 const reasons = [
50 `profile ${profile}: ${writes ? 'writes' : 'read-only'}`,
51 `model ${model}: complexity ${answers.complexity.score.toFixed(2)}`,
52 `effort ${effort}: deliberation ${answers.deliberation.noul.toFixed(2)}`,
53 ]
54
55 const verifier = verifierFor(model, answers, config)
56 if (verifier) {
57 const { score, confidence } = answers.complexity
58 reasons.push(`verifier ${verifier}: complexity ${score.toFixed(2)}, confidence ${confidence.toFixed(2)}`)
59 }
60
61 return { models: verifier ? [model, verifier] : [model], effort, toolGroups, profile, reasons }
62}
63
64function groupsOf(answers: JevAnswers): ToolGroup[] {
65 const groups = GROUP_QUESTIONS.filter(([question, group]) => {
66 return answers[question].noul >= GROUP_THRESHOLDS[group]
67 }).map(([, group]) => group)
68 return ['read', ...groups]
69}
70
71function desiredTier({ complexity, web_research }: JevAnswers): number {
72 let tier = complexity.score < HAIKU_BELOW ? 0 : complexity.score < SONNET_BELOW ? 1 : 2
73 if (complexity.confidence < LOW_CONFIDENCE) tier = Math.min(tier + 1, MODELS.length - 1)
74 if (web_research.noul >= WEB_RESEARCH_FLOOR) tier = Math.max(tier, 1)
75 return tier
76}
77
78// The wanted tier, capped at maxModel.
79const projectModel = (tier: number, config: Config): Model => MODELS[Math.min(tier, MODELS.indexOf(config.maxModel))]
80
81// The strongest enabled model other than the primary, only when the task justifies a second look.
82function verifierFor(primary: Model, { complexity }: JevAnswers, config: Config): Model | undefined {
83 const justified =
84 config.multiModel &&
85 complexity.score >= SONNET_BELOW &&
86 complexity.confidence < VERIFY_CONFIDENCE_BELOW
87 if (!justified) return undefined
88 return MODELS.slice(0, MODELS.indexOf(config.maxModel) + 1).filter((model) => model !== primary).pop()
89}
90
91export function desiredEffort({ deliberation }: JevAnswers): Effort {
92 return EFFORT_CUTS.find(([limit]) => deliberation.noul < limit)?.[1] ?? 'max'
93}
94
95// Highest allowed effort not above the request; if the request is below all, the lowest allowed.
96export function projectEffort(wanted: Effort, allowed: Effort[]): Effort {
97 const rank = (effort: Effort) => EFFORTS.indexOf(effort)
98 const sorted = [...allowed].sort((a, b) => rank(a) - rank(b))
99 return sorted.filter((effort) => rank(effort) <= rank(wanted)).pop() ?? sorted[0]
100}
101src/tools/catalog.ts 12 lines1import type { ToolGroup } from './groups'
2
3// Names or `prefix*` patterns. Only tools seen in the built-in .d.ts / spike H7 list.
4export const GROUP_PATTERNS: Record<ToolGroup, readonly string[]> = {
5 read: ['Read', 'Grep', 'Glob'],
6 edit: ['Edit', 'Write', 'NotebookEdit'],
7 shell: ['Bash'],
8 web: ['WebSearch', 'WebFetch'],
9 docs: ['mcp__context7__*'],
10 browser: ['mcp__claude-in-chrome__*'],
11}
12src/tools/groups.ts 4 lines1export const TOOL_GROUPS = ['read', 'edit', 'shell', 'web', 'docs', 'browser'] as const
2
3export type ToolGroup = (typeof TOOL_GROUPS)[number]
4src/agents/scope.ts 40 lines1export const SCOPES = ['builtin', 'all'] as const
2export type Scope = (typeof SCOPES)[number]
3
4// The agents Claude Code ships; the fallback while agent.offer has not named a type's source yet.
5export const BUILTIN_AGENTS = ['general-purpose', 'Explore', 'Plan', 'claude', 'claude-code-guide', 'statusline-setup']
6// Generic agents: the plugin may swap them for one of its tool profiles. Any other agent keeps its
7// own type (prompt and tools) and gets only the model and the effort.
8export const GENERIC_AGENTS = ['general-purpose', 'Explore', 'claude']
9export const HINT_MAX = 120
10
11// What agent.offer said of a type: where its definition came from (`built-in`, `plugin`, a settings source) and its listing line.
12export type Offered = { source: string; description: string }
13
14// builtin: only Claude Code's own agents; all: those, the ones in .claude/agents and the plugins'.
15export function intercepts(scope: Scope, type: string, offered: Offered | undefined): boolean {
16 if (scope === 'all') return true
17 return offered ? offered.source === 'built-in' : BUILTIN_AGENTS.includes(type)
18}
19
20// A user's agent that shadows a generic name keeps its own type: only a built-in, or a type the host has not offered yet, may be swapped.
21export const swapsProfile = (type: string, offered: Offered | undefined) =>
22 GENERIC_AGENTS.includes(type) && (!offered || offered.source === 'built-in')
23
24// "name=text|name=text" → name → text, each text cut to HINT_MAX; an entry without both parts is dropped.
25export function parseHints(value: unknown): Map<string, string> {
26 const hints = new Map<string, string>()
27 if (typeof value !== 'string') return hints
28 for (const entry of value.split('|')) {
29 const at = entry.indexOf('=')
30 const name = entry.slice(0, at).trim()
31 const text = entry.slice(at + 1).trim()
32 if (at > 0 && name && text) hints.set(name, text.slice(0, HINT_MAX))
33 }
34 return hints
35}
36
37// What JEV reads beside the agent's name: the user's hint, else the host's listing line, cut to HINT_MAX.
38export const hintFor = (type: string, offered: Offered | undefined, hints: Map<string, string>): string =>
39 hints.get(type) ?? (offered?.description ?? '').replace(/\s+/g, ' ').trim().slice(0, HINT_MAX)
40src/jev/answers.ts 9 lines1import type { questions } from './questions'
2
3export type ScoreAnswer = { score: number; confidence: number }
4export type NoulAnswer = { noul: number }
5
6export type JevAnswers = {
7 [K in keyof typeof questions]: (typeof questions)[K]['type'] extends 'score' ? ScoreAnswer : NoulAnswer
8}
9