SLOPSHOPPER

idle-mod

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…

panebandguardcommandprocesstimer
v0.3.1MITupdated 2026-10-18demartinogiuseppe/idle-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · idle-mod
│ ┃ idle-mod ✕ › fix the failing auth test and add an audit log call │ ┃ This session │ ┃ nothing new from the checks that ran. ⏺ Read(src/auth.ts) │ ┃ ▲ npm checks didn't run: npm audit gave no ⎿ Read 6 lines │ ┃ report ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ r: rescan scanned 0m ago ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /idle-mod │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · idle-mod
This session nothing new from the checks that ran. ▲ npm checks didn't run: npm audit gave no report r: rescan scanned 0m ago
README

idle-mod

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.

What it detects

CollectorFindings
npmknown vulnerabilities (npm audit), major version jumps (npm outdated)
gitunpushed commits, forgotten stashes, branches untouched 30+ days
codebaseTODO / 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.

Why a mod and not a status line

The original idle-intel was a bash script wired into statusLine. The mod version fixes what that could not:

  • It knows when you're actually waiting. turn.start and turn.complete are real events, so the briefing appears during the wait and leaves when work resumes — no 12-second guessing.
  • It's installable. /plugin install, nothing to edit in settings.json, no .cmd wrapper, no three copies of bash.exe fighting over one command on Windows.
  • It can act. A status line can only print. A mod can carry a button that submits a prompt.

Install

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

Requirements

NeedsForGet it
Claude Code with mod supportthe mod itselfbuilt and tested on v2.1.301; claude --version to check, claude update to update
Gitgit checks, TODO/FIXME countgit-scm.com/downloads
Node.js with npmnpm checks, only in projects with a package.jsonnodejs.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.

What it sends, and where it runs

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.

  • A project's own .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.
  • A repo's .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.
  • Package names and versions are dropped unless they have npm's own format, and branch names are shown as plain ASCII, so text from the repo cannot carry control characters or sentences into the pane or a prompt. Counts can still be faked by a repo, so read a finding before you act on it.

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 before you trust

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.

License

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.

Source 1 files
hooks/register.js 743 lines
1// 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