@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.
@@ -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 the dashboard
17
- claude-fleet start start it without opening a browser
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, String(process.pid), { flag: 'wx', mode: 0o600 }); return }
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 pid = Number(fs.readFileSync(this.lock, 'utf8'))
87
- if (!Number.isInteger(pid) || pid <= 0) throw new Error(`Invalid Fleet lock file; inspect ${this.lock}.`)
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
- throw new Error(`Fleet controls are already running (PID ${pid}). Open that server instead.`)
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 (fs.readFileSync(this.lock, 'utf8') === String(process.pid)) fs.unlinkSync(this.lock) } catch {}
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.1.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){console.error(error.message);process.exit(1)}
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