@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 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
+ ![The Fleet console showing a rendered Markdown answer with an inline table, above a composer](docs/console.png)
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) }