Turns Claude Code's wait state into a live briefing on your own project: vulnerabilities, unpushed commits, stale branches, TODO/FIXME debt. Local only, zero…

The wait state belongs to whoever is waiting. This is idle-intel rebuilt as a Claude Code mod: while the agent thinks, a line above the prompt tells you something true about the work of this session, and the pane behind it hands each finding to Claude with one button.
✻ Incubating… (23s)
◈ FIXME src/app.js:3 race condition on writes details
Click details, or run /idle-mod, and the pane opens. From the keyboard, ctrl+x tab moves focus to the band, then i opens it; while you are typing in the prompt, i is just a letter.
This session
● 1 commit made this session, not pushed yet [ preview ] [ fix with Claude ]
● FIXME src/app.js:3 race condition on writes [ preview ] [ fix with Claude ]
● 2 TODO markers in files changed this session
[ show the project lines (5) ]
rescan scanned 4m ago
This session lists only what the session itself brought: each FIXME in the files Claude edited or wrote here (new files included) on a line of its own, with file and line and a button for that one, the TODO count of those files, commits made here and not pushed yet, and npm findings that were not there at the session's first scan (after an install, say). The band above the prompt rotates through these lines only. Everything that was already in the project, such as old stashes, stale branches, the project-wide TODO count or a vulnerability that predates the session, sits under show the project lines, folded until you open it. Two sessions on the same project each see their own work.
After a turn that edited files or ran git commit, the session lines are rescanned right away (git only, no network); after npm install and the like, npm runs again too. Files changed by a shell command other than Claude's edit tools are not tracked.
preview shows, under the line, the exact prompt that button would send; fix with Claude closes the pane and submits it for you. The prompts ask Claude to look and propose; none of them tells it to install, push or change anything before you say so.
| Collector | Findings |
|---|---|
| npm | known vulnerabilities (npm audit), major version jumps (npm outdated) |
| git | unpushed commits, forgotten stashes, branches untouched 30+ days |
| codebase | TODO / FIXME counts, via git grep over tracked files only: lines holding FIXME: or FIXME(, as a comment writes it, not the bare word in a sentence |
The npm checks run where the package.json is: in a monorepo, the package you started Claude in. The git checks run from the repository's top folder. A check that times out keeps its previous lines rather than dropping them, and none of them can throw: a broken check never breaks the interface. A check that cannot run says why at the bottom of the pane: the command could not start (not installed, or not on the PATH the app sees), it timed out, or npm audit found no package-lock.json. The branch you have checked out never counts as stale.
The original idle-intel was a bash script wired into statusLine. The mod version fixes what that could not:
turn.start and turn.complete are real events, so the briefing appears during the wait and leaves when work resumes — no 12-second guessing./plugin install, nothing to edit in settings.json, no .cmd wrapper, no three copies of bash.exe fighting over one command on Windows./plugin install idle-mod --marketplace demartinogiuseppe/idle-mod
In the Desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with demartinogiuseppe/idle-mod, then Add plugin.
Or in two steps: /plugin marketplace add demartinogiuseppe/idle-mod, then /plugin install idle-mod@idle-mod.
To try it before installing, clone this repo and run claude --plugin-dir ./idle-mod from your project.
| Needs | For | Get it |
|---|---|---|
| Claude Code with mod support | the mod itself | built and tested on v2.1.301; claude --version to check, claude update to update |
| Git | git checks, TODO/FIXME count | git-scm.com/downloads |
| Node.js with npm | npm checks, only in projects with a package.json | nodejs.org/en/download |
The mod API is in early access and may change between releases, so an older or much newer Claude Code may not load it. If /idle-mod is not a valid command after a restart, the mod did not load, and it cannot tell you why from inside: check the version first.
When Git or npm is missing, the pane says which check did not run and links to where to get it. If it is installed but the pane still says it could not start, the app does not see it on its PATH: check that git --version (or npm --version) works in a new terminal, then restart the app.
No telemetry, no server of ours, no account. The briefings never call the model, so they cost zero tokens; only pressing fix with Claude sends a prompt, the same as typing it yourself.
What does leave your machine: in a project with a package.json, the mod runs npm audit and npm outdated when a session starts, and again when you start a turn or open the pane and the last scan is more than 30 minutes old. Like running those commands yourself, they send your dependency names and versions to the npm registry in your npm configuration. Everything else (git and the TODO/FIXME count) stays local.
The last scan of each of your 20 most recent projects is saved on your machine, under Claude Code's configuration folder, so the next session can show it right away. It holds the project's path and the finding lines, nothing else.
Opening a repo you don't trust.
.npmrc can send npm to any host and fill its values from your environment variables, so a cloned repo could have npm hand your tokens to that host. npm has no option to ignore that file, so the mod skips the npm checks in any project that has its own .npmrc and says so in the pane. That includes an .npmrc in any folder between the package and the repo's top, since npm 10 reads a workspace root's file too; one above the repo's top is yours and does not count. Run npm audit yourself there if you trust the file..git/config can name programs for git to run. Every git command runs with -c core.fsmonitor=false -c log.showSignature=false -c submodule.recurse=false, which keeps git grep and git log from starting one and git out of submodules the repo brings along, and every npm command with --git=git. The names of the files changed this session reach git grep after -- with --literal-pathspecs, so a file name is never read as a pattern or an option. A FIXME's comment is the repo's text: the pane shows it as plain ASCII, cut short, and its fix with Claude prompt names only the file and line, never the comment, so a comment cannot write the prompt.One thing to know before you install this or any other mod: mods are not sandboxed. Through Claude Code they can run programs and read files on your machine. Read the source before you trust it — it's one file, hooks/register.js, and you can list what it does without running it:
claude plugin validate ./idle-mod
The hooks: and calls: lines tell you every event it handles and everything it asks Claude Code to do.
The band and the pane are drawn in the terminal and in the Desktop app's Code tab. Where nothing is drawn (claude -p, CI, an SDK script with no app attached) the mod runs no checks at all.
test-kit/make-demo.sh builds a disposable project containing seven planted defects — a real lodash CVE, a major version jump (chalk 4 → latest), two unpushed commits against a local fake remote, a stash, a branch backdated 45 days, and known TODO/FIXME counts — so you can check every collector against an answer written down in advance. The demo installs a package with a known vulnerability on purpose: keep it in its own folder, never run its code, and delete it when you're done.
It needs bash, git, npm and Python 3. Run it from an empty folder outside this repo:
mkdir idle-mod-try && cd idle-mod-try
bash /path/to/idle-mod/test-kit/make-demo.sh
cd idle-intel-demo
claude --plugin-dir /path/to/idle-mod
Then run /idle-mod, open show the project lines and count: the seven defects were there before the session started, so they are the project's. Expected: 7 of 7. To see a session line, ask Claude to add a // FIXME: test comment to src/app.js: after that turn it shows under This session, with its file and line, and in the band. Run it twice — once in the terminal, once in the Desktop app — because those are two separate installs of Claude Code, and a mod built against one can break on the other.
That discipline comes from twenty years of food production auditing: you don't validate an inspection system by pointing it at something and hoping. You plant known defects and verify it finds exactly those.
MIT. No server, no business model, free by construction.
idle-mod is an independent project, not made, affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic, used here only to say what the mod works with.
hooks/register.js 743 lines1// idle-mod
2// Fills Claude Code's wait state with facts about your own project.
3// No telemetry, no server, no model calls; only npm talks to its registry.
4
5const PANE = 'idle-mod'
6const MAX_AGE_MS = 30 * 60 * 1000
7const ROTATE_MS = 12000
8const KEEP_PROJECTS = 20
9
10// Severity sets the order in the pane; the band rotates through every line.
11const SEV = { high: 0, warn: 1, info: 2 }
12const CHECKS = { npm: 'npm checks', git: 'git checks', debt: 'TODO/FIXME count' }
13// What to do when a command the checks need cannot start.
14const HELP = {
15 git: { install: 'Install Git, then restart Claude Code.', href: 'https://git-scm.com/downloads', label: 'git-scm.com/downloads' },
16 npm: { install: 'Install Node.js, which includes npm, then restart Claude Code.', href: 'https://nodejs.org/en/download', label: 'nodejs.org/en/download' },
17}
18
19// Module state. Lost on reload, rebuilt by the next session.start.
20let insights = []
21let collectedAt = 0
22let collecting = false
23let busy = false
24let interactive = true
25// The finding whose prompt the pane shows in full, by its text; null for none.
26let previewed = null
27// Why checks of the last scan could not run, one line each, for the pane.
28let problems = []
29// The project the findings belong to: `top` runs git, `npm` runs the npm checks.
30let where = { top: '', npm: '', git: false }
31// This session: when it started (unix seconds), the files Claude changed in
32// it, the npm lines already there at its first scan, and what its tools did
33// since the last scan. A line that is not this session's is the project's.
34let session = fresh()
35// Whether the pane shows the project's lines below this session's.
36let projectOpen = false
37
38function fresh() {
39 return { since: Math.floor(Date.now() / 1000), touched: new Set(), npmBefore: null, dirty: { git: false, npm: false } }
40}
41
42// A cloned repo's .git/config names programs for git to run. These overrides
43// keep the checks from running one: core.fsmonitor (run by git grep) and
44// gpg.program (run by git log and friends when log.showSignature is on).
45// submodule.recurse keeps git out of submodules the repo brings along.
46const GIT = ['git', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', '-c', 'submodule.recurse=false']
47const NPM_SAFE = '--git=git'
48
49// Names and versions come from the project's own files and end up in a prompt
50// sent as the user, so they are used only when they look like what npm writes.
51const PKG_NAME = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]{0,213}$/
52const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.]{1,20})?(\+[0-9A-Za-z.]{1,20})?$/
53
54// Text from the repo that is shown but never sent: printable ASCII only.
55const safe = (s, max = 40) => String(s).replace(/[^\x20-\x7e]/g, '?').slice(0, max)
56
57// Markers as comments write them: `FIXME:` or `FIXME(name)`, a git grep pattern.
58const MARK = { FIXME: 'FIXME[:(]', TODO: 'TODO[:(]' }
59// This session's FIXMEs get a line each, up to this many.
60const MAX_MARK_ROWS = 10
61// A file name that may go into a prompt: letters, digits and . _ - / only.
62const PLAIN_PATH = /^[A-Za-z0-9._\/-]{1,200}$/
63
64// --------------------------------------------------------------- collectors
65// Each collector returns a list of { severity, text, fix }, or { failed } with
66// the reason when a check could not run: the previous lines of that collector
67// then stay, and the pane says why. None of them throws.
68
69// $.process.run rejects both when a command cannot start and when it runs out
70// of time; how long it took tells the two apart.
71async function run($, argv, cwd, timeoutMs) {
72 const started = Date.now()
73 try {
74 return await $.process.run(argv, { cwd, timeoutMs })
75 } catch {
76 const late = Date.now() - started >= timeoutMs - 1000
77 if (late) return { failed: `${argv[0]} timed out` }
78 return { failed: `${argv[0]} could not start`, missing: argv[0] }
79 }
80}
81
82const failed = (r) => Boolean(r && r.failed)
83
84async function exists($, path) {
85 try {
86 return await $.fs.exists(path)
87 } catch {
88 return false
89 }
90}
91
92// The git top level when the session started inside a repo's subfolder,
93// otherwise the session's own root. A monorepo package that has its own
94// package.json is where the npm checks run.
95async function locate($) {
96 let root
97 try {
98 root = await $.session.root()
99 } catch {
100 return null
101 }
102 const r = await run($, [...GIT, 'rev-parse', '--show-toplevel'], root, 5000)
103 const git = Boolean(r && r.exitCode === 0 && r.stdout.trim())
104 const top = git ? r.stdout.trim() : root
105 const npm = (await exists($, `${root}/package.json`)) ? root : top
106 return { top, npm, git }
107}
108
109// npm 10 run in a workspace package also reads the .npmrc of the workspace
110// root above it, so every folder from the npm one up to the repo's top is
111// checked: those are the folders a cloned repo controls. Above the top the
112// .npmrc files are the person's own, and outside git only the npm folder is.
113async function npmrcInRepo($, w) {
114 let dir = w.npm
115 for (let depth = 0; depth < 40; depth++) {
116 if (await exists($, `${dir}/.npmrc`)) return true
117 if (!w.git || (await exists($, `${dir}/.git`))) return false
118 const up = dir.replace(/[\\/]+[^\\/]*$/, '')
119 if (!up || up === dir) return false
120 dir = up
121 }
122 return true
123}
124
125// $.store is one file shared by every project, so each project gets its key.
126const scanKey = (w) => 'lastScan:' + w.npm
127
128// Point the mod at the session's project. After /cd or a worktree move the
129// old project's lines go, and the new project's last scan shows right away.
130async function follow($) {
131 const w = await locate($)
132 if (!w || (w.top === where.top && w.npm === where.npm)) return
133 where = w
134 insights = []
135 problems = []
136 collectedAt = 0
137 // Another project's npm lines say nothing about what this one had.
138 session.npmBefore = null
139 try {
140 const saved = await $.store.get(scanKey(w))
141 if (saved && Array.isArray(saved.insights)) {
142 // A saved session line belonged to the session that saved it.
143 insights = saved.insights.filter((item) => item.scope !== 'session')
144 collectedAt = Number(saved.at) || 0
145 }
146 } catch {
147 // no saved scan: the next one fills in
148 }
149}
150
151async function npmInsights($, w) {
152 if (!(await exists($, `${w.npm}/package.json`))) return []
153
154 // A project's .npmrc can point npm at any host and fill its values from
155 // environment variables, so a cloned repo could make npm send your tokens
156 // there. npm has no switch to ignore it, so such projects are not checked.
157 if ((await npmrcInRepo($, w)) || (await exists($, `${w.top}/.npmrc`))) {
158 return [{ severity: 'info', text: 'npm checks skipped: this project has its own .npmrc' }]
159 }
160
161 const [audit, outdated] = await Promise.all([
162 run($, ['npm', 'audit', '--json', NPM_SAFE], w.npm, 30000),
163 run($, ['npm', 'outdated', '--json', NPM_SAFE], w.npm, 60000),
164 ])
165 if (failed(audit)) return audit
166 if (failed(outdated)) return outdated
167
168 const out = []
169 try {
170 const report = JSON.parse(audit.stdout)
171 if (report.error && report.error.code === 'ENOLOCK') return { failed: 'npm audit needs a package-lock.json' }
172 const vulns = report.metadata.vulnerabilities
173 const critical = Number(vulns.critical) || 0
174 const high = Number(vulns.high) || 0
175 const moderate = Number(vulns.moderate) || 0
176 if (critical + high > 0) {
177 out.push({
178 severity: 'high',
179 text: `npm audit: ${critical} critical, ${high} high severity`,
180 fix: "Run npm audit, explain each high or critical finding and propose a fix for each. Don't change or install anything until I say so.",
181 })
182 } else if (moderate) {
183 out.push({ severity: 'info', text: `npm audit: ${moderate} moderate, nothing critical` })
184 }
185 } catch {
186 return { failed: 'npm audit gave no report' }
187 }
188
189 if (outdated.stdout.trim()) {
190 try {
191 const major = (v) => Number(v.split('.')[0])
192 const majors = Object.entries(JSON.parse(outdated.stdout)).filter(
193 ([name, d]) =>
194 d &&
195 !Array.isArray(d) &&
196 PKG_NAME.test(name) &&
197 VERSION.test(d.current) &&
198 VERSION.test(d.latest) &&
199 major(d.latest) > major(d.current),
200 )
201 if (majors.length) {
202 const [name, d] = majors[0]
203 const rest = majors.length > 1 ? ` (+${majors.length - 1} more)` : ''
204 out.push({
205 severity: 'warn',
206 text: `major update: ${name} ${d.current} → ${d.latest}${rest}`,
207 fix: `Check what would break if ${name} went from ${d.current} to ${d.latest}. Tell me first; don't change or install anything until I say so.`,
208 })
209 }
210 } catch {
211 return { failed: 'npm outdated gave no report' }
212 }
213 }
214 return out
215}
216
217async function gitInsights($, w) {
218 if (!(await exists($, `${w.top}/.git`))) return []
219
220 const [ahead, mine, stash, branches] = await Promise.all([
221 run($, [...GIT, 'rev-list', '--count', '@{u}..HEAD'], w.top, 8000),
222 run($, [...GIT, 'rev-list', '--count', `--since=@${session.since}`, '@{u}..HEAD'], w.top, 8000),
223 run($, [...GIT, 'stash', 'list'], w.top, 8000),
224 run(
225 $,
226 [...GIT, 'for-each-ref', '--sort=committerdate', '--format=%(HEAD)|%(committerdate:unix)|%(refname:short)', 'refs/heads/'],
227 w.top,
228 8000,
229 ),
230 ])
231 const broken = [ahead, mine, stash, branches].find(failed)
232 if (broken) return broken
233
234 const out = []
235 // No upstream, a detached HEAD or no commits yet: rev-list fails, no line.
236 const count = (r) => (r.exitCode === 0 ? Number(r.stdout.trim()) || 0 : 0)
237 const n = count(ahead)
238 if (n > 0) {
239 out.push({
240 severity: 'warn',
241 text: `${n} unpushed commit${n > 1 ? 's' : ''} on this branch`,
242 fix: "Show me the unpushed commits on this branch and tell me whether they look ready to push. Don't push anything until I say so.",
243 })
244 }
245 const m = count(mine)
246 if (m > 0) {
247 out.push({
248 scope: 'session',
249 severity: 'warn',
250 text: `${m} commit${m > 1 ? 's' : ''} made this session, not pushed yet`,
251 fix: "Show me the commits made in this session that are not pushed yet and tell me whether they look ready to push. Don't push anything until I say so.",
252 })
253 }
254
255 const stashes = stash.stdout.trim() ? stash.stdout.trim().split('\n').length : 0
256 if (stashes) out.push({ severity: 'info', text: `${stashes} stash${stashes > 1 ? 'es' : ''} sitting around` })
257
258 // A branch name may hold `|`, so it goes last; the checked-out one never counts.
259 const cutoff = Date.now() / 1000 - 30 * 86400
260 const stale = branches.stdout
261 .split('\n')
262 .map((l) => {
263 const i = l.indexOf('|')
264 const j = l.indexOf('|', i + 1)
265 return { head: l.slice(0, i), at: Number(l.slice(i + 1, j)), name: l.slice(j + 1) }
266 })
267 .filter((b) => b.name && b.head !== '*' && b.at < cutoff)
268 if (stale.length) {
269 out.push({
270 severity: 'info',
271 text: `${stale.length} branch${stale.length > 1 ? 'es' : ''} untouched 30+ days (e.g. ${safe(stale[0].name)})`,
272 })
273 }
274 return out
275}
276
277async function debtInsights($, w) {
278 if (!(await exists($, `${w.top}/.git`))) return []
279
280 // git grep stays inside tracked files, so node_modules and build output
281 // never enter the count. It counts lines that hold the marker as a comment
282 // writes it, `FIXME:` or `FIXME(`, not the bare word in a sentence. The
283 // files changed this session are searched again on their own, new ones
284 // included, their names taken literally rather than as patterns.
285 const files = touchedIn(w)
286 const inFiles = (flags, word) =>
287 files.length
288 ? run($, [...GIT, '--literal-pathspecs', 'grep', flags, '--untracked', '-e', word, '--', ...files], w.top, 10000)
289 : { exitCode: 1, stdout: '' }
290 const [fixme, todo, myFixme, myTodo] = await Promise.all([
291 run($, [...GIT, 'grep', '-cI', '-e', MARK.FIXME], w.top, 10000),
292 run($, [...GIT, 'grep', '-cI', '-e', MARK.TODO], w.top, 10000),
293 inFiles('-nIz', MARK.FIXME),
294 inFiles('-cI', MARK.TODO),
295 ])
296 const broken = [fixme, todo, myFixme, myTodo].find(failed)
297 if (broken) return broken
298
299 const total = (res) =>
300 res.stdout
301 .trim()
302 .split('\n')
303 .filter(Boolean)
304 .reduce((sum, line) => sum + (Number(line.split(':').pop()) || 0), 0)
305
306 const out = []
307 const nFixme = total(fixme)
308 const nTodo = total(todo)
309 if (nFixme) {
310 out.push({
311 severity: 'warn',
312 text: `${nFixme} FIXME marker${nFixme > 1 ? 's' : ''} in tracked files`,
313 fix: 'Find the FIXME markers, group them by theme, and tell me which ones are worth fixing now.',
314 })
315 }
316 if (nTodo) out.push({ severity: 'info', text: `${nTodo} TODO marker${nTodo > 1 ? 's' : ''} across the project` })
317
318 // One line per FIXME of this session, with its own button. Its comment is
319 // the repo's text: shown cleaned, never put in the prompt, which names only
320 // the file and line, so Claude reads the comment as code rather than orders.
321 // A file name may hold a newline and so forge a row: only rows that name
322 // one of the files asked about count.
323 const asked = new Set(files)
324 const marks = myFixme.stdout
325 .split('\n')
326 .map((l) => l.split('\0'))
327 .filter((p) => p.length >= 3 && asked.has(p[0]) && /^\d+$/.test(p[1]))
328 for (const [path, line, ...rest] of marks.slice(0, MAX_MARK_ROWS)) {
329 const note = rest.join(' ').replace(/^.*?FIXME(\([^)]*\))?:?\s*/, '')
330 out.push({
331 scope: 'session',
332 severity: 'warn',
333 text: `FIXME ${safe(path, 60)}:${line} ${safe(note, 60)}`.trim(),
334 fix: PLAIN_PATH.test(path)
335 ? `Look at the FIXME at ${path}:${line}, tell me what it is about and propose a fix. Don't change anything until I say so.`
336 : 'Find the FIXME markers in the files you changed in this session and tell me which ones to fix now.',
337 })
338 }
339 const more = marks.length - MAX_MARK_ROWS
340 if (more > 0) {
341 out.push({
342 scope: 'session',
343 severity: 'warn',
344 text: `${more} more FIXME marker${more > 1 ? 's' : ''} in files changed this session`,
345 fix: 'Find the FIXME markers in the files you changed in this session and tell me which ones to fix now.',
346 })
347 }
348 const sTodo = total(myTodo)
349 if (sTodo) {
350 out.push({ scope: 'session', severity: 'info', text: `${sTodo} TODO marker${sTodo > 1 ? 's' : ''} in files changed this session` })
351 }
352 return out
353}
354
355// The files changed this session inside this repo, relative to its top.
356function touchedIn(w) {
357 const norm = (p) => String(p).replace(/\\/g, '/').replace(/\/+$/, '')
358 const top = norm(w.top) + '/'
359 const out = []
360 for (const path of session.touched) {
361 const f = norm(path)
362 if (f.toLowerCase().startsWith(top.toLowerCase()) && f.length > top.length) out.push(f.slice(top.length))
363 }
364 return out.slice(0, 200)
365}
366
367// Keep the last few projects only; the store is one file shared by all of them.
368async function remember($, w) {
369 const key = scanKey(w)
370 await $.store.set(key, { at: collectedAt, insights })
371 const seen = await $.store.get('recent')
372 const recent = [key, ...(Array.isArray(seen) ? seen.filter((k) => k !== key) : [])]
373 for (const old of recent.slice(KEEP_PROJECTS)) await $.store.delete(old)
374 await $.store.set('recent', recent.slice(0, KEEP_PROJECTS))
375}
376
377function redraw($) {
378 try {
379 $.ui.invalidate('ui.render')
380 } catch {
381 // nothing drawing
382 }
383}
384
385// Never rejects, so callers may leave it unawaited. `npm: false` keeps the
386// npm lines as they are: a quick rescan after a turn, with no network.
387async function collect($, { npm = true } = {}) {
388 if (collecting || !interactive) return
389 collecting = true
390 session.dirty = { git: false, npm: npm ? false : session.dirty.npm }
391 let w = null
392 try {
393 await follow($)
394 w = where
395 if (!w.top) return
396 const sources = ['npm', 'git', 'debt']
397 const groups = await Promise.all([npm ? npmInsights($, w) : null, gitInsights($, w), debtInsights($, w)])
398 if (w !== where) return
399 // The npm lines there at the session's first scan are the project's;
400 // one that shows up later (after an install, say) is the session's.
401 const npmLines = groups[0]
402 if (Array.isArray(npmLines)) {
403 if (!session.npmBefore) session.npmBefore = new Set(npmLines.map((item) => item.text))
404 for (const item of npmLines) if (!session.npmBefore.has(item.text)) item.scope = 'session'
405 }
406 const kept = (g) => g === null || failed(g)
407 insights = groups
408 .flatMap((g, i) =>
409 kept(g)
410 ? insights.filter((item) => item.source === sources[i])
411 : g.map((item) => ({ scope: 'project', ...item, source: sources[i] })),
412 )
413 .sort((a, b) => SEV[a.severity] - SEV[b.severity])
414 problems = [
415 ...(npm ? [] : problems.filter((p) => p.source === 'npm')),
416 ...groups.flatMap((g, i) => (failed(g) ? [{ source: sources[i], text: `${CHECKS[sources[i]]} didn't run: ${g.failed}`, missing: g.missing }] : [])),
417 ]
418 collectedAt = Date.now()
419 await remember($, w)
420 } catch {
421 // a failed scan keeps whatever the last good one found
422 } finally {
423 collecting = false
424 redraw($)
425 // The session moved to another project mid-scan: scan that one too.
426 if (w && w !== where) collect($)
427 }
428}
429
430function isStale() {
431 return Date.now() - collectedAt > MAX_AGE_MS
432}
433
434// ------------------------------------------------------------------ drawing
435
436const DOT = { high: 'red', warn: 'yellow', info: 'gray' }
437
438const mine = () => insights.filter((item) => item.scope === 'session')
439
440// The band rotates through this session's lines only.
441function current() {
442 const lines = mine()
443 if (!lines.length) return null
444 return lines[Math.floor(Date.now() / ROTATE_MS) % lines.length]
445}
446
447// Whether anything draws this session. The desktop app hosts its Code tab as
448// an SDK session (isInteractive false) and attaches its surface, so a surface
449// counts as much as a person at the prompt. claude -p and CI have neither.
450async function drawn($) {
451 try {
452 return (await $.session.surfaces()).length > 0
453 } catch {
454 return false
455 }
456}
457
458// The .catch of the hooks that only watch tool calls: the call goes on as is.
459function passOn($, e, next) {
460 return next(e)
461}
462
463// The first time the session turns out to draw: show the saved scan, refresh.
464async function wake($) {
465 if (interactive) return
466 interactive = true
467 await follow($)
468 collect($)
469}
470
471export function register(on) {
472 on('session.start', async ($, e, next) => {
473 // Headless runs draw nothing, so they scan nothing.
474 interactive = false
475 session = fresh()
476
477 // Rotate the band while Claude is busy. Cheap: one redraw, no work.
478 $.clock.every(ROTATE_MS, () => {
479 if (busy && insights.length) redraw($)
480 })
481
482 try {
483 await $.command.register({ name: 'idle-mod', description: 'Open the idle-mod pane' })
484 } catch {
485 // name taken: the pane is still reachable from the band
486 }
487
488 if (e.isInteractive !== false || (await drawn($))) await wake($)
489 return next(e)
490 })
491
492 // The desktop app may attach after the session started.
493 on('session.attach', async ($, e, next) => {
494 const result = await next(e)
495 await wake($)
496 return result
497 })
498
499 // A turn is the wait state. Refresh if the project moved or the scan is old.
500 // Subagent turns fire these events too; only the main turn counts.
501 on('turn.start', async ($, e, next) => {
502 if (e.agentId !== undefined) return next(e)
503 if (!interactive && (await drawn($))) await wake($)
504 if (!interactive) return next(e)
505 busy = true
506 await follow($)
507 if (isStale()) collect($)
508 redraw($)
509 return next(e)
510 })
511
512 // After a turn that changed files, made commits or installed packages,
513 // rescan: git at once, npm only when the turn touched packages.
514 on('turn.complete', async ($, e, next) => {
515 if (e.agentId !== undefined) return next(e)
516 busy = false
517 if (interactive && (session.dirty.git || session.dirty.npm)) collect($, { npm: session.dirty.npm })
518 redraw($)
519 return next(e)
520 })
521
522 // The files Claude changes, in the main loop or a subagent, are this
523 // session's. A failed or refused edit changed nothing. These hooks only
524 // watch: the bookkeeping cannot throw, and if anything else does, the
525 // tool call goes on untouched.
526 for (const tool of ['Edit', 'Write', 'NotebookEdit']) {
527 on('tool.call', { tool }, async ($, e, next) => {
528 const result = await next(e)
529 try {
530 const path = e.file_path || e.notebook_path
531 if (typeof path === 'string' && path && !result.deny && !result.isError) {
532 session.touched.add(path)
533 session.dirty.git = true
534 if (/(^|[\\/])package(-lock)?\.json$/.test(path)) session.dirty.npm = true
535 }
536 } catch {
537 // bookkeeping only
538 }
539 return result
540 }).catch(passOn)
541 }
542
543 // Commands that change commits or installed packages call for a rescan.
544 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
545 const result = await next(e)
546 try {
547 const command = String(e.command || '')
548 if (/\bgit\s+(commit|merge|rebase|cherry-pick|revert|am|pull|reset|push)\b/.test(command)) session.dirty.git = true
549 if (/\b(npm|pnpm|yarn)\s+(i|install|add|ci|update|up|upgrade|remove|rm|uninstall)\b/.test(command)) session.dirty.npm = true
550 } catch {
551 // bookkeeping only
552 }
553 return result
554 }).catch(passOn)
555
556 on('command.run', { command: 'idle-mod' }, async ($) => {
557 // Someone ran the command, so something draws.
558 interactive = true
559 await follow($)
560 if (isStale()) collect($)
561 await $.ui.open({ id: PANE, title: 'idle-mod', focus: true, closeOnEscape: true })
562 return {}
563 })
564
565 // The band above the prompt: one line, only while Claude is working.
566 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
567 if (!e.props.isWorking || e.props.hasSurvey) return next(e)
568 const item = current()
569 if (!item) return next(e)
570 const { Box, Text, Button } = $.ui.resolve(e)
571 return Box({
572 flexDirection: 'row',
573 columnGap: 1,
574 children: [
575 Text({ children: ['◈'], color: DOT[item.severity] }),
576 Text({ children: [item.text], dimColor: item.severity === 'info' }),
577 Button({
578 key: 'open-pane',
579 label: 'details',
580 hotkey: 'i',
581 plain: true,
582 dimColor: true,
583 onPress: async () => {
584 await $.ui.open({ id: PANE, title: 'idle-mod', focus: true, closeOnEscape: true })
585 },
586 }),
587 ],
588 })
589 })
590
591 // The pane: everything at once, each fixable line with its own button.
592 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
593 if (e.requestId !== PANE) return next(e)
594 // The pane is being drawn, so something draws, whatever session.start said.
595 if (!interactive) wake($)
596 const { Box, Text, Button, Link } = $.ui.resolve(e)
597
598 // An empty pane keeps the rescan row below, so a scan can always be rerun.
599 const done = problems.length ? 'nothing new from the checks that ran.' : 'nothing new in this session.'
600 const empty = Text({
601 children: [collecting ? 'scanning…' : collectedAt ? done : 'not scanned yet.'],
602 dimColor: true,
603 })
604
605 // Checks that could not run say so, instead of leaving the pane silent,
606 // and a missing command comes with where to get it, once per command.
607 const missing = [...new Set(problems.map((p) => p.missing).filter((m) => HELP[m]))]
608 const notes = [
609 ...problems.map((p) => Text({ children: ['▲ ' + p.text], color: 'yellow' })),
610 ...missing.map((m) =>
611 Box({
612 flexDirection: 'column',
613 paddingLeft: 2,
614 children: [
615 Box({
616 flexDirection: 'row',
617 columnGap: 1,
618 children: [Text({ children: [HELP[m].install] }), Link({ href: HELP[m].href, label: HELP[m].label })],
619 }),
620 Text({ children: [`Already installed? If \`${m} --version\` works in a new terminal, restart the app so it sees the same PATH.`], dimColor: true }),
621 ],
622 }),
623 ),
624 ]
625
626 // Each row: the finding on the left, its buttons on the right edge, so the
627 // buttons line up in one column. preview shows the exact prompt fix sends.
628 // A row's keys use its place in `insights`, unique across both sections.
629 const row = (item) => {
630 const i = insights.indexOf(item)
631 const line = Box({
632 flexDirection: 'row',
633 justifyContent: 'space-between',
634 alignItems: 'center',
635 columnGap: 2,
636 children: [
637 Box({
638 flexDirection: 'row',
639 flexShrink: 1,
640 columnGap: 1,
641 children: [
642 Text({ children: ['●'], color: DOT[item.severity] }),
643 Text({ children: [item.text], dimColor: item.severity === 'info' }),
644 ],
645 }),
646 ...(item.fix
647 ? [
648 Box({
649 flexDirection: 'row',
650 columnGap: 1,
651 children: [
652 Button({
653 key: 'preview-' + i,
654 label: previewed === item.text ? 'hide' : 'preview',
655 onPress: async () => {
656 previewed = previewed === item.text ? null : item.text
657 redraw($)
658 },
659 }),
660 Button({
661 key: 'fix-' + i,
662 label: 'fix with Claude',
663 onPress: async () => {
664 previewed = null
665 await $.ui.close({ id: PANE })
666 await $.prompt.submit({ text: item.fix, asUser: true })
667 },
668 }),
669 ],
670 }),
671 ]
672 : []),
673 ],
674 })
675 if (!item.fix || previewed !== item.text) return line
676 return Box({
677 flexDirection: 'column',
678 children: [
679 line,
680 Box({
681 flexDirection: 'column',
682 paddingLeft: 2,
683 children: [
684 Text({ children: ['fix with Claude sends this prompt:'], dimColor: true }),
685 Text({ children: [item.fix] }),
686 ],
687 }),
688 ],
689 })
690 }
691
692 // This session's lines first; the project's, there before the session
693 // started or outside what it changed, folded below until asked for.
694 const ours = mine()
695 const theirs = insights.filter((item) => item.scope !== 'session')
696 const heading = (text) => Text({ children: [text], bold: true })
697 const project = theirs.length
698 ? [
699 Text({ children: [' '] }),
700 Button({
701 key: 'project',
702 label: projectOpen ? 'hide the project lines' : `show the project lines (${theirs.length})`,
703 onPress: async () => {
704 projectOpen = !projectOpen
705 redraw($)
706 },
707 }),
708 ...(projectOpen ? [heading('In the project'), ...theirs.map(row)] : []),
709 ]
710 : []
711
712 const age = Math.round((Date.now() - collectedAt) / 60000)
713 const status = collecting ? 'scanning…' : collectedAt ? `scanned ${age}m ago` : ''
714
715 return Box({
716 flexDirection: 'column',
717 children: [
718 heading('This session'),
719 ...(ours.length ? ours.map(row) : [empty]),
720 ...notes,
721 ...project,
722 Text({ children: [' '] }),
723 Box({
724 flexDirection: 'row',
725 columnGap: 2,
726 children: [
727 Button({
728 key: 'rescan',
729 label: 'rescan',
730 hotkey: 'r',
731 plain: true,
732 onPress: async () => {
733 await collect($)
734 },
735 }),
736 Text({ children: [status], dimColor: true }),
737 ],
738 }),
739 ],
740 })
741 })
742}
743