Enforces model-mix on delegated launches: names the model on Agent calls of the built-in types and on cloud and headless shell launches (--model sonnet), keeps…

Personal skills for running Claude Code as a coordinator that delegates work to subagents, cloud sessions and workflows, plus the tools around it. They work in any project.
| Skill | What it decides |
|---|---|
| model-mix | Which model and effort each piece of delegated work gets (Haiku 5.5 for short reading and small mechanical tickets, Sonnet 5.5 for implementation, Opus 5.5 for security, verification and final review), the profile for the plan (Pro, Max 5x, Max 20x), and a budget check that paces the weekly limit against the elapsed week, counting banked limit resets. |
| smart-ultracode | When a multi-agent workflow is worth it, how wide it should be within the plan's profile, which shape to use, and what to believe of its result. |
| coordinator-method | How delegated work runs: briefing cloud and local workers, merging only on green checks, safe git, a round of checks after every merge, the budget check before every launch, overnight loops, UI changes. Includes launch.exp, a launcher for Claude Code cloud sessions. |
| smart-mods | When a Claude Code mod is the right extension, how to write, test and install one globally, and how to vet someone else's mod before it runs with your permissions. |
| cloud-worker | How to launch, follow and collect cloud workers from this machine, Windows included, with launch.ps1. |
| merge-gate | The review and merge procedure for a finished PR: review sized to the diff, verified findings applied, merge on the pinned head, then the follow-up. |
| overnight | Running unattended overnight cheaply and safely: before sleeping, wake design, usage limits, keeping the machine awake, the morning summary. |
| roadmap-tracker | Tracker hygiene for the roadmap: pinned issue, milestones on every issue, labels mapped to the queue, tickets closed with the PR that landed them. |
| matt-bridge | How Matt Pocock's skills run inside the coordinator method, and which rule wins where they differ. |
| verified-research | Researching fast-changing facts with parallel readers, adversarial verifiers and a critic, and reporting them sorted by what survived. |
| release-watch | Cheap checks for Claude Code, Desktop, model, plan and upstream skill changes that should update the skills and mods. |
| skill-audit | Auditing skills for trigger quality, size, duplication, stale facts and token cost, with concrete edits. |
| context-hygiene | Keeping sessions cheap: what every turn pays, how to measure it, when to compact, clear or hand off. |
| windows-ops | What an agent must know on Windows 11: shells, Git Bash path conversion, long paths, line endings, missing tools, where Claude Code keeps its files. |
| mod-ui | The visual language of the mods: band, pane, chat card, toasts, theme colors, Desktop and terminal differences. |
| clean-code | Eight code-quality rules for code Claude writes or reviews (names, small functions, few arguments, no hidden side effects, KISS, DRY, YAGNI, SOLID), each blocking or advisory, with per-project overrides. |
| git-conventions | Default naming for commits, branches, PR titles and merge subjects (Conventional Commits, Conventional Branch), the history and tag rules, and a Node validator with a commit-msg hook for projects with no CI of its own. |
The skills point to each other instead of repeating themselves. A project's own AGENTS.md, CONTRIBUTING.md and branch protection always win over them; project facts stay in the project.
git clone https://github.com/emanueledenaro/claude-skills.git
cd claude-skills
cp -R model-mix smart-ultracode coordinator-method smart-mods cloud-worker merge-gate overnight roadmap-tracker matt-bridge verified-research release-watch skill-audit context-hygiene windows-ops mod-ui clean-code git-conventions ~/.claude/skills/
Then add a routing block to your global ~/.claude/CLAUDE.md, so every project reaches them at the right moment:
Before launching any agent, cloud session, workflow or review, use `model-mix`. Before writing a workflow script, use `smart-ultracode`. When coordinating delegated work (workers, merges, rounds, overnight loops, UI changes), use `coordinator-method`. Before writing, changing, installing or reviewing a Claude Code mod or any plugin, or when asked for a pane, a band above the prompt, a custom command or a tool-call guard, use `smart-mods`; for what a mod draws, also `mod-ui`.
Launching or steering a cloud worker: `cloud-worker`. Merging a finished PR: `merge-gate`. Going unattended or to sleep: `overnight`. Issues, milestones, the roadmap: `roadmap-tracker`. Running Matt Pocock's skills: `matt-bridge`. Researching facts that change (prices, limits, versions, APIs): `verified-research`. Checking for new releases or models: `release-watch`. Reviewing skills or their cost: `skill-audit`. A heavy or long session: `context-hygiene`. Shell, paths or tools on Windows: `windows-ops`. Writing, refactoring or reviewing code: `clean-code`. Writing a commit message, naming a branch, titling a PR or a merge commit: `git-conventions`.
Claude Code can tell Pro from Max, but not Max 5x from Max 20x. Add one line to the same ~/.claude/CLAUDE.md and keep it current:
Claude plan: Max 20x · reserve 10% · banked: weekly reset, expires 2026-10-22
weekly reset, expires YYYY-MM-DD, 5-hour reset, expires YYYY-MM-DD, or … reset, no expiry, separated by ;. Claude Code cannot read them and only you can redeem them, so delete an entry once you redeem it or it expires.usage file: ~/.claude/usage.json, if your statusline script saves its rate_limits there. Desktop sessions read usage directly.Without the line, the skills ask once and use the Pro profile on Pro, the Max 5x profile on Max.
Optional safety net: in the env block of ~/.claude/settings.json, add "CLAUDE_CODE_SUBAGENT_MODEL": "sonnet" next to the keys already there; do not replace the block. A general-purpose or workflow agent launched without a model then runs on Sonnet instead of the session's Opus, and an explicit opus still wins. It does not reach the built-in Explore and Plan agents, which stay on the session's model, or agents whose definition sets model: (including inherit), so the skills still name the model on every call. Do not add CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1: it also overrides the explicit opus the verify stages need.
From a repository root:
expect ~/.claude/skills/coordinator-method/launch.exp task.txt rules.txt worker.log sonnet high
The prompt is the task file, then the worker brief from coordinator-method, then the project's rules file (- for none). It prints the cloud session URL. It needs expect (macOS, Linux, or WSL on Windows; Git Bash does not ship it). On Windows without WSL, start the cloud session from Claude Desktop instead, or use cloud-worker's launch.ps1 in a terminal.
Two Claude Code mods in mods/ enforce and route the skills. They need Claude Code 2.1.287 or later in the terminal and 2.1.286 or later in the Desktop app (check with /status), and were tested with 2.1.293. They run unsandboxed with your permissions, like every mod: read them first (claude plugin validate mods/<name> lists what each one hooks and calls). None calls a model.
| Mod | What it does |
|---|---|
| model-guard | Applies model-mix to delegated launches. Agent calls: Sonnet for a general-purpose or Plan agent (or one with no type) launched without a model, an Explore agent without a model on Haiku at medium effort where the haiku alias is Haiku 5.5 (Claude Code 2.1.293 or later on the Anthropic API, with ANTHROPIC_DEFAULT_HAIKU_MODEL unset or on a Haiku 5.5 id) and on Sonnet elsewhere, the alias itself turned into Sonnet where it is not Haiku 5.5, medium effort added to a Haiku agent that names none, a Haiku 4.5 id turned into Sonnet, Fable turned into Opus, and isolation: 'remote' counted as a cloud session. Every spawned agent (Agent calls, teammates, workflow agents, other plugins' spawns) is judged on the model it will run on: a fork, an agent with no model or model: inherit, and a custom type with no model run on the session model or may inherit it, so in a Fable session they are refused with the advice to use general-purpose with model: 'opus', and the Haiku rules apply to an inherited model too. Custom agents are judged at spawn: the mod cannot read an agent definition's model, so a custom type with no model in the call is refused in a Fable session until the call names one. Workflow agents on Fable, on the alias where it is not Haiku 5.5 or on Haiku 4.5 are refused, and a run's width is capped by the profile that applied at its first agent; that record is kept in session state, so a reload of the mod keeps it, and a workflow resume while red or on Solo gets the plan's own width. From Bash, PowerShell and Monitor, claude --cloud, -p, --bg and a headless prompt get --model sonnet when they name none; Fable, the bare haiku alias and Haiku 4.5 are refused there (also as --fallback-model, in launch.exp and in cloud-worker's launch.ps1), so Haiku takes the full id claude-haiku-5-5; launches through Start-Process or cmd start, and claude launch flags it cannot read, are refused (run claude directly). Routines (RemoteTrigger) are cloud sessions: a model named in the body is checked the same way and Solo allows no new one. While the budget is red (or, with redPolicy warn, only flagged) or the 5-hour window is paused, new launches are refused. A workflow resume and a local session resume (--resume, --continue or --from-pr with -p or --bg) pass while red but wait while the 5-hour window is paused, since a resumed run can hit the limit and fail. Messages to workers sent directly with claude (claude -p "<msg>" --cloud <session> included), git, gh and merges are never blocked; a claude word with -p, --cloud or --bg after echo, git, gh, grep, ls, cat and similar programs, or in a comment, is read as text, and only a git command that runs claude itself (git bisect run claude -p ..., git rebase -x claude) is refused. With no usage reading yet, or a green one older than 10 minutes, the color is unknown and launches pass with one line, also in an unattended session, which a mod cannot tell apart from the Desktop app. A red or yellow reading older than 10 minutes keeps holding: $.session.usage() repeats the last figures, and an old reading never loosens the guard. Not covered, and left to the model like the other model-mix checks: run cost and the cost of work in flight, the Fable window, usage credits, Solo's one Agent call at most, a configured default subagent model, the effort that goes with Sonnet, and the Haiku stage and context rules (keeping Haiku off verify, judge and security work, sending work above about 100K tokens to Sonnet, the Opus review of the first Haiku ticket), since a mod cannot tell what an agent is for or how much it will read. |
| skill-router | Smart activation of skills: when your message calls for a skill that is not loaded (for example "vado a dormire" for overnight, "controlla le pr" for merge-gate), it adds one line telling Claude to load it and shows a dim line in the transcript; before the first workflow, agent, cloud launch, merge or mod edit of a session it asks once for the skills that step needs. Messages sent directly to cloud workers and subagents' own calls are never held; a claude launch through Start-Process or cmd start is held once like a cloud launch. Its gates are nudges and fail open: a gate that fails lets the call through. |
model-guard computes the budget color with budget.js, which follows model-mix/budget.md, from the Claude plan: line of your CLAUDE.md. Without that line (or the mod's planLine option) it cannot tell Pro from Max and assumes Max 5x, so on Pro add the line.
Install from this repository as a marketplace:
claude plugin marketplace add emanueledenaro/claude-skills
claude plugin install model-guard@emanueledenaro
claude plugin install skill-router@emanueledenaro
To try them first, run claude --plugin-dir mods/model-guard --plugin-dir mods/skill-router from a clone. Turn one off in /plugin (Desktop: + → Plugins → Manage plugins).
MIT
hooks/register.js 369 lines1// model-guard: enforces model-mix on delegated launches (Agent, Workflow, RemoteTrigger,
2// cloud and local headless sessions started from Bash, PowerShell or Monitor, and every spawned agent:
3// workflow agents, forks, teammates, other plugins' spawns).
4// The only module that touches `$`. Decisions live in rules.js, the budget maths in budget.js.
5// It never calls a model and never reads credentials: the plan comes from the person's
6// CLAUDE.md (prompt.context) or the planLine option, the usage from session.measure and
7// $.session.usage(). Whether the haiku alias is Haiku 5.5 comes from $.session.version() and the
8// provider variables ($.env.get), read only for Agent calls and spawned agents.
9// The plan and each workflow run's width record live in $.state (types/index.d.ts), so a reload of this
10// module keeps them; $.state itself starts over on /clear, /resume and /branch.
11
12import { computeBudget } from './budget.js'
13import {
14 decideAgent, decideRemoteTrigger, decideShell, decideSpawn, decideWorkflow, decideWorkflowAgent,
15 failureReason, fiveHourBanked, haikuAlias, planFromFiles, planFromOption, scanShell, statusText, texts,
16} from './rules.js'
17
18const RUNS = { plugin: 'model-guard', key: 'runs' } // one per runId: { admitted: number[], width, name? }
19const PLAN = { plugin: 'model-guard', key: 'plan' } // the plan line read from CLAUDE.md
20
21const config = { lang: 'it', redPolicy: 'deny', planLine: '' }
22const memo = {
23 plan: null, // parsePlanLine result from CLAUDE.md (kept in PLAN too)
24 planRead: false, // PLAN already read from $.state by this module
25 rateLimits: null, // last reading, from session.measure or $.session.usage()
26 readingAt: null, // when that reading was last known to be fresh (budget.md: 10 minutes)
27 unknownLogged: false,
28 fableLogged: false, // the Agent fable -> opus line already reached the transcript
29 status: null, // last status text sent; null = never sent
30 version: null, // $.session.version() answer, kept once read (the engine does not change under a module)
31 clockAt: null, // the last $.clock.now() answer, and the Date.now() it was read at (realAt)
32 realAt: null,
33}
34// runId -> { admitted: Set of agentIndex, width, name }: this module's copy of RUNS (width fixed at the
35// run's first agent or its resume), read and changed with no await between decision and count.
36const runs = new Map()
37const loggedRuns = new Set() // runId + ':' + line already in the transcript (later repeats go to debug)
38
39async function nowOf($) {
40 try {
41 const t = await $.clock.now()
42 if (typeof t === 'number' && Number.isFinite(t)) {
43 memo.clockAt = t
44 memo.realAt = Date.now()
45 return t
46 }
47 } catch {}
48 return Date.now()
49}
50
51// The time without a $ call (re-entry): the last $.clock.now() answer moved on by the real time since.
52function nowFromMemo() {
53 const real = Date.now()
54 return typeof memo.clockAt === 'number' ? memo.clockAt + Math.max(0, real - memo.realAt) : real
55}
56
57function sameReading(a, b) {
58 try {
59 return JSON.stringify(a) === JSON.stringify(b)
60 } catch {
61 return false
62 }
63}
64
65// The plan line a previous load of this module read (prompt.context may not run again after a reload).
66async function loadPlan($) {
67 if (memo.plan || memo.planRead) return
68 memo.planRead = true
69 try {
70 const { value } = await $.state.get(PLAN)
71 if (!memo.plan && value && typeof value === 'object' && Array.isArray(value.banked)) memo.plan = value
72 } catch {}
73}
74
75// The budget at decision time, from the last reading: $.session.usage() (free) or session.measure.
76// usage() answers what the last API response reported and carries no time, so only a reading that
77// changed counts as new; an unchanged one keeps the time a session.measure or a change last vouched for
78// it. After 10 minutes (budget.md) computeBudget keeps a red or yellow color and turns only green into
79// unknown: an old reading never loosens the guard.
80// Returns { budget, fiveHourBanked }.
81async function readBudget($) {
82 const now = await nowOf($)
83 try {
84 const usage = await $.session.usage()
85 if (usage && Array.isArray(usage.rateLimits) && usage.rateLimits.length) {
86 if (memo.readingAt === null || !sameReading(usage.rateLimits, memo.rateLimits)) memo.readingAt = now
87 memo.rateLimits = usage.rateLimits
88 }
89 } catch {}
90 await loadPlan($)
91 const plan = memo.plan || planFromOption(config.planLine)
92 const budget = computeBudget({ rateLimits: Array.isArray(memo.rateLimits) ? memo.rateLimits : [], now, plan, inFlight: 0, readingAt: memo.readingAt })
93 return { budget, fiveHourBanked: fiveHourBanked(plan, now) }
94}
95
96// Whether the haiku alias is Haiku 5.5 here (rules.haikuAlias): the engine's version, the variables that
97// move Claude Code off the Anthropic API, and the one that remaps the alias. A read that fails counts as unknown: haiku is then not kept.
98async function readHaiku($) {
99 if (!memo.version) {
100 try {
101 const v = await $.session.version()
102 if (v && typeof v === 'object') memo.version = v
103 } catch {}
104 }
105 let env = null
106 try {
107 env = {
108 CLAUDE_CODE_USE_BEDROCK: await $.env.get('CLAUDE_CODE_USE_BEDROCK'),
109 CLAUDE_CODE_USE_VERTEX: await $.env.get('CLAUDE_CODE_USE_VERTEX'),
110 CLAUDE_CODE_USE_FOUNDRY: await $.env.get('CLAUDE_CODE_USE_FOUNDRY'),
111 CLAUDE_CODE_USE_ANTHROPIC_AWS: await $.env.get('CLAUDE_CODE_USE_ANTHROPIC_AWS'),
112 CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD: await $.env.get('CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD'),
113 CLAUDE_CODE_USE_MANTLE: await $.env.get('CLAUDE_CODE_USE_MANTLE'),
114 CLAUDE_CODE_USE_GATEWAY: await $.env.get('CLAUDE_CODE_USE_GATEWAY'),
115 ANTHROPIC_BASE_URL: await $.env.get('ANTHROPIC_BASE_URL'),
116 ANTHROPIC_DEFAULT_HAIKU_MODEL: await $.env.get('ANTHROPIC_DEFAULT_HAIKU_MODEL'),
117 }
118 } catch {}
119 return haikuAlias(memo.version, env)
120}
121
122function syncStatus($, budget) {
123 const text = statusText(budget, config.redPolicy, config.lang)
124 if (text === memo.status) return
125 try {
126 $.ui.status(text)
127 memo.status = text
128 } catch {}
129}
130
131async function contextOf($) {
132 const { budget, fiveHourBanked: banked } = await readBudget($)
133 syncStatus($, budget)
134 if (budget.color !== 'unknown') memo.unknownLogged = false
135 return {
136 budget,
137 redPolicy: config.redPolicy,
138 lang: config.lang,
139 unknownLogged: memo.unknownLogged,
140 fableLogged: memo.fableLogged,
141 fiveHourBanked: banked,
142 }
143}
144
145function report($, decision) {
146 if (decision.unknownNoted) memo.unknownLogged = true
147 if (decision.fableNoted) memo.fableLogged = true
148 if (!decision.log) return
149 try {
150 $.ui.log(decision.log, { to: decision.to === 'debug' ? 'debug' : 'transcript' })
151 } catch {}
152}
153
154function refuse($, e, next) {
155 const kind = next.error ? next.error.kind : 'error'
156 try {
157 $.ui.log(texts(config.lang).failed, { to: 'transcript' })
158 $.ui.log('model-guard: check failed: ' + kind + (next.error && next.error.message ? ' (' + next.error.message + ')' : ''), { to: 'debug' })
159 } catch {}
160 return { deny: failureReason(kind) }
161}
162
163// ---------------------------------------------------------------- workflow runs ($.state RUNS)
164
165// Reads a run's record from $.state into `runs` when this module does not hold it (after a reload).
166async function loadRun($, runId) {
167 if (!runId || runs.has(runId)) return
168 let value
169 try {
170 value = (await $.state.get({ ...RUNS, id: runId })).value
171 } catch {}
172 if (runs.has(runId)) return // a parallel spawn of the run loaded or started it meanwhile
173 if (value && Array.isArray(value.admitted) && typeof value.width === 'number' && Number.isFinite(value.width)) {
174 runs.set(runId, {
175 admitted: new Set(value.admitted.filter(i => typeof i === 'number')),
176 width: value.width,
177 name: typeof value.name === 'string' ? value.name : undefined,
178 })
179 }
180}
181
182async function saveRun($, runId) {
183 const run = runs.get(runId)
184 if (!runId || !run) return
185 const value = { admitted: [...run.admitted], width: run.width }
186 if (typeof run.name === 'string') value.name = run.name
187 try {
188 await $.state.set({ ...RUNS, id: runId }, value)
189 } catch {}
190}
191
192// Applies a decideWorkflowAgent decision to `runs`, synchronously. Returns whether the record changed.
193function applyRun(runId, e, decision) {
194 let changed = false
195 if (decision.startRun && !runs.has(runId)) {
196 runs.set(runId, { admitted: new Set(), ...decision.startRun })
197 changed = true
198 }
199 if (decision.admit && runs.has(runId) && !runs.get(runId).admitted.has(e.workflow.agentIndex)) {
200 runs.get(runId).admitted.add(e.workflow.agentIndex)
201 changed = true
202 }
203 return changed
204}
205
206// ---------------------------------------------------------------- failure and re-entry
207
208// The check when an event rises beneath one of model-guard's own $ calls (another plugin's hook raising
209// it there): the host does not run the hook, and its $ calls reject. The same rules run here from memo
210// alone: the last reading, the plan, the engine version and the runs held in memory, with nowFromMemo()
211// for the time, no $ call and no line logged. What is unread counts against the launch: the
212// environment is not read, so the haiku alias is not Haiku 5.5; a launch with no valid reading
213// (unknownNoted) is refused, not passed with a note; and anything that throws refuses.
214function reentryDecision(e) {
215 const now = nowFromMemo()
216 const plan = memo.plan || planFromOption(config.planLine)
217 const budget = computeBudget({ rateLimits: Array.isArray(memo.rateLimits) ? memo.rateLimits : [], now, plan, inFlight: 0, readingAt: memo.readingAt })
218 const ctx = {
219 budget, redPolicy: config.redPolicy, lang: config.lang, unknownLogged: false, fableLogged: true,
220 fiveHourBanked: fiveHourBanked(plan, now), haiku: haikuAlias(memo.version, null),
221 }
222 if (typeof e.tool !== 'string') {
223 if (!e.workflow) return decideSpawn(e, ctx)
224 const runId = typeof e.workflow.runId === 'string' ? e.workflow.runId : ''
225 const d = decideWorkflowAgent(e, ctx, runs.get(runId))
226 applyRun(runId, e, d) // in memory; the run's next spawn saves it
227 return d
228 }
229 if (e.tool === 'Agent') return decideAgent(e, ctx)
230 if (e.tool === 'Workflow') return decideWorkflow(e, ctx)
231 if (e.tool === 'RemoteTrigger') return decideRemoteTrigger(e, ctx)
232 if (e.tool === 'Monitor' && (e.command === undefined || e.command === null)) return { action: 'allow' }
233 if (e.tool === 'Bash' || e.tool === 'PowerShell' || e.tool === 'Monitor') return decideShell(e, ctx, scanShell(e.command))
234 return { action: 'deny', reason: failureReason('re-entry') }
235}
236
237function reentryRewriteReason(d) {
238 return `model-guard checked this call where it cannot change it (it was raised inside model-guard's own check), so it was not run. Run it again with this change: ${d.reason}`
239}
240
241// The .catch every guard shares: re-entry runs the check from memo (reentryDecision), a hook that
242// already called next keeps what next settled to, anything else refuses with the event's own deny.
243async function guardFailed($, e, next) {
244 if (next.error && next.error.kind === 're-entry') {
245 let d
246 try {
247 d = reentryDecision(e)
248 } catch {
249 return { deny: failureReason('re-entry') }
250 }
251 if (d.action === 'deny') return { deny: d.reason }
252 if (d.unknownNoted) {
253 return { deny: 'model-guard checked this launch where it cannot read the budget (it was raised inside model-guard\'s own check) and has no current reading, so it was not run. Run it again.' }
254 }
255 // Beneath its own frame next runs on the call as raised, so a rewrite cannot be applied: refuse.
256 if (d.action === 'rewrite') return { deny: reentryRewriteReason(d) }
257 return next(e)
258 }
259 if (next.called) return next(e)
260 return refuse($, e, next)
261}
262
263async function passOn($, e, next) {
264 return next(e)
265}
266
267async function applyToolDecision($, e, next, decision) {
268 report($, decision)
269 if (decision.action === 'deny') return { deny: decision.reason }
270 if (decision.action === 'rewrite') return next(decision.input)
271 return next(e)
272}
273
274export function register(on, options) {
275 const opts = options || {}
276 config.lang = opts.language === 'en' ? 'en' : 'it'
277 config.redPolicy = opts.redPolicy === 'warn' ? 'warn' : 'deny'
278 config.planLine = typeof opts.planLine === 'string' ? opts.planLine : ''
279
280 // Observers: they record and pass the event on unchanged.
281 on('prompt.context', async ($, e, next) => {
282 const plan = planFromFiles(e.instructionFiles)
283 if (plan && !sameReading(plan, memo.plan)) {
284 memo.plan = plan
285 try {
286 await $.state.set(PLAN, plan)
287 } catch {}
288 }
289 return next(e)
290 }).catch(passOn)
291
292 on('session.measure', async ($, e, next) => {
293 try {
294 if (Array.isArray(e.rateLimits) && e.rateLimits.length) {
295 memo.rateLimits = e.rateLimits
296 memo.readingAt = await nowOf($)
297 const plan = memo.plan || planFromOption(config.planLine)
298 syncStatus($, computeBudget({ rateLimits: memo.rateLimits, now: memo.readingAt, plan, inFlight: 0, readingAt: memo.readingAt }))
299 }
300 } catch {}
301 return next(e)
302 }).catch(passOn)
303
304 // Guards: each refuses with a deny when it fails; on re-entry the .catch runs the check from memo.
305 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
306 const ctx = await contextOf($)
307 ctx.haiku = await readHaiku($)
308 return applyToolDecision($, e, next, decideAgent(e, ctx))
309 }).catch(guardFailed)
310
311 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
312 const ctx = await contextOf($)
313 const decision = decideWorkflow(e, ctx)
314 // A resume of a run this module does not know (lost on a reload): record it with the width the
315 // decision gives, so its agents are not refused as a new run while red.
316 if (decision.action !== 'deny' && decision.startRun) {
317 const runId = e.resumeFromRunId
318 await loadRun($, runId)
319 if (!runs.has(runId)) {
320 runs.set(runId, { admitted: new Set(), ...decision.startRun })
321 await saveRun($, runId)
322 }
323 }
324 return applyToolDecision($, e, next, decision)
325 }).catch(guardFailed)
326
327 on('tool.call', { tool: 'RemoteTrigger' }, async ($, e, next) => {
328 const ctx = await contextOf($)
329 return applyToolDecision($, e, next, decideRemoteTrigger(e, ctx))
330 }).catch(guardFailed)
331
332 // Every tool that runs a shell command; a Monitor watch without a command (a WebSocket) passes.
333 on('tool.call', { tool: ['Bash', 'PowerShell', 'Monitor'] }, async ($, e, next) => {
334 if (e.tool === 'Monitor' && (e.command === undefined || e.command === null)) return next(e)
335 const launches = scanShell(e.command)
336 if (!launches.length) return next(e)
337 const ctx = await contextOf($)
338 return applyToolDecision($, e, next, decideShell(e, ctx, launches))
339 }).catch(guardFailed)
340
341 // Every spawn: the model it will run on (a fork or an agent that inherits runs on the session's, so
342 // Fable there is refused); a workflow agent also goes through its run's width.
343 on('agent.spawn', async ($, e, next) => {
344 if (!e.workflow) {
345 const decision = decideSpawn(e, { lang: config.lang, haiku: await readHaiku($) })
346 report($, decision)
347 if (decision.action === 'deny') return { deny: decision.reason }
348 return next(e)
349 }
350 const ctx = await contextOf($)
351 ctx.haiku = await readHaiku($)
352 const runId = typeof e.workflow.runId === 'string' ? e.workflow.runId : ''
353 await loadRun($, runId)
354 // Decide and count with no await in between, so parallel spawns of one run cannot both pass the cap.
355 const decision = decideWorkflowAgent(e, ctx, runs.get(runId))
356 const changed = applyRun(runId, e, decision)
357 // One transcript line per run and kind of refusal; a 30-agent run past width 16 repeats it to debug.
358 if (decision.log && decision.to !== 'debug') {
359 const key = runId + ':' + decision.log
360 if (loggedRuns.has(key)) decision.to = 'debug'
361 else loggedRuns.add(key)
362 }
363 report($, decision)
364 if (changed) await saveRun($, runId)
365 if (decision.action === 'deny') return { deny: decision.reason }
366 return next(e)
367 }).catch(guardFailed)
368}
369hooks/budget.js 146 lines1// Budget color from a usage reading, as model-mix/budget.md computes it.
2// Pure functions only: no `$`, so each mod imports its own copy. mods/model-guard/hooks/budget.js and
3// coordinator-lens's hooks/budget.js (branch feature/coordinator-lens) are the same file: keep the two identical.
4// A mod cannot import outside its folder, so each mod's tests pin the same fingerprint of this module.
5
6const DAY = 24 * 60 * 60 * 1000
7const WEEK = 7 * DAY
8const READING_MAX_AGE = 10 * 60 * 1000
9
10export const PROFILES = {
11 'Max 20x': { name: 'Max 20x', width: 16, cloud: 3, verify: 'opus-high-per-finding', reserve: 10 },
12 'Max 5x': { name: 'Max 5x', width: 8, cloud: 1, verify: 'opus-high-batched', reserve: 15 },
13 'Pro': { name: 'Pro', width: 4, cloud: 1, verify: 'opus-medium-final', reserve: 25 },
14 'Solo': { name: 'Solo', width: 0, cloud: 0, verify: 'main-session', reserve: null },
15}
16
17const LADDER = ['Max 20x', 'Max 5x', 'Pro', 'Solo']
18
19export function stepDown(name) {
20 const i = LADDER.indexOf(name)
21 return LADDER[Math.min(i < 0 ? 1 : i + 1, LADDER.length - 1)]
22}
23
24export function planName(text) {
25 if (!text) return null
26 if (/max\s*20\s*x/i.test(text)) return 'Max 20x'
27 if (/max\s*5\s*x/i.test(text)) return 'Max 5x'
28 if (/\bpro\b/i.test(text)) return 'Pro'
29 return null
30}
31
32// Reads `Claude plan: Max 20x · reserve 10% · banked: weekly reset, expires 2026-10-22`.
33// Returns null when the text has no plan line.
34export function parsePlanLine(text) {
35 if (typeof text !== 'string') return null
36 const m = /^[ \t>*-]*Claude plan:[ \t]*(.+)$/im.exec(text)
37 if (!m) return null
38 const parts = m[1].split(/[·|]/).map(s => s.trim()).filter(Boolean)
39 const info = { name: planName(parts[0]), reserve: null, banked: [], usageFile: null, raw: m[1].trim() }
40 for (const part of parts.slice(1)) {
41 const reserve = /^reserve\s+(\d+(?:\.\d+)?)\s*%?$/i.exec(part)
42 if (reserve) { info.reserve = Number(reserve[1]); continue }
43 const banked = /^banked\s*:\s*(.*)$/i.exec(part)
44 if (banked) {
45 for (const entry of banked[1].split(';').map(s => s.trim()).filter(Boolean)) {
46 const type = /5\s*-?\s*hour/i.test(entry) ? '5-hour' : /weekly/i.test(entry) ? 'weekly' : null
47 if (!type) continue
48 const date = /expires\s+(\d{4}-\d{2}-\d{2})/i.exec(entry)
49 info.banked.push({ type, expires: date ? date[1] : null })
50 }
51 continue
52 }
53 const file = /^usage file\s*:\s*(.+)$/i.exec(part)
54 if (file) info.usageFile = file[1].trim()
55 }
56 return info
57}
58
59function windowOf(rateLimits, kind) {
60 const w = (rateLimits || []).find(r => r && r.kind === kind)
61 if (!w || typeof w.percentUsed !== 'number') return null
62 const resetsAt = w.resetsAt ? Date.parse(w.resetsAt) : NaN
63 return { used: w.percentUsed, resetsAt: Number.isFinite(resetsAt) ? resetsAt : null }
64}
65
66function utcDay(ms) {
67 return new Date(ms).toISOString().slice(0, 10)
68}
69
70// Weekly resets still worth counting: not redeemed (the caller drops those), with an expiry date after today.
71export function countedResets(plan, now) {
72 const today = utcDay(now)
73 return (plan && plan.banked ? plan.banked : [])
74 .filter(b => b.type === 'weekly' && b.expires && b.expires > today)
75 .map(b => ({ ...b, weeks: Math.max(1, (Date.parse(b.expires + 'T00:00:00Z') - now) / WEEK) }))
76}
77
78// Banked weekly resets not yet lost: no expiry date, or one not before today (budget.md: ask to redeem
79// one past 100 - reserve with 2 days left, whether or not it counts in the pace).
80export function bankedWeekly(plan, now) {
81 const today = utcDay(now)
82 return (plan && Array.isArray(plan.banked) ? plan.banked : [])
83 .filter(b => b && b.type === 'weekly' && (!b.expires || b.expires >= today)).length
84}
85
86// input: { rateLimits, now, plan (parsePlanLine result or null), inFlight (weekly points still to come), readingAt }
87export function computeBudget(input) {
88 const now = input.now
89 const plan = input.plan || null
90 const known = !!(plan && plan.name)
91 const planProfile = PROFILES[known ? plan.name : 'Max 5x']
92 const reserve = plan && typeof plan.reserve === 'number' ? plan.reserve : planProfile.reserve
93 const weekly = windowOf(input.rateLimits, 'seven_day')
94 const fiveHour = windowOf(input.rateLimits, 'five_hour')
95 const inFlight = Math.max(0, input.inFlight || 0)
96 const base = {
97 plan: { name: planProfile.name, known, reserve },
98 weekly, fiveHour, inFlight,
99 pausedFiveHour: !!(fiveHour && fiveHour.used >= 90 && (!fiveHour.resetsAt || fiveHour.resetsAt > now)),
100 resets: countedResets(plan, now),
101 bankedWeekly: bankedWeekly(plan, now),
102 }
103 const unknown = reason => ({ ...base, color: 'unknown', reason, pace: null, margin: null, d: null, lastHours: false, profile: planProfile })
104 if (!weekly) return unknown('no-reading')
105 if (!weekly.resetsAt || weekly.resetsAt <= now) return unknown('window-expired')
106 const hoursToReset = (weekly.resetsAt - now) / (60 * 60 * 1000)
107 const d = Math.min(7, Math.max(0.5, 7 - hoursToReset / 24))
108 const boost = base.resets.reduce((sum, r) => sum + 1 / r.weeks, 0)
109 const pace = d / 7 * 100 * (1 + boost)
110 const margin = weekly.used + inFlight - pace
111 const overReserve = weekly.used >= 100 - reserve
112 const lastHours = hoursToReset <= 12
113 let color
114 if (overReserve) color = 'red'
115 else if (lastHours) color = 'green'
116 else if (margin <= 10) color = 'green'
117 else if (margin <= 25) color = 'yellow'
118 else color = 'red'
119 const profile = color === 'red'
120 ? { name: 'Red', width: 0, cloud: 0, verify: 'main-session', reserve: null }
121 : color === 'yellow' ? PROFILES[stepDown(planProfile.name)] : planProfile
122 // A reading older than 10 minutes (budget.md) never loosens the guard: a red or yellow one keeps holding
123 // (an unchanged usage() reading also means nothing moved), and only a green one turns unknown, which
124 // allows the same launches with one line. The 5-hour pause in base holds either way.
125 const stale = typeof input.readingAt === 'number' && now - input.readingAt > READING_MAX_AGE
126 if (stale && color === 'green') return unknown('stale-reading')
127 return { ...base, color, reason: overReserve ? 'over-reserve' : lastHours ? 'last-12-hours' : 'pace', pace, margin, d, lastHours, profile }
128}
129
130// Weekly points a run would cost, from the run-cost unit: points per Sonnet-weighted agent (Haiku counts 0.05,
131// Opus 2, Fable 5). Haiku 5.5 is about 1/20 of Sonnet per token while its prompt stays under 100K tokens.
132export function estimatePoints(agents, unitPoints) {
133 if (typeof unitPoints !== 'number' || !(unitPoints > 0)) return null
134 const a = agents || {}
135 return unitPoints * (0.05 * (a.haiku || 0) + (a.sonnet || 0) + 2 * (a.opus || 0) + 5 * (a.fable || 0) + (a.other || 0))
136}
137
138export function modelFamily(model) {
139 if (!model) return null
140 if (/fable/i.test(model)) return 'fable'
141 if (/opus/i.test(model)) return 'opus'
142 if (/sonnet/i.test(model)) return 'sonnet'
143 if (/haiku/i.test(model)) return 'haiku'
144 return 'other'
145}
146hooks/rules.js 1621 lines1// model-guard decisions: pure functions, data in, data out. No `$` here.
2// Each decide* returns { action: 'allow'|'rewrite'|'deny', input?, reason, log, to?, ... }.
3// input the whole rewritten tool input (rewrite only)
4// reason English, short, for the model (deny) or the debug log (rewrite/allow)
5// log the line the person sees, in their language ('' when nothing to say)
6// to where the log goes: 'transcript' (default) or 'debug'
7// A malformed event (a field of the wrong type) throws: the guard's .catch then refuses it.
8
9import { PROFILES, modelFamily, parsePlanLine } from './budget.js'
10
11// Agent types that inherit the session model when no model is given (Opus by default).
12export const INHERITING_TYPES = ['general-purpose', 'Explore', 'Plan']
13
14// RemoteTrigger actions that start or schedule work; the rest only read.
15export const LAUNCH_ACTIONS = ['create', 'update', 'run', 'create_webhook_trigger']
16
17// claude's own subcommands: they manage the install or local sessions and never start one.
18const MANAGEMENT = [
19 'plugin', 'plugins', 'mcp', 'auth', 'update', 'upgrade', 'agents', 'doctor', 'config', 'attach', 'logs',
20 'stop', 'kill', 'rm', 'respawn', 'install', 'setup-token', 'auto-mode', 'import', 'purge', 'gateway',
21]
22
23// ---------------------------------------------------------------- texts
24
25// Why the haiku alias is not Haiku 5.5 here (haikuAlias's `why`), in the person's language and the model's.
26const HAIKU_WHY = {
27 it: {
28 old: 'prima di Claude Code 2.1.293 haiku è Haiku 4.5',
29 unknown: 'versione di Claude Code non letta, haiku può essere Haiku 4.5',
30 provider: 'fuori dall\'API Anthropic haiku è Haiku 4.5',
31 env: 'variabili d\'ambiente non lette, haiku può essere Haiku 4.5',
32 remapped: 'ANTHROPIC_DEFAULT_HAIKU_MODEL fa puntare haiku a un altro modello',
33 remote: 'agente in cloud: la sessione cloud ha la sua versione',
34 },
35 en: {
36 old: 'before Claude Code 2.1.293 haiku is Haiku 4.5',
37 unknown: 'Claude Code version not read, haiku may be Haiku 4.5',
38 provider: 'off the Anthropic API haiku is Haiku 4.5',
39 env: 'environment not read, haiku may be Haiku 4.5',
40 remapped: 'ANTHROPIC_DEFAULT_HAIKU_MODEL points haiku at another model',
41 remote: 'cloud agent: the cloud session runs its own version',
42 },
43 model: {
44 old: 'this Claude Code is older than 2.1.293, where the haiku alias is still Haiku 4.5',
45 unknown: 'model-guard could not read the Claude Code version, and before 2.1.293 the haiku alias is Haiku 4.5',
46 provider: 'this session runs on Bedrock, Google Cloud, Microsoft Foundry, Claude Platform on AWS or a gateway, where the haiku alias is still Haiku 4.5',
47 env: 'model-guard could not read the environment that tells the provider, and off the Anthropic API the haiku alias is Haiku 4.5',
48 remapped: 'ANTHROPIC_DEFAULT_HAIKU_MODEL points the haiku alias at a model other than Haiku 5.5',
49 remote: "isolation 'remote' runs in a cloud session on its own Claude Code version, where the haiku alias may still be Haiku 4.5",
50 },
51}
52
53const TEXTS = {
54 it: {
55 red: 'rosso', yellow: 'giallo', green: 'verde', unknown: 'sconosciuto',
56 agentNoModel: 'model-guard: agente senza modello -> sonnet',
57 explore: kept => `model-guard: Explore senza modello -> haiku${kept ? '' : ', effort medium'}`,
58 haiku: why => `model-guard: haiku -> sonnet (${HAIKU_WHY.it[why] || HAIKU_WHY.it.unknown})`,
59 haikuOld: 'model-guard: Haiku 4.5 -> sonnet (model-mix non fissa mai Haiku 4.5)',
60 fable: 'model-guard: fable -> opus sugli agenti (un mod non legge la finestra Fable: niente Fable fuori dalla sessione principale)',
61 ownModel: t => `model-guard: tipo ${t} senza modello, lasciato al modello della sua definizione`,
62 deniedRed: b => `model-guard: lancio bloccato, budget rosso (${budgetBrief(b, 'it')})`,
63 deniedPaused: at => `model-guard: lancio bloccato, finestra 5 ore oltre il 90%${at ? ' fino alle ' + at : ''}`,
64 warnRed: b => `model-guard: budget rosso (${budgetBrief(b, 'it')}), lancio consentito (solo segnalato)`,
65 resumeRed: (b, what) => `model-guard: budget rosso (${budgetBrief(b, 'it')}), ripresa ${what === 'session' ? 'della sessione' : 'del workflow'} consentita (lavoro aperto)`,
66 unknown: 'model-guard: nessuna lettura del budget ancora, lancio consentito',
67 unknownStale: 'model-guard: lettura del budget scaduta, lancio consentito',
68 cloudModel: 'model-guard: claude --cloud senza --model -> --model sonnet',
69 localModel: 'model-guard: claude -p/--bg senza --model -> --model sonnet',
70 nested: 'model-guard: lancio di claude senza --model in uno script annidato, bloccato',
71 cloudBad: m => `model-guard: sessione cloud con modello ${m} bloccata`,
72 localBad: m => `model-guard: sessione headless con modello ${m} bloccata`,
73 launchBad: (m, script) => `model-guard: ${script || 'launch.exp'} con modello ${m} bloccato`,
74 unparsed: 'model-guard: claude con -p, --cloud o --bg in un punto che model-guard non legge, bloccato: va lanciato claude direttamente con --model',
75 modelUnread: 'model-guard: lancio di claude con un --model vuoto o variabile, bloccato',
76 wfFable: 'model-guard: agente di workflow su Fable bloccato, va fissato opus',
77 spawnFable: why => `model-guard: agente su Fable bloccato (${why === 'fork' ? 'un fork gira sul modello della sessione' : why === 'definition' ? 'il suo tipo può ereditare il modello della sessione' : 'eredita il modello della sessione'}), va usato general-purpose con opus`,
78 spawnHaiku: why => `model-guard: agente su haiku bloccato (${HAIKU_WHY.it[why] || HAIKU_WHY.it.unknown}), va fissato sonnet`,
79 spawnHaikuOld: 'model-guard: agente su Haiku 4.5 bloccato, va fissato sonnet o claude-haiku-5-5',
80 routineBad: m => `model-guard: routine con modello ${m} bloccata`,
81 wfHaiku: why => `model-guard: agente di workflow su haiku bloccato (${HAIKU_WHY.it[why] || HAIKU_WHY.it.unknown}), va fissato sonnet`,
82 wfHaikuOld: 'model-guard: agente di workflow su Haiku 4.5 bloccato, va fissato sonnet o claude-haiku-5-5',
83 wfWidth: p => `model-guard: workflow oltre la larghezza ${p.width} (${profileLabel(p, 'it')}), altri agenti bloccati`,
84 wfWidthWarn: p => `model-guard: workflow oltre la larghezza ${p.width} (${profileLabel(p, 'it')}), altri agenti consentiti (solo segnalato)`,
85 wfSolo: p => `model-guard: workflow bloccato, il profilo di oggi (${profileLabel(p, 'it')}) non ne consente`,
86 cloudSolo: p => `model-guard: sessione cloud bloccata, il profilo di oggi (${profileLabel(p, 'it')}) non ne consente`,
87 indirect: 'model-guard: lancio di claude tramite Start-Process o start bloccato, va lanciato direttamente',
88 failed: 'model-guard: controllo fallito, lancio bloccato',
89 status: (b, warn) => `model-guard: budget rosso · ${budgetBrief(b, 'it')} · ${warn ? 'lanci solo segnalati' : 'nuovi lanci bloccati'}`,
90 },
91 en: {
92 red: 'red', yellow: 'yellow', green: 'green', unknown: 'unknown',
93 agentNoModel: 'model-guard: agent without a model -> sonnet',
94 explore: kept => `model-guard: Explore without a model -> haiku${kept ? '' : ', effort medium'}`,
95 haiku: why => `model-guard: haiku -> sonnet (${HAIKU_WHY.en[why] || HAIKU_WHY.en.unknown})`,
96 haikuOld: 'model-guard: Haiku 4.5 -> sonnet (model-mix never pins Haiku 4.5)',
97 fable: 'model-guard: fable -> opus on agents (a mod cannot read the Fable window: no Fable outside the main session)',
98 ownModel: t => `model-guard: type ${t} has no model, left to its definition's model`,
99 deniedRed: b => `model-guard: launch blocked, budget red (${budgetBrief(b, 'en')})`,
100 deniedPaused: at => `model-guard: launch blocked, 5-hour window past 90%${at ? ' until ' + at : ''}`,
101 warnRed: b => `model-guard: budget red (${budgetBrief(b, 'en')}), launch allowed (only flagged)`,
102 resumeRed: (b, what) => `model-guard: budget red (${budgetBrief(b, 'en')}), ${what === 'session' ? 'session' : 'workflow'} resume allowed (open work)`,
103 unknown: 'model-guard: no budget reading yet, launch allowed',
104 unknownStale: 'model-guard: budget reading out of date, launch allowed',
105 cloudModel: 'model-guard: claude --cloud without --model -> --model sonnet',
106 localModel: 'model-guard: claude -p/--bg without --model -> --model sonnet',
107 nested: 'model-guard: claude launch without --model inside a nested script, blocked',
108 cloudBad: m => `model-guard: cloud session on ${m} blocked`,
109 localBad: m => `model-guard: headless session on ${m} blocked`,
110 launchBad: (m, script) => `model-guard: ${script || 'launch.exp'} on ${m} blocked`,
111 unparsed: 'model-guard: claude with -p, --cloud or --bg where model-guard cannot read it, blocked: run claude directly with --model',
112 modelUnread: 'model-guard: claude launch with an empty or variable --model, blocked',
113 wfFable: 'model-guard: workflow agent on Fable blocked, pin opus',
114 spawnFable: why => `model-guard: agent on Fable blocked (${why === 'fork' ? 'a fork runs on the session model' : why === 'definition' ? 'its type may inherit the session model' : 'it inherits the session model'}), use general-purpose with opus`,
115 spawnHaiku: why => `model-guard: agent on haiku blocked (${HAIKU_WHY.en[why] || HAIKU_WHY.en.unknown}), pin sonnet`,
116 spawnHaikuOld: 'model-guard: agent on Haiku 4.5 blocked, pin sonnet or claude-haiku-5-5',
117 routineBad: m => `model-guard: routine on ${m} blocked`,
118 wfHaiku: why => `model-guard: workflow agent on haiku blocked (${HAIKU_WHY.en[why] || HAIKU_WHY.en.unknown}), pin sonnet`,
119 wfHaikuOld: 'model-guard: workflow agent on Haiku 4.5 blocked, pin sonnet or claude-haiku-5-5',
120 wfWidth: p => `model-guard: workflow past width ${p.width} (${profileLabel(p, 'en')}), further agents blocked`,
121 wfWidthWarn: p => `model-guard: workflow past width ${p.width} (${profileLabel(p, 'en')}), further agents allowed (only flagged)`,
122 wfSolo: p => `model-guard: workflow blocked, today's profile (${profileLabel(p, 'en')}) allows none`,
123 cloudSolo: p => `model-guard: cloud session blocked, today's profile (${profileLabel(p, 'en')}) allows none`,
124 indirect: 'model-guard: claude launch through Start-Process or start blocked, run it directly',
125 failed: 'model-guard: check failed, launch blocked',
126 status: (b, warn) => `model-guard: budget red · ${budgetBrief(b, 'en')} · ${warn ? 'launches only flagged' : 'new launches blocked'}`,
127 },
128}
129
130export function texts(lang) {
131 return lang === 'en' ? TEXTS.en : TEXTS.it
132}
133
134function pad(n) {
135 return String(n).padStart(2, '0')
136}
137
138// 'HH:MM UTC'
139export function clockUtc(ms) {
140 if (typeof ms !== 'number' || !Number.isFinite(ms)) return null
141 const d = new Date(ms)
142 return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`
143}
144
145// 'YYYY-MM-DD HH:MM UTC' (en, model) or 'DD/MM HH:MM UTC' (it)
146export function dateUtc(ms, lang) {
147 if (typeof ms !== 'number' || !Number.isFinite(ms)) return null
148 const d = new Date(ms)
149 const time = clockUtc(ms)
150 if (lang === 'it') return `${pad(d.getUTCDate())}/${pad(d.getUTCMonth() + 1)} ${time}`
151 return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${time}`
152}
153
154function signed(n) {
155 const r = Math.round(n)
156 return (r > 0 ? '+' : '') + r
157}
158
159// A profile's name in the person's lines: the Red profile reads as the color, in their language.
160function profileLabel(p, lang) {
161 return p && p.name === 'Red' ? (lang === 'it' ? 'rosso' : 'red') : p ? p.name : ''
162}
163
164function budgetBrief(b, lang) {
165 const it = lang === 'it'
166 const parts = []
167 if (typeof b.margin === 'number') parts.push((it ? 'margine ' : 'margin ') + signed(b.margin))
168 if (b.weekly) parts.push((it ? 'settimanale ' : 'weekly ') + Math.round(b.weekly.used) + '%')
169 if (b.reason === 'over-reserve') parts.push(it ? 'oltre la riserva' : 'past the reserve')
170 if (b.weekly && b.weekly.resetsAt) parts.push((it ? 'reset ' : 'resets ') + dateUtc(b.weekly.resetsAt, lang))
171 return parts.join(' · ')
172}
173
174// ---------------------------------------------------------------- plan
175
176// The plan line from the person's CLAUDE.md files (kind 'user'), else null.
177export function planFromFiles(files) {
178 if (!Array.isArray(files)) return null
179 for (const f of files) {
180 if (!f || f.kind !== 'user' || typeof f.content !== 'string') continue
181 const plan = parsePlanLine(f.content)
182 if (plan) return plan
183 }
184 return null
185}
186
187// The userConfig fallback: a whole `Claude plan: ...` line or just what follows it.
188export function planFromOption(text) {
189 if (typeof text !== 'string' || !text.trim()) return null
190 return parsePlanLine(text) || parsePlanLine('Claude plan: ' + text.trim())
191}
192
193// Whether the plan line banks a 5-hour reset still worth redeeming (no expiry, or not expired before today).
194export function fiveHourBanked(plan, now) {
195 if (!plan || !Array.isArray(plan.banked) || typeof now !== 'number') return false
196 const today = new Date(now).toISOString().slice(0, 10)
197 return plan.banked.some(r => r && r.type === '5-hour' && (!r.expires || r.expires >= today))
198}
199
200// ---------------------------------------------------------------- haiku (model-mix: Haiku 5.5)
201
202// The first Claude Code release whose `haiku` alias is Haiku 5.5 on the Anthropic API.
203export const HAIKU_55_SINCE = [2, 1, 293]
204
205// The variables that move Claude Code off the Anthropic API (Bedrock, Google Cloud, Microsoft Foundry,
206// Claude Platform on AWS, a gateway). register.js reads each by name; ANTHROPIC_BASE_URL is checked apart.
207export const PROVIDER_FLAGS = [
208 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
209 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD', 'CLAUDE_CODE_USE_MANTLE', 'CLAUDE_CODE_USE_GATEWAY',
210]
211
212function flagOn(value) {
213 return typeof value === 'string' && value.trim() !== '' && !/^(0|false|no|off)$/i.test(value.trim())
214}
215
216// [major, minor, patch] from $.session.version()'s answer (its release `base`, else `version`), else null.
217export function parseVersion(v) {
218 const text = v && typeof v === 'object' ? (typeof v.base === 'string' ? v.base : v.version) : null
219 const m = typeof text === 'string' ? /^(\d+)\.(\d+)\.(\d+)/.exec(text.trim()) : null
220 return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
221}
222
223// Whether the haiku alias is Haiku 5.5 in this session: Claude Code 2.1.293 or later on the Anthropic API.
224// version: $.session.version()'s answer, or null when it could not be read.
225// env: the provider variables by name (unset ones absent), or null when they could not be read.
226// ANTHROPIC_DEFAULT_HAIKU_MODEL, when set, is the model the alias runs: it must be a Haiku 5.5 or later id.
227// Returns { ok: true } or { ok: false, why: 'provider' | 'remapped' | 'env' | 'old' | 'unknown' }; anything unread is not ok.
228export function haikuAlias(version, env) {
229 if (!env || typeof env !== 'object') return { ok: false, why: 'env' }
230 if (PROVIDER_FLAGS.some(name => flagOn(env[name]))) return { ok: false, why: 'provider' }
231 const base = typeof env.ANTHROPIC_BASE_URL === 'string' ? env.ANTHROPIC_BASE_URL.trim() : ''
232 if (base && !/^https:\/\/api\.anthropic\.com(:443)?\/?$/i.test(base)) return { ok: false, why: 'provider' }
233 const remap = typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === 'string' ? env.ANTHROPIC_DEFAULT_HAIKU_MODEL.trim() : ''
234 if (remap && haikuKind(remap) !== 'current') return { ok: false, why: 'remapped' }
235 const v = parseVersion(version)
236 if (!v) return { ok: false, why: 'unknown' }
237 for (let i = 0; i < 3; i++) {
238 if (v[i] !== HAIKU_55_SINCE[i]) return v[i] > HAIKU_55_SINCE[i] ? { ok: true } : { ok: false, why: 'old' }
239 }
240 return { ok: true }
241}
242
243// A Haiku model name: 'alias' (the bare `haiku`), 'current' (a pinned Haiku 5.5 or later id, any case),
244// 'old' (any other Haiku id: Haiku 4.5, 3.5), or null for another family.
245export function haikuKind(model) {
246 if (typeof model !== 'string' || modelFamily(model) !== 'haiku') return null
247 const m = model.trim().toLowerCase()
248 if (m === 'haiku') return 'alias'
249 const v = /haiku-(\d)(?:-(\d))?(?!\d)/.exec(m)
250 return v && (Number(v[1]) > 5 || (Number(v[1]) === 5 && Number(v[2] || 0) >= 5)) ? 'current' : 'old'
251}
252
253// ctx.haiku: haikuAlias's answer for this session; a ctx without one (or an unread one) is not ok.
254function haikuOf(ctx) {
255 return ctx && ctx.haiku && typeof ctx.haiku === 'object' ? ctx.haiku : { ok: false, why: 'unknown' }
256}
257
258// ---------------------------------------------------------------- budget gate (rules 4, 5, 8)
259
260// The redeem advice only when it pays (budget.md): a banked weekly reset not yet lost (b.bankedWeekly,
261// with or without an expiry date), weekly use at or above 100 - reserve, and at least 2 days left before
262// the weekly reset (d at most 5): later, the reset is worth little. A red caused by pace alone gets no such sentence.
263function redReason(b) {
264 const parts = []
265 if (typeof b.margin === 'number') parts.push('margin ' + signed(b.margin))
266 if (b.weekly) parts.push('weekly ' + Math.round(b.weekly.used) + '% used')
267 if (b.reason === 'over-reserve' && b.plan && typeof b.plan.reserve === 'number') {
268 parts.push(`at or past the ${100 - b.plan.reserve}% reserve line (100 - reserve ${b.plan.reserve}%), red whatever the margin`)
269 }
270 if (b.weekly && b.weekly.resetsAt) parts.push('weekly reset ' + dateUtc(b.weekly.resetsAt, 'en'))
271 const redeem = !!(b.bankedWeekly > 0 && b.weekly && b.plan && typeof b.plan.reserve === 'number'
272 && b.weekly.used >= 100 - b.plan.reserve && typeof b.d === 'number' && b.d <= 5)
273 return `Budget red (${parts.join(', ')}): model-guard did not run this launch. Start nothing new; finish open work only (a fix on a worker's own open PR, a final review in this session, merging what is green).`
274 + (redeem ? ' A weekly reset is banked and at least 2 days are left before the weekly reset: ask the person to redeem it (Settings > Usage).' : '')
275}
276
277// One text for every paused launch, resumes included: a resumed run or session can hit the 5-hour limit
278// and fail as well (budget.md, Projected cost), so model-guard holds it too.
279function pausedReason(b) {
280 const at = b.fiveHour && b.fiveHour.resetsAt ? clockUtc(b.fiveHour.resetsAt) : null
281 const used = b.fiveHour ? Math.round(b.fiveHour.used) + '%' : '90% or more'
282 return at
283 ? `The 5-hour window is at ${used}: model-guard paused new launches and resumes, since a run started now can hit the limit and fail. Wait until ${at}, then launch or resume; keep working in this session meanwhile.`
284 : `The 5-hour window is at ${used}: model-guard paused new launches and resumes until it resets, since a run started now can hit the limit and fail. Keep working in this session meanwhile.`
285}
286
287// ctx.fiveHourBanked: the plan line banks a 5-hour reset (budget.md: with a green weekly color, ask
288// the person whether to redeem it instead of pausing; model-guard cannot redeem, so it still pauses).
289function pausedGate(b, t, ctx) {
290 const at = b.fiveHour && b.fiveHour.resetsAt ? clockUtc(b.fiveHour.resetsAt) : null
291 const ask = b.color === 'green' && ctx && ctx.fiveHourBanked
292 ? ' A 5-hour reset is banked and the weekly color is green: ask the person whether to redeem it (Settings > Usage) instead of waiting.'
293 : ''
294 return { deny: pausedReason(b) + ask, log: t.deniedPaused(at) }
295}
296
297// The gate on new work. ctx: { budget, redPolicy, lang, unknownLogged, fiveHourBanked?, fableLogged?, haiku? }
298// An unknown color (no reading yet) always allows, with one line per stretch without a reading:
299// a session that never gets a reading (an API key, say) must not be locked out. budget.md counts it red
300// when nobody can be asked, but the mods API has no reliable unattended signal: session.start's
301// isInteractive is false for the Desktop app (an SDK host) as for -p, and background sessions are not named.
302// Returns { deny, log } | { note, unknownNoted? } | null.
303export function launchGate(ctx) {
304 const b = ctx.budget
305 const t = texts(ctx.lang)
306 const red = b.color === 'red'
307 if (red && ctx.redPolicy !== 'warn') return { deny: redReason(b), log: t.deniedRed(b) }
308 if (b.pausedFiveHour) return pausedGate(b, t, ctx)
309 if (red) return { note: t.warnRed(b) }
310 if (b.color === 'unknown' && !ctx.unknownLogged) return { note: b.reason === 'stale-reading' || b.reason === 'window-expired' ? t.unknownStale : t.unknown, unknownNoted: true }
311 return null
312}
313
314function denied(gate) {
315 return { action: 'deny', reason: gate.deny, log: gate.log, to: 'transcript' }
316}
317
318function joinLogs(...lines) {
319 return lines.filter(Boolean).join(' · ')
320}
321
322function allowWith(gate, reason) {
323 const d = { action: 'allow', reason, log: gate ? gate.note || '' : '', to: 'transcript' }
324 if (gate && gate.unknownNoted) d.unknownNoted = true
325 return d
326}
327
328function optionalString(value, field) {
329 if (value === undefined || value === null) return undefined
330 if (typeof value !== 'string') throw new TypeError(field + ' is not text')
331 return value
332}
333
334// ---------------------------------------------------------------- Agent tool (rules 1, 2, 3 + gate)
335
336// Fable never runs on an Agent call: a mod cannot read the Fable window, and budget.md runs no Fable
337// stages when it is not readable. Any Fable name (`fable`, `claude-fable-5-1`, any case) becomes opus.
338// The rewrite is logged to the transcript once (ctx.fableLogged after).
339// The haiku alias is kept where it is Haiku 5.5 (ctx.haiku ok: Claude Code 2.1.293 or later on the
340// Anthropic API) and the agent runs here; otherwise, or with isolation 'remote' (a cloud session on its
341// own version), it becomes sonnet. A pinned Haiku 4.5 id becomes sonnet too (model-mix never pins it).
342// An Explore agent without a model is broad reading: haiku at effort medium (unless an effort is given)
343// where the alias is Haiku 5.5, else sonnet like the other inheriting types.
344// isolation 'remote' starts a cloud session: it also needs a cloud slot in today's profile.
345// A fork ignores model (it always runs on the session model) and a custom type without a model runs its
346// definition's: neither is rewritten here. The tool call does not carry the session model, so the
347// agent.spawn guard (decideSpawn) checks the model they will run on.
348// ctx: launchGate's, plus haiku (haikuAlias's answer).
349export function decideAgent(input, ctx) {
350 const model = optionalString(input.model, 'model')
351 const type = optionalString(input.subagent_type, 'subagent_type')
352 const isolation = optionalString(input.isolation, 'isolation')
353 const effort = optionalString(input.effort, 'effort')
354 const t = texts(ctx.lang)
355 const gate = launchGate(ctx)
356 if (gate && gate.deny) return denied(gate)
357 const p = ctx.budget.profile
358 if (isolation === 'remote' && ctx.budget.color !== 'red' && p && p.cloud === 0) {
359 return {
360 action: 'deny',
361 reason: `Today's profile is ${p.name} (budget ${ctx.budget.color}): no new cloud sessions, and isolation 'remote' starts one. Run the agent locally (leave out isolation: 'remote') or do the work in this session.`,
362 log: t.cloudSolo(p),
363 to: 'transcript',
364 }
365 }
366 const h = haikuOf(ctx)
367 const haikuHere = h.ok && isolation !== 'remote'
368 const haikuWhy = isolation === 'remote' ? 'remote' : h.why || 'unknown'
369 const family = modelFamily(model)
370 const kind = haikuKind(model)
371 let changes = null
372 let line = ''
373 let why = ''
374 let fableNoted = false
375 if (type === 'fork') {
376 const d = allowWith(gate, 'A fork runs on the session model and ignores model: model-guard checks that model when the fork is spawned.')
377 if (!gate) d.to = 'debug'
378 return d
379 }
380 if (!model) {
381 if (type === 'Explore' && haikuHere) {
382 changes = effort ? { model: 'haiku' } : { model: 'haiku', effort: 'medium' }
383 line = t.explore(!!effort)
384 why = `Agent model set to haiku${effort ? '' : ', effort medium'}: Explore is broad reading, model-mix's Haiku row, and haiku is Haiku 5.5 here.`
385 } else if (!type || INHERITING_TYPES.includes(type)) {
386 changes = { model: 'sonnet' }
387 line = t.agentNoModel
388 why = `Agent model set to sonnet: ${type || 'the default type'} would inherit the session model.`
389 + (type === 'Explore' ? ` Not haiku: ${HAIKU_WHY.model[haikuWhy]}.` : '')
390 } else {
391 const d = allowWith(gate, `Agent type ${type} has no model: its definition decides (checked at spawn against the session model).`)
392 d.log = joinLogs(d.log, t.ownModel(type))
393 if (!gate) d.to = 'debug'
394 return d
395 }
396 } else if (kind === 'alias' && !haikuHere) {
397 changes = { model: 'sonnet' }
398 line = t.haiku(haikuWhy)
399 why = `Agent model haiku set to sonnet: ${HAIKU_WHY.model[haikuWhy]}.`
400 } else if ((kind === 'alias' || kind === 'current') && !effort && (kind === 'current' || haikuHere)) {
401 changes = { effort: 'medium' }
402 why = `Agent effort set to medium: model-mix pairs Haiku with medium effort.`
403 } else if (kind === 'old') {
404 changes = { model: 'sonnet' }
405 line = t.haikuOld
406 why = `Agent model ${model} set to sonnet: model-mix never pins Haiku 4.5.`
407 } else if (family === 'fable') {
408 changes = { model: 'opus' }
409 fableNoted = !ctx.fableLogged
410 line = fableNoted ? t.fable : ''
411 why = `Agent model ${model} set to opus: model-guard cannot read the Fable window, and budget.md runs no Fable stages when it is not readable.`
412 }
413 if (!changes) return allowWith(gate, 'Agent model kept: ' + model + '.')
414 const d = { action: 'rewrite', input: { ...input, ...changes }, reason: why, log: joinLogs(gate && gate.note, line), to: 'transcript' }
415 if (!d.log && family === 'fable') {
416 d.log = t.fable
417 d.to = 'debug'
418 } else if (!d.log) {
419 d.to = 'debug'
420 }
421 if (fableNoted) d.fableNoted = true
422 if (gate && gate.unknownNoted) d.unknownNoted = true
423 return d
424}
425
426// ---------------------------------------------------------------- Workflow tool (gate; Solo width 0)
427
428// A resume (resumeFromRunId) finishes open work: allowed while red (redPolicy deny too) and on Solo,
429// held only while the 5-hour window is paused. A fresh run is new work and goes through the gate.
430// An allowed resume carries startRun { width, name }: the caller records the run with it when it does not
431// know the run (a reload lost it, or the module never saw its first agent), so the resumed run's agents
432// spawn up to that width: today's profile, or the plan's own profile when today's allows none (red, Solo),
433// as decideWorkflowAgent's warnAdmits base does. A run the caller already knows keeps its own record.
434export function decideWorkflow(input, ctx) {
435 optionalString(input.script, 'script')
436 optionalString(input.name, 'name')
437 optionalString(input.scriptPath, 'scriptPath')
438 const resume = optionalString(input.resumeFromRunId, 'resumeFromRunId')
439 const b = ctx.budget
440 const t = texts(ctx.lang)
441 if (resume) {
442 if (b.pausedFiveHour) return denied(pausedGate(b, t, ctx))
443 const gate = b.color === 'red' ? { note: t.resumeRed(b) } : launchGate(ctx)
444 const own = b.profile && b.profile.width > 0 ? b.profile : (b.plan && PROFILES[b.plan.name]) || PROFILES['Max 5x']
445 const d = allowWith(gate, 'Workflow resume allowed: it finishes open work.')
446 d.startRun = { width: own.width, name: own.name }
447 return d
448 }
449 const gate = launchGate(ctx)
450 if (gate && gate.deny) return denied(gate)
451 if (b.color !== 'red' && b.profile && b.profile.width === 0) {
452 return {
453 action: 'deny',
454 reason: `Today's profile is ${b.profile.name} (budget ${b.color}): no workflows. Do the work in this session, or one Agent call at most.`,
455 log: t.wfSolo(b.profile),
456 to: 'transcript',
457 }
458 }
459 return allowWith(gate, 'Workflow allowed.')
460}
461
462// ---------------------------------------------------------------- RemoteTrigger (gate on launching actions)
463
464// An update whose body sets enabled: false only stops a routine (what budget.md asks for at the limit),
465// so it passes in any color. Any other update (enabling, rescheduling, a new prompt) is gated.
466function disablesOnly(input) {
467 const body = input.body
468 return !!(body && typeof body === 'object' && !Array.isArray(body) && body.enabled === false)
469}
470
471// The text values of every field whose name ends in "model" (model, default_model, ...), at any depth of
472// a RemoteTrigger body: the routine's model, wherever the body schema puts it.
473export function bodyModels(value, depth = 0, out = []) {
474 if (depth > 32) return out
475 if (Array.isArray(value)) {
476 for (const v of value) bodyModels(v, depth + 1, out)
477 } else if (value && typeof value === 'object') {
478 for (const [k, v] of Object.entries(value)) {
479 if (typeof v === 'string' && /model$/i.test(k)) out.push(v)
480 else bodyModels(v, depth + 1, out)
481 }
482 }
483 return out
484}
485
486// RemoteTrigger actions that start a cloud session, now or on a schedule, and so need a cloud slot.
487const STARTING_ACTIONS = ['create', 'run', 'create_webhook_trigger']
488
489// A routine is a cloud session: after the budget gate, a model named anywhere in the body is checked as
490// a cloud launch's --model is (forbiddenModel: Fable, the bare haiku alias, Haiku 4.5), and a create or run
491// needs a cloud slot in today's profile (Solo, Pro yellow, has none).
492export function decideRemoteTrigger(input, ctx) {
493 const action = optionalString(input.action, 'action')
494 if (!action || !LAUNCH_ACTIONS.includes(action)) return { action: 'allow', reason: 'RemoteTrigger read.', log: '' }
495 if (action === 'update' && disablesOnly(input)) return { action: 'allow', reason: 'RemoteTrigger disable.', log: '' }
496 const t = texts(ctx.lang)
497 const gate = launchGate(ctx)
498 if (gate && gate.deny) return denied(gate)
499 for (const model of bodyModels(input.body)) {
500 const bad = forbiddenModel(model)
501 if (bad) {
502 const m = shellModelText(bad)
503 return {
504 action: 'deny',
505 reason: `Routines run as cloud sessions, which never run ${m.name} (model-mix); this body names ${model}. Set the routine's model to sonnet (model-mix's row for routines), opus for security-critical work, or claude-haiku-5-5 for a small mechanical job, and send it again.`,
506 log: t.routineBad(m.log),
507 to: 'transcript',
508 }
509 }
510 }
511 const p = ctx.budget.profile
512 if (STARTING_ACTIONS.includes(action) && ctx.budget.color !== 'red' && p && p.cloud === 0) {
513 return {
514 action: 'deny',
515 reason: `Today's profile is ${p.name} (budget ${ctx.budget.color}): no new cloud sessions, and a routine ${action === 'run' ? 'run starts' : 'starts'} one. Do the work in this session; disabling a routine still passes.`,
516 log: t.cloudSolo(p),
517 to: 'transcript',
518 }
519 }
520 return allowWith(gate, 'RemoteTrigger allowed.')
521}
522
523// ---------------------------------------------------------------- shell commands (rules 4, 5, 7)
524
525// ---------------------------------------------------------------- shell parser (shared with skill-router)
526// From here to the "end of the shared shell parser" line, this block is the same text in model-guard's
527// rules.js and skill-router's routes.js: keep the two identical. Both mods' tests run one corpus through
528// tokenizeFull, so a drift fails a test.
529
530const SEPARATORS = new Set([';', '|', '&', '(', ')', '\n', '\r'])
531
532// Reads a heredoc delimiter word at i (quotes and backslashes stripped). Returns { word, next }.
533function heredocWord(command, i) {
534 let word = ''
535 while (i < command.length) {
536 const c = command[i]
537 if (c === ' ' || c === '\t' || SEPARATORS.has(c) || c === '<' || c === '>') break
538 if (c === "'" || c === '"') {
539 const close = command.indexOf(c, i + 1)
540 if (close < 0) { word += command.slice(i + 1); i = command.length; break }
541 word += command.slice(i + 1, close)
542 i = close + 1
543 } else if (c === '\\' && i + 1 < command.length) {
544 word += command[i + 1]
545 i += 2
546 } else {
547 word += c
548 i++
549 }
550 }
551 return { word, next: i }
552}
553
554// From `from` (the start of a line), where the line that ends a heredoc body starts (`body`) and the
555// index just past it (`next`), or null when no line ends it. Unterminated bodies are scanned as
556// commands: missing a real launch costs more than a false alarm (`$((1<<2))` also looks like a heredoc).
557function heredocEnd(command, from, h) {
558 let p = from
559 while (p <= command.length) {
560 const nl = command.indexOf('\n', p)
561 const stop = nl < 0 ? command.length : nl
562 let line = command.slice(p, stop)
563 if (line.endsWith('\r')) line = line.slice(0, -1)
564 if (h.stripTabs) line = line.replace(/^\t+/, '')
565 if (line === h.word) return { body: p, next: nl < 0 ? command.length : nl + 1 }
566 if (nl < 0) return null
567 p = nl + 1
568 }
569 return null
570}
571
572// A PowerShell here-string opening at i (`@'` or `@"` closing its line): { end, body } with end just
573// past its closing `'@` / `"@` at the start of a line, else null.
574function hereString(command, i) {
575 const q = command[i + 1]
576 if (command[i] !== '@' || (q !== "'" && q !== '"')) return null
577 const nl = command.indexOf('\n', i + 2)
578 if (nl < 0 || command.slice(i + 2, nl).trim() !== '') return null
579 const close = command.indexOf('\n' + q + '@', nl)
580 if (close < 0) return null
581 return { end: close + 3, body: command.slice(nl + 1, close).replace(/\r$/, '') }
582}
583
584// Splits a shell line (Bash or PowerShell) into segments of tokens { value, start, end }, with:
585// ends the separator that ended each segment (';', '|', '&&', '\n', '{', ...; '' for the last)
586// subs command substitutions that stay inside one token, { value, start, end }: `$(...)` inside
587// double quotes, and backtick pairs (inside double quotes, or unquoted on one line)
588// bodies heredoc bodies, { seg, value } (seg: the segment that opened them)
589// Never throws on odd input: an unclosed quote runs to the end of the line.
590// A line continuation (`\` in Bash, ` in PowerShell, before a newline) joins the next line.
591// `{` and `}` standing alone (a brace group, a loop body, a PowerShell script block, `%{`) end a
592// segment, so the command inside sits at a command position.
593// Heredoc bodies (`<<EOF`, `<<-'EOF'`, several on one line) add no tokens; a PowerShell here-string
594// (`@'` ... `'@`) is one token holding its body, never split into commands. Even an unquoted `<<EOF` or
595// `@"` body, which the shell expands, is skipped: a launch hidden in a `$(...)` there is far rarer than
596// commit, PR and brief texts that only mention `claude --cloud`. Only a body fed to a shell is a script
597// (the callers read `bodies`). A heredoc inside a `$(...)` inside double quotes (Claude Code's commit
598// and PR idiom `"$(cat <<'EOF' ... EOF\n)"`) is data too: its body stays in the quoted token, and its
599// quotes never close the string.
600export function tokenizeFull(command) {
601 const segments = [[]]
602 const ends = []
603 const subs = []
604 const bodies = []
605 let tok = null
606 let heredocs = []
607 const cur = () => segments[segments.length - 1]
608 const push = () => {
609 if (tok) { cur().push(tok); tok = null }
610 }
611 const brk = sep => {
612 push()
613 if (cur().length) { ends[segments.length - 1] = sep; segments.push([]) }
614 }
615 const startTok = i => { if (!tok) tok = { value: '', start: i, end: i } }
616 let i = 0
617 while (i < command.length) {
618 const c = command[i]
619 const next = command[i + 1]
620 if (c === ' ' || c === '\t') { push(); i++; continue }
621 if ((c === '\\' || c === '`') && (next === '\n' || (next === '\r' && command[i + 2] === '\n'))) {
622 push()
623 i += next === '\r' ? 3 : 2
624 continue
625 }
626 if (c === '<' && next === '<' && command[i + 2] !== '<' && command[i - 1] !== '<') {
627 push()
628 let j = i + 2
629 const stripTabs = command[j] === '-'
630 if (stripTabs) j++
631 while (command[j] === ' ' || command[j] === '\t') j++
632 const { word, next: after } = heredocWord(command, j)
633 if (word) heredocs.push({ word, stripTabs, seg: cur() })
634 i = after
635 continue
636 }
637 const alone = next === undefined || /\s/.test(next)
638 if ((c === '{' && next !== '}' && (alone || !tok || tok.value === '%')) || (c === '}' && !tok && (alone || /[;|&)]/.test(next)))) {
639 brk(c)
640 i++
641 continue
642 }
643 // An & or | inside a redirection stays in its word, so it splits nothing: `2>&1`, `>&2`, `<&3`,
644 // `>|file`, `&>log`, `&>>log`.
645 if ((c === '&' || c === '|') && tok && tok.end === i && (command[i - 1] === '>' || (c === '&' && command[i - 1] === '<'))) {
646 tok.value += c
647 i++
648 tok.end = i
649 continue
650 }
651 if (c === '&' && next === '>') {
652 push()
653 startTok(i)
654 tok.value += c
655 i++
656 tok.end = i
657 continue
658 }
659 if (SEPARATORS.has(c)) {
660 const doubled = (c === '&' || c === '|') && next === c
661 brk(doubled ? c + c : c)
662 i += doubled ? 2 : 1
663 if (c === '\n' && heredocs.length) {
664 for (const h of heredocs) {
665 const end = heredocEnd(command, i, h)
666 if (!end) break
667 bodies.push({ seg: h.seg, value: command.slice(i, end.body) })
668 i = end.next
669 }
670 heredocs = []
671 }
672 continue
673 }
674 const here = c === '@' ? hereString(command, i) : null
675 if (here) {
676 startTok(i)
677 tok.value += here.body
678 i = here.end
679 tok.end = i
680 continue
681 }
682 startTok(i)
683 if (c === "'") {
684 const close = command.indexOf("'", i + 1)
685 const stop = close < 0 ? command.length : close
686 tok.value += command.slice(i + 1, stop)
687 i = close < 0 ? command.length : close + 1
688 } else if (c === '"') {
689 let open = 0 // open `$(` inside this string
690 let from = 0 // where the outermost one's text starts
691 let inner = [] // heredocs opened inside them, waiting for the end of their line
692 i++
693 while (i < command.length && command[i] !== '"') {
694 const d = command[i]
695 if (d === '$' && command[i + 1] === '(') {
696 if (!open) from = i + 2
697 open++
698 tok.value += '$('
699 i += 2
700 continue
701 }
702 if (d === ')' && open > 0) {
703 open--
704 if (!open) subs.push({ value: command.slice(from, i), start: from, end: i })
705 }
706 if (open > 0 && d === '<' && command[i + 1] === '<' && command[i + 2] !== '<' && command[i - 1] !== '<') {
707 let j = i + 2
708 const stripTabs = command[j] === '-'
709 if (stripTabs) j++
710 while (command[j] === ' ' || command[j] === '\t') j++
711 const { word, next: after } = heredocWord(command, j)
712 if (word) inner.push({ word, stripTabs })
713 tok.value += command.slice(i, after)
714 i = after
715 continue
716 }
717 if (d === '\n' && inner.length) {
718 let p = i + 1
719 for (const h of inner) {
720 const end = heredocEnd(command, p, h)
721 if (!end) { p = -1; break }
722 p = end.next
723 }
724 inner = []
725 if (p > 0) {
726 tok.value += command.slice(i, p)
727 i = p
728 continue
729 }
730 }
731 if ((d === '\\' || d === '`') && (command[i + 1] === '"' || command[i + 1] === '\\' || command[i + 1] === '`')) {
732 tok.value += command[i + 1]
733 i += 2
734 continue
735 }
736 if (d === '`') {
737 // A Bash substitution, when it closes before the string does (PowerShell's "`n" escapes
738 // read as one too: their text holds no command).
739 const close = command.indexOf('`', i + 1)
740 const quote = command.indexOf('"', i + 1)
741 if (close > 0 && (quote < 0 || close < quote)) {
742 subs.push({ value: command.slice(i + 1, close), start: i + 1, end: close })
743 tok.value += command.slice(i, close + 1)
744 i = close + 1
745 continue
746 }
747 }
748 tok.value += d
749 i++
750 }
751 if (open > 0) subs.push({ value: command.slice(from, i), start: from, end: i })
752 i++
753 } else if (c === '`') {
754 const close = command.indexOf('`', i + 1)
755 const nl = command.indexOf('\n', i + 1)
756 if (close > 0 && (nl < 0 || close < nl)) {
757 subs.push({ value: command.slice(i + 1, close), start: i + 1, end: close })
758 tok.value += command.slice(i, close + 1)
759 i = close + 1
760 } else {
761 tok.value += c
762 i++
763 }
764 } else {
765 tok.value += c
766 i++
767 }
768 tok.end = Math.min(i, command.length)
769 }
770 push()
771 // Only the last segment can be empty, so a kept segment keeps its index.
772 const kept = segments.filter(s => s.length)
773 return {
774 segments: kept,
775 ends: kept.map((s, i) => ends[i] || ''),
776 subs,
777 bodies: bodies.map(b => ({ seg: kept.indexOf(b.seg), value: b.value })),
778 }
779}
780
781// tokenizeFull's segments alone.
782export function tokenize(command) {
783 return tokenizeFull(command).segments
784}
785
786function baseName(value) {
787 const parts = value.split(/[\\/]/)
788 return parts[parts.length - 1].toLowerCase()
789}
790
791// claude by name or path (`claude.exe`, `/usr/local/bin/claude`), or its npm package (`npx @anthropic-ai/claude-code`).
792function isClaude(value) {
793 return /^claude(\.exe|\.cmd|\.ps1)?$/.test(baseName(value)) || /^@anthropic-ai\/claude-code(@\S*)?$/i.test(value)
794}
795
796function isLaunchScript(value) {
797 return /^launch\.(exp|ps1)$/.test(baseName(value))
798}
799
800// Words that run the command after them, each with its own options: v lists the options that take the
801// next word as their value (an attached value, `-n5`, `-I{}` or `--signal=KILL`, is one word); n counts
802// the operands before the command (timeout's duration, chrt's priority, taskset's mask). Any other
803// option stands alone (env -S's string is then the command word), and `--` ends the options. Shell
804// keywords (kw) take no options.
805const WRAPPERS = new Map([
806 ...['if', 'then', 'else', 'elif', 'while', 'until', 'do', '!', '{', '}'].map(w => [w, { kw: true }]),
807 ['time', { v: ['-o', '-f', '--output', '--format'] }],
808 ['exec', { v: ['-a'] }],
809 ['command', {}], ['nohup', {}], ['setsid', {}], ['call', {}], ['source', {}], ['.', {}],
810 ['env', { v: ['-u', '-C', '--unset', '--chdir'] }],
811 ['sudo', { v: ['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U', '-T', '-R', '--user', '--group', '--host', '--prompt', '--close-from', '--chdir', '--role', '--type', '--other-user', '--command-timeout', '--chroot'] }],
812 ['xargs', { v: ['-I', '-n', '-P', '-L', '-s', '-d', '-E', '-a', '--max-args', '--max-procs', '--max-lines', '--max-chars', '--delimiter', '--eof', '--arg-file', '--replace', '--process-slot-var'] }],
813 ['timeout', { v: ['-s', '-k', '--signal', '--kill-after'], n: 1 }],
814 ['gtimeout', { v: ['-s', '-k', '--signal', '--kill-after'], n: 1 }],
815 ['nice', { v: ['-n', '--adjustment'] }],
816 ['ionice', { v: ['-c', '-n', '--class', '--classdata'] }],
817 ['chrt', { v: ['-T', '-P', '-D', '--sched-runtime', '--sched-period', '--sched-deadline'], n: 1 }],
818 ['taskset', { n: 1 }],
819 ['caffeinate', { v: ['-t', '-w'] }],
820 ['stdbuf', { v: ['-i', '-o', '-e', '--input', '--output', '--error'] }],
821 ['watch', { v: ['-n', '--interval', '-q', '--equexit'] }],
822 ['npx', { v: ['-p', '--package', '--cache', '--registry', '--userconfig'] }],
823 ['bunx', { v: ['-p', '--package'] }],
824])
825
826// Index of the segment's command word, past `VAR=value`, shell keywords and wrappers with their options.
827function commandIndex(seg) {
828 let i = 0
829 while (i < seg.length) {
830 const word = seg[i].value
831 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) { i++; continue }
832 const w = WRAPPERS.get(word)
833 if (!w) break
834 i++
835 if (w.kw) continue
836 while (i < seg.length && seg[i].value.startsWith('-')) {
837 const o = seg[i].value
838 i += o !== '--' && w.v && w.v.includes(o) ? 2 : 1
839 if (o === '--') break
840 }
841 i += w.n || 0
842 }
843 return i < seg.length ? i : -1
844}
845
846const SHELLS = /^(bash|sh|zsh|dash|pwsh|powershell|cmd)(\.exe)?$/
847const SCRIPT_FLAG = /^(-[a-z]*c|-command|\/c|\/k)$/i
848// Shells whose script flag takes the rest of the line (bash -c takes one word; the rest are $0, $1...).
849const REST_SHELLS = /^(pwsh|powershell|cmd)(\.exe)?$/
850const STARTERS = ['start-process', 'start', 'saps']
851
852// The scripts a nested shell may run (`bash -lc "..."`, `pwsh -Command "..."`, `cmd /c "..."`), in the
853// order to try, else null. For cmd and PowerShell the script flag takes the rest of the segment: an
854// unquoted script (`cmd /c claude --cloud x`) is that raw text, a synthetic token. A quoted script with
855// more words after it is tried as the quoted text first (`cmd /c "claude --cloud x" 2>&1`, where the
856// rest is the outer shell's redirection), then as the rest of the segment (`cmd /c "claude" --cloud x`).
857function nestedScripts(command, seg, ci) {
858 const shell = baseName(seg[ci].value)
859 if (!SHELLS.test(shell)) return null
860 for (let j = ci + 1; j < seg.length - 1; j++) {
861 if (!SCRIPT_FLAG.test(seg[j].value)) continue
862 if (!REST_SHELLS.test(shell)) {
863 // A POSIX shell reads all its options before the script: `bash -c -- "..."`, `sh -c -e "..."`,
864 // `bash -c -o pipefail "..."` (-o and -O take a value).
865 let s = j + 1
866 while (s < seg.length && /^[-+]./.test(seg[s].value) && command.slice(seg[s].start, seg[s].end) === seg[s].value) {
867 const o = seg[s].value
868 s += /^[-+][oO]$/.test(o) ? 2 : 1
869 if (o === '--') break
870 }
871 return s < seg.length ? [seg[s]] : null
872 }
873 if (j + 2 >= seg.length) return [seg[j + 1]]
874 const start = seg[j + 1].start
875 const end = seg[seg.length - 1].end
876 const rest = { value: command.slice(start, end), start, end, synthetic: true }
877 const q = command[start]
878 return q === '"' || q === "'" || q === '@' ? [seg[j + 1], rest] : [rest]
879 }
880 return null
881}
882
883const EVALS = ['eval', 'iex', 'invoke-expression']
884// Commands whose arguments may each hold a whole command line they run (`tmux new "..."`, `ssh host "..."`,
885// `find . -exec sh -c "..."`): every argument with a space in it is read as a script.
886const SCRIPT_TAKERS = ['tmux', 'screen', 'ssh', 'su', 'runuser', 'script', 'parallel', 'find']
887const ECHOES = ['echo', 'printf', 'write-output', 'write-host']
888
889// Whether segment k's command reads a script on stdin: a shell, ssh or su, or Invoke-Expression with no argument.
890function readsStdin(seg, ci) {
891 const name = baseName(seg[ci].value)
892 if (EVALS.includes(name)) return seg.length === ci + 1 && name !== 'eval'
893 return SHELLS.test(name) || name === 'ssh' || name === 'su' || name === 'runuser'
894}
895
896// The other scripts segment k runs, as texts to scan (never edited in place): eval's and Invoke-Expression's
897// arguments; the command line `watch` or `env -S` runs; SCRIPT_TAKERS' arguments; AppleScript's
898// `do shell script "..."`; and what a shell reads on stdin: a heredoc or `<<<` it takes, or what the segment
899// before pipes into it (`cat <<'EOF' | bash`, `echo "..." | sh`, `"..." | iex`).
900function extraScripts(full, k, ci) {
901 const seg = full.segments[k]
902 const name = baseName(seg[ci].value)
903 const args = seg.slice(ci + 1).map(t => t.value)
904 const out = []
905 if (EVALS.includes(name)) out.push(args.filter(a => !/^-c(ommand)?$/i.test(a)).join(' '))
906 if (/\s/.test(seg[ci].value) && seg.slice(0, ci).some(t => ['watch', '-S', '--split-string'].includes(t.value))) out.push(seg.slice(ci).map(t => t.value).join(' '))
907 if (SCRIPT_TAKERS.includes(name)) out.push(...args.filter(a => /\s/.test(a)))
908 if (name === 'osascript') {
909 for (const a of args) for (const m of a.matchAll(/do (?:shell )?script\s+"((?:[^"\\]|\\.)*)"/g)) out.push(m[1].replace(/\\(.)/g, '$1'))
910 }
911 if (readsStdin(seg, ci)) {
912 for (let j = 0; j < args.length; j++) if (args[j].startsWith('<<<')) out.push(args[j].length > 3 ? args[j].slice(3) : args[j + 1] || '')
913 for (const b of full.bodies) if (b.seg === k) out.push(b.value)
914 if (k > 0 && full.ends[k - 1] === '|') {
915 const prev = full.segments[k - 1]
916 for (const b of full.bodies) if (b.seg === k - 1) out.push(b.value)
917 const pi = commandIndex(prev)
918 if (pi >= 0 && /\s/.test(prev[pi].value)) out.push(prev[pi].value)
919 else if (pi >= 0 && ECHOES.includes(baseName(prev[pi].value))) out.push(prev.slice(pi + 1).map(t => t.value).join(' '))
920 }
921 }
922 return out.filter(Boolean)
923}
924
925// Where a launch script (coordinator-method's launch.exp, cloud-worker's launch.ps1) sits in segment
926// seg: as the command word (`./launch.exp`, `& "...\launch.ps1"`, `. launch.ps1`), after expect and its
927// flags, or as any argument of powershell or pwsh (`-File`, or the first operand). Else -1.
928function launchScriptAt(seg, ci) {
929 if (isLaunchScript(seg[ci].value)) return ci
930 const name = baseName(seg[ci].value)
931 if (/^expect(\.exe)?$/.test(name)) {
932 let j = ci + 1
933 while (j < seg.length && seg[j].value.startsWith('-')) j++
934 return j < seg.length && isLaunchScript(seg[j].value) ? j : -1
935 }
936 if (REST_SHELLS.test(name)) {
937 for (let j = ci + 1; j < seg.length; j++) if (isLaunchScript(seg[j].value)) return j
938 }
939 return -1
940}
941
942function hasFlag(values, ...names) {
943 return values.some(v => names.includes(v) || names.some(n => v.startsWith(n + '=')))
944}
945
946// ---------------------------------------------------------------- indirect launches (Start-Process, start)
947// What a starter hands claude is a Windows command line, and re-reading its quoting kept letting launches
948// through. So the argument list is never read: only which program the starter runs, from the starter's
949// own parameters.
950
951// Start-Process's switches and the parameters that take a value. PowerShell takes an exact name or alias
952// first (`-wi` is -WhatIf, not -WindowStyle), else any prefix of a name (`-NoNew`, `-File`). `-Name:value`
953// is one word, and `-Name:` before a space takes the next word. An unknown name is taken to have a value.
954const START_SWITCHES = ['wait', 'nonewwindow', 'nnw', 'passthru', 'loaduserprofile', 'lup', 'usenewenvironment', 'verbose', 'vb', 'debug', 'db', 'whatif', 'wi', 'confirm', 'cf']
955const START_VALUED = ['argumentlist', 'args', 'workingdirectory', 'windowstyle', 'verb', 'credential', 'environment', 'redirectstandardinput', 'redirectstandardoutput', 'redirectstandarderror', 'rsi', 'rso', 'rse']
956const START_FILE = ['filepath', 'path', 'pspath']
957// cmd's start switches that take the next word (`/D C:\dir`); the others (`/MIN`, `/WAIT`, `/B`) take none.
958const CMD_VALUED = ['d', 'node', 'affinity']
959
960function startSwitch(name) {
961 if (START_SWITCHES.includes(name)) return true
962 return START_SWITCHES.some(s => s.startsWith(name)) && ![...START_VALUED, ...START_FILE].some(v => v.startsWith(name))
963}
964
965// `{` minus `}` in a word's raw text, outside quotes.
966function braceDelta(raw) {
967 let d = 0
968 let q = ''
969 for (const ch of raw) {
970 if (q) { if (ch === q) q = '' } else if (ch === "'" || ch === '"') q = ch
971 else if (ch === '{') d++
972 else if (ch === '}') d--
973 }
974 return d
975}
976
977// The words after a starter, as units: a word, or a group with what touches it: `( ... )` (`@('a','b')`,
978// `(Get-Command claude).Source`) or a hashtable `@{ ... }` (`@{ A = 'b' }`). The statement runs on past
979// breaks made only of parentheses and braces (and of newlines inside a group, and of a hashtable's `;`;
980// a ` or \ line continuation is a space) and ends at any other separator. A `{` or `}` standing alone is
981// a separator to tokenizeFull, so it sits in the gap between words; one inside a word (`@{A='b'`) is
982// counted from the word's text.
983function starterUnits(command, segments, k, ci) {
984 const units = []
985 let prev = segments[k][ci]
986 let depth = 0
987 let braces = 0
988 for (let s = k; s < segments.length; s++) {
989 for (let j = s === k ? ci + 1 : 0; j < segments[s].length; j++) {
990 const tok = segments[s][j]
991 let apart = !units.length
992 for (const ch of command.slice(prev.end, tok.start).replace(/[`\\]\r?\n/g, ' ')) {
993 if (ch === '(') depth++
994 else if (ch === ')') depth = Math.max(0, depth - 1)
995 else if (ch === '{') braces++
996 else if (ch === '}' && braces > 0) braces--
997 else if (ch === ' ' || ch === '\t' || ((ch === '\n' || ch === '\r') && (depth > 0 || braces > 0))) apart = apart || (depth === 0 && braces === 0)
998 else if (ch !== ';' || braces === 0) return units
999 }
1000 prev = tok
1001 if (apart) units.push([tok])
1002 else units[units.length - 1].push(tok)
1003 braces = Math.max(0, braces + braceDelta(command.slice(tok.start, tok.end)))
1004 }
1005 }
1006 return units
1007}
1008
1009// Whether a starter (Start-Process, start, saps, cmd's start) runs claude: the -FilePath value when one
1010// is given before the program, else the first positional word; cmd's start takes a double-quoted first
1011// word as the window title (`start "" claude`). The words after the program word are its own (cmd's
1012// `start claude -p x`, where `-p` is claude's): there only a -FilePath of two letters or more (`-File`,
1013// `-Path`; claude's short flags have one) is still read, as PowerShell binds it. A path counts
1014// (`C:\x\claude.exe`), and so does a group naming claude (`(Get-Command claude).Source`); a URL
1015// (`https://.../claude`) does not.
1016function startsClaude(command, segments, k, ci) {
1017 const units = starterUnits(command, segments, k, ci)
1018 const url = v => v.includes('://') && !/^file:/i.test(v)
1019 const names = u => u.some(t => isClaude(t.value) && !url(t.value))
1020 // The last unit of a value: PowerShell's array commas (`'a', 'b'`) carry it on.
1021 const valueEnd = j => {
1022 while (j + 1 < units.length && (command[units[j][units[j].length - 1].end - 1] === ',' || command[units[j + 1][0].start] === ',')) j++
1023 return j
1024 }
1025 const cmdStart = baseName(segments[k][ci].value) === 'start'
1026 let file = null // null: no -FilePath; else whether one names claude
1027 let program = null
1028 let title = false
1029 for (let i = 0; i < units.length; i++) {
1030 const word = units[i].length === 1 ? units[i][0].value : ''
1031 const param = /^-([a-z][a-z0-9]*)(?::([\s\S]*))?$/i.exec(word)
1032 if (param) {
1033 const name = param[1].toLowerCase()
1034 const inline = param[2] ? [{ value: param[2] }] : null
1035 if (START_FILE.some(f => f.startsWith(name)) && (!program || name.length > 1)) {
1036 file = !!file || names(inline || units[i + 1] || [])
1037 if (!inline) i = valueEnd(i + 1)
1038 } else if (!program && !inline && (param[2] === '' || !startSwitch(name))) i = valueEnd(i + 1)
1039 continue
1040 }
1041 if (program) continue
1042 const sw = /^\/\/?([a-z][^/]*)$/i.exec(word)
1043 if (sw && !isClaude(word)) {
1044 if (CMD_VALUED.includes(sw[1].toLowerCase())) i = valueEnd(i + 1)
1045 continue
1046 }
1047 if (file !== null) continue // after -FilePath, positional words are its arguments
1048 if (cmdStart && !title && command[units[i][0].start] === '"' && !names(units[i])) {
1049 title = true
1050 continue
1051 }
1052 program = units[i]
1053 }
1054 return (program !== null && names(program)) || !!file
1055}
1056
1057// ---------------------------------------------------------------- end of the shared shell parser
1058
1059// ---------------------------------------------------------------- claude launches (model-guard)
1060
1061// The last value of an option (a later one overrides an earlier one), `--name value` or `--name=value`.
1062function flagValue(args, name) {
1063 let found = { present: false, value: null }
1064 for (let i = 0; i < args.length; i++) {
1065 const v = args[i].value
1066 if (v === name) found = { present: true, value: args[i + 1] ? args[i + 1].value : null }
1067 else if (v.startsWith(name + '=')) found = { present: true, value: v.slice(name.length + 1) }
1068 }
1069 return found
1070}
1071
1072// A model name model-guard can read: not empty, not the next option, no variable or substitution.
1073function literalModel(value) {
1074 return typeof value === 'string' && value !== '' && !value.startsWith('-') && !/[$`]/.test(value)
1075}
1076
1077// claude's options that take the next word (the variadic ones, `--add-dir a b`, are read as taking one,
1078// so a prompt after them still counts), and those whose value is optional (taken unless an option follows).
1079const CLAUDE_VALUED = [
1080 '--add-dir', '--agent', '--agents', '--allowedTools', '--allowed-tools', '--append-system-prompt', '--append-system-prompt-file',
1081 '--autocompact', '--betas', '--debug-file', '--disallowedTools', '--disallowed-tools', '--effort', '--environment',
1082 '--fallback-model', '--file', '--input-format', '--json-schema', '--max-budget-usd', '--max-turns', '--mcp-config', '--model',
1083 '-n', '--name', '--output-format', '--permission-mode', '--permission-prompts', '--permission-prompt-tool', '--plugin-dir',
1084 '--plugin-url', '--remote-control-session-name-prefix', '--session-id', '--setting-sources', '--settings', '--system-prompt',
1085 '--system-prompt-file', '--system-prompt-snapshot', '--tools',
1086]
1087const CLAUDE_OPTIONAL = ['-r', '--resume', '-d', '--debug', '--from-pr', '-w', '--worktree', '--remote-control', '--teleport', '--prompt-suggestions', '--cloud']
1088// A redirection word: `>`, `2>>`, `<`, `&>`, `>|`, `2>&1`, with its target attached or in the next word.
1089const REDIRECT = /^(?:\d*\*?|&)(>>?|<)(.*)$/
1090// Whether a redirection word's target is the next word (`> log`, `&> log`, `>& log`, `>| log`).
1091const targetNext = r => /^[&|]?$/.test(r[2])
1092
1093// Short flags grouped in one word (`-pc`) read one by one.
1094function claudeWords(values) {
1095 return values.flatMap(v => (/^-[a-zA-Z]{2,}$/.test(v) ? [...v.slice(1)].map(c => '-' + c) : [v]))
1096}
1097
1098// claude's first operand (a subcommand or the prompt), past options, their values and redirections; null when none.
1099function firstOperand(values) {
1100 for (let i = 0; i < values.length; i++) {
1101 const v = values[i]
1102 if (v === '--') return i + 1 < values.length ? values[i + 1] : null
1103 const r = REDIRECT.exec(v)
1104 if (r) { if (targetNext(r)) i++; continue }
1105 if (!v.startsWith('-') || v === '-') return v
1106 if (v.includes('=')) continue
1107 if (CLAUDE_VALUED.includes(v)) i++
1108 else if (CLAUDE_OPTIONAL.includes(v) && i + 1 < values.length && !values[i + 1].startsWith('-')) i++
1109 }
1110 return null
1111}
1112
1113// What a claude command line starts, from its argument values: 'cloud', 'local', 'resume', 'ultrareview' or null.
1114// `--cloud` with `-p`/`--print` (and no `--environment`) only queues a message to an existing cloud
1115// session (`claude -p "<msg>" --cloud <session_id|cse_id|url>`): steering open work, null. Without a
1116// terminal --cloud cannot create a session; `-p --environment <id> --cloud` does, so it stays a launch.
1117// `--cloud` otherwise: a new cloud session.
1118// `ultrareview`: a cloud-hosted review; it names no model.
1119// A management subcommand (`claude plugin ...`), --version or --help: null.
1120// `-p`/`--print`, `--bg`/`--background`, or a prompt word: a local headless session. From the Bash,
1121// PowerShell and Monitor tools stdout is not a terminal, so `claude "task"` runs headless as -p does.
1122// With `-r`/`--resume`, `-c`/`--continue` or `--from-pr` (and no `--fork-session`) it carries on an
1123// existing local session: 'resume', open work. Otherwise (--fork-session included) a new one: 'local'.
1124// Without any of them (`claude`, `claude --resume abc`) it is an interactive session, no delegated launch: null.
1125export function claudeLaunch(raw) {
1126 const values = claudeWords(raw)
1127 if (hasFlag(values, '--cloud')) return hasFlag(values, '-p', '--print') && !hasFlag(values, '--environment') ? null : 'cloud'
1128 if (hasFlag(values, '--version', '-v', '--help', '-h')) return null
1129 const operand = firstOperand(values)
1130 if (operand !== null && MANAGEMENT.includes(operand)) return null
1131 if (operand === 'ultrareview') return 'ultrareview'
1132 if (operand === null && !hasFlag(values, '-p', '--print', '--bg', '--background')) return null
1133 if (hasFlag(values, '-r', '--resume', '-c', '--continue', '--from-pr') && !hasFlag(values, '--fork-session')) return 'resume'
1134 return 'local'
1135}
1136
1137// The launch a claude command word starts (kind from claudeLaunch). A --model with no readable name
1138// (`--model=`, a trailing `--model`, `--model "$M"`) names no model: hasModel false, and modelUnread,
1139// because an inserted --model sonnet would lose to it. --fallback-model's names are kept in `fallback`.
1140function claudeEntry(kind, args, insertAt) {
1141 if (kind === 'ultrareview') return { kind }
1142 const m = flagValue(args, '--model')
1143 const l = { kind, hasModel: m.present, model: m.value, insertAt }
1144 if (m.present && !literalModel(m.value)) {
1145 l.hasModel = false
1146 l.model = null
1147 l.modelUnread = true
1148 }
1149 const f = flagValue(args, '--fallback-model')
1150 if (f.present && literalModel(f.value)) l.fallback = f.value.split(',').map(s => s.trim()).filter(Boolean)
1151 return l
1152}
1153
1154// cloud-worker's launch.ps1 parameters. PowerShell binds named parameters anywhere, by any unambiguous
1155// prefix (`-Dry`), with `-Name:value` as one word.
1156const PS1_PARAMS = ['taskfile', 'rulesfile', 'logfile', 'model', 'effort', 'ref', 'dryrun', 'force', 'nottycheck', 'exe', 'exeargs']
1157const PS1_SWITCHES = ['dryrun', 'force', 'nottycheck']
1158
1159// The launch a launch script at seg[li] starts. launch.exp takes the model as its 4th argument; launch.ps1
1160// as its 4th positional argument or -Model, and -DryRun only prints the prompt (no launch: null). A
1161// launch.ps1 line cut short by `(` (`-ExeArgs @('-p')`) cannot be read: unparsed.
1162function launchEntry(command, seg, li) {
1163 let model
1164 let script
1165 if (baseName(seg[li].value) === 'launch.exp') {
1166 const arg = seg[li + 4]
1167 model = arg ? arg.value : null
1168 } else {
1169 if (/^\s*\(/.test(command.slice(seg[seg.length - 1].end))) return { kind: 'unparsed' }
1170 script = 'launch.ps1'
1171 model = null
1172 let named = false
1173 const positional = []
1174 for (let j = li + 1; j < seg.length; j++) {
1175 const v = seg[j].value
1176 const r = REDIRECT.exec(v)
1177 if (r) { if (targetNext(r)) j++; continue }
1178 const p = /^-([a-z]+)(?::([\s\S]*))?$/i.exec(v)
1179 if (!p) { positional.push(v); continue }
1180 const n = p[1].toLowerCase()
1181 const hits = PS1_PARAMS.includes(n) ? [n] : PS1_PARAMS.filter(x => x.startsWith(n))
1182 const name = hits.length === 1 ? hits[0] : null
1183 if (name && PS1_SWITCHES.includes(name)) {
1184 if (name === 'dryrun' && !/^\$?false$/i.test(p[2] || '')) return null
1185 continue
1186 }
1187 let value = p[2]
1188 if (value === undefined) {
1189 j++
1190 value = j < seg.length ? seg[j].value : null
1191 // An array value runs on over commas: 'a','b' or 'a', 'b'.
1192 while (j + 1 < seg.length && (command[seg[j].end - 1] === ',' || command[seg[j + 1].start] === ',')) j++
1193 }
1194 if (name === 'model') { model = value; named = true }
1195 }
1196 if (!named) model = positional.length > 3 ? positional[3] : null
1197 }
1198 const l = script ? { kind: 'launchExp', model, script } : { kind: 'launchExp', model }
1199 if (model !== null && !literalModel(model)) l.modelUnread = true
1200 return ltypes/index.d.ts 21 lines1// model-guard's named values in $.state: they outlive a reload of the hooks module, not /clear, /resume or /branch.
2
3// A workflow run's width record, one per runId: the agentIndex values admitted so far, and the width
4// (and profile name) that applied when the run started or was resumed.
5export type ModelGuardRun = { admitted: number[]; width: number; name?: string }
6
7// The `Claude plan:` line read from the person's CLAUDE.md (parsePlanLine's result).
8export type ModelGuardPlan = {
9 name: string | null
10 reserve: number | null
11 banked: { type: string; expires: string | null }[]
12 usageFile: string | null
13 raw: string
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'model-guard': { runs: StateFamily<ModelGuardRun>; plan: ModelGuardPlan }
19 }
20}
21