@sergeychuvayev/claude-fleet 0.11.0 → 0.12.1

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/README.md CHANGED
@@ -6,12 +6,13 @@
6
6
 
7
7
  **A local control room for Claude Code.**
8
8
 
9
- See every session running on your machine, launch agents you can talk to,
10
- and ask one question across everything you have ever worked on.
9
+ See every session running on your machine, launch agents you can talk to, hand a
10
+ bigger job to a manager-led team whose work an independent verifier checks before
11
+ it ships, and ask one question across everything you have ever worked on.
11
12
 
12
13
  <img src="https://img.shields.io/badge/node-%E2%89%A522-2aa889?style=flat-square&labelColor=0c1014" alt="Node 22+">
13
14
  <img src="https://img.shields.io/badge/binds-127.0.0.1-2aa889?style=flat-square&labelColor=0c1014" alt="Binds to localhost">
14
- <img src="https://img.shields.io/badge/deps-4%20runtime-2aa889?style=flat-square&labelColor=0c1014" alt="Four runtime dependencies">
15
+ <img src="https://img.shields.io/badge/deps-5%20runtime-2aa889?style=flat-square&labelColor=0c1014" alt="Five runtime dependencies">
15
16
  <img src="https://img.shields.io/badge/license-MIT-2aa889?style=flat-square&labelColor=0c1014" alt="MIT license">
16
17
 
17
18
  <br>
@@ -205,6 +206,19 @@ from `marked`, `DOMPurify` and `highlight.js`, bundled into `public/vendor/libs.
205
206
  and served by Fleet itself. There is no CDN, and the page's content security policy
206
207
  still allows scripts only from Fleet.
207
208
 
209
+ ### Panels resize to fit the work
210
+
211
+ The session list and inspector share a horizontal split; drag it, or focus it and use the
212
+ arrow keys. Inside the inspector, the team overview and the composer are flat sections set
213
+ off by a rule rather than the rounded cards earlier versions used, and each carries its own
214
+ divider so the conversation between them can give up height to whichever one needs it.
215
+
216
+ Drag a divider, resize it from the keyboard with the arrow keys, jump to an extreme with
217
+ Home/End, or double-click to reset it. Sizes persist per browser and are clamped so neither
218
+ the conversation nor the panel being resized can be squeezed out of use. Under 720px width,
219
+ or while a panel is collapsed, its divider disappears and the layout falls back to normal
220
+ document flow.
221
+
208
222
  ### Reference another agent
209
223
 
210
224
  In a Fleet-managed agent’s message composer, type **@** and search by session name,
@@ -266,24 +280,39 @@ conversation on every question.
266
280
  ### An initiative is a team behind one conversation
267
281
 
268
282
  Some work is too big for one agent and too small to project-manage by hand. Launch it with a
269
- **team** instead of alone and you get an *initiative*: a manager that plans and delegates, a
270
- developer that implements, and a QA that independently verifies, all behind a single
271
- conversation.
283
+ **team** instead of alone and you get an *initiative*: a manager that plans and delegates,
284
+ one or more roles that implement, and a required verifier that checks the work
285
+ independently, all behind a single conversation.
272
286
 
273
287
  You talk to the manager and only to the manager. That is not a rule in a prompt: the manager
274
288
  holds the main thread, and the rest of the team is reachable only through the Agent tool, so
275
289
  they have no channel to you at all. Their work arrives as delegation blocks in the
276
290
  conversation, each showing the role, the mandate it was given, and the report it sent back.
277
291
 
278
- The manager has no edit tools. Its only way to ship is to delegate, which is the entire point
279
- of having a team rather than an agent with a long prompt. QA has no edit tools either: it
280
- reproduces the problem, runs the project's own gates, and returns PASS or FAIL with evidence,
281
- so a developer's account of its own work is never the last word.
292
+ The manager has no shell or edit tools. Its only way to ship is to delegate and to track the
293
+ work through Fleet's task tool, which is the entire point of having a team rather than an
294
+ agent with a long prompt. A verifier has no direct edit tools either: it reproduces the
295
+ problem, runs the project's own gates, and returns PASS or FAIL with evidence, so a
296
+ developer's account of its own work is never the last word, and the manager cannot mark a
297
+ task verified itself. Bash, where a verifier's role enables it for running tests, stays a
298
+ general-purpose shell governed by the initiative's approval mode, not a read-only sandbox. A
299
+ failed review sends work back for repair and invalidates the previous attempt's reviews.
300
+
301
+ In **New agent**, **Software delivery** gives a brief to a Manager backed by Product,
302
+ Developer, Reviewer and QA roles: three implementation attempts per task and $10 in reported
303
+ SDK usage before it stops for you, adjustable while the initiative is idle. **Quick task**
304
+ trims that to a Sonnet manager, one developer and one independent verifier, for small, clearly
305
+ scoped changes, with two attempts and a $3 cap. **No team · single agent** skips orchestration
306
+ for a one-line fix. **Customize team…** saves your own roster: rename, add or remove roles,
307
+ write their instructions, pick a model and allowed tools per role, up to eight roles with at
308
+ least one manager and one verifier. Each initiative snapshots its team at launch, so editing a
309
+ template only affects the initiatives you start after that.
282
310
 
283
311
  An initiative works in a git worktree of its own, branched from wherever the project is
284
312
  checked out, so a team editing files cannot collide with your own editing or with another
285
- initiative. It finishes by opening a pull request. Pushing stops for your approval like any
286
- other publishing command, so nothing leaves the machine without you.
313
+ initiative. It finishes at a local branch, with the manager expected to open a pull request
314
+ as its last delegated action. Doing that requires a push, and a push stops for your approval
315
+ like any other publishing command, so nothing leaves the machine without that click.
287
316
 
288
317
  Closing an initiative forgets Fleet's record of the conversation and leaves the worktree and
289
318
  its branch alone. Deleting code is never the same click as tidying a list.
@@ -291,6 +320,40 @@ its branch alone. Deleting code is never the same click as tidying a list.
291
320
  Teams cost roughly an order of magnitude more tokens than a single agent, and only earn it
292
321
  when the work genuinely splits. For a one-line fix, launch an agent.
293
322
 
323
+ <details>
324
+ <summary><b>The task board, and what "verified" actually means</b></summary>
325
+
326
+ The manager creates tasks with owners, acceptance criteria and dependencies through Fleet's
327
+ task tool, up to 100 per initiative. The initiative inspector shows the roster, task states,
328
+ assignments and returned reports. Reads from the board return compact metadata, so checking
329
+ task state repeatedly does not re-inject every assignment, tool output and report into the
330
+ manager's context; the manager can pull one delegation's full record with the task tool's
331
+ `inspect` action.
332
+
333
+ Delegations run sequentially in the shared worktree. After an interruption, the saved board
334
+ survives and unfinished delegations are marked interrupted, so messaging the manager resumes
335
+ where it left off. "Verified" records the configured verifier's PASS/FAIL for that attempt,
336
+ not a guarantee that the evaluation was correct or that later work cannot regress it. The SDK
337
+ budget is an execution cutoff, not a billing guarantee: usage is reported at turn end, and a
338
+ killed runtime may not report its final spend, and each turn also has a 100-turn SDK limit
339
+ with bounded continuation reminders. Custom teams live in `teams.json` under Fleet's state
340
+ directory; task history and team snapshots live with the initiative in `sessions.json`.
341
+
342
+ </details>
343
+
344
+ ### Inspect individual agents
345
+
346
+ Select a subagent row beneath a managed initiative to see its assignment, actual model,
347
+ status, elapsed time, attempt, tool steps, and report. Expand **Input and output** for a
348
+ tool's recorded payload. The inspector is read-only; direction and approvals still go through
349
+ the manager. Tool history keeps the most recent 200 steps with bounded inputs and outputs, and
350
+ older runs may have no recorded steps or usage.
351
+
352
+ Reported input/output and cache tokens are shown when available, falling back to SDK progress
353
+ totals otherwise; repeated assistant events do not count usage twice, and per-agent cost
354
+ appears only when the SDK explicitly reports it rather than being shown as zero. Token totals
355
+ describe recorded usage across messages, not current context size.
356
+
294
357
  ### Approvals that stay out of the way
295
358
 
296
359
  Every agent runs in one of three modes, chosen at launch and changeable from the
@@ -399,7 +462,7 @@ no API credentials are involved.
399
462
  ## How it is built
400
463
 
401
464
  Vanilla HTML, CSS and JavaScript over a Node HTTP server, with the official Claude
402
- Agent SDK for managed runs. Four runtime dependencies, no framework, no build step
465
+ Agent SDK for managed runs. Five runtime dependencies, no framework, no build step
403
466
  for the app itself.
404
467
 
405
468
  | File | Responsibility |
@@ -408,18 +471,23 @@ for the app itself.
408
471
  | [`fleet.js`](fleet.js) | Cached, read-only collection of external Claude sessions |
409
472
  | [`archive.js`](archive.js) | Which sessions are put away, the age rule, and its store |
410
473
  | [`managed.js`](managed.js) | SDK runs, approvals, tool blocks, persistence, cancellation |
474
+ | [`references.js`](references.js) | Resolves `@`-mentioned sessions and snapshots what a reference shares |
411
475
  | [`teams.js`](teams.js) | The roles an initiative runs, and how they compile into SDK options |
476
+ | [`team-store.js`](team-store.js) | Custom team definitions, saved and edited outside the built-in presets |
477
+ | [`tasks.js`](tasks.js) | The task board: tasks, delegations, and the initiative's verification ledger |
412
478
  | [`worktree.js`](worktree.js) | The git worktree an initiative works in, and its branch |
479
+ | [`usage.js`](usage.js) | The account's plan-usage windows behind the status bar |
413
480
  | [`search.js`](search.js) | Transcript index, BM25 ranking, and the answering turn |
414
481
  | [`permissions.js`](permissions.js) | The three approval modes and the command list that still stops |
415
482
  | [`theme.js`](theme.js) | Reads the local Warp palette and renders it as CSS variables |
416
483
  | [`catalog.js`](catalog.js) | Read-only listing of a project's slash commands and skills |
417
- | `public/app.js` | Dashboard layout, session list, filters, monitoring |
484
+ | `public/app.js` | Dashboard layout, session list, filters, monitoring, resizable panels |
418
485
  | `public/blocks.js` | Incremental block rendering, Markdown, highlighting |
419
486
  | `public/control.js` | Launch form, composer, approvals, streamed updates |
420
487
  | `public/ask.js` | The Ask panel, its polling, and the result cards |
421
488
  | [`paths.js`](paths.js) | Where Fleet's own state lives, and carrying over an old checkout's |
422
489
  | [`update.js`](update.js) | The npm version check, its cache, and the self-install |
490
+ | [`open.js`](open.js) | Opens the dashboard as a Chromium app window, falling back across browsers |
423
491
  | [`bin/claude-fleet.js`](bin/claude-fleet.js) | The installed command: start, install-app, update |
424
492
  | `build/` | Vendored browser bundle, icon drawing, macOS launcher |
425
493
 
@@ -476,62 +544,3 @@ MIT. See [LICENSE](LICENSE).
476
544
  <div align="center">
477
545
  <sub>Screenshots use synthetic sessions generated for the purpose. Fleet is not affiliated with Anthropic.</sub>
478
546
  </div>
479
-
480
- ### Configurable teams and durable tasks
481
-
482
- In **New agent**, choose **Software delivery** to give a brief to a Manager backed by
483
- Product, Developer, Reviewer and QA roles. Choose **Customize team…** to save your own
484
- team: rename/add/remove roles, write their instructions, select a Claude model per role,
485
- and choose allowed tools. Built-in teams are copied; existing custom teams can be edited.
486
- Choose **Quick task** for small, clearly scoped changes: a Sonnet manager, one developer,
487
- and one independent QA verifier, with two implementation attempts and a $3 usage cap.
488
- **Software delivery** keeps the thorough review-and-QA workflow. **No team · single agent**
489
- avoids orchestration entirely when you just need one agent. Existing initiatives keep
490
- their original team snapshot. The original Bug fix preset remains available.
491
-
492
- One role is the manager and at least one separate role is a required verifier. A third
493
- role owns the work. Fleet adds delegation and operator-question tools to the manager;
494
- workers report back to it. Manager roles cannot use the shell or edit tools. Verifiers
495
- cannot use direct edit tools; Bash, when enabled for tests, remains a general-purpose
496
- shell governed by the initiative's approval mode, not a read-only sandbox.
497
-
498
- Each initiative snapshots its team and works in its own Git worktree. Template edits
499
- apply to new initiatives. The Manager creates tasks with owners, acceptance criteria and
500
- dependencies through Fleet's task tool. The initiative inspector shows the roster,
501
- task states, assignments and returned reports. Verification comes from actual subagent
502
- reports, including each required verifier's PASS/FAIL; the Manager cannot mark a task
503
- verified itself. A failed review returns work for repair and invalidates the previous
504
- attempt's reviews. “Verified” records the configured agents' verdicts for that attempt,
505
- not a guarantee that their evaluation is correct or that later work cannot regress it.
506
-
507
- Delegations run sequentially in the shared initiative worktree. After an interruption,
508
- the saved task board survives and unfinished delegations are marked interrupted. Message
509
- the Manager to resume. Software delivery starts with three implementation attempts per task and
510
- $10 in reported SDK usage; **Adjust limits** changes them explicitly while idle. The SDK
511
- budget is an execution cutoff, not a billing guarantee: usage is reported at turn end,
512
- and a killed runtime may not report its final spend. Each turn also has a 100-turn SDK
513
- limit and bounded continuation reminders.
514
-
515
- Custom teams live in `teams.json` under Fleet's state directory; task history and team
516
- snapshots live with the initiative in `sessions.json`. This version supports models
517
- available through the Claude Agent SDK, up to eight roles per team, and 100 tasks per
518
- initiative. It finishes at a local branch ready for review; it does not automatically
519
- publish or merge changes.
520
-
521
- ### Inspect individual agents
522
-
523
- Select a subagent row beneath a managed initiative to inspect its assignment, actual
524
- model, status, elapsed time, attempt, tool steps, and report. Expand **Input and output**
525
- to see a tool's recorded payload. The inspector is read-only; send direction and answer
526
- approvals through the manager. Tool history keeps the most recent 200 steps with bounded
527
- inputs and outputs. Older runs may have no recorded steps or usage.
528
-
529
- The inspector shows reported input/output and cache tokens, or SDK progress totals when
530
- only those are available. Repeated assistant events do not count usage twice. Per-agent
531
- cost appears only when the SDK explicitly reports it; missing data is not shown as zero.
532
- Token totals describe recorded usage across messages, not current context size.
533
-
534
- Task-board reads return compact metadata, so repeatedly checking task state does not
535
- re-inject all assignments, tool output and reports into the manager's context. The manager
536
- can request a specific delegation's full assignment/report using the task tool's
537
- `inspect` action with `delegationId`. Verification gates and saved evidence are unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sergeychuvayev/claude-fleet",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "A local control room for Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude",
package/public/app.js CHANGED
@@ -14,6 +14,8 @@ const $ = id => document.getElementById(id)
14
14
  const api = (...args) => window.FleetControl.api(...args)
15
15
  const STATES = ['busy', 'idle', 'stale', 'dead']
16
16
  const LABELS = { busy: 'Working', idle: 'Waiting', stale: 'Stale', dead: 'Offline' }
17
+ const DATE_FILTERS = ['today', 'week']
18
+ const DATE_LABELS = { all: 'All time', today: 'Today', week: 'This week' }
17
19
  const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
18
20
  const key = s => s.managedId || s.sessionId || `session:${s.pid}`
19
21
  const percent = s => s.contextTokens == null ? null : Math.min(100, Math.max(0, s.contextTokens / s.contextLimit * 100))
@@ -24,6 +26,15 @@ const age = timestamp => {
24
26
  const secs = Math.max(0, Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000))
25
27
  return secs < 60 ? `${secs}s` : secs < 3600 ? `${Math.floor(secs/60)}m` : secs < 86400 ? `${Math.floor(secs/3600)}h` : `${Math.floor(secs/86400)}d`
26
28
  }
29
+ // "Today" resets at local midnight; "This week" is a rolling 7 days, so it never
30
+ // jumps forward mid-session the way a calendar-week reset would.
31
+ const startOfToday = () => { const d = new Date(); d.setHours(0, 0, 0, 0); return d.getTime() }
32
+ const matchesDate = (s, when) => {
33
+ if (when === 'all') return true
34
+ if (!s.startedAt) return false
35
+ const started = new Date(s.startedAt).getTime()
36
+ return when === 'today' ? started >= startOfToday() : started >= Date.now() - 7 * 86400000
37
+ }
27
38
  // Everything Fleet remembers between visits goes through here. It is declared before
28
39
  // its first reader on purpose: a `const` used above its declaration throws a
29
40
  // ReferenceError that the callers' own try/catch would quietly absorb, leaving every
@@ -33,7 +44,7 @@ const store = {
33
44
  set(key, value) { try { localStorage.setItem(key, value) } catch {} },
34
45
  clear(key) { try { localStorage.removeItem(key) } catch {} },
35
46
  }
36
- let snapshot = null, filter = 'all', selected = null, pending = false, toastTimer
47
+ let snapshot = null, filter = 'all', dateFilter = 'all', filterMenuOpen = false, selected = null, pending = false, toastTimer
37
48
  // A delegation row nested under a team session. Keyed on the delegation id, never on
38
49
  // its position or status, so a sub-agent finishing does not move the operator's focus.
39
50
  let selectedChild = null, childDetail = null, childDetailFor = null, childDetailError = null, childRequest = 0, lastChildId = null
@@ -276,15 +287,22 @@ function sessionRowHtml(s, spawnCounts) {
276
287
  const childSelectedHere = !!selectedChild && (s.delegations || []).some(d => d.id === selectedChild)
277
288
  const name = (s.managed ? 'FLEET · ' : '') + (s.name || s.shortId || 'Unnamed session')
278
289
  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)}`
290
+ const title = `<span class="session-title">${esc(s.title || s.lastPrompt || 'Untitled session')}</span>`
291
+ const body = `${top}<span class="session-title-row">${initiativeTag(s)}${title}</span>${rowMeta(s)}${turnRow(s)}`
280
292
  return `<button class="session${childSelectedHere ? ' session-ancestor' : ''}" draggable="true" 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
293
  }
282
- const filterBarHtml = (counts, foreground, background, archived) => [
294
+ // The trigger names whichever combination is active instead of repeating every
295
+ // count Sessions' own header badge already shows.
296
+ const filterTriggerLabel = () => {
297
+ const base = filter === 'all' ? 'All sessions' : filter === 'background' ? 'Background' : filter === 'archived' ? 'Archived' : LABELS[filter]
298
+ return dateFilter === 'all' ? base : `${base} · ${DATE_LABELS[dateFilter]}`
299
+ }
300
+ const filterMenuHtml = (counts, foreground, background, archived, dateCounts) => `<button type="button" class="filter-trigger" id="filter-trigger" aria-haspopup="menu" aria-expanded="${filterMenuOpen}" aria-controls="filter-panel">${esc(filterTriggerLabel())}<span class="filter-chevron" aria-hidden="true">⌄</span></button><div class="filter-panel" id="filter-panel" role="menu" aria-label="Filter sessions"${filterMenuOpen ? '' : ' hidden'}><div class="filter-group" role="group" aria-label="Status">${[
283
301
  ['all', 'All sessions', foreground],
284
302
  ...STATES.map(s => [s, LABELS[s], counts[s] || 0]),
285
303
  ...(background ? [['background', 'Background', background]] : []),
286
304
  ...(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('')
305
+ ].map(([s, label, n]) => `<button type="button" class="filter" data-filter="${s}" aria-pressed="${filter === s}">${label}<span>${n}</span></button>`).join('')}</div><div class="filter-group"><span class="filter-group-heading">Created</span>${['all', ...DATE_FILTERS].map(k => `<button type="button" class="filter" data-date-filter="${k}" aria-pressed="${dateFilter === k}">${DATE_LABELS[k]}<span>${dateCounts[k]}</span></button>`).join('')}</div></div>`
288
306
  const emptyListHtml = total =>
289
307
  filter === 'background' ? 'No background sessions right now.'
290
308
  : total ? 'No sessions match your filters.<br>Try another search or select All sessions.'
@@ -309,11 +327,14 @@ function render() {
309
327
  const pool = filter === 'archived' ? archived : filter === 'background' ? background : foreground
310
328
  // Ordering comes from the server (approval, then busy, then most recent) and
311
329
  // finding a specific session is what the Ask modal is for.
312
- const shown = pool.filter(s => filter === 'all' || filter === 'background' || filter === 'archived' || s.state === filter)
330
+ const shown = pool.filter(s => (filter === 'all' || filter === 'background' || filter === 'archived' || s.state === filter) && matchesDate(s, dateFilter))
313
331
  if (!shown.some(s => key(s) === selected)) selected = shown[0] ? key(shown[0]) : null
314
332
  $('shown-count').textContent = shown.length
315
333
  renderStatusbar(snapshot.usage, live)
316
- update('filters', filterBarHtml(visibleCounts, foreground.length, background.length, archived.length))
334
+ // Date counts sit against the foreground pool, same base the status counts use,
335
+ // so switching status tabs never makes these numbers jump for an unrelated reason.
336
+ const dateCounts = { all: foreground.length, today: foreground.filter(s => matchesDate(s, 'today')).length, week: foreground.filter(s => matchesDate(s, 'week')).length }
337
+ update('filters', filterMenuHtml(visibleCounts, foreground.length, background.length, archived.length, dateCounts))
317
338
  renderArchiveBar(live.filter(s => s.state === 'dead'), archived.length)
318
339
  update('session-list', shown.length
319
340
  ? shown.map(s => sessionRowHtml(s, spawnCounts)).join('')
@@ -502,10 +523,21 @@ function toast(message) {
502
523
  clearTimeout(toastTimer)
503
524
  toastTimer = setTimeout(() => { box.hidden = true }, 3000)
504
525
  }
526
+ // Click-outside is the one thing a plain button pair can't give a popover for
527
+ // free, so it gets its own listener rather than folding into the delegated one
528
+ // below, which only ever looks at clicks that landed on a button.
529
+ document.addEventListener('click', event => {
530
+ if (filterMenuOpen && !event.target.closest('.filter-menu')) { filterMenuOpen = false; render() }
531
+ })
532
+ document.addEventListener('keydown', event => {
533
+ if (event.key === 'Escape' && filterMenuOpen) { filterMenuOpen = false; render(); $('filter-trigger')?.focus() }
534
+ })
505
535
  document.addEventListener('click', async event => {
506
536
  const b = event.target.closest('button')
507
537
  if (!b) return
508
- if (b.dataset.filter) { filter = b.dataset.filter; render() }
538
+ if (b.id === 'filter-trigger') { filterMenuOpen = !filterMenuOpen; return render() }
539
+ if (b.dataset.filter) { filter = b.dataset.filter; filterMenuOpen = false; render() }
540
+ if (b.dataset.dateFilter) { dateFilter = b.dataset.dateFilter; filterMenuOpen = false; render() }
509
541
  if (b.dataset.foldSession) {
510
542
  const k = b.dataset.foldSession, collapsed = !collapsedChildren.has(k)
511
543
  setChildrenCollapsed(k, collapsed)
package/public/control.js CHANGED
@@ -60,8 +60,14 @@ async function loadLaunchTeams() {
60
60
  function openLaunch(source=null) {
61
61
  window.FleetTeams?.reset()
62
62
  resumeSource=source
63
+ // Leftover text, a picked team, a swapped model or approval mode from a task that
64
+ // was never launched should not greet the next one. The remembered project
65
+ // directory is the one thing worth carrying forward, so it survives the reset.
66
+ const form=$('launch-form'), cwd=form.elements.cwd.value
67
+ form.reset()
68
+ form.elements.cwd.value=cwd
63
69
  $('launch-title').textContent=source ? 'Continue this conversation in Fleet.' : 'Give your next task a home.'
64
- if(source){$('launch-cwd').value=source.cwd || '';$('launch-form').elements.name.value=source.title || source.name || ''}
70
+ if(source){$('launch-cwd').value=source.cwd || '';form.elements.name.value=source.title || source.name || ''}
65
71
  loadLaunchTeams()
66
72
  $('launch-cwd').readOnly=!!source
67
73
  openModal('launch-backdrop', '[name=prompt]')
@@ -116,7 +122,7 @@ function selectControl(session) {
116
122
  window.Fleet.watchConversation(null)
117
123
  $('control-panel').innerHTML=''
118
124
  if(next){
119
- $('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="reference-tray" class="reference-tray" aria-label="Referenced sessions" hidden></div><div id="attach-tray" class="attach-tray" hidden></div><textarea id="message-input" rows="3" maxlength="16000" placeholder="What should this agent do next? · @ to reference an agent · / 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>`
125
+ $('control-panel').innerHTML=`<div class="conversation-header"><div class="header-title"><h3 id="conversation-title">Conversation</h3><span id="agent-context" class="subtle context-chip"></span><span id="agent-state" class="subtle">Connecting…</span></div><div class="header-controls"><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><button type="button" id="close-agent" class="button close-agent" title="Remove this conversation from Fleet">Close</button></div></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="reference-tray" class="reference-tray" aria-label="Referenced sessions" hidden></div><div id="attach-tray" class="attach-tray" hidden></div><textarea id="message-input" rows="3" maxlength="16000" placeholder="What should this agent do next? · @ to reference an agent · / 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>`
120
126
  window.Fleet.watchConversation($('conversation'))
121
127
  catalog=[];catalogFor=null;closePicker();renderTray();renderReferences()
122
128
  window.Fleet.syncDetails()
package/public/index.html CHANGED
@@ -3,7 +3,7 @@
3
3
  <body><header class="topbar"><a class="brand" href="/" aria-label="Fleet home"><span class="brandmark">✳</span> fleet <span class="brand-sub">/ CLAUDE CODE</span></a><span class="app-version" id="app-version" title="The version of Fleet this server is running"></span><div class="refresh-info"><button id="update-pill" class="button update-pill" hidden></button><button id="refresh" class="button">↻ Refresh</button><button id="ask-sessions" class="button" aria-expanded="false" aria-controls="ask-backdrop" title="Ask a question across every session on this machine">⌕ Ask <kbd id="ask-shortcut">⌘K</kbd></button><button id="new-session" class="button resume" aria-expanded="false" aria-controls="launch-backdrop">+ New agent</button></div></header>
4
4
  <main>
5
5
  <div id="error" class="error" role="status" hidden></div>
6
- <section class="workspace" aria-label="Sessions"><div class="sessions-pane"><div class="section-heading"><h2>Sessions <span id="shown-count">0</span></h2></div><div class="filters" id="filters" aria-label="Filter sessions by status"></div><div class="archive-bar" id="archive-bar" role="group" aria-label="Archive" hidden></div><div class="list-head"><span>SESSION / PROJECT</span><span>CONTEXT</span></div><div id="session-list" class="session-list"><div class="empty">Loading your sessions…</div></div></div><div id="splitter" class="splitter" role="separator" aria-orientation="vertical" aria-label="Resize the session inspector" aria-valuemin="25" aria-valuemax="75" aria-valuenow="58" tabindex="0" title="Drag to resize · double-click to reset"></div><aside id="detail" class="detail" aria-label="Session details"><section id="control-panel" aria-label="Agent controls"></section><button type="button" id="details-toggle" class="details-toggle" aria-expanded="true" aria-controls="detail-content" hidden><span class="details-chevron" aria-hidden="true">›</span>Session details</button><div id="detail-content"></div></aside></section>
6
+ <section class="workspace" aria-label="Sessions"><div class="sessions-pane"><div class="section-heading"><h2>Sessions <span id="shown-count">0</span></h2></div><div class="filter-menu" id="filters"></div><div class="archive-bar" id="archive-bar" role="group" aria-label="Archive" hidden></div><div class="list-head"><span>SESSION / PROJECT</span><span>CONTEXT</span></div><div id="session-list" class="session-list"><div class="empty">Loading your sessions…</div></div></div><div id="splitter" class="splitter" role="separator" aria-orientation="vertical" aria-label="Resize the session inspector" aria-valuemin="25" aria-valuemax="75" aria-valuenow="58" tabindex="0" title="Drag to resize · double-click to reset"></div><aside id="detail" class="detail" aria-label="Session details"><section id="control-panel" aria-label="Agent controls"></section><button type="button" id="details-toggle" class="details-toggle" aria-expanded="true" aria-controls="detail-content" hidden><span class="details-chevron" aria-hidden="true">›</span>Session details</button><div id="detail-content"></div></aside></section>
7
7
  </main>
8
8
  <footer class="statusbar" id="statusbar"><div class="status-usage" id="status-usage"></div><div class="status-fleet" id="status-fleet"></div><div class="status-conn"><span id="connection-dot" class="dot busy"></span><span id="connection">Connecting</span><span id="updated">Waiting for first snapshot</span><span class="local">LOCAL CONTROL ROOM</span></div></footer>
9
9
  <div class="modal-backdrop" id="ask-backdrop" hidden>
package/public/styles.css CHANGED
@@ -233,6 +233,16 @@ select {
233
233
  overflow:hidden;
234
234
  margin-bottom:8px;
235
235
  }
236
+ .session-title-row {
237
+ display:flex;
238
+ align-items:baseline;
239
+ gap:8px;
240
+ min-width:0;
241
+ }
242
+ .session-title-row .session-title {
243
+ flex:1;
244
+ min-width:0;
245
+ }
236
246
  .session-meta {
237
247
  display:flex;
238
248
  gap:9px;
@@ -415,7 +425,7 @@ select {
415
425
  @media(max-width:720px) {
416
426
  .refresh-info #updated { display:none }
417
427
  .section-heading { padding-inline:16px }
418
- .filters { padding-inline:13px }
428
+ .filter-menu { padding-inline:13px }
419
429
  .brand { font-size:24px }
420
430
  .session-title { font-size:var(--text-md) }
421
431
  .connection { font-size:var(--text-xs) }
@@ -488,6 +498,19 @@ select {
488
498
  border-top:1px solid var(--line);
489
499
  }
490
500
  .conversation-header h3 { font-size:var(--text-lg); margin:0 }
501
+ .header-title {
502
+ display:flex;
503
+ align-items:center;
504
+ gap:10px;
505
+ flex-wrap:wrap;
506
+ min-width:0;
507
+ }
508
+ .header-controls {
509
+ display:flex;
510
+ align-items:center;
511
+ gap:8px;
512
+ flex:none;
513
+ }
491
514
  .composer { margin-top:20px }
492
515
  .composer textarea:focus { outline:1px solid var(--accent); outline-offset:0 }
493
516
  .composer-footer {
@@ -917,7 +940,7 @@ body[data-resizing] { cursor:col-resize; user-select:none }
917
940
  .splitter::after { transition:none }
918
941
  }
919
942
  /* Approval mode */
920
- .mode-picker { margin-left:auto; margin-right:10px }
943
+ .mode-picker { margin:0 }
921
944
  .mode-picker select {
922
945
  font-family:var(--mono);
923
946
  font-size:var(--text-xs);
@@ -1043,13 +1066,70 @@ body {
1043
1066
  }
1044
1067
  .section-heading { padding:13px 18px 10px }
1045
1068
  .section-heading { flex:none }
1046
- .filters {
1047
- display:flex;
1048
- gap:5px;
1069
+ .filter-menu {
1070
+ position:relative;
1049
1071
  padding:10px 18px 9px;
1050
- flex-wrap:wrap;
1051
1072
  flex:none;
1052
1073
  }
1074
+ .filter-trigger {
1075
+ display:inline-flex;
1076
+ align-items:center;
1077
+ gap:7px;
1078
+ border:1px solid var(--line);
1079
+ background:var(--raised);
1080
+ color:var(--text);
1081
+ border-radius:var(--radius-md);
1082
+ padding:6px 10px;
1083
+ font-size:var(--text-sm);
1084
+ }
1085
+ .filter-trigger:hover { background:color-mix(in oklab,var(--raised) 85%,var(--text)) }
1086
+ .filter-chevron {
1087
+ display:inline-block;
1088
+ position:relative;
1089
+ top:1px;
1090
+ color:var(--faint);
1091
+ font-size:var(--text-xs);
1092
+ line-height:1;
1093
+ transition:transform .12s ease;
1094
+ }
1095
+ .filter-trigger[aria-expanded="true"] .filter-chevron { transform:rotate(180deg) }
1096
+ @media(prefers-reduced-motion:reduce) {
1097
+ .filter-chevron { transition:none }
1098
+ }
1099
+ .filter-panel {
1100
+ position:absolute;
1101
+ top:calc(100% + 6px);
1102
+ left:18px;
1103
+ z-index:5;
1104
+ min-width:210px;
1105
+ padding:8px;
1106
+ display:flex;
1107
+ flex-direction:column;
1108
+ gap:2px;
1109
+ background:var(--panel);
1110
+ border:1px solid var(--line);
1111
+ border-radius:var(--radius-md);
1112
+ box-shadow:0 12px 34px #0009;
1113
+ }
1114
+ .filter-group + .filter-group {
1115
+ margin-top:6px;
1116
+ padding-top:6px;
1117
+ border-top:1px solid var(--line);
1118
+ }
1119
+ .filter-group-heading {
1120
+ display:block;
1121
+ font-size:var(--text-2xs);
1122
+ letter-spacing:.6px;
1123
+ text-transform:uppercase;
1124
+ color:var(--faint);
1125
+ padding:4px 9px 2px;
1126
+ }
1127
+ .filter-panel .filter {
1128
+ display:flex;
1129
+ justify-content:space-between;
1130
+ width:100%;
1131
+ text-align:left;
1132
+ }
1053
1133
  .list-head {
1054
1134
  display:flex;
1055
1135
  justify-content:space-between;
@@ -1127,18 +1207,30 @@ body {
1127
1207
  }
1128
1208
  }
1129
1209
  .close-agent {
1210
+ position:relative;
1130
1211
  padding:3px 8px;
1131
1212
  font-size:var(--text-xs);
1132
1213
  font-family:var(--mono);
1133
- margin-left:auto;
1214
+ margin-left:14px;
1134
1215
  color:var(--muted);
1135
1216
  }
1217
+ /* A divider in the gap, not on the button's own border box, keeps Close visually
1218
+ apart from the model/approval pickers without a doubled-up border look. */
1219
+ .close-agent::before {
1220
+ content:"";
1221
+ position:absolute;
1222
+ left:-8px;
1223
+ top:50%;
1224
+ transform:translateY(-50%);
1225
+ width:1px;
1226
+ height:16px;
1227
+ background:var(--line);
1228
+ }
1136
1229
  .close-agent:hover { color:var(--text) }
1137
1230
  .close-agent[data-armed="1"] {
1138
1231
  color:color-mix(in oklab,var(--term-red) 70%,var(--term-fg));
1139
1232
  border-color:color-mix(in oklab,var(--term-red) 50%,var(--line));
1140
1233
  }
1141
- .close-agent+.mode-picker { margin-left:8px }
1142
1234
  /* Controls live in the header now; the row they used to occupy is gone. */
1143
1235
  .topbar {
1144
1236
  height:46px;
@@ -1190,8 +1282,9 @@ body {
1190
1282
  }
1191
1283
  .detail:has(#composer) .conversation-header {
1192
1284
  margin:0 0 10px;
1193
- padding:0;
1285
+ padding:0 0 10px;
1194
1286
  border:0;
1287
+ border-bottom:1px solid var(--line);
1195
1288
  flex:none;
1196
1289
  }
1197
1290
  /* The console takes whatever is left, until it is dragged to an explicit height. */
@@ -2119,14 +2212,17 @@ body[data-modal] { overflow:hidden }
2119
2212
  @keyframes update-pill-in { from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none} }
2120
2213
  .initiative-tag {
2121
2214
  display:inline-block;
2122
- max-width:100%;
2123
- margin-top:6px;
2215
+ flex:none;
2216
+ max-width:50%;
2124
2217
  padding:2px 6px;
2125
2218
  border:1px solid var(--line);
2126
2219
  border-radius:var(--radius-sm);
2127
2220
  color:var(--muted);
2128
2221
  font-size:var(--text-xs);
2129
2222
  overflow-wrap:anywhere;
2223
+ white-space:nowrap;
2224
+ overflow:hidden;
2225
+ text-overflow:ellipsis;
2130
2226
  }
2131
2227
  .block[data-tool="Task"], .block[data-tool="Agent"] { border-left:2px solid var(--accent) }
2132
2228
  .block[data-tool="Task"] .block-tool, .block[data-tool="Agent"] .block-tool {
@@ -2606,7 +2702,6 @@ body[data-modal] { overflow:hidden }
2606
2702
  .block-reference summary{cursor:pointer;color:var(--accent);overflow-wrap:anywhere}
2607
2703
  .block-reference summary span,.block-reference p{color:var(--muted);font-size:10px}
2608
2704
  .block-reference pre{max-height:240px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.6 var(--mono);color:var(--muted)}
2609
- .session[draggable=true]{cursor:grab}
2610
2705
  .session[draggable=true]:active{cursor:grabbing}
2611
2706
  .reference-state{font-size:10px;color:var(--muted)}
2612
2707
 
package/public/teams.js CHANGED
@@ -4,7 +4,7 @@ window.FleetTeams=(()=>{
4
4
  const { esc, toast } = window.Fleet
5
5
  const control = () => window.FleetControl
6
6
  const api = (...args) => window.FleetControl.api(...args)
7
- let draft=null,tools=[],originalId=null,returnTeam=null
7
+ let draft=null,tools=[],originalId=null,returnTeam=null,openToken=0
8
8
  const escape=value=>esc(String(value ?? ''))
9
9
  const field=(label,key,value,max=500)=>`<label>${label}<input data-team-field="${key}" value="${escape(value)}" maxlength="${max}" required></label>`
10
10
  function mount() {
@@ -34,9 +34,14 @@ window.FleetTeams=(()=>{
34
34
  }
35
35
  async function open() {
36
36
  mount()
37
+ // Closing (or resetting) the launch modal bumps this past whatever token a call
38
+ // to open() captured, so a fetch that resolves after the dialog moved on updates
39
+ // nothing instead of quietly hijacking whatever's open now into team-edit mode.
40
+ const token=++openToken
37
41
  try {
38
42
  const selected=document.getElementById('launch-team').value || 'delivery'
39
43
  const [data,catalog]=await Promise.all([api(`/api/teams/${encodeURIComponent(selected)}`),api('/api/teams')])
44
+ if(token!==openToken)return
40
45
  tools=catalog.tools;draft=data.team;returnTeam=selected
41
46
  const custom=catalog.teams.find(t=>t.id===selected)?.custom
42
47
  originalId=custom ? selected:null
@@ -44,7 +49,7 @@ window.FleetTeams=(()=>{
44
49
  draft.workflow ||= {reviewers:['qa'],maxAttempts:3,budgetUsd:10}
45
50
  for(const [name,role] of Object.entries(draft.roles))role.tools ||= tools.filter(t=>!(role.disallowedTools || []).includes(t) && (name!==draft.manager || ['Read','Glob','Grep','WebSearch','WebFetch'].includes(t)))
46
51
  render();toggle(true)
47
- } catch(error){toast(error.message)}
52
+ } catch(error){if(token===openToken)toast(error.message)}
48
53
  }
49
54
  function render() {
50
55
  document.getElementById('team-editor-error').hidden=true
@@ -134,6 +139,6 @@ window.FleetTeams=(()=>{
134
139
  catch(error){const el=box.querySelector('p');el.textContent=error.message;el.hidden=false;event.target.disabled=false}
135
140
  })
136
141
  }
137
- function reset(){if(document.getElementById('team-editor'))toggle(false)}
142
+ function reset(){openToken++;if(document.getElementById('team-editor'))toggle(false)}
138
143
  return {open,board,reset,save,isEditing:()=>!!document.getElementById('team-editor') && !document.getElementById('team-editor').hidden}
139
144
  })()