@sergeychuvayev/claude-fleet 0.8.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sergeychuvayev/claude-fleet",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "A local control room for Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude",
package/public/app.js CHANGED
@@ -1,5 +1,17 @@
1
1
  'use strict'
2
+ // An isolated scope, the way teams.js and blocks.js already do it. Everything in
3
+ // here used to sit in the page's shared global scope alongside control.js, blocks.js
4
+ // and ask.js, where a name chosen twice in two files is a SyntaxError that takes the
5
+ // whole dashboard down before a line of it runs. What the other files legitimately
6
+ // need is window.Fleet, published partway down; nothing else escapes.
7
+ // The leading semicolon is load-bearing: without it the directive above and the
8
+ // parenthesis below join into a call on the string "use strict".
9
+ ;(() => {
2
10
  const $ = id => document.getElementById(id)
11
+ // The one thing the core borrows back from control.js, which owns the control
12
+ // token every write is authenticated with. Resolved per call, not at load: app.js
13
+ // runs first, and every caller here is an event handler that fires long after.
14
+ const api = (...args) => window.FleetControl.api(...args)
3
15
  const STATES = ['busy', 'idle', 'stale', 'dead']
4
16
  const LABELS = { busy: 'Working', idle: 'Waiting', stale: 'Stale', dead: 'Offline' }
5
17
  const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
@@ -12,6 +24,15 @@ const age = timestamp => {
12
24
  const secs = Math.max(0, Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000))
13
25
  return secs < 60 ? `${secs}s` : secs < 3600 ? `${Math.floor(secs/60)}m` : secs < 86400 ? `${Math.floor(secs/3600)}h` : `${Math.floor(secs/86400)}d`
14
26
  }
27
+ // Everything Fleet remembers between visits goes through here. It is declared before
28
+ // its first reader on purpose: a `const` used above its declaration throws a
29
+ // ReferenceError that the callers' own try/catch would quietly absorb, leaving every
30
+ // remembered preference silently reset on each load.
31
+ const store = {
32
+ get(key) { try { return localStorage.getItem(key) } catch { return null } },
33
+ set(key, value) { try { localStorage.setItem(key, value) } catch {} },
34
+ clear(key) { try { localStorage.removeItem(key) } catch {} },
35
+ }
15
36
  let snapshot = null, filter = 'all', selected = null, pending = false, toastTimer
16
37
  // A delegation row nested under a team session. Keyed on the delegation id, never on
17
38
  // its position or status, so a sub-agent finishing does not move the operator's focus.
@@ -23,7 +44,10 @@ const formatModel = m => m ? String(m).replace('claude-', '') : 'Model pending'
23
44
  // A child row shares its data-session with the parent that owns it, so the session
24
45
  // id alone is not a unique row key: folding in data-delegation is what tells a
25
46
  // delegation row apart from its parent when the list redraws underneath focus.
26
- const rowFocusKey = b => b.dataset.delegation ? `${b.dataset.session}::${b.dataset.delegation}` : b.dataset.session || b.dataset.filter
47
+ // The fold header shares no data-session with anything, so it needs a key of its own:
48
+ // without one it reads as unkeyed and the poll two seconds after a click would drop
49
+ // the focus ring off the control the operator just used.
50
+ const rowFocusKey = b => b.dataset.foldSession ? `fold::${b.dataset.foldSession}` : b.dataset.delegation ? `${b.dataset.session}::${b.dataset.delegation}` : b.dataset.session || b.dataset.filter
27
51
  function update(id, html) {
28
52
  const el = $(id)
29
53
  if (!el || el.innerHTML === html) return
@@ -95,10 +119,44 @@ function childRowHtml(s, d) {
95
119
  const label = DELEGATION_LABEL[d.status] || d.status
96
120
  return `<button class="session session-child" data-session="${esc(key(s))}" data-delegation="${esc(d.id)}" aria-pressed="${selectedChild === d.id}" aria-controls="detail"><span><span class="session-top"><span class="badge ${cls}"><span class="dot"></span>${esc(label)}</span><span class="session-name">⑂ ${esc(d.role)}</span></span><span class="session-title">${esc(formatModel(d.model))}</span></span><span class="session-context"></span></button>`
97
121
  }
122
+ // A session's sub-agents fold away behind a header of their own. An initiative can sit
123
+ // between two sessions with twenty delegation rows wedged in the gap, which buries the
124
+ // list those rows belong to. Folding is per session and remembered, so putting one
125
+ // team's sub-agents away leaves every other team's on screen.
126
+ const COLLAPSED_KEY = 'fleet:children-collapsed'
127
+ let collapsedChildren = new Set()
128
+ try { collapsedChildren = new Set(JSON.parse(store.get(COLLAPSED_KEY) || '[]')) } catch { collapsedChildren = new Set() }
129
+ const childGroupId = k => `children-${String(k).replace(/[^\w-]/g, '_')}`
130
+ function setChildrenCollapsed(k, collapsed) {
131
+ if (collapsed) collapsedChildren.add(k)
132
+ else collapsedChildren.delete(k)
133
+ // Sessions come and go; only the folds still worth honouring are worth storing.
134
+ store.set(COLLAPSED_KEY, JSON.stringify([...collapsedChildren].slice(-200)))
135
+ }
136
+ // The header names the group and carries the fold. It is a sibling of the session
137
+ // row rather than part of it because that row is itself a button, and a button
138
+ // cannot hold another one.
139
+ function childToggleHtml(s, collapsed) {
140
+ const all = s.delegations || []
141
+ const running = all.filter(d => d.status === 'running').length
142
+ const failed = all.filter(d => d.status === 'failed').length
143
+ const counts = [`${all.length} sub-agent${all.length === 1 ? '' : 's'}`, running ? `${running} working` : '', failed ? `${failed} failed` : ''].filter(Boolean).join(' · ')
144
+ return `<button class="session-children-toggle${collapsed ? ' is-collapsed' : ''}" data-fold-session="${esc(key(s))}" aria-expanded="${!collapsed}" aria-controls="${esc(childGroupId(key(s)))}"><span class="children-chevron" aria-hidden="true">›</span><span class="children-count">⑂ ${esc(counts)}</span></button>`
145
+ }
98
146
  // An initiative that runs long enough accumulates delegations without bound; the
99
147
  // row list stays a list, not a scrollbar of its own, by showing only the tail.
100
148
  const CHILD_ROW_LIMIT = 20
101
149
  const childRowsHtml = s => {
150
+ const all = s.delegations || []
151
+ if (!all.length) return ''
152
+ // A fold never hides the one row the list reads as selected: clicking the header
153
+ // hands the selection back to the session first, and a group still holding the
154
+ // selected delegation by any other route draws open however it was left.
155
+ const collapsed = collapsedChildren.has(key(s)) && !all.some(d => d.id === selectedChild)
156
+ const group = `<div class="session-children" id="${esc(childGroupId(key(s)))}"${collapsed ? ' hidden' : ''}>${collapsed ? '' : childTailHtml(s)}</div>`
157
+ return childToggleHtml(s, collapsed) + group
158
+ }
159
+ const childTailHtml = s => {
102
160
  const all = s.delegations || []
103
161
  const recent = all.length > CHILD_ROW_LIMIT ? all.slice(-CHILD_ROW_LIMIT) : all
104
162
  // The parent row has already handed its aria-pressed to session-ancestor, so a
@@ -177,6 +235,61 @@ function renderStatusbar(usage, sessions) {
177
235
  if (usage?.blocked) banner.textContent = `Rate limited until ${clockAt(usage.blocked.resetsAt)} (${untilReset(usage.blocked.resetsAt, now)}). A new agent will not get past its first message until this window resets.`
178
236
  }
179
237
  }
238
+
239
+ // ── The session row ─────────────────────────────────────────────────────────
240
+ // One row is four lines: what the agent is and how it is doing, what it was asked,
241
+ // where it is working, and the story of its latest turn. Each line is built by its
242
+ // own function, so changing the look of one does not mean reading the other three.
243
+
244
+ // Line one, after the status badge: the qualifiers that say this row is not an
245
+ // ordinary foreground session you started yourself.
246
+ function rowTags(s, spawnCounts) {
247
+ const spawned = spawnCounts.get(s.pid)
248
+ return [
249
+ spawned ? `<span class="spawn-badge" title="Running ${spawned} background session(s)">⑂ ${spawned}</span>` : '',
250
+ s.background ? `<span class="spawn-owner" title="Started by ${esc(s.spawnedByName || 'a program')}, not from a terminal">via ${esc(s.spawnedByName || 'a program')}</span>` : '',
251
+ s.archived ? '<span class="archived-tag" title="Archived. Hidden from your fleet, still on disk and still resumable.">archived</span>' : '',
252
+ ].join('')
253
+ }
254
+ // A team session says which team is running it and how far through its tasks it is.
255
+ function initiativeTag(s) {
256
+ if (s.kind !== 'initiative') return ''
257
+ const p = s.taskProgress
258
+ const progress = p ? ` · ${p.verified}/${p.total} verified${p.blocked ? ` · ${p.blocked} need attention` : ''}` : ''
259
+ return `<span class="initiative-tag">Initiative · ${esc(s.teamName || s.teamId || 'Team')}${progress}</span>`
260
+ }
261
+ // Where the work is happening, and what it has cost.
262
+ function rowMeta(s) {
263
+ const project = s.cwd?.split('/').filter(Boolean).pop() || 'No project'
264
+ const spend = money(s.costUsd)
265
+ return `<span class="session-meta"><span>${esc(project)}</span><span class="branch">⑂ ${esc(s.branch || 'No branch')}</span>${s.links?.length ? `<span>↗ ${s.links.length}</span>` : ''}${spend ? `<span class="session-cost" title="What this conversation has cost so far">${esc(spend)}</span>` : ''}</span>`
266
+ }
267
+ // The right-hand column: how full the context window is, and how long ago the
268
+ // agent last did anything.
269
+ function contextCell(s) {
270
+ const p = percent(s)
271
+ return `<span class="session-context ${heat(p)}">${p === null ? '—' : Math.round(p) + '%'}<span class="mini-bar"><i class="${heat(p)}" style="width:${p || 0}%"></i></span><small>${age(s.lastActivity)} ago</small></span>`
272
+ }
273
+ function sessionRowHtml(s, spawnCounts) {
274
+ // A row holding the selected sub-agent is an ancestor of the selection, not the
275
+ // selection itself, so it gives up aria-pressed to the child row below it.
276
+ const childSelectedHere = !!selectedChild && (s.delegations || []).some(d => d.id === selectedChild)
277
+ const name = (s.managed ? 'FLEET · ' : '') + (s.name || s.shortId || 'Unnamed session')
278
+ const top = `<span class="session-top">${hasUnseen(s) ? '<span class="unseen" aria-label="New output"></span>' : ''}${status(s)}<span class="session-name">${esc(name)}</span>${rowTags(s, spawnCounts)}</span>`
279
+ const body = `${top}${initiativeTag(s)}<span class="session-title">${esc(s.title || s.lastPrompt || 'Untitled session')}</span>${rowMeta(s)}${turnRow(s)}`
280
+ return `<button class="session${childSelectedHere ? ' session-ancestor' : ''}" data-session="${esc(key(s))}" aria-pressed="${selected === key(s) && !childSelectedHere}" aria-controls="detail" title="${hasUnseen(s) ? 'New output since you last opened this' : ''}"><span>${body}</span>${contextCell(s)}</button>${childRowsHtml(s)}`
281
+ }
282
+ const filterBarHtml = (counts, foreground, background, archived) => [
283
+ ['all', 'All sessions', foreground],
284
+ ...STATES.map(s => [s, LABELS[s], counts[s] || 0]),
285
+ ...(background ? [['background', 'Background', background]] : []),
286
+ ...(archived ? [['archived', 'Archived', archived]] : []),
287
+ ].map(([s, label, n]) => `<button class="filter" data-filter="${s}" aria-pressed="${filter === s}">${label}<span>${n}</span></button>`).join('')
288
+ const emptyListHtml = total =>
289
+ filter === 'background' ? 'No background sessions right now.'
290
+ : total ? 'No sessions match your filters.<br>Try another search or select All sessions.'
291
+ : 'Your fleet is quiet.<br>Start a Claude Code session and it will appear here automatically.'
292
+
180
293
  function render() {
181
294
  if (!snapshot) return
182
295
  const {sessions, total} = snapshot
@@ -200,13 +313,11 @@ function render() {
200
313
  if (!shown.some(s => key(s) === selected)) selected = shown[0] ? key(shown[0]) : null
201
314
  $('shown-count').textContent = shown.length
202
315
  renderStatusbar(snapshot.usage, live)
203
- update('filters', [['all','All sessions',foreground.length],...STATES.map(s => [s,LABELS[s],(visibleCounts[s] || 0)]),...(background.length ? [['background','Background',background.length]] : []),...(archived.length ? [['archived','Archived',archived.length]] : [])].map(([s,label,n]) => `<button class="filter" data-filter="${s}" aria-pressed="${filter === s}">${label}<span>${n}</span></button>`).join(''))
316
+ update('filters', filterBarHtml(visibleCounts, foreground.length, background.length, archived.length))
204
317
  renderArchiveBar(live.filter(s => s.state === 'dead'), archived.length)
205
- update('session-list', shown.length ? shown.map(s => {
206
- const p = percent(s)
207
- const childSelectedHere = !!selectedChild && (s.delegations || []).some(d => d.id === selectedChild)
208
- return `<button class="session${childSelectedHere ? ' session-ancestor' : ''}" data-session="${esc(key(s))}" aria-pressed="${selected === key(s) && !childSelectedHere}" aria-controls="detail" title="${hasUnseen(s) ? 'New output since you last opened this' : ''}"><span><span class="session-top">${hasUnseen(s) ? '<span class="unseen" aria-label="New output"></span>' : ''}${status(s)}<span class="session-name">${esc((s.managed ? 'FLEET · ' : '') + (s.name || s.shortId || 'Unnamed session'))}</span>${spawnCounts.get(s.pid) ? `<span class="spawn-badge" title="Running ${spawnCounts.get(s.pid)} background session(s)">⑂ ${spawnCounts.get(s.pid)}</span>` : ''}${s.background ? `<span class="spawn-owner" title="Started by ${esc(s.spawnedByName || 'a program')}, not from a terminal">via ${esc(s.spawnedByName || 'a program')}</span>` : ''}${s.archived ? '<span class="archived-tag" title="Archived. Hidden from your fleet, still on disk and still resumable.">archived</span>' : ''}</span>${s.kind === 'initiative' ? `<span class="initiative-tag">Initiative · ${esc(s.teamName || s.teamId || 'Team')}${s.taskProgress ? ` · ${s.taskProgress.verified}/${s.taskProgress.total} verified${s.taskProgress.blocked ? ` · ${s.taskProgress.blocked} need attention` : ''}` : ''}</span>` : ''}<span class="session-title">${esc(s.title || s.lastPrompt || 'Untitled session')}</span><span class="session-meta"><span>${esc(s.cwd?.split('/').filter(Boolean).pop() || 'No project')}</span><span class="branch">⑂ ${esc(s.branch || 'No branch')}</span>${s.links?.length ? `<span>↗ ${s.links.length}</span>` : ''}${money(s.costUsd) ? `<span class="session-cost" title="What this conversation has cost so far">${esc(money(s.costUsd))}</span>` : ''}</span>${turnRow(s)}</span><span class="session-context ${heat(p)}">${p === null ? '—' : Math.round(p)+'%'}<span class="mini-bar"><i class="${heat(p)}" style="width:${p || 0}%"></i></span><small>${age(s.lastActivity)} ago</small></span></button>${childRowsHtml(s)}`
209
- }).join('') : `<div class="empty">${filter === 'background' ? 'No background sessions right now.' : total ? 'No sessions match your filters.<br>Try another search or select All sessions.' : 'Your fleet is quiet.<br>Start a Claude Code session and it will appear here automatically.'}</div>`)
318
+ update('session-list', shown.length
319
+ ? shown.map(s => sessionRowHtml(s, spawnCounts)).join('')
320
+ : `<div class="empty">${emptyListHtml(total)}</div>`)
210
321
  const current = shown.find(s => key(s) === selected)
211
322
  if (current) markSeen(key(current), current.lastActivity)
212
323
  // A delegation belongs to whichever session is actually current; switching sessions,
@@ -224,10 +335,10 @@ function render() {
224
335
  renderChildDetail(current, childId)
225
336
  // A sub-agent is not addressable: clearing the control panel drops its composer
226
337
  // and conversation from the DOM entirely, not merely hiding them.
227
- if (typeof selectControl === 'function') selectControl(null)
338
+ window.FleetControl?.selectControl(null)
228
339
  } else {
229
340
  renderDetail(current)
230
- if (typeof selectControl === 'function') selectControl(current)
341
+ window.FleetControl?.selectControl(current)
231
342
  }
232
343
  syncDetails()
233
344
  }
@@ -380,11 +491,32 @@ document.addEventListener('click', event => {
380
491
  if (event.target === $(openModalId) || event.target.closest('[data-close-modal]')) closeModal()
381
492
  })
382
493
 
383
- function toast(message) { $('toast').textContent = message; $('toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => $('toast').hidden = true, 3000) }
494
+ // Reached from every error path, including ones that fire before the page has
495
+ // finished wiring itself up, so a missing toast element costs the message and
496
+ // nothing more.
497
+ function toast(message) {
498
+ const box = $('toast')
499
+ if (!box) return
500
+ box.textContent = message
501
+ box.hidden = false
502
+ clearTimeout(toastTimer)
503
+ toastTimer = setTimeout(() => { box.hidden = true }, 3000)
504
+ }
384
505
  document.addEventListener('click', async event => {
385
506
  const b = event.target.closest('button')
386
507
  if (!b) return
387
508
  if (b.dataset.filter) { filter = b.dataset.filter; render() }
509
+ if (b.dataset.foldSession) {
510
+ const k = b.dataset.foldSession, collapsed = !collapsedChildren.has(k)
511
+ setChildrenCollapsed(k, collapsed)
512
+ // Folding the group away takes the selection back up to the session that owns it,
513
+ // so the list never hides the one row reading as selected.
514
+ if (collapsed && selectedChild && snapshot?.sessions.find(s => key(s) === k)?.delegations?.some(d => d.id === selectedChild)) {
515
+ selected = k
516
+ selectedChild = null
517
+ }
518
+ return render()
519
+ }
388
520
  if (b.dataset.delegation) {
389
521
  selected = b.dataset.session; selectedChild = b.dataset.delegation; render()
390
522
  if (matchMedia('(max-width:720px)').matches) $('detail').scrollIntoView({behavior:'instant',block:'start'})
@@ -405,9 +537,23 @@ document.addEventListener('click', async event => {
405
537
  catch { toast('Clipboard unavailable. The command is shown below.'); const code = document.createElement('pre'); code.className = 'response'; code.textContent = s.resumeCmd; $('detail').append(code) }
406
538
  }
407
539
  })
540
+ const setBusy = busy => { if ($('refresh')) $('refresh').disabled = busy }
541
+ const setConnection = (text, state) => {
542
+ if ($('connection')) $('connection').textContent = text
543
+ if ($('connection-dot')) $('connection-dot').className = `dot ${state}`
544
+ }
545
+ const showError = message => {
546
+ const box = $('error')
547
+ if (!box) return
548
+ box.hidden = !message
549
+ if (message) box.textContent = message
550
+ }
408
551
  async function tick() {
409
552
  if (pending) return
410
- pending = true; $('refresh').disabled = true
553
+ // The poll runs on a timer and in the catch below, so it never assumes the
554
+ // chrome it writes into is mounted.
555
+ pending = true
556
+ setBusy(true)
411
557
  try {
412
558
  const r = await fetch('/api/sessions', {cache:'no-store',signal:AbortSignal.timeout(8000)})
413
559
  if (!r.ok) throw new Error(`HTTP ${r.status}`)
@@ -422,18 +568,55 @@ async function tick() {
422
568
  }
423
569
  // Other panels (the ask results) re-read the snapshot to refresh "open now" state.
424
570
  document.dispatchEvent(new CustomEvent('fleet-snapshot'))
425
- $('connection').textContent = 'Live connection'; $('connection-dot').className = 'dot busy'
426
- $('updated').textContent = `Updated ${new Date(data.generatedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}`
427
- $('error').hidden = !data.storageError
428
- if (data.storageError) $('error').textContent = data.storageError
571
+ setConnection('Live connection', 'busy')
572
+ if ($('updated')) $('updated').textContent = `Updated ${new Date(data.generatedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}`
573
+ showError(data.storageError)
429
574
  } catch {
430
- $('connection').textContent = 'Disconnected'; $('connection-dot').className = 'dot stale'
431
- $('error').textContent = snapshot ? 'Connection lost. Showing the last successful snapshot; retrying automatically.' : 'Unable to connect to the local server. Retrying automatically.'
432
- $('error').hidden = false
575
+ setConnection('Disconnected', 'stale')
576
+ showError(snapshot ? 'Connection lost. Showing the last successful snapshot; retrying automatically.' : 'Unable to connect to the local server. Retrying automatically.')
433
577
  if (!snapshot) { update('session-list','<div class="empty">Waiting for the local server…</div>'); renderDetail(null) }
434
- } finally { pending = false; $('refresh').disabled = false }
578
+ } finally { pending = false; setBusy(false) }
435
579
  }
436
- $('refresh').addEventListener('click',tick)
580
+ // ── What the rest of the page may use ───────────────────────────────────────
581
+ // Published here, before the boot sequence below runs, and not as the value the
582
+ // wrapper returns: control.js, teams.js and ask.js destructure this the moment they
583
+ // load, so an element missing from the wiring that follows must not take the whole
584
+ // page down with it. State is handed out through functions rather than as live
585
+ // bindings, so a caller cannot take a copy of `snapshot` and read a stale one after
586
+ // the next poll.
587
+ window.Fleet = {
588
+ // DOM and formatting helpers the other files share.
589
+ $, esc, update, key, age, tokens, money, status, store,
590
+ // The account's plan windows, rendered from a usage reading.
591
+ usageHtml,
592
+ // Current state.
593
+ snapshot: () => snapshot,
594
+ // Actions. Each one renders, so a caller never has to remember to.
595
+ render,
596
+ setSnapshot(next) { snapshot = next; render() },
597
+ setFilter(next) { filter = next; render() },
598
+ select(sessionKey, delegationId = null) { selected = sessionKey; selectedChild = delegationId; render() },
599
+ setChildrenCollapsed(sessionKey, collapsed) { setChildrenCollapsed(sessionKey, collapsed); render() },
600
+ // What loadChildDetail's completion does: hand over the delegation the session
601
+ // route returned, then redraw. Select the delegation first; a detail handed over
602
+ // for one that is not selected has nowhere to be drawn.
603
+ setChildDetail(delegationId, detail, error = null) {
604
+ childDetail = detail
605
+ childDetailFor = delegationId
606
+ childDetailError = error
607
+ render()
608
+ },
609
+ toast,
610
+ // Modals.
611
+ modalIsOpen, openModal, closeModal,
612
+ // Layout, formerly window.FleetLayout.
613
+ watchConversation, syncDetails,
614
+ // Control-panel rendering, for tests and for the update poller.
615
+ renderUpdate: (...args) => window.FleetControl.renderUpdate(...args),
616
+ }
617
+
618
+ // ── Boot ────────────────────────────────────────────────────────────────────
619
+ $('refresh')?.addEventListener('click',tick)
437
620
  tick()
438
621
  setInterval(() => { if (!document.hidden) tick() },2000)
439
622
  document.addEventListener('visibilitychange', () => { if (!document.hidden) tick() })
@@ -442,11 +625,6 @@ document.addEventListener('visibilitychange', () => { if (!document.hidden) tick
442
625
  // inspector, and a resizable console. Both are remembered per browser; a storage
443
626
  // failure (private window, blocked site data) only costs the remembered size.
444
627
  const LAYOUT = { split: 'fleet:split', height: 'fleet:conv-height' }
445
- const store = {
446
- get(key) { try { return localStorage.getItem(key) } catch { return null } },
447
- set(key, value) { try { localStorage.setItem(key, value) } catch {} },
448
- clear(key) { try { localStorage.removeItem(key) } catch {} },
449
- }
450
628
  const SPLIT_DEFAULT = 58, LIST_MIN = 300, DETAIL_MIN = 380
451
629
 
452
630
  function applySplit(percent, { save = true } = {}) {
@@ -525,7 +703,6 @@ function watchConversation(element) {
525
703
  conversationObserver.disconnect()
526
704
  conversationObserver.observe(element)
527
705
  }
528
- window.FleetLayout = { watchConversation }
529
706
  initSplitter()
530
707
 
531
708
  // The session details sit behind a disclosure: with a console on screen the terminal
@@ -545,4 +722,4 @@ $('details-toggle')?.addEventListener('click', () => {
545
722
  store.set(DETAILS_KEY, open ? '1' : '0')
546
723
  syncDetails()
547
724
  })
548
- window.FleetLayout.syncDetails = syncDetails
725
+ })()
package/public/ask.js CHANGED
@@ -1,24 +1,36 @@
1
1
  'use strict'
2
+ // An isolated scope, matching teams.js and blocks.js.
3
+ ;(() => {
4
+ const { $, esc, key, age, update, status, render, toast, modalIsOpen, openModal, closeModal } = window.Fleet
5
+ const api = (...args) => window.FleetControl.api(...args)
2
6
  // Ask panel: one question, searched across every transcript on this machine.
3
7
  // Keyword matches render the moment the server has them; the written answer
4
8
  // arrives a few seconds later and reorders the cards by what Claude found relevant.
5
9
  let askJob = null, askPoll = null, askRequest = 0
6
10
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform)
7
- $('ask-shortcut').textContent = isMac ? '⌘K' : 'Ctrl K'
8
11
 
9
12
  const openAsk = () => { renderAsk(); openModal('ask-backdrop', '#ask-input') }
10
- $('ask-welcome').addEventListener('click', event => {
13
+
14
+ // ── What the rest of the page may use ───────────────────────────────────────
15
+ // Published before the boot wiring below, as app.js and control.js do. Nothing
16
+ // consumes this yet; it exists so opening the panel is a call rather than a
17
+ // synthesised click, and so this file leaks one name like every other.
18
+ window.FleetAsk = { openAsk }
19
+
20
+ // ── Boot ────────────────────────────────────────────────────────────────────
21
+ if ($('ask-shortcut')) $('ask-shortcut').textContent = isMac ? '⌘K' : 'Ctrl K'
22
+ $('ask-welcome')?.addEventListener('click', event => {
11
23
  const suggestion = event.target.closest('[data-question]')
12
24
  if (!suggestion) return
13
25
  $('ask-input').value = suggestion.dataset.question
14
26
  $('ask-input').focus()
15
27
  })
16
- $('ask-sessions').addEventListener('click', () => modalIsOpen('ask-backdrop') ? closeModal() : openAsk())
28
+ $('ask-sessions')?.addEventListener('click', () => modalIsOpen('ask-backdrop') ? closeModal() : openAsk())
17
29
  document.addEventListener('keydown', event => {
18
30
  if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') { event.preventDefault(); openAsk() }
19
31
  })
20
32
 
21
- $('ask-form').addEventListener('submit', async event => {
33
+ $('ask-form')?.addEventListener('submit', async event => {
22
34
  event.preventDefault()
23
35
  const question = $('ask-input').value.trim()
24
36
  if (!question) return
@@ -55,7 +67,9 @@ async function pollAsk() {
55
67
 
56
68
  const REL_WORD = { high: 'strong match', medium: 'related', low: 'loosely related' }
57
69
  const dateOf = ms => ms ? new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' }) : ''
58
- const liveSession = id => snapshot?.sessions.find(s => s.sessionId === id) || null
70
+ // Read through the accessor, never a captured binding: the poll replaces the
71
+ // snapshot object every couple of seconds.
72
+ const liveSession = id => window.Fleet.snapshot()?.sessions.find(s => s.sessionId === id) || null
59
73
 
60
74
  function hitCard(hit, match) {
61
75
  const live = liveSession(hit.sessionId)
@@ -97,7 +111,7 @@ function renderAsk() {
97
111
  update('ask-results', `<div class="ask-head"><span class="ask-question">“${esc(job.question)}”</span>${stats}</div>${status}${answer}${cards}`)
98
112
  }
99
113
 
100
- $('ask-results').addEventListener('click', async event => {
114
+ $('ask-results')?.addEventListener('click', async event => {
101
115
  const button = event.target.closest('button')
102
116
  if (!button) return
103
117
  if (button.dataset.openSession) {
@@ -117,3 +131,5 @@ $('ask-results').addEventListener('click', async event => {
117
131
  // Live sessions may appear or vanish while the results are on screen; refresh the
118
132
  // "open now" state and the Open button from the latest snapshot.
119
133
  document.addEventListener('fleet-snapshot', () => { if (askJob && modalIsOpen('ask-backdrop')) renderAsk() })
134
+
135
+ })()
package/public/blocks.js CHANGED
@@ -1,4 +1,6 @@
1
1
  'use strict'
2
+ // An isolated scope, matching teams.js. This file borrows nothing from the others.
3
+ window.FleetBlocks = (() => {
2
4
  // Warp-style conversation blocks. Every message, tool call and result is its own
3
5
  // block with a sticky header, a copy action and collapse. Rendering is incremental:
4
6
  // a block is only rebuilt when its content signature changes, so a streaming turn
@@ -190,4 +192,5 @@ function renderBlocks(container, messages, { streamingId = null, onCopy = () =>
190
192
  for (const element of [...container.children]) if (!seen.has(element.dataset?.block)) element.remove()
191
193
  }
192
194
 
193
- window.FleetBlocks = { renderBlocks, proseHtml, codeHtml, highlight }
195
+ return { renderBlocks, proseHtml, codeHtml, highlight }
196
+ })()
package/public/control.js CHANGED
@@ -1,4 +1,9 @@
1
1
  'use strict'
2
+ // An isolated scope, matching teams.js and blocks.js. What this file borrows from
3
+ // app.js is destructured once, here, instead of being picked out of a global scope
4
+ // the two files happened to share.
5
+ ;(() => {
6
+ const { $, esc, update, toast, modalIsOpen, openModal, closeModal } = window.Fleet
2
7
  let controlToken=null, controlSession=null, controlId=null, controlFetch=null, controlVersion=0
3
8
  const drafts=new Map()
4
9
  const inFlight=new Set()
@@ -60,7 +65,23 @@ function openLaunch(source=null) {
60
65
  $('launch-cwd').readOnly=!!source
61
66
  openModal('launch-backdrop', '[name=prompt]')
62
67
  }
63
- $('new-session').addEventListener('click',()=>modalIsOpen('launch-backdrop') ? closeModal() : openLaunch())
68
+ // ── What the rest of the page may use ───────────────────────────────────────
69
+ // Published before the boot wiring below, for the same reason app.js does it there:
70
+ // teams.js destructures this at load, and an element missing from the wiring must
71
+ // not cost it the whole namespace. The two values teams.js has to change are handed
72
+ // out as setters rather than as variables it reaches in and assigns.
73
+ window.FleetControl = {
74
+ selectControl, isWorking, updateLaunchTeam, renderUpdate,
75
+ // teams.js posts to the same endpoints through the same helper, and app.js and
76
+ // ask.js borrow it back: this file owns the token every write is signed with.
77
+ api,
78
+ launchTeams: () => launchTeams,
79
+ setLaunchTeams(teams) { launchTeams = teams },
80
+ setLaunchRequestId(id) { launchRequestId = id },
81
+ }
82
+
83
+ // ── Boot ────────────────────────────────────────────────────────────────────
84
+ $('new-session')?.addEventListener('click',()=>modalIsOpen('launch-backdrop') ? closeModal() : openLaunch())
64
85
  document.addEventListener('keydown', event => {
65
86
  if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'n') {
66
87
  event.preventDefault()
@@ -68,8 +89,8 @@ document.addEventListener('keydown', event => {
68
89
  else openLaunch()
69
90
  }
70
91
  })
71
- $('launch-form').addEventListener('input',()=>{launchRequestId=null})
72
- $('launch-form').addEventListener('submit',async event=>{
92
+ $('launch-form')?.addEventListener('input',()=>{launchRequestId=null})
93
+ $('launch-form')?.addEventListener('submit',async event=>{
73
94
  event.preventDefault()
74
95
  if(window.FleetTeams?.isEditing()){window.FleetTeams.save();return}
75
96
  const button=$('launch-submit'); if(button.disabled)return
@@ -94,9 +115,9 @@ function selectControl(session) {
94
115
  $('control-panel').innerHTML=''
95
116
  if(next){
96
117
  $('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>`
97
- window.FleetLayout?.watchConversation($('conversation'))
118
+ window.Fleet.watchConversation($('conversation'))
98
119
  catalog=[];catalogFor=null;closePicker();renderTray()
99
- window.FleetLayout?.syncDetails?.()
120
+ window.Fleet.syncDetails()
100
121
  $('message-input').value=drafts.get(next)?.text || ''
101
122
  $('message-input').addEventListener('input',()=>drafts.set(next,{text:$('message-input').value,requestId:crypto.randomUUID()}))
102
123
  $('message-input').addEventListener('keydown',event=>{
@@ -129,7 +150,7 @@ function selectControl(session) {
129
150
  }else if(session){
130
151
  $('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>`
131
152
  $('resume-in-fleet')?.addEventListener('click',()=>openLaunch(session))
132
- window.FleetLayout?.syncDetails?.()
153
+ window.Fleet.syncDetails()
133
154
  }
134
155
  }
135
156
  async function refreshControl() {
@@ -439,7 +460,7 @@ async function waitForRestart(deadline = Date.now() + 60000) {
439
460
  renderUpdate()
440
461
  toast('Fleet installed the update but did not come back. Start it again.')
441
462
  }
442
- $('update-pill').addEventListener('click', async () => {
463
+ $('update-pill')?.addEventListener('click', async () => {
443
464
  if (fleetUpdateBusy || !fleetUpdate || !fleetUpdate.canInstall) return
444
465
  fleetUpdateBusy = true
445
466
  renderUpdate()
@@ -463,3 +484,4 @@ pollUpdate()
463
484
  // time to answer, then settle into a slow poll for long-lived windows.
464
485
  setTimeout(pollUpdate, 9000)
465
486
  setInterval(pollUpdate, 60 * 60 * 1000)
487
+ })()