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