Tells Fleet View the moment a turn starts or ends and which tool is running, so it never has to guess from the logs

A live view of every Claude Code session on this machine, in its own app window (or, with --tui, in the terminal): what each one is doing right now, its running agents, how far its current plan has got, and how close its work is to production. Four views: Cards (one card per conversation), Map (a graph of conversations, agents, repos, PRs and shared files, like Obsidian's graph view), Wall (one big tile per conversation, for a second monitor) and Projects (Claude Code's own layout: each repo with its conversations under it, and the picked one as a chat). Each conversation also shows its tokens and what they would cost at API prices.
◆ DETAILFORGE-WEB ▾ · FLEET VIEW 5 live 4 agents 0 waiting 32 merged today 11:51 PM
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚑ calendar-dialog-refactor PR #666 merged 4s ago
⠋ texting-compliance-and-deploy-status AGENTS ▃▄▂ ▄▆█ 5s
goal Fix the review findings, merge, and watch the deploy
▸ fix:plans shell Run web typecheck after last change
plan ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸━━━━━━━━━━━━ 78% ●●◉ ◉ review:plans
◆ Fix ── ◆ Native check ── ◈ Review │ ◆ push ─ ◆ PR ─ ◈ checks ─ ◇ merged ─ ◇ live
Windows 10/11 with Node.js 18+ (winget install OpenJS.NodeJS.LTS), git (winget install Git.Git) and Claude Code logged in with your own Claude account. To install Claude Code, in PowerShell: irm https://claude.ai/install.ps1 | iex, then run claude once and log in.
Then clone Fleet View into your home folder and run its installer. In PowerShell:
git clone https://github.com/GLODUSK/fleet-view-share.git "$HOME\fleet-view"; powershell -NoProfile -ExecutionPolicy Bypass -File "$HOME\fleet-view\install.ps1"
Or in Command Prompt:
git clone https://github.com/GLODUSK/fleet-view-share.git "%USERPROFILE%\fleet-view" && powershell -NoProfile -ExecutionPolicy Bypass -File "%USERPROFILE%\fleet-view\install.ps1"
install.ps1 checks what it needs, installs the desktop window (desktop/, Electron, with npm ci), puts the folder on your user PATH (so fleet-view and fv work in any new console), installs the fleet-view skill and the fleet-view-feed mod (below) into every Claude login (~/.claude and each ~/.claude-<letter>), and starts Fleet View. Add -StartAtSignIn to start it at sign-in, -NoDesktop to use an Edge app window instead, -NoLaunch to not start it. It needs no admin rights and sends nothing anywhere: it reads your own ~/.claude and serves its page on 127.0.0.1 only, to this machine's own browser window; the scripting API needs a token made on first start (%LOCALAPPDATA%\fleet-view\api-token). It ends by saying which version it installed (below: Versions). A login you add later (a second account) gets the skill and the mod when you run install.ps1 again.
To update, use Update now in Fleet View (below: Update available), or run the same install.ps1 again (the powershell … install.ps1 part above), also while Fleet View runs: it pulls, then puts the update in. The server and the page reload by themselves; a changed session host (desktop/host.js, handoff.js) restarts once no session is mid-turn and resumes them; any other change in desktop/ restarts the window (the sessions keep running). When the update changes the desktop window's packages, npm ci can't run under a running window, so it asks Quit Fleet View now? [y/N] first: yes closes the window, saves the open sessions, ends them and quits, and Fleet View starts again at the end with every session resumed; no (or a run with no console to ask in) stops with: choose Quit everything from the tray icon, then run it again. A desktop\package-lock.json changed by hand (npm install does that) is put back as committed first.
It works with one Claude account (~/.claude); every other login in a ~/.claude-<letter> folder (~/.claude-a is A, ~/.claude-c is C, and so on) is another account, with its letter tag, "New session · <letter>" and "Send to Claude <letter>". A new folder shows up within a minute, with no code change. Send to Claude <letter> needs the /handoff and /pickup commands in that login (a skill or a command file; Fleet View doesn't ship them) and the conversation's log in its projects folder (one login's projects linked to the other's); the menu offers it only for the logins that have both.
For the maintainer: the share repo is published from the main repo with node scripts/publish-share.js (below: Update available).
Type fleet-view in any cmd window. The folder is on the user PATH, so the command works from anywhere.
The app window is the default. fleet-view starts a small server on http://127.0.0.1:4777 (this machine only) and opens it in the app window: the blurred desktop window when it is installed (below), otherwise a Microsoft Edge app window (no tabs, no address bar, its own Edge profile under %LOCALAPPDATA%\fleet-view\edge). The window opens where it was last time and saves its place and size. No console window shows: fleet-view hands off to fleet-view.vbs, which runs the launcher hidden, and the desktop app runs the server hidden and stops it when it quits. Run (Win+R) and the Start menu's search go straight to fleet-view.vbs too: the server registers the name fleet-view in App Paths and adds a Fleet View Start menu shortcut (with its icon, to pin) when one is missing, so no Windows Terminal window flashes up. Typed in a terminal, fleet-view still runs fleet-view.cmd there. Typing fleet-view again while it runs just brings up the window on the same server (Edge opens another one). When node isn't on the PATH fleet-view.vbs was started with, it says so in a message box ("Fleet View needs Node.js 18 or newer, and node was not found.") instead of failing with nothing on screen.
desktop/ holds a small Electron shell that shows the same page in a window with Windows 11 acrylic behind it: the desktop and the windows behind it show through, blurred, like the Fleet View terminal profile. Edge app windows can't be see-through, which is why this exists. install.ps1 installs it (npm ci in desktop/, from the committed package-lock.json); don't run npm install there, which can rewrite that file and then blocks Update now.
From then on fleet-view opens this window instead of Edge. It picks Electron whenever desktop/node_modules/electron is there and falls back to Edge when it isn't (delete desktop/node_modules to go back to Edge).
?glass=1 and switches to glass mode: the background, cards, panels, header and footer are tinted see-through instead of solid, and the map canvas is left clear; text keeps its full colour. Menus, the confirm box, toasts and the map's tooltip and legend are frosted with a strong tint, so nothing behind them shows through. In Edge or a browser the surfaces are solid.main.js keeps its frame and the usual header until it restarts.--no-open anyway). Its profile lives in %LOCALAPPDATA%\fleet-view\electron-<port>.window-state.json, which it reads first, with whether it was maximized) and on the server (webBounds, the same setting the Edge window used, so it first opens where the Edge window was). It is put back with the same restored (not maximized) bounds it saved, once when it is made and again just before it shows, and moves while it is being placed are not saved. If that place is on no screen any more, it opens in the middle of the main display.m, or the small square button at the right end of the header, shrinks Fleet View into a small glass window that stays on top of other windows and has no taskbar button: the main window hides while it shows. It has the live and waiting counts, the clock, and up to three sections: Needs you (asking, a question, an API error, stalled; most urgent and longest waiting first, with what it asks), Working (every conversation at work, newest action first, with a progress ring, its repo, running agents and its last action) and Just finished (the last hour's, up to 3, with how far each shipped). Its height follows its rows (up to 8, then it scrolls). Its expand button or Esc brings the main window back as it was (same place and size, maximized or not); a click on a row does the same with that conversation picked and its panel open. Drag its top bar to move it. It remembers its place (miniBounds) and comes back on a screen if its old place is on none; the app always starts on the main window.desktop/backdrop.js, started with the window and closed with it) sends it WM_NCACTIVATE, which keeps the backdrop on. The side effect: the title bar stays drawn as active while another window has focus. Focus and typing are unchanged.F5 / Ctrl+Shift+R reload (plain Ctrl+R is the chat box's history search), F12 / Ctrl+Shift+I dev tools.fleet-view app window (same as node fleet-view.js --web)
fleet-view --tui the terminal view, exactly as before
fleet-view --port 4800 serve on another port
node fleet-view.js --web --no-open --demo serve made-up data without opening a window
--tui)Once, add the blurred Windows Terminal profile (in the Fleet View folder):
node fleet-view.js --install-profile
Then run fleet-view --tui. Outside Windows Terminal it opens its own window with the "Fleet View" profile (acrylic blur, 45% opacity). Inside Windows Terminal it runs in the current tab. The profile runs fleet-view.cmd --tui; run --install-profile again if yours was added before the app window existed (it would open the app window instead). The view never paints a background colour, so whatever blur the tab has shows through; --solid paints one instead.
Updates reload in place. When fleet-view.js (or api.js, which it loads) changes on disk (a pull or merge), Fleet View reloads itself: fleet-view.cmd runs it in a small loop that starts it again when it exits with code 75. In the app window the server restarts with --no-open, so no second window appears; the page shows a small "reconnecting" note and picks up the new server by itself. In the terminal it reloads in the same tab, so the window keeps its place and size. A version that doesn't parse is ignored until it does. When the page's own files (web/) change, the page reloads itself a few seconds later, in the same window. Run --install-profile once more after updating from an older version, so the profile goes through fleet-view.cmd.
Versions. Every copy of Fleet View has a version number, MAJOR.MINOR.PATCH (1.0.12), the same number for the same code on every PC, so "which version are you on" has a plain answer. version.json at the repo root holds MAJOR.MINOR ("1.0"); PATCH counts the commits on main (first parent) since that file last changed, so every merged PR moves it up by one (version.js works it out with git). Edit the number in version.json for a bigger step (1.1, 2.0) and PATCH starts again at 0. The share repo's copy of version.json already holds the full number and the main commit it came from ({ "version": "1.0.12", "commit": "<sha>", "published": "<date>" }, written by publish-share.js), so a friend's PC knows its version without main's history; each publish is also tagged v1.0.12 there. To see it: the small v1.0.12 at the right end of the footer (a click opens the update card: Fleet View 1.0.12 · up to date, Check again, the automatic-update switch), fleet-view --version, fv version, node version.js, GET /update's version and GET /api's fleetView. server.log's start line says version 1.0.12 (02dddec): the number, then the commit. Without version.json (or git, on main) every one of these falls back to the short commit.
Update available. Fleet View checks its folder's git upstream a minute after it starts and every 30 minutes after that (updater.js). When there is something new, an Update available · N pill shows in the header (and the footer's version turns green); a click opens a card with the versions (1.0.10 → 1.0.12), what changed (the commit messages; for the share repo, the notes publish-share.js wrote), Update now, Check again and an Update automatically when no conversation is working switch (kept in %LOCALAPPDATA%\fleet-view\update.json). Update now runs git pull --ff-only, then the server and page reload themselves; a change in desktop/ restarts the window (the sessions live in the host and stay), and a changed host.js or handoff.js runs desktop\restart-host.js, which waits until no session is mid-turn. After the pull it copies the new fleet-view skill into every Claude login whose copy differs. The card's notes leave out each share commit's "Fleet View 1.0.12" title (the card shows the versions) and the dash in front of each line. It refuses when files in the folder were changed locally (a desktop\package-lock.json that npm install rewrote only when the update changes it too; the card then gives the git checkout that puts it back), and, where the desktop window is installed, when desktop/'s packages changed: then the card gives the install.ps1 command to run, which asks to quit Fleet View first (see To update above). With the Edge window (-NoDesktop) those packages don't matter and the update goes in as usual. Routes: GET /update, POST /update/check, POST /update/apply, POST /update/auto {on}. After an update the header shows Updated to 1.0.12 for a while. Publishing: node scripts/publish-share.js --notes "what changed", or without --notes it lists the titles of the PRs merged since the last publish; the share commit is titled Fleet View 1.0.12, and the script prints published: 1.0.12 (<main commit>) on GLODUSK/fleet-view-share, or the share repo already has this version when there is nothing new to publish (also when main's new commits change only files the share repo leaves out, such as CLAUDE.md). --dry-run prints the version and the file list and publishes nothing.
Crashes restart by themselves. If the server hits an error nothing catches, it writes the error and its stack to the log (below) and exits with code 76. fleet-view.cmd then starts it again after 3 seconds, with --no-open, so the open window just reconnects. Any other non-zero exit counts as a crash too. After 5 crashes within 2 minutes it stops trying and says so in the log; type fleet-view to start it again. Exit code 0 (Ctrl+C, closing the console, or another Fleet View already serving the port) ends the loop as before. One unreadable line in a session log is skipped and logged, never fatal, and a request that fails gets a 500 instead of stopping the server.
Log. The server writes a small log to %LOCALAPPDATA%\fleet-view\server.log: a line when it starts (pid, version as the number and the git commit, 1.0.12 (02dddec), plus fleet-view.js's time, port, and why it restarted, if it did) and when it stops (exit code and reason), every crash and unhandled promise rejection with its stack, HTTP server errors, and the launcher's restart and give-up lines ([fleet-view.cmd]). Nothing is logged per request, and an error that repeats every poll is written once a minute at most. At 2 MB the log moves to server.log.1, replacing the older one.
fleet-view --install-startup make "Fleet View.lnk" in your Startup folder
fleet-view --remove-startup delete it
The shortcut runs fleet-view.vbs (no console window), in this folder, so Fleet View starts (and opens its window) when you sign in. Both commands say what they did and run in the console you type them in. The Startup folder is the one Windows reports for your account (shell:startup). /state reports whether the shortcut is there as settings.autostart.
Needs Node 18+ and no packages. The gh CLI, logged in, powers the ship track and merge counts.
In the app window: v or Tab next view (Cards, Map, Wall, Projects), click the name top left for the repo menu, / filter, arrows pick, Enter or o (or a double-click) opens the picked conversation, c compact cards, s steady or live order, m the mini view (desktop window only), and in the Map the wheel zooms at the cursor, drag pans, + / - zoom, 0 recenters, l shows the legend. Map repos stay where they are: each keeps the spot it first got (new repos take the nearest free spot, nothing else moves). Drag a repo's hub to move it with everything on it; that pins it, so it never moves by itself again. g re-arranges the repos that aren't pinned, G unpins all of them too. Spots and pins are saved (mapSpots in ~/.fleet-view.json). The view fits the map when it opens (and on 0), then holds still: changes never pan or zoom it by themselves. A right-click on a repo or a conversation opens its menu (see "Right-click menus"). In the desktop window that opens its live session in the panel (see "Live sessions in the panel"); in Edge it opens a Windows Terminal tab, and a conversation that hasn't finished asks first, since it may still be open in another window. The footer lists the keys.
Steady order (the default; s switches to live order and back, and it is saved as steady). Cards and Wall put the conversations that need you first (asking, then a question, an API error, stalled), longest waiting first within each. Every other conversation keeps the place it had when the page first saw it, and new ones join at the end, so cards don't swap places each time one of them does something; a card moves only when it starts or stops needing you. Live order sorts by state, then by latest activity, as before. The first-seen order lives in the page only, so it starts over when the page reloads.
Detail panel. Picking a conversation (a click in Cards, Wall or Map, the arrows, or a row of the "recently finished" strip) opens a panel on the right: its name, account and state; repo, branch, worktree, model, context and turn time; an Open in terminal button; its last reply in full (or the question it waits on); its plan and agents; its ship track with the PR and checks; the files it touched; its last 30 tool calls; and its tokens and cost. It updates every second and keeps its scroll position. On a window wider than 1300 px the view narrows to make room; on a narrower one the panel sits over it. Esc or × closes it. Esc closes one thing at a time, innermost first: the right-click menu, the confirm box, the repo menu, the filter box while you type, Since you looked, the map legend, a selection of several conversations, the panel, then the pick (and an applied filter).
Chat tab. The panel opens on Chat: the conversation as a chat, read from its log (GET /conversation, below), with a real chat box under it that types into the same live session the Session tab runs. Your messages are glass bubbles on the right; Claude's replies run full width as Markdown (marked 18, then DOMPurify 3, so nothing in a reply runs as HTML; links open in the browser), and fenced code is a card with its language and a Copy button, coloured by highlight.js 11 (12 languages). Each tool call is one folded row (Bash · npm test, Edit · app.js +4 −2) that opens to its command and output, a red/green diff for edits, a checklist for todos, the question and options for AskUserQuestion. Select text in the chat and Reply quotes it into the box. Scrolled up past your last message, a bar pinned at the top shows it (You · its first line); click it to jump back to it. The box is a normal text box (click, select, right-click for Cut/Copy/Paste): Enter sends, Shift+Enter is a new line, and the draft is kept per conversation. Paste, drop or attach (📎) images: each is saved by POST /chat/image and its path goes in the message, which Claude Code attaches as [Image #N]. Sending into a conversation that isn't running here starts it (like an explicit open) and waits until Claude is idle. The box reads the live screen too: while Claude works, an activity line shows its spinner text with Interrupt (Esc in an empty box does the same; Send only ever sends). What you send shows in the chat at once, marked Sending or, while Claude is mid-turn, Queued, until the log has it; a queued one has Unsend (it leaves Claude Code's queue the way the terminal does it: ↑ pulls the queue into the prompt, Ctrl+U clears it, the others are queued again; the queue is read from the log's queue-operation lines) and Interrupt; a long message (over 400 characters or 6 lines) folds to a few lines behind Show more · N lines; hovering one of your messages shows Rewind, which runs /rewind back to just before it (a session not running here is started first, and one mid-turn is stopped first with Esc, waiting until it is idle; Claude Code then asks what to restore, as a card), after which the chat drops what came after (a prompt whose parent is older than an earlier prompt marks the rewind in the log) and the message comes back into the box to edit; messages typed mid-turn are logged as queued_command attachments, and the chat shows those as your messages too; a permission prompt, AskUserQuestion or plan approval shows as a card of buttons (or keys 1–9) that press that option, and nothing is pasted or Entered while one is up; the Mode picker presses Shift+Tab until the footer shows Default, Accept edits, Plan or Bypass (never while a question is up). Slash commands: type / and a list of commands shows above the box (Claude Code's built-in ones, the bundled skills, and the commands and skills in ~/.claude and the conversation's folder and repo, from GET /chat/commands); arrows move, Tab puts the name in the box for its arguments, Enter runs it, Esc cl
hooks/register.ts 102 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Fleet View's live feed. Fleet View reads each session's state from its log and from Claude Code's
4// sessions/<pid>.json, and has to guess whether a turn is still open: a turn that went quiet for 5 minutes
5// may be a long command or a stuck one (STALLED?). This mod knows. On every turn start and end and every
6// tool call it writes %LOCALAPPDATA%\fleet-view\live\<sessionId>.json, which Fleet View reads with the pid
7// files. The file is the whole state each time, so a missed write is healed by the next one.
8
9const VERSION = 1
10
11type Running = { id: string; tool: string; what: string; agent: string | null; at: number }
12
13// what the session is doing now; the module's variables start over on a reload, as the process does
14const loadedAt = Date.now()
15let turnOpen = false
16let turnAt = 0
17let lastTurn: { reason: string; at: number; ms: number } | null = null
18const running = new Map<string, Running>()
19let seq = 0
20
21// a turn's start and end are the main loop's: a background subagent's calls run on past them
22const clearMain = () => { for (const [id, r] of running) if (!r.agent) running.delete(id) }
23
24// one line on what a tool call does, from its arguments: the command, the file, the pattern
25const whatOf = (e: Record<string, unknown>): string => {
26 for (const k of ['description', 'command', 'file_path', 'path', 'pattern', 'url', 'query', 'prompt', 'skill']) {
27 const v = e[k]
28 if (typeof v === 'string' && v.trim()) return v.trim().replace(/\s+/g, ' ').slice(0, 160)
29 }
30 return ''
31}
32
33let dir: string | null = null
34let chain: Promise<void> = Promise.resolve()
35
36// writes in order, one at a time; a failed write (a full disk, a locked file) never touches the session
37function publish($: EngineInterface) {
38 const n = ++seq
39 chain = chain.then(async () => {
40 if (n !== seq) return // a newer state is queued behind this one: write that instead
41 try {
42 if (!dir) {
43 const local = (await $.env.get('LOCALAPPDATA')) || `${(await $.env.get('USERPROFILE')) || ''}\\AppData\\Local`
44 dir = `${local}\\fleet-view\\live`
45 }
46 const sessionId = await $.session.id()
47 const tools = [...running.values()]
48 await $.fs.write(`${dir}\\${sessionId}.json`, JSON.stringify({
49 v: VERSION, sessionId, loadedAt, at: Date.now(),
50 turnOpen, turnAt: turnOpen ? turnAt : null, lastTurn,
51 tools: tools.slice(-8),
52 }))
53 } catch {}
54 })
55}
56
57export const register: Register = on => {
58 on('session.start', async ($, e, next) => {
59 const r = await next(e)
60 publish($)
61 return r
62 })
63
64 on('turn.start', async ($, e, next) => {
65 turnOpen = true
66 turnAt = Date.now()
67 clearMain()
68 publish($)
69 return next(e)
70 })
71
72 on('turn.complete', async ($, e, next) => {
73 turnOpen = false
74 lastTurn = { reason: e.reason, at: Date.now(), ms: e.durationMs }
75 clearMain()
76 publish($)
77 return next(e)
78 })
79
80 // the tool runs inside next(): it is running from here until next settles (a permission prompt included,
81 // which sessions/<pid>.json reports as waiting)
82 on('tool.call', async ($, e, next) => {
83 const id = e.tool_use_id || `t${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
84 running.set(id, { id, tool: String(e.tool), what: whatOf(e as unknown as Record<string, unknown>), agent: e.agentId || null, at: Date.now() })
85 publish($)
86 try {
87 return await next(e)
88 } finally {
89 running.delete(id)
90 publish($)
91 }
92 })
93
94 on('session.end', async ($, e, next) => {
95 turnOpen = false
96 running.clear()
97 publish($)
98 await chain
99 return next(e)
100 })
101}
102