@sergeychuvayev/claude-fleet 0.1.0 → 0.2.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/bin/claude-fleet.js +9 -2
- package/managed.js +29 -6
- package/open.js +94 -0
- package/package.json +2 -1
- package/server.js +19 -6
package/bin/claude-fleet.js
CHANGED
|
@@ -13,18 +13,25 @@ const pkg = require(path.join(ROOT, 'package.json'))
|
|
|
13
13
|
const HELP = `
|
|
14
14
|
Claude Fleet v${pkg.version} — a local control room for Claude Code sessions
|
|
15
15
|
|
|
16
|
-
claude-fleet start Fleet and open
|
|
17
|
-
claude-fleet start start it without opening
|
|
16
|
+
claude-fleet start Fleet and open it as an app window
|
|
17
|
+
claude-fleet start start it without opening anything
|
|
18
|
+
claude-fleet --browser open a normal browser tab instead of an app window
|
|
18
19
|
claude-fleet install-app put a "Claude Fleet" app in ~/Applications (macOS)
|
|
19
20
|
claude-fleet update install the latest published version
|
|
20
21
|
claude-fleet --version print the version
|
|
21
22
|
claude-fleet --help this
|
|
22
23
|
|
|
24
|
+
The app window is a Chromium window with no tab strip and no address bar. If Fleet
|
|
25
|
+
is already running, claude-fleet puts that server on screen rather than starting a
|
|
26
|
+
second one.
|
|
27
|
+
|
|
23
28
|
Environment
|
|
24
29
|
PORT port to listen on (default 7777, next free one if taken)
|
|
25
30
|
CLAUDE_FLEET_HOME where Fleet keeps its own state (default ~/.claude-fleet)
|
|
26
31
|
CLAUDE_FLEET_DIR the Claude directory to read (default ~/.claude)
|
|
27
32
|
CLAUDE_FLEET_EXECUTABLE the claude binary to run; "bundled" uses the SDK's own
|
|
33
|
+
CLAUDE_FLEET_BROWSER the Chromium to open the app window with
|
|
34
|
+
CLAUDE_FLEET_APP_PROFILE where that window keeps its profile
|
|
28
35
|
`
|
|
29
36
|
|
|
30
37
|
// A PATH walk rather than `command -v`, because this has to work without a shell
|
package/managed.js
CHANGED
|
@@ -78,22 +78,45 @@ class ManagedSessions extends EventEmitter {
|
|
|
78
78
|
}
|
|
79
79
|
} catch (error) { this.releaseLock(); throw new Error(`Cannot read Fleet session store: ${error.message}`) }
|
|
80
80
|
}
|
|
81
|
+
// The lock used to hold a bare PID. It now holds {pid, port} so that a second
|
|
82
|
+
// `claude-fleet` can put the running dashboard on screen instead of only naming the
|
|
83
|
+
// process that beat it to the lock. A bare PID is still read: the file outlives an
|
|
84
|
+
// upgrade, and a stale one must not read as corrupt.
|
|
85
|
+
readLock() {
|
|
86
|
+
const raw = fs.readFileSync(this.lock, 'utf8').trim()
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(raw)
|
|
89
|
+
if (Number.isInteger(parsed?.pid) && parsed.pid > 0) return { pid: parsed.pid, port: Number(parsed.port) || null }
|
|
90
|
+
} catch {}
|
|
91
|
+
const pid = Number(raw)
|
|
92
|
+
return Number.isInteger(pid) && pid > 0 ? { pid, port: null } : null
|
|
93
|
+
}
|
|
81
94
|
acquireLock() {
|
|
82
95
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
83
|
-
try { fs.writeFileSync(this.lock,
|
|
96
|
+
try { fs.writeFileSync(this.lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 }); return }
|
|
84
97
|
catch (error) {
|
|
85
98
|
if (error.code !== 'EEXIST') throw error
|
|
86
|
-
const
|
|
87
|
-
if (!
|
|
88
|
-
try { process.kill(pid, 0) }
|
|
99
|
+
const held = this.readLock()
|
|
100
|
+
if (!held) throw new Error(`Invalid Fleet lock file; inspect ${this.lock}.`)
|
|
101
|
+
try { process.kill(held.pid, 0) }
|
|
89
102
|
catch (e) { if (e.code === 'ESRCH') { fs.unlinkSync(this.lock); continue } }
|
|
90
|
-
|
|
103
|
+
// Carries the holder so main() can open it rather than print and exit 1. Being
|
|
104
|
+
// already running is the normal case, not a failure.
|
|
105
|
+
const conflict = new Error(`Fleet controls are already running (PID ${held.pid})`)
|
|
106
|
+
conflict.code = 'FLEET_ALREADY_RUNNING'
|
|
107
|
+
conflict.holder = held
|
|
108
|
+
throw conflict
|
|
91
109
|
}
|
|
92
110
|
}
|
|
93
111
|
throw new Error('Cannot acquire Fleet session lock')
|
|
94
112
|
}
|
|
113
|
+
// Called once the server knows which port it actually got, which is not always the
|
|
114
|
+
// one it asked for: the listen path walks upwards past anything already bound.
|
|
115
|
+
recordPort(port) {
|
|
116
|
+
try { fs.writeFileSync(this.lock, JSON.stringify({ pid: process.pid, port }), { mode: 0o600 }) } catch {}
|
|
117
|
+
}
|
|
95
118
|
releaseLock() {
|
|
96
|
-
try { if (
|
|
119
|
+
try { if (this.readLock()?.pid === process.pid) fs.unlinkSync(this.lock) } catch {}
|
|
97
120
|
}
|
|
98
121
|
save() {
|
|
99
122
|
clearTimeout(this.saveTimer); this.saveTimer = null
|
package/open.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// How the dashboard gets on screen. Fleet is a desktop tool in everything but
|
|
3
|
+
// packaging, so the default is a Chromium app window — no tab strip, no address
|
|
4
|
+
// bar, its own Dock entry — rather than a tab that goes missing among forty others.
|
|
5
|
+
//
|
|
6
|
+
// The profile directory is shared with the bundle `install-app` builds, on purpose.
|
|
7
|
+
// Chrome keys localStorage and window state to the profile, so a terminal launch and
|
|
8
|
+
// a Spotlight launch have to point at the same one or they become two windows that
|
|
9
|
+
// disagree about which sessions you had open.
|
|
10
|
+
const fs = require('node:fs')
|
|
11
|
+
const os = require('node:os')
|
|
12
|
+
const path = require('node:path')
|
|
13
|
+
|
|
14
|
+
// Tried in order. Chrome first because it is the likeliest to be installed and its
|
|
15
|
+
// `--app` behaviour has been stable for years.
|
|
16
|
+
const MAC_BROWSERS = [
|
|
17
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
18
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
19
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
20
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
21
|
+
]
|
|
22
|
+
const UNIX_BROWSERS = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge', 'brave-browser']
|
|
23
|
+
const WIN_BROWSERS = [
|
|
24
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
25
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
26
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const executable = file => { try { fs.accessSync(file, fs.constants.X_OK); return true } catch { return false } }
|
|
30
|
+
|
|
31
|
+
// Absolute paths are checked as given; bare names are walked down PATH, because an
|
|
32
|
+
// app launched from Finder or a .desktop file has no shell to resolve them for it.
|
|
33
|
+
function locate(candidate, { env, exists }) {
|
|
34
|
+
if (candidate.includes(path.sep) || candidate.includes('/')) return exists(candidate) ? candidate : null
|
|
35
|
+
for (const dir of (env.PATH || '').split(path.delimiter)) {
|
|
36
|
+
if (!dir) continue
|
|
37
|
+
const full = path.join(dir, candidate)
|
|
38
|
+
if (exists(full)) return full
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Kept in step with build/make-app.sh. If one of them moves, both move.
|
|
44
|
+
function profileDir({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
|
|
45
|
+
if (env.CLAUDE_FLEET_APP_PROFILE) return path.resolve(env.CLAUDE_FLEET_APP_PROFILE)
|
|
46
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'ClaudeFleetApp')
|
|
47
|
+
if (platform === 'win32') return path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'ClaudeFleetApp')
|
|
48
|
+
return path.join(env.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'ClaudeFleetApp')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// CLAUDE_FLEET_BROWSER wins outright, so an operator on a Chromium this does not know
|
|
52
|
+
// about is never stuck with a plain tab.
|
|
53
|
+
function findBrowser({ env = process.env, platform = process.platform, exists = executable } = {}) {
|
|
54
|
+
const known = platform === 'darwin' ? MAC_BROWSERS : platform === 'win32' ? WIN_BROWSERS : UNIX_BROWSERS
|
|
55
|
+
const candidates = env.CLAUDE_FLEET_BROWSER ? [env.CLAUDE_FLEET_BROWSER, ...known] : known
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
const found = locate(candidate, { env, exists })
|
|
58
|
+
if (found) return found
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The plain-tab route, for when no Chromium is installed or the operator asked for it.
|
|
64
|
+
function browserCommand(url, platform) {
|
|
65
|
+
if (platform === 'darwin') return ['open', [url]]
|
|
66
|
+
if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]]
|
|
67
|
+
return ['xdg-open', [url]]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Returns what it did, so the caller can say so and the tests can assert on it without
|
|
71
|
+
// launching anything: {mode:'app'|'browser', command, args}.
|
|
72
|
+
function openDashboard(url, {
|
|
73
|
+
env = process.env,
|
|
74
|
+
platform = process.platform,
|
|
75
|
+
home = os.homedir(),
|
|
76
|
+
exists = executable,
|
|
77
|
+
spawn = require('node:child_process').spawn,
|
|
78
|
+
app = true,
|
|
79
|
+
} = {}) {
|
|
80
|
+
const browser = app ? findBrowser({ env, platform, exists }) : null
|
|
81
|
+
const [command, args] = browser
|
|
82
|
+
// --app is what drops the tab strip and the address bar. The separate profile also
|
|
83
|
+
// keeps Fleet out of the way of whatever the operator has open for actual browsing.
|
|
84
|
+
? [browser, [`--app=${url}`, `--user-data-dir=${profileDir({ env, platform, home })}`]]
|
|
85
|
+
: browserCommand(url, platform)
|
|
86
|
+
// Detached: the window has to outlive `claude-fleet` when the server is already up
|
|
87
|
+
// and this process is about to exit.
|
|
88
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true })
|
|
89
|
+
child.on('error', () => {})
|
|
90
|
+
child.unref()
|
|
91
|
+
return { mode: browser ? 'app' : 'browser', command, args }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { openDashboard, findBrowser, profileDir, MAC_BROWSERS, UNIX_BROWSERS, WIN_BROWSERS }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sergeychuvayev/claude-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A local control room for Claude Code sessions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"catalog.js",
|
|
30
30
|
"fleet.js",
|
|
31
31
|
"managed.js",
|
|
32
|
+
"open.js",
|
|
32
33
|
"paths.js",
|
|
33
34
|
"permissions.js",
|
|
34
35
|
"search.js",
|
package/server.js
CHANGED
|
@@ -12,6 +12,7 @@ const { SearchJobs, warm: warmSearch, WINDOW_DAYS: SEARCH_DAYS } = require('./se
|
|
|
12
12
|
const { Archive } = require('./archive.js')
|
|
13
13
|
const { Updater } = require('./update.js')
|
|
14
14
|
const { defaultCwd } = require('./paths.js')
|
|
15
|
+
const { openDashboard } = require('./open.js')
|
|
15
16
|
const { version: VERSION } = require('./package.json')
|
|
16
17
|
const HOST = '127.0.0.1'
|
|
17
18
|
const MODEL_FALLBACK = [
|
|
@@ -226,7 +227,21 @@ function main(){
|
|
|
226
227
|
child.unref()
|
|
227
228
|
setTimeout(()=>process.exit(0),100).unref()
|
|
228
229
|
}
|
|
229
|
-
try{app=createApp({restart})}catch(error){
|
|
230
|
+
try{app=createApp({restart})}catch(error){
|
|
231
|
+
// Already running is the everyday case, not a crash: put the window the operator
|
|
232
|
+
// asked for on screen and leave quietly. Exiting 1 with no window was the whole
|
|
233
|
+
// reason a second `claude-fleet` looked like a broken one.
|
|
234
|
+
if(error.code==='FLEET_ALREADY_RUNNING'){
|
|
235
|
+
// A lock written by an older Fleet carries no port, so fall back to the one this
|
|
236
|
+
// run would have asked for. Every lock written from here on records the real one.
|
|
237
|
+
const running=error.holder?.port||Number(process.env.PORT||7777)
|
|
238
|
+
const target=`http://${HOST}:${running}`
|
|
239
|
+
console.log(`\n ${error.message} → ${target}\n`)
|
|
240
|
+
if(process.argv.includes('--open')) openDashboard(target,{app:!process.argv.includes('--browser')})
|
|
241
|
+
process.exit(0)
|
|
242
|
+
}
|
|
243
|
+
console.error(error.message);process.exit(1)
|
|
244
|
+
}
|
|
230
245
|
let attempt=0,port=Number(process.env.PORT||7777)
|
|
231
246
|
app.server.on('error',async error=>{
|
|
232
247
|
if(error.code==='EADDRINUSE' && attempt++<10){app.server.listen(++port,HOST);return}
|
|
@@ -234,16 +249,14 @@ function main(){
|
|
|
234
249
|
})
|
|
235
250
|
app.server.on('listening',()=>{
|
|
236
251
|
const url=`http://${HOST}:${app.server.address().port}`
|
|
252
|
+
// Write the port where the next `claude-fleet` will look for it.
|
|
253
|
+
app.manager.recordPort(app.server.address().port)
|
|
237
254
|
console.log(`\n Claude Fleet v${VERSION} → ${url}\n Local dashboard + managed agents · ctrl-c to stop\n`)
|
|
238
255
|
// Index transcripts in the background so the first question does not wait for it.
|
|
239
256
|
setTimeout(()=>warmSearch().catch(()=>{}),1500).unref()
|
|
240
257
|
// And ask npm whether there is a newer Fleet, well after the page has loaded.
|
|
241
258
|
setTimeout(()=>app.updater.check().catch(()=>{}),5000).unref()
|
|
242
|
-
if(process.argv.includes('--open')) {
|
|
243
|
-
const opener=process.platform==='darwin'?'open':'xdg-open'
|
|
244
|
-
const child=require('node:child_process').spawn(opener,[url],{stdio:'ignore'})
|
|
245
|
-
child.on('error',()=>{});child.unref()
|
|
246
|
-
}
|
|
259
|
+
if(process.argv.includes('--open')) openDashboard(url,{app:!process.argv.includes('--browser')})
|
|
247
260
|
})
|
|
248
261
|
app.server.listen(port,HOST)
|
|
249
262
|
let closing=false
|