A small, autonomous delivery toolkit: contract-first changes carried to a checked, independently reviewed, landed state, alone or as a parallel fleet across…

A Claude Code plugin that runs a whole delivery team on the models you already have, free ones first.
🚀 Quickstart · 🎬 Demos · 📖 Guide · ❓ FAQ · 🏗️ How it works · 🤝 Contributing
/plugin install sysop --marketplace 0xctx/sysop
/sysop:go fix the off-by-one in pagination --land
Want to see it first? /sysop demo sets up three small repos with work queued; /sysop:fleet --all --yes builds them all at once. 🎬
That's it. sysop:
New here? Just type /sysop:go. It sets your repo up in a minute and walks you through a first real change, explaining each step as it happens. 🧭
| 🤝 A second opinion, always | Every change is reviewed by another vendor's model (two for risky work), and the review must name every file it read, or it doesn't count. |
| 🆓 Free models first | Gemini keys, OpenRouter, Kilo and Groq free tiers, even your own model on Kaggle's free GPUs. Out of quota? sysop reaches the same model through another route before it switches. |
| 🛡️ Your Claude plan, protected | sysop stops sending work to Claude at 80% of your 5-hour or weekly limit. The last 20% stays yours. |
| 😴 Claude sleeps while workers build | The session hands work off and goes quiet; sysop wakes it only when something needs judgement. Waiting costs zero tokens. |
| 👀 See everything, for free | /sysop and the live board run inside Claude Code without a model turn: land, halt, switch models, all at zero tokens. |
| 🧾 Evidence, not vibes | Plans, build reports, reviews and seals live on disk next to your code. |
/sysop:go fix the login redirect 🔧 one change
/sysop:fleet --yes --land add CSV export; cache stats ⚙️ a batch, built in parallel
/sysop:go build me an app that tracks my reading 💡 an idea → product doc → tickets → app
/sysopRuns inside Claude Code at zero tokens, even mid-turn:
| Command | Does |
|---|---|
/sysop | 📋 every lane in every repo, at a glance |
/sysop board | 🗂️ a live pane: Needs you · Working · Ready · Landed, with Land / Why / Halt buttons |
/sysop land | 🛬 merge everything that's ready (or /sysop land t3 for one); the safety gates still apply |
/sysop autoland on · off | 🤖 this repo merges ready work by itself, or waits for you |
/sysop halt · unhalt | ⏸️ stop / resume new work |
/sysop preset free-max · preset back | 🔀 switch models, or undo |
/sysop doctor | 🩺 is this machine ready? one fix per problem |
/sysop models · serve · config · lane | ⚙️ every everyday command, typed right here |
While work runs, a band above your prompt says it all: sysop · 2 working · 1 needs you · 1 ready · plan 41% · $0.12 today. Press 1 for the board, 2 to land everything ready. 🔔 A toast pops when something lands or needs you.
/sysop preset ✓ ready ◐ partly ✗ missing something
/sysop preset free-max 🆓 every free source you have
/sysop preset back ↩️ undo
| Preset | For you if you have… |
|---|---|
| ⭐ claude-plan (default) | a Claude plan |
| 🐎 haiku-workhorse | a Claude plan and lots of tickets |
| 🆓 free-max | pi and any free key |
| 🌐 openrouter-free | an OpenRouter key |
| 🌙 overnight-fleet | Claude + free models, running all night |
| ✍️ writing | books and articles, not code |
| 🔷 google-plan | a Google AI plan (Gemini through agy) |
| 🤝 two-plans | both a Claude plan and a Google plan |
Fourteen presets ship; the guide lists them all. 🛠️ Make your own in plain words, /sysop:tune use Gemini for building and Claude for reviews, or by hand: the guide shows the format.
Serve an open model on Kaggle's free 2×T4 for a work session, and let it build:
/sysop serve list 🧠 what fits, with measured speed (Gemma 4 26B ~134 tok/s)
/sysop serve up ⏳ 3–10 min to ready
/sysop preset kaggle-session 🏗️ your model builds; Claude takes risky work and reviews
It shuts itself down when idle (20 min), when the run ends, or after 4 hours. Setup is in the guide. ⚖️ Kaggle's terms allow personal ML use only: sysop never runs builds or tests there, never shares the endpoint, never chains sessions.
/sysop lane add free --preset openrouter-free --land
/sysop:go --lane free add CSV export
/sysop:fleet --all 🌍 every lane in every repo
sysop:<lane>; sysop builds it, merges it and closes the issue.Measured in sysop's own end-to-end tests (Haiku coordinating), whole session:
| Run | Cost |
|---|---|
| 🎓 setup + a narrated first change | $0.03–0.06 |
| 🔧 a tiny fix, planned, reviewed, landed | $0.03–0.43 |
| ⚙️ a two-ticket fleet | $0.10–0.62 |
| 🛣️ two lanes across two repos on free models | $0.09–0.12 |
On a Claude plan these come out of your plan, not your card. Free presets: $0. Every run ends by telling you its cost.
--land, sysop does everything up to the merge and stops at ready; then /sysop land, the board's Land button, or /sysop autoland on for a repo you trust.reset --hard after all? A dialog lets you run it once, logged./plugin install sysop --marketplace 0xctx/sysop
Answer y, pick a scope, then run /sysop:go. Needs: Python ≥ 3.11, git, Claude Code (2.1.287+ for the board and wake-ups). Optional: agy, pi ≥ 0.86, gh, kaggle. 🐧 Linux tested · 🍎 macOS expected.
If sysop saved you an afternoon, a ⭐ helps others find it.
MIT licensed · no server · no database · no build step · standard-library Python
hooks/sysop.mod.jsx 446 lines1import { atom, read, update } from 'claude-code'
2
3// The sysop mod: what runs inside Claude Code. It shows, wakes and runs the exact `sysop.py` command the
4// operator pressed; it never decides. Mechanics live in scripts/ (tested Python), judgement in skills.
5
6const HELP = [
7 '/sysop the board as text: every lane in every repo',
8 '/sysop board the board as a pane, with Land, Why and Halt buttons',
9 '/sysop land merge everything that is ready (your word; the gates still apply)',
10 '/sysop land <ticket> merge one ready ticket',
11 '/sysop autoland on|off this repo merges ready work by itself, or waits for /sysop land',
12 '/sysop halt [reason] stop new work; /sysop unhalt resumes',
13 '/sysop preset [name|back] list, switch or undo model presets',
14 '/sysop lane … lanes: list, add <name> --preset <p> [--land] [--pr], intake <lane>',
15 '/sysop models · config · serve · kaggle · doctor the same commands as sysop.py, no tokens',
16 '/sysop demo [folder] three small repos with work queued: then /sysop:fleet --all --yes',
17 'Asking for work is /sysop:go.',
18].join('\n')
19
20const MAX_TEXT = 4000
21// The everyday sysop.py commands, passed through as typed (plumbing stays with the skills).
22const PASS = new Set(['models', 'config', 'serve', 'kaggle', 'doctor', 'lane'])
23
24// The argv after `sysop.py` for what the operator typed, or a string to print instead.
25export function plan(args) {
26 const words = args.trim().split(/\s+/).filter(Boolean)
27 const [verb = 'status', ...rest] = words
28 switch (verb) {
29 case 'status':
30 return ['status', '--all', ...rest]
31 case 'board':
32 return { pane: BOARD }
33 case 'land': {
34 if (rest.length === 0) return ['land', '--ready', '--all']
35 if (rest.length !== 1) return 'usage: /sysop land (everything ready)\n /sysop land <ticket> (or <repo>:<ticket>)'
36 const [repo, ticket] = rest[0].includes(':') ? rest[0].split(/:(.*)/s) : ['', rest[0]]
37 const branch = ticket.startsWith('sysop/') ? ticket : `sysop/${ticket}`
38 return ['land', repo ? `${repo}:${branch}` : branch, '--land']
39 }
40 case 'autoland':
41 if (rest.length !== 1 || !['on', 'off'].includes(rest[0])) return 'usage: /sysop autoland on|off'
42 return ['config', 'set', 'land', rest[0] === 'on' ? 'true' : 'false', '--scope', 'local']
43 case 'halt':
44 return rest.length ? ['halt', '--reason', rest.join(' ')] : ['halt']
45 case 'unhalt':
46 return rest.length ? 'usage: /sysop unhalt' : ['unhalt']
47 case 'preset':
48 if (rest.length === 0) return ['preset', 'list']
49 if (rest.length === 1) return ['preset', 'use', rest[0] === 'back' ? 'previous' : rest[0]]
50 return ['preset', ...rest]
51 case 'demo': // three small repos with work queued in one lane: then /sysop:fleet --all --yes
52 return { script: 'demo.sh', args: rest }
53 case 'help':
54 return HELP
55 default:
56 if (PASS.has(verb)) return verb === 'lane' && rest.length === 0 ? ['lane', 'list'] : [verb, ...rest]
57 return `/sysop has no "${verb}".\n${HELP}`
58 }
59}
60
61// `land` with no ticket answers JSON: said as one line per outcome.
62export function landed(text) {
63 try {
64 const out = JSON.parse(text)
65 if (!Array.isArray(out.landed)) return text
66 const lines = out.landed.length ? [`Landed: ${out.landed.join(', ')}`] : []
67 if (out.refused) lines.push(`Stopped at ${out.refused.ticket}: ${out.refused.why}`)
68 return lines.join('\n') || 'Nothing was ready to land.'
69 } catch {
70 return text
71 }
72}
73
74// What the operator reads: the command's output, or its refusal; never a log dump.
75export function say(result) {
76 const out = result.exitCode === 0 ? result.stdout : result.stderr.trim() || result.stdout
77 const text = out.trim().replace(/^sysop: /, '') || (result.exitCode === 0 ? 'done' : `failed (exit ${result.exitCode})`)
78 return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}\n… (cut; run the command in a shell for all of it)` : text
79}
80
81// Settings `agents` (the model each sysop agent runs on), read through sysop.py and kept until a settings file
82// changes, so a reviewer dispatched without its model goes out with it instead of bouncing off the guard.
83let agentsCache = { key: '', agents: {} }
84
85async function agentModels($) {
86 const [home, root] = await Promise.all([$.env.get('HOME'), $.session.root()])
87 const stamps = await Promise.all([`${home}/.claude/sysop.json`, `${root}/.claude/sysop.json`].map(path =>
88 $.fs.stat(path).then(s => s.mtimeMs, () => 0)))
89 const key = `${root}:${stamps.join(':')}`
90 if (key !== agentsCache.key) {
91 const out = await $.process.run(['python3', `${$.plugin.root}/scripts/sysop.py`, 'settings', '--key', 'agents'])
92 let agents = {}
93 try {
94 agents = (out.exitCode === 0 && JSON.parse(out.stdout)) || {}
95 } catch {} // unreadable: no model filled in; the guard still asks for it
96 agentsCache = { key, agents }
97 }
98 return agentsCache.agents
99}
100
101async function withAgentModel($, e, next) {
102 const agent = String(e.subagent_type || '')
103 if (!agent.startsWith('sysop:') || e.model) return next(e)
104 const agents = await agentModels($)
105 const want = agents[agent] || agents.default
106 return want && want !== 'inherit' ? next({ ...e, model: want }) : next(e)
107}
108
109// The Claude plan's limits, for sysop.py: route keeps delegated work off Claude near a limit (claude_reserve).
110// Written only from a real reading: before the first one, rateLimits is empty and means nothing.
111async function cacheDir($) {
112 const [xdg, home] = await Promise.all([$.env.get('XDG_CACHE_HOME'), $.env.get('HOME')])
113 return xdg ? `${xdg}/sysop` : home ? `${home}/.cache/sysop` : null // null: nowhere sysop.py would look
114}
115
116async function recordUsage($, rateLimits) {
117 if (!rateLimits || rateLimits.length === 0) return
118 const dir = await cacheDir($)
119 if (!dir) return
120 const record = { rateLimits: rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
121 at: (await $.clock.now()) / 1000 }
122 await $.fs.write(`${dir}/claude-usage.json`, JSON.stringify(record))
123}
124
125// --- The board: a band above the prompt while sysop has work, and a pane with a button per action. ---------
126// Both draw `status --all --json`, refreshed only when a watched record changed (5 s stat) or once a minute.
127
128const BOARD = 'sysop-board'
129const board = atom({ plugin: 'sysop', key: 'board' }, null)
130const why = atom({ plugin: 'sysop', key: 'why' }, null)
131const TIERS = ['haiku', 'sonnet', 'opus']
132let seen = '' // the watched records' stamps at the last refresh
133let hinted = false // the session-model hint shows once per session
134
135const sysopArgv = ($, argv) => ['python3', `${$.plugin.root}/scripts/sysop.py`, ...argv]
136
137export function counts(rows) {
138 const ready = rows.filter(r => r.actions.includes('land') || r.column === 'ready').length
139 const needs = rows.filter(r => r.column === 'needs_you' && !r.actions.includes('land')).length
140 const working = rows.filter(r => r.column === 'working').length
141 return { working, needs, ready }
142}
143
144export function bandText(b) {
145 const { working, needs, ready } = counts(b.tickets)
146 const parts = ['sysop']
147 if (working) parts.push(`${working} working`)
148 if (needs) parts.push(`${needs} need${needs === 1 ? 's' : ''} you`)
149 if (ready) parts.push(`${ready} ready`)
150 if (b.halted.length) parts.push('halted')
151 if (b.plan_percent != null) parts.push(`plan ${b.plan_percent}%`)
152 if (b.spend_usd) parts.push(`$${b.spend_usd.toFixed(2)} today`)
153 return parts.join(' · ')
154}
155
156// One line per change worth a glance: landed, blocked, or waiting for your word.
157export function transitions(before, after) {
158 const was = new Map(before.map(r => [`${r.repo}:${r.id}`, r]))
159 const lines = []
160 for (const r of after) {
161 const old = was.get(`${r.repo}:${r.id}`)
162 if (!old || (old.column === r.column && old.status === r.status)) continue
163 if (r.column === 'landed') lines.push(`sysop: ${r.name} landed`)
164 else if (r.actions.includes('land') && !old.actions.includes('land')) lines.push(`sysop: ${r.name} is ready to land`)
165 else if (r.status === 'blocked') lines.push(`sysop: ${r.name} needs you: ${r.last}`)
166 }
167 return lines
168}
169
170async function stamps($) {
171 const b = await read($, board)
172 const paths = (b?.repos ?? []).flatMap(repo => ['tickets.json', 'HALT', 'active', 'runs'].map(f => `${repo}/.sysop/${f}`))
173 const marks = await Promise.all(paths.map(p => $.fs.stat(p).then(s => s.mtimeMs, () => 0)))
174 return marks.join(',')
175}
176
177async function refresh($) {
178 const out = await $.process.run(sysopArgv($, ['status', '--all', '--json']), { timeoutMs: 60000 })
179 if (out.exitCode !== 0) return
180 let next
181 try {
182 next = JSON.parse(out.stdout)
183 } catch {
184 return
185 }
186 const before = await read($, board)
187 if (before) for (const line of transitions(before.tickets, next.tickets)) $.ui.toast(line)
188 await update($, board, () => next)
189 seen = await stamps($)
190 if (!hinted && next.session_model) {
191 hinted = true
192 const model = String(await $.session.model()).toLowerCase()
193 const have = TIERS.findIndex(t => model.includes(t))
194 const want = TIERS.indexOf(next.session_model)
195 if (have > want && want >= 0) {
196 $.ui.toast(`sysop: this session runs on ${TIERS[have]}; the preset suggests ${next.session_model} (/model)`)
197 }
198 }
199}
200
201async function poll($) {
202 if ((await stamps($)) !== seen) await refresh($)
203}
204
205async function press($, argv) {
206 const result = await $.process.run(sysopArgv($, argv), { timeoutMs: 600000 })
207 $.ui.toast(`sysop: ${say(result).split('\n')[0]}`)
208 await refresh($)
209}
210
211async function landReady($) {
212 const result = await $.process.run(sysopArgv($, ['land', '--ready', '--all']), { timeoutMs: 600000 })
213 for (const line of landed(result.stdout || result.stderr).split('\n')) $.ui.toast(`sysop: ${line}`)
214 await refresh($)
215}
216
217function band($, e, b) {
218 const { Box, Button, Text } = $.ui.resolve(e)
219 const waiting = b.tickets.some(r => r.actions.includes('land'))
220 return (
221 <Box key="sysop-band">
222 <Text dimColor>{bandText(b)} </Text>
223 <Button key="open-board" label="board" hotkey="1" plain onPress={() => $.ui.open({ id: BOARD, title: 'sysop board' })} />
224 {waiting && <Text> </Text>}
225 {waiting && <Button key="land-ready" label="land ready" hotkey="2" plain onPress={() => landReady($)} />}
226 </Box>
227 )
228}
229
230const TITLES = { needs_you: 'Needs you', working: 'Working', ready: 'Ready to land', landed: 'Landed' }
231
232function row($, e, r, open) {
233 const { Box, Button, Text } = $.ui.resolve(e)
234 const id = `${r.repo}:${r.id}`
235 return (
236 <Box key={`row:${id}`} flexDirection="column">
237 <Box>
238 <Text>{r.name} </Text>
239 <Text dimColor>({r.status}) </Text>
240 {r.actions.includes('land') && (
241 <Button key={`land:${id}`} label="Land" variant="primary"
242 onPress={() => press($, ['land', `${r.repo}:sysop/${r.id}`, '--land'])} />
243 )}
244 <Button key={`why:${id}`} label="Why" onPress={() => update($, why, w => (w === id ? null : id))} />
245 {r.actions.includes('halt') && (
246 <Button key={`halt:${id}`} label="Halt repo" onPress={() => press($, ['--cwd', r.repo, 'halt', '--reason', `halted from the board at ${r.name}`])} />
247 )}
248 </Box>
249 {open === id && <Text dimColor> {r.last || 'no log yet'}</Text>}
250 {open === id && r.report && <Text dimColor> report: {r.report}</Text>}
251 </Box>
252 )
253}
254
255// --- Wake-ups: Claude sleeps while workers build. ------------------------------------------------------------
256// Every 10 s the mod beats its heart (so `sysop.py session` says wakes: true) and, while no turn runs, asks
257// `sysop.py wake` whether this session's fleet run (in any registered repo) has more than waiting to do.
258// A set of due actions wakes it once, after 20 s of quiet so finishes that land together wake it together.
259
260const WAKE_EVERY = 10000
261const COALESCE = 20000
262// A plugin's prompt may not start with a slash: it asks for the skill in words instead.
263const WAKE_TEXT = why => `sysop: the fleet has work for you (${why}). Resume it: run the sysop:fleet skill with --resume.`
264let busy = false // a main turn is running: the coordinator (or the operator) is already at work
265let pending = null // { key, since }: due actions seen, waiting out the coalescing window
266let woken = '' // the set of due actions the last wake-up was for
267
268async function watch($) {
269 const [id, dir] = await Promise.all([$.session.id(), cacheDir($)])
270 if (dir) await $.fs.write(`${dir}/mod-${id}.json`, JSON.stringify({ at: (await $.clock.now()) / 1000 }))
271 if (busy) {
272 pending = null
273 return
274 }
275 const out = await $.process.run(sysopArgv($, ['wake', '--session', id]), { timeoutMs: 60000 })
276 let state = { wake: false }
277 try {
278 state = JSON.parse(out.stdout)
279 } catch {}
280 const now = await $.clock.now()
281 if (!state.wake || state.key === woken) {
282 if (!state.wake) pending = null
283 return
284 }
285 if (!pending || pending.key !== state.key) {
286 pending = { key: state.key, since: now }
287 return
288 }
289 if (now - pending.since < COALESCE) return
290 woken = state.key
291 pending = null
292 await $.prompt.submit({ text: WAKE_TEXT(state.why) })
293}
294
295// --- Issue intake: lanes that set `intake.every_minutes` take their labelled GitHub issues in on a timer. ----
296// New tickets are the fleet's next launches, so the wake-up watcher above wakes the coordinator for them.
297const lastIntake = {} // lane -> when it last took issues in
298
299async function takeIssuesIn($) {
300 const out = await $.process.run(sysopArgv($, ['settings', '--key', 'lanes']), { timeoutMs: 30000 })
301 let lanes = {}
302 try {
303 lanes = (out.exitCode === 0 && JSON.parse(out.stdout)) || {}
304 } catch {}
305 const now = await $.clock.now()
306 for (const [lane, conf] of Object.entries(lanes)) {
307 const every = conf?.intake?.every_minutes
308 if (!every || (lane in lastIntake && now - lastIntake[lane] < every * 60000)) continue
309 lastIntake[lane] = now
310 const got = await $.process.run(sysopArgv($, ['lane', 'intake', lane]), { timeoutMs: 120000 })
311 try {
312 const added = JSON.parse(got.stdout).added ?? []
313 if (added.length) $.ui.toast(`sysop: ${added.join(', ')} joined lane ${lane}`)
314 } catch {
315 $.ui.toast(`sysop: issue intake for lane ${lane} failed: ${say(got).split('\n')[0]}`)
316 }
317 }
318}
319
320// --- Approve in place (interactive only; operator-approved 2026-10-11 and 2026-10-12): a destructive git
321// command the guard stopped is put to the operator in a dialog, run once if they press Run once, and logged.
322// Only that class is offered: credential reads, forged records and AI attribution never reach the dialog.
323// Headless, no dialog can open and the guard's refusal stands.
324const OFFERED = ['force push', 'git reset --hard', 'git clean -f', 'forced branch deletion', 'git checkout -- <path>',
325 'git switch --discard-changes', 'git restore', 'git stash drop', 'forced worktree removal']
326
327export function offered(reason) {
328 const found = /sysop guard blocked: ([^\n]*)/.exec(String(reason || ''))
329 return found && OFFERED.some(start => found[1].startsWith(start)) ? found[1].split(' (')[0].split('. ')[0] : null
330}
331
332async function approveOnce($, e, next) {
333 const verdict = await next(e)
334 const why = verdict.decision === 'deny' && verdict.hook === 'PreToolUse' ? offered(verdict.reason) : null
335 if (!why) return verdict
336 const command = String(e.input?.command ?? '').slice(0, 300)
337 let answer
338 try {
339 answer = await $.ui.ask(`sysop's guard stopped this (${why}): ${command} — run it once?`, ['Run once', 'Refuse'])
340 } catch {
341 return verdict // nobody to ask (headless), or the dialog was dismissed
342 }
343 if (answer !== 'Run once') return verdict
344 const root = await $.session.root()
345 const path = `${root}/.sysop/approvals.jsonl`
346 const before = await $.fs.read(path).catch(() => '')
347 const row = { at: new Date(await $.clock.now()).toISOString(), session: await $.session.id(), command, reason: why }
348 await $.fs.write(path, `${before}${JSON.stringify(row)}\n`)
349 return { decision: 'allow', reason: `the operator approved it once in the dialog (${why})` }
350}
351
352export const register = on => {
353 on('tool.check', { tool: 'Bash' }, approveOnce).catch(($, e, next) => next(e))
354
355 on('turn.start', ($, e, next) => {
356 busy = true
357 return next(e)
358 })
359
360 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
361 const b = await read($, board)
362 if (e.props.hasSurvey || !b) return next(e)
363 const { working, needs, ready } = counts(b.tickets)
364 if (!working && !needs && !ready) return next(e)
365 return band($, e, b)
366 })
367
368 on('ui.render', { component: 'Pane', requestId: BOARD }, async ($, e) => {
369 const { Box, Button, Text } = $.ui.resolve(e)
370 const b = await read($, board)
371 const open = await read($, why)
372 if (!b) return <Text>sysop: reading the board…</Text>
373 return (
374 <Box flexDirection="column">
375 <Box>
376 <Text dimColor>{bandText(b)} </Text>
377 {b.tickets.some(r => r.actions.includes('land')) && (
378 <Button key="land-all" label="Land all ready" variant="primary" onPress={() => landReady($)} />
379 )}
380 </Box>
381 {Object.entries(TITLES).map(([column, title]) => {
382 const rows = b.tickets.filter(r => r.column === column)
383 return rows.length === 0 ? null : (
384 <Box key={`col:${column}`} flexDirection="column">
385 <Text bold>{title} ({rows.length})</Text>
386 {rows.map(r => row($, e, r, open))}
387 </Box>
388 )
389 })}
390 {b.tickets.length === 0 && <Text dimColor>No tickets. Next: {b.next}</Text>}
391 {b.plan && <Text dimColor>{b.plan}</Text>}
392 </Box>
393 )
394 })
395
396 on('session.measure', async ($, e, next) => {
397 if (e.changed?.includes('rateLimits')) await recordUsage($, e.rateLimits)
398 return next(e)
399 }).catch(($, e, next) => next(e))
400 on('turn.complete', async ($, e, next) => {
401 if (e.agentId === undefined) busy = false
402 const result = await next(e)
403 if (e.agentId === undefined) await recordUsage($, (await $.session.usage()).rateLimits)
404 return result
405 }).catch(($, e, next) => next(e))
406
407 for (const tool of ['Agent', 'Task']) {
408 // Fails open: with the mod's lookup broken, the guard still asks for the model, as with mods off.
409 on('tool.call', { tool }, withAgentModel).catch(($, e, next) => next(e))
410 }
411
412 on('session.start', async ($, e, next) => {
413 if (e.surface !== null) { // nothing draws under -p or the SDK: no board to keep fresh
414 await refresh($)
415 $.clock.every(5000, () => poll($))
416 $.clock.every(60000, () => refresh($))
417 $.clock.every(WAKE_EVERY, () => watch($))
418 $.clock.every(60000, () => takeIssuesIn($))
419 }
420 await $.command.register({
421 name: 'sysop',
422 description: 'sysop board and controls, no tokens: land, halt, preset, lane, doctor (help lists them)',
423 argumentHint: '[land <ticket> | halt | unhalt | preset <name|back> | lane … | doctor | help]',
424 immediate: true,
425 })
426 return next(e)
427 })
428
429 on('command.run', { command: 'sysop' }, async ($, e) => {
430 const argv = plan(e.args || '')
431 if (typeof argv === 'string') return { text: argv }
432 if (argv.script) {
433 const ran = await $.process.run(['bash', `${$.plugin.root}/scripts/${argv.script}`, ...argv.args], { timeoutMs: 120000 })
434 return { text: say(ran) }
435 }
436 if (argv.pane) {
437 await refresh($)
438 await $.ui.open({ id: argv.pane, title: 'sysop board' })
439 return { text: 'sysop board opened' }
440 }
441 const result = await $.process.run(['python3', `${$.plugin.root}/scripts/sysop.py`, ...argv], { timeoutMs: 600000 })
442 if (argv[0] === 'land' && argv[1] === '--ready') await refresh($)
443 return { text: argv[1] === '--ready' ? landed(result.stdout || result.stderr) : say(result) }
444 })
445}
446types/index.d.ts 33 lines1// The sysop mod's state contract: what its band and pane draw from (hooks/sysop.mod.jsx).
2
3/** One ticket on the board, as `sysop.py status --all --json` lists it. */
4export type SysopTicket = {
5 id: string
6 name: string
7 repo: string
8 lane: string | null
9 status: string
10 column: 'needs_you' | 'working' | 'ready' | 'landed'
11 last: string
12 report: string | null
13 actions: ('land' | 'why' | 'halt')[]
14}
15
16/** The board: `status --all --json`, the fields the mod reads. */
17export type SysopBoard = {
18 tickets: SysopTicket[]
19 repos: string[]
20 halted: string[]
21 plan: string
22 plan_percent: number | null
23 spend_usd: number | null
24 session_model: string | null
25 next: string
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 sysop: { board: SysopBoard | null; why: string | null }
31 }
32}
33