@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/public/blocks.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Warp-style conversation blocks. Every message, tool call and result is its own
|
|
3
|
+
// block with a sticky header, a copy action and collapse. Rendering is incremental:
|
|
4
|
+
// a block is only rebuilt when its content signature changes, so a streaming turn
|
|
5
|
+
// does not re-highlight the whole log on every poll.
|
|
6
|
+
|
|
7
|
+
const LIBS = () => window.FleetLibs || null
|
|
8
|
+
const ICONS = {
|
|
9
|
+
Bash: '⚡', BashOutput: '⚡', Read: '▤', Write: '✎', Edit: '✎', NotebookEdit: '✎',
|
|
10
|
+
Grep: '⌕', Glob: '⌕', WebSearch: '⌕', WebFetch: '↓', Task: '✳', Skill: '◆',
|
|
11
|
+
TodoWrite: '☑', AskUserQuestion: '?', ExitPlanMode: '▸',
|
|
12
|
+
}
|
|
13
|
+
const EXTENSIONS = {
|
|
14
|
+
js:'javascript', jsx:'javascript', mjs:'javascript', cjs:'javascript', ts:'typescript', tsx:'typescript',
|
|
15
|
+
kt:'kotlin', kts:'kotlin', java:'java', py:'python', rs:'rust', go:'go', swift:'swift',
|
|
16
|
+
json:'json', yml:'yaml', yaml:'yaml', sql:'sql', css:'css', scss:'css', html:'xml', xml:'xml', svg:'xml',
|
|
17
|
+
md:'markdown', sh:'bash', bash:'bash', zsh:'bash', diff:'diff', patch:'diff', toml:'plaintext',
|
|
18
|
+
}
|
|
19
|
+
// Mirrors toolTarget() in managed.js: the input key already shown in the block header.
|
|
20
|
+
const TARGET_KEYS = {
|
|
21
|
+
Bash: 'command', BashOutput: 'bash_id', Task: 'description', WebSearch: 'query',
|
|
22
|
+
WebFetch: 'url', Grep: 'pattern', Glob: 'pattern', Skill: 'skill',
|
|
23
|
+
}
|
|
24
|
+
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))
|
|
25
|
+
const languageFor = file => EXTENSIONS[String(file || '').split('.').pop().toLowerCase()] || null
|
|
26
|
+
const duration = ms => ms == null ? null : ms < 1000 ? `${ms}ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 60000)}m ${Math.round(ms % 60000 / 1000)}s`
|
|
27
|
+
const clock = at => new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
28
|
+
|
|
29
|
+
// hljs escapes its own output, so its HTML is safe to inject directly.
|
|
30
|
+
function highlight(code, language) {
|
|
31
|
+
const libs = LIBS()
|
|
32
|
+
if (!libs) return escapeHtml(code)
|
|
33
|
+
try {
|
|
34
|
+
if (language && libs.hljs.getLanguage(language)) return libs.hljs.highlight(code, { language, ignoreIllegals: true }).value
|
|
35
|
+
return libs.hljs.highlightAuto(code).value
|
|
36
|
+
} catch { return escapeHtml(code) }
|
|
37
|
+
}
|
|
38
|
+
function codeHtml(code, language, extraClass = '') {
|
|
39
|
+
const text = String(code ?? '').replace(/\s+$/, '')
|
|
40
|
+
if (!text) return ''
|
|
41
|
+
return `<pre class="block-code ${extraClass}"><code class="hljs">${highlight(text, language)}</code></pre>`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Markdown for prose blocks: parse, sanitise, then highlight fenced code in place.
|
|
45
|
+
function proseHtml(text, { skipHighlight = false } = {}) {
|
|
46
|
+
const libs = LIBS()
|
|
47
|
+
const source = String(text ?? '')
|
|
48
|
+
if (!libs) return `<pre class="block-plain">${escapeHtml(source)}</pre>`
|
|
49
|
+
let html
|
|
50
|
+
try { html = libs.marked.parse(source) } catch { return `<pre class="block-plain">${escapeHtml(source)}</pre>` }
|
|
51
|
+
const clean = libs.DOMPurify.sanitize(html, {
|
|
52
|
+
ADD_ATTR: ['target', 'rel'],
|
|
53
|
+
FORBID_TAGS: ['style', 'form', 'input', 'button', 'iframe', 'object', 'embed'],
|
|
54
|
+
FORBID_ATTR: ['style'],
|
|
55
|
+
})
|
|
56
|
+
const holder = document.createElement('div')
|
|
57
|
+
holder.className = 'block-prose'
|
|
58
|
+
holder.innerHTML = clean
|
|
59
|
+
for (const link of holder.querySelectorAll('a[href]')) { link.target = '_blank'; link.rel = 'noreferrer noopener' }
|
|
60
|
+
if (!skipHighlight) for (const code of holder.querySelectorAll('pre > code')) {
|
|
61
|
+
const declared = [...code.classList].map(c => c.match(/^language-(.+)$/)?.[1]).find(Boolean)
|
|
62
|
+
code.innerHTML = highlight(code.textContent, declared)
|
|
63
|
+
code.classList.add('hljs')
|
|
64
|
+
code.parentElement.classList.add('block-code')
|
|
65
|
+
}
|
|
66
|
+
return holder.outerHTML
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// What a tool block shows in its body, per tool. Falls back to its JSON input.
|
|
70
|
+
function toolBody(message) {
|
|
71
|
+
const input = message.input || {}
|
|
72
|
+
const name = message.tool
|
|
73
|
+
if (name === 'Bash' || name === 'BashOutput') return codeHtml(input.command || input.bash_id || '', 'bash', 'is-command')
|
|
74
|
+
if (name === 'Edit' || name === 'NotebookEdit') {
|
|
75
|
+
const before = String(input.old_string ?? input.old_source ?? '')
|
|
76
|
+
const after = String(input.new_string ?? input.new_source ?? '')
|
|
77
|
+
if (!before && !after) return codeHtml(JSON.stringify(input, null, 2), 'json')
|
|
78
|
+
const lines = []
|
|
79
|
+
if (before) for (const line of before.split('\n')) lines.push(`- ${line}`)
|
|
80
|
+
if (after) for (const line of after.split('\n')) lines.push(`+ ${line}`)
|
|
81
|
+
return codeHtml(lines.join('\n'), 'diff')
|
|
82
|
+
}
|
|
83
|
+
if (name === 'Write') return codeHtml(input.content || '', languageFor(input.file_path))
|
|
84
|
+
if (name === 'TodoWrite') {
|
|
85
|
+
const todos = Array.isArray(input.todos) ? input.todos : []
|
|
86
|
+
if (!todos.length) return ''
|
|
87
|
+
const mark = t => t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '▸' : '☐'
|
|
88
|
+
return `<ul class="block-todos">${todos.map(t => `<li class="todo-${escapeHtml(t.status)}"><span aria-hidden="true">${mark(t)}</span>${escapeHtml(t.content || t.activeForm || '')}</li>`).join('')}</ul>`
|
|
89
|
+
}
|
|
90
|
+
if (name === 'Task' || name === 'Skill') return codeHtml(input.prompt || input.args || input.description || '', 'plaintext')
|
|
91
|
+
if (name === 'AskUserQuestion') return ''
|
|
92
|
+
// The header already shows the main argument, so repeating it as JSON is noise.
|
|
93
|
+
const shown = TARGET_KEYS[name] || 'file_path'
|
|
94
|
+
const rest = Object.fromEntries(Object.entries(input).filter(([k, value]) => k !== shown && k !== 'path' && value !== '' && value != null))
|
|
95
|
+
return Object.keys(rest).length ? codeHtml(JSON.stringify(rest, null, 2), 'json') : ''
|
|
96
|
+
}
|
|
97
|
+
function resultHtml(message) {
|
|
98
|
+
if (!message.result) return ''
|
|
99
|
+
const language = message.tool === 'Read' ? languageFor(message.input?.file_path) : message.status === 'error' ? 'plaintext' : null
|
|
100
|
+
const note = message.truncated ? '<p class="block-note">Output truncated by Fleet.</p>' : ''
|
|
101
|
+
return `<div class="block-result ${message.status === 'error' ? 'is-error' : ''}">${codeHtml(message.result, language)}${note}</div>`
|
|
102
|
+
}
|
|
103
|
+
const actionsHtml = '<span class="block-actions"><button type="button" class="block-button" data-copy title="Copy block">⧉</button><button type="button" class="block-button" data-collapse title="Collapse block" aria-expanded="true">⌄</button></span>'
|
|
104
|
+
|
|
105
|
+
function blockHtml(message, { streaming = false } = {}) {
|
|
106
|
+
if (message.role === 'tool') {
|
|
107
|
+
const icon = ICONS[message.tool] || '▸'
|
|
108
|
+
const meta = [duration(message.ms), clock(message.at)].filter(Boolean).join(' · ')
|
|
109
|
+
const state = message.status === 'error' ? 'is-failed' : message.status === 'running' ? 'is-running' : message.status === 'interrupted' ? 'is-interrupted' : 'is-done'
|
|
110
|
+
const stateLabel = state === 'is-done' ? '' : state.slice(3)
|
|
111
|
+
const target = message.target ? `<span class="block-target" title="${escapeHtml(message.target)}">${escapeHtml(message.target)}</span>` : ''
|
|
112
|
+
const auto = message.approval === 'auto' ? '<span class="block-auto" title="Fleet approved this automatically">auto</span>' : ''
|
|
113
|
+
return `<div class="block-head"><span class="block-icon" aria-hidden="true">${icon}</span><span class="block-tool">${escapeHtml(message.tool)}</span>${target}<span class="block-meta">${escapeHtml(meta)}</span>${auto}<span class="block-state ${state}">${stateLabel}</span>${actionsHtml}</div><div class="block-body">${toolBody(message)}${resultHtml(message)}</div>`
|
|
114
|
+
}
|
|
115
|
+
const who = message.role === 'user' ? 'YOU' : 'CLAUDE'
|
|
116
|
+
const icon = message.role === 'user' ? '›' : '✳'
|
|
117
|
+
const live = streaming ? '<span class="block-state is-running">streaming</span>' : ''
|
|
118
|
+
const attachments = Array.isArray(message.attachments) && message.attachments.length
|
|
119
|
+
? `<div class="block-attachments">${message.attachments.map(a => `<a href="/api/attachments/${escapeHtml(a.id)}" target="_blank" rel="noreferrer noopener" title="${escapeHtml(a.mediaType)} · ${Math.round((a.bytes || 0) / 1024)} KB"><img src="/api/attachments/${escapeHtml(a.id)}" alt="Attached image" loading="lazy"></a>`).join('')}</div>`
|
|
120
|
+
: ''
|
|
121
|
+
const body = message.text ? proseHtml(message.text, { skipHighlight: streaming }) : ''
|
|
122
|
+
return `<div class="block-head"><span class="block-icon" aria-hidden="true">${icon}</span><span class="block-tool">${who}</span><span class="block-meta">${clock(message.at)}</span>${live}${actionsHtml}</div><div class="block-body">${attachments}${body}</div>`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Signature drives the incremental update: an identical signature means an identical block.
|
|
126
|
+
const signature = (message, streaming) => [
|
|
127
|
+
message.role, message.tool || '', message.status || '', message.ms ?? '', message.approval || '', streaming ? 'S' : '',
|
|
128
|
+
(message.text || '').length, (message.result || '').length, (message.attachments || []).length,
|
|
129
|
+
message.role === 'tool' ? JSON.stringify(message.input || {}).length : 0,
|
|
130
|
+
(message.text || '').slice(-80), (message.result || '').slice(-80),
|
|
131
|
+
].join('~|~')
|
|
132
|
+
|
|
133
|
+
function copyText(message) {
|
|
134
|
+
if (message.role !== 'tool') return message.text || ''
|
|
135
|
+
const parts = [`${message.tool}${message.target ? ` · ${message.target}` : ''}`]
|
|
136
|
+
if (message.tool === 'Bash' && message.input?.command) parts.push(message.input.command)
|
|
137
|
+
else parts.push(JSON.stringify(message.input || {}, null, 2))
|
|
138
|
+
if (message.result) parts.push('', message.result)
|
|
139
|
+
return parts.join('\n')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderBlocks(container, messages, { streamingId = null, onCopy = () => {} } = {}) {
|
|
143
|
+
const seen = new Set()
|
|
144
|
+
let previous = null
|
|
145
|
+
for (const message of messages) {
|
|
146
|
+
const streaming = message.id === streamingId
|
|
147
|
+
const sig = signature(message, streaming)
|
|
148
|
+
seen.add(message.id)
|
|
149
|
+
let element = container.querySelector(`[data-block="${CSS.escape(message.id)}"]`)
|
|
150
|
+
if (!element) {
|
|
151
|
+
element = document.createElement('article')
|
|
152
|
+
element.className = 'block'
|
|
153
|
+
element.dataset.block = message.id
|
|
154
|
+
// Long tool output starts collapsed, the way a long command block does in a terminal.
|
|
155
|
+
if (message.role === 'tool' && (message.result || '').length > 1200) element.classList.add('collapsed')
|
|
156
|
+
}
|
|
157
|
+
if (element.dataset.sig !== sig) {
|
|
158
|
+
element.dataset.sig = sig
|
|
159
|
+
element.dataset.role = message.role
|
|
160
|
+
element.dataset.tool = message.tool || ''
|
|
161
|
+
element.dataset.status = message.status || ''
|
|
162
|
+
element.innerHTML = blockHtml(message, { streaming })
|
|
163
|
+
element.querySelector('[data-collapse]')?.addEventListener('click', event => {
|
|
164
|
+
const collapsed = element.classList.toggle('collapsed')
|
|
165
|
+
event.currentTarget.setAttribute('aria-expanded', String(!collapsed))
|
|
166
|
+
})
|
|
167
|
+
element.querySelector('[data-copy]')?.addEventListener('click', () => {
|
|
168
|
+
navigator.clipboard?.writeText(copyText(message)).then(() => onCopy('Block copied'), () => onCopy('Copying needs clipboard permission'))
|
|
169
|
+
})
|
|
170
|
+
if (element.classList.contains('collapsed')) element.querySelector('[data-collapse]')?.setAttribute('aria-expanded', 'false')
|
|
171
|
+
}
|
|
172
|
+
// Keep DOM order aligned with message order without rebuilding untouched blocks.
|
|
173
|
+
const shouldFollow = previous ? previous.nextElementSibling : container.firstElementChild
|
|
174
|
+
if (shouldFollow !== element) container.insertBefore(element, shouldFollow)
|
|
175
|
+
previous = element
|
|
176
|
+
}
|
|
177
|
+
for (const element of [...container.children]) if (!seen.has(element.dataset?.block)) element.remove()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
window.FleetBlocks = { renderBlocks, proseHtml, codeHtml, highlight }
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
let controlToken=null, controlSession=null, controlId=null, controlFetch=null, controlVersion=0
|
|
3
|
+
const drafts=new Map()
|
|
4
|
+
const inFlight=new Set()
|
|
5
|
+
let launchRequestId=null, resumeSource=null, fallbackWarned=false
|
|
6
|
+
const managedLabels={starting:'Starting Claude…',running:'Working on your task',approval:'Your input is needed',stopping:'Stopping the agent…',stopped:'Stopped · ready to continue',error:'Turn failed',idle:'Ready for your next message'}
|
|
7
|
+
const isWorking=s=>['starting','running','approval','stopping'].includes(s.status)
|
|
8
|
+
|
|
9
|
+
async function api(url,body) {
|
|
10
|
+
if(!controlToken) await initializeControls()
|
|
11
|
+
const response=await fetch(url,{method:body ? 'POST':'GET',headers:body ? {'content-type':'application/json','x-fleet-token':controlToken}: {},body:body ? JSON.stringify(body):undefined,signal:AbortSignal.timeout(15000)})
|
|
12
|
+
const data=await response.json()
|
|
13
|
+
if(!response.ok) throw new Error(data.error || 'The request failed.')
|
|
14
|
+
return data
|
|
15
|
+
}
|
|
16
|
+
async function initializeControls() {
|
|
17
|
+
const response=await fetch('/api/control',{cache:'no-store',signal:AbortSignal.timeout(8000)})
|
|
18
|
+
if(!response.ok) throw new Error('Agent controls are unavailable. Restart the updated Fleet server.')
|
|
19
|
+
const data=await response.json()
|
|
20
|
+
controlToken=data.token
|
|
21
|
+
if(!$('launch-cwd').value) $('launch-cwd').value=data.defaultCwd
|
|
22
|
+
}
|
|
23
|
+
function openLaunch(source=null) {
|
|
24
|
+
resumeSource=source
|
|
25
|
+
$('launch-title').textContent=source ? 'Continue this conversation in Fleet.' : 'Give your next task a home.'
|
|
26
|
+
if(source){$('launch-cwd').value=source.cwd || '';$('launch-form').elements.name.value=source.title || source.name || ''}
|
|
27
|
+
$('launch-cwd').readOnly=!!source
|
|
28
|
+
openModal('launch-backdrop', '[name=prompt]')
|
|
29
|
+
}
|
|
30
|
+
$('new-session').addEventListener('click',()=>modalIsOpen('launch-backdrop') ? closeModal() : openLaunch())
|
|
31
|
+
document.addEventListener('keydown', event => {
|
|
32
|
+
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'n') {
|
|
33
|
+
event.preventDefault()
|
|
34
|
+
if (modalIsOpen('launch-backdrop')) $('launch-form').elements.prompt.focus()
|
|
35
|
+
else openLaunch()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
$('launch-form').addEventListener('input',()=>{launchRequestId=null})
|
|
39
|
+
$('launch-form').addEventListener('submit',async event=>{
|
|
40
|
+
event.preventDefault()
|
|
41
|
+
const button=$('launch-submit'); if(button.disabled)return
|
|
42
|
+
button.disabled=true;button.textContent='Launching…';$('launch-error').hidden=true
|
|
43
|
+
const form=event.currentTarget
|
|
44
|
+
form.querySelectorAll('input,textarea').forEach(el=>el.disabled=true)
|
|
45
|
+
launchRequestId ||= crypto.randomUUID()
|
|
46
|
+
try{
|
|
47
|
+
const data=await api('/api/managed',{cwd:form.elements.cwd.value,name:form.elements.name.value,prompt:form.elements.prompt.value,approvalMode:form.elements.approvalMode.value,model:form.elements.model.value,requestId:launchRequestId,...(resumeSource ? {resumeSessionId:resumeSource.sessionId}: {})})
|
|
48
|
+
selected=data.session.id;filter='all'
|
|
49
|
+
form.elements.prompt.value='';launchRequestId=null
|
|
50
|
+
closeModal()
|
|
51
|
+
await tick();toast('Agent launched')
|
|
52
|
+
if(matchMedia('(max-width:720px)').matches)$('detail').scrollIntoView({block:'start',behavior:'instant'})
|
|
53
|
+
}catch(error){$('launch-error').textContent=error.message;$('launch-error').hidden=false}
|
|
54
|
+
finally{button.disabled=false;button.textContent='Launch agent ↗';form.querySelectorAll('input,textarea').forEach(el=>el.disabled=false)}
|
|
55
|
+
})
|
|
56
|
+
function selectControl(session) {
|
|
57
|
+
const next=session?.managedId || null
|
|
58
|
+
if(next===controlId && next) return
|
|
59
|
+
controlId=next;controlSession=null;controlVersion++
|
|
60
|
+
$('control-panel').innerHTML=''
|
|
61
|
+
if(next){
|
|
62
|
+
$('control-panel').innerHTML=`<div class="conversation-header"><h3 id="conversation-title">Conversation</h3><button type="button" id="close-agent" class="button close-agent" title="Remove this conversation from Fleet">Close</button><label class="mode-picker"><span class="sr-only">Model for this agent</span><select id="model-choice" title="Applies from your next message"></select></label><label class="mode-picker"><span class="sr-only">Approvals for this agent</span><select id="approval-mode"><option value="auto">Auto approvals</option><option value="ask">Ask every time</option><option value="all">Approve everything</option></select></label><span id="agent-context" class="subtle context-chip"></span><span id="agent-state" class="subtle">Connecting…</span></div><div id="conversation" class="conversation" role="log" aria-label="Agent conversation" aria-live="off"><p class="note">Loading conversation…</p></div><div id="agent-error" class="form-error" role="status" hidden></div><div id="approvals"></div><form id="composer" class="composer"><label class="sr-only" for="message-input">Message this agent</label><ul id="slash-picker" class="slash-picker" role="listbox" aria-label="Commands and skills" hidden></ul><div id="attach-tray" class="attach-tray" hidden></div><textarea id="message-input" rows="3" maxlength="16000" placeholder="What should this agent do next? · press / for commands · paste an image" role="combobox" aria-expanded="false" aria-controls="slash-picker" aria-autocomplete="list"></textarea><div class="composer-footer"><span id="composer-hint" class="note">Enter to send · Shift + Enter for a new line</span><button id="stop-agent" type="button" class="button stop" hidden>■ Stop</button><button id="send-message" class="button resume" type="submit">Send ↗</button></div><p id="send-error" class="form-error" role="alert" hidden></p></form>`
|
|
63
|
+
window.FleetLayout?.watchConversation($('conversation'))
|
|
64
|
+
catalog=[];catalogFor=null;closePicker();renderTray()
|
|
65
|
+
window.FleetLayout?.syncDetails?.()
|
|
66
|
+
$('message-input').value=drafts.get(next)?.text || ''
|
|
67
|
+
$('message-input').addEventListener('input',()=>drafts.set(next,{text:$('message-input').value,requestId:crypto.randomUUID()}))
|
|
68
|
+
$('message-input').addEventListener('keydown',event=>{
|
|
69
|
+
composerKeydown(event)
|
|
70
|
+
if(event.defaultPrevented) return
|
|
71
|
+
if(event.key==='Enter' && !event.shiftKey && !event.isComposing){event.preventDefault();if(!$('send-message').disabled)$('composer').requestSubmit()}
|
|
72
|
+
})
|
|
73
|
+
$('message-input').addEventListener('input',composerInput)
|
|
74
|
+
$('message-input').addEventListener('paste',event=>{
|
|
75
|
+
const files=[...(event.clipboardData?.items || [])].filter(i=>i.kind==='file' && i.type.startsWith('image/')).map(i=>i.getAsFile()).filter(Boolean)
|
|
76
|
+
if(!files.length) return // ordinary text paste proceeds untouched
|
|
77
|
+
event.preventDefault(); attachImages(files)
|
|
78
|
+
})
|
|
79
|
+
$('composer').addEventListener('dragover',event=>{ if([...(event.dataTransfer?.types || [])].includes('Files')){ event.preventDefault(); $('composer').classList.add('is-dropping') } })
|
|
80
|
+
$('composer').addEventListener('dragleave',()=>$('composer').classList.remove('is-dropping'))
|
|
81
|
+
$('composer').addEventListener('drop',event=>{ event.preventDefault(); $('composer').classList.remove('is-dropping'); attachImages([...(event.dataTransfer?.files || [])].filter(f=>f.type.startsWith('image/'))) })
|
|
82
|
+
$('attach-tray').addEventListener('click',event=>{ const b=event.target.closest('[data-remove]'); if(b){ removeImage(Number(b.dataset.remove)) } })
|
|
83
|
+
renderTray()
|
|
84
|
+
$('message-input').addEventListener('blur',()=>setTimeout(closePicker,120))
|
|
85
|
+
$('slash-picker').addEventListener('mousedown',event=>{
|
|
86
|
+
const item=event.target.closest('[data-index]')
|
|
87
|
+
if(item){event.preventDefault();insertPick(Number(item.dataset.index))}
|
|
88
|
+
})
|
|
89
|
+
$('composer').addEventListener('submit',sendMessage)
|
|
90
|
+
$('approval-mode').addEventListener('change',changeMode)
|
|
91
|
+
fillModels($('model-choice')).then(()=>$('model-choice')?.addEventListener('change',changeModel))
|
|
92
|
+
$('close-agent').addEventListener('click',closeAgent)
|
|
93
|
+
$('stop-agent').addEventListener('click',stopAgent)
|
|
94
|
+
refreshControl()
|
|
95
|
+
}else if(session){
|
|
96
|
+
$('control-panel').innerHTML=`<div class="external-note"><strong>Opened outside Fleet</strong><p>${session.alive ? 'This session is running in a terminal. Use its terminal to send messages, or launch a new Fleet-managed agent.' : 'This process has stopped. Continue its saved conversation here with a new message.'}</p>${!session.alive && session.sessionId && session.cwd ? '<button id="resume-in-fleet" class="button">Continue in Fleet ↗</button>' : ''}</div>`
|
|
97
|
+
$('resume-in-fleet')?.addEventListener('click',()=>openLaunch(session))
|
|
98
|
+
window.FleetLayout?.syncDetails?.()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function refreshControl() {
|
|
102
|
+
const id=controlId,version=controlVersion
|
|
103
|
+
if(!id)return
|
|
104
|
+
if(controlFetch?.id===id){controlFetch.again=true;return}
|
|
105
|
+
const task={id,again:false};controlFetch=task
|
|
106
|
+
try{
|
|
107
|
+
const data=await api(`/api/managed/${id}`)
|
|
108
|
+
if(controlId!==id || controlVersion!==version)return
|
|
109
|
+
controlSession=data.session;renderControl()
|
|
110
|
+
}catch(error){if(controlId===id && $('agent-error')){$('agent-error').hidden=false;$('agent-error').textContent=error.message}}
|
|
111
|
+
finally{if(controlFetch===task)controlFetch=null;if(task.again && controlId===id)refreshControl()}
|
|
112
|
+
}
|
|
113
|
+
function renderControl() {
|
|
114
|
+
const s=controlSession;if(!s || s.id!==controlId || !$('composer'))return
|
|
115
|
+
$('conversation-title').textContent=s.aiTitle || s.name
|
|
116
|
+
$('agent-state').textContent=s.currentTool && s.status==='running' ? `Using ${s.currentTool}` : managedLabels[s.status]
|
|
117
|
+
$('agent-state').className=`subtle ${s.status==='approval' ? 'stale' : ''}`
|
|
118
|
+
const used=s.contextTokens, limit=s.contextLimit || 200000
|
|
119
|
+
const share=used==null ? null : Math.min(100,Math.round(used/limit*100))
|
|
120
|
+
$('agent-context').textContent=share==null ? '' : `${share}%`
|
|
121
|
+
$('agent-context').className=`subtle context-chip ${share>=90 ? 'hot' : share>=75 ? 'warn' : ''}`
|
|
122
|
+
$('agent-context').title=share==null ? '' : `${used.toLocaleString()} of ${limit.toLocaleString()} tokens used`
|
|
123
|
+
if($('model-choice')!==document.activeElement && $('model-choice').options.length) $('model-choice').value=s.selectedModel || ''
|
|
124
|
+
if($('approval-mode')!==document.activeElement) $('approval-mode').value=s.approvalMode || 'auto'
|
|
125
|
+
$('approval-mode').dataset.mode=s.approvalMode || 'auto'
|
|
126
|
+
$('agent-error').hidden=!s.error
|
|
127
|
+
$('agent-error').textContent=s.error || ''
|
|
128
|
+
const log=$('conversation'),atBottom=log.scrollHeight-log.scrollTop-log.clientHeight<60
|
|
129
|
+
const last=s.messages[s.messages.length-1]
|
|
130
|
+
const streamingId=isWorking(s) && last?.role==='assistant' ? last.id : null
|
|
131
|
+
if(!s.messages.length){
|
|
132
|
+
if(!log.querySelector('.note')) log.innerHTML='<p class="note">Send your first instruction below.</p>'
|
|
133
|
+
}else if(window.FleetBlocks){
|
|
134
|
+
log.querySelector('.note')?.remove()
|
|
135
|
+
window.FleetBlocks.renderBlocks(log,s.messages,{streamingId,onCopy:toast})
|
|
136
|
+
}else{
|
|
137
|
+
// Console assets unavailable — usually a page loaded from an older running server.
|
|
138
|
+
update('conversation',s.messages.map(m=>`<article class="block" data-role="${esc(m.role)}"><div class="block-head"><span class="block-tool">${m.role==='tool' ? esc(m.tool) : m.role==='user' ? 'YOU' : 'CLAUDE'}</span><span class="block-meta">${new Date(m.at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span></div><pre class="block-plain">${esc(m.role==='tool' ? [m.target,m.result].filter(Boolean).join('\n\n') : m.text)}</pre></article>`).join(''))
|
|
139
|
+
if(!fallbackWarned){fallbackWarned=true;toast('Console assets did not load. Restart Fleet, then reload this page.')}
|
|
140
|
+
}
|
|
141
|
+
if(atBottom)log.scrollTop=log.scrollHeight
|
|
142
|
+
// Approval DOM is independent of the streamed response so answers keep their focus and values.
|
|
143
|
+
const ids=s.approvals.map(p=>p.id).join(',')
|
|
144
|
+
if($('approvals').dataset.ids!==ids){$('approvals').dataset.ids=ids;renderApprovals(s.approvals)}
|
|
145
|
+
const working=isWorking(s)
|
|
146
|
+
const held=s.openElsewhere
|
|
147
|
+
const heldText=held ? `Open ${held.entrypoint==='cli' ? 'in a terminal' : 'in another program'}${held.name ? ' · '+held.name : ''}${held.startedAt ? ' · since '+new Date(held.startedAt).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) : ''}. Close it there to continue here.` : null
|
|
148
|
+
if(held){ $('agent-state').textContent=held.state==='busy' ? 'Working in a terminal' : 'Open in a terminal'; $('agent-state').className='subtle stale' }
|
|
149
|
+
$('send-message').disabled=working || inFlight.has(s.id) || !!held
|
|
150
|
+
$('stop-agent').hidden=!working
|
|
151
|
+
$('stop-agent').disabled=s.status==='stopping' || inFlight.has(`stop:${s.id}`)
|
|
152
|
+
$('composer-hint').textContent=heldText || (working ? 'You can draft your next message while Claude works.' : 'Enter to send · Shift + Enter for a new line')
|
|
153
|
+
$('composer-hint').classList.toggle('is-held',!!held)
|
|
154
|
+
}
|
|
155
|
+
function renderApprovals(approvals) {
|
|
156
|
+
$('approvals').innerHTML=approvals.map(p=>{
|
|
157
|
+
const question=p.tool==='AskUserQuestion'
|
|
158
|
+
return `<form class="approval" data-approval="${esc(p.id)}"><div class="eyebrow">${question?'CLAUDE HAS A QUESTION':'APPROVAL REQUIRED'}</div><h4>${esc(p.description || p.tool)}</h4>${p.reason && !question ? `<p class="approval-reason">${esc(p.reason)}</p>` : ''}${question ? (p.input.questions || []).map((q,i)=>`<fieldset><legend>${esc(q.question)}</legend>${(q.options || []).map(o=>`<label class="answer-option"><input type="${q.multiSelect?'checkbox':'radio'}" name="q${i}" value="${esc(o.label)}"><span>${esc(o.label)}${o.description?`<small>${esc(o.description)}</small>`:''}</span></label>`).join('')}<label class="other-answer">Your answer<input type="text" name="other${i}" placeholder="Or type your own answer" maxlength="4000"></label></fieldset>`).join('') : `<pre class="tool-input">${esc(JSON.stringify(p.input,null,2))}</pre>`}<div class="approval-actions"><button class="button" type="button" data-deny="${esc(p.id)}">${question?'Skip question':'Deny'}</button><button class="button resume" type="submit">${question?'Send answer':'Allow once'}</button></div><p class="form-error" role="alert" hidden></p></form>`
|
|
159
|
+
}).join('')
|
|
160
|
+
$('approvals').querySelectorAll('form').forEach(form=>{
|
|
161
|
+
const approval=approvals.find(p=>p.id===form.dataset.approval)
|
|
162
|
+
form.addEventListener('submit',event=>{
|
|
163
|
+
event.preventDefault()
|
|
164
|
+
const answers={}
|
|
165
|
+
if(approval.tool==='AskUserQuestion')for(const [i,q] of (approval.input.questions || []).entries()){
|
|
166
|
+
const other=form.elements[`other${i}`].value.trim()
|
|
167
|
+
const chosen=[...form.querySelectorAll(`input[name="q${i}"]:checked`)].map(input=>input.value)
|
|
168
|
+
answers[q.question]=other || chosen.join(', ')
|
|
169
|
+
if(!answers[q.question]){const e=form.querySelector('.form-error');e.hidden=false;e.textContent='Answer each question before continuing.';return}
|
|
170
|
+
}
|
|
171
|
+
decide(form,'allow',answers)
|
|
172
|
+
})
|
|
173
|
+
form.querySelector('[data-deny]').addEventListener('click',()=>decide(form,'deny'))
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
async function decide(form,decision,answers) {
|
|
177
|
+
const id=controlId,approval=form.dataset.approval
|
|
178
|
+
const buttons=[...form.querySelectorAll('button')];buttons.forEach(b=>b.disabled=true)
|
|
179
|
+
try{await api(`/api/managed/${id}/approvals/${approval}`,{decision,answers});await refreshControl();await tick()}
|
|
180
|
+
catch(error){const e=form.querySelector('.form-error');e.hidden=false;e.textContent=error.message;buttons.forEach(b=>b.disabled=false)}
|
|
181
|
+
}
|
|
182
|
+
const pendingImages=new Map()
|
|
183
|
+
const MAX_IMAGES=6, MAX_IMAGE_BYTES=8*1024*1024
|
|
184
|
+
const IMAGE_OK=new Set(['image/png','image/jpeg','image/gif','image/webp'])
|
|
185
|
+
const attachedImages=()=>pendingImages.get(controlId) || []
|
|
186
|
+
async function attachImages(files) {
|
|
187
|
+
const list=attachedImages()
|
|
188
|
+
for(const file of files){
|
|
189
|
+
if(!IMAGE_OK.has(file.type)){ toast('Only PNG, JPEG, GIF and WebP images can be attached.'); continue }
|
|
190
|
+
if(file.size>MAX_IMAGE_BYTES){ toast(`${file.name || 'That image'} is over 8 MB.`); continue }
|
|
191
|
+
if(list.length>=MAX_IMAGES){ toast(`Up to ${MAX_IMAGES} images per message.`); break }
|
|
192
|
+
const dataUrl=await new Promise((resolve,reject)=>{ const r=new FileReader(); r.onload=()=>resolve(r.result); r.onerror=reject; r.readAsDataURL(file) })
|
|
193
|
+
list.push({ mediaType:file.type, dataUrl, bytes:file.size, name:file.name || 'Pasted image' })
|
|
194
|
+
}
|
|
195
|
+
pendingImages.set(controlId,list)
|
|
196
|
+
renderTray()
|
|
197
|
+
$('message-input')?.focus()
|
|
198
|
+
}
|
|
199
|
+
function removeImage(index) {
|
|
200
|
+
const list=attachedImages(); list.splice(index,1); pendingImages.set(controlId,list); renderTray()
|
|
201
|
+
}
|
|
202
|
+
function renderTray() {
|
|
203
|
+
const tray=$('attach-tray'); if(!tray) return
|
|
204
|
+
const list=attachedImages()
|
|
205
|
+
tray.hidden=!list.length
|
|
206
|
+
tray.innerHTML=list.map((img,i)=>`<figure class="attach-thumb"><img src="${img.dataUrl}" alt="${esc(img.name)}"><figcaption>${esc(img.name)} · ${Math.round(img.bytes/1024)} KB</figcaption><button type="button" class="attach-remove" data-remove="${i}" aria-label="Remove ${esc(img.name)}">×</button></figure>`).join('')
|
|
207
|
+
const hint=$('composer-hint'); if(hint && list.length && !isWorking(controlSession || {})) hint.textContent=`${list.length} image${list.length===1?'':'s'} attached · Enter to send`
|
|
208
|
+
}
|
|
209
|
+
async function sendMessage(event) {
|
|
210
|
+
event.preventDefault()
|
|
211
|
+
const id=controlId
|
|
212
|
+
if(!id || inFlight.has(id) || !controlSession || isWorking(controlSession))return
|
|
213
|
+
const message=$('message-input').value.trim()
|
|
214
|
+
const images=attachedImages().map(img=>({ mediaType:img.mediaType, data:img.dataUrl.slice(img.dataUrl.indexOf(',')+1) }))
|
|
215
|
+
if(!message && !images.length)return
|
|
216
|
+
const draft=drafts.get(id) || {text:message,requestId:crypto.randomUUID()};drafts.set(id,draft)
|
|
217
|
+
inFlight.add(id);renderControl();$('send-error').hidden=true
|
|
218
|
+
try{
|
|
219
|
+
await api(`/api/managed/${id}/messages`,{message,...(images.length ? {images} : {}),requestId:draft.requestId})
|
|
220
|
+
if(drafts.get(id)?.requestId===draft.requestId){drafts.delete(id);if(controlId===id)$('message-input').value=''}
|
|
221
|
+
pendingImages.delete(id); if(controlId===id) renderTray()
|
|
222
|
+
await refreshControl();await tick()
|
|
223
|
+
}catch(error){if(controlId===id){$('send-error').hidden=false;$('send-error').textContent=error.message}}
|
|
224
|
+
finally{inFlight.delete(id);renderControl()}
|
|
225
|
+
}
|
|
226
|
+
// The model list comes from the running Claude runtime once one has reported it.
|
|
227
|
+
let modelList=null
|
|
228
|
+
async function fillModels(select) {
|
|
229
|
+
try{ modelList ||= (await api('/api/models')).models || [] }catch{ modelList=[] }
|
|
230
|
+
if(!select.isConnected) return
|
|
231
|
+
const current=controlSession?.selectedModel || ''
|
|
232
|
+
select.innerHTML=modelList.map(m=>`<option value="${esc(m.value)}" title="${esc(m.description || '')}">${esc(m.displayName || m.value || 'Default')}</option>`).join('')
|
|
233
|
+
select.value=current
|
|
234
|
+
if($('launch-model')) $('launch-model').innerHTML=select.innerHTML
|
|
235
|
+
}
|
|
236
|
+
async function changeModel(event) {
|
|
237
|
+
const id=controlId, model=event.target.value
|
|
238
|
+
try{ await api(`/api/managed/${id}/model`,{model}); await refreshControl(); toast(model ? `Next message uses ${event.target.selectedOptions[0].textContent}` : 'Back to the project default') }
|
|
239
|
+
catch(error){ toast(error.message); refreshControl() }
|
|
240
|
+
}
|
|
241
|
+
async function changeMode(event) {
|
|
242
|
+
const id=controlId, mode=event.target.value
|
|
243
|
+
try{ await api(`/api/managed/${id}/mode`,{mode}); await refreshControl(); toast(mode==='ask' ? 'Every tool will ask' : mode==='all' ? 'Approving everything, including destructive commands' : 'Auto approvals on') }
|
|
244
|
+
catch(error){ toast(error.message); refreshControl() }
|
|
245
|
+
}
|
|
246
|
+
async function closeAgent(event) {
|
|
247
|
+
const id=controlId, button=event.currentTarget
|
|
248
|
+
if(button.dataset.armed!=='1'){
|
|
249
|
+
button.dataset.armed='1'
|
|
250
|
+
button.textContent=controlSession && isWorking(controlSession) ? 'Stop and close?' : 'Close for good?'
|
|
251
|
+
setTimeout(()=>{if(button.isConnected){button.dataset.armed='';button.textContent='Close'}},4000)
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
button.disabled=true;button.textContent='Closing…'
|
|
255
|
+
try{
|
|
256
|
+
await api(`/api/managed/${id}/close`,{})
|
|
257
|
+
if(controlId===id){selected=null;selectControl(null);$('control-panel').innerHTML=''}
|
|
258
|
+
await tick()
|
|
259
|
+
toast('Closed. Claude still has its own transcript of it.')
|
|
260
|
+
}catch(error){button.disabled=false;button.dataset.armed='';button.textContent='Close';toast(error.message)}
|
|
261
|
+
}
|
|
262
|
+
async function stopAgent() {
|
|
263
|
+
const id=controlId;if(!id || inFlight.has(`stop:${id}`))return
|
|
264
|
+
inFlight.add(`stop:${id}`);renderControl()
|
|
265
|
+
try{await api(`/api/managed/${id}/stop`,{});await refreshControl();await tick()}
|
|
266
|
+
catch(error){toast(error.message)}finally{inFlight.delete(`stop:${id}`);renderControl()}
|
|
267
|
+
}
|
|
268
|
+
window.addEventListener('fleet-libs-ready',()=>{
|
|
269
|
+
const log=$('conversation')
|
|
270
|
+
if(log) for(const block of log.children) delete block.dataset.sig
|
|
271
|
+
renderControl()
|
|
272
|
+
})
|
|
273
|
+
;(async()=>{ try{ modelList=(await api('/api/models')).models || []; if($('launch-model')) $('launch-model').innerHTML=modelList.map(m=>`<option value="${esc(m.value)}">${esc(m.displayName || m.value || 'Default')}</option>`).join('') }catch{} })()
|
|
274
|
+
initializeControls().catch(error=>toast(error.message))
|
|
275
|
+
const events=new EventSource('/api/events')
|
|
276
|
+
events.addEventListener('sessions',event=>{try{if(JSON.parse(event.data).includes(controlId))refreshControl()}catch{}})
|
|
277
|
+
events.onopen=()=>{refreshControl();tick()}
|
|
278
|
+
// Polling also recovers from a dropped event stream or a server restart.
|
|
279
|
+
setInterval(()=>{if(!document.hidden)refreshControl()},2500)
|
|
280
|
+
window.addEventListener('beforeunload',()=>events.close())
|
|
281
|
+
|
|
282
|
+
// Slash picker: typing `/` at the start of a line offers this project's commands
|
|
283
|
+
// and skills. It only inserts text into the composer; nothing runs until you send.
|
|
284
|
+
let catalog = [], catalogFor = null, picked = 0, matches = []
|
|
285
|
+
const SCOPE_LABEL = { project: 'project', user: 'user', plugin: 'plugin' }
|
|
286
|
+
|
|
287
|
+
async function loadCatalog(id) {
|
|
288
|
+
if (catalogFor === id) return
|
|
289
|
+
catalogFor = id
|
|
290
|
+
try { catalog = (await api(`/api/managed/${id}/commands`)).commands || [] }
|
|
291
|
+
catch { catalog = [] }
|
|
292
|
+
}
|
|
293
|
+
// The token being typed, or null when the caret is not in a slash word.
|
|
294
|
+
function slashQuery(input) {
|
|
295
|
+
if (input.selectionStart !== input.selectionEnd) return null
|
|
296
|
+
const before = input.value.slice(0, input.selectionStart)
|
|
297
|
+
const line = before.slice(before.lastIndexOf('\n') + 1)
|
|
298
|
+
const match = line.match(/^\/([\w:-]*)$/)
|
|
299
|
+
return match ? match[1] : null
|
|
300
|
+
}
|
|
301
|
+
function closePicker() {
|
|
302
|
+
matches = []
|
|
303
|
+
const list = $('slash-picker')
|
|
304
|
+
if (list) { list.hidden = true; list.innerHTML = '' }
|
|
305
|
+
$('message-input')?.removeAttribute('aria-activedescendant')
|
|
306
|
+
}
|
|
307
|
+
function renderPicker(query) {
|
|
308
|
+
const list = $('slash-picker')
|
|
309
|
+
if (!list) return
|
|
310
|
+
const needle = query.toLowerCase()
|
|
311
|
+
matches = catalog
|
|
312
|
+
.filter(entry => entry.name.toLowerCase().includes(needle))
|
|
313
|
+
// Prefer a prefix match, then the project's own entries.
|
|
314
|
+
.sort((a, b) => (b.name.toLowerCase().startsWith(needle) - a.name.toLowerCase().startsWith(needle)) || 0)
|
|
315
|
+
.slice(0, 40)
|
|
316
|
+
if (!matches.length) return closePicker()
|
|
317
|
+
picked = Math.min(picked, matches.length - 1)
|
|
318
|
+
list.hidden = false
|
|
319
|
+
list.innerHTML = matches.map((entry, index) => `<li id="slash-${index}" role="option" aria-selected="${index === picked}" class="${index === picked ? 'is-picked' : ''}" data-index="${index}"><span class="slash-name">/${esc(entry.name)}</span><span class="slash-kind">${entry.kind === 'skill' ? '◆' : '›'} ${esc(SCOPE_LABEL[entry.scope] || '')}</span>${entry.hint ? `<span class="slash-hint">${esc(entry.hint)}</span>` : ''}<span class="slash-desc">${esc(entry.description)}</span></li>`).join('')
|
|
320
|
+
list.querySelector('.is-picked')?.scrollIntoView({ block: 'nearest' })
|
|
321
|
+
$('message-input').setAttribute('aria-activedescendant', `slash-${picked}`)
|
|
322
|
+
}
|
|
323
|
+
function insertPick(index) {
|
|
324
|
+
const entry = matches[index]
|
|
325
|
+
const input = $('message-input')
|
|
326
|
+
if (!entry || !input) return
|
|
327
|
+
const before = input.value.slice(0, input.selectionStart)
|
|
328
|
+
const start = before.lastIndexOf('\n') + 1
|
|
329
|
+
const after = input.value.slice(input.selectionStart)
|
|
330
|
+
const insertion = `/${entry.name} `
|
|
331
|
+
input.value = input.value.slice(0, start) + insertion + after
|
|
332
|
+
input.selectionStart = input.selectionEnd = start + insertion.length
|
|
333
|
+
closePicker()
|
|
334
|
+
input.focus()
|
|
335
|
+
drafts.set(controlId, { text: input.value, requestId: crypto.randomUUID() })
|
|
336
|
+
}
|
|
337
|
+
function composerInput() {
|
|
338
|
+
const query = slashQuery($('message-input'))
|
|
339
|
+
if (query === null) return closePicker()
|
|
340
|
+
picked = 0
|
|
341
|
+
loadCatalog(controlId).then(() => { if (slashQuery($('message-input')) !== null) renderPicker(slashQuery($('message-input'))) })
|
|
342
|
+
if (catalog.length) renderPicker(query)
|
|
343
|
+
}
|
|
344
|
+
function composerKeydown(event) {
|
|
345
|
+
if (!matches.length) return
|
|
346
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
347
|
+
event.preventDefault()
|
|
348
|
+
picked = (picked + (event.key === 'ArrowDown' ? 1 : matches.length - 1)) % matches.length
|
|
349
|
+
return renderPicker(slashQuery($('message-input')) ?? '')
|
|
350
|
+
}
|
|
351
|
+
if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey) { event.preventDefault(); return insertPick(picked) }
|
|
352
|
+
if (event.key === 'Tab') { event.preventDefault(); return insertPick(picked) }
|
|
353
|
+
if (event.key === 'Escape') { event.preventDefault(); return closePicker() }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---- Updates -------------------------------------------------------------
|
|
357
|
+
// The server asks npm whether a newer Fleet has been published; the pill only
|
|
358
|
+
// appears when there is one, and nothing installs without a click.
|
|
359
|
+
let fleetUpdate = null, fleetUpdateBusy = false
|
|
360
|
+
|
|
361
|
+
// Takes its state as arguments rather than reading the two globals, so the states
|
|
362
|
+
// can be exercised one by one from a test.
|
|
363
|
+
function renderUpdate(update = fleetUpdate, busy = fleetUpdateBusy) {
|
|
364
|
+
const pill = $('update-pill')
|
|
365
|
+
if (!update || !update.available) { pill.hidden = true; return }
|
|
366
|
+
pill.hidden = false
|
|
367
|
+
pill.disabled = busy || !update.canInstall
|
|
368
|
+
if (busy) {
|
|
369
|
+
pill.textContent = update.state === 'installed' ? 'Restarting…' : 'Installing…'
|
|
370
|
+
pill.title = 'Fleet will reload itself when this finishes.'
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
pill.textContent = `↑ v${update.latest}`
|
|
374
|
+
// A checkout is the operator's to pull; only an npm install can replace itself.
|
|
375
|
+
pill.title = update.canInstall
|
|
376
|
+
? `Claude Fleet v${update.latest} is available. Click to install it and reload.`
|
|
377
|
+
: update.channel === 'source'
|
|
378
|
+
? `v${update.latest} is published. This Fleet runs from a git checkout — update it with git pull.`
|
|
379
|
+
: `v${update.latest} is published. This Fleet was not installed with npm, so it cannot update itself.`
|
|
380
|
+
}
|
|
381
|
+
async function pollUpdate() {
|
|
382
|
+
try {
|
|
383
|
+
const response = await fetch('/api/update', { cache: 'no-store', signal: AbortSignal.timeout(8000) })
|
|
384
|
+
if (!response.ok) return
|
|
385
|
+
fleetUpdate = (await response.json()).update
|
|
386
|
+
renderUpdate()
|
|
387
|
+
} catch {} // An older server, or no network. Either way there is nothing to show.
|
|
388
|
+
}
|
|
389
|
+
// The server hands the port to the new version, so wait for it to answer again
|
|
390
|
+
// rather than reloading into a closed socket.
|
|
391
|
+
async function waitForRestart(deadline = Date.now() + 60000) {
|
|
392
|
+
while (Date.now() < deadline) {
|
|
393
|
+
await new Promise(resolve => setTimeout(resolve, 700))
|
|
394
|
+
try {
|
|
395
|
+
const response = await fetch('/api/control', { cache: 'no-store', signal: AbortSignal.timeout(3000) })
|
|
396
|
+
if (response.ok) return location.reload()
|
|
397
|
+
} catch {}
|
|
398
|
+
}
|
|
399
|
+
fleetUpdateBusy = false
|
|
400
|
+
renderUpdate()
|
|
401
|
+
toast('Fleet installed the update but did not come back. Start it again.')
|
|
402
|
+
}
|
|
403
|
+
$('update-pill').addEventListener('click', async () => {
|
|
404
|
+
if (fleetUpdateBusy || !fleetUpdate || !fleetUpdate.canInstall) return
|
|
405
|
+
fleetUpdateBusy = true
|
|
406
|
+
renderUpdate()
|
|
407
|
+
try {
|
|
408
|
+
const data = await api('/api/update', {})
|
|
409
|
+
fleetUpdate = data.update
|
|
410
|
+
renderUpdate()
|
|
411
|
+
if (data.update.restarting) return waitForRestart()
|
|
412
|
+
fleetUpdateBusy = false
|
|
413
|
+
renderUpdate()
|
|
414
|
+
toast(`v${data.update.installed} installed. Restart Fleet to use it.`)
|
|
415
|
+
} catch (error) {
|
|
416
|
+
fleetUpdateBusy = false
|
|
417
|
+
fleetUpdate = { ...fleetUpdate, state: 'failed' }
|
|
418
|
+
renderUpdate()
|
|
419
|
+
toast(error.message || 'The update could not be installed.')
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
pollUpdate()
|
|
423
|
+
// The first check runs in the background on the server; ask again once it has had
|
|
424
|
+
// time to answer, then settle into a slow poll for long-lived windows.
|
|
425
|
+
setTimeout(pollUpdate, 9000)
|
|
426
|
+
setInterval(pollUpdate, 60 * 60 * 1000)
|
|
Binary file
|
|
Binary file
|