SLOPSHOPPER

model-guard

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…

newguardstatuspromptagents
A shopper browsing a rack in a slop shop
README

Claude Code skills

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.

SkillWhat it decides
model-mixWhich 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-ultracodeWhen 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-methodHow 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-modsWhen 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-workerHow to launch, follow and collect cloud workers from this machine, Windows included, with launch.ps1.
merge-gateThe 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.
overnightRunning unattended overnight cheaply and safely: before sleeping, wake design, usage limits, keeping the machine awake, the morning summary.
roadmap-trackerTracker hygiene for the roadmap: pinned issue, milestones on every issue, labels mapped to the queue, tickets closed with the PR that landed them.
matt-bridgeHow Matt Pocock's skills run inside the coordinator method, and which rule wins where they differ.
verified-researchResearching fast-changing facts with parallel readers, adversarial verifiers and a critic, and reporting them sorted by what survived.
release-watchCheap checks for Claude Code, Desktop, model, plan and upstream skill changes that should update the skills and mods.
skill-auditAuditing skills for trigger quality, size, duplication, stale facts and token cost, with concrete edits.
context-hygieneKeeping sessions cheap: what every turn pays, how to measure it, when to compact, clear or hand off.
windows-opsWhat 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-uiThe visual language of the mods: band, pane, chat card, toasts, theme colors, Desktop and terminal differences.
clean-codeEight 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-conventionsDefault 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.

Install

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

Your plan

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
  • reserve: the share of the weekly limit the skills leave for your own use.
  • banked: the limit resets shown in Settings → Usage, each as 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 (optional, terminal only): 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.

Launching a cloud worker

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.

Mods

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.

ModWhat it does
model-guardApplies 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-routerSmart 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).

License

MIT

Source 4 files
hooks/register.js 369 lines
1// 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}
369
hooks/budget.js 146 lines
1// 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}
146
hooks/rules.js 1621 lines
1// 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 l
types/index.d.ts 21 lines
1// 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