SLOPSHOPPER

agent-cockpit

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-cockpit
│ ┃ dgs settings ✕ › fix the failing auth test and add an audit log call │ ┃ [ Status line ] [ TTSR ] [ Rules ] [ Advisor │ ┃ write to [ Global ] [ This project ] /Users/ ● agent-cockpit: agent-cockpit: theme "dark" not found; using dark │ ┃ 󰚩 Opus 5.5 1M   project   main ↑1 +2 ~1 ⏺ Read(src/auth.ts) │ ┃ ⚠ theme "dark" not found; using dark ⎿ Read 6 lines │ ┃ Look ⏺ Update(src/auth.ts) │ ┃ Enabled [x] on ⎿ Added 2 lines, removed 1 line │ ┃ Theme family dark ▾ theme dark ▾ ⏺ Bash(bun test) │ ┃ Separator powerline-thin ▾ ⎿ 3 pass, 1 fail │ ┃ Fill gauge ▾ │ ┃ Icons nerd ▾ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Path style basename ▾ │ ┃ Segments ✻ Worked for 42s · done 4:20 PM │ ┃ model [x] L ↑ ↓ │ ┃ caveman [x] L ↑ ↓ › /dgs │ ┃ path [x] L ↑ ↓ ⎿ agent-cockpit: dgs settings opened. │ ┃ git [x] L ↑ ↓ │ ┃ sha [x] L ↑ ↓ │ ┃ activity [x] R ↑ ↓ │ ┃ delta [x] R ↑ ↓ │ ┃ tokens [x] R ↑ ↓ │ ┃ cost [x] R ↑ ↓ │ ┃ fiveHour [x] R ↑ ↓ │ ┃ sevenDay [x] R ↑ ↓ │ ┃ duration [x] R ↑ ↓ │ ┃ ctx [ ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · dgs settings
[ Status line ] [ TTSR ] [ Rules ] [ Advisor ] [ Context ] write to [ Global ] [ This project ] /Users/dev/.agents/mods 󰚩 Opus 5.5 1M   project   main ↑1 +2 ~1 ?3  $0.42 ⚠ theme "dark" not found; using dark Look Enabled [x] on Theme family dark ▾ theme dark ▾ Separator powerline-thin ▾ Fill gauge ▾ Icons nerd ▾ Path style basename ▾ Segments model [x] L ↑ ↓ caveman [x] L ↑ ↓ path [x] L ↑ ↓ git [x] L ↑ ↓ sha [x] L ↑ ↓ activity [x] R ↑ ↓ delta [x] R ↑ ↓ tokens [x] R ↑ ↓ cost [x] R ↑ ↓ fiveHour [x] R ↑ ↓ sevenDay [x] R ↑ ↓ duration [x] R ↑ ↓ ctx [ ] Git Git ahead/behind [x] on Git file counts [x] on Limits 5h reset time [x] on 7d reset time [x] on Thresholds Context warn % 50 Context error % 80
README

claude-mods

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.

FeatureWhat it does
APPEND_SYSTEMAppends your own text to the system prompt, global and per project
AGENTS.mdLoads AGENTS.md files (project ancestors + ~/.agents/AGENTS.md) beside CLAUDE.md
Rulesomp-format rule files: alwaysApply rules go into the system prompt, described rules form a rulebook index
TTSRTime Traveling Stream Rules, tool scope: rules checked against every Edit/Write; a reminder after the result, or a deny before it
AdvisorOptional reviewer of turns that edited files; you accept or ignore its note; spend tracking and a budget
Status lineomp-style themed line above the prompt (102 omp themes + custom): configurable segments, separators, icons and a context gauge filling the width
/dgsSettings pane for all of the above

Works on Linux and Windows. Nothing reads omp's own ~/.omp files; omp is not required.

Install

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.

Layers

Everything is read from .agents/ folders, lowest to highest:

  1. built-in rules shipped with the plugin (plugins/agent-cockpit/builtin-rules/, vendored from omp)
  2. global: ~/.agents/ (%USERPROFILE%\.agents\ on Windows)
  3. project: every .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 merge by file name: a project rules/foo.md replaces a global or built-in foo.
  • config.json deep-merges, higher layers win, arrays replace.
  • APPEND_SYSTEM.md concatenates low to high; frontmatter 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.

Settings

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

KeyTabDefaultValuesMeaning
statusline.enabledStatus linetrueboolDraw the status line in the band above the prompt.
statusline.themeStatus line"dark"theme nameColour theme: one of the 102 omp themes or a custom one from .agents/mods/themes/.
statusline.separatorStatus line"powerline-thin"powerline powerline-thin slash pipe block none asciiHow segments are separated; powerline draws filled segments on the theme background.
statusline.fillStatus line"gauge"gauge space noneThe room between the sides: a context gauge, blank space pushing the right side to the edge, or nothing.
statusline.iconsStatus line"nerd"nerd ascii noneNerd Font glyphs, short ASCII labels, or no icons.
statusline.leftStatus line["model","caveman","path","git","sha"]segment idsSegments on the left, in order.
statusline.rightStatus line["activity","delta","tokens","cost","fiveHour","sevenDay","duration"]segment idsSegments on the right, in order.
statusline.path.styleStatus line"basename"basename full homeFolder name only, the full path, or the path relative to home (~).
statusline.git.aheadBehindStatus linetrueboolShow ↑/↓ commit counts against the upstream branch.
statusline.git.countsStatus linetrueboolShow staged (+), unstaged (~) and untracked (?) file counts; off shows a * when dirty.
statusline.fiveHour.showResetStatus linetrueboolShow the time until the 5-hour usage window resets.
statusline.sevenDay.showResetStatus linetrueboolShow the time until the weekly usage window resets.
statusline.ctx.warnAtStatus line50≥ 0, ≤ 100Context use at which the segment turns to the warning colour.
statusline.ctx.errorAtStatus line80≥ 0, ≤ 100Context use at which the segment turns to the error colour.
ttsr.enabledTTSRtrueboolCheck every Edit/Write against trigger rules.
ttsr.interruptModeTTSR"always"always tool-only never prose-onlyDefault for rules without their own: always/tool-only deny the edit, never reminds after it.
ttsr.repeatModeTTSR"once"once after-gapA rule fires once per session, or again after a gap of completed turns.
ttsr.repeatGapTTSR10≥ 1, ≤ 1000Completed turns before an after-gap rule may fire again.
rules.builtinRulestrueboolLoad the rules vendored from oh-my-pi.
rules.disabledRules[]namesRule names to drop.
advisor.enabledAdvisorfalseboolReview turns that edited files. (stored by /dgs advisor and the pane, not config.json)
advisor.modelAdvisornonestringReviewer model id; empty reuses the session through a cached fork. (stored by /dgs advisor and the pane, not config.json)
advisor.budgetUsdAdvisornone≥ 0The advisor turns itself off once total spend reaches this; empty for none. (stored by /dgs advisor and the pane, not config.json)
append.enabledContexttrueboolAppend .agents/mods/APPEND_SYSTEM.md to the system prompt.
agentsMd.enabledContexttrueboolLoad AGENTS.md files beside CLAUDE.md.

<!-- settings:end -->

advisor.prices ({ "<model>": { input, output, cacheRead, cacheWrite } }, USD per MTok) overrides the built-in price table.

Status line themes

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:

SegmentShows
modelThe session's model, shortened (Opus 5.5 1M)
cavemancaveman's mode badge and savings, when caveman is active
pathThe working folder (statusline.path.style)
gitBranch, ↑/↓ against upstream, and staged +, unstaged ~, untracked ? file counts
shaThe short commit id
tokensThe input of the last response (what fills the context window)
costWhat the session has cost
fiveHour, sevenDayThe share left of the 5-hour and weekly usage windows, with the time to reset
ctxContext use as a percent (the gauge shows it too)
deltaWhat the last turn cost and how much it grew or shrank the context
activityagent-cockpit's own work this session: TTSR rules fired, advisor spend (! while a note waits)
durationTime 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:

ValueDraws
gaugeA rule filled to the context share in the ctx colour, the percent at the fill's end, the window size last: ────58%──────── 1M
spaceBlank space; the right segments sit at the right edge
noneTwo spaces; the sides sit together

The line refreshes at session start, after each turn and every 5 seconds.

Rule format

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

Advisor

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.

Development

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.

Licence

MIT. plugins/agent-cockpit/builtin-rules/ comes from oh-my-pi (MIT); see NOTICE.

Source 18 files
hooks/register.tsx 985 lines
1// 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}
985
hooks/advisor.ts 75 lines
1// 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}
75
hooks/agentsmd.ts 22 lines
1// 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}
22
hooks/config-patch.ts 62 lines
1// 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}
62
hooks/load.ts 113 lines
1// 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}
113
hooks/pane-model.ts 176 lines
1// 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}
176
hooks/paths.ts 66 lines
1// 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}
66
hooks/rules.ts 28 lines
1// 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}
28
hooks/status.ts 301 lines
1// 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}
301
hooks/settings-schema.ts 157 lines
1// 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}
157
hooks/statusline-config.ts 62 lines
1// 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}
62
hooks/themes.ts 137 lines
1// 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