Writes each session's state, context fill and rate limits to ~/.cache/claude-agents for the Noctalia agents widget

A comprehensive dotfiles repository for macOS development environment setup. Manages configurations for terminal applications, editors, system utilities, and development tools through symlinked dotfiles and automated setup scripts.
To set up Juliet, run:
$ git clone --recursive git@github.com:avegancafe/Juliet ~/.config/Juliet
$ ~/.config/Juliet/bin/juliet-bootstrap
Core Binaries:
juliet-bootstrap - Main setup and update scriptbrew bundle to install packages~/.config/Juliet/bin/juliet-bootstrapSystem Utilities:
reindex-spotlight - macOS Spotlight reindexingupdate-kitty - Kitty terminal updaterenvsource - Environment variable sourcingsudo - Enhanced sudo wrapperGit Binaries:
better-branch - Enhanced git branch viewerdelete-current-branch - Safe branch deletion~/.delog for recoverygit-recreate - Branch recreation utilitygit-hoard/ - Git repository organization tool (Node.js project)Git Functions:
g - Short alias for gitcurrent-branch - Get current git branch namecb - Copy current branch name to clipboardcurrent-sha - Get current commit SHAdefault-branch - Get repository's default branchgdiff - Git diff wrappergut - Git status shortcutclone - Enhanced git cloneGit Aliases:
git a - Add files to staginggit ape - Amend previous commit and force push with leasegit b / git bbranch - Show enhanced branch list (uses better-branch)git clean-branches - Delete all branches except master/maingit clean-merged / git cm - Delete branches that have been mergedgit co - Checkout branch/commitgit com - Commit changesgit cp - Cherry-pick commitsgit del - Delete current branch safely (uses delete-current-branch)git diff-branch - Show diff from fork point with mastergit fixup - Create fixup commit for interactive rebasegit fo - Fetch from origingit judge - Enhanced blame with whitespace/move detectiongit main - Switch to main/master branch automaticallygit o / git open - Open repository in GitHubgit pdiff - Pretty diff using bat with syntax highlightinggit pf - Push with force-with-lease (safer force push)git pu - Pull changesgit rb - Rebase commitsgit re - Restore filesgit s - Switch branchesgit squash - Interactive rebase to squash commits against maingit staash - Stash all changes including untracked filesgit staged - Show staged changesgit su - Update submodules recursivelygit t - Ticket (custom command)git up - Push changesEditor Tools:
nv - Launch Neovide GUI editor, auto-installs if missingvim - Alias for nvimcursor - Launch Cursor editorNeovim Management:
nvim-refresh-plugins - Neovim plugin managerEnhanced File Tools:
cat - Enhanced cat using bat with syntax highlightingls - Enhanced ls using eza with better formattingWorkspace Management:
work - Interactive workspace/directory switcher~/.workspaces and ~/.workspaces__local@workspace (directories with subdirs) and @dir (specific dirs) @workspace ~/workspace @dir ~/.config/nvim ``GitHub Integration:
run - GitHub Actions workflow runnerrun <workflow-name> [-c/--current] [-a/--args="args"]gist-to-pdf - GitHub Gist convertergist-to-pdf <gist-id>Container & Cloud Tools:
dc - Docker Compose shortcutkc - kubectl shortcutLanguage-Specific Tools:
node - Node.js wrapperhas-pipfile - Check for Python PipfileSystem Monitoring:
logs - System log viewerwatch - File watching utilitypv - Process viewerst - Status checkerGeneral Utilities:
note - Note-taking utilitysymlinked/ - Contains all dotfiles organized by target locationsymlinked/config/ - Files that go in ~/.config/symlinked/home/ - Files that go in ~/bin/ - Custom utility scripts and toolsetc/ - Assets (fonts, themes, wallpapers, keyboard layouts)Brewfile - Homebrew package definitionsAll Fish shell functions are auto-loaded alphabetically from symlinked/config/fish/functions/aliases/ and integrate with the broader Juliet ecosystem for a cohesive development environment.
hooks/register.ts 154 lines1// agents-feed: every session writes what it is doing to
2// ~/.cache/claude-agents/<sessionId>.json for the Noctalia claude_agents widget,
3// plus the account-wide rate limits to _usage.json. Observe only: every hook
4// passes its event through unchanged, and a failure here never reaches the session.
5
6import type { EngineInterface, Register } from 'claude-code'
7
8import { clip, feedDir, parseMarkers, summarize } from './feed'
9import type { Feed, FeedRateLimit, FeedState } from './feed'
10
11let feed: Feed | null = null
12let dir = ''
13// Set when a tool call's permission check came back `ask` (a prompt is up);
14// the call itself starting means you answered it.
15let isAwaitingPermission = false
16
17const IGNORED_NOTIFICATIONS = new Set(['idle_prompt', 'auth_success'])
18
19// The session's file as last written, so a reload of this module (which runs
20// session.start again with fresh variables) picks up where it left off.
21async function previous($: EngineInterface, path: string, sessionId: string): Promise<Partial<Feed>> {
22 try {
23 const old = JSON.parse(await $.fs.read(path)) as Partial<Feed>
24 return old.sessionId === sessionId && old.state !== 'ended' ? old : {}
25 } catch {
26 return {}
27 }
28}
29
30async function ensure($: EngineInterface): Promise<Feed> {
31 if (feed) return feed
32 const home = (await $.env.get('HOME')) ?? ''
33 const jobDir = await $.env.get('CLAUDE_JOB_DIR')
34 const sessionId = await $.session.id()
35 dir = feedDir(home)
36 const old = await previous($, `${dir}/${sessionId}.json`, sessionId)
37 feed = {
38 v: 1,
39 state: old.state ?? 'idle',
40 reason: old.reason ?? '',
41 summary: old.summary ?? '',
42 context: old.context ?? {},
43 rateLimits: old.rateLimits ?? [],
44 updatedAt: old.updatedAt ?? 0,
45 sessionId,
46 jobId: jobDir ? (jobDir.replace(/\/+$/, '').split('/').pop() ?? null) : null,
47 kind: jobDir ? 'background' : 'interactive',
48 cwd: await $.session.cwd(),
49 model: await $.session.model(),
50 }
51 return feed
52}
53
54// Tell a running widget to re-read now; missing noctalia (macOS) just fails quietly.
55function ping($: EngineInterface) {
56 $.process.run(['noctalia', 'msg', 'plugin', 'kyle/claude_agents:status', 'all', 'refresh']).catch(() => {})
57}
58
59async function save($: EngineInterface, patch: Partial<Feed>) {
60 try {
61 const f = await ensure($)
62 Object.assign(f, patch, { updatedAt: await $.clock.now() })
63 await $.fs.write(`${dir}/${f.sessionId}.json`, JSON.stringify(f))
64 ping($)
65 } catch {
66 // never let the feed break the session
67 }
68}
69
70function setState($: EngineInterface, state: FeedState, reason = '') {
71 const text = clip(reason)
72 return save($, { state, reason: text, summary: summarize(text) })
73}
74
75export const register: Register = (on) => {
76 on('session.start', async ($, e, next) => {
77 await save($, { cwd: e.cwd })
78 return next(e)
79 })
80
81 on('turn.start', async ($, e, next) => {
82 if (e.agentId === undefined) await setState($, 'working')
83 return next(e)
84 })
85
86 on('turn.complete', async ($, e, next) => {
87 if (e.agentId === undefined) {
88 const f = await ensure($).catch(() => null)
89 const isError = e.reason === 'error' || e.reason === 'refusal'
90 const marked = f?.kind === 'background' ? parseMarkers(e.answer) : null
91 if (marked) await setState($, marked.state, marked.reason)
92 else if (isError) await setState($, 'failed', e.reason === 'refusal' ? (e.refusal.explanation ?? 'refused') : 'API error')
93 else if (e.isAborted) await setState($, 'idle')
94 else if (f?.kind === 'interactive') await setState($, 'done', e.answer)
95 else await setState($, 'idle', e.answer)
96 }
97 return next(e)
98 })
99
100 // The model is asking you something: blocked on the question until it's answered.
101 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
102 if (e.agentId === undefined) await setState($, 'blocked', e.questions[0]?.question ?? 'question')
103 const result = await next(e)
104 if (e.agentId === undefined) await setState($, 'working')
105 return result
106 }).catch(($, e, next) => next(e))
107
108 // A permission prompt: the check answered `ask`. Read the verdict, never change it.
109 on('tool.check', async ($, e, next) => {
110 const verdict = await next(e)
111 if (e.agentId === undefined && verdict.decision === 'ask') {
112 isAwaitingPermission = true
113 await setState($, 'blocked', `permission: ${e.tool}`)
114 }
115 return verdict
116 }).catch(($, e, next) => next(e))
117
118 // The call running means the prompt was answered: back to work.
119 on('tool.call', async ($, e, next) => {
120 if (isAwaitingPermission && e.agentId === undefined) {
121 isAwaitingPermission = false
122 await setState($, 'working')
123 }
124 return next(e)
125 }).catch(($, e, next) => next(e))
126
127 // Permission prompts and the like, where classic hooks reach installed plugins (an
128 // organization's managed settings can withhold them). The idle "waiting for your
129 // input" notice is skipped: turn.complete already said why the turn ended, and
130 // that reason is the useful one.
131 on('classic.Notification', async ($, e, next) => {
132 if (!IGNORED_NOTIFICATIONS.has(e.notification_type)) await setState($, 'blocked', e.message)
133 return next(e)
134 }).catch(($, e, next) => next(e))
135
136 on('session.measure', async ($, e, next) => {
137 const rateLimits: FeedRateLimit[] = e.rateLimits.map((r) => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
138 await save($, { context: { percent: e.context.percent, tokens: e.context.tokens, window: e.context.window }, rateLimits })
139 if (rateLimits.length > 0) {
140 try {
141 await $.fs.write(`${dir}/_usage.json`, JSON.stringify({ at: await $.clock.now(), rateLimits }))
142 } catch {
143 // best-effort
144 }
145 }
146 return next(e)
147 })
148
149 on('session.end', async ($, e, next) => {
150 await setState($, 'ended')
151 return next(e)
152 })
153}
154hooks/feed.ts 64 lines1// Pure pieces of the feed: the snapshot shape, the background-job marker lines and
2// the <=3-word summary. No `$` here, so the tests can hold them directly.
3
4export type FeedState = 'idle' | 'working' | 'blocked' | 'failed' | 'done' | 'ended'
5
6export type FeedRateLimit = { kind: string; percentUsed: number; resetsAt?: string }
7
8// One file per session at ~/.cache/claude-agents/<sessionId>.json, read by the
9// Noctalia claude_agents widget (symlinked/home/.local/share/noctalia/plugins/claude_agents).
10export type Feed = {
11 v: 1
12 sessionId: string
13 jobId: string | null
14 kind: 'background' | 'interactive'
15 cwd: string
16 model: string
17 state: FeedState
18 reason: string
19 summary: string
20 context: { percent?: number; tokens?: number; window?: number }
21 rateLimits: FeedRateLimit[]
22 updatedAt: number
23}
24
25// Background sessions end a turn with one of these lines (see the background-job
26// conventions): `result:` delivered, `needs input:` blocked on you, `failed:` stuck.
27const MARKERS: ReadonlyArray<[RegExp, FeedState]> = [
28 [/^\s*result:\s*(.*)$/i, 'done'],
29 [/^\s*needs input:\s*(.*)$/i, 'blocked'],
30 [/^\s*failed:\s*(.*)$/i, 'failed'],
31]
32
33// The last marker line in the answer wins; null when there is none.
34export function parseMarkers(answer: string): { state: FeedState; reason: string } | null {
35 let found: { state: FeedState; reason: string } | null = null
36 for (const line of answer.split('\n')) {
37 for (const [rx, state] of MARKERS) {
38 const m = rx.exec(line)
39 if (m) found = { state, reason: (m[1] ?? '').trim() }
40 }
41 }
42 return found
43}
44
45const FILLER = new Set(['a', 'an', 'the', 'and', 'of', 'to', 'for', 'with', 'in', 'on', 'that', 'this', 'it', 'is', 'as'])
46
47// "delivered agent roster, commands, and run workflow" -> "agent roster commands"
48export function summarize(reason: string): string {
49 const words = (reason.toLowerCase().match(/[a-z][a-z0-9_]*/g) ?? []).filter((w) => !FILLER.has(w))
50 // Past-tense summaries ("delivered ...", "fixed ..."): drop the verb.
51 if (words.length > 3 && words[0]?.endsWith('ed')) words.shift()
52 return words.slice(0, 3).join(' ')
53}
54
55// First line, trimmed to what fits a panel row.
56export function clip(text: string, max = 160): string {
57 const line = text.trim().split('\n')[0] ?? ''
58 return line.length > max ? `${line.slice(0, max - 1)}…` : line
59}
60
61export function feedDir(home: string): string {
62 return `${home}/.cache/claude-agents`
63}
64