@sergeychuvayev/claude-fleet 0.7.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/public/teams.js CHANGED
@@ -1,6 +1,9 @@
1
1
  'use strict'
2
2
  // An isolated scope: Fleet's browser files are classic scripts.
3
3
  window.FleetTeams=(()=>{
4
+ const { esc, toast } = window.Fleet
5
+ const control = () => window.FleetControl
6
+ const api = (...args) => window.FleetControl.api(...args)
4
7
  let draft=null,tools=[],originalId=null,returnTeam=null
5
8
  const escape=value=>esc(String(value ?? ''))
6
9
  const field=(label,key,value,max=500)=>`<label>${label}<input data-team-field="${key}" value="${escape(value)}" maxlength="${max}" required></label>`
@@ -27,7 +30,7 @@ window.FleetTeams=(()=>{
27
30
  // Hidden editor fields must not participate in the launch form's validation.
28
31
  document.querySelectorAll('#team-editor input,#team-editor textarea,#team-editor select').forEach(el=>el.disabled=!editing)
29
32
  if(editing){syncTools();document.querySelector('#team-editor input')?.focus()}
30
- else {document.getElementById('launch-team').value=returnTeam || '';updateLaunchTeam();document.getElementById('launch-prompt').focus()}
33
+ else {document.getElementById('launch-team').value=returnTeam || '';control().updateLaunchTeam();document.getElementById('launch-prompt').focus()}
31
34
  }
32
35
  async function open() {
33
36
  mount()
@@ -83,10 +86,10 @@ window.FleetTeams=(()=>{
83
86
  const button=document.getElementById('team-save');button.disabled=true
84
87
  try {
85
88
  read();const {team}=await api('/api/teams',draft)
86
- const catalog=await api('/api/teams');launchTeams=catalog.teams
89
+ const catalog=await api('/api/teams');control().setLaunchTeams(catalog.teams)
87
90
  const select=document.getElementById('launch-team');select.replaceChildren(new Option('No team · single agent',''))
88
- for(const t of launchTeams)select.add(new Option(t.name,t.id))
89
- returnTeam=team.id;launchRequestId=null;toggle(false);toast('Team saved. Ready for your task.')
91
+ for(const t of catalog.teams)select.add(new Option(t.name,t.id))
92
+ returnTeam=team.id;control().setLaunchRequestId(null);toggle(false);toast('Team saved. Ready for your task.')
90
93
  } catch(error){showError(error)}finally{button.disabled=false}
91
94
  }
92
95
  // The model actually reported by a delegation wins; a role's configured model is
@@ -112,7 +115,7 @@ window.FleetTeams=(()=>{
112
115
  const focused=document.activeElement?.closest('[data-evidence]')?.dataset.evidence
113
116
  panel.fleetSignature=signature
114
117
  const active=b.delegations.find(d=>d.status==='running')
115
- panel.innerHTML=`<summary><strong>${escape(s.teamName)}</strong><span>${done}/${b.tasks.length} verified</span><span title="API-rate equivalent the SDK reports. Not billed on a Claude subscription; the run still stops here.">$${(s.costUsd || 0).toFixed(2)} / $${s.limits?.budgetUsd ?? s.teamSnapshot.workflow.budgetUsd} cap</span></summary><div class="initiative-body"><div class="initiative-roster" aria-label="Team and active agent">${Object.entries(s.teamSnapshot.roles).map(([name,r])=>`<div class="initiative-role ${(active?.role || (isWorking(s) ? s.teamSnapshot.manager:null))===name ? 'is-active':''}"><strong>${escape(name)}</strong><small>${escape(name===s.teamSnapshot.manager && s.selectedModel ? s.selectedModel : r.model)}${name===s.teamSnapshot.manager ? ' · your contact':active?.role===name ? ' · working':''}</small></div>`).join('<span class="team-connector" aria-hidden="true">·</span>')}</div>${b.tasks.length ? `<ol class="initiative-tasks">${b.tasks.map(t=>`<li><details data-evidence="${t.id}" ${opened.has(t.id) ? 'open':''}><summary><span class="task-state" data-state="${escape(t.status)}">${escape(t.status.replaceAll('_',' '))}</span><strong>${escape(t.title)}</strong><small>${escape(t.owner)} · attempt ${t.attempt}</small></summary><ul>${t.criteria.map(c=>`<li>${escape(c)}</li>`).join('')}</ul>${t.blocker ? `<p class="form-error">${escape(t.blocker)}</p>`:''}${t.dependencies.length ? `<p class="note">After: ${t.dependencies.map(id=>escape(b.tasks.find(t=>t.id===id)?.title || id)).join(', ')}</p>`:''}${b.delegations.filter(d=>d.taskId===t.id).map(d=>handoffHtml(s,d,opened)).join('')}</details></li>`).join('')}</ol>`:'<p class="note">The manager is shaping your brief. Tasks and handoffs will appear here as work begins.</p>'}<button type="button" class="button" data-adjust-limits ${isWorking(s) ? 'disabled':''}>Adjust limits</button><p class="note">Verified means all configured verifiers returned passing reports for that task’s attempt. Expand a task to inspect the evidence.</p></div>`
118
+ panel.innerHTML=`<summary><strong>${escape(s.teamName)}</strong><span>${done}/${b.tasks.length} verified</span><span title="API-rate equivalent the SDK reports. Not billed on a Claude subscription; the run still stops here.">$${(s.costUsd || 0).toFixed(2)} / $${s.limits?.budgetUsd ?? s.teamSnapshot.workflow.budgetUsd} cap</span></summary><div class="initiative-body"><div class="initiative-roster" aria-label="Team and active agent">${Object.entries(s.teamSnapshot.roles).map(([name,r])=>`<div class="initiative-role ${(active?.role || (control().isWorking(s) ? s.teamSnapshot.manager:null))===name ? 'is-active':''}"><strong>${escape(name)}</strong><small>${escape(name===s.teamSnapshot.manager && s.selectedModel ? s.selectedModel : r.model)}${name===s.teamSnapshot.manager ? ' · your contact':active?.role===name ? ' · working':''}</small></div>`).join('<span class="team-connector" aria-hidden="true">·</span>')}</div>${b.tasks.length ? `<ol class="initiative-tasks">${b.tasks.map(t=>`<li><details data-evidence="${t.id}" ${opened.has(t.id) ? 'open':''}><summary><span class="task-state" data-state="${escape(t.status)}">${escape(t.status.replaceAll('_',' '))}</span><strong>${escape(t.title)}</strong><small>${escape(t.owner)} · attempt ${t.attempt}</small></summary><ul>${t.criteria.map(c=>`<li>${escape(c)}</li>`).join('')}</ul>${t.blocker ? `<p class="form-error">${escape(t.blocker)}</p>`:''}${t.dependencies.length ? `<p class="note">After: ${t.dependencies.map(id=>escape(b.tasks.find(t=>t.id===id)?.title || id)).join(', ')}</p>`:''}${b.delegations.filter(d=>d.taskId===t.id).map(d=>handoffHtml(s,d,opened)).join('')}</details></li>`).join('')}</ol>`:'<p class="note">The manager is shaping your brief. Tasks and handoffs will appear here as work begins.</p>'}<button type="button" class="button" data-adjust-limits ${control().isWorking(s) ? 'disabled':''}>Adjust limits</button><p class="note">Verified means all configured verifiers returned passing reports for that task’s attempt. Expand a task to inspect the evidence.</p></div>`
116
119
  panel.querySelector('.initiative-body').scrollTop=scrollTop
117
120
  if(focused)panel.querySelector(`[data-evidence="${CSS.escape(focused)}"]>summary`)?.focus({preventScroll:true})
118
121
  const composer=document.getElementById('message-input');composer.placeholder=`Message ${s.teamSnapshot.manager}…`
package/server.js CHANGED
@@ -92,7 +92,11 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
92
92
  if(s.archived) archived++
93
93
  else counts[s.state]++
94
94
  }
95
- return {...snap,sessions,counts,total:sessions.length-archived,archived,archiveRule:archive.rule,storageError}
95
+ // The transcript rejection is merged into the account view rather than shipped
96
+ // beside it: the dashboard should never have to reconcile two rate-limit stories.
97
+ const {rateLimit,...rest}=snap
98
+ const usage=manager.usage ? manager.usage.snapshot({rejection:rateLimit}) : null
99
+ return {...rest,sessions,counts,total:sessions.length-archived,archived,archiveRule:archive.rule,storageError,usage}
96
100
  }
97
101
  const authorized=(req)=>{
98
102
  const supplied=req.headers['x-fleet-token']
package/usage.js ADDED
@@ -0,0 +1,111 @@
1
+ 'use strict'
2
+ // Plan usage is an account fact, not a session one: the five-hour and weekly windows
3
+ // are shared by every Claude process on this machine, including the terminal sessions
4
+ // Fleet only watches. Three sources feed this, none of them available all of the time:
5
+ // - `rate_limit_event`, pushed by the SDK while a Fleet run is streaming
6
+ // - the runtime's own /usage data, pulled once at the end of a turn
7
+ // - a rejected `quotaLimits` record in any transcript, which is the only signal that
8
+ // survives Fleet being idle and the only one that says "you are blocked right now"
9
+ // Nothing here spends a token to find out. A synthetic request would consume the very
10
+ // window it claims to measure.
11
+ const WINDOWS=['five_hour','seven_day','seven_day_opus','seven_day_sonnet','seven_day_oauth_apps']
12
+ const LABELS={five_hour:'5h',seven_day:'week',seven_day_opus:'opus wk',seven_day_sonnet:'sonnet wk',seven_day_oauth_apps:'apps wk'}
13
+ // A live reading only arrives while a run streams. Past this age the bar says "as of",
14
+ // because presenting an hour-old number as current is the one thing it must never do.
15
+ const STALE_MS=120000
16
+ // Utilisation is a whole percentage. A value outside the range is discarded rather than
17
+ // clamped: that far off is a change of shape, not a reading worth drawing.
18
+ const valid=n=>typeof n==='number' && Number.isFinite(n) && n>=0 && n<=100
19
+ // `resetsAt` is unix seconds on the push event and an ISO string on the pull.
20
+ function resetTime(value) {
21
+ if (typeof value==='number' && Number.isFinite(value) && value>0) return Math.round(value<1e12 ? value*1000 : value)
22
+ if (typeof value==='string') {const parsed=Date.parse(value);return Number.isFinite(parsed) ? parsed:null}
23
+ return null
24
+ }
25
+ // A block with no reset time cannot be shown honestly and cannot expire on its own, so
26
+ // it is dropped: a red bar that never clears is worse than no bar.
27
+ const live=(block,now)=>block && block.resetsAt && block.resetsAt>now ? block:null
28
+
29
+ class UsageTracker {
30
+ constructor() {
31
+ this.windows=new Map()
32
+ this.observedAt=null
33
+ this.subscription=null
34
+ // null until something says either way. Unknown is not "no limits": the bar stays
35
+ // empty rather than reporting a zero it never measured.
36
+ this.available=null
37
+ this.blocked=null
38
+ }
39
+ // The SDK's push event carries one window at a time and is the only source that
40
+ // updates mid-turn.
41
+ recordEvent(info,now=Date.now()) {
42
+ if (!info || typeof info!=='object') return false
43
+ const type=typeof info.rateLimitType==='string' ? info.rateLimitType:null
44
+ let changed=false
45
+ if (type && valid(info.utilization)) {
46
+ this.windows.set(type,{utilization:info.utilization,resetsAt:resetTime(info.resetsAt)})
47
+ this.observedAt=now
48
+ this.available=true
49
+ changed=true
50
+ }
51
+ if (info.status==='rejected' && type) {
52
+ this.blocked={rateLimitType:type,resetsAt:resetTime(info.resetsAt),reason:info.overageDisabledReason || null,at:now,source:'run'}
53
+ changed=true
54
+ } else if (info.status && this.blocked) {this.blocked=null;changed=true}
55
+ return changed
56
+ }
57
+ // The pull at turn end: every window at once, plus the plan behind them. Experimental
58
+ // upstream, so absence and malformed shapes are ordinary outcomes, never errors.
59
+ recordUsage(response,now=Date.now()) {
60
+ if (!response || typeof response!=='object') return false
61
+ if (response.rate_limits_available===false) {
62
+ const had=this.available!==false
63
+ this.available=false;this.windows.clear();this.observedAt=now
64
+ return had
65
+ }
66
+ let changed=false
67
+ const limits=response.rate_limits
68
+ if (limits && typeof limits==='object') for (const name of WINDOWS) {
69
+ const window=limits[name]
70
+ if (!window || typeof window!=='object' || !valid(window.utilization)) continue
71
+ this.windows.set(name,{utilization:window.utilization,resetsAt:resetTime(window.resets_at)})
72
+ changed=true
73
+ }
74
+ if (typeof response.subscription_type==='string' && response.subscription_type!==this.subscription) {
75
+ this.subscription=response.subscription_type
76
+ changed=true
77
+ }
78
+ if (changed) {this.observedAt=now;this.available=true}
79
+ return changed
80
+ }
81
+ // A rejection read out of a transcript. Any session on this machine can supply it,
82
+ // which is what makes a block visible while Fleet itself is driving nothing.
83
+ static rejection(record) {
84
+ if (!record || typeof record!=='object') return null
85
+ const resetsAt=resetTime(record.resetsAt)
86
+ if (!resetsAt) return null
87
+ return {rateLimitType:record.rateLimitType || null,resetsAt,reason:record.reason || null,at:record.at || null,source:'transcript'}
88
+ }
89
+ snapshot({now=Date.now(),rejection=null}={}) {
90
+ // Whichever block is newer wins; both expire by themselves at their reset time.
91
+ const blocks=[live(this.blocked,now),live(UsageTracker.rejection(rejection),now)].filter(Boolean)
92
+ const blocked=blocks.sort((a,b)=>(b.at || 0)-(a.at || 0))[0] || null
93
+ const windows=[...this.windows.entries()]
94
+ .map(([name,w])=>({name,label:LABELS[name] || name,utilization:Math.round(w.utilization),resetsAt:w.resetsAt}))
95
+ .sort((a,b)=>WINDOWS.indexOf(a.name)-WINDOWS.indexOf(b.name))
96
+ // One bar on screen, drawn for whichever window is closest to stopping the fleet.
97
+ const binding=windows.reduce((worst,w)=>!worst || w.utilization>worst.utilization ? w:worst,null)
98
+ return {
99
+ available:this.available!==false,
100
+ known:windows.length>0 || !!blocked,
101
+ windows,
102
+ binding:binding ? binding.name:null,
103
+ subscription:this.subscription,
104
+ observedAt:this.observedAt,
105
+ stale:this.observedAt ? now-this.observedAt>STALE_MS:true,
106
+ blocked,
107
+ }
108
+ }
109
+ }
110
+
111
+ module.exports={UsageTracker,WINDOWS,LABELS,STALE_MS}