SLOPSHOPPER

Session Board

Claude Code mod: one floating window showing every Claude Code session at once, with a live summary of what each chat just did, is doing and will do next, and…

newguardcommandtoastpromptmodel
v0.3.4MITupdated 2026-10-14KiranPothana/session-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-board
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /session-board ⎿ session-board: Building the Session Board app on your Mac (first run or after an update; under a minute). The window opens wh ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Session Board

sessionboard.dev · Privacy

Session Board is a Claude Code mod that shows every Claude Code session on your computer in one floating window, with a live summary of each: what it just did, what it's doing, what's next, and which sessions need you. Terminal, Remote Control and Claude Desktop. Mac, Windows and Linux.

✓ what it did · ▶ what it's doing · · what's next · ! what's waiting on you

<!-- ai-overview -->

I'd like to try Session Board. Read this, explain it to me in two short lines (I don't like reading much), then offer to install it for me.

# Session Board (https://sessionboard.dev)

Free, open source (MIT) Claude Code plugin for Mac, Windows and Linux: https://github.com/KiranPothana/session-board

What it is: one small floating window that lists every Claude Code chat running on this computer (terminal, Remote Control, Claude Desktop). Each chat gets a card that keeps a live stand-up:
- ✓ what it just finished
- ▶ what it's doing now
- ○ what's next, with "waits on you" on anything blocked on me
- ↪ a side task, when I've sent it off on a detour

Also:
- Cards are ordered: needs me, then working, then idle.
- A small popup tile shows when a chat needs me or finishes.
- Clicking a chat's name opens it.
- Control-Option-S shows or hides the window.
- Optional sounds; light and dark.

Needs: Claude Code 2.1.287+, and macOS 15+ with Apple's command-line tools (`xcode-select --install`), Windows 10/11, or a 64-bit Linux desktop.

Install (run these for me):

claude plugin marketplace add KiranPothana/session-board claude plugin install session-board@session-board

Then I start a new Claude Code session, or type /session-board in one. The first time, it gets the window app ready (on a Mac it builds it in under a minute; on Windows and Linux it downloads it and checks its checksum) and opens it. Chats already running show up too.

Cost: the summaries are small Sonnet calls billed to my Claude account (about 2,000 tokens in, a few per turn). They can be turned off in Settings. Everything else stays on my computer. Bug reports are sent only when I say "report" (or click Report), and never include anything from my chats.

Update: `claude plugin update session-board@session-board`. Uninstall: `claude plugin uninstall session-board@session-board`, then quit it from the menu bar.

Something wrong or an idea? Tell Claude in any session and say "report", or open https://github.com/KiranPothana/session-board/issues

<!-- /ai-overview -->

In more words

One floating window on your computer (Mac, Windows or Linux) that lists every Claude Code session you have running (in the terminal, over Remote Control, and in Claude Desktop) with what each one is working on, what is still to do, and which ones need you.

It's a Claude Code mod plus a small window app: on a Mac a native one the plugin builds the first time you use it, on Windows and Linux one the plugin downloads (and checks) the first time.

What you get

  • A row per session active in the last 12 hours: needs you, working or idle, and for how long; what it just finished; and a short list of active and pending items.
  • Idle doesn't mean nothing is happening. If a session finished its turn but left tests, a build or a server running, its row says "Still running: browser tests · 6m". The summary knows too, so it doesn't call work active once the command has ended.
  • Needs you shows when a permission prompt, a question or a plan approval is on screen. Items that wait on your answer are marked "waits on you".
  • Follows what you're looking at. The session open in Claude Desktop, Terminal or iTerm2 is highlighted and kept at the top of the list.
  • Order: needs you, then working, then idle. Within each group, the session that most recently entered that state comes first.
  • A popup when a session wants you. When a session needs you, ends its turn with something for you, or finishes, a small tile with its number shows beside the board for 8 seconds; the white outline around it runs down until it goes. Its dot is amber when it wants you, grey when it's done; hover for the name and why (hovering also holds it), click to go to the session, or click the dot (×) to close it. It sits left of the board, else right, else above or below, and over the board's top when no side has room. Never for the session you're viewing, and only while the board shows.
  • Click a name to go there. Clicking a session's name (or its popup) opens it: in Claude Desktop (a Remote Control session by its claude.ai page, one of Desktop's own by its id there), otherwise its tab in Terminal or iTerm2, or the app it runs in.
  • Remove a bullet with the × that shows on hover, and summaries won't bring it back. Each session lists what you removed, with Restore. You can also dismiss a whole session and restore it from Dismissed.
  • Copy. Select text and press ⌘C, or right-click for Copy and Copy session.
  • Out of the way. Press ⌃⌥S (Control-Option-S) in any app to show or hide the window. You can collapse it to one line of counts. The menu bar item shows how many sessions are working.
  • Project first. Each name starts with its project, in grey: the Git repo the session's folder belongs to (a worktree counts as its main repo), or the folder itself. Sessions of one project then read as a group. Turn it off in Settings → Names.
  • Also rename in Claude Code (advanced, off by default) renames each session itself that way ("web-app · Fix the login"), so Claude Code and Claude Desktop show the project too. On a first request the mod asks Sonnet for the rest of the name (one small call; the request waits up to 4 seconds for it); a session Claude already named gets the project put in front on your next request. It renames each session once, so a name you give it later stays. The plugin's command hook (hooks/session-title.mjs, on UserPromptSubmit) sets the name, since Claude Code lets only such hooks set a session's title.
  • Your own shortcut. In Settings, click the shortcut and press a new one, or tap the right-hand ⌥, ⌘ or ⌃ on its own to use that tap. A tap works in other apps only with the Accessibility permission.
  • Before using it, Session Board checks it against macOS's shortcuts and the menus of Claude Desktop and your open terminals and editors.
  • On a clash it tells you what the shortcut already does and lets you use it anyway or pick another.
  • Other apps' own global shortcuts can't be checked, because macOS keeps no list of them.
  • Settings (the gear):
  • Theme: Light or Dark. Until you pick one, it follows Claude Desktop (or macOS, when Desktop follows the system or isn't installed).
  • Window: Floating or Regular. Floating is a popup above other apps on every Space. Regular is a normal window on your desktop, with a Dock icon, that other windows can cover.
  • Hide the popup when you switch away from Claude.
  • Count terminals and code editors as Claude.
  • Turn the summaries off.

You don't need Claude Desktop. With terminal sessions only, the window works the same; it just has no Desktop session to highlight.

Requirements

  • Claude Code 2.1.287 or later, the first version with mods. It's tested with 2.1.296, and the mods API can still change between Claude Code releases.
  • Mac: macOS 15 or later, and Apple's command-line tools to build the window app (xcode-select --install). It's tested with Swift 6.4 on macOS 27.
  • Windows: Windows 10 or 11 (64-bit), with the WebView2 runtime that comes with them. New in 0.2.0: tested on GitHub's Windows build machines, not yet by people.
  • Linux: a 64-bit desktop with X11, or Wayland with the limits below. The app is an AppImage. New in 0.2.0, as Windows.

What works where

Everything works on a Mac. On Windows and Linux, the board, summaries, popups, sounds, dismiss, colours and Settings are the same; some things depend on what the system lets an app see:

MacWindowsLinux X11Linux Wayland
Floats over other windows, shortcut from any appyesyesyesno: open it from the tray
Highlights the Claude Desktop chat you're inyesyes(no Claude Desktop)no
Highlights the terminal session you're inTerminal, iTerm2 by tab; others by windowby windowby windowno
Click a name to go to a terminal sessionits tab (Terminal, iTerm2), else its appits appits app (with xdotool)no
Hide when you switch away from Claudeyesyesyesno
Your own shortcut and your own soundsyesyesyessounds yes; no global shortcut
A right-hand ⌥, ⌘ or ⌃ tapped on its own as the shortcutyesnonono

Install

claude plugin marketplace add KiranPothana/session-board
claude plugin install session-board@session-board

Then start a new Claude Code session, or type /session-board in one.

  • Mac: the first time, the plugin builds the window app into ~/Applications/Session Board.app (under a minute) and opens it. It builds again only when an update changes the app's source.
  • Windows and Linux: the first time, the plugin downloads the window app from this repo's release for its version, checks it against the SHA-256 in app/release.json (part of the plugin; a download that doesn't match is thrown away), and starts it. Windows: %LOCALAPPDATA%\Programs\Session Board. Linux: ~/.local/share/session-board/app. When several sessions start together, one installs it: an install.lock file in that folder, which only one session can create, holds the others off, and each download goes under a name of its own.
  • Sessions that were already running show up too, with no restart. Once one session runs the plugin, it reads the others' saved conversations every 30 seconds.

To update: claude plugin update session-board@session-board.

Permissions

On a Mac the window asks for these permissions (Windows and Linux ask for none):

  • Accessibility, to read which session Claude Desktop has open, for the highlight. Without it, everything else still works. It's in System Settings → Privacy & Security → Accessibility.
  • Automation for Terminal or iTerm2, asked the first time one of them is in front, for the highlight there.

The app is signed ad hoc unless you have an Apple Development certificate. An ad hoc rebuild after an update counts as a new app to macOS. If the highlight stops following Claude Desktop after an update, turn Session Board off and on again in the Accessibility list.

Cost and privacy

Summary calls: the plugin asks Sonnet (low effort) for each session's short active/pending list:

  • when you send a message, so the board shows the new work right away;
  • when a turn ends;
  • during a long turn, at most every 90 seconds, and only when the session has written something new.

Each call is small: about 2,000 tokens in and 100 out.

That's a few small calls per turn, per session, billed to your Claude account like the rest of your Claude Code use.

  • Each call sends the last four requests and replies (about 15,000 characters at most) and the previous list.
  • The list is rebuilt from those exchanges each time, so finished or abandoned items drop off instead of piling up.
  • To stop the calls, turn Summaries by Sonnet off in Settings. The board then shows each session's request and its own task list.

Everything else stays on your Mac.

  • The window reads files Claude Code already writes: the session registry in ~/.claude/sessions and the plugin's store.
  • It talks to no server on its own.

Reports, only when you choose to send one. Nothing is sent until you say report in a chat or click Report (a crash) or a Feedback button in the window:

  • What's sent: your description, the versions of Session Board, Claude Code, macOS and Swift, the window's settings, how many sessions are on the board, Session Board's recent error messages, and the top of the stack of a crash. Your home folder and user name are removed. Nothing from your chats is sent: no session names, summaries, requests or project names.
  • Where it goes: with the GitHub CLI signed in, a public issue on this repo, under your GitHub account. Without it, Session Board's report inbox (a Cloudflare Worker, reports-worker/), which files it as an issue in a private repo only the team reads. The Feedback buttons open GitHub in your browser for you to send.

What the mod does on your computer

Everything here is in hooks/register.js (the mod) and hooks/session-title.mjs (the one command hook).

What it sends, and where

  • Summaries: the last few requests and replies of a session (about 15,000 characters at most) and its previous list, to Claude through Claude Code's own model call ($.model.complete, Sonnet at low effort), billed to your Claude account. Turn them off in Settings.
  • Naming (only with "Also rename in Claude Code" on): a session's first request, to Claude the same way, for a short name.
  • Bug reports, only when you ask (you say "report", or click Report or a Feedback button): your description, versions, the window's settings, session counts, error messages and a crash's stack, with your home folder and user name removed and nothing from your chats. With the GitHub CLI signed in, to a public issue on this repo; otherwise to Session Board's report inbox (session-board-reports.kiran-987.workers.dev, a Cloudflare Worker that files it as an issue in a private repo).
  • Windows and Linux: the window app is downloaded from this repo's GitHub release for the plugin's version (curl), and checked against the SHA-256 in app/release.json before it's installed.
  • Nothing else leaves your computer. There's no analytics.

Programs it runs, and why

  • sh with tail, sed and awk (macOS, Linux), or powershell (Windows): to read the end of a session's saved conversation, without its image data, for the summary and the step under way.
  • ps (macOS, Linux), or powershell with Get-CimInstance Win32_Process (Windows): to find the commands sessions left running, for "Still running".
  • uname: to tell macOS from Linux, and the processor.
  • macOS: xcode-select -p to check Apple's command-line tools, sh board/build.sh to build the window app (it runs swiftc, codesign and iconutil), and open -g to start it.
  • Windows and Linux: curl to download the window app, sha256sum or Get-FileHash to check it, mkdir, mv, chmod or Move-Item, taskkill to stop an old copy before replacing it, then cmd /c start or nohup to start it. For the install lock: ln, mv and rm on Linux; on Windows, PowerShell's [IO.File]::Move and Remove-Item.

What it writes

  • Each session's record in Claude Code's plugin store (its status, request, step, task list and summary), deleted when the session ends.
  • In the shared folder (~/Library/Application Support/session-board, %APPDATA%\session-board or ~/.local/share/session-board): show-request (from /session-board), last-error.json (a failed build, for a report), built.sha, and on Linux the downloaded app.
  • macOS: the window app in ~/Applications/Session Board.app. Windows: %LOCALAPPDATA%\Programs\Session Board. Windows and Linux: install.lock in the app's folder while an install runs, removed after.
  • It edits no build, start-up, settings or instructions file of yours.

What its hooks do. They watch; none of them blocks, changes or answers anything, and none takes part in a permission decision. Each passes its event on unchanged.

  • session.start: registers /session-board, starts the window app, and starts a check every 30 seconds (catching up sessions that started before the plugin, and noticing commands that ended).
  • prompt.submit, turn.start, turn.complete: note the request, that the session is working, and that the turn ended; start a summary.
  • tool.call: notes the step under way and the session's task list (TodoWrite, TaskCreate, TaskUpdate), and that a question or plan approval is on screen.
  • classic.Notification: notes that a permission prompt is open, so the board shows the session needs you.
  • classic.SessionStart (clear, resume, branch) and session.end: start a new record, or delete the old one.
  • command.run: answers /session-board by showing the window.
  • The command hook (UserPromptSubmit, hooks/session-title.mjs): with "Also rename in Claude Code" on, sets the session's title with its project in front. It never blocks a prompt.

How it works

  • The mod (hooks/register.js) keeps a record for each session in the plugin's shared store.
  • The record holds the status, the step under way, the request, the task list and the summary.
  • It's updated from the session's own events: prompts, turns, tool calls and permission prompts.
  • Sessions that started before the plugin loaded get a record built from their saved conversations.
  • claude -p runs and SDK jobs are skipped.
  • The window joins those records with Claude Code's session registry every 2 seconds. On a Mac it's the native app in board/ (board/main.swift, built by board/build.sh). On Windows and Linux it's app/, a Tauri app (a small Rust side in app/src-tauri, the board as a web page in app/ui) that the release workflow builds.
  • A shared folder connects the two: ~/Library/Application Support/session-board on a Mac, %APPDATA%\session-board on Windows, ~/.local/share/session-board on Linux. It holds:
  • the settings;
  • the bullets you removed;
  • requests from /session-board to show the window;
  • status.json, which records what the highlight last saw.

Uninstall

claude plugin uninstall session-board@session-board

Then quit Session Board from its menu bar item (right-click → Quit Session Board). Delete ~/Applications/Session Board.app and ~/Library/Application Support/session-board.

Bugs and ideas

  • In any Claude Code session: say what's wrong with Session Board. Claude offers to report it, and you say report to send it.
  • It goes on GitHub as a public issue, with versions, settings and error messages, and nothing from your chats.
  • With the GitHub CLI signed in, it's filed at once. Otherwise GitHub opens in your browser with it filled in.
  • A failed build's notice says to tell Claude "report the Session Board error".
  • The skill is skills/feedback.
  • In the window: Settings → Feedback → Report a problem or Suggest an idea, or right-click the menu bar item → Report a Problem…
  • On GitHub: open an issue.

You'll get GitHub's notifications when it's answered or fixed.

Troubleshooting

  • No window:
  • Press your shortcut (⌃⌥S unless you changed it), or click the ◉ in the menu bar.
  • With "Hide when you switch away from Claude" on, the popup only shows while Claude Desktop is in front, or a terminal or editor if that setting is on too.
  • A Regular window lives on the Space you put it on, so it doesn't show over a full-screen app. Its Dock icon brings it back.
  • The build failed: the error shows under the prompt in a terminal session. To see the whole output, run the build yourself from the plugin's folder: sh board/build.sh "$HOME/Applications/Session Board.app"
  • "no task data yet": the session hasn't finished a turn since the plugin started watching it.
  • The highlight doesn't follow Claude Desktop:
  • Check the Accessibility permission. status.json in the shared folder shows what the window saw.
  • The highlight reads Claude Desktop's window title ("<session> - Claude Code"), so a Desktop update can break it.

Development

claude --plugin-dir .                 # load your working copy
claude plugin test                    # the mod's tests
claude plugin validate --strict .
cd app && npm ci && npm test          # the window's rules (node) ...
cargo test --manifest-path src-tauri/Cargo.toml   # ... and its Rust side
npx tauri dev                         # run the window from source
SESSION_BOARD_SHARED_DIR=/tmp/sb npx tauri dev   # with its own shared folder: silent, always shown

Open app/ui/index.html through any local web server to see the board with sample sessions (?theme=light, ?viewing=2, ?empty).

Don't load a working copy and an installed copy at the same time: both would run every hook.

Sounds

Session Board can play a sound when a session needs you, when a turn ends with something for you, and (if you turn it on) when a session is done. Each moment can use any of its sounds, or a file of your own:

  • Session Board: three sounds made for it (app/ui/sounds, MIT).
  • macOS: the system alert sounds every Mac has (Glass, Ping, Hero, Submarine…), played from /System/Library/Sounds, not bundled. Mac only.
  • Kenney · CC0: eleven sounds from Kenney's Interface Sounds (public domain, CC0; thanks, Kenney). Licence in app/ui/sounds/KENNEY-LICENSE.txt.
  • Your own: Settings › Sounds › the sound's menu › Add your own… (any audio file, under 10 seconds and 5 MB). It's copied to the shared folder's sounds.

On a Mac, sounds that play on their own play at the alert volume you chose (50% unless you change it), then put your volume back (muted stays muted). On Windows and Linux they play at that share of your volume.

License

MIT. The bundled Kenney sounds are CC0.

Source 1 files
hooks/register.js 1469 lines
1// Session Board: keeps, for every Claude Code session on this Mac, a record of what it is
2// working on (active) and what is waiting (pending), for the Session Board window
3// (~/Applications/Session Board.app, built from board/main.swift), which shows them all in
4// one floating panel.
5//
6// Each session's copy of this mod keeps its own record in the shared $.store, under
7// "s:<sessionId>". Sessions that started before the mod loaded get a record too, written
8// by the catch-up pass from their saved conversations. The window joins these records with
9// the live-session registry Claude Code writes to ~/.claude/sessions/<pid>.json.
10//
11// The mod and the window also share a folder, ~/Library/Application Support/session-board:
12//   settings.json  the window's settings the mod follows ({"summaries": false} turns the summaries off)
13//   removed.json   bullets removed in the window, by session id, which summaries leave out
14//   show-request   written by /session-board, so the window shows even if it was hidden
15//   built.sha      what the installed app was built from (written by board/build.sh)
16//   last-error.json  the last failed build, for the feedback skill's reports
17
18const PREFIX = 's:'
19// A record whose session left the registry this long ago is deleted
20const STALE_MS = 10 * 60 * 1000
21// Catch-up for sessions started before the mod loaded: how often one session reads their
22// saved conversations
23const CATCHUP_MS = 30 * 1000
24const CATCHUP_LEASE = 'lease:catchup'
25// Raised when the catch-up pass changes, so a session running newer code takes the lease from
26// one still running older code (which yields to any lease another session holds)
27const LEASE_VERSION = 4
28// How much of a saved conversation's end is read: first this much, then four times more
29// until the last few requests are in it (screenshots and tool output make a turn megabytes
30// long), up to the cap
31const TAIL_START_BYTES = 1000000
32const TAIL_MAX_BYTES = 32000000
33// The tail as the summary needs it: without the base64 data of images, without lines over
34// 20 KB (tool output and file contents, never read), and only its last READ_OUT_BYTES. A
35// command's output is cut at a limit, and a cut keeps the start: the cap keeps the end instead.
36const READ_OUT_BYTES = 600000
37const READ_TAIL = `tail -c "$1" "$2" | sed -E 's/"data":"[A-Za-z0-9+\\/=]{200,}"/"data":""/g' | awk 'length($0) <= 20000' | tail -c ${READ_OUT_BYTES}`
38
39// How often, and for how long after a request, a session without a name looks for the one
40// Claude gives it; and the last name line in its saved conversation
41const TITLE_POLL_MS = 3000
42const TITLE_WATCH_MS = 2 * 60 * 1000
43const READ_TITLE = `tail -c 2000000 "$1" | grep '"type":"ai-title"' | tail -n 1`
44// Naming a session with its project first: how long the first request waits for a name, and
45// what the model is asked
46const TITLE_TIMEOUT_MS = 4000
47const TITLE_SYSTEM = 'You name a Claude Code conversation from its first request, the way a chat app titles one: 2 to 6 words, sentence case, plain words, no quotes and no full stop. Reply with the name only.'
48
49// A turn that runs long is summarized again this often, so the board keeps up with it
50// Through a long turn, a new summary at most this often, and only once the assistant has written
51// something new: the board keeps up with the chat without spending calls on nothing
52const BUSY_RESUMMARY_MS = 90 * 1000
53// A failed summary is tried again this much later, at most this many times in a row
54const SUMMARY_RETRY_MS = 15 * 1000
55const SUMMARY_RETRIES = 2
56// A summary call that hasn't answered after this long is given up on, so one that hangs can't
57// hold back every summary after it
58const SUMMARY_TIMEOUT_MS = 90 * 1000
59// A summary still going after this long (a step besides the model call that never answered) is
60// given up on, so the next one can run
61const SUMMARY_STUCK_MS = 3 * 60 * 1000
62// How long a summary after a turn waits for the turn's last reply to reach the saved conversation
63const ANSWER_WAIT_MS = 1000
64const ANSWER_TRIES = 3
65// What a summary reads: the last few exchanges, the latest reply at most this long, older
66// replies and the requests shorter
67const EXCHANGES = 4
68// The model that writes the summaries, and how hard it thinks
69const SUMMARY_MODEL = 'sonnet'
70const SUMMARY_EFFORT = 'low'
71const LATEST_REPLY_CHARS = 5000
72const OLDER_REPLY_CHARS = 1600
73const ASK_CHARS = 1500
74// Building the window app: one session at a time, for at most this long
75const BUILD_LEASE = 'lease:build'
76const BUILD_MS = 10 * 60 * 1000
77const APP_NAME = 'Session Board.app'
78
79const SUMMARY_SYSTEM = [
80  'You keep a short to-do board for one Claude Code session: what the user would expect to see at a glance.',
81  "You get the session's last few exchanges, oldest first (each the user's request and the assistant's final reply; for the latest, first its notes along the way, oldest first: what it said as it worked), then the board as it was before.",
82  'Rebuild the board from the exchanges. Reply with JSON only: {"done": "...", "side": "...", "paused": "...", "active": [...], "pending": [...]}.',
83  'side: when the user raised something in the middle of other work that still has open steps (a bug they spotted, a quick question, a detour), the work the session is doing for it right now; paused: the planned work it set aside for it. Both "" otherwise, and both "" again once the side task is finished and the planned work goes on. A request can be finished partway through a turn: when the notes show the assistant answered or finished the latest request and went back to earlier work, that request is done, side and paused are "", and the work it went back to is active. The board shows them on their own lines: while there is a side task, active does not name it again and pending does not repeat the paused work.',
84  'done: the last piece of work the session finished, as one short line in plain words: what was achieved, not the commands run. "" when the exchanges show nothing finished yet.',
85  'active: the work the session is doing now for the latest request, 0 to 2 items. When the latest request has no reply yet, or the assistant is still working on it, name that work, reading it in the light of the earlier exchanges (a "go ahead" continues what was planned). Work the assistant is still doing never waits on the user. When the latest reply is the assistant\'s final reply (its turn has ended), active is empty, even while a command or agent it left running goes on: the board shows those on their own line. What it will do once they finish (release after the tests pass) is pending, not active.',
86  'pending: what is still to do, 0 to 5 items, the ones that wait on the user first. One item per piece of work: never split one piece of work into its steps.',
87  'Mark a pending item "· waits on you" only when the session is stopped on the user for it right now: a reply asked them a question, asked for their approval or go, or asked them to do something themselves now, such as try, test, check, review, choose, run or tell it something ("please try it", "check it on your phone", "tell me which you prefer", "run this yourself"), and they have not answered it or done it since. A later message of theirs about something else (a side question, a detour) does not answer it: it still waits on them. Word that item as the user\'s step ("Test the new login · waits on you"). A step that needs the user later, after work the session is still doing or will do first (an iPhone check after the deploy it is running, a go for live after the sandbox), does not wait on them yet. While the session is still working, or a command it left running is still going, nothing waits on the user unless a reply asked them something they haven\'t answered. Never mark an active item.',
88  'You also get the commands this session left running (tests, builds, servers, agents). Never list one that is still running as an item, since the board shows it; list what comes after it as pending. When none is left, work a reply said was running in the background has finished.',
89  'Keep an item only while the exchanges show it is still open: work the latest replies list as still to do, a question still unanswered, or work under way. Drop what they show as done, replaced, handled elsewhere or no longer wanted, and anything only an older exchange mentioned that the later ones moved past.',
90  'The board before is no evidence: an item on it that the exchanges do not show as still open is dropped. When an item is the same work as one on it, reuse that wording, so the board does not churn. One exception: an item on it marked "· waits on you" was asked of the user in a reply that may be older than the exchanges you see; keep it, still marked, unless the exchanges show the user answered it, did it, or the session dropped it.',
91  'List only what this session itself is doing or still has to do. Leave out remarks about other sessions.',
92  'Never list an item the user removed from the board, even reworded.',
93  'Each item is under 70 characters, in plain words, with no markdown and no ids.',
94].join('\n')
95
96// This session's record, as it is stored
97let me = null
98// The request just sent, while the assistant hasn't replied to it: the summary names the
99// work it starts
100let awaitingReply = ''
101let summarizing = false
102// When the summary under way started, and a count of runs, so a stuck one can be given up on
103let summarizingSince = 0
104let summaryRun = 0
105// A summary was asked for while one ran: run again once it's done, with what's new
106let summarizeAgain = false
107// The reply the last turn ended with, until the next one starts
108let turnAnswer = ''
109// Summaries that failed in a row, so a failure is retried only SUMMARY_RETRIES times
110let summaryFailures = 0
111// The reply checkOwnJobs last summarized because the board had missed it, so it tries once per reply
112let healedReply = ''
113// When the last summary started, so a long turn asks again only every BUSY_RESUMMARY_MS
114let lastTry = 0
115// The status the last publish wrote, so statusSince moves only when the status really changes
116let publishedStatus = null
117// watchTitle is looking for the conversation's name
118let watchingTitle = false
119// The live-session registry as last read, by session id
120let liveReg = new Map()
121// Each catch-up session's transcript size and mtime at the last read, so an unchanged file isn't read again
122const seenTranscripts = new Map()
123// This process's build of the window app, while it runs
124let building = null
125// The tool call under way (a permission prompt is for it)
126let callInProgress = null
127
128const oneLine = (s, n) => {
129  const line = String(s ?? '').split('\n').map((x) => x.trim()).find(Boolean) ?? ''
130  return line.length > n ? line.slice(0, n - 1) + '…' : line
131}
132
133const baseName = (p) => String(p ?? '').split('/').filter(Boolean).pop() ?? '?'
134
135// Which OS this is: Windows says so in OS; uname tells macOS from Linux. Asked once.
136let osName = null
137async function osOf($) {
138  if (osName) return osName
139  if ((await $.env.get('OS')) === 'Windows_NT') return (osName = 'windows')
140  const r = await $.process.run(['uname', '-s'], { timeoutMs: 5000 }).catch(() => null)
141  return (osName = String(r?.stdout ?? '').trim() === 'Linux' ? 'linux' : 'macos')
142}
143
144const homeOf = async ($) => (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
145
146// The folder the mod and the window share, per OS (the window's shared_dir in app/src-tauri)
147async function dataDir($) {
148  const os = await osOf($)
149  if (os === 'windows') return ((await $.env.get('APPDATA')) || (await homeOf($)) + '/AppData/Roaming') + '/session-board'
150  if (os === 'linux') return ((await $.env.get('XDG_DATA_HOME')) || (await homeOf($)) + '/.local/share') + '/session-board'
151  return (await homeOf($)) + '/Library/Application Support/session-board'
152}
153
154// PowerShell on Windows, which has no sh: a script run with its arguments as literals
155const psQuote = (x) => "'" + String(x).replace(/'/g, "''") + "'"
156const powershell = ($, script, timeoutMs) =>
157  $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { timeoutMs })
158
159// A file's last `bytes` as text, read like READ_TAIL (the cap and the image data and long lines
160// left out), on any OS
161async function tailOf($, path, bytes) {
162  if ((await osOf($)) !== 'windows') {
163    const r = await $.process.run(['sh', '-c', READ_TAIL, 'sh', String(bytes), path], { timeoutMs: 20000 })
164    return r.exitCode === 0 ? r.stdout ?? '' : ''
165  }
166  const script = [
167    `$f = [IO.File]::Open(${psQuote(path)}, 'Open', 'Read', 'ReadWrite,Delete')`,
168    `try { $start = [Math]::Max(0, $f.Length - ${Number(bytes)}); [void]$f.Seek($start, 'Begin'); $buf = New-Object byte[] ($f.Length - $start); $n = 0`,
169    `while ($n -lt $buf.Length) { $r = $f.Read($buf, $n, $buf.Length - $n); if ($r -le 0) { break }; $n += $r } } finally { $f.Close() }`,
170    `$t = [Text.Encoding]::UTF8.GetString($buf, 0, $n) -replace '"data":"[A-Za-z0-9+/=]{200,}"', '"data":""'`,
171    `$t = ($t -split "\n" | Where-Object { $_.Length -le 20000 }) -join "\n"`,
172    `if ($t.Length -gt ${READ_OUT_BYTES}) { $t = $t.Substring($t.Length - ${READ_OUT_BYTES}) }`,
173    `[Console]::OutputEncoding = [Text.Encoding]::UTF8; [Console]::Out.Write($t)`,
174  ].join('\n')
175  const r = await powershell($, script, 20000)
176  return r.exitCode === 0 ? r.stdout ?? '' : ''
177}
178
179// One line naming what a tool call is doing, such as "Bash: Run the tests"
180function stepOf(e) {
181  const arg = [e.description, e.subject, e.command, e.file_path, e.pattern, e.url, e.query, e.prompt]
182    .find((v) => typeof v === 'string' && v.trim())
183  const shown = arg && arg === e.file_path ? baseName(arg) : arg
184  return oneLine(e.tool + (shown ? ': ' + shown : ''), 90)
185}
186
187// Keep the record's task list in step with the session's own task tools
188function trackTasks(e, r) {
189  if (e.tool === 'TodoWrite' && Array.isArray(e.todos)) {
190    me.tasks = e.todos.map((t, i) => ({ id: String(i + 1), subject: oneLine(t.content, 90), status: t.status }))
191  } else if (e.tool === 'TaskCreate' && !r?.deny) {
192    const id = r?.result?.task?.id ?? /#(\w+)/.exec(r?.text ?? '')?.[1] ?? String(me.tasks.length + 1)
193    me.tasks = [...me.tasks, { id: String(id), subject: oneLine(e.subject, 90), status: 'pending' }].slice(-30)
194  } else if (e.tool === 'TaskUpdate' && !r?.deny) {
195    me.tasks = me.tasks.flatMap((t) => {
196      if (t.id !== String(e.taskId)) return [t]
197      if (e.status === 'deleted') return []
198      return [{ ...t, subject: e.subject ? oneLine(e.subject, 90) : t.subject, status: e.status ?? t.status }]
199    })
200  }
201}
202
203const WAITS = ' · waits on you'
204const answered = (item) => (item.endsWith(WAITS) ? item.slice(0, -WAITS.length) : item)
205
206// What a session waiting on the user is asking, in one line
207function askFor(tool, input) {
208  if (tool === 'AskUserQuestion') return oneLine(input?.questions?.[0]?.question ?? 'A question for you', 120)
209  if (tool === 'ExitPlanMode') return 'Approve the plan'
210  return oneLine('Allow ' + stepOf({ tool, ...(input ?? {}) }), 120)
211}
212
213const openTasks = (rec) => (rec?.tasks ?? []).filter((t) => t.status === 'pending' || t.status === 'in_progress')
214
215async function publish($) {
216  if (!me) return
217  const now = await $.clock.now()
218  me.updatedAt = now
219  // When it entered its status: the window orders each status group by it, and shows the
220  // time since. A tool call is no change, so rows don't reshuffle while a session works.
221  if (me.status !== publishedStatus || !me.statusSince) {
222    me.statusSince = now
223    // A change after the first publish is activity; the first one is only the process starting
224    if (publishedStatus !== null) me.lastMessageAt = now
225    publishedStatus = me.status
226  }
227  await $.store.set(PREFIX + me.id, me)
228}
229
230// Fire and forget: a failed write must never hold up a tool call or a turn
231const publishSoon = ($) => void publish($).catch(() => {})
232
233async function init($) {
234  const id = await $.session.id()
235  const saved = await $.store.get(PREFIX + id)
236  me = {
237    tasks: [],
238    summary: null,
239    request: '',
240    ...(saved && typeof saved === 'object' ? saved : {}),
241    id,
242    cwd: await $.session.cwd(),
243    status: 'idle',
244    step: '',
245  }
246  // A record the catch-up pass wrote for this session before the mod loaded is now its own
247  delete me.shadow
248  // A reload keeps the saved statusSince unless the status differs from the saved one
249  publishedStatus = saved?.shadow ? null : saved?.status ?? null
250  lastTry = await $.clock.now()
251  await publish($)
252}
253
254// The window's settings, as it last wrote them
255async function settings($) {
256  try {
257    return JSON.parse(await $.fs.read((await dataDir($)) + '/settings.json')) ?? {}
258  } catch {
259    return {}
260  }
261}
262
263// Summaries, unless they were switched off in the window's settings
264async function summariesOn($) {
265  return (await settings($)).summaries !== false
266}
267
268// The project a folder belongs to: the Git repo it is in (a worktree counts as its main repo,
269// Claude Desktop's .claude/worktrees ones too), or else the folder itself. The window works it
270// out the same way.
271async function projectOf($, cwd) {
272  if (!cwd) return ''
273  let root = cwd
274  const worktree = cwd.indexOf('/.claude/worktrees/')
275  if (worktree >= 0) root = cwd.slice(0, worktree)
276  const home = await homeOf($)
277  for (let dir = root; home && dir.startsWith(home + '/'); dir = dir.slice(0, dir.lastIndexOf('/'))) {
278    let git
279    try {
280      git = await $.fs.stat(dir + '/.git')
281    } catch {
282      continue
283    }
284    root = dir
285    // A worktree's .git is a file: "gitdir: <main repo>/.git/worktrees/<name>"
286    if (git.kind === 'file') {
287      try {
288        const main = /^gitdir:\s*(.+?)\/\.git\/worktrees\//m.exec(await $.fs.read(dir + '/.git'))
289        if (main) root = main[1]
290      } catch {
291        // Unreadable: the folder holding it is the project
292      }
293    }
294    break
295  }
296  return root.split('/').filter(Boolean).pop() ?? ''
297}
298
299// A name for a new conversation from its first request, the way Claude names one; '' when the
300// model doesn't answer within TITLE_TIMEOUT_MS, since the request waits for it
301async function titleFor($, request) {
302  const answer = await Promise.race([
303    $.model.complete({ model: SUMMARY_MODEL, effort: SUMMARY_EFFORT, maxTokens: 40, system: TITLE_SYSTEM, prompt: request.slice(0, ASK_CHARS) }),
304    $.clock.sleep(TITLE_TIMEOUT_MS).then(() => null),
305  ])
306  const reply = typeof answer === 'string' ? answer : answer?.isAnswered === false ? '' : String(answer?.text ?? '')
307  return oneLine(reply.replace(/^["'“‘\s]+|["'”’.\s]+$/g, ''), 60)
308}
309
310// Project first, renaming the session too (Settings → Names, off by default): name this session
311// with its project first ("web-app · Fix the login"), once, so Claude Code and Claude Desktop show
312// it. The project goes in front of the name Claude gave it, or a name is made from this first
313// request. Only a settings-style hook can set a session's title, and Claude Code doesn't hand
314// those to mods (classic.UserPromptSubmit never reached one in 2.1.296), so the name is left in
315// title-<session>.json and the plugin's command hook (session-title.mjs), which runs right
316// after, hands it to Claude Code.
317async function nameWithProject($, request) {
318  const set = await settings($)
319  if (set.projectNames === false || set.renameSessions !== true) return
320  // The folder the session was opened in, not one it moved into since
321  const opened = liveReg.get(me.id)?.cwd ?? me.cwd
322  const project = await projectOf($, opened)
323  if (!project) return
324  const plain = (x) => x.toLowerCase().replace(/[^a-z0-9]/g, '')
325  let title = String(me.title ?? '').trim()
326  if (title && plain(title).startsWith(plain(project))) {
327    me.projectTitled = true
328    return
329  }
330  if (!title) title = await titleFor($, request)
331  if (!title || !me) return
332  me.projectTitled = true
333  me.title = project + ' · ' + title
334  await $.fs.write((await dataDir($)) + '/title-' + me.id + '.json', JSON.stringify({ title: me.title }))
335  publishSoon($)
336}
337
338// Bullets you removed in the window, by session id. The window writes this file; the
339// summary leaves them out, so the model doesn't bring them back.
340async function removedFor($, id) {
341  try {
342    const all = JSON.parse(await $.fs.read((await dataDir($)) + '/removed.json'))
343    return Array.isArray(all?.[id]) ? all[id].filter((x) => typeof x === 'string') : []
344  } catch {
345    return []
346  }
347}
348
349// The assistant's final reply in one exchange: its text after its last tool call. While it
350// is still working, its latest text so far. Notes (the thinking text it shows as it works) count
351// only when it wrote nothing else.
352function finalReply(all) {
353  const parts = all.filter((p) => !p.note)
354  if (!parts.some((p) => p.text?.trim())) return all.filter((p) => p.text?.trim()).pop()?.text ?? ''
355  let lastTool = -1
356  parts.forEach((p, i) => {
357    if (p.tool) lastTool = i
358  })
359  const after = parts.slice(lastTool + 1).map((p) => p.text).filter((t) => t?.trim())
360  if (after.length > 0) return after.join('\n')
361  const texts = parts.map((p) => p.text).filter((t) => t?.trim())
362  return texts[texts.length - 1] ?? ''
363}
364
365// An exchange's notes along the way: the assistant's text before tool calls and the thinking text
366// it shows as it works, each cut short, the last NOTES of them (its final reply is read on its
367// own). A side question is often answered, and the planned work picked up again, in such a note:
368// the final text alone misses both, and the board keeps showing a side task that's done.
369// The cut that follows (clip) keeps the start and the end: why it switched to a side task is
370// usually said first, and where it stands now last
371const NOTES = 40
372const NOTE_CHARS = 300
373const LATEST_NOTES_CHARS = 3000
374const OLDER_NOTES_CHARS = 800
375function notesOf(parts) {
376  const reply = finalReply(parts)
377  const short = (t) => {
378    const one = t.replace(/\s+/g, ' ').trim()
379    return one.length > NOTE_CHARS ? one.slice(0, NOTE_CHARS - 1) + '…' : one
380  }
381  return parts.map((p) => p.text).filter((t) => t?.trim() && !reply.includes(t)).slice(-NOTES).map(short)
382}
383
384// The exchanges a summary reads. Those of the turn still going on get their notes along the way:
385// the latest, and each one before it that a message sent mid-turn cut short (the turn went on
386// into the next exchange, so its "final" text isn't where it ended up). Older ones are read by
387// their final replies.
388function asExchanges(list) {
389  const recent = list.slice(-EXCHANGES)
390  let first = recent.length - 1
391  while (first > 0 && recent[first].midTurn) first--
392  return recent.map((x, i) => ({ ask: x.ask, reply: finalReply(x.parts), notes: i >= first ? notesOf(x.parts) : [] }))
393}
394
395// A request the user typed, without the reminders and context Claude Code and Claude Desktop
396// add around it; '' for a command's output, a task notification, an interruption marker or
397// another message the user didn't type
398function typedText(text) {
399  let clean = String(text ?? '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
400  if (/^(<(command-|local-command|task-notification)|\[Request interrupted)/.test(clean)) return ''
401  // Context an app puts in front of the request, such as Claude Desktop's
402  // <artifact-view-context>, an editor's <ide_opened_file>, or a lone marker such as
403  // <artifact-content-authored-by-others/>
404  const wrapper = /^(<([a-z][\w-]*)\b[^>]*>[\s\S]*?<\/\2>|<[a-z][\w-]*\b[^>]*\/>)\s*/
405  while (wrapper.test(clean)) clean = clean.replace(wrapper, '')
406  // The summary Claude Code writes when it compacts a conversation is not something typed
407  if (/^(?:[^\n]*\n){0,3}This session is being continued from a previous conversation/.test(clean)) return ''
408  return clean.trim()
409}
410
411// The session's own messages as exchanges: each request the user typed, and the replies to it
412function exchangesFromMessages(msgs) {
413  const out = []
414  for (const m of msgs) {
415    const ask = m.role === 'user' && !m.toolResults?.length ? typedText(m.text) : ''
416    if (ask) out.push({ ask, parts: [] })
417    else if (m.role === 'assistant' && out.length > 0) out[out.length - 1].parts.push({ text: m.text, tool: m.toolUses?.length > 0 })
418  }
419  return asExchanges(out)
420}
421
422// A reply cut to n characters, keeping its start and its end: replies tend to open with what
423// was done and close with what's next
424function clip(text, n) {
425  if (text.length <= n) return text
426  const head = Math.floor(n * 0.4)
427  return text.slice(0, head) + '\n[…]\n' + text.slice(-(n - head))
428}
429
430// What the summary model reads: the board before, the bullets the user removed, and the exchanges.
431// working: the assistant is still on the latest request, so its reply is only its text so far.
432// jobs: the commands it left running, as labels, or null when that isn't known.
433function summaryPrompt(before, removed, exchanges, pendingAsk, working = false, jobs = null) {
434  const removedNote = removed.length > 0 ? `\n\nItems the user removed from the board (never list these): ${JSON.stringify(removed)}` : ''
435  const turns = exchanges.map((x, i) => {
436    const latest = i === exchanges.length - 1 && !pendingAsk
437    const reply = clip(x.reply, latest ? LATEST_REPLY_CHARS : OLDER_REPLY_CHARS)
438    const label = latest && working ? "Assistant, still working on it (its latest text so far)" : "Assistant's final reply"
439    // What it said along the way, on its own allowance, so the final reply is cut no shorter
440    const notes = x.notes?.length ? `Assistant's notes along the way, oldest first:\n${clip(x.notes.map((n) => '- ' + n).join('\n'), latest ? LATEST_NOTES_CHARS : OLDER_NOTES_CHARS)}\n\n` : ''
441    return `User:\n${x.ask.slice(0, ASK_CHARS)}\n\n${notes}${label}:\n${reply || '(no reply yet)'}`
442  })
443  if (pendingAsk) turns.push(`User (latest request, no reply yet):\n${pendingAsk.slice(0, ASK_CHARS)}`)
444  const conversation = turns.map((t, i) => `--- Exchange ${i + 1} of ${turns.length}\n${t}`).join('\n\n')
445  const running = jobs === null ? ''
446    : `\n\n--- Commands this session left running: ${jobs.length > 0 ? jobs.join(', ') : 'none'}`
447  return `${conversation}${running}\n\n--- The board before (for wording only; no evidence that an item is still open)\n${before}${removedNote}`
448}
449
450// What a command does, in plain words, from its command line (the window labels them the same way)
451function jobLabel(command) {
452  const c = command.toLowerCase()
453  const has = (words) => words.some((w) => c.includes(w))
454  if (c.includes('playwright test')) return 'browser tests'
455  if (has(['vitest', 'jest ', 'npm test', 'npm run test', 'bun test', 'pytest', 'go test', 'cargo test', 'swift test', 'plugin test'])) return 'tests'
456  if (has(['vite preview', 'vite dev', 'npm run dev', 'next dev', 'http.server', 'npm start'])) return 'a local server'
457  if (has(['build.sh', 'npm run build', 'vite build', 'swiftc ', 'xcodebuild', 'cargo build', 'go build', 'docker build'])) return 'a build'
458  if (has(['deploy', 'rsync ', 'scp ', 'ssh '])) return 'work on a server'
459  return 'a command'
460}
461
462// The command a session ran, read from its shell's command line: Claude Code runs it as
463// `… && eval '<command>' < /dev/null && pwd -P …`. Its key (spaces collapsed, the first 200
464// characters) is how a running shell is matched to the description Claude gave the command,
465// here and in the windows, which read it the same way.
466function commandOf(line) {
467  const start = line.indexOf("eval '")
468  if (start < 0) return ''
469  const end = line.lastIndexOf("' < /dev/null")
470  const raw = end > start ? line.slice(start + 6, end) : line.slice(start + 6)
471  return raw.split(`'"'"'`).join("'")
472}
473const commandKey = (command) => String(command).replace(/\s+/g, ' ').trim().slice(0, 200)
474
475// A command's description as the board says it: "Run the browser tests" → "run the browser tests"
476const asPhrase = (text) => (/^[A-Z][a-z]/.test(text) ? text[0].toLowerCase() + text.slice(1) : text)
477
478// Labels for a session's running commands: the description Claude gave each when it ran it
479// (from the session's conversation, `commands`, by key), else what the command line suggests
480function describeJobs(jobs, commands = {}) {
481  return jobs.map((j) => ({ ...j, label: commands[j.key] ? asPhrase(commands[j.key]) : j.label }))
482}
483
484// The running commands Claude described, by key: what the record keeps for the windows
485const describedOnly = (jobs, commands = {}) => Object.fromEntries(jobs.filter((j) => commands[j.key]).map((j) => [j.key, asPhrase(commands[j.key])]))
486
487// The commands each session still has running, by session id: { label, key } each. Claude Code
488// runs a command in a shell started from the session's shell snapshot, named by when it was made
489// (the session's first command): the session is the live one that started last before that.
490const JOBS_CMD = "ps -axo command= | grep '/.claude/shell-snapshots/snapshot-' | grep -v grep"
491async function runningJobs($, registry) {
492  const jobs = new Map()
493  const r = (await osOf($)) === 'windows'
494    ? await powershell($, "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*shell-snapshots*snapshot-*' } | ForEach-Object { $_.CommandLine }", 15000).catch(() => null)
495    : await $.process.run(['sh', '-c', JOBS_CMD], { timeoutMs: 10000 }).catch(() => null)
496  const starts = [...registry.values()].filter((x) => x.startedAt).map((x) => ({ id: x.sessionId, startedAt: x.startedAt }))
497  for (const line of String(r?.stdout ?? '').split('\n')) {
498    const made = /[\\/]\.claude[\\/]shell-snapshots[\\/]snapshot-[a-z]+-(\d{13})-/.exec(line)
499    if (!made) continue
500    const owner = starts.filter((x) => x.startedAt <= Number(made[1])).sort((a, b) => b.startedAt - a.startedAt)[0]
501    if (!owner) continue
502    const key = commandKey(commandOf(line))
503    const list = jobs.get(owner.id) ?? []
504    // One command can show as more than one process (a subshell): count it once
505    if (key && list.some((j) => j.key === key)) continue
506    jobs.set(owner.id, [...list, { label: jobLabel(line), key }])
507  }
508  return jobs
509}
510
511// One summary call: the active and pending items, rebuilt from the last few exchanges. pendingAsk
512// is a request sent since, which has no reply yet.
513async function summarizeExchanges($, id, exchanges, pendingAsk, previous, working = false, jobs = null) {
514  const removed = await removedFor($, id)
515  const isRemoved = (item) => removed.includes(answered(item))
516  const before = previous
517    ? JSON.stringify({ active: previous.active.filter((x) => !isRemoved(x)), pending: previous.pending.filter((x) => !isRemoved(x)) })
518    : 'none'
519  const call = $.model.complete({
520    // Sonnet 5.5 at low effort: replayed on real moments, it read a long,
521    // many-topic chat correctly where Haiku blended two unrelated things into one, answered
522    // faster, and used about a tenth of the output tokens Haiku spent thinking
523    model: SUMMARY_MODEL,
524    effort: SUMMARY_EFFORT,
525    maxTokens: 1000,
526    system: SUMMARY_SYSTEM,
527    prompt: summaryPrompt(before, removed, exchanges, pendingAsk, working, jobs),
528  })
529  const answer = await Promise.race([call, $.clock.sleep(SUMMARY_TIMEOUT_MS).then(() => null)])
530  if (answer === null) throw new Error('the summary call timed out')
531  // Older builds resolve the reply's text, newer ones { isAnswered, text, usage }
532  const reply = typeof answer === 'string' ? answer : answer?.isAnswered === false ? '' : String(answer?.text ?? '')
533  const json = JSON.parse(reply.slice(reply.indexOf('{'), reply.lastIndexOf('}') + 1))
534  // "waits on you" goes at the end, however the model placed it
535  const waitsLast = (x) => {
536    const marker = /\s*[·•-]?\s*\(?waits on you\)?\s*[:–-]?\s*/i
537    return marker.test(x) ? x.replace(marker, ' ').trim() + WAITS : x
538  }
539  const clean = (list, n) =>
540    (Array.isArray(list) ? list : []).filter((x) => typeof x === 'string' && x.trim()).slice(0, n).map((x) => waitsLast(oneLine(x, 90)))
541  const keep = (list) => list.filter((x) => !isRemoved(x))
542  // Something waiting on the user isn't work under way: it goes with the pending items
543  const active = keep(clean(json.active, 2))
544  const side = typeof json.side === 'string' ? oneLine(json.side, 90) : ''
545  const paused = side && typeof json.paused === 'string' ? oneLine(json.paused, 90) : ''
546  // A side task is the work under way, and the board shows it and the work it paused on their
547  // own lines: the lists don't repeat them
548  const same = (a, b) => a.replace(WAITS, '').replace(/[^a-z0-9]+/gi, ' ').trim().toLowerCase() === b.replace(/[^a-z0-9]+/gi, ' ').trim().toLowerCase()
549  const pending = keep(clean(json.pending, 5)).filter((x) => !paused || !same(x, paused))
550  return {
551    done: typeof json.done === 'string' ? oneLine(json.done, 90) : '',
552    side,
553    paused,
554    active: side ? [] : active.filter((x) => !x.endsWith(WAITS)),
555    pending: [...active.filter((x) => x.endsWith(WAITS)), ...pending].slice(0, 5),
556    at: await $.clock.now(),
557  }
558}
559
560// Ask a small model for the session's active and pending items: when a request is sent, when
561// a turn ends, and every BUSY_RESUMMARY_MS through a long turn. Skipped when the session keeps
562// its own task list, which is exact.
563async function summarize($, { onlyIfNew = false } = {}) {
564  if (!me || openTasks(me).length > 0) return
565  let started
566  try {
567    started = await $.clock.now()
568  } catch {
569    // The session is going away
570    return
571  }
572  // One at a time; but one stuck this long (a step that never answered) is given up on, so it
573  // can't hold back every summary after it, the turn-end catch-up included
574  if (summarizing && started - summarizingSince < SUMMARY_STUCK_MS) {
575    summarizeAgain = true
576    return
577  }
578  const run = ++summaryRun
579  summarizing = true
580  summarizingSince = started
581  // Where the last summary got to, kept on the record, for when a board falls behind
582  me.summaryTry = { at: started, stage: 'reading' }
583  try {
584    lastTry = started
585    if (!(await summariesOn($))) return
586    // The saved conversation records everything, messages sent mid-turn included; the
587    // session's messages are the fallback
588    let own = await ownTranscript($)
589    // After a turn, the saved conversation can lag behind the reply the turn ended with: wait a
590    // little for it, then use that reply, or the summary reads the turn as still under way
591    const answer = me.status === 'idle' ? turnAnswer.trim() : ''
592    const hasAnswer = (list) => list.length > 0 && list[list.length - 1].reply.includes(answer.slice(0, 200))
593    for (let i = 0; answer && own.exchanges.length > 0 && !hasAnswer(own.exchanges) && i < ANSWER_TRIES; i++) {
594      await $.clock.sleep(ANSWER_WAIT_MS)
595      own = await ownTranscript($)
596    }
597    let exchanges = own.exchanges
598    if (answer && exchanges.length > 0 && !hasAnswer(exchanges)) exchanges = [...exchanges.slice(0, -1), { ...exchanges[exchanges.length - 1], reply: answer }]
599    me.agentsRunning = own.agents ?? 0
600    me.workflowsRunning = own.workflows ?? 0
601    if (own.title) me.title = own.title
602    if (exchanges.length === 0) exchanges = exchangesFromMessages(await $.session.messages())
603    const pendingAsk = awaitingReply
604    // The request just sent may already be in the messages, with no reply yet
605    if (pendingAsk && exchanges.length > 0 && exchanges[exchanges.length - 1].ask.trim() === pendingAsk.trim()) exchanges = exchanges.slice(0, -1)
606    if (exchanges.length === 0 && !pendingAsk) return
607    // Mid-turn: nothing new written since the last summary, nothing to redo
608    const latestReply = exchanges[exchanges.length - 1]?.reply ?? ''
609    if (onlyIfNew && !pendingAsk && me.summaryReply !== undefined && latestReply === me.summaryReply) return
610    await syncRegistry($).catch(() => {})
611    const running = describeJobs((await runningJobs($, liveReg)).get(me.id) ?? [], own.commands)
612    me.runningLabels = describedOnly(running, own.commands)
613    const jobs = [...running.map((j) => j.label), ...backgroundLabels(me.agentsRunning, me.workflowsRunning)]
614    me.jobsKey = [...jobs].sort().join(',')
615    me.summaryTry = { at: started, stage: 'asking the model' }
616    me.summary = await summarizeExchanges($, me.id, exchanges.slice(-EXCHANGES), pendingAsk, me.summary, me.status !== 'idle', jobs)
617    me.summaryTry = { at: started, stage: 'done' }
618    me.summaryReply = latestReply
619    delete me.summaryError
620    summaryFailures = 0
621    await publish($)
622  } catch (err) {
623    // A failed summary leaves the previous one in place; why it failed, for troubleshooting.
624    // Tried once more a little later: after the turn's last summary, nothing else would, and the
625    // board would keep showing the work as under way.
626    if (me) {
627      me.summaryTry = { ...(me.summaryTry ?? {}), stage: 'failed' }
628      me.summaryError = oneLine(err?.message ?? err, 200)
629      publishSoon($)
630    }
631    if (++summaryFailures <= SUMMARY_RETRIES) void $.clock.sleep(SUMMARY_RETRY_MS).then(() => summarize($)).catch(() => {})
632  } finally {
633    // A run given up on that ends late leaves the newer one alone
634    if (run === summaryRun) {
635      summarizing = false
636      if (summarizeAgain) {
637        summarizeAgain = false
638        void summarize($)
639      }
640    }
641  }
642}
643
644// Background agents and workflows, as the summary is told: one label each, without the count,
645// so twenty agents finishing one by one don't redo the summary twenty times
646function backgroundLabels(agents = 0, workflows = 0) {
647  return [
648    ...(workflows === 1 ? ['a workflow'] : workflows > 1 ? ['workflows'] : []),
649    ...(agents === 1 ? ['a background agent'] : agents > 1 ? ['background agents'] : []),
650  ]
651}
652
653// Where this session's own conversation is saved
654async function ownTranscriptPath($) {
655  const home = await homeOf($)
656  let reg = liveReg.get(me.id)
657  if (!reg) {
658    for (const f of await $.fs.list(home + '/.claude/sessions')) {
659      if (!f.name.endsWith('.json')) continue
660      try {
661        const r = JSON.parse(await $.fs.read(home + '/.claude/sessions/' + f.name))
662        if (r?.sessionId === me.id) reg = r
663      } catch {
664        // A registry file mid-write
665      }
666    }
667  }
668  return transcriptPath(home, { cwd: reg?.cwd ?? me.cwd, sessionId: me.id })
669}
670
671// This session's own saved conversation, read the way the catch-up pass reads the others'
672async function ownTranscript($) {
673  try {
674    // One more request than the summary reads, so the oldest one it reads is whole
675    return await readExchanges($, await ownTranscriptPath($), EXCHANGES + 1)
676  } catch {
677    return { exchanges: [], step: '' }
678  }
679}
680
681// Claude names a new conversation a few seconds after its first request. Until then the
682// registry only has a placeholder ("app-a6"), and Claude Desktop already shows the new name,
683// so the row neither matches nor highlights. Look for the name every TITLE_POLL_MS for a
684// while after a request, instead of waiting for the turn to end.
685async function watchTitle($) {
686  if (!me || me.title || watchingTitle) return
687  watchingTitle = true
688  try {
689    const until = (await $.clock.now()) + TITLE_WATCH_MS
690    while (me && !me.title && (await $.clock.now()) < until) {
691      await $.clock.sleep(TITLE_POLL_MS)
692      if (!me || me.title) break
693      const path = await ownTranscriptPath($)
694      let line = ''
695      if ((await osOf($)) === 'windows') {
696        line = (await tailOf($, path, 2000000)).split('\n').filter((l) => l.includes('"type":"ai-title"')).pop() ?? ''
697      } else {
698        const r = await $.process.run(['sh', '-c', READ_TITLE, 'sh', path], { timeoutMs: 10000 })
699        line = r.exitCode === 0 ? (r.stdout ?? '').trim().split('\n').pop() : ''
700      }
701      let title = ''
702      try {
703        title = String(JSON.parse(line).aiTitle ?? '').trim()
704      } catch {
705        // No name yet, or a line cut mid-write
706      }
707      if (title && me) {
708        me.title = title
709        await publish($)
710      }
711    }
712  } finally {
713    watchingTitle = false
714  }
715}
716
717// Read the live-session registry, and delete the records of sessions that ended a while ago
718async function syncRegistry($) {
719  const home = await homeOf($)
720  const dir = home + '/.claude/sessions'
721  const live = new Map()
722  try {
723    for (const f of await $.fs.list(dir)) {
724      if (!f.name.endsWith('.json')) continue
725      try {
726        const reg = JSON.parse(await $.fs.read(dir + '/' + f.name))
727        if (reg?.sessionId) live.set(reg.sessionId, reg)
728      } catch {
729        // A registry file mid-write; the next pass reads it
730      }
731    }
732  } catch {
733    // No registry directory: delete nothing
734    return
735  }
736  liveReg = live
737  const now = await $.clock.now()
738  for (const key of await $.store.keys()) {
739    if (!key.startsWith(PREFIX)) continue
740    const rec = await $.store.get(key)
741    if (rec?.id && !live.has(rec.id) && rec.id !== me?.id && now - (rec.updatedAt ?? 0) > STALE_MS) {
742      await $.store.delete(key).catch(() => {})
743    }
744  }
745}
746
747// Claude Code keeps each conversation at ~/.claude/projects/<cwd, every non-alphanumeric as ->/<id>.jsonl
748const transcriptPath = (home, reg) => home + '/.claude/projects/' + String(reg.cwd).replace(/[^a-zA-Z0-9]/g, '-') + '/' + reg.sessionId + '.jsonl'
749
750// The user's answer to the assistant's questions or plan, as a request of theirs
751function answerText(tool, result, details) {
752  const content = typeof result.content === 'string' ? result.content
753    : Array.isArray(result.content) ? result.content.filter((b) => b?.type === 'text').map((b) => b.text).join('\n') : ''
754  if (tool === 'ExitPlanMode') return result.is_error ? "Didn't approve the plan: " + content.slice(0, 500) : 'Approved the plan.'
755  const answers = details?.answers && typeof details.answers === 'object' ? Object.entries(details.answers) : []
756  if (answers.length > 0) return "Answered the assistant's questions:\n" + answers.map(([q, a]) => `- ${q} → ${a}`).join('\n')
757  return "Answered the assistant's questions: " + content.replace(/\s*Read the answers carefully[\s\S]*$/, '').slice(0, 1500)
758}
759
760// From the end of a saved conversation: its last few exchanges, and the tool call under way
761function exchangesFromTranscript(tail) {
762  const entries = []
763  // The first line is likely cut by the tail
764  for (const line of tail.split('\n').slice(1)) {
765    try {
766      entries.push(JSON.parse(line))
767    } catch {
768      // A partial or non-JSON line
769    }
770  }
771  const out = []
772  // The turn has ended: the last thing the assistant wrote ended its turn ("end_turn"), and
773  // nothing came after it. Claude Code's registry can't tell: it keeps saying "shell" while a
774  // command the turn left running goes on.
775  let ended = false
776  // The last tool call that has no result yet: what a "waiting" session is waiting on
777  let pendingTool = null
778  // Agents and workflows it started that keep running after their call returns, and that no
779  // notification has reported finished, by tool call id. Claude Code marks such a call's result
780  // "async_launched"; older versions set run_in_background on the call itself. The agents a
781  // workflow runs are its own business: the workflow counts once.
782  const launched = new Map()
783  const running = new Map()
784  // The title Claude gave the conversation (Claude Desktop shows it; the registry may only hold a
785  // placeholder name derived from the folder, like "site-54")
786  let title = ''
787  // A name given to it (a rename, or the project-first name), which Claude Code shows instead
788  let customTitle = ''
789  const finishedAgent = (text) => {
790    const s = String(text ?? '')
791    const id = /<tool-use-id>([^<]+)<\/tool-use-id>/.exec(s)?.[1]
792    const task = /<task-id>([^<]+)<\/task-id>/.exec(s)?.[1]
793    if (id) running.delete(id)
794    if (task) for (const [k, v] of running) if (v.taskId === task) running.delete(k)
795  }
796  // The assistant's questions (AskUserQuestion) and plans (ExitPlanMode), by tool call id: the
797  // user's answer to one comes back as that call's result, not as a typed message
798  const asked = new Map()
799  // The commands it ran, by key, with the description it gave each
800  const commands = {}
801  // When the last request or reply was written (ms)
802  let lastAt = 0
803  const startExchange = (ask, midTurn = false) => {
804    // The same request recorded twice (queued, then delivered) is one exchange
805    if (ask && out[out.length - 1]?.ask !== ask) out.push({ ask, parts: [], lastTool: null, midTurn })
806  }
807  for (const d of entries) {
808    if (d.isSidechain) continue
809    if ((d.type === 'user' || d.type === 'assistant') && !d.isMeta) lastAt = Date.parse(d.timestamp) || lastAt
810    if (d.type === 'ai-title' && typeof d.aiTitle === 'string' && d.aiTitle.trim()) title = d.aiTitle.trim()
811    if (d.type === 'custom-title' && typeof d.customTitle === 'string' && d.customTitle.trim()) customTitle = d.customTitle.trim()
812    const c = d.message?.content
813    if (d.type === 'user' && Array.isArray(c)) {
814      for (const b of c) {
815        if (b?.type !== 'tool_result' || !launched.has(b.tool_use_id)) continue
816        const r = d.toolUseResult
817        if (r?.status === 'async_launched' || r?.isAsync === true) running.set(b.tool_use_id, { kind: launched.get(b.tool_use_id), taskId: r?.taskId ?? r?.agentId ?? '' })
818      }
819    }
820    if (d.type === 'assistant') ended = d.message?.stop_reason === 'end_turn'
821    else if (d.type === 'user' && !d.isMeta) ended = false
822    if (d.type === 'user' && Array.isArray(c) && pendingTool && c.some((b) => b?.type === 'tool_result' && b.tool_use_id === pendingTool.id)) pendingTool = null
823    const answer = d.type === 'user' && Array.isArray(c) ? c.find((b) => b?.type === 'tool_result' && asked.has(b.tool_use_id)) : null
824    if (answer) {
825      startExchange(answerText(asked.get(answer.tool_use_id), answer, d.toolUseResult))
826    } else if (d.type === 'user' && !d.isMeta && !d.isCompactSummary) {
827      const text =
828        typeof c === 'string' ? c
829        : Array.isArray(c) && !c.some((b) => b?.type === 'tool_result') ? c.filter((b) => b?.type === 'text').map((b) => b.text).join('\n')
830        : ''
831      if (/<task-notification>/.test(text)) finishedAgent(text)
832      startExchange(typedText(text))
833    } else if (d.type === 'attachment' && d.attachment?.type === 'queued_command' && d.attachment.commandMode === 'task-notification') {
834      finishedAgent(Array.isArray(d.attachment.prompt) ? d.attachment.prompt.map((b) => b?.text ?? '').join('') : d.attachment.prompt)
835    } else if (d.type === 'attachment' && d.attachment?.type === 'queued_command' && d.attachment.commandMode === 'prompt') {
836      // A message sent while the session was working: its text, or its text parts when it
837      // came with images
838      const prompt = d.attachment.prompt
839      const text = Array.isArray(prompt) ? prompt.filter((b) => b?.type === 'text').map((b) => b.text).join('\n') : prompt
840      startExchange(typedText(text), true)
841    } else if (d.type === 'assistant' && out.length > 0 && Array.isArray(c)) {
842      const x = out[out.length - 1]
843      for (const b of c) {
844        if (b?.type === 'text') x.parts.push({ text: b.text, tool: false })
845        // Thinking it shows as it works (stored empty when it isn't shown): it can hold an answer
846        if (b?.type === 'thinking' && b.thinking?.trim()) x.parts.push({ text: b.thinking, tool: false, note: true })
847        if (b?.type === 'tool_use') {
848          // The description Claude gave a command it ran, for when it's still running
849          if ((b.name === 'Bash' || b.name === 'Monitor') && typeof b.input?.command === 'string' && typeof b.input?.description === 'string' && b.input.description.trim()) {
850            commands[commandKey(b.input.command)] = oneLine(b.input.description, 120)
851          }
852          x.parts.push({ text: '', tool: true })
853          x.lastTool = b
854          pendingTool = b
855          if (b.name === 'AskUserQuestion' || b.name === 'ExitPlanMode') asked.set(b.id, b.name)
856          if (b.name === 'Agent' || b.name === 'Task' || b.name === 'Workflow') {
857            const kind = b.name === 'Workflow' ? 'workflow' : 'agent'
858            launched.set(b.id, kind)
859            if (b.input?.run_in_background) running.set(b.id, { kind, taskId: '' })
860          }
861        }
862      }
863    }
864  }
865  const last = out[out.length - 1]
866  const step = last?.lastTool ? stepOf({ tool: last.lastTool.name, ...(last.lastTool.input ?? {}) }) : ''
867  return { exchanges: asExchanges(out), step, count: out.length, ended, agents: [...running.values()].filter((r) => r.kind === 'agent').length,
868    workflows: [...running.values()].filter((r) => r.kind === 'workflow').length, pendingTool, title: customTitle || title, commands, lastAt }
869}
870
871// A saved conversation's last exchanges, reading back until it has `want` requests (or the
872// whole file, or the cap)
873async function readExchanges($, path, want) {
874  let size = Infinity
875  try {
876    size = (await $.fs.stat(path)).size
877  } catch {
878    // Read what tail finds
879  }
880  for (let bytes = TAIL_START_BYTES; ; bytes = Math.min(bytes * 4, TAIL_MAX_BYTES)) {
881    const out = await tailOf($, path, bytes)
882    const parsed = exchangesFromTranscript(out)
883    // Reading further back can't help once the output is full
884    const full = out.length >= READ_OUT_BYTES - 1000
885    if (parsed.count >= want || full || bytes >= size || bytes >= TAIL_MAX_BYTES) return parsed
886  }
887}
888
889// This session's own commands: when one ends (or starts) while it's idle, its board is out of
890// date, since no turn will come to summarize it again. Every session checks its own.
891// The same goes for a turn whose last summary never happened: the mod reloaded as the turn
892// ended (the new copy starts idle and gets no turn.complete), or the call failed for good.
893async function checkOwnJobs($) {
894  if (!me || me.status !== 'idle' || summarizing) return
895  const registry = new Map()
896  const home = await homeOf($)
897  for (const f of await $.fs.list(home + '/.claude/sessions')) {
898    if (!f.name.endsWith('.json')) continue
899    try {
900      const reg = JSON.parse(await $.fs.read(home + '/.claude/sessions/' + f.name))
901      if (reg?.sessionId) registry.set(reg.sessionId, reg)
902    } catch {
903      // A registry file mid-write
904    }
905  }
906  const own = await ownTranscript($)
907  const latest = own.exchanges[own.exchanges.length - 1]?.reply ?? ''
908  const missed = own.ended && latest !== '' && latest !== me.summaryReply && latest !== healedReply
909  // What the last idle check saw, kept on the record, for when a board falls behind
910  me.idleCheck = { at: await $.clock.now(), ended: !!own.ended, missed }
911  // When the conversation last had a message, for a session that hasn't changed status since it
912  // started: reopening an old chat in Claude Desktop starts a new process, and that alone isn't activity
913  if (!me.lastMessageAt && own.lastAt) {
914    me.lastMessageAt = own.lastAt
915    await publish($)
916  }
917  if (missed) {
918    healedReply = latest
919    return summarize($)
920  }
921  if (!me.summary) return
922  const mine = [...describeJobs((await runningJobs($, registry)).get(me.id) ?? [], own.commands).map((j) => j.label), ...backgroundLabels(own.agents, own.workflows)].sort().join(',')
923  if (me.jobsKey === undefined || me.jobsKey !== mine) await summarize($)
924  else if ((own.agents ?? 0) !== (me.agentsRunning ?? 0) || (own.workflows ?? 0) !== (me.workflowsRunning ?? 0)) {
925    me.agentsRunning = own.agents ?? 0
926    me.workflowsRunning = own.workflows ?? 0
927    await publish($)
928  }
929}
930
931// Sessions that started before the mod loaded keep no record of their own. One session at a
932// time (a lease in the store) reads their saved conversations and keeps a record for them,
933// marked shadow: what they are doing now, and the summary's items when a new request shows up, after
934// a turn, and every BUSY_RESUMMARY_MS through a long one. The window takes their status from
935// the registry.
936// A debugging aid: the model call the summaries make, on request, to check a model on a real
937// prompt. Put <name>.json in ~/Library/Application Support/session-board/replay with
938// { model, effort?, maxTokens? } and either { system, prompt } or { transcript, working?, jobs? }
939// (a saved conversation, read and turned into the summary prompt exactly as the catch-up pass
940// does). The session holding the catch-up lease writes <name>.out.json next to it with the
941// prompt, the answer and how long it took.
942async function runReplays($) {
943  const dir = (await dataDir($)) + '/replay'
944  let files
945  try {
946    files = await $.fs.list(dir)
947  } catch {
948    return
949  }
950  for (const f of files) {
951    if (!f.name.endsWith('.json') || f.name.endsWith('.out.json')) continue
952    const out = dir + '/' + f.name.replace(/\.json$/, '.out.json')
953    if (await $.fs.exists(out)) continue
954    let req
955    try {
956      req = JSON.parse(await $.fs.read(dir + '/' + f.name))
957    } catch {
958      continue
959    }
960    const started = await $.clock.now()
961    let result
962    let system = req.system
963    let prompt = req.prompt
964    try {
965      if (req.transcript) {
966        const { exchanges: recent, ended } = await readExchanges($, req.transcript, 1)
967        const latest = recent[recent.length - 1]
968        const busy = !ended && !!req.working
969        const pendingAsk = busy && latest && !latest.reply ? latest.ask : ''
970        const { exchanges } = await readExchanges($, req.transcript, EXCHANGES + 1)
971        const read = (pendingAsk ? exchanges.slice(0, -1) : exchanges).slice(-EXCHANGES)
972        system = SUMMARY_SYSTEM
973        prompt = summaryPrompt('none', [], read, pendingAsk, busy, req.jobs ?? [])
974      }
975      const answer = await $.model.complete({
976        model: req.model ?? 'haiku',
977        maxTokens: req.maxTokens ?? 400,
978        system,
979        prompt,
980        ...(req.effort ? { effort: req.effort } : {}),
981      })
982      result = typeof answer === 'string' ? { text: answer } : { text: answer?.text ?? '', answered: answer?.isAnswered, reason: answer?.reason, usage: answer?.usage ?? null }
983    } catch (err) {
984      result = { error: String(err?.message ?? err) }
985    }
986    await $.fs.write(out, JSON.stringify({ ...result, model: req.model ?? 'haiku', effort: req.effort ?? null, ms: (await $.clock.now()) - started, prompt }, null, 2))
987  }
988}
989
990async function catchUp($) {
991  if (!me) return
992  const now = await $.clock.now()
993  const lease = await $.store.get(CATCHUP_LEASE)
994  if (lease && lease.id !== me.id && lease.until > now && (lease.v ?? 0) >= LEASE_VERSION) return
995  await $.store.set(CATCHUP_LEASE, { id: me.id, until: now + 3 * CATCHUP_MS, v: LEASE_VERSION })
996  await runReplays($).catch(() => {})
997  await syncRegistry($)
998  const home = await homeOf($)
999  const summaries = await summariesOn($)
1000  const jobs = await runningJobs($, liveReg)
1001  for (const [id, reg] of liveReg) {
1002    if (id === me.id || !reg.cwd) continue
1003    const rec = await $.store.get(PREFIX + id)
1004    if (rec && !rec.shadow) continue
1005    const running = jobs.get(id) ?? []
1006    let sessionJobs = running.map((j) => j.label)
1007    let jobsKey = [...sessionJobs].sort().join(',')
1008    // A record from before jobs were tracked counts as changed once, so it's checked against them
1009    const jobsChanged = rec?.jobsKey === undefined ? !!rec?.summary : rec.jobsKey !== jobsKey
1010    const path = transcriptPath(home, reg)
1011    let stat
1012    try {
1013      stat = await $.fs.stat(path)
1014    } catch {
1015      continue
1016    }
1017    const mark = stat.size + ':' + stat.mtimeMs
1018    // The registry's status moved since this record was read (a new turn, or a turn that ended
1019    // while its jobs run): read again, even when the conversation looks the same
1020    const statusMoved = (reg.statusUpdatedAt ?? 0) > (rec?.updatedAt ?? 0)
1021    if (seenTranscripts.get(id) === mark && !jobsChanged && !statusMoved) continue
1022    seenTranscripts.set(id, mark)
1023    // The latest request and step need only the end; a summary reads further back
1024    const { exchanges: recent, step, ended, agents, workflows, pendingTool, title, commands, lastAt } = await readExchanges($, path, 1)
1025    // Its running commands by the descriptions it gave them
1026    const described = describeJobs(running, commands)
1027    sessionJobs = described.map((j) => j.label)
1028    const latest = recent[recent.length - 1]
1029    // Working: "busy" or "shell" while the turn goes on. The registry keeps saying so after
1030    // the turn has ended while a command or a background agent it started still runs: the
1031    // conversation's end of turn decides. "waiting" is waiting on the user.
1032    const busy = !ended && (reg.status === 'busy' || reg.status === 'shell')
1033    const next = { tasks: [], ...(rec ?? {}), id, cwd: reg.cwd, shadow: true, runningLabels: describedOnly(running, commands), step: busy ? step : '', turnEnded: ended, agentsRunning: agents, workflowsRunning: workflows, title: title || rec?.title || '', lastMessageAt: lastAt || rec?.lastMessageAt, ask: reg.status === 'waiting' && pendingTool ? askFor(pendingTool.name, pendingTool.input) : '', updatedAt: now }
1034    // Background agents count as jobs too, for the summary and for noticing when they finish
1035    sessionJobs.push(...backgroundLabels(agents, workflows))
1036    if (latest) next.request = oneLine(latest.ask, 140)
1037    // A request the board hasn't seen yet, a finished turn, or a long one
1038    const newAsk = !!latest && latest.ask !== rec?.summaryAsk
1039    const wroteMore = (latest?.reply ?? '') !== (rec?.summaryReply ?? '')
1040    const due = newAsk || !busy || jobsChanged || (wroteMore && now - (rec?.summary?.at ?? 0) > BUSY_RESUMMARY_MS)
1041    if (summaries && due && latest && (next.summaryMark !== mark || jobsChanged)) {
1042      try {
1043        // A request with no reply yet is the work starting
1044        const pendingAsk = busy && !latest.reply ? latest.ask : ''
1045        const { exchanges } = await readExchanges($, path, EXCHANGES + 1)
1046        next.summary = await summarizeExchanges($, id, pendingAsk ? exchanges.slice(0, -1) : exchanges, pendingAsk, rec?.summary, busy, sessionJobs)
1047        next.summaryMark = mark
1048        next.summaryAsk = latest.ask
1049        next.summaryReply = latest.reply ?? ''
1050        next.jobsKey = jobsKey
1051        delete next.summaryError
1052      } catch (err) {
1053        // Keep the previous items; the next change tries again. Why it failed, for troubleshooting
1054        next.summaryError = oneLine(err?.message ?? err, 200)
1055      }
1056    }
1057    await $.store.set(PREFIX + id, next)
1058  }
1059}
1060
1061// The window app lives at ~/Applications/Session Board.app. It is built on this Mac from the
1062// plugin's source, the first time and again when an update changes that source: an app built
1063// here runs without the Apple notarization a downloaded one would need. `current` is false
1064// when it has to be (re)built.
1065async function appState($) {
1066  const app = (await homeOf($)) + '/Applications/' + APP_NAME
1067  const board = $.plugin.root + '/board'
1068  let want = ''
1069  try {
1070    const sum = await $.process.run(['shasum', '-a', '256', board + '/main.swift', board + '/build.sh'], { timeoutMs: 10000 })
1071    want = String(sum.stdout ?? '').split('\n').map((l) => l.split(' ')[0]).filter(Boolean).join(':')
1072  } catch {
1073    // Can't tell what the source is: an app that is there will do
1074  }
1075  let have = ''
1076  try {
1077    have = String(await $.fs.read((await dataDir($)) + '/built.sha')).trim()
1078  } catch {
1079    // Never built here
1080  }
1081  const exists = await $.fs.exists(app)
1082  return { app, current: exists && (!want || have === want) }
1083}
1084
1085// Build the app when it isn't current. One session builds (a lease in the store); the others
1086// throw "building" and leave it to that one, which opens the window when it's done.
1087async function buildApp($) {
1088  const { app, current } = await appState($)
1089  if (current) return app
1090  const now = await $.clock.now()
1091  const lease = await $.store.get(BUILD_LEASE)
1092  if (lease && lease.id !== me?.id && lease.until > now) throw new Error('building')
1093  await $.store.set(BUILD_LEASE, { id: me?.id ?? '', until: now + BUILD_MS })
1094  try {
1095    const r = await $.process.run(['sh', $.plugin.root + '/board/build.sh', app], { timeoutMs: BUILD_MS })
1096    if (r.exitCode !== 0) throw new Error(oneLine(r.stderr || r.stdout, 200) || 'the build failed')
1097  } finally {
1098    await $.store.delete(BUILD_LEASE).catch(() => {})
1099  }
1100  return app
1101}
1102
1103// Windows and Linux: the window app (app/ in this repo) is built for each OS by the release
1104// workflow and downloaded, then checked against the checksum in app/release.json, part of this
1105// plugin, before it is installed: a download that doesn't match is thrown away.
1106const RELEASES = 'https://github.com/KiranPothana/session-board/releases/download'
1107
1108async function releaseOf($) {
1109  try {
1110    return JSON.parse(await $.fs.read($.plugin.root + '/app/release.json'))
1111  } catch {
1112    return null
1113  }
1114}
1115
1116async function downloadedApp($) {
1117  const os = await osOf($)
1118  const release = await releaseOf($)
1119  if (!release) throw new Error("the plugin's app/release.json is missing")
1120  const machine = os === 'windows'
1121    ? String((await $.env.get('PROCESSOR_ARCHITECTURE')) ?? '')
1122    : String((await $.process.run(['uname', '-m'], { timeoutMs: 5000 }).catch(() => null))?.stdout ?? '').trim()
1123  const key = `${os}-${/arm64|aarch64/i.test(machine) ? 'arm64' : 'x64'}`
1124  const asset = release.assets?.[key]
1125  if (!asset?.file || !/^[0-9a-f]{64}$/.test(asset.sha256 ?? '')) throw new Error(`there's no Session Board window app for ${key} yet`)
1126  // Where it goes, and the file saying which version is there
1127  const dir = os === 'windows' ? ((await $.env.get('LOCALAPPDATA')) || (await homeOf($)) + '/AppData/Local') + '/Programs/Session Board' : (await dataDir($)) + '/app'
1128  const app = dir + '/' + asset.file
1129  const versionFile = dir + '/version.txt'
1130  let have = ''
1131  try {
1132    have = String(await $.fs.read(versionFile)).trim()
1133  } catch {
1134    // Not installed yet
1135  }
1136  return { os, dir, app, asset, versionFile, version: release.version, current: have === release.version && (await $.fs.exists(app)) }
1137}
1138
1139// One install at a time across sessions, which can all start together. The lock is a file in
1140// the app's folder, made by an operation that fails when the file is already there (a hard link
1141// on Linux, a move that doesn't overwrite on Windows), so only one session gets it. It holds its
1142// owner's token and the time it runs out: a lock left by a session that died is taken over once
1143// it has run out, and a session removes a lock only while it still holds its own token.
1144async function fileOp($, os, op, from, to) {
1145  const ps = (script) => powershell($, script, 20000)
1146  let r
1147  if (op === 'remove') r = os === 'windows' ? await ps(`Remove-Item -Force -LiteralPath ${psQuote(from)}`) : await $.process.run(['rm', '-f', from], { timeoutMs: 20000 })
1148  // Put `from` at `to`, failing when `to` is there
1149  else if (op === 'place') r = os === 'windows' ? await ps(`try { [IO.File]::Move(${psQuote(from)}, ${psQuote(to)}) } catch { exit 1 }`) : await $.process.run(['ln', from, to], { timeoutMs: 20000 })
1150  // Move `from` to `to`, a name no other session uses, failing when `from` is gone
1151  else r = os === 'windows' ? await ps(`try { [IO.File]::Move(${psQuote(from)}, ${psQuote(to)}) } catch { exit 1 }`) : await $.process.run(['mv', from, to], { timeoutMs: 20000 })
1152  return r?.exitCode === 0
1153}
1154
1155async function lockHolder($, path) {
1156  let text
1157  try {
1158    text = String(await $.fs.read(path))
1159  } catch {
1160    return null
1161  }
1162  try {
1163    const held = JSON.parse(text)
1164    return { token: String(held.token ?? ''), until: Number(held.until) || 0 }
1165  } catch {
1166    // Not a lock this code wrote: out of date
1167    return { token: text, until: 0 }
1168  }
1169}
1170
1171// Puts a file made under a name of its own at `to` without overwriting, then clears the name
1172// (on Linux the link leaves it; on Windows the move took it)
1173async function placeOnce($, os, from, to) {
1174  const placed = await fileOp($, os, 'place', from, to)
1175  if (os !== 'windows' || !placed) await fileOp($, os, 'remove', from).catch(() => {})
1176  return placed
1177}
1178
1179// Moves the lock aside under a name of its own, so of two sessions only one gets it, and keeps it
1180// only when it still holds `token`; another session's lock is put back. True when it was kept.
1181async function takeLock($, os, lock, token) {
1182  const aside = `${lock}.${crypto.randomUUID()}.old`
1183  if (!(await fileOp($, os, 'move', lock, aside))) return false
1184  const held = await lockHolder($, aside)
1185  if (held?.token === token) {
1186    await fileOp($, os, 'remove', aside).catch(() => {})
1187    return true
1188  }
1189  await placeOnce($, os, aside, lock)
1190  return false
1191}
1192
1193async function lockInstall($, d) {
1194  const lock = d.dir + '/install.lock'
1195  const token = crypto.randomUUID()
1196  for (let i = 0; i < 3; i++) {
1197    const mine = `${lock}.${token}`
1198    await $.fs.write(mine, JSON.stringify({ token, until: (await $.clock.now()) + BUILD_MS }))
1199    if (await placeOnce($, d.os, mine, lock)) return { lock, token }
1200    const held = await lockHolder($, lock)