@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/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 }
|