@sergeychuvayev/claude-fleet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +377 -0
- package/archive.js +96 -0
- package/bin/claude-fleet.js +102 -0
- package/build/make-app.sh +110 -0
- package/catalog.js +117 -0
- package/fleet.js +450 -0
- package/managed.js +456 -0
- package/package.json +62 -0
- package/paths.js +64 -0
- package/permissions.js +73 -0
- package/public/app.js +369 -0
- package/public/ask.js +119 -0
- package/public/blocks.js +180 -0
- package/public/control.js +426 -0
- package/public/icons/fleet-192.png +0 -0
- package/public/icons/fleet-512.png +0 -0
- package/public/index.html +48 -0
- package/public/styles.css +454 -0
- package/public/vendor/libs.js +75 -0
- package/search.js +425 -0
- package/server.js +255 -0
- package/theme.js +89 -0
- package/update.js +183 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergey Chuvayev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="public/icons/fleet-512.png" width="88" alt="">
|
|
4
|
+
|
|
5
|
+
# Claude Fleet
|
|
6
|
+
|
|
7
|
+
**A local control room for Claude Code.**
|
|
8
|
+
|
|
9
|
+
See every session running on your machine, launch agents you can talk to,
|
|
10
|
+
and ask one question across everything you have ever worked on.
|
|
11
|
+
|
|
12
|
+
<img src="https://img.shields.io/badge/node-%E2%89%A522-2aa889?style=flat-square&labelColor=0c1014" alt="Node 22+">
|
|
13
|
+
<img src="https://img.shields.io/badge/binds-127.0.0.1-2aa889?style=flat-square&labelColor=0c1014" alt="Binds to localhost">
|
|
14
|
+
<img src="https://img.shields.io/badge/deps-4%20runtime-2aa889?style=flat-square&labelColor=0c1014" alt="Four runtime dependencies">
|
|
15
|
+
<img src="https://img.shields.io/badge/license-MIT-2aa889?style=flat-square&labelColor=0c1014" alt="MIT license">
|
|
16
|
+
|
|
17
|
+
<br>
|
|
18
|
+
<br>
|
|
19
|
+
|
|
20
|
+
<img src="docs/dashboard.png" alt="The Fleet dashboard: a list of Claude Code sessions on the left with status, context usage and a per-turn activity strip, and a terminal-style conversation on the right showing a syntax-highlighted diff and a failed test run.">
|
|
21
|
+
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
Claude Code is happiest in a terminal, which is fine until you have six of them.
|
|
27
|
+
Fleet gives that sprawl one window: what each session is doing, which one failed,
|
|
28
|
+
which one is about to run out of context, and which one has been waiting on you
|
|
29
|
+
for twenty minutes.
|
|
30
|
+
|
|
31
|
+
Terminal sessions are watched read-only. Fleet never injects keystrokes and never
|
|
32
|
+
kills a process it did not start. Agents you launch *from* Fleet are different:
|
|
33
|
+
those you can message, approve, interrupt and resume.
|
|
34
|
+
|
|
35
|
+
Everything runs on `127.0.0.1` against the Claude account already configured on
|
|
36
|
+
your machine. There is no service, no account, and no telemetry.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install -g @sergeychuvayev/claude-fleet
|
|
42
|
+
claude-fleet
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
That prints the URL it bound to and opens it. If port 7777 is taken, Fleet tries
|
|
46
|
+
the next ten. To try it without installing anything:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx @sergeychuvayev/claude-fleet
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
claude-fleet start # no browser window
|
|
54
|
+
PORT=8080 claude-fleet # pick a port
|
|
55
|
+
claude-fleet install-app # put "Claude Fleet" in ~/Applications (macOS)
|
|
56
|
+
claude-fleet update # install the latest published version
|
|
57
|
+
claude-fleet --help # every command and variable
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Requires **Node 22+** and a working `claude` on your PATH.
|
|
61
|
+
|
|
62
|
+
Fleet checks npm for a newer version a few times a day and shows a pill in the
|
|
63
|
+
top bar when there is one. Clicking it installs the update and reloads; nothing
|
|
64
|
+
is installed without that click. From a git checkout the pill tells you to
|
|
65
|
+
`git pull` instead of offering to overwrite your working copy.
|
|
66
|
+
|
|
67
|
+
<details>
|
|
68
|
+
<summary><b>Running from a checkout</b></summary>
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/sergey-chuvayev/claude-fleet && cd claude-fleet
|
|
72
|
+
npm install
|
|
73
|
+
./start.sh
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`start.sh` is the same entry point as the installed `claude-fleet` command, so
|
|
77
|
+
both prefer the `claude` already on your PATH over the one bundled with the SDK.
|
|
78
|
+
|
|
79
|
+
</details>
|
|
80
|
+
|
|
81
|
+
## What it does
|
|
82
|
+
|
|
83
|
+
### The session list reads like a CI job
|
|
84
|
+
|
|
85
|
+
Each row is one session: its state, project, branch, context pressure, and the
|
|
86
|
+
story of its latest turn. The strip of segments is one segment per tool call
|
|
87
|
+
since your last message, coloured by the kind of work (grey for inspecting,
|
|
88
|
+
accent for changing files, cyan for commands, violet for sub-agents) and red
|
|
89
|
+
where a call failed, attributed by exact tool id rather than by name. Beside it,
|
|
90
|
+
in words, is the step running now or the last step taken. On the right, how long
|
|
91
|
+
the turn has been going.
|
|
92
|
+
|
|
93
|
+
Colour never carries meaning alone: every segment names its call on hover, and
|
|
94
|
+
the row has a spoken summary such as *"This turn: 4 inspecting, 2 changing files,
|
|
95
|
+
1 failed. Now: Bash Run the test suite."*
|
|
96
|
+
|
|
97
|
+
| State | Meaning |
|
|
98
|
+
|---|---|
|
|
99
|
+
| **Working** | Marked busy by Claude |
|
|
100
|
+
| **Waiting** | Alive, waiting for input |
|
|
101
|
+
| **Stale** | Alive but untouched for over three days |
|
|
102
|
+
| **Offline** | Registry entry whose process has exited |
|
|
103
|
+
| **In terminal** | A Fleet conversation currently held by a terminal |
|
|
104
|
+
|
|
105
|
+
Context turns amber at 75% and red at 90%. A `[1m]` marker or observed usage
|
|
106
|
+
above 200k identifies a 1M context window.
|
|
107
|
+
|
|
108
|
+
<details>
|
|
109
|
+
<summary><b>Background sessions and sub-agents</b></summary>
|
|
110
|
+
|
|
111
|
+
Sessions a program started rather than a person (an SDK run, a plugin's worker, a
|
|
112
|
+
background indexer) are kept out of the main list and counted under a
|
|
113
|
+
**Background** filter. They are identified by a registry `entrypoint` other than
|
|
114
|
+
`cli`. The registry records no parent, so Fleet walks the process tree: when an
|
|
115
|
+
ancestor is another session, that session's row shows a `⑂ n` badge and the
|
|
116
|
+
background row reads "via that session"; when the chain leads to a daemon
|
|
117
|
+
instead, the row names the program running it.
|
|
118
|
+
|
|
119
|
+
Tools invoked inside a turn, including sub-agents from the Task tool, run in the
|
|
120
|
+
session's own process and never appear as separate rows at all.
|
|
121
|
+
|
|
122
|
+
</details>
|
|
123
|
+
|
|
124
|
+
### Old sessions can be put away
|
|
125
|
+
|
|
126
|
+
Every transcript Claude has ever written is a row, so a machine that has been
|
|
127
|
+
working for a month opens on ninety Offline sessions and three live ones. **Archive**
|
|
128
|
+
in the inspector takes one out of the list; under the **Offline** filter, a strip
|
|
129
|
+
offers to archive everything untouched past a threshold in one go, and to keep
|
|
130
|
+
doing it.
|
|
131
|
+
|
|
132
|
+
Archiving is a view, not an edit. Nothing moves and nothing is deleted:
|
|
133
|
+
`claude --resume <session id>` still reaches an archived session, Ask still finds
|
|
134
|
+
it, and **Restore** puts the row back. The set lives in `.fleet/archive.json`,
|
|
135
|
+
alongside Fleet's own conversations rather than inside `~/.claude`.
|
|
136
|
+
|
|
137
|
+
The standing rule only ever reaches sessions whose process has exited. A session
|
|
138
|
+
that is alive stays in the list however long it has been quiet, because a quiet
|
|
139
|
+
session you can still talk to is the one thing this dashboard exists to show you.
|
|
140
|
+
Restoring a session by hand also exempts it from the rule permanently, so the next
|
|
141
|
+
refresh cannot quietly undo the decision you just made.
|
|
142
|
+
|
|
143
|
+
### The conversation is a stack of blocks
|
|
144
|
+
|
|
145
|
+

|
|
146
|
+
|
|
147
|
+
Each message, tool call and tool result is its own block, the way a terminal
|
|
148
|
+
groups a command with its output. A block shows what ran, how long it took and
|
|
149
|
+
whether it failed, and can be collapsed or copied on its own. Long output starts
|
|
150
|
+
collapsed.
|
|
151
|
+
|
|
152
|
+
Tool blocks are rendered per tool: a shell command as a prompt line, an edit as a
|
|
153
|
+
diff, a to-do list as a checklist, everything else as its input. Assistant text is
|
|
154
|
+
Markdown with syntax-highlighted code. Markdown, sanitising and highlighting come
|
|
155
|
+
from `marked`, `DOMPurify` and `highlight.js`, bundled into `public/vendor/libs.js`
|
|
156
|
+
and served by Fleet itself. There is no CDN, and the page's content security policy
|
|
157
|
+
still allows scripts only from Fleet.
|
|
158
|
+
|
|
159
|
+
### Ask your sessions
|
|
160
|
+
|
|
161
|
+
**Ask** in the top bar (`Cmd/Ctrl+K`) answers a question across every Claude Code
|
|
162
|
+
transcript on this machine: *"did we ever work out why recordings went missing on
|
|
163
|
+
answered calls?"* It replaces scrolling back through `claude --resume` hunting for
|
|
164
|
+
the session where something was decided.
|
|
165
|
+
|
|
166
|
+
It runs in two stages, and you see the first one immediately.
|
|
167
|
+
|
|
168
|
+
1. **Keyword pass, local.** Fleet indexes the visible conversation of every
|
|
169
|
+
transcript modified in the last 60 days and ranks passages with BM25. Nothing
|
|
170
|
+
leaves the machine, and it takes about 10 ms once the index is warm.
|
|
171
|
+
2. **Answer pass, one Claude call.** The best ten sessions and their excerpts go
|
|
172
|
+
to a single short, tool-less turn that writes the answer and says which
|
|
173
|
+
sessions are genuinely about the question. It cannot cite a session the
|
|
174
|
+
keyword pass did not find. Haiku by default.
|
|
175
|
+
|
|
176
|
+
What gets indexed is what a person would recognise as the conversation: your
|
|
177
|
+
messages and Claude's replies. Tool calls and results are excluded, since they are
|
|
178
|
+
the bulk of a transcript and would match on file contents rather than discussion.
|
|
179
|
+
|
|
180
|
+
<details>
|
|
181
|
+
<summary><b>Why the answering turn is so bare</b></summary>
|
|
182
|
+
|
|
183
|
+
`tools: []`, no project settings, no CLAUDE.md, `persistSession: false` so a search
|
|
184
|
+
never becomes a transcript that the next search finds, and thinking disabled.
|
|
185
|
+
That last one is the whole latency budget: measured on a real corpus, adaptive
|
|
186
|
+
thinking cost 33 s and 2,800 output tokens for the same answer that takes 9 s and
|
|
187
|
+
578 tokens without it.
|
|
188
|
+
|
|
189
|
+
The `claude-mem` plugin's observer sessions are skipped entirely. They are
|
|
190
|
+
machine-written summaries of every other session, so they would out-match the real
|
|
191
|
+
conversation on every question.
|
|
192
|
+
|
|
193
|
+
</details>
|
|
194
|
+
|
|
195
|
+
### Approvals that stay out of the way
|
|
196
|
+
|
|
197
|
+
Every agent runs in one of three modes, chosen at launch and changeable from the
|
|
198
|
+
conversation header.
|
|
199
|
+
|
|
200
|
+
- **Auto** (the default) answers ordinary requests for you and still stops for
|
|
201
|
+
anything that destroys data (`rm`, `shred`, `dd`), reaches another host
|
|
202
|
+
(`curl`, `wget`, `ssh`, `rsync`), runs an unreviewable script (`sh -c`, `eval`),
|
|
203
|
+
escalates (`sudo`, `doas`), or publishes (`git push`, `npm publish`).
|
|
204
|
+
- **Ask every time** runs nothing unreviewed.
|
|
205
|
+
- **Approve everything** never stops.
|
|
206
|
+
|
|
207
|
+
A command is judged per shell segment, so `cd build && rm -rf .` is read as `rm`,
|
|
208
|
+
and wrappers like `env FOO=1`, `xargs` and `find -exec` do not hide it. A question
|
|
209
|
+
from Claude and a plan for review always reach you, in every mode. Blocks Fleet
|
|
210
|
+
approved on your behalf are marked **auto**, so a quiet run is never a silent one.
|
|
211
|
+
Your existing Claude permission rules and hooks still apply first.
|
|
212
|
+
|
|
213
|
+
That list is one array in [`permissions.js`](permissions.js). Edit it to taste.
|
|
214
|
+
|
|
215
|
+
<details>
|
|
216
|
+
<summary><b>Models, images, slash commands, and sessions held elsewhere</b></summary>
|
|
217
|
+
|
|
218
|
+
**Models.** Picked at launch and switchable from the conversation header. A change
|
|
219
|
+
applies from your next message, because each message starts a fresh query against
|
|
220
|
+
the same resumed session. The list is the runtime's own once a run has reported
|
|
221
|
+
it, and falls back to Opus/Sonnet/Haiku before then.
|
|
222
|
+
|
|
223
|
+
**Images.** Paste a screenshot into the composer or drop a file on it. Up to six
|
|
224
|
+
per message, PNG/JPEG/GIF/WebP, 8 MB each. Bytes are sniffed rather than trusted by
|
|
225
|
+
declared type, and stored owner-only under `.fleet/attachments/` with a fresh id,
|
|
226
|
+
which is the only thing the `/api/attachments/<id>` route accepts.
|
|
227
|
+
|
|
228
|
+
**Slash commands.** Type `/` at the start of a line to search this project's
|
|
229
|
+
commands and skills: yours, the project's, and each plugin's. The picker only
|
|
230
|
+
writes text into the composer, and nothing runs until you send. Claude Code's
|
|
231
|
+
built-ins (`/model`, `/clear`, `/compact`) are interpreted by the interactive CLI
|
|
232
|
+
rather than the SDK, so they are deliberately absent; where Fleet can offer the
|
|
233
|
+
same thing it does so as a real control instead.
|
|
234
|
+
|
|
235
|
+
**Held elsewhere.** If you resume a Fleet conversation in a terminal, its row
|
|
236
|
+
switches to **In terminal**, mirrors what that terminal is doing, and the composer
|
|
237
|
+
says which window has it and since when. Fleet refuses to send until that process
|
|
238
|
+
exits, because two writers on one transcript would corrupt it.
|
|
239
|
+
|
|
240
|
+
**Limits.** Four simultaneous runs, one turn per agent, up to 100 managed
|
|
241
|
+
conversations, messages up to 16,000 characters. The latest 200 conversation
|
|
242
|
+
entries persist in `.fleet/sessions.json`. Claude keeps its own full transcript, so
|
|
243
|
+
`claude --resume <session id>` still reaches a conversation Fleet has forgotten.
|
|
244
|
+
|
|
245
|
+
</details>
|
|
246
|
+
|
|
247
|
+
### It borrows your terminal's colours
|
|
248
|
+
|
|
249
|
+
Fleet reads the active theme named in `~/.warp/settings.toml`, loads it from
|
|
250
|
+
`~/.warp/themes/`, and serves it as CSS variables at `/theme.css`. Surfaces and
|
|
251
|
+
muted text are mixed from the terminal's own background and foreground with
|
|
252
|
+
`color-mix()`, so any Warp theme produces a coherent dashboard rather than a
|
|
253
|
+
clashing one.
|
|
254
|
+
|
|
255
|
+
Only colour values and the terminal font size are read, a theme file outside the
|
|
256
|
+
themes directory is ignored, and anything that is not a hex colour is discarded.
|
|
257
|
+
Without Warp installed, Fleet uses its own palette. Set `CLAUDE_FLEET_WARP_DIR` to
|
|
258
|
+
read a different directory.
|
|
259
|
+
|
|
260
|
+
The app icons are drawn geometrically from that same palette by `npm run icons`,
|
|
261
|
+
with no image library, so rerun it if you switch themes.
|
|
262
|
+
|
|
263
|
+
### It can live in the Dock
|
|
264
|
+
|
|
265
|
+
`claude-fleet install-app` puts **Claude Fleet.app** in `~/Applications`. Opening
|
|
266
|
+
it starts the server if it is not already listening, then opens Fleet in a Chrome
|
|
267
|
+
app window with no tab strip or address bar. It falls back to Edge, then Brave,
|
|
268
|
+
then your default browser, and logs to `~/Library/Logs/claude-fleet.log`. The
|
|
269
|
+
bundle holds no credentials and no copy of the project, only the paths to node and
|
|
270
|
+
to Fleet's entry point — which npm keeps stable, so updates do not break it. If you
|
|
271
|
+
later switch Node versions with a version manager, rerun `claude-fleet install-app`;
|
|
272
|
+
until you do, the app says so in a notification rather than failing silently.
|
|
273
|
+
|
|
274
|
+
Fleet also serves a web app manifest, so you can install it from the browser
|
|
275
|
+
instead: in Chrome, **⋮ → Cast, Save and Share → Install page as app**; in Safari,
|
|
276
|
+
**File → Add to Dock**.
|
|
277
|
+
|
|
278
|
+
## Local boundary
|
|
279
|
+
|
|
280
|
+
Fleet binds to `127.0.0.1`. It rejects unrecognised Host headers and cross-origin
|
|
281
|
+
requests, requires a per-server token for actions, serves only explicit UI assets,
|
|
282
|
+
and does not enable CORS. **Do not expose this server through a public proxy.**
|
|
283
|
+
|
|
284
|
+
Managed agents can modify files and run tools as permitted by your Claude settings
|
|
285
|
+
and approvals. Monitoring external sessions only ever reads their state, and
|
|
286
|
+
archiving one changes only Fleet's own record of what to show.
|
|
287
|
+
|
|
288
|
+
Asking a question reads every transcript in the window, including sessions from
|
|
289
|
+
other projects, and sends the matched excerpts (not whole transcripts) to Claude as
|
|
290
|
+
one prompt. That is the only part of Fleet that leaves the machine.
|
|
291
|
+
|
|
292
|
+
Local state files use owner-only permissions. A process lock prevents two Fleet
|
|
293
|
+
servers from controlling the same stored conversations. Graceful shutdown cancels
|
|
294
|
+
managed runs and pending approvals; stopping Fleet does not stop external agents.
|
|
295
|
+
|
|
296
|
+
GitHub PR and Linear issue links shown on a row are extracted from visible
|
|
297
|
+
conversation text. They are recorded references, not live PR or ticket status, and
|
|
298
|
+
no API credentials are involved.
|
|
299
|
+
|
|
300
|
+
## How it is built
|
|
301
|
+
|
|
302
|
+
Vanilla HTML, CSS and JavaScript over a Node HTTP server, with the official Claude
|
|
303
|
+
Agent SDK for managed runs. Four runtime dependencies, no framework, no build step
|
|
304
|
+
for the app itself.
|
|
305
|
+
|
|
306
|
+
| File | Responsibility |
|
|
307
|
+
|---|---|
|
|
308
|
+
| [`server.js`](server.js) | Local HTTP API, event stream, origin and token checks, static assets |
|
|
309
|
+
| [`fleet.js`](fleet.js) | Cached, read-only collection of external Claude sessions |
|
|
310
|
+
| [`archive.js`](archive.js) | Which sessions are put away, the age rule, and its store |
|
|
311
|
+
| [`managed.js`](managed.js) | SDK runs, approvals, tool blocks, persistence, cancellation |
|
|
312
|
+
| [`search.js`](search.js) | Transcript index, BM25 ranking, and the answering turn |
|
|
313
|
+
| [`permissions.js`](permissions.js) | The three approval modes and the command list that still stops |
|
|
314
|
+
| [`theme.js`](theme.js) | Reads the local Warp palette and renders it as CSS variables |
|
|
315
|
+
| [`catalog.js`](catalog.js) | Read-only listing of a project's slash commands and skills |
|
|
316
|
+
| `public/app.js` | Dashboard layout, session list, filters, monitoring |
|
|
317
|
+
| `public/blocks.js` | Incremental block rendering, Markdown, highlighting |
|
|
318
|
+
| `public/control.js` | Launch form, composer, approvals, streamed updates |
|
|
319
|
+
| `public/ask.js` | The Ask panel, its polling, and the result cards |
|
|
320
|
+
| [`paths.js`](paths.js) | Where Fleet's own state lives, and carrying over an old checkout's |
|
|
321
|
+
| [`update.js`](update.js) | The npm version check, its cache, and the self-install |
|
|
322
|
+
| [`bin/claude-fleet.js`](bin/claude-fleet.js) | The installed command: start, install-app, update |
|
|
323
|
+
| `build/` | Vendored browser bundle, icon drawing, macOS launcher |
|
|
324
|
+
|
|
325
|
+
Fleet keeps its own state — conversations, attachments, the archive, the process
|
|
326
|
+
lock — in `~/.claude-fleet`, never in the install directory, which npm replaces on
|
|
327
|
+
every update. A pre-install `.fleet/` next to a checkout is copied over on first
|
|
328
|
+
run and left in place.
|
|
329
|
+
|
|
330
|
+
### Environment
|
|
331
|
+
|
|
332
|
+
| Variable | Effect |
|
|
333
|
+
|---|---|
|
|
334
|
+
| `PORT` | Preferred port, default 7777 |
|
|
335
|
+
| `CLAUDE_FLEET_HOME` | Where Fleet keeps its own state, default `~/.claude-fleet` |
|
|
336
|
+
| `CLAUDE_FLEET_DEFAULT_CWD` | Directory a new agent starts in when none is picked |
|
|
337
|
+
| `CLAUDE_FLEET_DIR` | Claude home to read sessions from, default `~/.claude` |
|
|
338
|
+
| `CLAUDE_FLEET_EXECUTABLE` | Absolute path to the `claude` binary, or `bundled` for the SDK's own |
|
|
339
|
+
| `CLAUDE_FLEET_WARP_DIR` | Warp configuration directory to theme from |
|
|
340
|
+
| `CLAUDE_FLEET_SEARCH_DAYS` | How far back Ask indexes transcripts, default 60 |
|
|
341
|
+
| `CLAUDE_FLEET_SEARCH_MODEL` | Model for the Ask answering turn |
|
|
342
|
+
|
|
343
|
+
The SDK ships its own Claude runtime, which can lag the CLI you actually use and
|
|
344
|
+
so offer an older set of models. The `claude-fleet` command therefore prefers the
|
|
345
|
+
`claude` on your PATH. Running `node server.js` directly does not apply this
|
|
346
|
+
preference.
|
|
347
|
+
|
|
348
|
+
### Development
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
npm test # node --test across *.test.js
|
|
352
|
+
npm run vendor # rebuild public/vendor/libs.js after changing its inputs
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Tests run against a throwaway `CLAUDE_FLEET_HOME` (see `test-setup.js`), so a test
|
|
356
|
+
run never touches your real state.
|
|
357
|
+
|
|
358
|
+
Releasing is a tag push. `npm version patch && git push --follow-tags` runs the
|
|
359
|
+
suite, checks the tag against `package.json`, and publishes to npm with provenance.
|
|
360
|
+
|
|
361
|
+
`app.js`, `blocks.js` and `control.js` are classic scripts sharing one global
|
|
362
|
+
scope, so a duplicate top-level `const` across files is a `SyntaxError` that kills
|
|
363
|
+
the page and `node --check` cannot see it. The test suite loads all three in one VM
|
|
364
|
+
context and fails on any such collision. **Run `npm test` after touching a browser
|
|
365
|
+
script.**
|
|
366
|
+
|
|
367
|
+
Fleet reads `index.html` from disk per request, but its static allowlist is held in
|
|
368
|
+
memory, so an old process will serve a new page whose new assets 404. Restart after
|
|
369
|
+
adding a route.
|
|
370
|
+
|
|
371
|
+
## License
|
|
372
|
+
|
|
373
|
+
MIT. See [LICENSE](LICENSE).
|
|
374
|
+
|
|
375
|
+
<div align="center">
|
|
376
|
+
<sub>Screenshots use synthetic sessions generated for the purpose. Fleet is not affiliated with Anthropic.</sub>
|
|
377
|
+
</div>
|
package/archive.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Which saved sessions the operator has put away. Archiving is a view over
|
|
3
|
+
// ~/.claude, never a change to it: an archived session still resumes with
|
|
4
|
+
// `claude --resume` and still answers an Ask. Fleet's own state, in ~/.claude-fleet.
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const { stateDir } = require('./paths')
|
|
8
|
+
|
|
9
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
10
|
+
const DEFAULT_RULE = { enabled: false, days: 14 }
|
|
11
|
+
// Two bounded maps rather than one unbounded log. Far past this, the oldest
|
|
12
|
+
// decisions have stopped mattering and the file should not keep growing.
|
|
13
|
+
const MAX_ENTRIES = 5000
|
|
14
|
+
|
|
15
|
+
const clampDays = (value, fallback) => {
|
|
16
|
+
const days = Number(value)
|
|
17
|
+
return Number.isFinite(days) ? Math.min(365, Math.max(1, Math.round(days))) : fallback
|
|
18
|
+
}
|
|
19
|
+
function toMap(list) {
|
|
20
|
+
const map = new Map()
|
|
21
|
+
if (!Array.isArray(list)) return map
|
|
22
|
+
for (const entry of list) {
|
|
23
|
+
if (!Array.isArray(entry)) continue
|
|
24
|
+
const [id, at] = entry
|
|
25
|
+
if (typeof id === 'string' && id && Number.isFinite(at)) map.set(id, at)
|
|
26
|
+
}
|
|
27
|
+
return map
|
|
28
|
+
}
|
|
29
|
+
// Newest wins: an archive that has outgrown its cap drops the sessions put away longest ago.
|
|
30
|
+
const trim = map => map.size <= MAX_ENTRIES ? map
|
|
31
|
+
: new Map([...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, MAX_ENTRIES))
|
|
32
|
+
|
|
33
|
+
class Archive {
|
|
34
|
+
constructor({ directory = stateDir() } = {}) {
|
|
35
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
36
|
+
this.file = path.join(directory, 'archive.json')
|
|
37
|
+
this.archived = new Map() // sessionId -> when it was put away
|
|
38
|
+
this.kept = new Map() // sessionId -> when it was restored; exempt from the age rule
|
|
39
|
+
this.rule = { ...DEFAULT_RULE }
|
|
40
|
+
this.load()
|
|
41
|
+
}
|
|
42
|
+
load() {
|
|
43
|
+
let data
|
|
44
|
+
// A corrupt or absent archive means nothing is hidden, which is the safe failure.
|
|
45
|
+
try { data = JSON.parse(fs.readFileSync(this.file, 'utf8')) } catch { return }
|
|
46
|
+
if (!data || typeof data !== 'object' || data.version !== 1) return
|
|
47
|
+
this.rule = { enabled: !!(data.rule && data.rule.enabled), days: clampDays(data.rule && data.rule.days, DEFAULT_RULE.days) }
|
|
48
|
+
this.archived = toMap(data.archived)
|
|
49
|
+
this.kept = toMap(data.kept)
|
|
50
|
+
}
|
|
51
|
+
// Disk first, memory second: a failed write leaves Fleet showing what is actually stored.
|
|
52
|
+
commit({ rule = this.rule, archived = this.archived, kept = this.kept }) {
|
|
53
|
+
const tmp = `${this.file}.${process.pid}.tmp`
|
|
54
|
+
const body = JSON.stringify({ version: 1, rule, archived: [...archived], kept: [...kept] })
|
|
55
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 })
|
|
56
|
+
fs.renameSync(tmp, this.file)
|
|
57
|
+
this.rule = rule
|
|
58
|
+
this.archived = archived
|
|
59
|
+
this.kept = kept
|
|
60
|
+
}
|
|
61
|
+
// The age rule only ever reaches sessions whose process has exited. A session that
|
|
62
|
+
// is alive, however long it has been quiet, stays in the list where it can be acted on.
|
|
63
|
+
matchesRule(session, now = Date.now()) {
|
|
64
|
+
if (!this.rule.enabled || session.state !== 'dead') return false
|
|
65
|
+
return !!session.lastActivity && now - session.lastActivity > this.rule.days * DAY
|
|
66
|
+
}
|
|
67
|
+
isArchived(session, now = Date.now()) {
|
|
68
|
+
const id = session && session.sessionId
|
|
69
|
+
// A Fleet conversation is closed, not archived; it has its own lifecycle.
|
|
70
|
+
if (!id || session.managed) return false
|
|
71
|
+
if (this.archived.has(id)) return true
|
|
72
|
+
if (this.kept.has(id)) return false
|
|
73
|
+
return this.matchesRule(session, now)
|
|
74
|
+
}
|
|
75
|
+
set(ids, archived) {
|
|
76
|
+
const list = [...new Set((Array.isArray(ids) ? ids : []).filter(id => typeof id === 'string' && id && id.length <= 200))]
|
|
77
|
+
if (!list.length) return 0
|
|
78
|
+
if (list.length > MAX_ENTRIES) throw Object.assign(new Error('Too many sessions in one request.'), { status: 413 })
|
|
79
|
+
const at = Date.now()
|
|
80
|
+
const next = { archived: new Map(this.archived), kept: new Map(this.kept) }
|
|
81
|
+
for (const id of list) {
|
|
82
|
+
if (archived) { next.archived.set(id, at); next.kept.delete(id) }
|
|
83
|
+
// Restoring has to outrank the age rule, or the next refresh puts it straight back.
|
|
84
|
+
else { next.archived.delete(id); next.kept.set(id, at) }
|
|
85
|
+
}
|
|
86
|
+
this.commit({ archived: trim(next.archived), kept: trim(next.kept) })
|
|
87
|
+
return list.length
|
|
88
|
+
}
|
|
89
|
+
setRule(input) {
|
|
90
|
+
const rule = { enabled: !!(input && input.enabled), days: clampDays(input && input.days, this.rule.days) }
|
|
91
|
+
this.commit({ rule })
|
|
92
|
+
return this.rule
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { Archive, DEFAULT_RULE, MAX_ENTRIES }
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
// The installed command. Everything Fleet does from a terminal goes through here,
|
|
4
|
+
// which also means argv[1] is a stable path that survives an in-place npm update —
|
|
5
|
+
// that is what lets the server re-run itself after installing a new version.
|
|
6
|
+
const fs = require('node:fs')
|
|
7
|
+
const path = require('node:path')
|
|
8
|
+
const { spawnSync } = require('node:child_process')
|
|
9
|
+
|
|
10
|
+
const ROOT = path.join(__dirname, '..')
|
|
11
|
+
const pkg = require(path.join(ROOT, 'package.json'))
|
|
12
|
+
|
|
13
|
+
const HELP = `
|
|
14
|
+
Claude Fleet v${pkg.version} — a local control room for Claude Code sessions
|
|
15
|
+
|
|
16
|
+
claude-fleet start Fleet and open the dashboard
|
|
17
|
+
claude-fleet start start it without opening a browser
|
|
18
|
+
claude-fleet install-app put a "Claude Fleet" app in ~/Applications (macOS)
|
|
19
|
+
claude-fleet update install the latest published version
|
|
20
|
+
claude-fleet --version print the version
|
|
21
|
+
claude-fleet --help this
|
|
22
|
+
|
|
23
|
+
Environment
|
|
24
|
+
PORT port to listen on (default 7777, next free one if taken)
|
|
25
|
+
CLAUDE_FLEET_HOME where Fleet keeps its own state (default ~/.claude-fleet)
|
|
26
|
+
CLAUDE_FLEET_DIR the Claude directory to read (default ~/.claude)
|
|
27
|
+
CLAUDE_FLEET_EXECUTABLE the claude binary to run; "bundled" uses the SDK's own
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
// A PATH walk rather than `command -v`, because this has to work without a shell
|
|
31
|
+
// and on Windows.
|
|
32
|
+
function which(command) {
|
|
33
|
+
const extensions = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : ['']
|
|
34
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
35
|
+
if (!dir) continue
|
|
36
|
+
for (const extension of extensions) {
|
|
37
|
+
const candidate = path.join(dir, command + extension)
|
|
38
|
+
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate } catch {}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The SDK ships its own Claude runtime, which can lag the CLI you actually use and
|
|
45
|
+
// therefore offer an older set of models. Prefer the installed CLI, so Fleet's
|
|
46
|
+
// agents run the same Claude as your terminals.
|
|
47
|
+
function preferInstalledClaude() {
|
|
48
|
+
const pinned = process.env.CLAUDE_FLEET_EXECUTABLE
|
|
49
|
+
if (pinned === 'bundled') { delete process.env.CLAUDE_FLEET_EXECUTABLE; return null }
|
|
50
|
+
if (pinned) return pinned
|
|
51
|
+
const found = which('claude')
|
|
52
|
+
if (found) process.env.CLAUDE_FLEET_EXECUTABLE = found
|
|
53
|
+
return found
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function start({ open }) {
|
|
57
|
+
const claude = preferInstalledClaude()
|
|
58
|
+
if (claude) console.log(` Using your Claude CLI: ${claude}`)
|
|
59
|
+
if (open && !process.argv.includes('--open')) process.argv.push('--open')
|
|
60
|
+
require(path.join(ROOT, 'server.js')).main()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function installApp() {
|
|
64
|
+
if (process.platform !== 'darwin') {
|
|
65
|
+
console.error('install-app builds a macOS app bundle. On other platforms, use your browser\'s "Install app" option instead.')
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
const node = process.execPath
|
|
69
|
+
// Bake in absolute paths: a GUI app launched from Finder gets a minimal PATH and
|
|
70
|
+
// would not find node, nvm or Homebrew.
|
|
71
|
+
const result = spawnSync('bash', [path.join(ROOT, 'build', 'make-app.sh')], {
|
|
72
|
+
stdio: 'inherit',
|
|
73
|
+
env: { ...process.env, PROJECT: ROOT, NODE_BIN: node, FLEET_BIN: path.join(ROOT, 'bin', 'claude-fleet.js') },
|
|
74
|
+
})
|
|
75
|
+
process.exit(result.status === null ? 1 : result.status)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function update() {
|
|
79
|
+
const { Updater } = require(path.join(ROOT, 'update.js'))
|
|
80
|
+
const updater = new Updater()
|
|
81
|
+
updater.check({ force: true }).then(async status => {
|
|
82
|
+
if (!status.available) return console.log(` Claude Fleet v${status.current} is up to date.`)
|
|
83
|
+
if (!status.canInstall) {
|
|
84
|
+
console.log(` v${status.latest} is out. This copy runs from ${status.channel === 'source' ? 'a git checkout — update it with `git pull`' : 'an install npm does not manage'}.`)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
console.log(` Installing Claude Fleet v${status.latest}…`)
|
|
88
|
+
try { await updater.apply(); console.log(` Done. v${status.latest} is installed.`) }
|
|
89
|
+
catch (error) { console.error(` ${error.message}`); process.exitCode = 1 }
|
|
90
|
+
}).catch(error => { console.error(` ${error.message}`); process.exitCode = 1 })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const [command] = process.argv.slice(2).filter(argument => !argument.startsWith('-'))
|
|
94
|
+
const flags = new Set(process.argv.slice(2).filter(argument => argument.startsWith('-')))
|
|
95
|
+
|
|
96
|
+
if (flags.has('--help') || flags.has('-h') || command === 'help') console.log(HELP)
|
|
97
|
+
else if (flags.has('--version') || flags.has('-v') || command === 'version') console.log(pkg.version)
|
|
98
|
+
else if (command === 'install-app') installApp()
|
|
99
|
+
else if (command === 'update') update()
|
|
100
|
+
else if (command === 'start') start({ open: false })
|
|
101
|
+
else if (!command) start({ open: !flags.has('--no-open') })
|
|
102
|
+
else { console.error(`Unknown command: ${command}\n${HELP}`); process.exit(1) }
|