Status band above the prompt in the Claude Desktop app's Code tab: folder, git branch and state, session age, prompt count and cost, 5-hour and weekly plan…

One source of truth for my user-level Claude Code config. Local sessions and cloud sessions (desktop app, claude.ai/code) both read it.
| Path | Holds | Becomes |
|---|---|---|
CLAUDE.md | Global working rules | ~/.claude/CLAUDE.md, through a one-line @ import |
settings.json | Output style, env vars, hooks | ~/.claude/settings.json |
agents/ | The builder, reviewer, test-author roles | ~/.claude/agents/*.md |
hooks/ | guard.py (refuses destructive git in a shared tree), config and decision watches, session_start.sh | ~/.claude/hooks/* |
lint/ | The Simplified Technical English prose linter and its hook gate | ~/.claude/lint/* |
janitor/ | A machine-wide sweep for orphaned worktrees, branches, and listeners. See janitor/GUIDE.md | not installed to ~/.claude, runs from the clone |
decisions/ | One file per ruling, cited by slug | stays in the repo |
debts/ | One file per known gap, pointing to its decision where one exists. See debts/README.md | stays in the repo |
deferred/, plans/ | Open questions and plans, not yet a ruling | stay in the repo |
actions/ | Composite GitHub Actions this repo publishes (stamp, ste-lint) | callers pin them by commit SHA |
merge/, bin/ | The merge command, and its launchers bin/merge and bin/merge.cmd | bin/ lands in ~/.claude/bin. merge/ runs from the clone |
.github/ | gates.yml, the CI that checks this repo | runs here only, never installed |
agents/, hooks/, and lint/ are the folders landed-dirs.txt names. Both installers read that file, so a new folder added there lands on every platform with no other change.
git clone https://github.com/shivinate7/claude-settings C:\src\claude-settings
cd C:\src\claude-settings
powershell -ExecutionPolicy Bypass -File .\install.ps1
Needs Windows Developer Mode (Settings > System > For developers), or an admin shell, for real symlinks. Without it, the script copies the files and installs a git post-merge hook that re-copies them after every pull on main.
mkdir -p ~/Developer
git clone https://github.com/shivinate7/claude-settings ~/Developer/claude-settings
cd ~/Developer/claude-settings
bash install.sh
Everything is a real symlink. A git pull in the clone is the whole update.
claude --cloud)A cloud VM never sees your machine's ~/.claude. In claude.ai/code, set the environment's setup script to:
curl -fsSL https://raw.githubusercontent.com/shivinate7/claude-settings/main/install.sh | bash -s -- --cloud
Leave network access at Trusted, or add raw.githubusercontent.com, github.com, and codeload.github.com to a Custom list. The script also writes a SessionStart hook that re-runs itself, so each new or resumed session re-fetches the files even from a stale snapshot. To pin cloud sessions to one commit instead of main, replace /main/ in the URL with /<sha>/, or set CLAUDE_SETTINGS_REF=<sha> as an environment variable.
/context, in any session, lists ~/.claude/CLAUDE.md and the imported file under Memory files./status shows the shiv-stylisms output style.echo $CLAUDE_CODE_SUBAGENT_MODEL. Expect sonnet.~\.claude\claude-settings-install.log for a dated line from the last run. The line reads settings.json=<mode> agents/lint=<mode>. A mode is symlink, copy, or kept. kept means that a replace failed (a file lock, say) and nothing was copied, so the old files stay in place. copy+failed (agents/lint only) means that some files were copied and at least one replace failed; that file keeps its old copy. The run prints why. Run install.ps1 again after you close the lock.Edit a file here, commit, push, open a pull request against main. .github/workflows/gates.yml runs the guard fixture suite, its mutation harness, the STE lint, and the rest of the checks named in that file. Merge only when you say so, or under a repo grant, per CLAUDE.md.
Once merged:
settings.json carries a SessionStart hook that pulls the clone at every session start. The new file is live in that same session, because Claude Code reads CLAUDE.md after SessionStart hooks finish.post-merge git hook copies the changed files after your next git pull on main, and re-runs install.ps1 itself if install.ps1 changed.merge commandOne command merges a pull request in any repo that has opted in. It claims record numbers before the merge, waits for CI on the claim commit, reverts the claim if a step fails, and then merges. Plan: plans/shared-merge-tool.md. The repo's own opt-in is the merge block of its stamp config, see "The merge tool contract" in actions/stamp/README.md.
Run it from inside the repo, with gh, git, node and Python 3 on PATH:
merge <pr> preview: what it would claim and merge. Presses nothing.
merge <pr> --confirm [--session NAME] lock, claim, push, wait, merge, move the local main, clean up.
merge --unlock remove this repo's merge lock. Reads no other state.
merge --dev ... run the checkout's own tree, not origin main
bin/merge lands in ~/.claude/bin. On Windows, bin\merge.cmd lands there too, and starts bin\merge. The installers put ~/.claude/bin on PATH. Both find this clone through CLAUDE_SETTINGS_DIR, then the @ line in CLAUDE.md under CLAUDE_CONFIG_DIR (default ~/.claude). With neither, they clone the public repo into a temporary folder. The checkout must have origin set to shivinate7/claude-settings. Any other result stops before a pull request read.origin main and runs from a temporary worktree at that SHA. It prints the SHA it ran. If it cannot prove fresh code, such as when offline, it prints a warning block and runs the code it has. --dev skips all of this and runs the checkout's own tree. Use it only to work on the tool.merge <pr> runs the claim in a temporary worktree and drops it. It prints the lock state, the state of the head's checks (green, or every pending or red check by name), the claim line, and the steps a run would take. Nothing reaches origin.--confirm. It takes the lock, then reads the required checks. If the list is empty or unreadable, it stops before the claim push. Then it checks first: it waits for the head's own checks, and a red one stops the run with nothing claimed or pushed. Only on green does it claim and push the claim, and wait for the checks on the claim SHA. It checks that each claimed number is still free on the base tip, and merges with --match-head-commit. Then it moves the local main, runs afterMerge, and deletes the head branch if merge.deleteBranch is set. A rerun on a branch that already holds its own claim resumes at the wait.merge.ignoreChecks is an optional list of {"name": "...", "reason": "..."} in the merge block. A check named there is left out of the wait, and the output names it. An entry whose name or reason is not a non-empty string refuses the config, and so does a name that is also a required check. Before the wait ends green it also reads the workflow runs of the head SHA: each must be completed with success, skipped or neutral, so a run whose jobs are not listed yet holds the wait. Then it waits 30 seconds and reads again, and the result must be green again. One window stays: a workflow that GitHub has not created yet.--unlock. The lock is the ref refs/merge-lock/<defaultBranch> on origin. It expires after twice merge.deadlineMinutes plus ten minutes (a run can wait twice), and a later run breaks an expired lock. --unlock removes it at once, for a run that died. It reads no lock state. It still reads the git root and the config. The lock records the PR, branch, host, pid and start time. Sessions should pass --session NAME with their session name. A held message names the holder and says if it is alive, dead, or unknown (another host, or an old lock). A release that fails prints one line and never hides the run's own result.Exit codes:
| Code | Meaning |
|---|---|
| 0 | A preview ran, the lock was removed, or the merge landed and every after-step passed. |
| 1 | A refusal or a failed step. The message on stderr names it. A launcher stop also exits 1. |
| 1 | The merge landed, but the local main did not move or an afterMerge command failed. The merge stays. The printed line says so. |
| 2 | The arguments are wrong, such as both a pull request number and --unlock. |
Exit 1 has two causes. Read the output to tell them apart. A line that starts merge: #<pr> merged as means that the merge landed.
CLAUDE.md: edit the file. Wrap the sentence in a <!-- rule:<slug> --> anchor if you want lint/rule_audit.py to hold you to naming its enforcement (a guard rule, a gate, a CI step, or unmechanized plus a reason) in lint/rule_mechanisms.json. All the guard rules, gates, and their design rationale live as comments and numbered decisions inside hooks/guard.py and lint/. Read there, not here, for the full list.agents/<name>.md. A project can override it with a project-level .claude/agents/<name>.md carrying the same name. See agents/builder.md and agents/reviewer.md for the two shipped roles, and CLAUDE.md's Roles section for what each is for.lint/. A guard change: edit hooks/. Re-run the installer once locally so a new symlink exists.decisions/, named by slug. Claim its number only at merge, per CLAUDE.md ("never allocate a numbered record on a branch").deferred/.python3 or python on PATH. Hook commands run under sh on Windows, so put Python on Git Bash's own PATH there. Python 3 ships with the Command Line Tools on macOS. When it is missing, run xcode-select --install. When Python is missing, the hook command exits silently, so a machine without it just runs without the gates.install.ps1 once, to replace the copies with real symlinks.~/.claude/CLAUDE.md or settings.json had content you wanted to keep. Both installers back up the old file as *.bak.<timestamp> before writing. Fold anything you want into the repo's own CLAUDE.md or settings.json, then commit.SessionStart hook the setup script installs re-fetches on every session, so this should self-heal. When it does not, check the environment's network access is not blocking the three hosts above.decisions/subagent-model-cap.md for the guarded override and its required expiry field.actions/ after this repository becomes private. Open Settings, then Actions, then General, then Access. Set "Accessible from repositories owned by the user".settings.json carries a SessionStart hook that runs a quiet git pull --ff-only in the clone, found through the @ pointer in ~/.claude/CLAUDE.md. It exits at once in cloud sessions. It never blocks a session. A failed pull falls through silently.CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=1 removes the Agent tool from every subagent, so only the main conversation fans out. A nested child's completion notice can misroute. This stalls the subagent that launched it (anthropics/claude-code#75043). A repo that needs nesting raises the depth in its own .claude/settings.json and records the risk in its CLAUDE.md.actions/ste-lint exists as a composite action. A caller repo can then lint only the lines a pull request changed, against the pull request base. It never pays down a whole document's old backlog on the first branch that touches it. See the diff-scope rationale in actions/ste-lint and decisions/ste-lint-gloss-collapse.md.janitor/GUIDE.md (decisions/the-janitor-is-one-machine-wide-sweep.md).No LICENSE file is in this repository yet. Until one is added, the default is "all rights reserved". Treat the content as personal configuration, not as licensed for reuse.
hooks/register.tsx 335 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, ProcessRunResult, Register } from 'claude-code'
3
4import type { Limit, Snapshot } from '../types'
5
6const snapshot = atom({ plugin: 'desktop-statusline', key: 'snap' } as const, null)
7const warned = atom({ plugin: 'desktop-statusline', key: 'warned' } as const, [])
8const lastTurn = atom({ plugin: 'desktop-statusline', key: 'lastTurn' } as const, null)
9const compactions = atom({ plugin: 'desktop-statusline', key: 'compactions' } as const, null)
10
11const REFRESH_MS = 60_000
12const WARN_AT = [95, 80]
13const LABELS: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly' }
14const LABEL_CELLS = 8
15const PERCENT_CELLS = 5
16const DETAIL_GAP = 2
17const METER_GAP = 3
18const AGENT_ROWS = 3
19// Desktop metrics in CSS px, measured from screenshots of the band: a Box `width` cell, one
20// column of `bodyColumns`, and an average glyph of the band's proportional font. Bars are SVG
21// in px, so the limits row is fitted in px, keeping 5% spare for the estimate.
22const CELL_PX = 15
23const COL_PX = 12.5
24const CHAR_PX = 13
25const SPARE = 0.95
26const MIN_BAR_PX = 60
27const MAX_BAR_PX = 180
28
29const label = (kind: string) => LABELS[kind] ?? kind.replace(/_/g, ' ')
30const tone = (percent: number) => (percent >= 95 ? 'error' : percent >= 80 ? 'warning' : undefined)
31
32const tokens = (n: number) =>
33 n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
34
35// Drawn as an image, so it cannot follow the theme: a translucent track reads on light and dark.
36const bar = (percent: number, width: number) => {
37 const fill = percent >= 95 ? '#e5484d' : percent >= 80 ? '#e0a030' : '#2f7de1'
38 const filled = Math.round((Math.min(percent, 100) / 100) * width)
39 return (
40 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8">` +
41 `<rect width="${width}" height="8" rx="4" fill="#808080" fill-opacity="0.3"/>` +
42 `<rect width="${filled}" height="8" rx="4" fill="${fill}"/></svg>`
43 )
44}
45
46const until = (iso: string, now: number) => {
47 const minutes = Math.max(0, Math.round((Date.parse(iso) - now) / 60_000))
48 if (minutes < 60) return `${minutes}m`
49 if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
50 return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`
51}
52
53const elapsed = (ms: number) => {
54 const s = Math.round(ms / 1000)
55 if (s < 60) return `${s}s`
56 if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
57 return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
58}
59
60const ago = (ms: number) => {
61 const minutes = Math.floor(ms / 60_000)
62 if (minutes < 1) return '<1m'
63 if (minutes < 60) return `${minutes}m`
64 if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
65 return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`
66}
67
68const cacheHit = (u: ModelUsage) => {
69 const input = u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
70 return input === 0 ? null : Math.round((u.cache_read_input_tokens / input) * 100)
71}
72
73const isOnDesktop = async ($: EngineInterface) => (await $.session.surfaces()).includes('desktop')
74
75// Each git command is written out in full at its call; this only reads the result.
76const output = async (run: Promise<ProcessRunResult>) => {
77 try {
78 const ran = await run
79 return ran.exitCode === 0 ? ran.stdout.trim() : null
80 } catch {
81 return null
82 }
83}
84
85const warn = async ($: EngineInterface, limits: Limit[], now: number) => {
86 const seen = await read($, warned)
87 const fresh: string[] = []
88
89 for (const limit of limits) {
90 const threshold = WARN_AT.find(t => limit.percent >= t)
91 const key = `${limit.kind}@${threshold}@${limit.resetsAt}`
92 if (threshold === undefined || seen.includes(key)) continue
93
94 fresh.push(key)
95 const resets = limit.resetsAt ? ` · resets in ${until(limit.resetsAt, now)}` : ''
96 $.ui.toast(`${label(limit.kind)} usage limit at ${limit.percent}%${resets}`, { timeoutMs: 8000 })
97 }
98
99 if (fresh.length > 0) await update($, warned, s => [...s, ...fresh].slice(-50))
100}
101
102const refresh = async ($: EngineInterface) => {
103 if (!(await isOnDesktop($))) return
104
105 const [usage, cwd, now, agents, prompts] = await Promise.all([
106 $.session.usage(),
107 $.session.cwd(),
108 $.clock.now(),
109 $.agent.list(),
110 $.session.turns(),
111 ])
112 const [status, dirs] = await Promise.all([
113 output($.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd, timeoutMs: 5000 })),
114 output($.process.run(['git', 'rev-parse', '--git-dir', '--git-common-dir'], { cwd, timeoutMs: 5000 })),
115 ])
116
117 const lines = status?.split('\n') ?? []
118 const head = lines.find(l => l.startsWith('# branch.head '))?.slice(14)
119 const ab = lines.find(l => l.startsWith('# branch.ab '))?.match(/\+(\d+) -(\d+)/)
120 const [gitDir, commonDir] = dirs?.split('\n') ?? []
121 const limits = usage.rateLimits.map(r => ({
122 kind: r.kind,
123 percent: r.percentUsed,
124 resetsAt: r.resetsAt ?? null,
125 }))
126 const snap: Snapshot = {
127 at: now,
128 startedAt: usage.startedAt,
129 prompts,
130 dir: cwd.split('/').pop() || cwd,
131 branch: head && head !== '(detached)' ? head : null,
132 isWorktree: gitDir !== commonDir,
133 ahead: Number(ab?.[1] ?? 0),
134 behind: Number(ab?.[2] ?? 0),
135 changed: lines.filter(l => l && !l.startsWith('#')).length,
136 costUsd: usage.cost?.usd ?? null,
137 limits,
138 agents: agents
139 .filter(a => a.status === 'running')
140 .map(a => ({ type: a.type, description: a.description })),
141 }
142
143 await update($, snapshot, () => snap)
144 await warn($, limits, now)
145}
146
147export const register: Register = (on, options) => {
148 // The main conversation's prompt-cache TTL (the `cache_ttl` option): 1 hour on a Claude
149 // subscription within plan usage, 5 minutes with pay-as-you-go API billing, a cloud provider or usage credits.
150 const cacheTtlMs = options.cache_ttl === '5m' ? 5 * 60_000 : 60 * 60_000
151
152 on('session.start', async ($, e, next) => {
153 const started = await next(e)
154 await refresh($)
155 $.clock.every(REFRESH_MS, () => void refresh($))
156
157 return started
158 })
159
160 on('session.attach', { surface: 'desktop' }, async ($, e, next) => {
161 const attached = await next(e)
162 await refresh($)
163
164 return attached
165 })
166
167 on('session.measure', async ($, e, next) => {
168 const measured = await next(e)
169 await refresh($)
170
171 return measured
172 })
173
174 on('turn.complete', async ($, e, next) => {
175 const completed = await next(e)
176 if (!e.agentId) {
177 const at = await $.clock.now()
178 await update($, lastTurn, () => ({
179 at,
180 durationMs: e.durationMs,
181 model: e.usage?.model ?? null,
182 cacheHit: e.usage ? cacheHit(e.usage) : null,
183 }))
184 }
185 await refresh($)
186
187 return completed
188 })
189
190 // A spawned agent shows as running only once it has started; look again shortly after.
191 on('tool.call', { tool: 'Agent' }, ($, e, next) => {
192 $.clock.after(2000, () => void refresh($))
193
194 return next(e)
195 })
196
197 on('session.compact', async ($, e, next) => {
198 const result = await next(e)
199 if (e.trigger === 'precompute' || e.agentId || result.messages === undefined) return result
200
201 const { startedAt } = await $.session.usage()
202 await update($, compactions, c => ({
203 since: startedAt,
204 count: (c?.since === startedAt ? c.count : 0) + 1,
205 before: result.tokensBefore ?? null,
206 after: result.tokensAfter ?? null,
207 }))
208
209 return result
210 })
211
212 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
213 if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
214
215 const snap = await read($, snapshot)
216 if (snap === null) return next(e)
217
218 const { Box, Svg, Text } = $.ui.resolve(e)
219
220 const meter = (name: string, percent: number | null, detail: string, barPx: number) => (
221 <Box flexDirection="row" alignItems="center">
222 <Box width={LABEL_CELLS} flexShrink={0}>
223 <Text>{name}</Text>
224 </Box>
225 {percent === null ? (
226 <Text dimColor>{'waiting for first response'}</Text>
227 ) : (
228 <Box flexDirection="row" alignItems="center" flexShrink={0}>
229 <Svg source={bar(percent, barPx)} alt={`${name} ${percent}% used`} width={barPx} height={8} />
230 <Box width={PERCENT_CELLS} justifyContent="flex-end">
231 <Text color={tone(percent)}>{`${percent}%`}</Text>
232 </Box>
233 </Box>
234 )}
235 <Box marginLeft={DETAIL_GAP} flexShrink={0}>
236 <Text dimColor>{detail}</Text>
237 </Box>
238 </Box>
239 )
240
241 // Fit the limit meters on one row: try "resets in 2h 8m", then "↻ 2h 8m", and stack them
242 // (the one case of 5 lines) only when even the short form leaves a bar under MIN_BAR_PX.
243 const availPx = e.props.bodyColumns * COL_PX * SPARE
244 const fixedPx = (LABEL_CELLS + PERCENT_CELLS + DETAIL_GAP) * CELL_PX
245 const n = Math.max(1, snap.limits.length)
246 const fit = (isLong: boolean) => {
247 const details = snap.limits.map(l => (l.resetsAt ? `${isLong ? 'resets in ' : '↻ '}${until(l.resetsAt, snap.at)}` : ''))
248 const textPx = details.reduce((sum, d) => sum + d.length * CHAR_PX, 0)
249 const barPx = Math.floor((availPx - n * fixedPx - (n - 1) * METER_GAP * CELL_PX - textPx) / n)
250 return { details, barPx: Math.min(MAX_BAR_PX, barPx) }
251 }
252 const long = fit(true)
253 const { details, barPx: sharedBar } = long.barPx >= MIN_BAR_PX ? long : fit(false)
254 const isStacked = sharedBar < MIN_BAR_PX
255 const longestPx = Math.max(0, ...details.map(d => d.length * CHAR_PX))
256 const limitBar = isStacked
257 ? Math.max(40, Math.min(MAX_BAR_PX, Math.floor(availPx - fixedPx - longestPx)))
258 : sharedBar
259
260 const where =
261 `📁 ${snap.dir}` +
262 (snap.branch === null ? '' : ` 🌿 ${snap.branch}${snap.isWorktree ? ' 🌳' : ''}`) +
263 `${snap.ahead ? ` ↑${snap.ahead}` : ''}${snap.behind ? ` ↓${snap.behind}` : ''}` +
264 `${snap.changed ? ` ● ${snap.changed} changed` : ''}`
265 const session =
266 `session ${ago(snap.at - snap.startedAt)} · ${snap.prompts} prompt${snap.prompts === 1 ? '' : 's'}` +
267 (snap.costUsd === null ? '' : ` · $${snap.costUsd.toFixed(2)}`)
268
269 // Bottom line parts: dim unless they need attention (warning) or are live (agents).
270 const activity: { text: string; emphasis?: 'warning' | 'live' }[] = []
271 const turn = await read($, lastTurn)
272 if (turn !== null && turn.at >= snap.startedAt) {
273 const idleMs = Math.max(0, snap.at - turn.at)
274 const isCold = idleMs >= cacheTtlMs
275 activity.push({ text: `Last turn ${elapsed(turn.durationMs)}${turn.model ? ` on ${turn.model}` : ''}` })
276 if (turn.cacheHit !== null) {
277 activity.push({ text: `cache ${turn.cacheHit}%`, emphasis: turn.cacheHit < 50 ? 'warning' : undefined })
278 }
279 if (!e.props.isWorking) {
280 activity.push({ text: `idle ${ago(idleMs)}${isCold ? ' (cache cold)' : ''}`, emphasis: isCold ? 'warning' : undefined })
281 }
282 }
283 const c = await read($, compactions)
284 if (c !== null && c.since === snap.startedAt) {
285 const sizes = c.before === null || c.after === null ? '' : ` (last ${tokens(c.before)} → ${tokens(c.after)})`
286 activity.push({ text: `compacted ${c.count}×${sizes}` })
287 }
288 // Running agents get rows of their own (description truncated, type kept), so a
289 // long description never runs off the "Last turn" line.
290 const agentRows = snap.agents.slice(0, AGENT_ROWS)
291 const moreAgents = snap.agents.length - agentRows.length
292
293 return (
294 <Box flexDirection="column">
295 <Box flexDirection="row" justifyContent="space-between" width="100%">
296 <Text wrap="truncate-end">{where}</Text>
297 <Box marginLeft={2} flexShrink={0}>
298 <Text dimColor>{session}</Text>
299 </Box>
300 </Box>
301 {snap.limits.length > 0 && (
302 <Box flexDirection={isStacked ? 'column' : 'row'} columnGap={METER_GAP}>
303 {snap.limits.map((l, i) => meter(label(l.kind), l.percent, details[i] ?? '', limitBar))}
304 </Box>
305 )}
306 {activity.length > 0 && (
307 <Box flexDirection="row">
308 {activity.map((a, i) => (
309 <Box flexDirection="row">
310 {i > 0 && <Text dimColor>{' · '}</Text>}
311 <Text
312 wrap="truncate-end"
313 color={a.emphasis === 'warning' ? 'warning' : undefined}
314 dimColor={a.emphasis === undefined}
315 >
316 {a.text}
317 </Text>
318 </Box>
319 ))}
320 </Box>
321 )}
322 {agentRows.map(a => (
323 <Box flexDirection="row" justifyContent="space-between" width="100%">
324 <Text wrap="truncate-end">{`⏳ ${a.description || a.type}`}</Text>
325 <Box marginLeft={2} flexShrink={0}>
326 <Text dimColor>{a.type.split(':').pop()}</Text>
327 </Box>
328 </Box>
329 ))}
330 {moreAgents > 0 && <Text dimColor>{` +${moreAgents} more agent${moreAgents > 1 ? 's' : ''} running`}</Text>}
331 </Box>
332 )
333 })
334}
335types/index.d.ts 39 lines1export type Limit = { kind: string; percent: number; resetsAt: string | null }
2
3export type Agent = { type: string; description: string }
4
5export type Snapshot = {
6 at: number
7 startedAt: number
8 prompts: number
9 dir: string
10 branch: string | null
11 isWorktree: boolean
12 ahead: number
13 behind: number
14 changed: number
15 costUsd: number | null
16 limits: Limit[]
17 agents: Agent[]
18}
19
20export type TurnStat = {
21 at: number
22 durationMs: number
23 model: string | null
24 cacheHit: number | null
25}
26
27export type Compactions = { since: number; count: number; before: number | null; after: number | null }
28
29declare module 'claude-code' {
30 interface PluginState {
31 'desktop-statusline': {
32 snap: Snapshot | null
33 warned: string[]
34 lastTurn: TurnStat | null
35 compactions: Compactions | null
36 }
37 }
38}
39