SLOPSHOPPER

jev-orchestrator

Routes Agent spawns through JEV (TypeSafe)

newguardcommandtoaststatusnetwork
★ 5v0.2.0MITupdated 2026-10-13hiyoku/jev-orc
A shopper browsing a rack in a slop shop
README

JEV Orchestrator

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.

Installation

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

Usage

How it works

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.

Plan

/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);
  • one file per task (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.

Run

/jev-orchestrator:run <NNN> executes the plan docs/dev/NNN_*/ (with no number, the latest one):

  1. It reads prompt.md and follows the Execution section, wave by wave.
  2. Each task of a wave becomes one subagent, spawned in parallel and routed by JEV (no model or effort is set by hand).
  3. Each agent gets its task file first, what the tasks it depends on delivered, and a short context.
  4. If a task fails, the waves that depend on it stop and you are told; independent waves go on.
  5. At the end you get a report per task (done, failed, or skipped), and the results are checked. Commits only happen when you ask.
/jev-orchestrator:run 001

Flow

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.
  • Fail-open: the MOD never denies a spawn. If JEV fails (missing key, timeout, network, http_*), the spawn proceeds on sonnet · medium (capped by maxModel/maxEffort) and the reason goes to the log.

JEV key

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.

Modes

modeBehavior
auto (default)JEV decides every spawn of the agents in scope (agentScope)
offdisabled; the status line shows jev OFF
explicitonly routes spawns whose description starts with [jev] (the marker is removed)

Toggle with the /jev-orc command:

CommandEffect
/jev-orctoggles auto ⇄ off (from explicit it goes to off)
/jev-orc on / off / explicitwrites auto / off / explicit
/jev-orc statusshows 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.

Status line

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.

Decision log

~/.claude/jev-orchestrator/decisions.jsonl, one JSON line per decision. Fields:

FieldContent
tsISO 8601
tool_use_idid of the Agent call
descriptiondescription, without the [jev] marker
outcomerouted, fallback, skip, or verifier
modelsmodels (the second is the verifier, if any)
effort, profileapplied decision
reasonswhy each choice was made
jevraw JEV numbers (scores, *_confidence, deliberation)
errormissing_key, timeout, network, bad_payload, or http_<status>
jev_requestonly 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).

Multi-model (optional)

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).

Main prompt effort (optional)

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.

Privacy

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.

Contributing

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.

  1. Open an issue to discuss it, or go straight to a pull request.
  2. Fork the repo, create a branch from main, and make your change. Small, focused PRs are easier to review.
  3. Run claude plugin test . and claude plugin validate .; both should pass.
  4. Open a pull request against main and say what changed and why. Commit messages follow the style of the history (feat(scope): summary).
  5. A merged PR releases the version in .claude-plugin/plugin.json: bump version there if you want a new tag and GitHub release. Without a bump nothing is released.

More

Source 17 files
hooks/register.ts 73 lines
1import 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}
73
src/command.ts 25 lines
1import 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}`
25
src/agents/profiles.ts 50 lines
1import 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
50
src/config.ts 123 lines
1import 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}
123
src/i18n.ts 80 lines
1// 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)}`
80
src/savings.ts 96 lines
1import 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}
96
src/hooks/spawn.ts 310 lines
1import { 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}
310
src/domain/policy.ts 101 lines
1import { 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}
101
src/tools/catalog.ts 12 lines
1import 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}
12
src/tools/groups.ts 4 lines
1export const TOOL_GROUPS = ['read', 'edit', 'shell', 'web', 'docs', 'browser'] as const
2
3export type ToolGroup = (typeof TOOL_GROUPS)[number]
4
src/agents/scope.ts 40 lines
1export 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)
40
src/jev/answers.ts 9 lines
1import 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