SLOPSHOPPER

session-board

Adds /board, a pane of every session on this machine with state and update-ready marks, and /safe-stop, which asks one session to write its handoff.

newpaneguardcommandprocesstimer
★ 1v0.1.2no licenseupdated 2026-10-15shivinate7/claude-settings/mods/session-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-board
│ ┃ Session board ✕ › fix the failing auth test and add an audit log call │ ┃ 0 working, 1 waiting, update-ready 0 of 1 │ ┃ repo branch ⏺ Read(src/auth.ts) │ ┃ state update ctx ag ⎿ Read 6 lines │ ┃ [ app - ⏺ Update(src/auth.ts) │ ┃ claude agents output not usable: shown from ⎿ Added 2 lines, removed 1 line │ ┃ the store only ⏺ Bash(bun test) │ ┃ Press a number or a row to open one session. ⎿ 3 pass, 1 fail │ ┃ [ Refresh ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ ⎿ session-board: Session board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session board
0 working, 1 waiting, update-ready 0 of 1 repo branch state update ctx ag [ app - waiting unk claude agents output not usable: shown from the store only Press a number or a row to open one session. [ Refresh ]
README

session-board

A mod. It adds two commands. /board shows the local sessions and agents and what each one is doing. /safe-stop checks that a session has no running work before you stop it. It draws nothing else. It calls no model.

/board

The command opens a pane with two levels.

All sessions: one band, then one row per session.

  • The band reads like "5 working, 2 waiting, update-ready 6 of 8".
  • A row shows repo, branch, state, update mark, context percent, and running agents.
  • State is "working", "waiting", or "permission". "Permission" means that a tool check returned "ask" and its tool call has not returned yet. An approved long tool call keeps the label until the call ends.
  • The update mark is "ready", "not ready", "unknown", or "no detail".
  • Press a row, or its number, to open that session.
  • The pane shows 40 rows. A line says how many more exist. The count includes entries the board did not read.

One session: agents with status, the last 5 tool calls, files touched, context percent, permission checks, and a receipt for the last turn.

/board text prints the same board as plain text. Use it where no pane shows.

Update-ready

A session is update-ready when all five hold: no running agents, state is waiting, no permission ask, no unpushed commits, and a clean tree.

  • Running servers and background tasks are not part of it. A session with a dev server can read "ready".
  • The git counts come from a reading at turn end. While the session waits, the heartbeat reads git again every 30 s, one read at a time. An idle session keeps a fresh reading.
  • A git read that needs 5 s or more makes the idle read back off. The wait doubles, up to 5 minutes. A quick read resets it to 30 s.
  • A read that began before the latest turn start is dropped when it lands.
  • A reading older than 60 s is "unknown". A failed or slow git call is "unknown". A branch with no upstream is "unknown". Unknown is never ready.
  • The 60 s rule guards against a stuck reader, not against idleness.

Where the data comes from

  • claude agents --json seeds the list. One call per refresh, 8 s timeout. A session without this mod shows as "no detail".
  • Each session with the mod writes sess:<sessionId> to $.store. The entry holds repo folder name, branch, and state. It also holds the git counts with their reading time, and the running agent count. It holds spawn counts by agent type, the context percent, and a time stamp. It stays under 1 KB.
  • It also writes detail:<sessionId>, under about 3 KB, for the one-session view. The mod writes it again only when its content changes.
  • A heartbeat refreshes the entry every 30 s. The board hides an entry older than 2 minutes, or stamped more than 30 s ahead of now. The session removes both records at session end. A killed session leaves them. The 2-minute rule hides them. An entry older than one day is deleted at the next session start or /board, unless claude agents lists that session as live. If the list is not available, nothing is deleted.
  • The board reads the 200 newest entries. It says how many more exist.
  • After /clear or a resume, the mod writes nothing for the old id and reads the id again on each beat. It writes under the new id when that binds. After session end it writes nothing.
  • The git call runs once per turn end and once per idle heartbeat: argv array, no shell, core.fsmonitor=false, --no-optional-locks, 5 s timeout.
  • These steps each wait at most 100 ms for their git, agent, context, and store work: session start, turn start, turn end, a permission ask, and the end of a tool call. The work then finishes on its own.
  • The 100 ms wait does not cover everything. At session start, $.session.repo() and $.command.register() are not bounded by it. At session end, the store deletes are not bounded by it. On /clear or a resume, $.session.repo() is not bounded either.
  • A permission ask with no tool id writes nothing.
  • The pane refreshes the board on the heartbeat while it is open. It stops when the pane closes, or after 10 refreshes with no /board call.

Privacy

  • The store is on this machine and is read by this machine only.
  • The mod stores no prompt text, no file content, and no folder path. It shows the repo folder name and the branch.
  • The recent list names a file only for the file tools: Read, Edit, Write, MultiEdit, NotebookEdit, Glob, and Grep. It stores the file name, never the folder. A Bash command and an MCP tool give the tool name only.
  • A name that looks secret shows as "(secret file)". The rule is the one in mods/plain-diff. Examples are .env, *.pem, id_rsa, and credentials.json. .env.example and credentials.ts keep their names.
  • A Grep or Glob path shows only when its last part has an extension. A folder named like a file still shows. That is a known hole.
  • Names on the board are plain text. Control characters, bidi marks, and the characters < > & " backtick are removed or replaced. Long names are cut. The mod reads store values back through the same check.
  • The mod shows no cost figure. The cost figure is not reliable.

/safe-stop

It asks this session to reach a stopping point and write its handoff.

  1. It runs the installed handoff hook's own path functions (hooks/precompact_handoff.py, through python3 or python). It gets the handoff path and the prompt file path. This makes the handoff folder if it is missing.
  2. It sends this session one prompt. The prompt says: finish the step, start no new work, and follow the hook's prompt file. It also says: rewrite the handoff whole, then report branch, uncommitted files, unpushed commits, and running agents.

The prompt is queued until the session is idle. The reply says so. If the host rejects the prompt, the reply says "could not send".

The mod writes no handoff. The session does, by the hook's own rules.

/safe-stop plan prints the prompt and sends nothing. If python or the hook is missing, /safe-stop prints what is missing and sends nothing.

The origin check is a block-list. A run from a plugin, a peer session, a coordinator, a schedule, or an auto-continuation is refused. Other origins run, such as unclassified, sdk, and bridge. The check does not prove that a person typed the command.

The python call runs with -I and with the config folder as its working directory, never the session folder. A repo file named json.py cannot run. A path over 4000 characters is refused, not cut.

Unknown

  • Whether turn end fires while a session waits on a permission prompt. Headless mode cannot test it. The mod uses a tool check that returns "ask" as the waiting signal. An auto-approve mode can answer an "ask" with no person.
  • The order of hooks in the desktop app. The mod clears a permission ask after the tool call returns. This assumes that the host runs the permission check inside the tool call. A headless run did not confirm it.
  • Whether the pane draws well in the desktop app. Only a terminal or the app can show it. Headless runs cannot.
  • Whether $.process works in the desktop app. The API says "CLI only". If it fails there, branch, readiness, and the claude agents seed are missing. The board then shows store entries alone, and no session is ready.
  • Whether the app and the terminal share one store. Same config folder should do it.
  • Whether a prompt sent from a command starts a turn in the desktop app (/safe-stop).
  • The time of the git call in the biggest repo. In the q_max clone, one run took 0.11 s with a warm cache. That is one run on one machine. The real per-turn cost is not measured.
  • Context percent reads 0 in a headless -p run.

Checked: the session id in claude agents --json equals the id the mod writes. Two live sessions merged into one row each.

Limits

  • A session without the mod shows on the board as "no detail", from the agents list. It shows repo and state only. It gets full detail after it loads the mod.
  • The store file name carries the install: a --plugin-dir run wrote session-board_inline-<hash>.json. Sessions loaded two ways probably do not share one file. Load every session the same way.
  • The running agent count is as of the last turn end or heartbeat. It can lag by 30 s.
  • A denied permission clears when the tool call returns, the same as an approved one. Turn end also clears it.
  • Named hole, the sweep: it deletes day-old records only when claude agents --json gives a valid JSON list. A banner line, an object, or no output deletes nothing. A valid empty list [] counts as real, so day-old records of sessions with no live row go.
  • Named hole, the clear race: after /clear or a resume, a store write already held at the host can land late. The mod removes it again after it lands. The old entry can show for a short moment.
  • Named hole, the idle git read: a quick read from a turn end or a beat puts the idle read back to every 30 s. A slow turn-end read does not change the interval. Only a slow idle read doubles it.
  • Named hole, the config folder: under Git Bash, HOME is a path like /c/Users/x. /safe-stop uses USERPROFILE first when it starts with a drive letter. Another setup can pick the wrong folder. Set CLAUDE_CONFIG_DIR to be sure.
  • A mod error is not shown in the session. It goes to the debug log (claude --debug), one line starting "session-board:".

Try in the desktop app

  1. Install: /plugin install session-board --marketplace shivinate7/claude-settings, then restart the session.
  2. Run /board. Check that the pane draws, that rows open, and that a session without the mod shows "no detail".
  3. Start a long turn in a second session. Check that its row reads "working", then "waiting" at the end.
  4. Trigger a permission prompt. Check that the row reads "permission".
  5. Run /safe-stop plan, then /safe-stop. Check that the handoff file changes.

Check

  • node mods/session-board/session-board.test.mjs runs the tests (stdlib only).
  • claude plugin validate mods/session-board checks the mod.
  • Try it: claude --plugin-dir mods/session-board, then /board.
Source 2 files
hooks/register.mjs 448 lines
1// session-board: one entry per session in the shared store, a pane that reads them, and /safe-stop.
2// Every host call sits in a try: a mod error never breaks a session. No model call anywhere.
3// The host reads on(...) and $.noun.method(...) from source, so they are spelled literally,
4// and helpers that take $ are top-level functions.
5import {
6  AGENTS_TIMEOUT_MS, GC_MS, GIT_BACKOFF_MAX_MS, GIT_SLOW_MS, GIT_TIMEOUT_MS, GRACE_MS, HEARTBEAT_MS, MAX_KEYS, MAX_PATH, MAX_POLLS, READ_BUDGET_MS,
7  bandText, baseName, clean, cleanPath, detailLines, gitArgs, headerText, isSessionId, isUserOrigin, makeTracker,
8  agentRows, configDir, mergeRows, num, parseStatus, readDetail, readEntry, rowText, safeStopMessage, safeStopPlan,
9} from './lib.mjs'
10
11const PANE = 'session-board'
12const tracker = makeTracker()
13let beat = null // the heartbeat timer
14let sid = '' // this session's id, read again on every write (a /clear changes it)
15let staleId = '' // after /clear: the id the host may still answer until the new one binds
16let watching = false // /board ran and its pane is open: the heartbeat also refreshes the board
17let polls = 0 // board refreshes on the heartbeat since the last /board
18let gitBusy = false // a heartbeat git read is running
19let gen = 0 // counts turn starts: a git read that began in an earlier count is dropped when it lands
20let ended = false // the session ended: nothing is written after that
21const retired = new Set() // ids a /clear or resume left behind: a write that lands late is removed again
22let gitEvery = HEARTBEAT_MS // idle git read interval: doubles after a slow read, back to 30 s on a quick one
23let gitNextAt = 0
24let lastDetail = '' // the detail record as last written, without its stamp
25let board = null // { rows, band, note, now, hidden }
26let view = { id: null, detail: null } // id null: all-sessions view
27
28export function register(on) {
29  on('session.start', async ($, e, next) => {
30    const result = await next(e)
31    try { await startUp($) } catch (err) { note($, 'start failed: ' + (err && err.message)) } // never breaks a session
32    return result
33  })
34
35  on('session.end', async ($, e, next) => {
36    try {
37      if (e.reason !== 'clear' && e.reason !== 'resume') ended = true // first: no write lands between the deletes and the flag
38      if (isSessionId(e.sessionId)) {
39        await $.store.delete('sess:' + e.sessionId)
40        await $.store.delete('detail:' + e.sessionId)
41      }
42      if (e.reason === 'clear' || e.reason === 'resume') {
43        // The process goes on under a new id and no session.start fires. The heartbeat keeps
44        // running and reads the id again each beat. Until the new id binds the host still
45        // answers the old one: write nothing for it.
46        staleId = e.sessionId
47        if (isSessionId(e.sessionId)) retired.add(e.sessionId)
48        lastDetail = ''
49        tracker.reset()
50        await setRepo($)
51        $.clock.after(1500, () => { void (async () => { await refreshGit($); await tick($) })() })
52      } else {
53        if (beat) beat.cancel()
54        beat = null
55        tracker.reset()
56      }
57    } catch (err) { note($, 'end failed: ' + (err && err.message)) } // a killed session leaves its entry: the 2-minute rule hides it
58    return next(e)
59  })
60
61  on('turn.start', async ($, e, next) => {
62    const result = await next(e)
63    if (!e.agentId) {
64      gen++
65      tracker.turnStart()
66      await soon($, flush($, false)) // the write runs on its own; the hook waits at most GRACE_MS
67    }
68    return result
69  })
70
71  on('turn.complete', async ($, e, next) => {
72    const result = await next(e)
73    try {
74      if (!e.agentId) tracker.turnComplete(e)
75      await soon($, refreshAll($, !e.agentId))
76    } catch (err) { note($, 'turn end failed: ' + (err && err.message)) }
77    return result
78  })
79
80  // Observes only. The verdict is the rest of the chain's: an "ask" is the waiting signal.
81  on('tool.check', async ($, e, next) => {
82    const result = await next(e)
83    try {
84      const verdict = result && typeof result === 'object' ? result.decision : undefined
85      tracker.toolCheck({ tool: e.tool, input: e.input, id: e.tool_use_id, verdict })
86      if (verdict === 'ask' && e.tool_use_id) await soon($, flush($, false))
87    } catch { /* observing never changes the verdict */ }
88    return result
89  }).catch(($, e, next) => next(e)) // a failure here lets the tool run as the others decided
90
91  // The host runs the permission check inside next(): the ask is answered once next() returns.
92  on('tool.call', async ($, e, next) => {
93    try {
94      return await next(e)
95    } finally {
96      try { if (tracker.toolCall(e.tool_use_id)) await soon($, flush($, false)) } catch { /* ignore */ }
97    }
98  }).catch(($, e, next) => next(e))
99
100  on('command.run', { command: 'board' }, async ($, e) => {
101    try {
102      view = { id: null, detail: null }
103      polls = 0
104      await loadBoard($)
105      if (String(e.args).trim() === 'text') return { text: boardText() } // no pane: plain text, also for -p
106      watching = true
107      const opened = await $.ui.open({ id: PANE, title: 'Session board' })
108      $.ui.invalidate('ui.render')
109      return { text: opened && opened.isPlaced ? 'Session board opened.' : boardText() }
110    } catch (err) {
111      return { text: 'board: could not open: ' + clean(err && err.message, 100) }
112    }
113  })
114
115  on('command.run', { command: 'safe-stop' }, async ($, e) => {
116    try {
117      if (!isUserOrigin(e.origin)) return { text: 'safe-stop: ignored. Only the person starts it, not a plugin, a peer, or a schedule.' }
118      return { text: await safeStop($, e.args) }
119    } catch (err) {
120      return { text: 'safe-stop: failed, nothing sent: ' + clean(err && err.message, 100) }
121    }
122  })
123
124  // The pane closed: stop refreshing the board on the heartbeat.
125  on('ui.close', async ($, e, next) => {
126    if (e && e.id === PANE) watching = false
127    return next(e)
128  }).catch(($, e, next) => next(e))
129
130  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
131    const { Box, Text, Button } = $.ui.resolve(e)
132    return paneTree($, e, Box, Text, Button)
133  })
134}
135
136async function startUp($) {
137  tracker.reset()
138  staleId = ''
139  ended = false
140  retired.clear()
141  lastDetail = ''
142  await $.command.register({ name: 'board', description: 'Show every session on this machine: state, update-ready, agents', argumentHint: '[text]' })
143  await $.command.register({ name: 'safe-stop', description: 'Reach a stopping point and write this session\'s handoff', argumentHint: '[plan]' })
144  await setRepo($)
145  if (beat) beat.cancel()
146  beat = $.clock.every(HEARTBEAT_MS, () => { void tick($) })
147  // The first reading and the sweep of killed sessions' entries share one wait of GRACE_MS.
148  await soon($, Promise.all([refreshAll($, true), sweepAll($)]))
149}
150
151async function setRepo($) {
152  try {
153    const repo = await $.session.repo()
154    tracker.setRepo(baseName(repo && repo.root ? repo.root : await $.session.cwd()))
155  } catch { /* the repo name stays empty */ }
156}
157
158// A failure leaves one line in the debug log (claude --debug) and nothing else.
159function note($, text) {
160  try { $.ui.log('session-board: ' + clean(text, 160), { to: 'debug' }) } catch { /* nothing to do */ }
161}
162
163// One host read, at most READ_BUDGET_MS: a hung read never holds the rest.
164function bounded($, p, ms = READ_BUDGET_MS) {
165  return new Promise((resolve, reject) => {
166    const timer = $.clock.after(ms, () => reject(new Error('timed out')))
167    p.then(v => { timer.cancel(); resolve(v) }, err => { timer.cancel(); reject(err) })
168  })
169}
170
171// Git (turn end only), agents, context, then the write. Never throws.
172async function refreshAll($, withGit) {
173  if (withGit) await refreshGit($)
174  await Promise.all([refreshAgents($), refreshCtx($)])
175  await flush($, true)
176}
177
178// The work (a refresh, a store write) runs on its own. A hook waits for it at most GRACE_MS, so
179// a slow or hung read or write never holds a hook, and a quick one is done before the hook returns.
180async function soon($, work) {
181  const w = Promise.resolve(work).catch(() => {})
182  let timer
183  const grace = new Promise(resolve => { timer = $.clock.after(GRACE_MS, resolve) })
184  await Promise.race([w, grace])
185  timer.cancel()
186}
187
188// Heartbeat. While the session waits, it reads git again, so an idle session keeps a fresh
189// reading. One read at a time: a beat that finds the last read still running skips it. This
190// runs from a timer, never inside a hook. A failed read makes the counts unknown.
191async function tick($) {
192  try {
193    if (tracker.isIdle() && !gitBusy && (gitEvery <= HEARTBEAT_MS || (await $.clock.now()) >= gitNextAt)) {
194      gitBusy = true
195      try {
196        const started = await $.clock.now()
197        const { slow } = await refreshGit($)
198        // A read that needed over 5 s: wait twice as long before the next one, up to 5 minutes.
199        // A quick read: back to every beat.
200        gitEvery = slow ? Math.min(gitEvery * 2, GIT_BACKOFF_MAX_MS) : HEARTBEAT_MS
201        gitNextAt = started + gitEvery
202      } finally { gitBusy = false }
203    }
204    await refreshAll($, false)
205    if (watching) {
206      if (++polls > MAX_POLLS) watching = false // no /board call for 10 beats: stop polling
207      else await refreshBoard($)
208    }
209  } catch { /* the next beat tries again */ }
210}
211
212async function ident($) {
213  try { sid = await $.session.id() } catch { /* keep the last id */ }
214  return sid
215}
216
217const gone = id => ended || retired.has(id)
218
219// The entry always; the detail record at turn end and on a beat, only when its content changed.
220async function flush($, withDetail) {
221  if (ended) return // the session ended: a refresh that lands now writes nothing
222  try {
223    const id = await ident($)
224    if (ended) return
225    if (!isSessionId(id)) return note($, 'no usable session id: entry not written')
226    if (id === staleId) return // /clear ran and the new id is not bound yet
227    staleId = ''
228    retired.delete(id) // the host answers this id as current
229    const now = await $.clock.now()
230    if (ended) return
231    await $.store.set('sess:' + id, tracker.entry(now))
232    if (withDetail && !gone(id)) {
233      const detail = tracker.detail(now)
234      const key = id + JSON.stringify({ ...detail, at: 0 })
235      if (key !== lastDetail) {
236        await $.store.set('detail:' + id, detail)
237        lastDetail = key
238      }
239    }
240    // A write held at the host can land after the session ended or the id was retired by a
241    // /clear or resume. It recreated the records: remove them again.
242    if (gone(id)) {
243      await $.store.delete('sess:' + id)
244      await $.store.delete('detail:' + id)
245    }
246  } catch (err) { note($, 'store write failed: ' + (err && err.message)) } // the entry goes stale and hides: the safe failure
247}
248
249// One cheap git call per turn end and per idle beat. A failed or slow read makes the counts
250// unknown. A read that began before the latest turn start is dropped when it lands: it may
251// describe the tree from before the turn. Gives { slow }: the read took GIT_SLOW_MS or more.
252async function refreshGit($) {
253  const mine = gen
254  let slow = false
255  try {
256    const started = await $.clock.now()
257    const cwd = await bounded($, $.session.cwd())
258    const r = await bounded($, $.process.run(gitArgs(), { cwd, timeoutMs: GIT_TIMEOUT_MS }), GIT_TIMEOUT_MS + 1000)
259    const at = await $.clock.now()
260    slow = at - started >= GIT_SLOW_MS
261    // A quick read, from a turn end or a beat, puts the idle interval back to 30 s.
262    if (!slow) { gitEvery = HEARTBEAT_MS; gitNextAt = at + HEARTBEAT_MS }
263    if (mine !== gen) return { slow }
264    if (r.exitCode !== 0) throw new Error('git exited ' + r.exitCode)
265    tracker.setGit(parseStatus(r.stdout), at)
266  } catch (err) {
267    slow = slow || (err && err.message === 'timed out')
268    if (mine === gen) {
269      tracker.setGit(null)
270      note($, 'git read failed: ' + (err && err.message))
271    }
272  }
273  return { slow }
274}
275
276async function refreshAgents($) {
277  try { tracker.setAgents(await bounded($, $.agent.list())) } catch (err) { note($, 'agent list read failed: ' + (err && err.message)) }
278}
279
280async function refreshCtx($) {
281  try {
282    const { context } = await bounded($, $.session.usage())
283    if (context && context.window) tracker.setCtx(num(context.percent ?? ((context.tokens ?? 0) / context.window) * 100, 0, 100))
284  } catch (err) { note($, 'usage read failed: ' + (err && err.message)) }
285}
286
287// Store entries by session id, newest key first, at most MAX_KEYS. total counts every entry key,
288// read or not. old lists the ids of entries a day past their last beat: sweep() decides on those.
289async function readEntries($) {
290  const entries = new Map()
291  const old = []
292  const now = await $.clock.now()
293  const keys = (await $.store.keys()).filter(k => typeof k === 'string' && k.startsWith('sess:'))
294  const newest = keys.slice().reverse().slice(0, MAX_KEYS)
295  await Promise.all(newest.map(async k => {
296    try {
297      const e = readEntry(await $.store.get(k))
298      if (!e) return
299      if (now - e.at > GC_MS) old.push(k.slice(5))
300      else entries.set(k.slice(5), e)
301    } catch { /* one bad key hides one row */ }
302  }))
303  return { entries, old, total: keys.length, read: newest.length }
304}
305
306// Removes the records of sessions a day past their last beat, except a session that
307// `claude agents` lists as live. live: a Set of ids, or null when that list is unknown: then
308// nothing is removed. Removing this session's own detail makes the next beat write it again.
309async function sweep($, old, live) {
310  if (!live) return
311  for (const id of old) {
312    if (live.has(id)) continue
313    try {
314      await $.store.delete('sess:' + id)
315      await $.store.delete('detail:' + id)
316      if (id === sid) lastDetail = ''
317    } catch { /* the next sweep tries again */ }
318  }
319}
320
321async function sweepAll($) {
322  const [agents, store] = await Promise.all([readAgents($), readEntries($)])
323  await sweep($, store.old, agents.ok ? new Set(agents.rows.map(r => r.id)) : null)
324}
325
326// One process call to `claude agents --json`. Never fatal: the note says what is missing.
327async function readAgents($) {
328  try {
329    const r = await bounded($, $.process.run(['claude', 'agents', '--json'], { timeoutMs: AGENTS_TIMEOUT_MS }), AGENTS_TIMEOUT_MS + 1000)
330    if (r.exitCode !== 0) return { rows: [], ok: false, note: 'claude agents failed: shown from the store only' }
331    // Parse once. Anything but a JSON array (a banner line, an object, a string) is not a list:
332    // not ok, so the sweep deletes nothing. A valid [] is a real list with no live session.
333    let list
334    try { list = JSON.parse(String(r.stdout)) } catch { list = null }
335    if (!Array.isArray(list)) return { rows: [], ok: false, note: 'claude agents output not usable: shown from the store only' }
336    return { rows: agentRows(list), ok: true, note: '' }
337  } catch {
338    return { rows: [], ok: false, note: 'claude agents unavailable: shown from the store only' }
339  }
340}
341
342async function loadBoard($) {
343  const [agents, store, now] = await Promise.all([readAgents($), readEntries($), $.clock.now()])
344  const rows = mergeRows(agents.rows, store.entries, now)
345  board = { rows, band: bandText(rows), note: agents.note, now, hidden: store.total - store.read }
346  await sweep($, store.old, agents.ok ? new Set(agents.rows.map(r => r.id)) : null)
347  if (view.id) await loadDetail($, view.id, rows)
348}
349
350async function refreshBoard($) {
351  try { await loadBoard($) } catch { /* keep the last board */ }
352  $.ui.invalidate('ui.render')
353}
354
355async function loadDetail($, id, rows) {
356  const now = await $.clock.now()
357  let detail = null
358  if (id === sid) detail = tracker.detail(now)
359  else detail = readDetail(await $.store.get('detail:' + id))
360  view = { id, detail, row: rows.find(r => r.id === id) ?? null }
361}
362
363function boardText() {
364  if (!board) return 'No board yet.'
365  const hidden = board.hidden > 0 ? `+${board.hidden} more entries not read` : ''
366  return [board.band, headerText(80), ...board.rows.map(r => rowText(r, 80)), hidden, board.note].filter(Boolean).join('\n')
367}
368
369function paneTree($, e, Box, Text, Button) {
370  const cols = e.bodyColumns ?? 80
371  const line = (s, dim) => Text({ dimColor: !!dim, children: s })
372  const refresh = Button({ key: 'refresh', label: 'Refresh', hotkey: 'r', onPress: () => { void refreshBoard($) } })
373  if (!board) return Box({ flexDirection: 'column', children: [line('Loading...', true), refresh] })
374  if (view.id) {
375    const row = view.row ?? { id: view.id, repo: '?', branch: '', state: 'unknown', ask: false, ready: null, ctx: null, agents: null, detail: false }
376    const back = Button({ key: 'back', label: 'Back to all sessions', hotkey: 'b', onPress: () => { view = { id: null, detail: null }; $.ui.invalidate('ui.render') } })
377    return Box({ flexDirection: 'column', children: [...detailLines(row, view.detail, board.now).map(s => line(s)), back, refresh] })
378  }
379  const rows = board.rows.slice(0, 40).map((r, i) => Button({
380    key: 'row-' + r.id,
381    label: rowText(r, cols),
382    ...(i < 9 ? { hotkey: String(i + 1) } : {}),
383    onPress: () => { void openSession($, r.id) },
384  }))
385  const unseen = Math.max(0, board.rows.length - 40) + Math.max(0, board.hidden)
386  const more = unseen > 0 ? [line(`+${unseen} more not shown`, true)] : []
387  return Box({
388    flexDirection: 'column',
389    children: [line(board.band), line(headerText(cols), true), ...rows, ...more, ...(board.note ? [line(board.note, true)] : []), line('Press a number or a row to open one session.', true), refresh],
390  })
391}
392
393async function openSession($, id) {
394  try { await loadDetail($, id, board ? board.rows : []) } catch { view = { id, detail: null, row: null } }
395  $.ui.invalidate('ui.render')
396}
397
398// Python that reuses the installed handoff hook's own path functions, so no second copy of
399// the handoff path rules lives here. argv: hooks folder, session id, mode ("plan" makes no folder).
400const HANDOFF_PY = [
401  'import sys, json',
402  'sys.path.append(sys.argv[1])',
403  'import precompact_handoff as p',
404  'if sys.argv[3] != "plan": p.ensure_handoff_dir(sys.argv[2])',
405  'print(json.dumps({"handoff": p.handoff_path(sys.argv[2]), "prompt": p.prompt_path()}))',
406].join('\n')
407
408async function safeStop($, args) {
409  const plan = String(args ?? '').trim() === 'plan'
410  const id = await ident($)
411  if (!isSessionId(id)) return safeStopPlan(null, ['this session id'])
412  const env = { CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'), USERPROFILE: await $.env.get('USERPROFILE'), HOME: await $.env.get('HOME') }
413  // A drive-letter USERPROFILE means Windows; the mod has no platform call.
414  const win = /^[A-Za-z]:[\\/]/.test(env.USERPROFILE || '')
415  const config = configDir(env, win ? 'win32' : 'posix')
416  if (!config) return safeStopPlan(null, ['the config folder (set CLAUDE_CONFIG_DIR, HOME, or USERPROFILE)'])
417  const hooks = config + (win ? '\\' : '/') + 'hooks'
418  const why = []
419  let paths = null
420  for (const py of ['python3', 'python']) {
421    try {
422      // -I: no current-folder or user-site imports. cwd is the config folder, never the session's:
423      // a json.py or datetime.py in a repo must not run.
424      const r = await $.process.run([py, '-I', '-B', '-c', HANDOFF_PY, hooks, id, plan ? 'plan' : 'run'], { cwd: config, timeoutMs: 10_000 })
425      const o = r.exitCode === 0 ? JSON.parse(String(r.stdout).trim().split('\n').pop()) : null
426      if (o && typeof o.handoff === 'string' && typeof o.prompt === 'string') { paths = o; break }
427      why.push(`${py}: ${clean(String(r.stderr).trim().split('\n').pop(), 100) || 'no usable output'}`)
428    } catch (err) {
429      why.push(`${py}: ${clean(err && err.message, 100)}`)
430    }
431  }
432  if (!paths) return safeStopPlan(null, [`python with ${hooks}${win ? '\\' : '/'}precompact_handoff.py (${why.join('; ')})`])
433  for (const p of [paths.handoff, paths.prompt]) {
434    // A path is shown and sent whole or not at all: never a cut path.
435    if (Array.from(p).length > MAX_PATH) return `Safe stop: a path from the handoff hook is over ${MAX_PATH} characters. Nothing was sent.`
436  }
437  const handoff = cleanPath(paths.handoff)
438  const text = safeStopMessage(cleanPath(paths.prompt), handoff)
439  if (plan) return safeStopPlan(text, [])
440  try {
441    const sent = await $.prompt.submit({ text })
442    if (sent && sent.drop) throw new Error('dropped: ' + clean(sent.drop, 80))
443  } catch (err) {
444    return 'Safe stop: could not send (' + clean(err && err.message, 100) + '). Nothing was sent.'
445  }
446  return 'Safe stop sent. The prompt is queued until this session is idle. It then rewrites its handoff at ' + handoff + '.'
447}
448
hooks/lib.mjs 396 lines
1// Pure helpers for session-board. No host calls here, so node can test them.
2
3export const STALE_MS = 120_000
4export const HEARTBEAT_MS = 30_000
5export const ENTRY_MAX_BYTES = 1024
6export const DETAIL_MAX_BYTES = 3072
7export const MAX_KEYS = 200 // entries read per refresh, newest key first
8export const GC_MS = 86_400_000 // an entry this long past its last beat is deleted
9export const SKEW_MS = 30_000 // a stamp further ahead than this is not fresh
10export const READ_MAX_AGE_MS = 60_000 // a git reading older than this is unknown
11export const MAX_POLLS = 10 // board refreshes on the heartbeat with no /board call
12export const GRACE_MS = 100 // most a hook waits for its refresh
13export const READ_BUDGET_MS = 3000 // most one host read may take
14export const MAX_PATH = 4000
15export const TO_MAX = 300 // a session name is kept and matched to this length (review round 2 of judge-scope), never the 40-char display cap
16export const GIT_SLOW_MS = 5000 // a git read this slow makes the idle beat back off
17export const GIT_BACKOFF_MAX_MS = 300_000 // the idle git read waits at most 5 minutes between reads
18export const MAX_RECENT = 5
19export const MAX_AGENTS = 8
20export const MAX_SPAWN_TYPES = 6
21export const MAX_FILES = 2000
22export const AGENTS_TIMEOUT_MS = 8000
23export const GIT_TIMEOUT_MS = 5000
24const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
25const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Glob', 'Grep'])
26const SPAWN_TOOLS = new Set(['Agent', 'Task'])
27const LIVE = new Set(['pending', 'running', 'waiting'])
28// Runs of /safe-stop that do not come from the person. Not a list of who may run it: the
29// desktop app can arrive as "unclassified", so only these are refused.
30const NON_USER_ORIGINS = new Set(['plugin', 'peer', 'peer-send-message', 'projects-relay', 'channel', 'coordinator', 'observer', 'observer-activity', 'task-notification', 'scheduled-trigger', 'auto-continuation'])
31export const isUserOrigin = origin => !(origin && typeof origin === 'object' && NON_USER_ORIGINS.has(origin.kind))
32
33// Plain text for anything shown. Control, format and bidi characters become spaces. Markup
34// characters become "_". Long runs of combining marks go. The result is short and inert.
35export function clean(v, max = 40) {
36  let s = typeof v === 'string' ? v : v == null ? '' : String(v)
37  s = s.replace(/[\p{C}]/gu, ' ').replace(/[<>&"`]/g, '_')
38  s = s.replace(/\p{M}{3,}/gu, '').replace(/\s+/g, ' ').trim()
39  const chars = Array.from(s)
40  return chars.length > max ? chars.slice(0, Math.max(0, max - 1)).join('') + '…' : s
41}
42
43// A path or file path shown or sent whole: control and bidi characters go, nothing else changes.
44// Not for names on the board: those go through clean().
45export function cleanPath(v) {
46  const s = typeof v === 'string' ? v : ''
47  return Array.from(s.replace(/\p{C}/gu, '')).slice(0, MAX_PATH).join('')
48}
49
50// A whole number in [lo, hi], else null.
51export const num = (v, lo = 0, hi = 999_999) =>
52  typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.round(v))) : null
53
54export const baseName = p => String(p ?? '').split(/[\\/]/).filter(Boolean).pop() ?? ''
55// UTF-8 size of the JSON text. The mod runs where there is no Node, so no Buffer.
56export function byteLen(o) {
57  let n = 0
58  for (const ch of JSON.stringify(o)) {
59    const c = ch.codePointAt(0)
60    n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4
61  }
62  return n
63}
64const isId = v => typeof v === 'string' && /^[0-9a-zA-Z_-]{8,64}$/.test(v)
65export const isSessionId = isId
66
67// The one git call per turn. Hooks, the optional lock and fsmonitor stay off.
68export const gitArgs = () => ['git', '-c', 'core.fsmonitor=false', '--no-optional-locks', 'status', '--porcelain=v2', '--branch']
69
70// `git status --porcelain=v2 --branch`. unpushed is null when git gives no ahead count
71// (no upstream): unknown, never zero.
72export function parseStatus(text) {
73  let branch = ''
74  let ahead = null
75  let dirty = 0
76  for (const line of String(text ?? '').split('\n')) {
77    if (line.startsWith('# branch.head ')) branch = line.slice(14).trim()
78    else if (line.startsWith('# branch.ab ')) {
79      const m = line.match(/\+(\d+) -(\d+)/)
80      if (m) ahead = Number(m[1])
81    } else if (line && line[0] !== '#') dirty++
82  }
83  return { branch: branch === '(detached)' ? 'detached' : branch, dirty: Math.min(dirty, 9999), unpushed: ahead === null ? null : Math.min(ahead, 9999) }
84}
85
86// Names that say they hold secrets. The same rule as isSecretPath in mods/plain-diff, copied
87// on purpose: each mod stands alone and neither imports the other.
88export function isSecretName(name) {
89  const b = String(name).replace(/\\/g, '/').split('/').pop().toLowerCase()
90  return (
91    (/^\.env(\..*)?$/.test(b) && !/\.(example|sample|template|dist)$/.test(b)) ||
92    /\.(env|pem|key|p12|pfx|jks|keystore|ppk|asc|tfvars)$/.test(b) ||
93    /^\.(envrc|npmrc|netrc|pgpass|pypirc|htpasswd|git-credentials)$/.test(b) ||
94    b === '_netrc' ||
95    /^id_(rsa|dsa|ecdsa|ed25519)$/.test(b) ||
96    /^(application_default_)?credentials(\.(json|ya?ml|toml|ini|xml|csv|txt))?$/.test(b) ||
97    /^secrets?\.(json|ya?ml|md|txt|env|toml)$/.test(b) ||
98    /^service-account.*\.json$/.test(b)
99  )
100}
101
102// What one tool call adds to the recent list: the tool name and, for a file tool only, a file
103// name. An MCP tool, a Bash command and any tool off the list give no name. A Grep or Glob path
104// counts only when its last part has an extension: a folder path never shows.
105// ponytail: a folder named like a file (v1.2) still passes. Upgrade: ask the file system.
106export function toolTarget(tool, input) {
107  const i = input && typeof input === 'object' ? input : {}
108  const out = { t: clean(tool, 24), f: '', edit: EDIT_TOOLS.has(tool), path: '' }
109  if (!FILE_TOOLS.has(tool)) return out
110  const path = tool === 'NotebookEdit' ? i.notebook_path : tool === 'Glob' || tool === 'Grep' ? i.path : i.file_path
111  if (typeof path !== 'string' || !path) return out
112  const name = baseName(path)
113  if ((tool === 'Glob' || tool === 'Grep') && !/\.[^.\s]+$/.test(name)) return out
114  out.f = isSecretName(name) ? '(secret file)' : clean(name, 40)
115  if (out.edit) out.path = path
116  return out
117}
118
119export const spawnType = input => clean(input && typeof input === 'object' ? input.subagent_type || 'general-purpose' : 'general-purpose', 24)
120
121// Spawn counts as a plain object, from a Map or an object. A type named like an
122// Object.prototype key (constructor, __proto__) is an own key here, never the inherited one.
123const put = (o, k, n) => Object.defineProperty(o, k, { value: n, enumerable: true, writable: true, configurable: true })
124function capSpawns(spawns) {
125  const out = {}
126  const sums = new Map()
127  for (const [k, n] of spawns instanceof Map ? spawns : Object.entries(spawns ?? {})) {
128    const key = clean(k, 24)
129    const count = num(n, 0, 9999) ?? 0
130    if (key && count > 0) sums.set(key, (sums.get(key) ?? 0) + count)
131  }
132  let other = 0
133  ;[...sums].sort((a, b) => b[1] - a[1]).forEach(([k, n], i) => {
134    if (i < MAX_SPAWN_TYPES) put(out, k, n)
135    else other += n
136  })
137  if (other) put(out, 'other', (Object.prototype.hasOwnProperty.call(out, 'other') ? out.other : 0) + other)
138  return out
139}
140
141// Shrink until the JSON fits the cap: spawn types first, then the long text fields.
142function fit(obj, cap, steps) {
143  for (const step of steps) {
144    if (byteLen(obj) <= cap) return obj
145    step(obj)
146  }
147  return byteLen(obj) <= cap ? obj : { v: 1, at: obj.at }
148}
149const half = (o, k) => { o[k] = Array.from(o[k]).slice(0, Math.max(1, Math.floor(Array.from(o[k]).length / 2))).join('') }
150
151// The `sess:<id>` entry. Under 1 KB. Repo name and branch only: no prompt, no path.
152export function buildEntry(s) {
153  const e = {
154    v: 1,
155    repo: clean(s.repo, 40),
156    branch: clean(s.branch, 60),
157    state: s.ask || !s.working ? 'waiting' : 'working',
158    ask: !!s.ask,
159    dirty: num(s.dirty, 0, 9999),
160    unpushed: num(s.unpushed, 0, 9999),
161    agents: num(s.agents, 0, 99) ?? 0,
162    spawns: capSpawns(s.spawns),
163    ctx: num(s.ctx, 0, 100),
164    at: s.now,
165  }
166  // When the git counts were read. Absent for an entry that has no reading time.
167  if (num(s.rt, 0, 8.64e15) !== null) e.rt = num(s.rt, 0, 8.64e15)
168  const dropSpawn = o => { const k = Object.keys(o.spawns).pop(); if (k) delete o.spawns[k] }
169  return fit(e, ENTRY_MAX_BYTES, [...Array(MAX_SPAWN_TYPES + 1).fill(dropSpawn), o => half(o, 'branch'), o => half(o, 'branch'), o => half(o, 'repo'), o => half(o, 'repo'), o => half(o, 'repo')])
170}
171
172// The `detail:<id>` record. About 3 KB. File names only.
173export function buildDetail(s) {
174  const d = {
175    v: 1,
176    at: s.now,
177    recent: (s.recent ?? []).slice(-MAX_RECENT).map(r => ({ t: clean(r.t, 24), f: clean(r.f, 40) })),
178    files: num(s.files, 0, 99_999) ?? 0,
179    agents: (s.agents ?? []).slice(0, MAX_AGENTS).map(a => ({ t: clean(a.type, 24), s: clean(a.status, 12) })),
180    spawns: capSpawns(s.spawns),
181    perm: { ask: num(s.perm?.ask, 0, 99_999) ?? 0, deny: num(s.perm?.deny, 0, 99_999) ?? 0, wait: num(s.perm?.wait, 0, 99) ?? 0 },
182    receipt: s.receipt ? { sec: num(s.receipt.sec, 0, 999_999) ?? 0, tools: num(s.receipt.tools, 0, 99_999) ?? 0, edits: num(s.receipt.edits, 0, 99_999) ?? 0, asks: num(s.receipt.asks, 0, 99_999) ?? 0, aborted: !!s.receipt.aborted } : null,
183  }
184  return fit(d, DETAIL_MAX_BYTES, [o => { o.agents.length = Math.min(o.agents.length, 3) }, o => { o.recent = o.recent.slice(-2) }, o => { o.agents = [] }, o => { o.recent = [] }, o => { o.spawns = {} }])
185}
186
187// Read back from the store: another writer on the machine may have put anything there.
188export function readEntry(raw) {
189  if (!raw || typeof raw !== 'object' || raw.v !== 1) return null
190  const at = num(raw.at, 0, 8.64e15)
191  if (at === null) return null
192  const spawns = capSpawns(raw.spawns && typeof raw.spawns === 'object' ? raw.spawns : {})
193  const e = {
194    repo: clean(raw.repo, 40),
195    branch: clean(raw.branch, 60),
196    // A missing or odd state is unknown, never waiting: waiting would let it count as ready.
197    state: raw.state === 'working' || raw.state === 'waiting' ? raw.state : 'unknown',
198    ask: raw.ask === true,
199    dirty: num(raw.dirty, 0, 9999),
200    unpushed: num(raw.unpushed, 0, 9999),
201    agents: num(raw.agents, 0, 99) ?? 0,
202    spawns,
203    ctx: num(raw.ctx, 0, 100),
204    at,
205  }
206  const rt = num(raw.rt, 0, 8.64e15)
207  if (rt !== null) e.rt = rt
208  return e
209}
210export function readDetail(raw) {
211  if (!raw || typeof raw !== 'object' || raw.v !== 1) return null
212  return buildDetail({
213    now: num(raw.at, 0, 8.64e15) ?? 0,
214    recent: Array.isArray(raw.recent) ? raw.recent.filter(r => r && typeof r === 'object').map(r => ({ t: r.t, f: r.f })) : [],
215    files: raw.files,
216    agents: Array.isArray(raw.agents) ? raw.agents.filter(a => a && typeof a === 'object').map(a => ({ type: a.t, status: a.s })) : [],
217    spawns: raw.spawns && typeof raw.spawns === 'object' ? raw.spawns : {},
218    perm: raw.perm && typeof raw.perm === 'object' ? raw.perm : {},
219    receipt: raw.receipt && typeof raw.receipt === 'object' ? raw.receipt : null,
220  })
221}
222
223// Hidden after 2 minutes without a heartbeat. A stamp more than 30 s ahead of now is not fresh.
224export const isFresh = (entry, now) => !!entry && entry.at <= now + SKEW_MS && now - entry.at <= STALE_MS
225
226// Update-ready: no running agents, waiting, no permission ask, no unpushed commits, clean tree.
227// true, false, or 'unknown' when a git count is missing or its reading (e.rt) is older than
228// 60 s or stamped ahead. Only true counts as ready. Without `now`, the reading time is not checked.
229export function readiness(e, now) {
230  if (!e || e.agents !== 0 || e.state !== 'waiting' || e.ask) return false
231  if (e.dirty === null || e.unpushed === null) return 'unknown'
232  if (typeof e.rt === 'number' && typeof now === 'number' && (now - e.rt > READ_MAX_AGE_MS || e.rt - now > SKEW_MS)) return 'unknown'
233  return e.dirty === 0 && e.unpushed === 0
234}
235export const isUpdateReady = (e, now) => readiness(e, now) === true
236
237// `claude agents --json` text to rows. Anything else gives []. A "waiting" row is a session
238// that waits on a permission answer.
239export function parseAgentsJson(text) {
240  let list
241  try { list = JSON.parse(text) } catch { return [] }
242  return agentRows(list)
243}
244
245// Rows from an already parsed `claude agents --json` list. Not an array gives [].
246export function agentRows(list) {
247  if (!Array.isArray(list)) return []
248  const state = s => (s === 'busy' ? 'working' : s === 'idle' || s === 'waiting' ? 'waiting' : 'unknown')
249  return list
250    .filter(r => r && typeof r === 'object' && isId(r.sessionId))
251    .map(r => ({ id: r.sessionId, name: typeof r.name === 'string' ? clean(r.name, TO_MAX) : '', repo: clean(baseName(r.cwd), 40), state: state(r.status), ask: r.status === 'waiting' }))
252}
253
254// The `claude agents` state of the one row named `name`: busy, idle or waiting (on a permission
255// answer). 'unseen' when no row, or more than one row, has that name, or its state is unknown.
256export function receiverState(rows, name) {
257  const find = n => (rows ?? []).filter(r => n && r.name === n)
258  const full = typeof name === 'string' ? clean(name, TO_MAX) : ''
259  let hits = find(full)
260  // ListAgents prints `name [ref]` when names clash. Only when the full name matches nothing, drop the ref.
261  if (!hits.length && typeof name === 'string') hits = find(clean(name.replace(/ \[[^\]]*\]$/, ''), TO_MAX))
262  if (hits.length !== 1) return 'unseen'
263  const r = hits[0]
264  return r.state === 'working' ? 'busy' : r.state !== 'waiting' ? 'unseen' : r.ask ? 'waiting' : 'idle'
265}
266
267// Rows for the board: `claude agents` rows seeded first, then fresh store entries merged in by
268// session id. A session without an entry shows as "no detail". entries: Map id -> readEntry().
269export function mergeRows(agentRows, entries, now) {
270  const rows = new Map()
271  for (const a of agentRows ?? []) rows.set(a.id, { id: a.id, repo: a.repo, branch: '', state: a.state, ask: !!a.ask, ready: null, ctx: null, agents: null, detail: false })
272  for (const [id, e] of entries ?? []) {
273    if (!isFresh(e, now) || !isId(id)) continue
274    rows.set(id, { id, repo: e.repo, branch: e.branch, state: e.state, ask: e.ask, ready: readiness(e, now), ctx: e.ctx, agents: e.agents, detail: true, at: e.at })
275  }
276  const order = { working: 0, waiting: 1, unknown: 2 }
277  return [...rows.values()].sort((a, b) => order[a.state] - order[b.state] || a.repo.localeCompare(b.repo) || a.branch.localeCompare(b.branch) || a.id.localeCompare(b.id))
278}
279
280export function bandText(rows) {
281  if (!rows.length) return 'No sessions found'
282  const n = s => rows.filter(r => r.state === s).length
283  const parts = [`${n('working')} working`, `${n('waiting')} waiting`, `update-ready ${rows.filter(r => r.ready === true).length} of ${rows.length}`]
284  if (n('unknown')) parts.push(`${n('unknown')} unknown`)
285  const blind = rows.filter(r => !r.detail).length
286  if (blind) parts.push(`${blind} no detail`)
287  return parts.join(', ')
288}
289
290const pad = (s, n) => { const c = Array.from(s); return c.length > n ? c.slice(0, n - 1).join('') + '…' : s + ' '.repeat(n - c.length) }
291const stateLabel = r => (r.state === 'waiting' && r.ask ? 'permission' : r.state)
292const readyLabel = r => (r.ready === null ? 'no detail' : r.ready === 'unknown' ? 'unknown' : r.ready ? 'ready' : 'not ready')
293function widths(cols) {
294  const c = num(cols, 40, 400) ?? 80
295  return { repo: 18, branch: Math.max(8, Math.min(24, c - 52)), state: 10, ready: 9 }
296}
297export function headerText(cols) {
298  const w = widths(cols)
299  return [pad('repo', w.repo), pad('branch', w.branch), pad('state', w.state), pad('update', w.ready), pad('ctx', 4), 'ag'].join(' ')
300}
301export function rowText(r, cols) {
302  const w = widths(cols)
303  return [pad(r.repo, w.repo), pad(r.branch || '-', w.branch), pad(stateLabel(r), w.state), pad(readyLabel(r), w.ready), pad(r.ctx === null ? '-' : `${r.ctx}%`, 4), r.agents === null ? '-' : String(r.agents)].join(' ')
304}
305
306export const ago = (now, at) => {
307  const s = Math.max(0, Math.round((now - at) / 1000))
308  return s < 90 ? `${s} s ago` : `${Math.round(s / 60)} min ago`
309}
310
311// Lines for the per-session view. row: a mergeRows row; detail: readDetail() or null.
312export function detailLines(row, detail, now) {
313  const out = [`${clean(row.repo, 24)}  ${clean(row.branch, 32) || '-'}  ${stateLabel(row)}  ${readyLabel(row)}`]
314  if (!row.detail || !detail) {
315    out.push(row.detail ? 'Detail record not found yet.' : 'No detail: this session does not run the session-board mod.')
316    return out
317  }
318  const spawns = Object.entries(detail.spawns).map(([k, n]) => `${k} ${n}`).join(', ')
319  out.push(`Context ${row.ctx === null ? '-' : row.ctx + '%'}   Files touched ${detail.files}   Updated ${ago(now, row.at ?? detail.at)}`)
320  out.push(`Agents running ${row.agents ?? 0}: ${detail.agents.map(a => `${a.t} ${a.s}`).join(', ') || 'none'}`)
321  out.push(`Spawned: ${spawns || 'none'}`)
322  out.push(`Permission checks: ${detail.perm.ask} asked, ${detail.perm.deny} denied, ${detail.perm.wait} waiting now`)
323  out.push(`Recent: ${detail.recent.map(r => (r.f ? `${r.t} ${r.f}` : r.t)).join(' | ') || 'none'}`)
324  const r = detail.receipt
325  out.push(r ? `Last turn: ${r.sec} s, ${r.tools} tool calls, ${r.edits} edits, ${r.asks} permission checks${r.aborted ? ', stopped early' : ''}` : 'Last turn: none yet')
326  return out
327}
328
329// The Claude config folder: CLAUDE_CONFIG_DIR, else the home folder plus .claude. On win32 the
330// home folder is USERPROFILE before HOME: under Git Bash HOME is an MSYS path (/c/Users/x) that
331// a Windows program cannot use. env holds CLAUDE_CONFIG_DIR, USERPROFILE and HOME, as set.
332export function configDir(env, platform) {
333  const e = env ?? {}
334  if (e.CLAUDE_CONFIG_DIR) return e.CLAUDE_CONFIG_DIR
335  const win = platform === 'win32'
336  const home = win ? e.USERPROFILE || e.HOME : e.HOME || e.USERPROFILE
337  return home ? home + (win ? '\\' : '/') + '.claude' : ''
338}
339
340// /safe-stop. The text goes to the session as a prompt. The handoff hook's own prompt file holds
341// the rules for the file itself. No handoff writer lives in this mod.
342export const safeStopMessage = (prompt, handoff) =>
343  `Safe stop. Finish or park the step you are on. Start no new work and no new agent. Then follow ${prompt} and rewrite your handoff at ${handoff}: the whole file, never append. When done, reply with one line: branch, uncommitted files, unpushed commits, running agents.`
344
345export function safeStopPlan(text, missing) {
346  const steps = ["/safe-stop would: (1) ask the installed handoff hook (hooks/precompact_handoff.py) for this session's handoff and prompt paths; (2) send this session one prompt."]
347  if (text) steps.push('Prompt: ' + text)
348  if (missing.length) steps.push('Missing: ' + missing.join('; ') + '. Nothing was sent.')
349  else steps.push('Plan only: nothing was sent.')
350  return steps.join('\n')
351}
352
353// One session's running record. The hooks feed it; entry() and detail() read it.
354export function makeTracker() {
355  let t
356  const reset = () => {
357    t = { inTurn: false, pending: new Set(), spawns: new Map(), recent: [], files: new Set(), ask: 0, deny: 0, turn: { tools: 0, edits: 0, asks: 0 }, receipt: null, git: { branch: '', dirty: null, unpushed: null, rt: null }, agents: [], ctx: null, repo: '' }
358  }
359  reset()
360  const live = () => t.agents.filter(a => LIVE.has(a.status))
361  return {
362    reset,
363    isIdle: () => !t.inTurn,
364    turnStart() { t.inTurn = true; t.pending.clear(); t.turn = { tools: 0, edits: 0, asks: 0 } },
365    turnComplete({ durationMs, isAborted }) {
366      t.inTurn = false
367      t.pending.clear()
368      t.receipt = { sec: Math.round((durationMs ?? 0) / 1000), ...t.turn, aborted: !!isAborted }
369    },
370    // verdict: what the rest of the chain decided: allow, ask or deny. A query has no id: skipped.
371    toolCheck({ tool, input, id, verdict }) {
372      if (!id) return
373      const g = toolTarget(tool, input)
374      t.recent.push({ t: g.t, f: g.f })
375      t.recent = t.recent.slice(-MAX_RECENT)
376      t.turn.tools++
377      if (verdict === 'deny') { t.deny++; return }
378      if (g.edit) { t.turn.edits++; if (g.path && t.files.size < MAX_FILES) t.files.add(g.path) }
379      if (SPAWN_TOOLS.has(tool)) { const k = spawnType(input); t.spawns.set(k, (t.spawns.get(k) ?? 0) + 1) }
380      if (verdict === 'ask') { t.ask++; t.turn.asks++; t.pending.add(id) }
381    },
382    toolCall(id) { return t.pending.delete(id) },
383    // g: parseStatus() output read at time `at`; null: the read failed, so the counts are unknown.
384    setGit(g, at) { t.git = g ? { ...g, rt: at ?? null } : { branch: t.git.branch, dirty: null, unpushed: null, rt: null } },
385    setAgents(list) { t.agents = (list ?? []).map(a => ({ type: a.type, status: a.status })) },
386    setCtx(p) { t.ctx = p },
387    setRepo(r) { t.repo = r },
388    entry(now) {
389      return buildEntry({ repo: t.repo, branch: t.git.branch, working: t.inTurn, ask: t.pending.size > 0, dirty: t.git.dirty, unpushed: t.git.unpushed, agents: live().length, spawns: t.spawns, ctx: t.ctx, rt: t.git.rt, now })
390    },
391    detail(now) {
392      return buildDetail({ now, recent: t.recent, files: t.files.size, agents: live(), spawns: t.spawns, perm: { ask: t.ask, deny: t.deny, wait: t.pending.size }, receipt: t.receipt })
393    },
394  }
395}
396