@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/managed.js ADDED
@@ -0,0 +1,456 @@
1
+ 'use strict'
2
+ const fs = require('node:fs')
3
+ const path = require('node:path')
4
+ const os = require('node:os')
5
+ const { randomUUID } = require('node:crypto')
6
+ const { EventEmitter } = require('node:events')
7
+ const { gitBranch, turnSummary, toolTarget } = require('./fleet')
8
+ const { askReason, normaliseMode, MODES, DEFAULT_MODE } = require('./permissions')
9
+ const { stateDir } = require('./paths')
10
+
11
+ const ACTIVE = new Set(['starting', 'running', 'approval', 'stopping'])
12
+ // Used until a live run reports the runtime's own list, which replaces it.
13
+ const FALLBACK_MODELS = [
14
+ { value: '', displayName: 'Project default', description: 'Whatever this project is configured to use' },
15
+ { value: 'opus', displayName: 'Opus', description: 'Most capable' },
16
+ { value: 'sonnet', displayName: 'Sonnet', description: 'Balanced' },
17
+ { value: 'haiku', displayName: 'Haiku', description: 'Fastest' },
18
+ ]
19
+ const MAX_MESSAGES = 200
20
+ // Pasted images: a handful per message, bounded in size, only the formats the API
21
+ // accepts, and sniffed by magic bytes because the client's declared type is a claim.
22
+ const MAX_IMAGES = 6
23
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024
24
+ const IMAGE_TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' }
25
+ function sniffImage(buffer) {
26
+ if (buffer.length < 12) return null
27
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return 'image/png'
28
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'
29
+ if (buffer.toString('ascii', 0, 4) === 'GIF8') return 'image/gif'
30
+ if (buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
31
+ return null
32
+ }
33
+ const MAX_TOOL_INPUT = 2000
34
+ const MAX_TOOL_RESULT = 6000
35
+ // Tool names whose result is the point of the block; others are summarised by their input.
36
+ const QUIET_RESULT = new Set(['TodoWrite', 'Write', 'Edit', 'NotebookEdit'])
37
+ function fail(message, status = 400) { const error = new Error(message); error.status = status; throw error }
38
+ function text(value, name, max) {
39
+ if (typeof value !== 'string' || !value.trim() || value.length > max) fail(`${name} must contain 1–${max} characters.`)
40
+ return value.trim()
41
+ }
42
+ function requestId(value) {
43
+ if (typeof value !== 'string' || !/^[\w-]{8,80}$/.test(value)) fail('A valid request ID is required.')
44
+ return value
45
+ }
46
+
47
+ class ManagedSessions extends EventEmitter {
48
+ constructor({ directory = stateDir(), queryFactory, externalSessions = () => [] } = {}) {
49
+ super()
50
+ this.directory = directory
51
+ this.queryFactory = queryFactory || (async args => (await import('@anthropic-ai/claude-agent-sdk')).query(args))
52
+ this.externalSessions = externalSessions
53
+ this.sessions = new Map()
54
+ this.runs = new Map()
55
+ this.pending = new Map()
56
+ this.closed = false
57
+ this.models = null
58
+ this.saveTimer = null
59
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
60
+ this.file = path.join(directory, 'sessions.json')
61
+ this.attachmentsDir = path.join(directory, 'attachments')
62
+ fs.mkdirSync(this.attachmentsDir, { recursive: true, mode: 0o700 })
63
+ this.lock = path.join(directory, 'server.lock')
64
+ this.acquireLock()
65
+ try {
66
+ if (fs.existsSync(this.file)) {
67
+ const data = JSON.parse(fs.readFileSync(this.file, 'utf8'))
68
+ if (data.version !== 1 || !Array.isArray(data.sessions)) throw new Error('Unsupported session store format')
69
+ for (const s of data.sessions) {
70
+ if (!s.id || !Array.isArray(s.messages)) throw new Error('Invalid saved session')
71
+ if (ACTIVE.has(s.status)) { s.status = 'stopped'; s.error = 'Fleet restarted. Send a message to continue this conversation.' }
72
+ s.approvals = []
73
+ s.currentTool = null
74
+ for (const m of s.messages) if (m.role === 'tool' && m.status === 'running') m.status = 'interrupted'
75
+ s.approvalMode = normaliseMode(s.approvalMode)
76
+ this.sessions.set(s.id, s)
77
+ }
78
+ }
79
+ } catch (error) { this.releaseLock(); throw new Error(`Cannot read Fleet session store: ${error.message}`) }
80
+ }
81
+ acquireLock() {
82
+ for (let attempt = 0; attempt < 2; attempt++) {
83
+ try { fs.writeFileSync(this.lock, String(process.pid), { flag: 'wx', mode: 0o600 }); return }
84
+ catch (error) {
85
+ 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) }
89
+ 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.`)
91
+ }
92
+ }
93
+ throw new Error('Cannot acquire Fleet session lock')
94
+ }
95
+ releaseLock() {
96
+ try { if (fs.readFileSync(this.lock, 'utf8') === String(process.pid)) fs.unlinkSync(this.lock) } catch {}
97
+ }
98
+ save() {
99
+ clearTimeout(this.saveTimer); this.saveTimer = null
100
+ const tmp = `${this.file}.${process.pid}.tmp`
101
+ fs.writeFileSync(tmp, JSON.stringify({version:1,sessions:[...this.sessions.values()].map(s => ({...s,approvals:[]}))}), {mode:0o600})
102
+ fs.renameSync(tmp, this.file)
103
+ }
104
+ changed(s, immediate = false) {
105
+ s.updatedAt = Date.now()
106
+ if (immediate) this.save()
107
+ else if (!this.saveTimer) this.saveTimer = setTimeout(() => {
108
+ try { this.save() } catch (error) { this.emit('storage-error', error) }
109
+ }, 750)
110
+ this.emit('change', s.id)
111
+ }
112
+ get(id) { const s = this.sessions.get(id); if (!s) fail('Session not found.',404); return s }
113
+ detail(id) { return structuredClone(this.get(id)) }
114
+ summaries() {
115
+ return [...this.sessions.values()].map(s => ({
116
+ managedId:s.id, sessionId:s.sessionId, shortId:(s.sessionId || s.id).slice(0,8),
117
+ name:s.name, title:s.name, branch:gitBranch(s.cwd), cwd:s.cwd, cwdShort:s.cwd.replace(os.homedir(),'~'),
118
+ state:ACTIVE.has(s.status) && s.status !== 'approval' ? 'busy' : 'idle',
119
+ managedStatus:s.status, managed:true, alive:ACTIVE.has(s.status), pid:null,
120
+ lastActivity:s.updatedAt, startedAt:s.createdAt, lastPrompt:s.lastPrompt,
121
+ latestResponse:[...s.messages].reverse().find(m => m.role === 'assistant')?.text || null,
122
+ model:s.model, contextTokens:s.contextTokens || null, contextLimit:s.contextLimit || 200000,
123
+ permissionMode:'default', approvalMode:s.approvalMode || DEFAULT_MODE, selectedModel:s.selectedModel || '', messages:s.messages.filter(m=>m.role!=='tool').length, links:linksFromMessages(s.messages), approvals:s.approvals.length,
124
+ turn:turnSummary(managedEvents(s.messages), { working: ACTIVE.has(s.status) && s.status !== 'approval' }),
125
+ error:s.error, currentTool:s.currentTool, resumeCmd:s.sessionId ? `claude --resume ${s.sessionId}` : null,
126
+ }))
127
+ }
128
+ create(body) {
129
+ const rid = requestId(body.requestId)
130
+ const previous = [...this.sessions.values()].find(s => s.createRequestId === rid)
131
+ if (previous) return previous
132
+ if (this.closed) fail('Fleet is shutting down.',503)
133
+ if (this.sessions.size >= 100) fail('Fleet has reached its 100-session limit.',409)
134
+ let cwd = text(body.cwd,'Project directory',4096)
135
+ if (cwd === '~' || cwd.startsWith('~/')) cwd = path.join(os.homedir(), cwd.slice(1))
136
+ if (!path.isAbsolute(cwd)) fail('Use an absolute project path or ~/path.')
137
+ try { cwd = fs.realpathSync(cwd); if (!fs.statSync(cwd).isDirectory()) fail('Project path must be a directory.') }
138
+ catch { fail('Project directory does not exist or is not accessible.') }
139
+ const hasImages = Array.isArray(body.images) && body.images.length > 0
140
+ const prompt = hasImages && !(body.prompt || '').trim() ? '' : text(body.prompt,'Message',16000)
141
+ const name = body.name?.trim() ? text(body.name,'Session name',100) : (prompt || 'Image').slice(0,70)
142
+ let resume = null
143
+ if (body.resumeSessionId) {
144
+ const source = this.externalSessions().find(s => s.sessionId === body.resumeSessionId)
145
+ if (!source || source.alive || source.cwd !== cwd) fail('Only a stopped session in this project can be resumed.',409)
146
+ if ([...this.sessions.values()].some(s => s.sessionId === source.sessionId)) fail('This conversation is already managed by Fleet.',409)
147
+ resume = source.sessionId
148
+ }
149
+ this.checkCapacity()
150
+ const s = {id:randomUUID(),sessionId:resume,name,cwd,createRequestId:rid,createdAt:Date.now(),updatedAt:Date.now(),status:'idle',approvalMode:normaliseMode(body.approvalMode),selectedModel:modelChoice(body.model),messages:[],approvals:[],model:null,contextTokens:null,error:null,currentTool:null,requestIds:[]}
151
+ this.sessions.set(s.id,s)
152
+ try { this.send(s.id,{message:prompt,images:body.images,requestId:rid}) }
153
+ catch (error) { this.sessions.delete(s.id); throw error }
154
+ return s
155
+ }
156
+ checkCapacity() {
157
+ if (this.closed) fail('Fleet is shutting down.',503)
158
+ if (this.runs.size >= 4) fail('Four agents are already running. Stop one or wait for it to finish.',409)
159
+ }
160
+ send(id, body) {
161
+ const s = this.get(id)
162
+ const rid = requestId(body.requestId)
163
+ if (s.requestIds.includes(rid)) return s
164
+ const hasImages = Array.isArray(body.images) && body.images.length > 0
165
+ const message = hasImages && !(body.message || '').trim() ? '' : text(body.message,'Message',16000)
166
+ if (this.runs.has(id)) fail('This agent is still working. Stop it or wait before sending another message.',409)
167
+ // Another live process on the same session would write the same transcript.
168
+ // Refuse, and name the holder so the operator can find that window.
169
+ const holder = s.sessionId ? this.externalSessions().find(x => x.sessionId === s.sessionId && x.alive) : null
170
+ if (holder) {
171
+ const where = holder.entrypoint === 'cli' ? 'a terminal' : 'another program'
172
+ const since = holder.startedAt ? ` since ${new Date(holder.startedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : ''
173
+ fail(`This conversation is open in ${where}${holder.name ? ` (${holder.name}${since})` : since ? ` (${since.trim()})` : ''}. Fleet will not send while another process is driving the same session; close it there, or keep working there.`,409)
174
+ }
175
+ this.checkCapacity()
176
+ const attachments = hasImages ? this.saveImages(body.images) : []
177
+ s.requestIds = [...s.requestIds,rid].slice(-200)
178
+ const entry = {id:randomUUID(),role:'user',text:message,at:Date.now(),...(attachments.length ? {attachments} : {})}
179
+ s.messages.push(entry)
180
+ this.pruneMessages(s)
181
+ s.lastPrompt = message || `${attachments.length} image${attachments.length === 1 ? '' : 's'}`; s.error = null; s.status = 'starting'; s.currentTool = null
182
+ const run = {controller:new AbortController(),query:null,stopping:false,finished:false,streamText:'',assistant:null,result:false,stderr:''}
183
+ this.runs.set(id,run)
184
+ try { this.changed(s,true) }
185
+ catch (error) { this.runs.delete(id); s.status='error'; s.requestIds=s.requestIds.filter(x=>x!==rid); s.messages.pop(); throw error }
186
+ run.done = this.run(s,run,entry)
187
+ return s
188
+ }
189
+ // Validate, sniff and persist pasted images. Files are owner-only and named by a
190
+ // fresh id, so a request can never choose where on disk its bytes land.
191
+ saveImages(images) {
192
+ if (!Array.isArray(images) || images.length > MAX_IMAGES) fail(`Attach up to ${MAX_IMAGES} images per message.`)
193
+ const saved = []
194
+ for (const image of images) {
195
+ if (!image || typeof image.data !== 'string' || !image.data) fail('An attached image was empty.')
196
+ let buffer
197
+ try { buffer = Buffer.from(image.data, 'base64') } catch { fail('An attached image could not be decoded.') }
198
+ if (!buffer.length || buffer.length > MAX_IMAGE_BYTES) fail(`Each image must be under ${MAX_IMAGE_BYTES / 1024 / 1024} MB.`)
199
+ const mediaType = sniffImage(buffer)
200
+ if (!mediaType) fail('Only PNG, JPEG, GIF and WebP images can be attached.')
201
+ const id = `${randomUUID()}.${IMAGE_TYPES[mediaType]}`
202
+ fs.writeFileSync(path.join(this.attachmentsDir, id), buffer, { mode: 0o600 })
203
+ saved.push({ id, mediaType, bytes: buffer.length })
204
+ }
205
+ return saved
206
+ }
207
+ // Keep the conversation window, and delete the files of any message that fell out.
208
+ pruneMessages(s) {
209
+ if (s.messages.length <= MAX_MESSAGES) return
210
+ for (const dropped of s.messages.slice(0, s.messages.length - MAX_MESSAGES)) this.deleteAttachments(dropped)
211
+ s.messages = s.messages.slice(-MAX_MESSAGES)
212
+ }
213
+ deleteAttachments(message) {
214
+ for (const a of message.attachments || []) { try { fs.unlinkSync(path.join(this.attachmentsDir, a.id)) } catch {} }
215
+ }
216
+ // A message with images has to travel as content blocks, which the SDK accepts
217
+ // only in streaming-input form: an iterable that yields the one message and ends.
218
+ promptFor(entry) {
219
+ if (!entry.attachments?.length) return entry.text
220
+ const dir = this.attachmentsDir
221
+ return (async function* () {
222
+ const content = []
223
+ for (const a of entry.attachments) {
224
+ let data
225
+ try { data = fs.readFileSync(path.join(dir, a.id)).toString('base64') } catch { continue }
226
+ content.push({ type: 'image', source: { type: 'base64', media_type: a.mediaType, data } })
227
+ }
228
+ content.push({ type: 'text', text: entry.text || 'See the attached image.' })
229
+ yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null }
230
+ })()
231
+ }
232
+ async run(s,run,entry) {
233
+ const prompt = typeof entry === 'string' ? entry : this.promptFor(entry)
234
+ try {
235
+ const options = {
236
+ cwd:s.cwd,permissionMode:'default',settingSources:['user','project','local'],
237
+ systemPrompt:{type:'preset',preset:'claude_code'},
238
+ includePartialMessages:true,abortController:run.controller,
239
+ canUseTool:(tool,input,context) => this.ask(s,run,tool,input,context),
240
+ stderr:chunk => { run.stderr = (run.stderr+chunk).slice(-4000) },
241
+ ...(s.sessionId ? {resume:s.sessionId} : {}),
242
+ }
243
+ if (s.selectedModel) options.model = s.selectedModel
244
+ if (process.env.CLAUDE_FLEET_EXECUTABLE) options.pathToClaudeCodeExecutable = process.env.CLAUDE_FLEET_EXECUTABLE
245
+ run.query = await this.queryFactory({prompt,options})
246
+ if (run.stopping) { run.query.close(); return }
247
+ if (!this.models) run.query.supportedModels?.()
248
+ .then(list => { if (Array.isArray(list) && list.length) this.models = [FALLBACK_MODELS[0], ...list] })
249
+ .catch(() => {})
250
+ for await (const event of run.query) {
251
+ if (run.stopping) break
252
+ this.event(s,run,event)
253
+ }
254
+ if (!run.stopping && !run.result) throw new Error(run.stderr || 'Claude exited before completing the turn.')
255
+ } catch (error) {
256
+ if (!run.stopping) { s.status='error'; s.error=String(error.message || error).slice(0,4000) }
257
+ } finally {
258
+ run.finished = true
259
+ this.cancelApprovals(s.id,'The agent stopped before this request was answered.')
260
+ try { run.query?.close() } catch {}
261
+ for (const entry of run.tools?.values() || []) if (entry.status === 'running') entry.status = 'interrupted'
262
+ if (run.stopping) s.status='stopped'
263
+ else if (s.status !== 'error') s.status='idle'
264
+ s.currentTool=null
265
+ this.runs.delete(s.id)
266
+ try { this.changed(s,true) } catch (error) { this.emit('storage-error',error) }
267
+ }
268
+ }
269
+ event(s,run,event) {
270
+ if (event.session_id) s.sessionId=event.session_id
271
+ if (event.type === 'system' && event.subtype === 'init') { s.model=event.model; s.status='running' }
272
+ if (event.type === 'stream_event' && !event.parent_tool_use_id) {
273
+ if (event.event.type === 'message_start') { run.assistant=null; run.streamText='' }
274
+ if (event.event.delta?.type === 'text_delta') {
275
+ run.streamText = (run.streamText + event.event.delta.text).slice(-24000)
276
+ if (!run.assistant) { run.assistant={id:randomUUID(),role:'assistant',text:'',at:Date.now()}; s.messages.push(run.assistant); s.messages=s.messages.slice(-MAX_MESSAGES) }
277
+ run.assistant.text=run.streamText.slice(-24000)
278
+ }
279
+ if (event.event.type === 'content_block_start' && event.event.content_block?.type === 'tool_use') s.currentTool=event.event.content_block.name
280
+ }
281
+ if (event.type === 'assistant' && !event.parent_tool_use_id) {
282
+ const content=event.message.content || []
283
+ const visible=content.filter(b=>b.type==='text').map(b=>b.text).join('\n\n')
284
+ if (visible) {
285
+ if (!run.assistant) { run.assistant={id:randomUUID(),role:'assistant',text:'',at:Date.now()}; s.messages.push(run.assistant) }
286
+ run.assistant.text=visible.slice(-24000)
287
+ }
288
+ for (const block of content) if (block.type==='tool_use') this.toolStarted(s,run,block)
289
+ const tool=content.find(b=>b.type==='tool_use'); if(tool) s.currentTool=tool.name
290
+ const usage=event.message.usage
291
+ if (usage) s.contextTokens=(usage.input_tokens||0)+(usage.cache_read_input_tokens||0)+(usage.cache_creation_input_tokens||0)
292
+ if (s.contextTokens > 200000 || s.model?.includes('[1m]')) s.contextLimit=1000000
293
+ run.assistant=null; run.streamText=''
294
+ s.messages=s.messages.slice(-MAX_MESSAGES)
295
+ }
296
+ if (event.type === 'user' && !event.parent_tool_use_id) {
297
+ for (const block of event.message?.content || []) if (block.type==='tool_result') this.toolFinished(s,run,block)
298
+ }
299
+ if (event.type === 'tool_progress') s.currentTool=event.tool_name
300
+ if (event.type === 'result') {
301
+ run.result=true
302
+ if (event.is_error) { s.status='error'; s.error=event.errors?.join('\n') || event.result || 'Claude could not finish this turn.' }
303
+ else if (event.result && !s.messages.some(m=>m.role==='assistant' && m.text===event.result.slice(-24000))) s.messages.push({id:randomUUID(),role:'assistant',text:event.result.slice(-24000),at:Date.now()})
304
+ s.costUsd=(s.costUsd||0)+(event.total_cost_usd||0)
305
+ s.messages=s.messages.slice(-MAX_MESSAGES)
306
+ }
307
+ this.changed(s)
308
+ }
309
+ // A tool call becomes its own conversation entry so the UI can render it as a command block.
310
+ toolStarted(s,run,block) {
311
+ if (!block.id || run.tools?.has(block.id)) return
312
+ run.tools ||= new Map()
313
+ const entry = {
314
+ id:block.id, role:'tool', tool:block.name || 'Tool', at:Date.now(), status:'running',
315
+ input:clampInput(block.input), target:toolTarget(block.name,block.input), text:'', result:null, ms:null,
316
+ approval:askReason(block.name, block.input, s.approvalMode) ? 'asked' : 'auto',
317
+ }
318
+ run.tools.set(block.id,entry)
319
+ s.messages.push(entry)
320
+ s.messages=s.messages.slice(-MAX_MESSAGES)
321
+ }
322
+ toolFinished(s,run,block) {
323
+ const entry = run.tools?.get(block.tool_use_id) || s.messages.find(m=>m.role==='tool' && m.id===block.tool_use_id)
324
+ if (!entry || entry.status!=='running') return
325
+ entry.status = block.is_error ? 'error' : 'done'
326
+ entry.ms = Date.now()-entry.at
327
+ const result = resultText(block.content)
328
+ entry.truncated = result.length > MAX_TOOL_RESULT
329
+ entry.result = block.is_error || !QUIET_RESULT.has(entry.tool) ? result.slice(0,MAX_TOOL_RESULT) : null
330
+ }
331
+ setModelChoice(id,body) {
332
+ const s=this.get(id)
333
+ s.selectedModel=modelChoice(body.model)
334
+ this.changed(s,true)
335
+ return s
336
+ }
337
+ setMode(id,body) {
338
+ const s=this.get(id)
339
+ if (!MODES.includes(body.mode)) fail('Choose ask, auto, or all.')
340
+ s.approvalMode=body.mode
341
+ this.changed(s,true)
342
+ return s
343
+ }
344
+ ask(s,run,tool,input,context) {
345
+ if (run.stopping || context.signal.aborted) return Promise.resolve({behavior:'deny',message:'Agent stopped.'})
346
+ if (JSON.stringify(input).length > 256000) return Promise.resolve({behavior:'deny',message:'Tool input is too large for Fleet approval. Split the action into smaller steps.'})
347
+ const reason=askReason(tool,input,s.approvalMode)
348
+ if (!reason) return Promise.resolve({behavior:'allow',updatedInput:input})
349
+ return new Promise(resolve => {
350
+ const id=randomUUID()
351
+ const approval={id,tool,input,at:Date.now(),reason,description:context.title || context.decisionReason || null}
352
+ let settled=false
353
+ const finish=result=>{
354
+ if(settled)return
355
+ settled=true
356
+ context.signal.removeEventListener('abort',abort)
357
+ this.pending.delete(id); s.approvals=s.approvals.filter(p=>p.id!==id)
358
+ if (!run.stopping && !run.finished) s.status=s.approvals.length ? 'approval' : 'running'
359
+ this.changed(s); resolve(result)
360
+ }
361
+ const abort=()=>finish({behavior:'deny',message:'Request cancelled.'})
362
+ this.pending.set(id,{sessionId:s.id,input,tool,finish})
363
+ context.signal.addEventListener('abort',abort,{once:true})
364
+ s.approvals.push(approval); s.status='approval'; this.changed(s)
365
+ })
366
+ }
367
+ decide(id,approvalId,body) {
368
+ this.get(id)
369
+ const pending=this.pending.get(approvalId)
370
+ if (!pending || pending.sessionId!==id) fail('This approval is no longer pending.',409)
371
+ if (!['allow','deny'].includes(body.decision)) fail('Choose allow or deny.')
372
+ if (body.decision==='deny') pending.finish({behavior:'deny',message:typeof body.reason==='string' && body.reason.trim() ? body.reason.slice(0,2000) : 'The user declined this action.'})
373
+ else {
374
+ let updatedInput=pending.input
375
+ if (pending.tool==='AskUserQuestion') {
376
+ const questions=pending.input.questions || []
377
+ const answers={}
378
+ for (const q of questions) answers[q.question]=text(body.answers?.[q.question],'Answer',4000)
379
+ updatedInput={...pending.input,answers}
380
+ }
381
+ pending.finish({behavior:'allow',updatedInput})
382
+ }
383
+ }
384
+ cancelApprovals(id,message) { for (const p of [...this.pending.values()]) if (p.sessionId===id) p.finish({behavior:'deny',message}) }
385
+ stop(id) {
386
+ const s=this.get(id),run=this.runs.get(id)
387
+ if (!run || run.stopping) return s
388
+ run.stopping=true; s.status='stopping'; this.cancelApprovals(id,'The user stopped this agent.')
389
+ run.controller.abort()
390
+ try { run.query?.close() } catch {}
391
+ this.changed(s,true)
392
+ return s
393
+ }
394
+ // Forgets Fleet's own record of a conversation. Claude keeps its transcript, so
395
+ // the session id remains resumable from a terminal afterwards.
396
+ async remove(id) {
397
+ const s=this.get(id), run=this.runs.get(id)
398
+ if (run) { this.stop(id); try { await run.done } catch {} }
399
+ this.cancelApprovals(id,'This agent was closed.')
400
+ for (const m of s.messages) this.deleteAttachments(m)
401
+ this.sessions.delete(id)
402
+ try { this.save() } catch (error) { this.emit('storage-error',error) }
403
+ this.emit('change',id)
404
+ return {id, sessionId:s.sessionId}
405
+ }
406
+ async close() {
407
+ this.closed=true
408
+ const running=[...this.runs.values()]
409
+ for (const id of this.runs.keys()) this.stop(id)
410
+ await Promise.allSettled(running.map(r=>r.done))
411
+ try { this.save() } finally { clearTimeout(this.saveTimer); this.releaseLock() }
412
+ }
413
+ }
414
+ // An empty choice means "leave it to the project", which is the SDK's own default.
415
+ function modelChoice(value) {
416
+ if (value === undefined || value === null || value === '') return ''
417
+ if (typeof value !== 'string' || !/^[\w.:-]{1,80}$/.test(value)) fail('That model name is not valid.')
418
+ return value
419
+ }
420
+ function clampInput(input) {
421
+ if (input === null || typeof input !== 'object') return {}
422
+ const out = {}
423
+ for (const [key,value] of Object.entries(input)) {
424
+ if (typeof value === 'string') out[key] = value.length > MAX_TOOL_INPUT ? value.slice(0,MAX_TOOL_INPUT)+'\n… truncated' : value
425
+ else if (value === null || ['number','boolean'].includes(typeof value)) out[key] = value
426
+ else { const json = JSON.stringify(value) ?? ''; out[key] = json.length > MAX_TOOL_INPUT ? json.slice(0,MAX_TOOL_INPUT)+'… truncated' : value }
427
+ }
428
+ return out
429
+ }
430
+ function resultText(content) {
431
+ if (typeof content === 'string') return content
432
+ if (Array.isArray(content)) return content.map(b => typeof b === 'string' ? b : b?.type === 'text' ? b.text || '' : '').filter(Boolean).join('\n')
433
+ return ''
434
+ }
435
+ // Stored conversation entries, in the shape turnSummary() reads from transcripts.
436
+ function managedEvents(messages) {
437
+ const out = []
438
+ for (const m of messages) {
439
+ if (m.role === 'user') out.push({ at: m.at, kind: 'user' })
440
+ else if (m.role === 'assistant') out.push({ at: m.at, kind: 'answer' })
441
+ else if (m.role === 'tool') {
442
+ out.push({ at: m.at, kind: 'tool', id: m.id, tool: m.tool, target: m.target || null })
443
+ if (m.status === 'error') out.push({ at: m.at + (m.ms || 0), kind: 'error', id: m.id, tool: m.tool })
444
+ }
445
+ }
446
+ return out
447
+ }
448
+ function linksFromMessages(messages) {
449
+ const links=new Map()
450
+ for(const message of messages)for(const match of (message.role==='tool' ? '' : message.text || '').matchAll(/https:\/\/(?:github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+|linear\.app\/[A-Za-z0-9_-]+\/issue\/[A-Za-z]+-\d+(?:\/[A-Za-z0-9_-]+)?)/g)) {
451
+ const url=match[0],pr=url.includes('github.com/')
452
+ links.set(url,{url,kind:pr?'pr':'linear',label:pr?'PR #'+url.split('/').pop():url.match(/issue\/([A-Za-z]+-\d+)/)[1]})
453
+ }
454
+ return [...links.values()].slice(-20)
455
+ }
456
+ module.exports={ManagedSessions,ACTIVE}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@sergeychuvayev/claude-fleet",
3
+ "version": "0.1.0",
4
+ "description": "A local control room for Claude Code sessions",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "dashboard",
9
+ "agents",
10
+ "cli",
11
+ "anthropic"
12
+ ],
13
+ "homepage": "https://github.com/sergey-chuvayev/claude-fleet#readme",
14
+ "bugs": "https://github.com/sergey-chuvayev/claude-fleet/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/sergey-chuvayev/claude-fleet.git"
18
+ },
19
+ "license": "MIT",
20
+ "author": "Sergey Chuvayev",
21
+ "bin": {
22
+ "claude-fleet": "bin/claude-fleet.js"
23
+ },
24
+ "files": [
25
+ "bin/",
26
+ "public/",
27
+ "build/make-app.sh",
28
+ "archive.js",
29
+ "catalog.js",
30
+ "fleet.js",
31
+ "managed.js",
32
+ "paths.js",
33
+ "permissions.js",
34
+ "search.js",
35
+ "server.js",
36
+ "theme.js",
37
+ "update.js"
38
+ ],
39
+ "scripts": {
40
+ "start": "node bin/claude-fleet.js start",
41
+ "test": "node --require ./test-setup.js --test *.test.js",
42
+ "vendor": "esbuild build/vendor-entry.js --bundle --minify --format=iife --target=es2022 --outfile=public/vendor/libs.js",
43
+ "icons": "node build/make-icons.js",
44
+ "app": "node bin/claude-fleet.js install-app",
45
+ "prepack": "npm run vendor"
46
+ },
47
+ "engines": {
48
+ "node": ">=22"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "dependencies": {
54
+ "@anthropic-ai/claude-agent-sdk": "0.3.177",
55
+ "dompurify": "^3.4.15",
56
+ "highlight.js": "^11.12.0",
57
+ "marked": "^18.0.13"
58
+ },
59
+ "devDependencies": {
60
+ "esbuild": "^0.28.2"
61
+ }
62
+ }
package/paths.js ADDED
@@ -0,0 +1,64 @@
1
+ 'use strict'
2
+ // Where Fleet keeps its own state. It used to sit in `.fleet/` next to the source,
3
+ // which is fine for a checkout and wrong for an installed package: `__dirname` is
4
+ // then inside node_modules, replaced wholesale on every update and not always
5
+ // writable. State belongs to the user, so it lives in the user's home.
6
+ // CLAUDE_FLEET_HOME overrides it (tests, fixtures, a second instance).
7
+ //
8
+ // Not to be confused with CLAUDE_FLEET_DIR, which points at the *Claude* directory
9
+ // Fleet reads (~/.claude).
10
+ const fs = require('node:fs')
11
+ const os = require('node:os')
12
+ const path = require('node:path')
13
+
14
+ // The pre-install layout. Only ever present in a source checkout.
15
+ const LEGACY_DIR = path.join(__dirname, '.fleet')
16
+ // Copied on first run of the new layout. attachments is a directory; the rest are files.
17
+ const CARRIED = ['sessions.json', 'archive.json', 'attachments']
18
+
19
+ let resolved = null
20
+
21
+ function target() {
22
+ const override = process.env.CLAUDE_FLEET_HOME
23
+ return override ? path.resolve(override) : path.join(os.homedir(), '.claude-fleet')
24
+ }
25
+
26
+ // Copy a pre-install `.fleet/` into the home directory once, so upgrading from a
27
+ // checkout does not look like losing every conversation. The source is left alone:
28
+ // a copy is reversible, a move is not, and the old directory is simply ignored
29
+ // afterwards. Anything already in the destination wins — never overwrite live state.
30
+ function migrate(dir, legacy = LEGACY_DIR) {
31
+ if (dir === legacy || !fs.existsSync(legacy)) return
32
+ for (const name of CARRIED) {
33
+ const from = path.join(legacy, name)
34
+ const to = path.join(dir, name)
35
+ if (!fs.existsSync(from) || fs.existsSync(to)) continue
36
+ try { fs.cpSync(from, to, { recursive: true, preserveTimestamps: true }) }
37
+ catch (error) { console.error(`Could not carry over ${name} from ${legacy}: ${error.message}`) }
38
+ }
39
+ }
40
+
41
+ // Created 0700: it holds conversations, pasted images and the session store.
42
+ function stateDir() {
43
+ const dir = target()
44
+ if (resolved === dir) return dir
45
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
46
+ migrate(dir)
47
+ resolved = dir
48
+ return dir
49
+ }
50
+
51
+ // The directory a new agent starts in when the operator has not picked one.
52
+ // `path.dirname(__dirname)` used to stand in for "the folder holding my projects",
53
+ // which stops being true the moment Fleet is installed rather than cloned.
54
+ function defaultCwd() {
55
+ const override = process.env.CLAUDE_FLEET_DEFAULT_CWD
56
+ if (override) return path.resolve(override)
57
+ const cwd = process.cwd()
58
+ // Launchers start the server from `/` or from inside the install; neither is a
59
+ // useful suggestion, and neither is a directory the operator chose.
60
+ const useless = cwd === path.parse(cwd).root || cwd.includes(`${path.sep}node_modules${path.sep}`) || cwd === __dirname
61
+ return useless ? os.homedir() : cwd
62
+ }
63
+
64
+ module.exports = { stateDir, defaultCwd, migrate, LEGACY_DIR }
package/permissions.js ADDED
@@ -0,0 +1,73 @@
1
+ 'use strict'
2
+ // Decides which tool requests Fleet answers on the operator's behalf.
3
+ //
4
+ // Fleet only sees a request at all when the project's own Claude settings did not
5
+ // already allow it, so this is the last gate before a prompt appears. In `auto`
6
+ // mode everything is approved except the commands on DENIED below; in `ask` mode
7
+ // nothing is, which is the original behaviour.
8
+
9
+ const MODES = ['ask', 'auto', 'all']
10
+ const DEFAULT_MODE = 'auto'
11
+
12
+ // Mirrors the command_denylist in ~/.warp/settings.toml, plus a few commands that
13
+ // destroy data outright. Edit this list to change what still stops for approval.
14
+ const DENIED = [
15
+ 'rm', 'rmdir', 'shred', 'dd', 'mkfs', // destroys data
16
+ 'curl', 'wget', 'ssh', 'scp', 'rsync', 'telnet', // reaches the network or another host
17
+ 'dig', 'nslookup', 'host',
18
+ 'sh', 'bash', 'zsh', 'fish', 'pwsh', // an arbitrary script is not reviewable
19
+ 'eval', 'exec', 'source', 'sudo', 'doas',
20
+ ]
21
+ // Commands whose damage is done elsewhere, so a human should see them first.
22
+ const DENIED_PHRASES = [/^git\s+push\b/, /^npm\s+publish\b/, /^gh\s+(pr|release)\s+(create|merge)\b/]
23
+ // A question for the operator is never a permission Fleet may answer for them.
24
+ const ALWAYS_ASK = new Set(['AskUserQuestion', 'ExitPlanMode'])
25
+
26
+ // Split a command line the way a shell would hand pieces to separate programs, so
27
+ // `cd build && rm -rf .` is judged on `rm -rf .` and not on `cd`.
28
+ function segments(command) {
29
+ return String(command)
30
+ .split(/\n|&&|\|\||[;|]|\$\(|`/)
31
+ .map(part => part.trim())
32
+ .filter(Boolean)
33
+ }
34
+ // Drop the wrappers that would otherwise hide the real command behind them. `sudo`
35
+ // is deliberately NOT stripped: running anything as root is itself worth approving.
36
+ function head(segment) {
37
+ let rest = segment.replace(/^[({\s]+/, '')
38
+ for (let i = 0; i < 4; i++) {
39
+ const next = rest.replace(/^(?:command|nohup|time|env|xargs|nice)\s+/, '').replace(/^\w+=[^\s]*\s+/, '')
40
+ if (next === rest) break
41
+ rest = next
42
+ }
43
+ return rest
44
+ }
45
+ function deniedCommand(command) {
46
+ if (typeof command !== 'string' || !command.trim()) return null
47
+ for (const segment of segments(command)) {
48
+ const rest = head(segment)
49
+ const name = rest.split(/\s/)[0].replace(/^.*\//, '')
50
+ if (DENIED.includes(name)) return name
51
+ for (const phrase of DENIED_PHRASES) if (phrase.test(rest)) return rest.split(/\s/).slice(0, 2).join(' ')
52
+ }
53
+ // `find . -exec rm {} \;` and friends never appear at the head of a segment.
54
+ for (const name of ['rm', 'shred', 'dd', 'mkfs']) {
55
+ if (new RegExp(`(?:^|\\s)-(?:exec|execdir|delete)\\s+${name}\\b|\\bxargs\\s+(?:-\\S+\\s+)*${name}\\b`).test(command)) return name
56
+ }
57
+ return null
58
+ }
59
+
60
+ // Returns null when Fleet may answer for the operator, or the reason it must ask.
61
+ function askReason(tool, input, mode = DEFAULT_MODE) {
62
+ if (ALWAYS_ASK.has(tool)) return tool === 'AskUserQuestion' ? 'Claude is asking you a question' : 'Plan needs your review'
63
+ if (mode === 'all') return null
64
+ if (mode !== 'auto') return 'Approvals are set to ask every time'
65
+ if (tool === 'Bash' || tool === 'BashOutput') {
66
+ const denied = deniedCommand(input?.command)
67
+ if (denied) return `\`${denied}\` is on the approval list`
68
+ }
69
+ return null
70
+ }
71
+ const normaliseMode = value => (MODES.includes(value) ? value : DEFAULT_MODE)
72
+
73
+ module.exports = { MODES, DEFAULT_MODE, DENIED, ALWAYS_ASK, askReason, deniedCommand, normaliseMode }