The instruments around each Claude Code session: themed status line, TTSR and layered rules, AGENTS.md and APPEND_SYSTEM, an edit advisor

Claude Code mods, shared between workstations. One plugin, agent-cockpit (formerly omp-port): the set of instruments around each session, a themed status line above the prompt, TTSR and layered rules, AGENTS.md and APPEND_SYSTEM loading, and an edit advisor. Most of it is ported from oh-my-pi (omp). Described below.
The Agent Switchboard plugin that used to live here has moved to its own repository, agent-switchboard-claude, beside the switchboard plugins for other agent harnesses.
| Feature | What it does |
|---|---|
| APPEND_SYSTEM | Appends your own text to the system prompt, global and per project |
| AGENTS.md | Loads AGENTS.md files (project ancestors + ~/.agents/AGENTS.md) beside CLAUDE.md |
| Rules | omp-format rule files: alwaysApply rules go into the system prompt, described rules form a rulebook index |
| TTSR | Time Traveling Stream Rules, tool scope: rules checked against every Edit/Write; a reminder after the result, or a deny before it |
| Advisor | Optional reviewer of turns that edited files; you accept or ignore its note; spend tracking and a budget |
| Status line | omp-style themed line above the prompt (102 omp themes + custom): configurable segments, separators, icons and a context gauge filling the width |
/dgs | Settings pane for all of the above |
Works on Linux and Windows. Nothing reads omp's own ~/.omp files; omp is not required.
claude plugin marketplace add dgsantana/claude-mods
claude plugin install agent-cockpit@dgsantana
Coming from omp-port (renamed in 0.5.0): claude plugin marketplace update dgsantana, then claude plugin uninstall omp-port@dgsantana and install agent-cockpit@dgsantana. Config, rules and themes under .agents/ carry over. The advisor's settings and spend were kept in omp-port's own store and start empty: set them again with /dgs advisor.
The status line comes with the plugin; nothing to install. It is a band above the prompt (Claude Code's own statusLine slot below the prompt is not reachable from a mod), drawn on the terminal and desktop surfaces. Turn it off with statusline.enabled: false.
If the caveman plugin is active, the line shows its mode badge and savings (read from ~/.claude/.caveman-active, same checks as caveman's own script), so caveman's own statusLine entry can go.
Optional, for AST-based TTSR rules: ast-grep on PATH (mise use -g ast-grep, scoop install ast-grep, or cargo install ast-grep). Without it those rules are skipped and you get one toast per session saying so.
Everything is read from .agents/ folders, lowest to highest:
plugins/agent-cockpit/builtin-rules/, vendored from omp)~/.agents/ (%USERPROFILE%\.agents\ on Windows).agents/ from the repository root down to the working directory; the nearest wins.agents/
AGENTS.md global only (project AGENTS.md files live in the project as usual)
rules/*.md omp rule format; omp itself also reads this folder
mods/APPEND_SYSTEM.md
mods/config.json
rules/foo.md replaces a global or built-in foo.replace: true in a layer drops everything below it..claude/rules/ is deliberately not used: Claude Code loads that folder in full on its own, which would put every trigger rule into context.
See examples/.agents/ for a sample config, append file and rules.
Run /dgs in a session for the settings pane (tabs: Status line, TTSR, Rules, Advisor, Context). It writes ~/.agents/mods/config.json by default; switch to This project to write the repository's .agents/mods/config.json. Each row shows where its value comes from (default, global, project, store) and has a reset. A config.json that isn't valid JSON is never overwritten. Everything can also be edited by hand:
<!-- settings:start -->
| Key | Tab | Default | Values | Meaning |
|---|---|---|---|---|
statusline.enabled | Status line | true | bool | Draw the status line in the band above the prompt. |
statusline.theme | Status line | "dark" | theme name | Colour theme: one of the 102 omp themes or a custom one from .agents/mods/themes/. |
statusline.separator | Status line | "powerline-thin" | powerline powerline-thin slash pipe block none ascii | How segments are separated; powerline draws filled segments on the theme background. |
statusline.fill | Status line | "gauge" | gauge space none | The room between the sides: a context gauge, blank space pushing the right side to the edge, or nothing. |
statusline.icons | Status line | "nerd" | nerd ascii none | Nerd Font glyphs, short ASCII labels, or no icons. |
statusline.left | Status line | ["model","caveman","path","git","sha"] | segment ids | Segments on the left, in order. |
statusline.right | Status line | ["activity","delta","tokens","cost","fiveHour","sevenDay","duration"] | segment ids | Segments on the right, in order. |
statusline.path.style | Status line | "basename" | basename full home | Folder name only, the full path, or the path relative to home (~). |
statusline.git.aheadBehind | Status line | true | bool | Show ↑/↓ commit counts against the upstream branch. |
statusline.git.counts | Status line | true | bool | Show staged (+), unstaged (~) and untracked (?) file counts; off shows a * when dirty. |
statusline.fiveHour.showReset | Status line | true | bool | Show the time until the 5-hour usage window resets. |
statusline.sevenDay.showReset | Status line | true | bool | Show the time until the weekly usage window resets. |
statusline.ctx.warnAt | Status line | 50 | ≥ 0, ≤ 100 | Context use at which the segment turns to the warning colour. |
statusline.ctx.errorAt | Status line | 80 | ≥ 0, ≤ 100 | Context use at which the segment turns to the error colour. |
ttsr.enabled | TTSR | true | bool | Check every Edit/Write against trigger rules. |
ttsr.interruptMode | TTSR | "always" | always tool-only never prose-only | Default for rules without their own: always/tool-only deny the edit, never reminds after it. |
ttsr.repeatMode | TTSR | "once" | once after-gap | A rule fires once per session, or again after a gap of completed turns. |
ttsr.repeatGap | TTSR | 10 | ≥ 1, ≤ 1000 | Completed turns before an after-gap rule may fire again. |
rules.builtin | Rules | true | bool | Load the rules vendored from oh-my-pi. |
rules.disabled | Rules | [] | names | Rule names to drop. |
advisor.enabled | Advisor | false | bool | Review turns that edited files. (stored by /dgs advisor and the pane, not config.json) |
advisor.model | Advisor | none | string | Reviewer model id; empty reuses the session through a cached fork. (stored by /dgs advisor and the pane, not config.json) |
advisor.budgetUsd | Advisor | none | ≥ 0 | The advisor turns itself off once total spend reaches this; empty for none. (stored by /dgs advisor and the pane, not config.json) |
append.enabled | Context | true | bool | Append .agents/mods/APPEND_SYSTEM.md to the system prompt. |
agentsMd.enabled | Context | true | bool | Load AGENTS.md files beside CLAUDE.md. |
<!-- settings:end -->
advisor.prices ({ "<model>": { input, output, cacheRead, cacheWrite } }, USD per MTok) overrides the built-in price table.
statusline.theme takes any of the 102 themes vendored from oh-my-pi (dark, light, dark-tokyo-night, dark-catppuccin, light-solarized, …; the /dgs pane lists them all) or a custom theme file .agents/mods/themes/<name>.json in any layer:
{ "extends": "dark-nord", "vars": { "hot": "#ff5f87" }, "colors": { "statusLineModel": "hot" } }
Colours are #rrggbb, an xterm index 0–255, or a name from vars. Tokens: statusLineBg, statusLineSep, statusLineModel, statusLinePath, statusLineGitClean, statusLineGitDirty, statusLineContext, statusLineSpend, statusLineOutput, statusLineCost, statusLineCaveman, accent, success, warning, error, dim. Missing tokens come from extends (or dark).
Segments:
| Segment | Shows |
|---|---|
model | The session's model, shortened (Opus 5.5 1M) |
caveman | caveman's mode badge and savings, when caveman is active |
path | The working folder (statusline.path.style) |
git | Branch, ↑/↓ against upstream, and staged +, unstaged ~, untracked ? file counts |
sha | The short commit id |
tokens | The input of the last response (what fills the context window) |
cost | What the session has cost |
fiveHour, sevenDay | The share left of the 5-hour and weekly usage windows, with the time to reset |
ctx | Context use as a percent (the gauge shows it too) |
delta | What the last turn cost and how much it grew or shrank the context |
activity | agent-cockpit's own work this session: TTSR rules fired, advisor spend (! while a note waits) |
duration | Time since the session began (a resumed session counts from its first launch) |
When the row is too narrow, segments drop from the lowest priority up: activity and caveman first, then sha and tokens, delta, duration, sevenDay, cost and fiveHour, keeping path, ctx, git and model longest. Separators: powerline (filled, on statusLineBg), powerline-thin, slash, pipe, block, none, ascii. Icons: nerd, ascii, none.
Fill (statusline.fill), the room between the left and right segments:
| Value | Draws |
|---|---|
gauge | A rule filled to the context share in the ctx colour, the percent at the fill's end, the window size last: ────58%──────── 1M |
space | Blank space; the right segments sit at the right edge |
none | Two spaces; the sides sit together |
The line refreshes at session start, after each turn and every 5 seconds.
omp's frontmatter, unchanged:
---
description: Never use Box::leak
condition: "Box::leak" # regex; (?i) (?m) (?s) prefixes work
astCondition: "for $I := 0; $I < $N; $I++ { $$$BODY }" # ast-grep pattern
scope: "tool:edit(*.rs), tool:write(*.rs)"
globs: "src/**"
interruptMode: never # never | tool-only | always
alwaysApply: true # rules without triggers: put the body in the prompt
---
Body: what the model reads.
A rule with condition or astCondition is a TTSR rule. Otherwise alwaysApply: true puts its body in the system prompt, and a description lists it in the rulebook. Prose/thinking scopes and judged question rules are not supported (Claude Code exposes no stream to watch them).
Claude Code has its own built-in /advisor; agent-cockpit's advisor lives under /dgs advisor.
/dgs advisor on | off | status | reset
/dgs advisor model <id> | default
/dgs advisor budget <usd> | none
Advisor settings (from /dgs advisor or the pane's Advisor tab) live in the plugin's store, so they apply to every project and outlive the session; once set they take precedence over advisor.* in config.json. When the budget trips the advisor turns itself off: raise the budget (or /dgs advisor reset) and /dgs advisor on again.
After a turn that edited files, the advisor asks a reviewer for OK or one short note. A note shows as a toast and a band above the prompt with Send with next prompt (default) and Ignore. Accepted notes ride along with your next prompt as context for the model.
bun test plugins/agent-cockpit/unit # pure logic
claude plugin test plugins/agent-cockpit # hooks against the engine
claude plugin validate . && claude plugin validate plugins/agent-cockpit
claude --plugin-dir plugins/agent-cockpit # try it in a session
bun run test:unit, bun run test:plugin and bun run validate run the same checks.
All engine calls ($) live in plugins/agent-cockpit/hooks/register.tsx: the engine only follows $ into functions declared in the hooks module itself. Everything else is pure and imported.
MIT. plugins/agent-cockpit/builtin-rules/ comes from oh-my-pi (MIT); see NOTICE.
hooks/register.tsx 985 lines1// The one hooks module. The engine follows `$` only into functions declared in
2// this file, so every call on `$` lives here; the logic it feeds is imported.
3
4import type { EngineInterface, Register } from 'claude-code'
5import { costUsd, parseVerdict, priceOrFallback, reviewPrompt } from './advisor'
6import { addAgentsMd } from './agentsmd'
7import { setPath, unsetPath } from './config-patch'
8import { discover, homeDir, type Io, type Snapshot } from './load'
9import {
10 hintFor,
11 labelWidth,
12 paneGroups,
13 paneRows,
14 parseListInput,
15 PREVIEW_DATA,
16 projectLayerDir,
17 rulesRows,
18 SELECT_MAX,
19 type SegmentLists,
20 segmentMove,
21 segmentRows,
22 segmentSide,
23 segmentToggle,
24 themeGroups,
25} from './pane-model'
26import { isWindowsPath, join } from './paths'
27import { renderRulesSection } from './rules'
28import { parseCaveman, parsePorcelain, type RateWindow, type StatusData, statusSpans, type TurnDelta } from './status'
29import { SETTINGS, type Setting, settingsFor, TABS, type Tab, validate } from './settings-schema'
30import { sanitizeStatusline, type StatuslineConfig } from './statusline-config'
31import { listThemes, resolveTheme } from './themes'
32import {
33 astTargets,
34 type Candidate,
35 type CompiledRule,
36 compileRule,
37 extractCandidates,
38 isEligible,
39 markInjected,
40 matchRegex,
41 newRepeatState,
42 onTurnEnd,
43 renderReminder,
44} from './ttsr-match'
45
46const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
47
48// Warnings repeat on every reload of the layers; each is shown once per load of the module.
49const warned = new Set<string>()
50
51function warnOnce($: EngineInterface, text: string): void {
52 if (warned.has(text)) return
53 warned.add(text)
54 $.ui.log(`agent-cockpit: ${text}`)
55}
56
57function ioFrom($: EngineInterface): Io {
58 return {
59 env: async name => (name === 'HOME' ? $.env.get('HOME') : $.env.get('USERPROFILE')),
60 read: async path => {
61 try {
62 if (!(await $.fs.exists(path))) return undefined
63 const text = await $.fs.read(path)
64 return typeof text === 'string' ? text : undefined
65 } catch {
66 return undefined
67 }
68 },
69 listFiles: async (dir, ext) => {
70 try {
71 if (!(await $.fs.exists(dir))) return []
72 const entries = await $.fs.list(dir)
73 const wanted = ext === '.md' ? /\.mdc?$/i : /\.json$/i
74 return entries
75 .filter(e => e.kind === 'file' && wanted.test(e.name))
76 .map(e => e.name)
77 .sort()
78 } catch {
79 return []
80 }
81 },
82 }
83}
84
85async function loadAll($: EngineInterface): Promise<Snapshot> {
86 const repo = await $.session.repo().catch(() => null)
87 const cwd = await $.session.cwd()
88 const root = repo?.root ?? (await $.session.root())
89 const snap = await discover(ioFrom($), { builtinDir: join($.plugin.root, 'builtin-rules'), root, cwd })
90 for (const w of snap.warnings) warnOnce($, w)
91 return snap
92}
93
94// The loaded layers, cached until the next turn starts, so rule edits made
95// between turns are picked up without re-reading on every model request.
96let cached: Promise<Snapshot> | undefined
97
98function snapshot($: EngineInterface): Promise<Snapshot> {
99 if (!cached) {
100 const loading = loadAll($)
101 loading.catch(() => {
102 if (cached === loading) cached = undefined
103 })
104 cached = loading
105 }
106 return cached
107}
108
109// TTSR: rules compiled once per snapshot.
110let compiledFor: Snapshot | undefined
111let compiled: CompiledRule[] = []
112
113function compiledRules($: EngineInterface, snap: Snapshot): CompiledRule[] {
114 if (compiledFor !== snap) {
115 compiledFor = snap
116 compiled = []
117 for (const rule of snap.rules) {
118 const c = compileRule(rule)
119 if (!c) continue
120 for (const w of c.warnings) warnOnce($, w)
121 compiled.push(c)
122 }
123 }
124 return compiled
125}
126
127const REPEAT = { plugin: 'agent-cockpit', key: 'ttsrRepeat' } as const
128const AST_HINTED = { plugin: 'agent-cockpit', key: 'astGrepHinted' } as const
129const TTSR_HITS = { plugin: 'agent-cockpit', key: 'ttsrHits' } as const
130
131function astGrepInstallHint(windows: boolean): string {
132 return windows
133 ? 'agent-cockpit: ast-grep not found, AST rules skipped. Install: `scoop install ast-grep` or `mise use -g ast-grep`'
134 : 'agent-cockpit: ast-grep not found, AST rules skipped. Install: `mise use -g ast-grep` or `cargo install ast-grep`'
135}
136
137// Runs ast-grep over the candidate text; 'missing' when the binary is absent.
138async function astGrepMatches($: EngineInterface, pattern: string, lang: string, text: string): Promise<boolean | 'missing'> {
139 try {
140 const r = await $.process.run(['ast-grep', 'run', '--pattern', pattern, '--lang', lang, '--stdin', '--json=compact'], {
141 stdin: text,
142 timeoutMs: 5000,
143 })
144 if (r.exitCode === 127) return 'missing'
145 const parsed: unknown = JSON.parse(r.stdout.trim() || '[]')
146 return Array.isArray(parsed) && parsed.length > 0
147 } catch (err) {
148 return /ENOENT|not found|no such file/i.test(errText(err)) ? 'missing' : false
149 }
150}
151
152async function ttsrMatches($: EngineInterface, rules: CompiledRule[], cand: Candidate): Promise<CompiledRule[]> {
153 const hits = new Set(matchRegex(rules, cand))
154 for (const t of astTargets(rules, cand)) {
155 if (hits.has(t.rule)) continue
156 for (const pattern of t.patterns) {
157 const m = await astGrepMatches($, pattern, t.lang, cand.text)
158 if (m === 'missing') {
159 const hinted = await $.state.get(AST_HINTED)
160 if (!hinted.value) {
161 await $.state.set(AST_HINTED, true)
162 $.ui.toast(astGrepInstallHint(isWindowsPath($.plugin.root)))
163 }
164 return [...hits]
165 }
166 if (m) {
167 hits.add(t.rule)
168 break
169 }
170 }
171 }
172 return [...hits]
173}
174
175type ToolAnswer = Awaited<ReturnType<EngineInterface['tool']['call']>>
176
177async function ttsr($: EngineInterface, e: { tool: string }, run: () => Promise<ToolAnswer>): Promise<ToolAnswer> {
178 let snap: Snapshot
179 try {
180 snap = await snapshot($)
181 } catch {
182 return run()
183 }
184 const cfg = snap.config.ttsr
185 const rules = cfg.enabled ? compiledRules($, snap) : []
186 const cands = extractCandidates(e.tool, e)
187 if (rules.length === 0 || cands.length === 0) return run()
188
189 const held = await $.state.get(REPEAT)
190 const state = held.value ?? newRepeatState()
191 const deny: string[] = []
192 const remind: string[] = []
193 const fired: string[] = []
194 for (const cand of cands) {
195 for (const c of await ttsrMatches($, rules, cand)) {
196 const name = c.rule.name
197 if (fired.includes(name) || !isEligible(state, name, cfg.repeatMode, cfg.repeatGap)) continue
198 const mode = c.rule.interruptMode ?? cfg.interruptMode
199 if (mode === 'prose-only') continue
200 fired.push(name)
201 markInjected(state, name)
202 ;(mode === 'never' ? remind : deny).push(renderReminder(c.rule, cand.path))
203 }
204 }
205 if (fired.length === 0) return run()
206 await $.state.set(REPEAT, state)
207 await $.state.set(TTSR_HITS, ((await $.state.get(TTSR_HITS)).value ?? 0) + fired.length)
208 $.ui.toast(`agent-cockpit TTSR: ${fired.join(', ')}`)
209 if (deny.length > 0) return { deny: [...deny, ...remind].join('\n\n') }
210 const result = await run()
211 if (result.deny !== undefined) return result
212 return { ...result, context: [...(result.context ?? []), remind.join('\n\n')] }
213}
214
215// Advisor: reviews turns that edited files; the person accepts or ignores
216// its note before the next prompt. Settings live in $.store over config.
217const EDITS = { plugin: 'agent-cockpit', key: 'advisorEdits' } as const
218const NOTE = { plugin: 'agent-cockpit', key: 'advisorNote' } as const
219const DECISION = { plugin: 'agent-cockpit', key: 'advisorDecision' } as const
220const SESSION_USD = { plugin: 'agent-cockpit', key: 'advisorSessionUsd' } as const
221const LAST_ERROR = { plugin: 'agent-cockpit', key: 'advisorLastError' } as const
222const ESTIMATED_FOR = { plugin: 'agent-cockpit', key: 'advisorEstimatedFor' } as const
223
224type AdvisorSettings = { enabled: boolean; model?: string; budgetUsd?: number; totalUsd: number }
225
226async function advisorSettings($: EngineInterface): Promise<AdvisorSettings> {
227 let cfg = { enabled: false } as { enabled: boolean; model?: string; budgetUsd?: number }
228 try {
229 cfg = (await snapshot($)).config.advisor
230 } catch {}
231 const enabled = await $.store.get('advisor.enabled')
232 const model = await $.store.get('advisor.model')
233 const budget = await $.store.get('advisor.budgetUsd')
234 const total = await $.store.get('advisor.totalUsd')
235 return {
236 enabled: typeof enabled === 'boolean' ? enabled : cfg.enabled,
237 model: typeof model === 'string' && model !== '' ? model : cfg.model,
238 budgetUsd: typeof budget === 'number' ? budget : cfg.budgetUsd,
239 totalUsd: typeof total === 'number' ? total : 0,
240 }
241}
242
243async function trackEdit($: EngineInterface, e: { file_path?: unknown; agentId?: string }, r: ToolAnswer): Promise<void> {
244 if (e.agentId !== undefined || r.deny !== undefined || r.isError === true || typeof e.file_path !== 'string') return
245 const held = await $.state.get(EDITS)
246 const edits = held.value ?? []
247 if (!edits.includes(e.file_path)) await $.state.set(EDITS, [...edits, e.file_path])
248}
249
250async function advisorReview($: EngineInterface, files: string[], answer: string): Promise<void> {
251 const s = await advisorSettings($)
252 if (!s.enabled || (s.budgetUsd !== undefined && s.totalUsd >= s.budgetUsd)) return
253 const r = s.model
254 ? await $.model.complete({ model: s.model, prompt: reviewPrompt(files, answer) })
255 : await $.model.fork({ prompt: reviewPrompt(files) })
256
257 if ('usage' in r) {
258 let prices = {}
259 try {
260 prices = (await snapshot($)).config.advisor.prices
261 } catch {}
262 const model = s.model ?? (await $.session.model())
263 const { price, estimated } = priceOrFallback(model, prices)
264 const cost = costUsd(r.usage, price)
265 if (estimated && (await $.state.get(ESTIMATED_FOR)).value !== model) {
266 await $.state.set(ESTIMATED_FOR, model)
267 $.ui.toast(`Advisor: no price for ${model}; spend estimated at the highest known rate (set advisor.prices in config)`)
268 }
269 const total = s.totalUsd + cost
270 await $.store.set('advisor.totalUsd', total)
271 const session = await $.state.get(SESSION_USD)
272 await $.state.set(SESSION_USD, (session.value ?? 0) + cost)
273 if (s.budgetUsd !== undefined && total >= s.budgetUsd) {
274 await $.store.set('advisor.enabled', false)
275 $.ui.toast(`Advisor off: budget $${s.budgetUsd.toFixed(2)} reached ($${total.toFixed(3)} spent)`)
276 }
277 }
278
279 if (!r.isAnswered) {
280 await $.state.set(LAST_ERROR, r.reason)
281 $.ui.log(`agent-cockpit: advisor got no review (${r.reason})`)
282 return
283 }
284 await $.state.set(LAST_ERROR, null)
285 const verdict = parseVerdict(r.text)
286 if (verdict.ok) return
287 await $.state.set(NOTE, verdict.note)
288 await $.state.set(DECISION, 'accept')
289 $.ui.toast(`Advisor: ${verdict.note}`)
290}
291
292async function advisorCommand($: EngineInterface, args: string): Promise<string> {
293 const [verb = 'status', ...rest] = args.trim().split(/\s+/).filter(Boolean)
294 const value = rest.join(' ')
295 switch (verb) {
296 case 'on':
297 await $.store.set('advisor.enabled', true)
298 return 'Advisor on.'
299 case 'off':
300 await $.store.set('advisor.enabled', false)
301 return 'Advisor off.'
302 case 'model':
303 if (value === '' || value === 'default') await $.store.delete('advisor.model')
304 else await $.store.set('advisor.model', value)
305 return value === '' || value === 'default' ? 'Advisor model: session model (fork).' : `Advisor model: ${value}.`
306 case 'budget': {
307 const usd = Number(value)
308 if (value === '' || value === 'none') {
309 await $.store.delete('advisor.budgetUsd')
310 return 'Advisor budget removed.'
311 }
312 if (!Number.isFinite(usd) || usd < 0) return `Not a budget: ${value}`
313 await $.store.set('advisor.budgetUsd', usd)
314 return `Advisor budget: $${usd.toFixed(2)}.`
315 }
316 case 'reset':
317 await $.store.set('advisor.totalUsd', 0)
318 return 'Advisor spend reset.'
319 case 'status': {
320 const s = await advisorSettings($)
321 const session = (await $.state.get(SESSION_USD)).value ?? 0
322 const lastError = (await $.state.get(LAST_ERROR)).value
323 const estimatedFor = (await $.state.get(ESTIMATED_FOR)).value
324 const reached = s.budgetUsd !== undefined && s.totalUsd >= s.budgetUsd
325 return [
326 `Advisor ${s.enabled ? 'on' : 'off'}${s.enabled && reached ? ' (budget reached; raise it or /dgs advisor reset)' : ''}`,
327 `model: ${s.model ?? 'session model (fork)'}`,
328 `budget: ${s.budgetUsd !== undefined ? `$${s.budgetUsd.toFixed(2)}` : 'none'}${reached ? ' — budget reached' : ''}`,
329 `spent: session $${session.toFixed(3)}, total $${s.totalUsd.toFixed(3)}${estimatedFor ? ` (estimated for ${estimatedFor})` : ''}`,
330 ...(lastError ? [`last review failed: ${lastError}`] : []),
331 ].join(' · ')
332 }
333 default:
334 return 'Usage: /dgs advisor on|off|status|model <id|default>|budget <usd|none>|reset'
335 }
336}
337
338// The /dgs settings pane: tabs over the settings catalogue, writing the
339// global or the project layer's config.json (store-backed rows to $.store).
340const PANE_ID = 'agent-cockpit-settings'
341const PANE_TAB = { plugin: 'agent-cockpit', key: 'paneTab' } as const
342const PANE_SCOPE = { plugin: 'agent-cockpit', key: 'paneScope' } as const
343const PANE_ERROR = { plugin: 'agent-cockpit', key: 'paneError' } as const
344const PANE_THEME_GROUP = { plugin: 'agent-cockpit', key: 'paneThemeGroup' } as const
345const PANE_FOCUS = { plugin: 'agent-cockpit', key: 'paneFocus' } as const
346
347let themeTable: Record<string, Record<string, string>> | undefined
348
349async function builtinThemes($: EngineInterface): Promise<Record<string, Record<string, string>>> {
350 if (themeTable) return themeTable
351 try {
352 const text = await ioFrom($).read(join($.plugin.root, 'themes', 'builtin.json'))
353 themeTable = text ? (JSON.parse(text) as Record<string, Record<string, string>>) : {}
354 } catch {
355 themeTable = {}
356 }
357 return themeTable
358}
359
360type PaneTargets = { global?: string; project?: string }
361
362async function paneTargets($: EngineInterface, snap: Snapshot): Promise<PaneTargets> {
363 const home = await homeDir(ioFrom($))
364 const repo = await $.session.repo().catch(() => null)
365 const projectDir = projectLayerDir(snap.layers, repo !== null)
366 return {
367 global: home ? join(home, '.agents', 'mods', 'config.json') : undefined,
368 project: projectDir ? join(projectDir, 'mods', 'config.json') : undefined,
369 }
370}
371
372async function storeValues($: EngineInterface): Promise<Record<string, unknown>> {
373 const out: Record<string, unknown> = {}
374 for (const s of SETTINGS) if (s.storage === 'store') out[s.key] = await $.store.get(s.key)
375 return out
376}
377
378// Applies one change to the chosen layer: `patch` turns the file's text into
379// the new text, or says why it can't.
380async function patchLayer(
381 $: EngineInterface,
382 setting: Setting,
383 patch: (text: string | undefined) => { text: string } | { error: string },
384): Promise<void> {
385 const snap = await snapshot($)
386 const scope = (await $.state.get(PANE_SCOPE)).value ?? 'global'
387 const targets = await paneTargets($, snap)
388 const target = scope === 'project' ? targets.project : targets.global
389 if (!target) {
390 $.ui.toast(`agent-cockpit: no ${scope} config location here`)
391 return
392 }
393 const fail = async (why: string) => {
394 $.ui.toast(`agent-cockpit: ${target}: ${why}; left unchanged`)
395 await $.state.set(PANE_ERROR, { key: setting.key, text: why })
396 }
397 // A file that exists but can't be read is never treated as missing.
398 let text: string | undefined
399 try {
400 if (await $.fs.exists(target)) {
401 const read = await $.fs.read(target)
402 if (typeof read !== 'string') return fail('not a text file')
403 text = read
404 }
405 } catch (err) {
406 return fail(`can't read it (${errText(err)})`)
407 }
408 const r = patch(text)
409 if ('error' in r) return fail(r.error)
410 try {
411 await $.fs.write(target, r.text)
412 } catch (err) {
413 return fail(errText(err))
414 }
415 await $.state.set(PANE_ERROR, null)
416 cached = undefined
417}
418
419async function writeSetting($: EngineInterface, setting: Setting, raw: unknown): Promise<void> {
420 if (setting.storage === 'store' && (raw === '' || raw === undefined)) {
421 await $.store.delete(setting.key)
422 await $.state.set(PANE_ERROR, null)
423 return
424 }
425 const v = validate(setting, raw)
426 if ('error' in v) {
427 await $.state.set(PANE_ERROR, { key: setting.key, text: v.error })
428 return
429 }
430 if (setting.storage === 'store') {
431 await $.store.set(setting.key, v.value)
432 await $.state.set(PANE_ERROR, null)
433 return
434 }
435 await patchLayer($, setting, text => setPath(text, setting.key, v.value))
436}
437
438async function resetSetting($: EngineInterface, setting: Setting): Promise<void> {
439 if (setting.storage === 'store') {
440 await $.store.delete(setting.key)
441 return
442 }
443 await patchLayer($, setting, text => unsetPath(text, setting.key))
444}
445
446// The two segment lists change together, in one write of the chosen layer.
447const SEGMENTS_SETTING = SETTINGS.find(s => s.key === 'statusline.left') as Setting
448
449async function writeSegments($: EngineInterface, lists: SegmentLists): Promise<void> {
450 await patchLayer($, SEGMENTS_SETTING, text => {
451 const left = setPath(text, 'statusline.left', lists.left)
452 return 'error' in left ? left : setPath(left.text, 'statusline.right', lists.right)
453 })
454}
455
456async function resetSegments($: EngineInterface): Promise<void> {
457 await patchLayer($, SEGMENTS_SETTING, text => {
458 const left = unsetPath(text, 'statusline.left')
459 return 'error' in left ? left : unsetPath(left.text, 'statusline.right')
460 })
461}
462
463async function setScope($: EngineInterface, scope: 'global' | 'project'): Promise<void> {
464 if (scope === 'project') {
465 const targets = await paneTargets($, await snapshot($))
466 if (!targets.project) {
467 $.ui.toast('agent-cockpit: "This project" needs a git repository; writing to the global layer')
468 return
469 }
470 }
471 await $.state.set(PANE_SCOPE, scope)
472}
473
474// The tab-specific block above the settings rows: the status line preview,
475// the rule list, the advisor's spend.
476async function paneExtras(
477 $: EngineInterface,
478 tab: Tab,
479 snap: Snapshot,
480 statusline: StatuslineConfig,
481 builtin: Record<string, Record<string, string>>,
482 e: Parameters<EngineInterface['ui']['resolve']>[0],
483 columns: number | undefined,
484) {
485 const { Box, Button, Text } = $.ui.resolve(e)
486 if (tab === 'statusline') {
487 const resolved = resolveTheme(statusline.theme, builtin, snap.themeSpecs)
488 const warnings = [...sanitizeStatusline(snap.config.statusline).warnings, ...resolved.warnings]
489 for (const w of warnings) warnOnce($, w)
490 return (
491 <Box flexDirection="column">
492 <Box flexDirection="row" flexWrap="nowrap" overflow="hidden">
493 {(() => {
494 const { left, middle, right } = statusSpans(PREVIEW_DATA, statusline, resolved.theme, columns)
495 return [...left, ...middle, ...right].map(s => (
496 <Text color={s.color} backgroundColor={s.backgroundColor} wrap="truncate">
497 {s.text}
498 </Text>
499 ))
500 })()}
501 </Box>
502 {warnings.map(w => (
503 <Text color="yellow">{`⚠ ${w}`}</Text>
504 ))}
505 </Box>
506 )
507 }
508 if (tab === 'rules') {
509 const disabledSetting = SETTINGS.find(s => s.key === 'rules.disabled') as Setting
510 const disabled = snap.config.rules.disabled
511 return (
512 <Box flexDirection="column">
513 {rulesRows(snap.allRules, snap.config.rules).map(r => (
514 <Box key={`rulerow-${r.name}`} gap={1}>
515 <Button
516 key={`rule-${r.name}`}
517 label={r.disabled ? '[ ]' : '[x]'}
518 plain
519 onPress={() =>
520 writeSetting($, disabledSetting, disabled.includes(r.name) ? disabled.filter(n => n !== r.name) : [...disabled, r.name])
521 }
522 />
523 <Text dimColor={r.disabled}>{`${r.name} ${r.kind} · ${r.source}`}</Text>
524 </Box>
525 ))}
526 </Box>
527 )
528 }
529 if (tab === 'advisor') {
530 const session = (await $.state.get(SESSION_USD)).value ?? 0
531 const total = await $.store.get('advisor.totalUsd')
532 const lastError = (await $.state.get(LAST_ERROR)).value
533 return (
534 <Box gap={1}>
535 <Text>{`spent: session $${session.toFixed(3)}, total $${(typeof total === 'number' ? total : 0).toFixed(3)}`}</Text>
536 <Button key="advisor-reset-spend" label="Reset spend" onPress={() => $.store.set('advisor.totalUsd', 0)} />
537 {lastError ? <Text color="red">{`last review failed: ${lastError}`}</Text> : null}
538 </Box>
539 )
540 }
541 return null
542}
543
544// The status line band: refreshStatus gathers what the segments show into
545// $.state, the AbovePrompt hook draws it. Refreshed at session start, after
546// each main-thread turn and on a timer (git changes, caveman mode, the reset
547// countdown).
548const STATUS_DATA = { plugin: 'agent-cockpit', key: 'statusData' } as const
549const TURN_BASE = { plugin: 'agent-cockpit', key: 'turnBase' } as const
550const LAST_TURN = { plugin: 'agent-cockpit', key: 'lastTurn' } as const
551const STATUS_TICK_MS = 5000
552let refreshing = false
553let stopTick: (() => void) | undefined
554
555// Caveman's flag files in the Claude config dir; a link is refused, as
556// caveman's own status line refuses it.
557async function readCavemanFile($: EngineInterface, path: string): Promise<string | undefined> {
558 try {
559 const st = await $.fs.stat(path)
560 if (st.isLink || st.kind !== 'file') return undefined
561 const text = await $.fs.read(path)
562 return typeof text === 'string' ? text : undefined
563 } catch {
564 return undefined
565 }
566}
567
568type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
569
570function rateWindow(limits: readonly RateLimit[], kind: string): RateWindow | undefined {
571 const w = limits.find(r => r.kind === kind)
572 return w ? { percentUsed: w.percentUsed, resetsAt: w.resetsAt ? Date.parse(w.resetsAt) : undefined } : undefined
573}
574
575// The session's cost and context fill now, to measure a turn by.
576async function usageMark($: EngineInterface): Promise<TurnDelta | undefined> {
577 try {
578 const usage = await $.session.usage()
579 return { usd: usage.cost?.usd ?? 0, tokens: usage.context.tokens ?? 0 }
580 } catch {
581 return undefined
582 }
583}
584
585async function recordTurnDelta($: EngineInterface): Promise<void> {
586 const base = (await $.state.get(TURN_BASE)).value
587 const now = await usageMark($)
588 if (!base || !now) return
589 await $.state.set(LAST_TURN, { usd: now.usd - base.usd, tokens: now.tokens - base.tokens })
590}
591
592async function gatherStatus($: EngineInterface): Promise<StatusData> {
593 const data: StatusData = { now: Math.floor((await $.clock.now()) / 60_000) * 60_000 }
594 const cwd = await $.session.cwd().catch(() => undefined)
595 const home = await homeDir(ioFrom($))
596 data.cwd = cwd
597 data.home = home
598 data.model = await $.session.model().catch(() => undefined)
599 try {
600 const usage = await $.session.usage()
601 data.startedAt = usage.startedAt
602 data.tokens = usage.context.tokens
603 data.percent = usage.context.percent
604 data.window = usage.context.window
605 data.usd = usage.cost?.usd
606 data.fiveHour = rateWindow(usage.rateLimits, 'five_hour')
607 data.sevenDay = rateWindow(usage.rateLimits, 'seven_day')
608 } catch {}
609 data.lastTurn = (await $.state.get(LAST_TURN)).value ?? undefined
610 const advisor = await advisorSettings($).catch(() => undefined)
611 data.activity = {
612 ttsrHits: (await $.state.get(TTSR_HITS)).value ?? 0,
613 advisor: advisor?.enabled
614 ? { usd: (await $.state.get(SESSION_USD)).value ?? 0, note: Boolean((await $.state.get(NOTE)).value) }
615 : undefined,
616 }
617 try {
618 const r = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd, timeoutMs: 1000, env: { GIT_OPTIONAL_LOCKS: '0' } })
619 if (r.exitCode === 0) data.git = parsePorcelain(r.stdout)
620 } catch {}
621 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) || (home ? join(home, '.claude') : undefined)
622 if (configDir) {
623 const flag = await readCavemanFile($, join(configDir, '.caveman-active'))
624 const suffix = flag === undefined ? undefined : await readCavemanFile($, join(configDir, '.caveman-statusline-suffix'))
625 data.caveman = parseCaveman(flag, suffix, (await $.env.get('CAVEMAN_STATUSLINE_SAVINGS')) !== '0')
626 }
627 // $.state holds plain JSON: fields without a reading are left out.
628 return JSON.parse(JSON.stringify(data)) as StatusData
629}
630
631async function refreshStatus($: EngineInterface): Promise<void> {
632 if (refreshing) return
633 refreshing = true
634 try {
635 const data = await gatherStatus($)
636 const held = (await $.state.get(STATUS_DATA)).value
637 if (JSON.stringify(held) !== JSON.stringify(data)) await $.state.set(STATUS_DATA, data)
638 } catch (err) {
639 warnOnce($, `status line refresh failed (${errText(err)})`)
640 } finally {
641 refreshing = false
642 }
643}
644
645async function statusRow($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], columns: number | undefined) {
646 const data = (await $.state.get(STATUS_DATA)).value
647 if (!data) return null
648 const snap = await snapshot($)
649 const { config, warnings } = sanitizeStatusline(snap.config.statusline)
650 if (!config.enabled) return null
651 const resolved = resolveTheme(config.theme, await builtinThemes($), snap.themeSpecs)
652 for (const w of [...warnings, ...resolved.warnings]) warnOnce($, w)
653 const { left, middle, right } = statusSpans(data, config, resolved.theme, columns)
654 const { Box, Text } = $.ui.resolve(e)
655 return (
656 <Box flexDirection="row" flexWrap="nowrap" overflow="hidden">
657 {[...left, ...middle, ...right].map(s => (
658 <Text color={s.color} backgroundColor={s.backgroundColor} wrap="truncate">
659 {s.text}
660 </Text>
661 ))}
662 </Box>
663 )
664}
665
666export const register: Register = on => {
667 on('session.start', async ($, e, next) => {
668 cached = undefined
669 // One refused registration (a name a built-in owns) must not stop the rest.
670 try {
671 await $.command.register({
672 name: 'dgs',
673 description: 'Settings pane (status line, TTSR, rules, advisor, context); `/dgs advisor on|off|status|model|budget|reset` for the advisor',
674 argumentHint: '[statusline|ttsr|rules|advisor|context] | advisor <verb>',
675 })
676 } catch (err) {
677 warnOnce($, `can't register /dgs (${errText(err)})`)
678 }
679 const started = await next(e)
680 stopTick?.()
681 stopTick = $.clock.every(STATUS_TICK_MS, () => {
682 refreshStatus($).catch(() => {})
683 }).cancel
684 $.clock.after(0, () => {
685 refreshStatus($).catch(() => {})
686 })
687 return started
688 })
689
690 on('turn.start', async ($, e, next) => {
691 cached = undefined
692 await $.state.set(EDITS, [])
693 const mark = await usageMark($)
694 if (mark) await $.state.set(TURN_BASE, mark)
695 return next(e)
696 })
697
698 on('command.run', { command: 'dgs' }, async ($, e) => {
699 const [first = '', ...rest] = e.args.trim().split(/\s+/)
700 if (first === 'advisor' && rest.length > 0) return { text: await advisorCommand($, rest.join(' ')) }
701 const tab = TABS.find(t => t.id === first)?.id
702 if (tab) await $.state.set(PANE_TAB, tab)
703 await $.ui.open({ id: PANE_ID, title: 'dgs settings', focus: true, closeOnEscape: true })
704 return { text: 'dgs settings opened.' }
705 })
706
707 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
708 if (e.surface === 'mobile') {
709 const { Text } = $.ui.resolve(e)
710 return <Text>dgs settings need a terminal or desktop session (pickers and inputs).</Text>
711 }
712 const { Box, Button, Input, Select, Text } = $.ui.resolve(e)
713 const tab: Tab = (await $.state.get(PANE_TAB)).value ?? 'statusline'
714 const scope = (await $.state.get(PANE_SCOPE)).value ?? 'global'
715 const error = (await $.state.get(PANE_ERROR)).value
716 const themeGroup = (await $.state.get(PANE_THEME_GROUP)).value
717 const hint = hintFor((await $.state.get(PANE_FOCUS)).value ?? undefined)
718 let snap: Snapshot
719 try {
720 snap = await snapshot($)
721 } catch (err) {
722 return <Text>agent-cockpit: settings unavailable ({errText(err)})</Text>
723 }
724 const targets = await paneTargets($, snap)
725 const rows = paneRows(tab, snap, await storeValues($))
726 const builtin = await builtinThemes($)
727 const themes = listThemes(builtin, snap.themeSpecs)
728 const statusline = sanitizeStatusline(snap.config.statusline).config
729 // The preview stops a cell short of the pane's frame.
730 const columns = e.props.bodyColumns === undefined ? undefined : e.props.bodyColumns - 1
731 const extra = await paneExtras($, tab, snap, statusline, builtin, e, columns)
732 const groups = paneGroups(settingsFor(tab))
733 const lw = labelWidth(groups.flatMap(g => g.settings))
734 const rowOf = new Map(rows.map(r => [r.setting.key, r]))
735
736 const control = (s: Setting, value: unknown) => {
737 const key = `set-${s.key}`
738 switch (s.kind) {
739 case 'bool':
740 return <Button key={key} label={value ? '[x] on' : '[ ] off'} plain onPress={() => writeSetting($, s, !value)} />
741 case 'enum':
742 return (
743 <Select key={key} options={s.options.map(o => ({ value: o }))} value={String(value)} onSelect={(v: string) => writeSetting($, s, v)} />
744 )
745 case 'theme': {
746 // Two steps: a family, then a theme in it (a Select takes at most SELECT_MAX options).
747 const groups = themeGroups(themes.includes(String(value)) ? themes : [String(value), ...themes])
748 const group = groups.find(g => g.label === themeGroup) ?? groups.find(g => g.names.includes(String(value))) ?? groups[0]
749 return (
750 <Box key={key + '-box'} gap={1}>
751 <Text dimColor>family</Text>
752 <Select
753 key={`${key}-group`}
754 options={groups.slice(0, SELECT_MAX).map(g => ({ value: g.label }))}
755 value={group?.label}
756 onSelect={(v: string) => $.state.set(PANE_THEME_GROUP, v)}
757 />
758 <Text dimColor>theme</Text>
759 <Select
760 key={key}
761 options={(group?.names ?? [String(value)]).map(o => ({ value: o }))}
762 value={group?.names.includes(String(value)) ? String(value) : undefined}
763 onSelect={(v: string) => writeSetting($, s, v)}
764 />
765 </Box>
766 )
767 }
768 case 'stringList':
769 return (
770 <Input
771 key={key}
772 value={Array.isArray(value) ? value.join(', ') : ''}
773 onSubmit={(v: string) => writeSetting($, s, parseListInput(v))}
774 />
775 )
776 default:
777 return <Input key={key} value={value === undefined ? '' : String(value)} onSubmit={(v: string) => writeSetting($, s, v)} />
778 }
779 }
780
781 // The segment table: every segment, on or off, its side and its order.
782 const lists: SegmentLists = { left: statusline.left, right: statusline.right }
783 const segOrigin = rows.find(r => r.setting.kind === 'segments' && r.origin !== 'default')?.origin
784 const segmentTable = (
785 <Box key="group-segments" flexDirection="column">
786 <Box gap={1}>
787 <Text bold color="suggestion">Segments</Text>
788 {segOrigin ? <Text dimColor>{`· ${segOrigin}`}</Text> : null}
789 {segOrigin ? <Button key="reset-segments" label="reset" plain onPress={() => resetSegments($)} /> : null}
790 </Box>
791 {segmentRows(lists).map(r => (
792 <Box key={`segrow-${r.id}`} gap={1}>
793 <Box width={lw}>
794 <Text dimColor={r.side === null}>{r.id}</Text>
795 </Box>
796 <Button key={`seg-on-${r.id}`} label={r.side ? '[x]' : '[ ]'} plain onPress={() => writeSegments($, segmentToggle(lists, r.id))} />
797 {r.side ? <Button key={`seg-side-${r.id}`} label={r.side} plain onPress={() => writeSegments($, segmentSide(lists, r.id))} /> : null}
798 {r.side ? <Button key={`seg-up-${r.id}`} label="↑" plain onPress={() => writeSegments($, segmentMove(lists, r.id, 'up'))} /> : null}
799 {r.side ? <Button key={`seg-down-${r.id}`} label="↓" plain onPress={() => writeSegments($, segmentMove(lists, r.id, 'down'))} /> : null}
800 </Box>
801 ))}
802 </Box>
803 )
804
805 return (
806 <Box flexDirection="column">
807 <Box gap={1}>
808 {TABS.map((t, i) => (
809 <Button
810 key={`tab-${t.id}`}
811 hotkey={String(i + 1)}
812 label={t.label}
813 variant={t.id === tab ? 'primary' : undefined}
814 onPress={() => $.state.set(PANE_TAB, t.id)}
815 />
816 ))}
817 </Box>
818 <Box gap={1}>
819 <Text dimColor>write to</Text>
820 <Button key="scope-global" label="Global" variant={scope === 'global' ? 'primary' : undefined} onPress={() => setScope($, 'global')} />
821 <Button
822 key="scope-project"
823 label="This project"
824 dimColor={!targets.project}
825 variant={scope === 'project' ? 'primary' : undefined}
826 onPress={() => setScope($, 'project')}
827 />
828 <Text dimColor>{(scope === 'project' ? targets.project : targets.global) ?? ''}</Text>
829 </Box>
830 {extra}
831 {groups.map((g, gi) => (
832 <Box key={`group-${gi}`} flexDirection="column">
833 {g.title ? <Text bold color="suggestion">{g.title}</Text> : null}
834 {g.settings.map(setting => {
835 const row = rowOf.get(setting.key)
836 const isSet = row !== undefined && row.origin !== 'default'
837 return (
838 <Box key={`row-${setting.key}`} flexDirection="column">
839 <Box gap={1}>
840 <Box width={lw}>
841 <Text>{setting.label}</Text>
842 </Box>
843 {control(setting, row?.value)}
844 {isSet ? <Text dimColor>{`· ${row.origin}`}</Text> : null}
845 {isSet ? <Button key={`reset-${setting.key}`} label="reset" plain onPress={() => resetSetting($, setting)} /> : null}
846 </Box>
847 {error?.key === setting.key ? <Text color="red">{error.text}</Text> : null}
848 </Box>
849 )
850 })}
851 {tab === 'statusline' && g.title === 'Look' ? segmentTable : null}
852 </Box>
853 ))}
854 {hint ? <Text dimColor>{hint}</Text> : null}
855 </Box>
856 )
857 })
858
859 // The pane's hint line describes whatever holds the focus ring.
860 on('ui.focus', { requestId: PANE_ID }, async ($, e, next) => {
861 try {
862 await $.state.set(PANE_FOCUS, e.element ?? null)
863 } catch (err) {
864 warnOnce($, `pane focus not tracked (${errText(err)})`)
865 }
866 return next(e)
867 })
868
869 on('prompt.submit', async ($, e, next) => {
870 const note = (await $.state.get(NOTE)).value
871 if (!note) return next(e)
872 const decision = (await $.state.get(DECISION)).value ?? 'accept'
873 await $.state.set(NOTE, null)
874 if (decision === 'ignore') return next(e)
875 const block = `<advisor-note>\nA reviewer looked at your last edits and flagged:\n${note}\n</advisor-note>`
876 return next({ ...e, context: [...(e.context ?? []), block] })
877 })
878
879 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
880 if (e.props.hasSurvey) return next(e)
881 let status = null
882 try {
883 status = await statusRow($, e, e.props.bodyColumns)
884 } catch (err) {
885 warnOnce($, `status line not drawn (${errText(err)})`)
886 }
887 const note = (await $.state.get(NOTE)).value
888 if (!note) return status ?? next(e)
889 const decision = (await $.state.get(DECISION)).value ?? 'accept'
890 const { Box, Button, Text } = $.ui.resolve(e)
891 return (
892 <Box flexDirection="column">
893 {status}
894 <Text>
895 <Text bold color="warning">Advisor: </Text>
896 {note}
897 </Text>
898 <Box>
899 <Button
900 key="advisor-accept"
901 label={decision === 'accept' ? '✓ Send with next prompt' : 'Send with next prompt'}
902 onPress={() => $.state.set(DECISION, 'accept')}
903 />
904 <Text> </Text>
905 <Button
906 key="advisor-ignore"
907 label={decision === 'ignore' ? '✓ Ignore' : 'Ignore'}
908 onPress={() => $.state.set(DECISION, 'ignore')}
909 />
910 </Box>
911 </Box>
912 )
913 })
914
915 on('prompt.compose', async ($, e, next) => {
916 const composed = await next(e)
917 try {
918 const snap = await snapshot($)
919 const added = []
920 if (snap.config.append.enabled && snap.append !== '') {
921 added.push({ id: 'agent-cockpit:append', text: snap.append, scope: 'session' as const })
922 }
923 const rules = renderRulesSection(snap.rules)
924 if (rules !== undefined) added.push({ id: 'agent-cockpit:rules', text: rules, scope: 'session' as const })
925 return added.length > 0 ? { ...composed, sections: [...composed.sections, ...added] } : composed
926 } catch (err) {
927 $.ui.log(`agent-cockpit: prompt.compose skipped (${errText(err)})`)
928 return composed
929 }
930 })
931
932 on('prompt.context', async ($, e, next) => {
933 const result = await next(e)
934 if (result.instructionFiles === undefined) return result
935 try {
936 const snap = await snapshot($)
937 if (!snap.config.agentsMd.enabled) return result
938 const found = await $.fs.ancestors({ names: ['AGENTS.md'] })
939 const io = ioFrom($)
940 const home = await homeDir(io)
941 const globalPath = home ? join(home, '.agents', 'AGENTS.md') : undefined
942 const globalText = globalPath ? await io.read(globalPath) : undefined
943 const global = globalPath && globalText !== undefined ? { path: globalPath, content: globalText } : undefined
944 return addAgentsMd(result, found, global)
945 } catch (err) {
946 $.ui.log(`agent-cockpit: AGENTS.md skipped (${errText(err)})`)
947 return result
948 }
949 })
950
951 on('turn.complete', async ($, e, next) => {
952 if (e.agentId === undefined) {
953 await recordTurnDelta($).catch(() => {})
954 $.clock.after(0, () => {
955 refreshStatus($).catch(() => {})
956 })
957 const held = await $.state.get(REPEAT)
958 const state = held.value ?? newRepeatState()
959 onTurnEnd(state)
960 await $.state.set(REPEAT, state)
961 const edits = (await $.state.get(EDITS)).value ?? []
962 if (!e.isAborted && edits.length > 0) {
963 await $.state.set(EDITS, [])
964 const answer = e.answer
965 // Outside this dispatch, so the review never holds up the turn.
966 $.clock.after(0, () => {
967 advisorReview($, edits, answer).catch(err => $.ui.log(`agent-cockpit: advisor failed (${errText(err)})`))
968 })
969 }
970 }
971 return next(e)
972 })
973
974 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
975 const r = await ttsr($, e, () => next(e))
976 await trackEdit($, e, r)
977 return r
978 })
979 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
980 const r = await ttsr($, e, () => next(e))
981 await trackEdit($, e, r)
982 return r
983 })
984}
985hooks/advisor.ts 75 lines1// Advisor: the reviewer prompt, its verdict, and what each review costs.
2
3import type { Price } from './layers'
4
5// USD per million tokens (Anthropic first-party list prices, 2026-09-25).
6// Cache writes priced at 1.25x input (5-minute TTL). Override in config.
7export const DEFAULT_PRICES: Record<string, Price> = {
8 'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
9 'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 },
10 'claude-sonnet-5-5': { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
11 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
12}
13
14const ALIASES: Record<string, string> = {
15 fable: 'claude-fable-5-1',
16 opus: 'claude-opus-5-5',
17 sonnet: 'claude-sonnet-5-5',
18 haiku: 'claude-haiku-4-5',
19}
20
21export type Usage = {
22 input_tokens: number
23 output_tokens: number
24 cache_read_input_tokens: number
25 cache_creation_input_tokens: number
26}
27
28export function priceFor(model: string, overrides: Record<string, Price>): Price | undefined {
29 const id = model.replace(/\[.*\]$/, '').trim()
30 const resolved = ALIASES[id] ?? id
31 return overrides[id] ?? overrides[resolved] ?? DEFAULT_PRICES[resolved]
32}
33
34// The price to charge a review at: the model's own, or, when it has none,
35// the most expensive one known, so a budget errs on the safe side.
36export function priceOrFallback(model: string, overrides: Record<string, Price>): { price: Price; estimated: boolean } {
37 const own = priceFor(model, overrides)
38 if (own) return { price: own, estimated: false }
39 const all = [...Object.values(DEFAULT_PRICES), ...Object.values(overrides)]
40 const top = all.reduce((a, b) => (b.input + b.output > a.input + a.output ? b : a))
41 return { price: top, estimated: true }
42}
43
44export function costUsd(u: Usage, p: Price): number {
45 return (
46 (u.input_tokens * p.input +
47 u.output_tokens * p.output +
48 u.cache_read_input_tokens * p.cacheRead +
49 u.cache_creation_input_tokens * p.cacheWrite) /
50 1_000_000
51 )
52}
53
54export type Verdict = { ok: true } | { ok: false; note: string }
55
56export function parseVerdict(text: string): Verdict {
57 const t = text.trim()
58 if (t === '' || /^\**ok\**\.?$/i.test(t)) return { ok: true }
59 return { ok: false, note: t }
60}
61
62export function reviewPrompt(files: readonly string[], answer?: string): string {
63 const lines = [
64 'You are a reviewer looking over the turn that just finished. It edited these files:',
65 ...files.map(f => `- ${f}`),
66 ]
67 if (answer !== undefined) lines.push('', 'The final answer of that turn:', answer)
68 lines.push(
69 '',
70 'Look for a real problem in those edits: a bug, a broken contract, a missed requirement, an unsafe change.',
71 'Reply with exactly OK if there is none. Otherwise reply with one short, actionable note (at most three sentences) and nothing else.',
72 )
73 return lines.join('\n')
74}
75hooks/agentsmd.ts 22 lines1// AGENTS.md as instruction files beside CLAUDE.md: the global
2// ~/.agents/AGENTS.md, then every AGENTS.md from the root down to the cwd.
3
4import type { FsAncestor, InstructionFile, PromptContextResult } from 'claude-code'
5import { join, samePath } from './paths'
6
7export function addAgentsMd(
8 result: PromptContextResult,
9 found: readonly FsAncestor[],
10 global?: { path: string; content: string },
11): PromptContextResult {
12 if (result.instructionFiles === undefined) return result
13 const files: InstructionFile[] = [...result.instructionFiles]
14 const has = (path: string) => files.some(f => samePath(f.path, path))
15 if (global && !has(global.path)) files.push({ path: global.path, kind: 'user', content: global.content })
16 for (const a of found) {
17 const path = join(a.dir, a.name)
18 if (!has(path)) files.push({ path, kind: 'project', content: a.content })
19 }
20 return files.length === result.instructionFiles.length ? result : { ...result, instructionFiles: files }
21}
22hooks/config-patch.ts 62 lines1// Edits one key path of a config.json text, keeping everything else. A file
2// that is not a valid JSON object is reported, never rewritten.
3
4type Obj = Record<string, unknown>
5type Patched = { text: string } | { error: string }
6
7const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v)
8
9function parse(text: string | undefined): { obj: Obj } | { error: string } {
10 if (text === undefined || text.trim() === '') return { obj: {} }
11 try {
12 const v: unknown = JSON.parse(text)
13 return isObj(v) ? { obj: v } : { error: 'config.json is not a JSON object' }
14 } catch (err) {
15 return { error: `config.json is not valid JSON (${err instanceof Error ? err.message : err})` }
16 }
17}
18
19const serialise = (o: Obj) => JSON.stringify(o, null, 2) + '\n'
20
21export function getPath(obj: unknown, key: string): unknown {
22 let cur = obj
23 for (const p of key.split('.')) {
24 if (!isObj(cur)) return undefined
25 cur = cur[p]
26 }
27 return cur
28}
29
30export function setPath(text: string | undefined, key: string, value: unknown): Patched {
31 const parsed = parse(text)
32 if ('error' in parsed) return parsed
33 const obj = parsed.obj
34 const parts = key.split('.')
35 let node = obj
36 for (const p of parts.slice(0, -1)) {
37 if (!isObj(node[p])) node[p] = {}
38 node = node[p] as Obj
39 }
40 node[parts[parts.length - 1] ?? key] = value
41 return { text: serialise(obj) }
42}
43
44export function unsetPath(text: string | undefined, key: string): Patched {
45 const parsed = parse(text)
46 if ('error' in parsed) return parsed
47 const obj = parsed.obj
48 const parts = key.split('.')
49 const trail: Obj[] = [obj]
50 for (const p of parts.slice(0, -1)) {
51 const next = trail[trail.length - 1]?.[p]
52 if (!isObj(next)) return { text: serialise(obj) }
53 trail.push(next)
54 }
55 delete trail[trail.length - 1]?.[parts[parts.length - 1] ?? key]
56 for (let i = trail.length - 1; i > 0; i--) {
57 if (Object.keys(trail[i] ?? {}).length > 0) break
58 delete trail[i - 1]?.[parts[i - 1] ?? '']
59 }
60 return { text: serialise(obj) }
61}
62hooks/load.ts 113 lines1// Discovers the layers on disk through a small Io interface; register.ts
2// backs it with the engine (`$` may not cross an import), tests with memory.
3
4import { type Config, mergeAppend, mergeConfig, mergeRules } from './layers'
5import { chainBetween, join, samePath } from './paths'
6import { type Rule, type RuleSource, ruleFromMarkdown } from './rule'
7import type { ThemeSpec } from './themes'
8
9export type Io = {
10 env: (name: 'HOME' | 'USERPROFILE') => Promise<string | undefined>
11 read: (path: string) => Promise<string | undefined>
12 listFiles: (dir: string, ext: '.md' | '.json') => Promise<string[]>
13}
14
15export type Layer = { source: RuleSource; dir: string }
16
17export type ConfigLayer = { source: RuleSource; dir: string; value: unknown }
18
19export type Snapshot = {
20 config: Config
21 configLayers: ConfigLayer[]
22 themeSpecs: Record<string, ThemeSpec>
23 rules: Rule[]
24 // Every discovered rule before `rules.disabled` / `rules.builtin` apply (the pane lists them).
25 allRules: Rule[]
26 append: string
27 layers: Layer[]
28 warnings: string[]
29}
30
31// The global layer, then one project layer per directory from root down to
32// cwd, skipping home (already the global layer).
33export function layerDirs(home: string | undefined, root: string, cwd: string): Layer[] {
34 const layers: Layer[] = []
35 if (home) layers.push({ source: 'global', dir: join(home, '.agents') })
36 for (const dir of chainBetween(root, cwd)) {
37 if (home && samePath(dir, home)) continue
38 layers.push({ source: 'project', dir: join(dir, '.agents') })
39 }
40 return layers
41}
42
43export async function homeDir(io: Io): Promise<string | undefined> {
44 return (await io.env('USERPROFILE')) || (await io.env('HOME')) || undefined
45}
46
47async function loadRules(io: Io, dir: string, source: RuleSource, warnings: string[]): Promise<Rule[]> {
48 const rules: Rule[] = []
49 for (const name of await io.listFiles(dir, '.md')) {
50 const path = join(dir, name)
51 const text = await io.read(path)
52 if (text === undefined) continue
53 const rule = ruleFromMarkdown(path, text, source)
54 if (rule.warning) warnings.push(`${path}: frontmatter fallback (${rule.warning})`)
55 rules.push(rule)
56 }
57 return rules
58}
59
60export async function discover(
61 io: Io,
62 where: { builtinDir: string; root: string; cwd: string },
63): Promise<Snapshot> {
64 const warnings: string[] = []
65 const home = await homeDir(io)
66 const layers: Layer[] = [{ source: 'builtin', dir: where.builtinDir }, ...layerDirs(home, where.root, where.cwd)]
67
68 const ruleLayers: Rule[][] = []
69 const configLayers: ConfigLayer[] = []
70 const themeSpecs: Record<string, ThemeSpec> = {}
71 const appends: string[] = []
72 for (const layer of layers) {
73 if (layer.source === 'builtin') {
74 ruleLayers.push(await loadRules(io, layer.dir, 'builtin', warnings))
75 continue
76 }
77 ruleLayers.push(await loadRules(io, join(layer.dir, 'rules'), layer.source, warnings))
78 const cfgPath = join(layer.dir, 'mods', 'config.json')
79 const cfgText = await io.read(cfgPath)
80 if (cfgText !== undefined) {
81 try {
82 configLayers.push({ source: layer.source, dir: layer.dir, value: JSON.parse(cfgText) })
83 } catch (err) {
84 warnings.push(`${cfgPath}: invalid config.json (${err instanceof Error ? err.message : err})`)
85 }
86 }
87 const append = await io.read(join(layer.dir, 'mods', 'APPEND_SYSTEM.md'))
88 if (append !== undefined) appends.push(append)
89 const themesDir = join(layer.dir, 'mods', 'themes')
90 for (const file of await io.listFiles(themesDir, '.json')) {
91 const path = join(themesDir, file)
92 try {
93 const spec = JSON.parse((await io.read(path)) ?? '') as ThemeSpec
94 themeSpecs[file.replace(/\.json$/i, '')] = { ...spec, colors: spec.colors ?? {} }
95 } catch (err) {
96 warnings.push(`${path}: invalid theme (${err instanceof Error ? err.message : err})`)
97 }
98 }
99 }
100
101 const config = mergeConfig(configLayers.map(l => l.value))
102 return {
103 config,
104 configLayers,
105 themeSpecs,
106 rules: mergeRules(ruleLayers, config),
107 allRules: mergeRules(ruleLayers, { ...config, rules: { builtin: true, disabled: [] } }),
108 append: mergeAppend(appends),
109 layers,
110 warnings,
111 }
112}
113hooks/pane-model.ts 176 lines1// What the /dgs pane shows: each setting's effective value and the layer it
2// comes from, and where "this project" writes.
3
4import { getPath } from './config-patch'
5import type { Config } from './layers'
6import type { ConfigLayer, Layer } from './load'
7import type { Rule } from './rule'
8import { SEGMENT_IDS, type SegmentId, type Setting, SETTINGS, settingsFor, type Tab } from './settings-schema'
9import type { StatusData } from './status'
10
11export type Origin = 'default' | 'global' | 'project' | 'store'
12export type PaneRow = { setting: Setting; value: unknown; origin: Origin }
13
14export function paneRows(
15 tab: Tab,
16 snap: { config: Config; configLayers: readonly ConfigLayer[] },
17 store: Record<string, unknown>,
18): PaneRow[] {
19 return settingsFor(tab).map(setting => {
20 if (setting.storage === 'store' && store[setting.key] !== undefined) {
21 return { setting, value: store[setting.key], origin: 'store' }
22 }
23 const from = [...snap.configLayers].reverse().find(l => getPath(l.value, setting.key) !== undefined)
24 if (from && from.source !== 'builtin') return { setting, value: getPath(from.value, setting.key), origin: from.source }
25 const merged = getPath(snap.config, setting.key)
26 return { setting, value: merged !== undefined ? merged : setting.default, origin: 'default' }
27 })
28}
29
30// "This project" is the repository root's .agents; none outside a repository.
31export function projectLayerDir(layers: readonly Layer[], inRepo: boolean): string | undefined {
32 return inRepo ? layers.find(l => l.source === 'project')?.dir : undefined
33}
34
35export function parseListInput(text: string): string[] {
36 return text.split(/[\s,]+/).map(s => s.trim()).filter(s => s !== '')
37}
38
39export type SegmentOp = { up: SegmentId } | { down: SegmentId } | { remove: SegmentId } | { add: SegmentId }
40
41export function segmentsEdit(list: readonly SegmentId[], op: SegmentOp): SegmentId[] {
42 const out = [...list]
43 if ('add' in op) return out.includes(op.add) ? out : [...out, op.add]
44 if ('remove' in op) return out.filter(id => id !== op.remove)
45 const id = 'up' in op ? op.up : op.down
46 const i = out.indexOf(id)
47 const j = 'up' in op ? i - 1 : i + 1
48 if (i < 0 || j < 0 || j >= out.length) return out
49 ;[out[i], out[j]] = [out[j] as SegmentId, out[i] as SegmentId]
50 return out
51}
52
53export type RuleRow = { name: string; source: Rule['source']; kind: 'always' | 'rulebook' | 'ttsr' | 'inactive'; disabled: boolean }
54
55export function rulesRows(rules: readonly Rule[], cfg: Config['rules']): RuleRow[] {
56 return [...rules]
57 .sort((a, b) => a.name.localeCompare(b.name))
58 .map(r => ({
59 name: r.name,
60 source: r.source,
61 kind: r.condition?.length || r.astCondition?.length ? 'ttsr' : r.alwaysApply ? 'always' : r.description ? 'rulebook' : 'inactive',
62 disabled: cfg.disabled.includes(r.name) || (!cfg.builtin && r.source === 'builtin'),
63 }))
64}
65
66// The segment table: every segment once, the shown ones first (the left side
67// in order, then the right), then the hidden ones in catalogue order.
68export type SegmentLists = { left: SegmentId[]; right: SegmentId[] }
69export type SegmentRow = { id: SegmentId; side: 'L' | 'R' | null }
70
71export function segmentRows(lists: SegmentLists): SegmentRow[] {
72 const shown = new Set([...lists.left, ...lists.right])
73 return [
74 ...lists.left.map(id => ({ id, side: 'L' as const })),
75 ...lists.right.map(id => ({ id, side: 'R' as const })),
76 ...SEGMENT_IDS.filter(id => !shown.has(id)).map(id => ({ id, side: null })),
77 ]
78}
79
80// On: off removes it from its side; a hidden segment joins the right side.
81export function segmentToggle(lists: SegmentLists, id: SegmentId): SegmentLists {
82 if (lists.left.includes(id) || lists.right.includes(id)) {
83 return { left: lists.left.filter(s => s !== id), right: lists.right.filter(s => s !== id) }
84 }
85 return { left: [...lists.left], right: [...lists.right, id] }
86}
87
88export function segmentSide(lists: SegmentLists, id: SegmentId): SegmentLists {
89 if (lists.left.includes(id)) return { left: lists.left.filter(s => s !== id), right: [...lists.right, id] }
90 if (lists.right.includes(id)) return { left: [...lists.left, id], right: lists.right.filter(s => s !== id) }
91 return { left: [...lists.left], right: [...lists.right] }
92}
93
94export function segmentMove(lists: SegmentLists, id: SegmentId, dir: 'up' | 'down'): SegmentLists {
95 const op = dir === 'up' ? { up: id } : { down: id }
96 return { left: segmentsEdit(lists.left, op), right: segmentsEdit(lists.right, op) }
97}
98
99export type PaneGroup = { title?: string; settings: Setting[] }
100
101// A tab's settings by heading, in catalogue order; segment lists are drawn as
102// the table instead.
103export function paneGroups(settings: readonly Setting[]): PaneGroup[] {
104 const out: PaneGroup[] = []
105 for (const setting of settings) {
106 if (setting.kind === 'segments') continue
107 const last = out[out.length - 1]
108 if (last && last.title === setting.group) last.settings.push(setting)
109 else out.push({ title: setting.group, settings: [setting] })
110 }
111 return out
112}
113
114export function labelWidth(settings: readonly Setting[]): number {
115 return Math.max(0, ...settings.map(s => s.label.length)) + 1
116}
117
118const SEGMENT_HINTS: Record<string, string> = {
119 'seg-on-': 'Turn this segment on or off; one turned on joins the right side.',
120 'seg-side-': 'Move this segment to the end of the other side.',
121 'seg-up-': 'Change its order within its side.',
122 'seg-down-': 'Change its order within its side.',
123}
124
125// The hint line under the pane: what the control holding the focus does.
126export function hintFor(element: string | undefined): string | undefined {
127 if (!element) return undefined
128 if (element === 'reset-segments') return 'Back to the default segments and order.'
129 const seg = Object.keys(SEGMENT_HINTS).find(p => element.startsWith(p))
130 if (seg) return SEGMENT_HINTS[seg]
131 const setting = SETTINGS.find(
132 s => element === `set-${s.key}` || element === `set-${s.key}-group` || element === `reset-${s.key}`,
133 )
134 return setting?.description
135}
136
137// Sample readings for the pane's preview, one for every segment.
138export const PREVIEW_DATA: StatusData = {
139 model: 'claude-opus-5-5[1m]',
140 cwd: '/home/u/project',
141 home: '/home/u',
142 git: { branch: 'main', dirty: true, ahead: 1, behind: 0, sha: '01a0fde', staged: 2, unstaged: 1, untracked: 3 },
143 caveman: { mode: 'ULTRA' },
144 tokens: 128_400,
145 percent: 43,
146 window: 1_000_000,
147 usd: 0.42,
148 fiveHour: { percentUsed: 23 },
149 sevenDay: { percentUsed: 18 },
150 startedAt: 0,
151 lastTurn: { usd: 0.12, tokens: 9_800 },
152 activity: { ttsrHits: 1 },
153 now: 26 * 60_000,
154}
155
156// Engine Selects take at most this many options.
157export const SELECT_MAX = 64
158
159export type ThemeGroup = { label: string; names: string[] }
160
161// Themes split into families (dark, light, other) for a two-step picker; a
162// family larger than a Select allows is paged.
163export function themeGroups(names: readonly string[]): ThemeGroup[] {
164 const family = (n: string) => (n === 'dark' || n.startsWith('dark-') ? 'dark' : n === 'light' || n.startsWith('light-') ? 'light' : 'other')
165 const out: ThemeGroup[] = []
166 for (const label of ['dark', 'light', 'other']) {
167 const list = [...names].filter(n => family(n) === label).sort()
168 if (list.length === 0) continue
169 const pages = Math.ceil(list.length / SELECT_MAX)
170 for (let p = 0; p < pages; p++) {
171 out.push({ label: pages > 1 ? `${label} ${p + 1}/${pages}` : label, names: list.slice(p * SELECT_MAX, (p + 1) * SELECT_MAX) })
172 }
173 }
174 return out
175}
176hooks/paths.ts 66 lines1// Path helpers that accept both `/` and `\` separators, so Windows paths
2// handed over by the engine work the same as POSIX ones.
3
4export function isWindowsPath(path: string): boolean {
5 return /^[a-zA-Z]:[\\/]/.test(path) || (path.includes('\\') && !path.includes('/'))
6}
7
8const sepOf = (path: string) => (isWindowsPath(path) ? '\\' : '/')
9
10export function basename(path: string): string {
11 const parts = path.split(/[\\/]+/).filter(p => p !== '')
12 return parts[parts.length - 1] ?? ''
13}
14
15export function join(base: string, ...parts: string[]): string {
16 const sep = sepOf(base)
17 let out = base.replace(/[\\/]+$/, '')
18 if (out === '' || /^[a-zA-Z]:$/.test(out)) out += sep
19 for (const p of parts) {
20 const clean = p.replace(/^[\\/]+|[\\/]+$/g, '')
21 if (clean === '') continue
22 out += (out.endsWith(sep) ? '' : sep) + clean
23 }
24 return out
25}
26
27export function dirname(path: string): string {
28 const sep = sepOf(path)
29 const trimmed = path.replace(/[\\/]+$/, '')
30 if (/^[a-zA-Z]:$/.test(trimmed)) return trimmed + sep
31 const i = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
32 if (i < 0) return trimmed === '' ? path : '.'
33 const head = trimmed.slice(0, i)
34 if (head === '' || /^[a-zA-Z]:$/.test(head)) return head + sep
35 return head
36}
37
38// Comparison key: forward slashes, no trailing slash; Windows paths compare
39// case-insensitively, as the file system does.
40function key(path: string): string {
41 const p = path.replace(/\\/g, '/').replace(/\/+$/, '')
42 return isWindowsPath(path) ? p.toLowerCase() : p
43}
44
45export function samePath(a: string, b: string): boolean {
46 return key(a) === key(b)
47}
48
49// The directories from `root` down to `cwd`, both included; just `cwd` when it
50// is not beneath `root`.
51export function chainBetween(root: string, cwd: string): string[] {
52 const r = key(root)
53 const c = key(cwd)
54 if (c !== r && !c.startsWith(r + '/')) return [cwd]
55 const chain: string[] = []
56 let dir = cwd
57 while (!samePath(dir, root)) {
58 chain.unshift(dir)
59 const up = dirname(dir)
60 if (samePath(up, dir)) return [cwd]
61 dir = up
62 }
63 chain.unshift(root)
64 return chain
65}
66hooks/rules.ts 28 lines1// The rules section of the system prompt: always-apply bodies in full, then
2// the rulebook (rules with a description) as an index the model reads from.
3
4import type { Rule } from './rule'
5
6const isTrigger = (r: Rule) => (r.condition?.length ?? 0) > 0 || (r.astCondition?.length ?? 0) > 0
7
8export function renderRulesSection(rules: readonly Rule[]): string | undefined {
9 const always = rules.filter(r => !isTrigger(r) && r.alwaysApply)
10 const book = rules.filter(r => !isTrigger(r) && !r.alwaysApply && r.description)
11 if (always.length === 0 && book.length === 0) return undefined
12
13 const parts: string[] = []
14 if (always.length > 0) {
15 parts.push('# Rules', ...always.map(r => `## ${r.name}\n\n${r.content}`))
16 }
17 if (book.length > 0) {
18 const lines = book.map(r => {
19 const globs = r.globs?.length ? ` (${r.globs.join(', ')})` : ''
20 return `- ${r.name}${globs}: ${r.description} → ${r.path}`
21 })
22 parts.push(
23 '# Rulebook\n\nDomain rules. Before working on something a rule covers, Read its file.\n\n' + lines.join('\n'),
24 )
25 }
26 return parts.join('\n\n')
27}
28hooks/status.ts 301 lines1// The status line band: what register.tsx gathers each refresh (StatusData)
2// and how it is laid out as coloured spans in the configured theme, separator
3// style and icon set.
4
5import type { Activity, Caveman, GitInfo, RateWindow, StatusData, TurnDelta } from '../types'
6import type { SegmentId } from './settings-schema'
7import type { StatuslineConfig } from './statusline-config'
8import { type ResolvedTheme, type ThemeToken, toHex } from './themes'
9
10export type { Activity, Caveman, GitInfo, RateWindow, StatusData, TurnDelta }
11
12export type Span = { text: string; color: string; backgroundColor?: string }
13
14export function parsePorcelain(out: string): GitInfo | undefined {
15 if (out.trim() === '') return undefined
16 let branch = ''
17 let oid = ''
18 let ahead = 0
19 let behind = 0
20 let staged = 0
21 let unstaged = 0
22 let untracked = 0
23 for (const line of out.split('\n')) {
24 if (line.startsWith('# branch.oid ')) oid = line.slice(13).trim()
25 else if (line.startsWith('# branch.head ')) branch = line.slice(14).trim()
26 else if (line.startsWith('# branch.ab ')) {
27 const m = /\+(\d+) -(\d+)/.exec(line)
28 ahead = Number(m?.[1] ?? 0)
29 behind = Number(m?.[2] ?? 0)
30 } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
31 // `XY`: the index (staged) state, then the worktree state; `.` is unchanged.
32 if (line[2] !== '.') staged++
33 if (line[3] !== '.') unstaged++
34 } else if (line.startsWith('u ')) unstaged++
35 else if (line.startsWith('? ')) untracked++
36 }
37 const sha = oid.slice(0, 7)
38 if (branch === '(detached)' || branch === '') branch = sha
39 return { branch, dirty: staged + unstaged + untracked > 0, ahead, behind, sha, staged, unstaged, untracked }
40}
41
42// Caveman's flag files, read the way its own status line reads them: each cut
43// to 64 characters, the mode held to a whitelist, control bytes stripped
44// from the savings suffix.
45const CAVEMAN_MODES: readonly string[] = [
46 'off', 'lite', 'full', 'ultra', 'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra', 'commit', 'review', 'compress',
47]
48const CAVEMAN_CAP = 64
49
50export function parseCaveman(flag: string | undefined, suffix: string | undefined, showSavings: boolean): Caveman | undefined {
51 if (flag === undefined) return undefined
52 const mode = flag.slice(0, CAVEMAN_CAP).replace(/[\r\n]/g, '').toLowerCase().replace(/[^a-z0-9-]/g, '')
53 if (!CAVEMAN_MODES.includes(mode) || mode === 'off') return undefined
54 const out: Caveman = { mode: mode === 'full' ? '' : mode.toUpperCase() }
55 const savings = suffix?.slice(0, CAVEMAN_CAP).replace(/[\x00-\x1f\x7f]/g, '').trim()
56 if (showSavings && savings) out.savings = savings
57 return out
58}
59
60// `claude-opus-5-5[1m]` → `Opus 5.5 1M`; anything not shaped like a Claude id is kept.
61export function modelLabel(id: string): string {
62 const m = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?(?:\[(\w+)\])?$/.exec(id)
63 if (!m) return id
64 const [, family = '', major, minor, window] = m
65 const label = `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}.${minor}`
66 return window ? `${label} ${window.toUpperCase()}` : label
67}
68
69export function formatTokens(n: number): string {
70 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
71 if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
72 return String(n)
73}
74
75export function formatDuration(ms: number): string {
76 const min = Math.floor(ms / 60_000)
77 if (min < 1) return '<1m'
78 const h = Math.floor(min / 60)
79 if (h >= 24) return `${Math.floor(h / 24)}d${String(h % 24).padStart(2, '0')}h`
80 return h > 0 ? `${h}h${String(min % 60).padStart(2, '0')}m` : `${min}m`
81}
82
83// Terminal cells a text takes: two for emoji and East Asian wide characters,
84// none for joiners, variation selectors and combining marks, one otherwise
85// (Nerd Font glyphs sit in the private use areas and are one cell).
86const WIDE: readonly [number, number][] = [
87 [0x1100, 0x115f], [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe],
88 [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab],
89 [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3],
90 [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728],
91 [0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0],
92 [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55], [0x2e80, 0xa4cf], [0xac00, 0xd7a3],
93 [0xf900, 0xfaff], [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f000, 0x1faff], [0x20000, 0x3fffd],
94]
95const ZERO: readonly [number, number][] = [[0x0300, 0x036f], [0x200b, 0x200f], [0xfe00, 0xfe0f], [0xe0100, 0xe01ef]]
96const within = (ranges: readonly [number, number][], n: number) => ranges.some(([lo, hi]) => n >= lo && n <= hi)
97
98export function cellWidth(text: string): number {
99 let cells = 0
100 for (const ch of text) {
101 const n = ch.codePointAt(0) ?? 0
102 cells += within(ZERO, n) ? 0 : within(WIDE, n) ? 2 : 1
103 }
104 return cells
105}
106
107function showPath(dir: string, style: StatuslineConfig['path']['style'], home: string | undefined): string {
108 if (style === 'full') return dir
109 if (style === 'home') {
110 if (!home) return dir
111 const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/'
112 const norm = (p: string) => p.replace(/[\\/]+$/, '').toLowerCase()
113 if (norm(dir) === norm(home)) return '~'
114 if (norm(dir).startsWith(norm(home) + sep)) return '~' + dir.slice(home.replace(/[\\/]+$/, '').length)
115 return dir
116 }
117 return dir.split(/[\\/]+/).filter(Boolean).pop() ?? dir
118}
119
120type IconKey = SegmentId | 'savings'
121const ICONS: Record<StatuslineConfig['icons'], Record<IconKey, string>> = {
122 nerd: {
123 model: '\u{f06a9}', caveman: '🪨', savings: '⛏', path: '', git: '',
124 tokens: '', cost: '', fiveHour: '\u{f051f}', ctx: '', duration: '\uf017', sha: '\uf417', sevenDay: '\uf073', delta: 'Δ', activity: '\uf0e7',
125 },
126 ascii: { model: 'M', caveman: 'cave', savings: '-', path: '@', git: 'git', tokens: 'T', cost: '', fiveHour: '5h', ctx: 'ctx', duration: 'up', sha: '#', sevenDay: '7d', delta: 'd', activity: 'mods' },
127 none: { model: '', caveman: '', savings: '', path: '', git: '', tokens: '', cost: '', fiveHour: '', ctx: '', duration: '', sha: '', sevenDay: '', delta: '', activity: '' },
128}
129
130// Glyph between segments: [left side, right side].
131const SEPARATOR_GLYPHS: Record<StatuslineConfig['separator'], [string, string]> = {
132 powerline: ['', ''],
133 'powerline-thin': ['', ''],
134 slash: ['/', '/'],
135 pipe: ['│', '│'],
136 block: ['▌', '▐'],
137 none: ['', ''],
138 ascii: ['>', '<'],
139}
140// The filled arrows that close a powerline run: after the left side, before the right.
141const POWERLINE_CAPS: [string, string] = ['', '']
142
143type Seg = { text: string; colour: ThemeToken }
144type Ctx = { data: StatusData; config: StatuslineConfig; icon: (k: IconKey) => string }
145
146const labelled = (icon: string, text: string) => (icon ? `${icon} ${text}` : text)
147
148const SEGMENTS: Record<SegmentId, (c: Ctx) => Seg | undefined> = {
149 model: ({ data, icon }) => (data.model ? { text: labelled(icon('model'), modelLabel(data.model)), colour: 'statusLineModel' } : undefined),
150 caveman: ({ data, icon }) => {
151 const c = data.caveman
152 if (!c) return undefined
153 let text = labelled(icon('caveman'), c.mode || 'CAVEMAN')
154 if (c.savings) text += ` ${labelled(icon('savings'), c.savings)}`
155 return { text, colour: 'statusLineCaveman' }
156 },
157 path: ({ data, config, icon }) =>
158 data.cwd ? { text: labelled(icon('path'), showPath(data.cwd, config.path.style, data.home)), colour: 'statusLinePath' } : undefined,
159 git: ({ data, config, icon }) => {
160 const g = data.git
161 if (!g) return undefined
162 let text = labelled(icon('git'), `${g.branch}${g.dirty && !config.git.counts ? '*' : ''}`)
163 if (config.git.aheadBehind && g.ahead) text += ` ↑${g.ahead}`
164 if (config.git.aheadBehind && g.behind) text += ` ↓${g.behind}`
165 if (config.git.counts) {
166 if (g.staged) text += ` +${g.staged}`
167 if (g.unstaged) text += ` ~${g.unstaged}`
168 if (g.untracked) text += ` ?${g.untracked}`
169 }
170 return { text, colour: g.dirty ? 'statusLineGitDirty' : 'statusLineGitClean' }
171 },
172 sha: ({ data, icon }) => (data.git?.sha ? { text: labelled(icon('sha'), data.git.sha), colour: 'dim' } : undefined),
173 tokens: ({ data, icon }) =>
174 data.tokens ? { text: labelled(icon('tokens'), formatTokens(data.tokens)), colour: 'statusLineOutput' } : undefined,
175 cost: ({ data, icon }) =>
176 typeof data.usd === 'number' ? { text: labelled(icon('cost'), `$${data.usd.toFixed(2)}`), colour: 'statusLineCost' } : undefined,
177 fiveHour: ({ data, config, icon }) => rateSeg(data.fiveHour, data.now, config.fiveHour.showReset, icon('fiveHour')),
178 sevenDay: ({ data, config, icon }) => rateSeg(data.sevenDay, data.now, config.sevenDay.showReset, icon('sevenDay')),
179 ctx: ({ data, config, icon }) =>
180 typeof data.percent === 'number'
181 ? { text: labelled(icon('ctx'), `${Math.round(data.percent)}%`), colour: ctxColour(data.percent, config) }
182 : undefined,
183 delta: ({ data, icon }) => {
184 const d = data.lastTurn
185 if (!d) return undefined
186 const parts: string[] = []
187 if (d.usd >= 0.005) parts.push(`+$${d.usd.toFixed(2)}`)
188 if (d.tokens !== 0) parts.push(`${d.tokens > 0 ? '+' : '-'}${formatTokens(Math.abs(d.tokens))}`)
189 return parts.length ? { text: labelled(icon('delta'), parts.join(' ')), colour: 'statusLineSpend' } : undefined
190 },
191 activity: ({ data, icon }) => {
192 const a = data.activity
193 if (!a) return undefined
194 const parts: string[] = []
195 if (a.ttsrHits > 0) parts.push(`ttsr ${a.ttsrHits}`)
196 if (a.advisor) parts.push(`adv $${a.advisor.usd.toFixed(2)}${a.advisor.note ? ' !' : ''}`)
197 return parts.length ? { text: labelled(icon('activity'), parts.join(' · ')), colour: 'accent' } : undefined
198 },
199 duration: ({ data, icon }) =>
200 data.startedAt !== undefined && data.now >= data.startedAt
201 ? { text: labelled(icon('duration'), formatDuration(data.now - data.startedAt)), colour: 'dim' }
202 : undefined,
203}
204
205// A usage window as the share left, coloured by it, with the time to its reset.
206function rateSeg(w: RateWindow | undefined, now: number, showReset: boolean, icon: string): Seg | undefined {
207 if (!w) return undefined
208 const left = Math.max(0, Math.round(100 - w.percentUsed))
209 let text = labelled(icon, `${left}% left`)
210 if (showReset && w.resetsAt !== undefined && w.resetsAt > now) text += ` · ${formatDuration(w.resetsAt - now)}`
211 return { text, colour: left < 20 ? 'error' : left < 50 ? 'warning' : 'success' }
212}
213
214// Which segments stay longest when the row is too narrow: the highest first.
215const PRIORITY: Record<SegmentId, number> = {
216 model: 10, git: 9, path: 8, ctx: 8, cost: 7, fiveHour: 7, sevenDay: 6, duration: 5,
217 delta: 4, tokens: 3, sha: 3, caveman: 2, activity: 2,
218}
219
220function ctxColour(percent: number, config: StatuslineConfig): ThemeToken {
221 return percent >= config.ctx.errorAt ? 'error' : percent >= config.ctx.warnAt ? 'warning' : 'statusLineContext'
222}
223
224// Below this many free cells the sides are only spaced apart.
225const MIN_FILL = 12
226
227// The room between the sides, `free` cells of it: omp's context gauge (a rule
228// filled to the context share, the percent at the fill's end, the window
229// size last) or blank space.
230function fill(data: StatusData, config: StatuslineConfig, theme: ResolvedTheme, free: number): Span[] {
231 const sep = toHex(theme.statusLineSep)
232 if (config.fill === 'none' || free < MIN_FILL) return [{ text: ' ', color: sep }]
233 if (config.fill === 'space') return [{ text: ' '.repeat(free), color: sep }]
234 const win = data.window ? ` ${formatTokens(data.window).replace(/\.0(?=[kM]$)/, '')}` : ''
235 if (typeof data.percent !== 'number') return [{ text: ` ${'─'.repeat(free - 2 - win.length)}${win} `, color: sep }]
236 const pct = Math.max(0, Math.min(100, Math.round(data.percent)))
237 const label = `${pct}%`
238 const bar = free - 2 - label.length - win.length
239 const filled = Math.round((bar * pct) / 100)
240 const colour = toHex(theme[ctxColour(pct, config)])
241 return [
242 { text: ' ', color: sep },
243 { text: '─'.repeat(filled), color: colour },
244 { text: label, color: colour },
245 { text: `${'─'.repeat(bar - filled)}${win} `, color: sep },
246 ]
247}
248
249// The band's row as spans: the left segments, the fill between, the right
250// segments. `columns` is the band's width; without it the sides are only
251// spaced apart.
252export function statusSpans(
253 data: StatusData,
254 config: StatuslineConfig,
255 theme: ResolvedTheme,
256 columns?: number,
257): { left: Span[]; middle: Span[]; right: Span[] } {
258 const ctx: Ctx = { data, config, icon: k => ICONS[config.icons][k] }
259 const draw = (ids: readonly SegmentId[]) =>
260 ids.flatMap(id => {
261 const seg = SEGMENTS[id]?.(ctx)
262 return seg ? [{ ...seg, id }] : []
263 })
264 let segs = { left: draw(config.left), right: draw(config.right) }
265 let spans = sides(segs.left, segs.right, config, theme)
266 // Too wide: drop the lowest-priority segment (the rightmost of equals) until it fits.
267 const width = () => cellWidth([...spans.left, ...spans.right].map(s => s.text).join(''))
268 while (columns !== undefined && width() + 2 > columns && segs.left.length + segs.right.length > 0) {
269 const all = [...segs.left, ...segs.right]
270 const victim = all.reduce((low, s) => (PRIORITY[s.id] <= PRIORITY[low.id] ? s : low))
271 segs = { left: segs.left.filter(s => s !== victim), right: segs.right.filter(s => s !== victim) }
272 spans = sides(segs.left, segs.right, config, theme)
273 }
274 return { ...spans, middle: fill(data, config, theme, columns === undefined ? 0 : columns - width()) }
275}
276
277function sides(left: Seg[], right: Seg[], config: StatuslineConfig, theme: ResolvedTheme): { left: Span[]; right: Span[] } {
278 const [lg, rg] = SEPARATOR_GLYPHS[config.separator]
279 const sep = toHex(theme.statusLineSep)
280
281 if (config.separator === 'powerline') {
282 const back = toHex(theme.statusLineBg)
283 const run = (segs: Seg[], glyph: string): Span[] =>
284 segs.flatMap((s, i) => [
285 ...(i > 0 ? [{ text: glyph, color: sep, backgroundColor: back }] : []),
286 { text: ` ${s.text} `, color: toHex(theme[s.colour]), backgroundColor: back },
287 ])
288 return {
289 left: left.length ? [...run(left, lg), { text: POWERLINE_CAPS[0], color: back }] : [],
290 right: right.length ? [{ text: POWERLINE_CAPS[1], color: back }, ...run(right, rg)] : [],
291 }
292 }
293
294 const join = (segs: Seg[], glyph: string): Span[] =>
295 segs.flatMap((s, i) => [
296 ...(i > 0 ? [glyph ? { text: ` ${glyph} `, color: sep } : { text: ' ', color: sep }] : []),
297 { text: s.text, color: toHex(theme[s.colour]) },
298 ])
299 return { left: join(left, lg), right: join(right, rg) }
300}
301hooks/settings-schema.ts 157 lines1// The catalogue of every agent-cockpit setting. It drives the config defaults,
2// validation, the /dgs pane's rows and the README's settings table.
3
4import type { Config } from './layers'
5
6export const SEGMENT_IDS = [
7 'model', 'caveman', 'path', 'git', 'sha', 'tokens', 'cost', 'fiveHour', 'sevenDay', 'ctx', 'delta', 'activity', 'duration',
8] as const
9export type SegmentId = (typeof SEGMENT_IDS)[number]
10
11// Segments of the old statusLine script that the band has no data for; configs
12// naming them are cleaned without a warning.
13export const RETIRED_SEGMENTS: readonly string[] = ['mode', 'session', 'pr']
14
15export const SEPARATORS = ['powerline', 'powerline-thin', 'slash', 'pipe', 'block', 'none', 'ascii'] as const
16export const ICON_SETS = ['nerd', 'ascii', 'none'] as const
17export const PATH_STYLES = ['basename', 'full', 'home'] as const
18export const FILLS = ['gauge', 'space', 'none'] as const
19
20export type Tab = 'statusline' | 'ttsr' | 'rules' | 'advisor' | 'context'
21export const TABS: readonly { id: Tab; label: string }[] = [
22 { id: 'statusline', label: 'Status line' },
23 { id: 'ttsr', label: 'TTSR' },
24 { id: 'rules', label: 'Rules' },
25 { id: 'advisor', label: 'Advisor' },
26 { id: 'context', label: 'Context' },
27]
28
29export type SettingKind =
30 | { kind: 'bool' }
31 | { kind: 'enum'; options: readonly string[] }
32 | { kind: 'number'; min?: number; max?: number }
33 | { kind: 'string' }
34 | { kind: 'theme' }
35 | { kind: 'segments' }
36 | { kind: 'stringList' }
37
38export type Setting = {
39 key: string
40 tab: Tab
41 label: string
42 description: string
43 default: unknown
44 storage?: 'config' | 'store'
45 // A heading the /dgs pane draws the setting under.
46 group?: string
47} & SettingKind
48
49export const SETTINGS: readonly Setting[] = [
50 { key: 'statusline.enabled', tab: 'statusline', group: 'Look', label: 'Enabled', kind: 'bool', default: true,
51 description: 'Draw the status line in the band above the prompt.' },
52 { key: 'statusline.theme', tab: 'statusline', group: 'Look', label: 'Theme', kind: 'theme', default: 'dark',
53 description: 'Colour theme: one of the 102 omp themes or a custom one from .agents/mods/themes/.' },
54 { key: 'statusline.separator', tab: 'statusline', group: 'Look', label: 'Separator', kind: 'enum', options: SEPARATORS, default: 'powerline-thin',
55 description: 'How segments are separated; powerline draws filled segments on the theme background.' },
56 { key: 'statusline.fill', tab: 'statusline', group: 'Look', label: 'Fill', kind: 'enum', options: FILLS, default: 'gauge',
57 description: 'The room between the sides: a context gauge, blank space pushing the right side to the edge, or nothing.' },
58 { key: 'statusline.icons', tab: 'statusline', group: 'Look', label: 'Icons', kind: 'enum', options: ICON_SETS, default: 'nerd',
59 description: 'Nerd Font glyphs, short ASCII labels, or no icons.' },
60 { key: 'statusline.left', tab: 'statusline', group: 'Segments', label: 'Left segments', kind: 'segments',
61 default: ['model', 'caveman', 'path', 'git', 'sha'], description: 'Segments on the left, in order.' },
62 { key: 'statusline.right', tab: 'statusline', group: 'Segments', label: 'Right segments', kind: 'segments',
63 default: ['activity', 'delta', 'tokens', 'cost', 'fiveHour', 'sevenDay', 'duration'], description: 'Segments on the right, in order.' },
64 { key: 'statusline.path.style', tab: 'statusline', group: 'Look', label: 'Path style', kind: 'enum', options: PATH_STYLES, default: 'basename',
65 description: 'Folder name only, the full path, or the path relative to home (~).' },
66 { key: 'statusline.git.aheadBehind', tab: 'statusline', group: 'Git', label: 'Git ahead/behind', kind: 'bool', default: true,
67 description: 'Show ↑/↓ commit counts against the upstream branch.' },
68 { key: 'statusline.git.counts', tab: 'statusline', group: 'Git', label: 'Git file counts', kind: 'bool', default: true,
69 description: 'Show staged (+), unstaged (~) and untracked (?) file counts; off shows a * when dirty.' },
70 { key: 'statusline.fiveHour.showReset', tab: 'statusline', group: 'Limits', label: '5h reset time', kind: 'bool', default: true,
71 description: 'Show the time until the 5-hour usage window resets.' },
72 { key: 'statusline.sevenDay.showReset', tab: 'statusline', group: 'Limits', label: '7d reset time', kind: 'bool', default: true,
73 description: 'Show the time until the weekly usage window resets.' },
74 { key: 'statusline.ctx.warnAt', tab: 'statusline', group: 'Thresholds', label: 'Context warn %', kind: 'number', min: 0, max: 100, default: 50,
75 description: 'Context use at which the segment turns to the warning colour.' },
76 { key: 'statusline.ctx.errorAt', tab: 'statusline', group: 'Thresholds', label: 'Context error %', kind: 'number', min: 0, max: 100, default: 80,
77 description: 'Context use at which the segment turns to the error colour.' },
78
79 { key: 'ttsr.enabled', tab: 'ttsr', label: 'Enabled', kind: 'bool', default: true,
80 description: 'Check every Edit/Write against trigger rules.' },
81 { key: 'ttsr.interruptMode', tab: 'ttsr', label: 'Interrupt mode', kind: 'enum',
82 options: ['always', 'tool-only', 'never', 'prose-only'], default: 'always',
83 description: 'Default for rules without their own: always/tool-only deny the edit, never reminds after it.' },
84 { key: 'ttsr.repeatMode', tab: 'ttsr', label: 'Repeat', kind: 'enum', options: ['once', 'after-gap'], default: 'once',
85 description: 'A rule fires once per session, or again after a gap of completed turns.' },
86 { key: 'ttsr.repeatGap', tab: 'ttsr', label: 'Repeat gap', kind: 'number', min: 1, max: 1000, default: 10,
87 description: 'Completed turns before an after-gap rule may fire again.' },
88
89 { key: 'rules.builtin', tab: 'rules', label: 'Built-in rules', kind: 'bool', default: true,
90 description: 'Load the rules vendored from oh-my-pi.' },
91 { key: 'rules.disabled', tab: 'rules', label: 'Disabled rules', kind: 'stringList', default: [],
92 description: 'Rule names to drop.' },
93
94 { key: 'advisor.enabled', tab: 'advisor', label: 'Enabled', kind: 'bool', default: false, storage: 'store',
95 description: 'Review turns that edited files.' },
96 { key: 'advisor.model', tab: 'advisor', label: 'Model', kind: 'string', default: '', storage: 'store',
97 description: 'Reviewer model id; empty reuses the session through a cached fork.' },
98 { key: 'advisor.budgetUsd', tab: 'advisor', label: 'Budget (USD)', kind: 'number', min: 0, default: undefined, storage: 'store',
99 description: 'The advisor turns itself off once total spend reaches this; empty for none.' },
100
101 { key: 'append.enabled', tab: 'context', label: 'APPEND_SYSTEM', kind: 'bool', default: true,
102 description: 'Append .agents/mods/APPEND_SYSTEM.md to the system prompt.' },
103 { key: 'agentsMd.enabled', tab: 'context', label: 'AGENTS.md', kind: 'bool', default: true,
104 description: 'Load AGENTS.md files beside CLAUDE.md.' },
105]
106
107export function settingsFor(tab: Tab): Setting[] {
108 return SETTINGS.filter(s => s.tab === tab)
109}
110
111export function validate(setting: Setting, value: unknown): { value: unknown } | { error: string } {
112 switch (setting.kind) {
113 case 'bool':
114 return typeof value === 'boolean' ? { value } : { error: 'expected true or false' }
115 case 'enum':
116 return typeof value === 'string' && setting.options.includes(value)
117 ? { value }
118 : { error: `expected one of ${setting.options.join(', ')}` }
119 case 'number': {
120 const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
121 if (typeof n !== 'number' || !Number.isFinite(n)) return { error: 'expected a number' }
122 if (setting.min !== undefined && n < setting.min) return { error: `must be at least ${setting.min}` }
123 if (setting.max !== undefined && n > setting.max) return { error: `must be at most ${setting.max}` }
124 return { value: n }
125 }
126 case 'string':
127 return typeof value === 'string' ? { value: value.trim() } : { error: 'expected text' }
128 case 'theme':
129 return typeof value === 'string' && value.trim() !== '' ? { value: value.trim() } : { error: 'expected a theme name' }
130 case 'segments': {
131 if (!Array.isArray(value)) return { error: 'expected a list of segments' }
132 const known: readonly unknown[] = SEGMENT_IDS
133 const bad = value.find(v => !known.includes(v))
134 if (bad !== undefined) return { error: `unknown segment ${String(bad)}` }
135 if (new Set(value).size !== value.length) return { error: 'a segment appears twice' }
136 return { value: [...value] }
137 }
138 case 'stringList':
139 return Array.isArray(value) && value.every(v => typeof v === 'string') ? { value: [...value] } : { error: 'expected a list of names' }
140 }
141}
142
143export type SchemaDefaults = Pick<Config, 'statusline' | 'ttsr' | 'rules' | 'append' | 'agentsMd'>
144
145// The defaults as a nested config object; store-backed settings are left out.
146export function schemaDefaults(): SchemaDefaults {
147 const out: Record<string, unknown> = {}
148 for (const s of SETTINGS) {
149 if (s.storage === 'store') continue
150 const parts = s.key.split('.')
151 let node = out
152 for (const p of parts.slice(0, -1)) node = (node[p] ??= {}) as Record<string, unknown>
153 node[parts[parts.length - 1] ?? s.key] = Array.isArray(s.default) ? [...s.default] : s.default
154 }
155 return out as SchemaDefaults
156}
157hooks/statusline-config.ts 62 lines1// The `statusline` section of config.json, sanitised so the status line band
2// always has a usable layout whatever the files say.
3
4import { getPath } from './config-patch'
5import { FILLS, ICON_SETS, PATH_STYLES, RETIRED_SEGMENTS, SEGMENT_IDS, SEPARATORS, type SegmentId, settingsFor, validate } from './settings-schema'
6
7export type StatuslineConfig = {
8 enabled: boolean
9 theme: string
10 separator: (typeof SEPARATORS)[number]
11 fill: (typeof FILLS)[number]
12 icons: (typeof ICON_SETS)[number]
13 left: SegmentId[]
14 right: SegmentId[]
15 path: { style: (typeof PATH_STYLES)[number] }
16 git: { aheadBehind: boolean; counts: boolean }
17 fiveHour: { showReset: boolean }
18 sevenDay: { showReset: boolean }
19 ctx: { warnAt: number; errorAt: number }
20}
21
22const PREFIX = 'statusline.'
23
24export function sanitizeStatusline(raw: unknown): { config: StatuslineConfig; warnings: string[] } {
25 const warnings: string[] = []
26 const isObject = typeof raw === 'object' && raw !== null && !Array.isArray(raw)
27 if (raw !== undefined && !isObject) warnings.push('statusline: expected an object; using defaults')
28 const out: Record<string, unknown> = {}
29 const taken = new Set<string>()
30
31 for (const s of settingsFor('statusline')) {
32 const path = s.key.slice(PREFIX.length).split('.')
33 let value = isObject ? getPath(raw, path.join('.')) : undefined
34 if (s.kind === 'segments' && Array.isArray(value)) {
35 const known: readonly unknown[] = SEGMENT_IDS
36 const kept: SegmentId[] = []
37 for (const id of value) {
38 if (RETIRED_SEGMENTS.includes(id as string)) continue
39 if (!known.includes(id)) warnings.push(`${s.key}: unknown segment ${String(id)} dropped`)
40 else if (kept.includes(id as SegmentId)) warnings.push(`${s.key}: duplicate segment ${String(id)} dropped`)
41 else if (!taken.has(id as string)) kept.push(id as SegmentId)
42 }
43 value = kept
44 }
45 let final = s.default
46 if (value !== undefined) {
47 const v = validate(s, value)
48 if ('error' in v) warnings.push(`${s.key}: ${v.error}; using default`)
49 else final = v.value
50 }
51 if (s.kind === 'segments') {
52 const list = (final as SegmentId[]).filter(id => !taken.has(id))
53 for (const id of list) taken.add(id)
54 final = list
55 }
56 let node = out
57 for (const p of path.slice(0, -1)) node = (node[p] ??= {}) as Record<string, unknown>
58 node[path[path.length - 1] ?? s.key] = Array.isArray(final) ? [...final] : final
59 }
60 return { config: out as StatuslineConfig, warnings }
61}
62hooks/themes.ts 137 lines1// Status line themes: omp's built-in palettes (vendored, already hex) plus
2// custom themes from `.agents/mods/themes/*.json` that may extend any theme.
3
4export const THEME_TOKENS = [
5 'statusLineBg',
6 'statusLineSep',
7 'statusLineModel',
8 'statusLinePath',
9 'statusLineGitClean',
10 'statusLineGitDirty',
11 'statusLineContext',
12 'statusLineSpend',
13 'statusLineOutput',
14 'statusLineCost',
15 'statusLineCaveman',
16 'accent',
17 'success',
18 'warning',
19 'error',
20 'dim',
21] as const
22
23export type ThemeToken = (typeof THEME_TOKENS)[number]
24export type Colour = string | number
25export type Rgb = { r: number; g: number; b: number }
26export type ThemeSpec = { extends?: string; vars?: Record<string, Colour>; colors: Partial<Record<string, Colour>> }
27export type ResolvedTheme = Record<ThemeToken, Rgb>
28
29const FALLBACK = 'dark'
30const CAVEMAN_DEFAULT = '#d7875f'
31
32const BASE16 = [
33 '#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0',
34 '#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff',
35]
36const CUBE = [0, 95, 135, 175, 215, 255]
37
38export function xterm256(i: number): Rgb | undefined {
39 if (!Number.isInteger(i) || i < 0 || i > 255) return undefined
40 if (i < 16) return hex(BASE16[i] ?? '')
41 if (i < 232) {
42 const n = i - 16
43 return { r: CUBE[Math.floor(n / 36)] ?? 0, g: CUBE[Math.floor(n / 6) % 6] ?? 0, b: CUBE[n % 6] ?? 0 }
44 }
45 const v = 8 + 10 * (i - 232)
46 return { r: v, g: v, b: v }
47}
48
49function hex(s: string): Rgb | undefined {
50 const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s)
51 if (!m) return undefined
52 let h = m[1] ?? ''
53 if (h.length === 3) h = [...h].map(c => c + c).join('')
54 return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) }
55}
56
57export function toHex(c: Rgb): string {
58 return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('')
59}
60
61// A colour: `#rrggbb`, `#rgb`, an xterm index 0–255, or a name looked up in
62// `lookup` (vars and other tokens), followed until it lands on one of those.
63export function parseColour(c: Colour, lookup: Record<string, Colour>): Rgb | undefined {
64 const seen = new Set<string>()
65 let cur: Colour | undefined = c
66 while (cur !== undefined) {
67 if (typeof cur === 'number') return xterm256(cur)
68 if (/^\d+$/.test(cur)) return xterm256(Number(cur))
69 if (cur.startsWith('#')) return hex(cur)
70 if (seen.has(cur)) return undefined
71 seen.add(cur)
72 cur = lookup[cur]
73 }
74 return undefined
75}
76
77type Raw = { colors: Record<string, Colour>; vars: Record<string, Colour> }
78
79export function resolveTheme(
80 name: string,
81 builtins: Record<string, Record<string, Colour>>,
82 custom: Record<string, ThemeSpec>,
83): { theme: ResolvedTheme; warnings: string[] } {
84 const warnings: string[] = []
85 const fallbackRaw = (): Raw => ({ colors: { ...(builtins[FALLBACK] ?? {}) }, vars: {} })
86
87 // Flattens a theme and its `extends` chain into one raw map, base first.
88 const build = (n: string, seen: Set<string>, preferCustom: boolean): Raw | undefined => {
89 const spec = preferCustom ? custom[n] : undefined
90 if (spec) {
91 if (seen.has(n)) return undefined
92 seen.add(n)
93 let base: Raw | undefined
94 if (spec.extends !== undefined) {
95 base = build(spec.extends, seen, spec.extends !== n)
96 if (!base) return undefined
97 } else {
98 base = builtins[n] ? { colors: { ...builtins[n] }, vars: {} } : fallbackRaw()
99 }
100 const colors = { ...base.colors }
101 for (const [k, v] of Object.entries(spec.colors ?? {})) if (v !== undefined) colors[k] = v
102 return { colors, vars: { ...base.vars, ...(spec.vars ?? {}) } }
103 }
104 const b = builtins[n]
105 return b ? { colors: { ...b }, vars: {} } : undefined
106 }
107
108 let raw = build(name, new Set(), true)
109 if (!raw) {
110 warnings.push(
111 custom[name]
112 ? `theme "${name}": extends chain is cyclic or names an unknown theme; using ${FALLBACK}`
113 : `theme "${name}" not found; using ${FALLBACK}`,
114 )
115 raw = fallbackRaw()
116 }
117
118 const base = fallbackRaw().colors
119 const lookup = { ...raw.colors, ...raw.vars }
120 const theme = {} as ResolvedTheme
121 for (const token of THEME_TOKENS) {
122 const value = raw.colors[token]
123 let rgb = value === undefined ? undefined : parseColour(value, lookup)
124 if (value !== undefined && !rgb) warnings.push(`theme "${name}": ${token} "${value}" is not a colour`)
125 if (!rgb) {
126 const fb = base[token] ?? (token === 'statusLineCaveman' ? CAVEMAN_DEFAULT : undefined)
127 rgb = fb === undefined ? undefined : parseColour(fb, base)
128 }
129 theme[token] = rgb ?? { r: 192, g: 192, b: 192 }
130 }
131 return { theme, warnings }
132}
133
134export function listThemes(builtins: Record<string, unknown>, custom: Record<string, unknown>): string[] {
135 return [...new Set([...Object.keys(builtins), ...Object.keys(custom)])].sort()
136}
137