@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/server.js ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+ const http = require('node:http')
4
+ const fs = require('node:fs')
5
+ const path = require('node:path')
6
+ const { randomBytes, timingSafeEqual } = require('node:crypto')
7
+ const { collect, transcriptFor } = require('./fleet.js')
8
+ const { ManagedSessions } = require('./managed.js')
9
+ const { readTheme, themeCss } = require('./theme.js')
10
+ const { collect: collectCatalog } = require('./catalog.js')
11
+ const { SearchJobs, warm: warmSearch, WINDOW_DAYS: SEARCH_DAYS } = require('./search.js')
12
+ const { Archive } = require('./archive.js')
13
+ const { Updater } = require('./update.js')
14
+ const { defaultCwd } = require('./paths.js')
15
+ const { version: VERSION } = require('./package.json')
16
+ const HOST = '127.0.0.1'
17
+ const MODEL_FALLBACK = [
18
+ { value:'', displayName:'Project default', description:'Whatever this project is configured to use' },
19
+ { value:'opus', displayName:'Opus', description:'Most capable' },
20
+ { value:'sonnet', displayName:'Sonnet', description:'Balanced' },
21
+ { value:'haiku', displayName:'Haiku', description:'Fastest' },
22
+ ]
23
+ const PUBLIC = path.join(__dirname,'public')
24
+ const TYPES = {'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8','.svg':'image/svg+xml','.png':'image/png','.jpg':'image/jpeg','.gif':'image/gif','.webp':'image/webp','.webmanifest':'application/manifest+json'}
25
+
26
+ function createApp({manager = new ManagedSessions({externalSessions:()=>collect().sessions}), collectSessions = collect, search = new SearchJobs(), archive = new Archive(), updater = new Updater(), restart = null} = {}) {
27
+ const token=randomBytes(32).toString('hex')
28
+ const clients=new Set(), changes=new Set()
29
+ let eventTimer=null, storageError=null
30
+ const broadcast=()=>{
31
+ eventTimer=null
32
+ const data=`event: sessions\ndata: ${JSON.stringify([...changes])}\n\n`
33
+ changes.clear()
34
+ for (const res of clients) if (!res.write(data)) { res.end(); clients.delete(res) }
35
+ }
36
+ manager.on('change',id=>{changes.add(id);if(!eventTimer) eventTimer=setTimeout(broadcast,120)})
37
+ manager.on('storage-error',error=>{storageError='Unable to save Fleet conversations. Check disk space and permissions.';console.error(error.message)})
38
+ let theme=readTheme(), themeReadAt=Date.now()
39
+ const currentTheme=()=>{
40
+ if(Date.now()-themeReadAt>30000){theme=readTheme();themeReadAt=Date.now()}
41
+ return theme
42
+ }
43
+ const heartbeat=setInterval(()=>{for(const res of clients) res.write(': heartbeat\n\n')},15000)
44
+ heartbeat.unref()
45
+ const json=(res,status,body)=>{res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});res.end(JSON.stringify(body))}
46
+ const elsewhere=(row)=>row && row.alive ? {pid:row.pid,name:row.name || row.shortId || null,entrypoint:row.entrypoint || null,background:!!row.background,state:row.state,startedAt:row.startedAt || null} : null
47
+ const getSnapshot=()=>{
48
+ const snap=collectSessions()
49
+ const managed=manager.summaries()
50
+ const managedIds=new Set(managed.map(s=>s.sessionId).filter(Boolean))
51
+ const external=snap.sessions.filter(s=>!managedIds.has(s.sessionId))
52
+ for(const s of managed) {
53
+ const transcript=snap.sessions.find(t=>t.sessionId===s.sessionId)
54
+ const holder=elsewhere(transcript)
55
+ if(holder){
56
+ s.openElsewhere=holder
57
+ s.state=holder.state==='busy' ? 'busy' : 'idle'; s.alive=true
58
+ if(transcript.turn) s.turn=transcript.turn
59
+ s.lastActivity=Math.max(s.lastActivity || 0, transcript.lastActivity || 0)
60
+ }
61
+ if(transcript){
62
+ s.branch=transcript.branch || s.branch
63
+ s.links=[...new Map([...(transcript.links||[]),...s.links].map(l=>[l.url,l])).values()].slice(-20)
64
+ s.transcriptTruncated=transcript.transcriptTruncated
65
+ // Claude names its own sessions once a conversation has taken shape. Prefer
66
+ // that over Fleet's first-prompt slice, and keep it for the detail view too.
67
+ }
68
+ // Claude names its own sessions once a conversation has taken shape. Prefer
69
+ // that over Fleet's first-prompt slice, and keep it for the detail view too.
70
+ const aiTitle=transcriptFor(s.sessionId)?.title
71
+ if(aiTitle){s.title=aiTitle;try{manager.get(s.managedId).aiTitle=aiTitle}catch{}}
72
+ }
73
+ const sessions=[...managed,...external].sort((a,b)=>{
74
+ const rank=s=>s.managedStatus==='approval'?0:s.state==='busy'?1:s.state==='idle'?2:s.state==='stale'?3:4
75
+ return rank(a)-rank(b) || (b.lastActivity||0)-(a.lastActivity||0)
76
+ })
77
+ // Archived rows are still sent, flagged: the dashboard needs them to offer an
78
+ // Archived filter, and the counts above them describe the fleet you are working.
79
+ const counts={busy:0,idle:0,stale:0,dead:0}
80
+ let archived=0
81
+ for(const s of sessions){
82
+ s.archived=archive.isArchived(s)
83
+ if(s.archived) archived++
84
+ else counts[s.state]++
85
+ }
86
+ return {...snap,sessions,counts,total:sessions.length-archived,archived,archiveRule:archive.rule,storageError}
87
+ }
88
+ const authorized=(req)=>{
89
+ const supplied=req.headers['x-fleet-token']
90
+ if(typeof supplied!=='string' || supplied.length!==token.length) return false
91
+ return timingSafeEqual(Buffer.from(supplied),Buffer.from(token))
92
+ }
93
+ async function body(req, limit = 65536) {
94
+ if (!(req.headers['content-type'] || '').startsWith('application/json')) throw Object.assign(new Error('JSON content type is required.'),{status:415})
95
+ let size=0, chunks=[]
96
+ for await(const chunk of req){size+=chunk.length;if(size>limit) throw Object.assign(new Error(limit > 65536 ? 'Attachments are too large for one message.' : 'Request is too large.'),{status:413});chunks.push(chunk)}
97
+ let data
98
+ try{data=JSON.parse(Buffer.concat(chunks).toString('utf8'))}catch{throw Object.assign(new Error('Invalid JSON.'),{status:400})}
99
+ if(!data || typeof data!=='object' || Array.isArray(data)) throw Object.assign(new Error('Expected a JSON object.'),{status:400})
100
+ return data
101
+ }
102
+ const server=http.createServer(async(req,res)=>{
103
+ res.setHeader('X-Content-Type-Options','nosniff')
104
+ res.setHeader('Referrer-Policy','no-referrer')
105
+ res.setHeader('X-Frame-Options','DENY')
106
+ res.setHeader('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
107
+ try{
108
+ const port=server.address()?.port
109
+ const hosts=new Set([`127.0.0.1:${port}`,`localhost:${port}`])
110
+ if(!hosts.has(req.headers.host)) return json(res,403,{error:'Invalid host.'})
111
+ const origin=req.headers.origin
112
+ if(origin && origin!==`http://${req.headers.host}`) return json(res,403,{error:'Cross-origin requests are not allowed.'})
113
+ if(req.headers['sec-fetch-site']==='cross-site') return json(res,403,{error:'Cross-site requests are not allowed.'})
114
+ const url=new URL(req.url,`http://${req.headers.host}`)
115
+ if(req.method==='POST') {
116
+ if(!authorized(req)) return json(res,403,{error:'Reload Fleet before sending commands.'})
117
+ // Stopping an agent and installing an update both stay available when the
118
+ // session store is unwritable: one is an escape hatch, the other may be the fix.
119
+ if(storageError && url.pathname!=='/api/update' && !/^\/api\/managed\/[\w-]+\/stop$/.test(url.pathname)) return json(res,503,{error:storageError})
120
+ // Only the two endpoints that carry a message accept image-sized bodies.
121
+ const carriesMessage=url.pathname==='/api/managed' || /^\/api\/managed\/[\w-]+\/messages$/.test(url.pathname)
122
+ const data=await body(req, carriesMessage ? 40 * 1024 * 1024 : 65536)
123
+ if(url.pathname==='/api/managed') return json(res,201,{session:manager.detail(manager.create(data).id)})
124
+ // Keyword hits come back at once; the answer is fetched by id while Claude reads them.
125
+ if(url.pathname==='/api/search') return json(res,201,{job:search.start(data)})
126
+ if(url.pathname==='/api/archive') return json(res,200,{changed:archive.set(data.ids,data.archived!==false),archived:archive.archived.size})
127
+ if(url.pathname==='/api/archive/rule') return json(res,200,{rule:archive.setRule(data)})
128
+ if(url.pathname==='/api/update'){
129
+ const update=await updater.apply()
130
+ // The new code is on disk but this process is still the old one. Hand the
131
+ // port over once the answer has been written, so the page knows to wait.
132
+ if(restart) setTimeout(()=>{restart().catch(error=>console.error(error.message))},250).unref()
133
+ return json(res,200,{update:{...update,restarting:!!restart}})
134
+ }
135
+ const match=url.pathname.match(/^\/api\/managed\/([\w-]+)\/(messages|stop|mode|model|close|approvals\/([\w-]+))$/)
136
+ if(!match) return json(res,404,{error:'Unknown action.'})
137
+ const [,id,action,approvalId]=match
138
+ if(action==='close') return json(res,200,{closed:await manager.remove(id)})
139
+ if(action==='messages') manager.send(id,data)
140
+ else if(action==='stop') manager.stop(id)
141
+ else if(action==='mode') manager.setMode(id,data)
142
+ else if(action==='model') manager.setModelChoice(id,data)
143
+ else manager.decide(id,approvalId,data)
144
+ return json(res,200,{session:manager.detail(id)})
145
+ }
146
+ if(req.method!=='GET') return json(res,405,{error:'Method not allowed.'})
147
+ if(url.pathname==='/api/control') return json(res,200,{token,version:VERSION,defaultCwd:defaultCwd(),maxConcurrent:4,storageError,searchDays:SEARCH_DAYS,theme:{name:currentTheme().name,source:currentTheme().source}})
148
+ if(url.pathname==='/api/update'){
149
+ // Answer from the cache and refresh behind the request: a page load should
150
+ // never wait on npm's registry, and the dashboard asks again shortly after.
151
+ updater.check().catch(()=>{})
152
+ return json(res,200,{update:updater.status()})
153
+ }
154
+ if(url.pathname==='/manifest.webmanifest'){
155
+ const theme=currentTheme()
156
+ res.writeHead(200,{'content-type':TYPES['.webmanifest'],'cache-control':'no-cache'})
157
+ return res.end(JSON.stringify({
158
+ name:'Claude Fleet', short_name:'Fleet', description:'Local control room for Claude Code sessions',
159
+ start_url:'/', scope:'/', display:'standalone', orientation:'any',
160
+ background_color:theme.background, theme_color:theme.background,
161
+ icons:[192,512].map(size=>({src:`/icons/fleet-${size}.png`,sizes:`${size}x${size}`,type:'image/png',purpose:'any maskable'})),
162
+ }))
163
+ }
164
+ if(url.pathname==='/theme.css'){
165
+ res.writeHead(200,{'content-type':TYPES['.css'],'cache-control':'no-cache'})
166
+ return res.end(themeCss(currentTheme()))
167
+ }
168
+ if(url.pathname==='/api/models') return json(res,200,{models:manager.models || MODEL_FALLBACK})
169
+ if(url.pathname==='/api/sessions') return json(res,200,getSnapshot())
170
+ if(url.pathname==='/api/events') {
171
+ if(clients.size>=20) return json(res,429,{error:'Too many dashboard connections.'})
172
+ res.writeHead(200,{'content-type':'text/event-stream','cache-control':'no-store','connection':'keep-alive','x-accel-buffering':'no'})
173
+ res.write(': connected\n\n');clients.add(res)
174
+ req.on('close',()=>clients.delete(res));return
175
+ }
176
+ const searchJob=url.pathname.match(/^\/api\/search\/([\w-]+)$/)
177
+ if(searchJob) return json(res,200,{job:search.get(searchJob[1])})
178
+ const commands=url.pathname.match(/^\/api\/managed\/([\w-]+)\/commands$/)
179
+ if(commands) return json(res,200,{commands:collectCatalog(manager.detail(commands[1]).cwd)})
180
+ const attachment=url.pathname.match(/^\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg|gif|webp))$/)
181
+ if(attachment){
182
+ let data
183
+ try{ data=await fs.promises.readFile(path.join(manager.attachmentsDir,attachment[1])) }
184
+ catch{ return json(res,404,{error:'Attachment not found.'}) }
185
+ res.writeHead(200,{'content-type':TYPES['.'+attachment[2]],'cache-control':'private, max-age=31536000, immutable','content-length':data.length})
186
+ return res.end(data)
187
+ }
188
+ const detail=url.pathname.match(/^\/api\/managed\/([\w-]+)$/)
189
+ if(detail){
190
+ const session=manager.detail(detail[1])
191
+ const holder=elsewhere(collectSessions().sessions.find(t=>t.sessionId===session.sessionId))
192
+ if(holder) session.openElsewhere=holder
193
+ return json(res,200,{session})
194
+ }
195
+ const files={'/':'index.html','/index.html':'index.html','/styles.css':'styles.css','/app.js':'app.js','/control.js':'control.js','/blocks.js':'blocks.js','/ask.js':'ask.js','/vendor/libs.js':path.join('vendor','libs.js'),'/icons/fleet-192.png':path.join('icons','fleet-192.png'),'/icons/fleet-512.png':path.join('icons','fleet-512.png')}
196
+ const file=files[url.pathname]
197
+ if(!file) return json(res,404,{error:'Not found.'})
198
+ const data=await fs.promises.readFile(path.join(PUBLIC,file))
199
+ res.writeHead(200,{'content-type':TYPES[path.extname(file)],'cache-control':'no-cache'});res.end(data)
200
+ }catch(error){if(!res.headersSent) json(res,error.status||500,{error:error.status ? error.message : 'Fleet could not complete the request. Check the server log.'});else res.end();if(!error.status) console.error(error)}
201
+ })
202
+ server.requestTimeout=15000
203
+ server.headersTimeout=10000
204
+ async function close(){clearTimeout(eventTimer);clearInterval(heartbeat);for(const res of clients)res.end();server.close();await Promise.all([manager.close(),search.close()])}
205
+ return {server,manager,search,archive,updater,close,getSnapshot}
206
+ }
207
+
208
+ // Starting the server is the CLI's job too, so it lives in a function rather than
209
+ // in a `require.main` block: bin/claude-fleet.js calls this, which keeps argv[1]
210
+ // pointing at the installed command that a restart needs to re-run.
211
+ function main(){
212
+ let app
213
+ // Hand the port to the version that was just installed. argv[1] is the entry npm
214
+ // put on PATH; the update replaced what it points at, so re-running it runs the
215
+ // new code. The session lock and the port are both released by close() first.
216
+ const restart=async()=>{
217
+ const entry=process.argv[1]
218
+ const held=app.server.address()?.port || port
219
+ // The page that asked for this is already open and waiting to be reloaded, so
220
+ // the replacement must not raise a second window. Dropping --open is not enough:
221
+ // with no command, the CLI opens one by default. --no-open says it outright.
222
+ const args=[...process.argv.slice(2).filter(a=>a!=='--open'),'--no-open']
223
+ await app.close()
224
+ const child=require('node:child_process').spawn(process.execPath,[entry,...args],{detached:true,stdio:'ignore',env:{...process.env,PORT:String(held)}})
225
+ child.on('error',error=>{console.error(`Could not restart Fleet: ${error.message}`);process.exit(1)})
226
+ child.unref()
227
+ setTimeout(()=>process.exit(0),100).unref()
228
+ }
229
+ try{app=createApp({restart})}catch(error){console.error(error.message);process.exit(1)}
230
+ let attempt=0,port=Number(process.env.PORT||7777)
231
+ app.server.on('error',async error=>{
232
+ if(error.code==='EADDRINUSE' && attempt++<10){app.server.listen(++port,HOST);return}
233
+ console.error(error.message);await app.close();process.exit(1)
234
+ })
235
+ app.server.on('listening',()=>{
236
+ const url=`http://${HOST}:${app.server.address().port}`
237
+ console.log(`\n Claude Fleet v${VERSION} → ${url}\n Local dashboard + managed agents · ctrl-c to stop\n`)
238
+ // Index transcripts in the background so the first question does not wait for it.
239
+ setTimeout(()=>warmSearch().catch(()=>{}),1500).unref()
240
+ // And ask npm whether there is a newer Fleet, well after the page has loaded.
241
+ 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
+ }
247
+ })
248
+ app.server.listen(port,HOST)
249
+ let closing=false
250
+ const shutdown=async()=>{if(closing)return;closing=true;await app.close();process.exit(0)}
251
+ process.on('SIGINT',shutdown);process.on('SIGTERM',shutdown)
252
+ return app
253
+ }
254
+ if(require.main===module) main()
255
+ module.exports={createApp,main}
package/theme.js ADDED
@@ -0,0 +1,89 @@
1
+ 'use strict'
2
+ // Reads the colours out of the local Warp configuration so Fleet matches the terminal
3
+ // the operator already uses. Nothing here is required: every failure falls back to the
4
+ // built-in palette, and only colour and font-size values are ever read.
5
+ const fs = require('node:fs')
6
+ const os = require('node:os')
7
+ const path = require('node:path')
8
+
9
+ const HEX = /^#[0-9a-fA-F]{3,8}$/
10
+ const SLOTS = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
11
+ const FALLBACK = {
12
+ name: 'Fleet Default',
13
+ background: '#0c1014', foreground: '#99d1ce', accent: '#2aa889', cursor: '#2aa889',
14
+ normal: { black: '#0c1014', red: '#c23127', green: '#2aa889', yellow: '#edb443', blue: '#195466', magenta: '#4e5166', cyan: '#33859e', white: '#99d1ce' },
15
+ bright: { black: '#11151c', red: '#d26937', green: '#2aa889', yellow: '#edb443', blue: '#195466', magenta: '#888ca6', cyan: '#33859e', white: '#d3ebe9' },
16
+ fontSize: 13,
17
+ source: null,
18
+ }
19
+
20
+ const colour = value => (typeof value === 'string' && HEX.test(value.trim()) ? value.trim() : null)
21
+ const unquote = value => value.trim().replace(/^["']|["']$/g, '').trim()
22
+
23
+ // A deliberately small YAML reader: Warp theme files are two levels of `key: value`.
24
+ function parseThemeYaml(text) {
25
+ const out = {}
26
+ let section = null, group = null
27
+ for (const raw of text.split('\n')) {
28
+ const line = raw.replace(/\t/g, ' ')
29
+ if (!line.trim() || line.trim().startsWith('#')) continue
30
+ const indent = line.length - line.trimStart().length
31
+ const match = line.trim().match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/)
32
+ if (!match) continue
33
+ const [, key, rest] = match
34
+ const value = unquote(rest)
35
+ if (indent === 0) { section = key; group = null; if (value) out[key] = value; else out[key] = {} }
36
+ else if (indent <= 3) { group = key; if (section && typeof out[section] === 'object') { if (value) out[section][key] = value; else out[section][key] = {} } }
37
+ else if (section && group && out[section]?.[group] && typeof out[section][group] === 'object') out[section][group][key] = value
38
+ }
39
+ return out
40
+ }
41
+
42
+ // Warp's settings.toml, read only for the active theme path and the terminal font size.
43
+ function parseSettings(text) {
44
+ const themeLine = text.match(/^\s*theme\s*=\s*(.+)$/m)?.[1] ?? ''
45
+ const fontSize = Number(text.match(/^\s*font_size\s*=\s*([\d.]+)/m)?.[1])
46
+ return {
47
+ themePath: unquote(themeLine.match(/path\s*=\s*("[^"]*"|'[^']*')/)?.[1] ?? '') || null,
48
+ fontSize: Number.isFinite(fontSize) && fontSize >= 8 && fontSize <= 32 ? fontSize : null,
49
+ }
50
+ }
51
+
52
+ function readTheme(warpDirectory = process.env.CLAUDE_FLEET_WARP_DIR || path.join(os.homedir(), '.warp')) {
53
+ const theme = structuredClone(FALLBACK)
54
+ try {
55
+ const settingsFile = path.join(warpDirectory, 'settings.toml')
56
+ if (!fs.existsSync(settingsFile)) return theme
57
+ const settings = parseSettings(fs.readFileSync(settingsFile, 'utf8'))
58
+ if (settings.fontSize) theme.fontSize = settings.fontSize
59
+ if (!settings.themePath) return theme
60
+ // Confine the lookup to the themes directory; a settings file never picks arbitrary paths.
61
+ const themesDirectory = path.join(warpDirectory, 'themes')
62
+ const file = path.resolve(themesDirectory, settings.themePath)
63
+ if (!file.startsWith(themesDirectory + path.sep) || !fs.existsSync(file)) return theme
64
+ if (fs.statSync(file).size > 64 * 1024) return theme
65
+ const parsed = parseThemeYaml(fs.readFileSync(file, 'utf8'))
66
+ theme.source = path.basename(file)
67
+ if (typeof parsed.name === 'string' && parsed.name.trim()) theme.name = parsed.name.trim().slice(0, 60)
68
+ for (const key of ['background', 'foreground', 'accent', 'cursor']) theme[key] = colour(parsed[key]) || theme[key]
69
+ for (const group of ['normal', 'bright']) for (const slot of SLOTS) {
70
+ theme[group][slot] = colour(parsed.terminal_colors?.[group]?.[slot]) || theme[group][slot]
71
+ }
72
+ } catch { return structuredClone(FALLBACK) }
73
+ return theme
74
+ }
75
+
76
+ function themeCss(theme) {
77
+ const lines = [
78
+ `--w-bg: ${theme.background};`,
79
+ `--w-fg: ${theme.foreground};`,
80
+ `--w-accent: ${theme.accent};`,
81
+ `--w-cursor: ${theme.cursor};`,
82
+ `--w-font-size: ${theme.fontSize}px;`,
83
+ ...SLOTS.map(slot => `--w-${slot}: ${theme.normal[slot]};`),
84
+ ...SLOTS.map(slot => `--w-bright-${slot}: ${theme.bright[slot]};`),
85
+ ]
86
+ return `/* Generated from ${theme.source ? `~/.warp/themes/${theme.source}` : 'the Fleet fallback palette'} — "${theme.name}". */\n:root {\n ${lines.join('\n ')}\n}\n`
87
+ }
88
+
89
+ module.exports = { readTheme, themeCss, parseThemeYaml, parseSettings, FALLBACK }
package/update.js ADDED
@@ -0,0 +1,183 @@
1
+ 'use strict'
2
+ // Fleet checks npm for a newer published version and can install it on request.
3
+ // The check is cached on disk and rate-limited, because a dashboard that phones a
4
+ // registry on every page load is a dashboard nobody trusts. Nothing installs on
5
+ // its own: the operator clicks.
6
+ const fs = require('node:fs')
7
+ const path = require('node:path')
8
+ const { spawn } = require('node:child_process')
9
+ const { stateDir } = require('./paths')
10
+ const pkg = require('./package.json')
11
+
12
+ const REGISTRY = process.env.CLAUDE_FLEET_REGISTRY || 'https://registry.npmjs.org'
13
+ const CHECK_EVERY = 6 * 60 * 60 * 1000
14
+ const CHECK_TIMEOUT = 6000
15
+ const INSTALL_TIMEOUT = 5 * 60 * 1000
16
+
17
+ function parse(value) {
18
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(value || '').trim())
19
+ return match ? { nums: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4] || null } : null
20
+ }
21
+ // Deliberately narrow: is `candidate` a release the operator should be offered?
22
+ // A prerelease never qualifies, so a stray `1.2.0-beta.1` on the registry cannot
23
+ // nag every install in the world.
24
+ function isNewer(candidate, current) {
25
+ const a = parse(candidate), b = parse(current)
26
+ if (!a || !b || a.pre) return false
27
+ for (let i = 0; i < 3; i++) if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i]
28
+ return !!b.pre
29
+ }
30
+ // How this copy of Fleet got here decides whether it can replace itself.
31
+ // A git checkout updates with `git pull`; only an npm install can be npm-installed over.
32
+ function detectChannel(dir = __dirname) {
33
+ if (dir.split(path.sep).includes('node_modules')) return 'npm'
34
+ if (fs.existsSync(path.join(dir, '.git'))) return 'source'
35
+ return 'unknown'
36
+ }
37
+
38
+ async function fetchLatest(name) {
39
+ const url = `${REGISTRY}/${name.replace('/', '%2f')}/latest`
40
+ const response = await fetch(url, {
41
+ headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
42
+ signal: AbortSignal.timeout(CHECK_TIMEOUT),
43
+ })
44
+ if (!response.ok) throw new Error(`Registry answered ${response.status}`)
45
+ const data = await response.json()
46
+ if (!data || typeof data.version !== 'string') throw new Error('Registry returned no version')
47
+ return data.version
48
+ }
49
+
50
+ function npmInstall(spec) {
51
+ return new Promise((resolve, reject) => {
52
+ const child = spawn('npm', ['install', '--global', '--no-fund', '--no-audit', spec], {
53
+ stdio: ['ignore', 'pipe', 'pipe'],
54
+ // npm resolves its own prefix; inheriting a project's .npmrc here would be surprising.
55
+ env: { ...process.env, npm_config_yes: 'true' },
56
+ })
57
+ let stderr = ''
58
+ child.stdout.on('data', () => {})
59
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-4000) })
60
+ const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('The install took too long and was stopped.')) }, INSTALL_TIMEOUT)
61
+ timer.unref()
62
+ child.on('error', error => { clearTimeout(timer); reject(new Error(`Could not run npm: ${error.message}`)) })
63
+ child.on('close', code => {
64
+ clearTimeout(timer)
65
+ if (code === 0) return resolve()
66
+ // npm's own last words are far more useful than "exit 1".
67
+ const tail = stderr.trim().split('\n').filter(Boolean).slice(-4).join(' · ')
68
+ reject(new Error(tail ? `npm failed: ${tail}` : `npm exited with code ${code}.`))
69
+ })
70
+ })
71
+ }
72
+
73
+ class Updater {
74
+ constructor({
75
+ directory = stateDir(),
76
+ name = pkg.name,
77
+ version = pkg.version,
78
+ channel = detectChannel(),
79
+ latestVersion = fetchLatest,
80
+ install = npmInstall,
81
+ now = Date.now,
82
+ } = {}) {
83
+ this.name = name
84
+ this.version = version
85
+ this.channel = channel
86
+ this.latestVersion = latestVersion
87
+ this.install = install
88
+ this.now = now
89
+ this.file = path.join(directory, 'update.json')
90
+ this.state = 'idle' // idle | checking | installing | installed | failed
91
+ this.error = null
92
+ this.installed = null
93
+ this.checkedAt = 0
94
+ this.latest = null
95
+ this.inFlight = null
96
+ this.load()
97
+ }
98
+ load() {
99
+ try {
100
+ const data = JSON.parse(fs.readFileSync(this.file, 'utf8'))
101
+ if (!data || data.version !== 1) return
102
+ if (Number.isFinite(data.checkedAt)) this.checkedAt = data.checkedAt
103
+ // A cached answer about a version we are no longer running says nothing.
104
+ if (typeof data.latest === 'string' && data.for === this.version) this.latest = data.latest
105
+ } catch {}
106
+ }
107
+ persist() {
108
+ try {
109
+ fs.writeFileSync(this.file, JSON.stringify({ version: 1, checkedAt: this.checkedAt, latest: this.latest, for: this.version }), { mode: 0o600 })
110
+ } catch {}
111
+ }
112
+ status() {
113
+ const available = !!this.latest && isNewer(this.latest, this.version)
114
+ return {
115
+ name: this.name,
116
+ current: this.version,
117
+ latest: this.latest,
118
+ available,
119
+ // Only an npm install can npm-install over itself; a checkout is the user's to pull.
120
+ canInstall: available && this.channel === 'npm',
121
+ channel: this.channel,
122
+ checkedAt: this.checkedAt || null,
123
+ state: this.state,
124
+ error: this.error,
125
+ installed: this.installed,
126
+ }
127
+ }
128
+ // Cheap and idempotent: concurrent callers share one request, and a fresh answer
129
+ // is reused until it goes stale.
130
+ check({ force = false } = {}) {
131
+ if (this.inFlight) return this.inFlight
132
+ if (!force && this.now() - this.checkedAt < CHECK_EVERY) return Promise.resolve(this.status())
133
+ this.state = 'checking'
134
+ this.inFlight = (async () => {
135
+ try {
136
+ this.latest = await this.latestVersion(this.name)
137
+ this.checkedAt = this.now()
138
+ this.error = null
139
+ this.persist()
140
+ } catch (error) {
141
+ // A registry that is down or blocked is not worth an alarm; it just means
142
+ // we do not know yet. Keep whatever the last known answer was.
143
+ this.error = null
144
+ this.checkedAt = this.now()
145
+ this.persist()
146
+ if (process.env.CLAUDE_FLEET_DEBUG) console.error(`Update check failed: ${error.message}`)
147
+ } finally {
148
+ if (this.state === 'checking') this.state = 'idle'
149
+ this.inFlight = null
150
+ }
151
+ return this.status()
152
+ })()
153
+ return this.inFlight
154
+ }
155
+ async apply() {
156
+ const status = this.status()
157
+ if (this.state === 'installing') { const error = new Error('An update is already installing.'); error.status = 409; throw error }
158
+ if (!status.available) { const error = new Error('Fleet is already up to date.'); error.status = 409; throw error }
159
+ if (!status.canInstall) {
160
+ const error = new Error(this.channel === 'source'
161
+ ? 'This Fleet runs from a git checkout. Update it with `git pull`.'
162
+ : 'This Fleet was not installed with npm, so it cannot update itself.')
163
+ error.status = 409
164
+ throw error
165
+ }
166
+ this.state = 'installing'
167
+ this.error = null
168
+ try {
169
+ await this.install(`${this.name}@${this.latest}`)
170
+ this.state = 'installed'
171
+ this.installed = this.latest
172
+ return this.status()
173
+ } catch (error) {
174
+ this.state = 'failed'
175
+ this.error = error.message
176
+ const failure = new Error(error.message)
177
+ failure.status = 502
178
+ throw failure
179
+ }
180
+ }
181
+ }
182
+
183
+ module.exports = { Updater, isNewer, detectChannel, CHECK_EVERY }