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.

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.
The command opens a pane with two levels.
All sessions: one band, then one row per session.
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.
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.
claude agents --json seeds the list. One call per refresh, 8 s timeout. A session without this mod shows as "no detail".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.detail:<sessionId>, under about 3 KB, for the one-session view. The mod writes it again only when its content changes./board, unless claude agents lists that session as live. If the list is not available, nothing is deleted./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.core.fsmonitor=false, --no-optional-locks, 5 s timeout.$.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./board call.mods/plain-diff. Examples are .env, *.pem, id_rsa, and credentials.json. .env.example and credentials.ts keep their names.< > & " backtick are removed or replaced. Long names are cut. The mod reads store values back through the same check.It asks this session to reach a stopping point and write its handoff.
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.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.
$.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./safe-stop).-p run.Checked: the session id in claude agents --json equals the id the mod writes. Two live sessions merged into one row each.
--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.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./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.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.claude --debug), one line starting "session-board:"./plugin install session-board --marketplace shivinate7/claude-settings, then restart the session./board. Check that the pane draws, that rows open, and that a session without the mod shows "no detail"./safe-stop plan, then /safe-stop. Check that the handoff file changes.node mods/session-board/session-board.test.mjs runs the tests (stdlib only).claude plugin validate mods/session-board checks the mod.claude --plugin-dir mods/session-board, then /board.hooks/register.mjs 448 lines1// 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}
448hooks/lib.mjs 396 lines1// 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