@sergeychuvayev/claude-fleet 0.1.0 → 0.3.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/README.md +30 -0
- package/bin/claude-fleet.js +9 -2
- package/fleet.js +1 -1
- package/managed.js +68 -10
- package/open.js +94 -0
- package/package.json +5 -2
- package/public/app.js +1 -1
- package/public/blocks.js +19 -6
- package/public/control.js +36 -5
- package/public/styles.css +10 -0
- package/server.js +21 -6
- package/teams.js +199 -0
- package/worktree.js +90 -0
package/README.md
CHANGED
|
@@ -192,6 +192,34 @@ conversation on every question.
|
|
|
192
192
|
|
|
193
193
|
</details>
|
|
194
194
|
|
|
195
|
+
### An initiative is a team behind one conversation
|
|
196
|
+
|
|
197
|
+
Some work is too big for one agent and too small to project-manage by hand. Launch it with a
|
|
198
|
+
**team** instead of alone and you get an *initiative*: a manager that plans and delegates, a
|
|
199
|
+
developer that implements, and a QA that independently verifies, all behind a single
|
|
200
|
+
conversation.
|
|
201
|
+
|
|
202
|
+
You talk to the manager and only to the manager. That is not a rule in a prompt: the manager
|
|
203
|
+
holds the main thread, and the rest of the team is reachable only through the Agent tool, so
|
|
204
|
+
they have no channel to you at all. Their work arrives as delegation blocks in the
|
|
205
|
+
conversation, each showing the role, the mandate it was given, and the report it sent back.
|
|
206
|
+
|
|
207
|
+
The manager has no edit tools. Its only way to ship is to delegate, which is the entire point
|
|
208
|
+
of having a team rather than an agent with a long prompt. QA has no edit tools either: it
|
|
209
|
+
reproduces the problem, runs the project's own gates, and returns PASS or FAIL with evidence,
|
|
210
|
+
so a developer's account of its own work is never the last word.
|
|
211
|
+
|
|
212
|
+
An initiative works in a git worktree of its own, branched from wherever the project is
|
|
213
|
+
checked out, so a team editing files cannot collide with your own editing or with another
|
|
214
|
+
initiative. It finishes by opening a pull request. Pushing stops for your approval like any
|
|
215
|
+
other publishing command, so nothing leaves the machine without you.
|
|
216
|
+
|
|
217
|
+
Closing an initiative forgets Fleet's record of the conversation and leaves the worktree and
|
|
218
|
+
its branch alone. Deleting code is never the same click as tidying a list.
|
|
219
|
+
|
|
220
|
+
Teams cost roughly an order of magnitude more tokens than a single agent, and only earn it
|
|
221
|
+
when the work genuinely splits. For a one-line fix, launch an agent.
|
|
222
|
+
|
|
195
223
|
### Approvals that stay out of the way
|
|
196
224
|
|
|
197
225
|
Every agent runs in one of three modes, chosen at launch and changeable from the
|
|
@@ -309,6 +337,8 @@ for the app itself.
|
|
|
309
337
|
| [`fleet.js`](fleet.js) | Cached, read-only collection of external Claude sessions |
|
|
310
338
|
| [`archive.js`](archive.js) | Which sessions are put away, the age rule, and its store |
|
|
311
339
|
| [`managed.js`](managed.js) | SDK runs, approvals, tool blocks, persistence, cancellation |
|
|
340
|
+
| [`teams.js`](teams.js) | The roles an initiative runs, and how they compile into SDK options |
|
|
341
|
+
| [`worktree.js`](worktree.js) | The git worktree an initiative works in, and its branch |
|
|
312
342
|
| [`search.js`](search.js) | Transcript index, BM25 ranking, and the answering turn |
|
|
313
343
|
| [`permissions.js`](permissions.js) | The three approval modes and the command list that still stops |
|
|
314
344
|
| [`theme.js`](theme.js) | Reads the local Warp palette and renders it as CSS variables |
|
package/bin/claude-fleet.js
CHANGED
|
@@ -13,18 +13,25 @@ const pkg = require(path.join(ROOT, 'package.json'))
|
|
|
13
13
|
const HELP = `
|
|
14
14
|
Claude Fleet v${pkg.version} — a local control room for Claude Code sessions
|
|
15
15
|
|
|
16
|
-
claude-fleet start Fleet and open
|
|
17
|
-
claude-fleet start start it without opening
|
|
16
|
+
claude-fleet start Fleet and open it as an app window
|
|
17
|
+
claude-fleet start start it without opening anything
|
|
18
|
+
claude-fleet --browser open a normal browser tab instead of an app window
|
|
18
19
|
claude-fleet install-app put a "Claude Fleet" app in ~/Applications (macOS)
|
|
19
20
|
claude-fleet update install the latest published version
|
|
20
21
|
claude-fleet --version print the version
|
|
21
22
|
claude-fleet --help this
|
|
22
23
|
|
|
24
|
+
The app window is a Chromium window with no tab strip and no address bar. If Fleet
|
|
25
|
+
is already running, claude-fleet puts that server on screen rather than starting a
|
|
26
|
+
second one.
|
|
27
|
+
|
|
23
28
|
Environment
|
|
24
29
|
PORT port to listen on (default 7777, next free one if taken)
|
|
25
30
|
CLAUDE_FLEET_HOME where Fleet keeps its own state (default ~/.claude-fleet)
|
|
26
31
|
CLAUDE_FLEET_DIR the Claude directory to read (default ~/.claude)
|
|
27
32
|
CLAUDE_FLEET_EXECUTABLE the claude binary to run; "bundled" uses the SDK's own
|
|
33
|
+
CLAUDE_FLEET_BROWSER the Chromium to open the app window with
|
|
34
|
+
CLAUDE_FLEET_APP_PROFILE where that window keeps its profile
|
|
28
35
|
`
|
|
29
36
|
|
|
30
37
|
// A PATH walk rather than `command -v`, because this has to work without a shell
|
package/fleet.js
CHANGED
|
@@ -59,7 +59,7 @@ function toolTarget(name, input) {
|
|
|
59
59
|
if (!input || typeof input !== 'object') return null
|
|
60
60
|
const first = value => (typeof value === 'string' ? value.split('\n')[0].trim().slice(0, 120) : null)
|
|
61
61
|
if (name === 'Bash' || name === 'BashOutput') return first(input.description) || first(input.command)
|
|
62
|
-
if (name === 'Task') return first(input.description)
|
|
62
|
+
if (name === 'Task' || name === 'Agent') return first(input.description)
|
|
63
63
|
if (name === 'WebSearch') return first(input.query)
|
|
64
64
|
if (name === 'WebFetch') return first(input.url)
|
|
65
65
|
if (name === 'Grep' || name === 'Glob') return first(input.pattern)
|
package/managed.js
CHANGED
|
@@ -7,6 +7,8 @@ const { EventEmitter } = require('node:events')
|
|
|
7
7
|
const { gitBranch, turnSummary, toolTarget } = require('./fleet')
|
|
8
8
|
const { askReason, normaliseMode, MODES, DEFAULT_MODE } = require('./permissions')
|
|
9
9
|
const { stateDir } = require('./paths')
|
|
10
|
+
const { getTeam, compile } = require('./teams')
|
|
11
|
+
const worktrees = require('./worktree')
|
|
10
12
|
|
|
11
13
|
const ACTIVE = new Set(['starting', 'running', 'approval', 'stopping'])
|
|
12
14
|
// Used until a live run reports the runtime's own list, which replaces it.
|
|
@@ -78,22 +80,45 @@ class ManagedSessions extends EventEmitter {
|
|
|
78
80
|
}
|
|
79
81
|
} catch (error) { this.releaseLock(); throw new Error(`Cannot read Fleet session store: ${error.message}`) }
|
|
80
82
|
}
|
|
83
|
+
// The lock used to hold a bare PID. It now holds {pid, port} so that a second
|
|
84
|
+
// `claude-fleet` can put the running dashboard on screen instead of only naming the
|
|
85
|
+
// process that beat it to the lock. A bare PID is still read: the file outlives an
|
|
86
|
+
// upgrade, and a stale one must not read as corrupt.
|
|
87
|
+
readLock() {
|
|
88
|
+
const raw = fs.readFileSync(this.lock, 'utf8').trim()
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(raw)
|
|
91
|
+
if (Number.isInteger(parsed?.pid) && parsed.pid > 0) return { pid: parsed.pid, port: Number(parsed.port) || null }
|
|
92
|
+
} catch {}
|
|
93
|
+
const pid = Number(raw)
|
|
94
|
+
return Number.isInteger(pid) && pid > 0 ? { pid, port: null } : null
|
|
95
|
+
}
|
|
81
96
|
acquireLock() {
|
|
82
97
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
83
|
-
try { fs.writeFileSync(this.lock,
|
|
98
|
+
try { fs.writeFileSync(this.lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 }); return }
|
|
84
99
|
catch (error) {
|
|
85
100
|
if (error.code !== 'EEXIST') throw error
|
|
86
|
-
const
|
|
87
|
-
if (!
|
|
88
|
-
try { process.kill(pid, 0) }
|
|
101
|
+
const held = this.readLock()
|
|
102
|
+
if (!held) throw new Error(`Invalid Fleet lock file; inspect ${this.lock}.`)
|
|
103
|
+
try { process.kill(held.pid, 0) }
|
|
89
104
|
catch (e) { if (e.code === 'ESRCH') { fs.unlinkSync(this.lock); continue } }
|
|
90
|
-
|
|
105
|
+
// Carries the holder so main() can open it rather than print and exit 1. Being
|
|
106
|
+
// already running is the normal case, not a failure.
|
|
107
|
+
const conflict = new Error(`Fleet controls are already running (PID ${held.pid})`)
|
|
108
|
+
conflict.code = 'FLEET_ALREADY_RUNNING'
|
|
109
|
+
conflict.holder = held
|
|
110
|
+
throw conflict
|
|
91
111
|
}
|
|
92
112
|
}
|
|
93
113
|
throw new Error('Cannot acquire Fleet session lock')
|
|
94
114
|
}
|
|
115
|
+
// Called once the server knows which port it actually got, which is not always the
|
|
116
|
+
// one it asked for: the listen path walks upwards past anything already bound.
|
|
117
|
+
recordPort(port) {
|
|
118
|
+
try { fs.writeFileSync(this.lock, JSON.stringify({ pid: process.pid, port }), { mode: 0o600 }) } catch {}
|
|
119
|
+
}
|
|
95
120
|
releaseLock() {
|
|
96
|
-
try { if (
|
|
121
|
+
try { if (this.readLock()?.pid === process.pid) fs.unlinkSync(this.lock) } catch {}
|
|
97
122
|
}
|
|
98
123
|
save() {
|
|
99
124
|
clearTimeout(this.saveTimer); this.saveTimer = null
|
|
@@ -123,6 +148,8 @@ class ManagedSessions extends EventEmitter {
|
|
|
123
148
|
permissionMode:'default', approvalMode:s.approvalMode || DEFAULT_MODE, selectedModel:s.selectedModel || '', messages:s.messages.filter(m=>m.role!=='tool').length, links:linksFromMessages(s.messages), approvals:s.approvals.length,
|
|
124
149
|
turn:turnSummary(managedEvents(s.messages), { working: ACTIVE.has(s.status) && s.status !== 'approval' }),
|
|
125
150
|
error:s.error, currentTool:s.currentTool, resumeCmd:s.sessionId ? `claude --resume ${s.sessionId}` : null,
|
|
151
|
+
kind:s.kind || 'agent', teamId:s.teamId || null, teamName:s.teamName || null,
|
|
152
|
+
worktreeBranch:s.worktree?.branch || null,
|
|
126
153
|
}))
|
|
127
154
|
}
|
|
128
155
|
create(body) {
|
|
@@ -147,10 +174,17 @@ class ManagedSessions extends EventEmitter {
|
|
|
147
174
|
resume = source.sessionId
|
|
148
175
|
}
|
|
149
176
|
this.checkCapacity()
|
|
150
|
-
|
|
177
|
+
// A team turns this conversation into an initiative: the manager takes the main thread
|
|
178
|
+
// and the work happens on a branch of its own rather than in the operator's checkout.
|
|
179
|
+
const team = body.teamId ? getTeam(text(body.teamId,'Team',60)) : null
|
|
180
|
+
if (body.teamId && !team) fail('That team does not exist.')
|
|
181
|
+
if (team && resume) fail('A resumed conversation cannot be given a team.',409)
|
|
182
|
+
const id = randomUUID()
|
|
183
|
+
const worktree = team ? worktrees.create({cwd,id,name}) : null
|
|
184
|
+
const s = {id,sessionId:resume,name,cwd:worktree ? worktree.path : cwd,createRequestId:rid,createdAt:Date.now(),updatedAt:Date.now(),status:'idle',approvalMode:normaliseMode(body.approvalMode),selectedModel:modelChoice(body.model),messages:[],approvals:[],model:null,contextTokens:null,error:null,currentTool:null,requestIds:[],kind:team ? 'initiative' : 'agent',teamId:team?.id || null,teamName:team?.name || null,worktree}
|
|
151
185
|
this.sessions.set(s.id,s)
|
|
152
186
|
try { this.send(s.id,{message:prompt,images:body.images,requestId:rid}) }
|
|
153
|
-
catch (error) { this.sessions.delete(s.id); throw error }
|
|
187
|
+
catch (error) { this.sessions.delete(s.id); if (worktree) worktrees.remove(worktree); throw error }
|
|
154
188
|
return s
|
|
155
189
|
}
|
|
156
190
|
checkCapacity() {
|
|
@@ -241,6 +275,11 @@ class ManagedSessions extends EventEmitter {
|
|
|
241
275
|
...(s.sessionId ? {resume:s.sessionId} : {}),
|
|
242
276
|
}
|
|
243
277
|
if (s.selectedModel) options.model = s.selectedModel
|
|
278
|
+
// `agent` puts the manager on the main thread, so the operator's messages reach it and
|
|
279
|
+
// nobody else; `agents` is where the Agent tool resolves the rest of the team from.
|
|
280
|
+
// Both compose with the claude_code preset above, which keeps the built-in tools.
|
|
281
|
+
const team = getTeam(s.teamId)
|
|
282
|
+
if (team) Object.assign(options, compile(team))
|
|
244
283
|
if (process.env.CLAUDE_FLEET_EXECUTABLE) options.pathToClaudeCodeExecutable = process.env.CLAUDE_FLEET_EXECUTABLE
|
|
245
284
|
run.query = await this.queryFactory({prompt,options})
|
|
246
285
|
if (run.stopping) { run.query.close(); return }
|
|
@@ -348,7 +387,7 @@ class ManagedSessions extends EventEmitter {
|
|
|
348
387
|
if (!reason) return Promise.resolve({behavior:'allow',updatedInput:input})
|
|
349
388
|
return new Promise(resolve => {
|
|
350
389
|
const id=randomUUID()
|
|
351
|
-
const approval={id,tool,input,at:Date.now(),reason,description:context.title || context.decisionReason || null}
|
|
390
|
+
const approval={id,tool,input,at:Date.now(),reason,description:context.title || context.decisionReason || null,role:roleAsking(s,context)}
|
|
352
391
|
let settled=false
|
|
353
392
|
const finish=result=>{
|
|
354
393
|
if(settled)return
|
|
@@ -401,7 +440,11 @@ class ManagedSessions extends EventEmitter {
|
|
|
401
440
|
this.sessions.delete(id)
|
|
402
441
|
try { this.save() } catch (error) { this.emit('storage-error',error) }
|
|
403
442
|
this.emit('change',id)
|
|
404
|
-
|
|
443
|
+
// An initiative's worktree is left on disk on purpose. Forgetting a conversation is a
|
|
444
|
+
// change to Fleet's records; deleting a branch with uncommitted work on it is a change
|
|
445
|
+
// to the operator's code, and the two should never happen with the same click. The path
|
|
446
|
+
// comes back so the caller can say where the work went.
|
|
447
|
+
return {id, sessionId:s.sessionId, worktree:s.worktree?.path || null, branch:s.worktree?.branch || null}
|
|
405
448
|
}
|
|
406
449
|
async close() {
|
|
407
450
|
this.closed=true
|
|
@@ -412,6 +455,21 @@ class ManagedSessions extends EventEmitter {
|
|
|
412
455
|
}
|
|
413
456
|
}
|
|
414
457
|
// An empty choice means "leave it to the project", which is the SDK's own default.
|
|
458
|
+
// Who is asking for this approval. Inside an initiative, "the session wants to run rm" is
|
|
459
|
+
// not good enough: the operator needs to know which role wants it. The SDK marks a
|
|
460
|
+
// subagent's request with an agentID but not with the role name, so the name is recovered
|
|
461
|
+
// from the delegation that is in flight. With two delegations running at once that is
|
|
462
|
+
// ambiguous, and an honest null beats a confident guess at the wrong role.
|
|
463
|
+
function roleAsking(s,context) {
|
|
464
|
+
const team = getTeam(s.teamId)
|
|
465
|
+
if (!team) return null
|
|
466
|
+
// No agentID means the request came from the main thread, which is the manager by
|
|
467
|
+
// definition. The role name comes from the team rather than a literal, because a future
|
|
468
|
+
// team is free to call that role something else.
|
|
469
|
+
if (!context.agentID) return team.manager
|
|
470
|
+
const running = s.messages.filter(m => m.role==='tool' && (m.tool==='Agent' || m.tool==='Task') && m.status==='running')
|
|
471
|
+
return running.length === 1 ? (running[0].input?.subagent_type || null) : null
|
|
472
|
+
}
|
|
415
473
|
function modelChoice(value) {
|
|
416
474
|
if (value === undefined || value === null || value === '') return ''
|
|
417
475
|
if (typeof value !== 'string' || !/^[\w.:-]{1,80}$/.test(value)) fail('That model name is not valid.')
|
package/open.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// How the dashboard gets on screen. Fleet is a desktop tool in everything but
|
|
3
|
+
// packaging, so the default is a Chromium app window — no tab strip, no address
|
|
4
|
+
// bar, its own Dock entry — rather than a tab that goes missing among forty others.
|
|
5
|
+
//
|
|
6
|
+
// The profile directory is shared with the bundle `install-app` builds, on purpose.
|
|
7
|
+
// Chrome keys localStorage and window state to the profile, so a terminal launch and
|
|
8
|
+
// a Spotlight launch have to point at the same one or they become two windows that
|
|
9
|
+
// disagree about which sessions you had open.
|
|
10
|
+
const fs = require('node:fs')
|
|
11
|
+
const os = require('node:os')
|
|
12
|
+
const path = require('node:path')
|
|
13
|
+
|
|
14
|
+
// Tried in order. Chrome first because it is the likeliest to be installed and its
|
|
15
|
+
// `--app` behaviour has been stable for years.
|
|
16
|
+
const MAC_BROWSERS = [
|
|
17
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
18
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
19
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
20
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
21
|
+
]
|
|
22
|
+
const UNIX_BROWSERS = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge', 'brave-browser']
|
|
23
|
+
const WIN_BROWSERS = [
|
|
24
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
25
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
26
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const executable = file => { try { fs.accessSync(file, fs.constants.X_OK); return true } catch { return false } }
|
|
30
|
+
|
|
31
|
+
// Absolute paths are checked as given; bare names are walked down PATH, because an
|
|
32
|
+
// app launched from Finder or a .desktop file has no shell to resolve them for it.
|
|
33
|
+
function locate(candidate, { env, exists }) {
|
|
34
|
+
if (candidate.includes(path.sep) || candidate.includes('/')) return exists(candidate) ? candidate : null
|
|
35
|
+
for (const dir of (env.PATH || '').split(path.delimiter)) {
|
|
36
|
+
if (!dir) continue
|
|
37
|
+
const full = path.join(dir, candidate)
|
|
38
|
+
if (exists(full)) return full
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Kept in step with build/make-app.sh. If one of them moves, both move.
|
|
44
|
+
function profileDir({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
|
|
45
|
+
if (env.CLAUDE_FLEET_APP_PROFILE) return path.resolve(env.CLAUDE_FLEET_APP_PROFILE)
|
|
46
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'ClaudeFleetApp')
|
|
47
|
+
if (platform === 'win32') return path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'ClaudeFleetApp')
|
|
48
|
+
return path.join(env.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'ClaudeFleetApp')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// CLAUDE_FLEET_BROWSER wins outright, so an operator on a Chromium this does not know
|
|
52
|
+
// about is never stuck with a plain tab.
|
|
53
|
+
function findBrowser({ env = process.env, platform = process.platform, exists = executable } = {}) {
|
|
54
|
+
const known = platform === 'darwin' ? MAC_BROWSERS : platform === 'win32' ? WIN_BROWSERS : UNIX_BROWSERS
|
|
55
|
+
const candidates = env.CLAUDE_FLEET_BROWSER ? [env.CLAUDE_FLEET_BROWSER, ...known] : known
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
const found = locate(candidate, { env, exists })
|
|
58
|
+
if (found) return found
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The plain-tab route, for when no Chromium is installed or the operator asked for it.
|
|
64
|
+
function browserCommand(url, platform) {
|
|
65
|
+
if (platform === 'darwin') return ['open', [url]]
|
|
66
|
+
if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]]
|
|
67
|
+
return ['xdg-open', [url]]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Returns what it did, so the caller can say so and the tests can assert on it without
|
|
71
|
+
// launching anything: {mode:'app'|'browser', command, args}.
|
|
72
|
+
function openDashboard(url, {
|
|
73
|
+
env = process.env,
|
|
74
|
+
platform = process.platform,
|
|
75
|
+
home = os.homedir(),
|
|
76
|
+
exists = executable,
|
|
77
|
+
spawn = require('node:child_process').spawn,
|
|
78
|
+
app = true,
|
|
79
|
+
} = {}) {
|
|
80
|
+
const browser = app ? findBrowser({ env, platform, exists }) : null
|
|
81
|
+
const [command, args] = browser
|
|
82
|
+
// --app is what drops the tab strip and the address bar. The separate profile also
|
|
83
|
+
// keeps Fleet out of the way of whatever the operator has open for actual browsing.
|
|
84
|
+
? [browser, [`--app=${url}`, `--user-data-dir=${profileDir({ env, platform, home })}`]]
|
|
85
|
+
: browserCommand(url, platform)
|
|
86
|
+
// Detached: the window has to outlive `claude-fleet` when the server is already up
|
|
87
|
+
// and this process is about to exit.
|
|
88
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true })
|
|
89
|
+
child.on('error', () => {})
|
|
90
|
+
child.unref()
|
|
91
|
+
return { mode: browser ? 'app' : 'browser', command, args }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { openDashboard, findBrowser, profileDir, MAC_BROWSERS, UNIX_BROWSERS, WIN_BROWSERS }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sergeychuvayev/claude-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A local control room for Claude Code sessions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -29,12 +29,15 @@
|
|
|
29
29
|
"catalog.js",
|
|
30
30
|
"fleet.js",
|
|
31
31
|
"managed.js",
|
|
32
|
+
"open.js",
|
|
32
33
|
"paths.js",
|
|
33
34
|
"permissions.js",
|
|
34
35
|
"search.js",
|
|
35
36
|
"server.js",
|
|
37
|
+
"teams.js",
|
|
36
38
|
"theme.js",
|
|
37
|
-
"update.js"
|
|
39
|
+
"update.js",
|
|
40
|
+
"worktree.js"
|
|
38
41
|
],
|
|
39
42
|
"scripts": {
|
|
40
43
|
"start": "node bin/claude-fleet.js start",
|
package/public/app.js
CHANGED
|
@@ -99,7 +99,7 @@ function render() {
|
|
|
99
99
|
renderArchiveBar(live.filter(s => s.state === 'dead'), archived.length)
|
|
100
100
|
update('session-list', shown.length ? shown.map(s => {
|
|
101
101
|
const p = percent(s)
|
|
102
|
-
return `<button class="session" data-session="${esc(key(s))}" aria-pressed="${selected === key(s)}" 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
|
|
102
|
+
return `<button class="session" data-session="${esc(key(s))}" aria-pressed="${selected === key(s)}" 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')}</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>` : ''}</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>`
|
|
103
103
|
}).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>`)
|
|
104
104
|
const current = shown.find(s => key(s) === selected)
|
|
105
105
|
if (current) markSeen(key(current), current.lastActivity)
|
package/public/blocks.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const LIBS = () => window.FleetLibs || null
|
|
8
8
|
const ICONS = {
|
|
9
9
|
Bash: '⚡', BashOutput: '⚡', Read: '▤', Write: '✎', Edit: '✎', NotebookEdit: '✎',
|
|
10
|
-
Grep: '⌕', Glob: '⌕', WebSearch: '⌕', WebFetch: '↓', Task: '✳', Skill: '◆',
|
|
10
|
+
Grep: '⌕', Glob: '⌕', WebSearch: '⌕', WebFetch: '↓', Task: '✳', Agent: '✳', Skill: '◆',
|
|
11
11
|
TodoWrite: '☑', AskUserQuestion: '?', ExitPlanMode: '▸',
|
|
12
12
|
}
|
|
13
13
|
const EXTENSIONS = {
|
|
@@ -18,9 +18,13 @@ const EXTENSIONS = {
|
|
|
18
18
|
}
|
|
19
19
|
// Mirrors toolTarget() in managed.js: the input key already shown in the block header.
|
|
20
20
|
const TARGET_KEYS = {
|
|
21
|
-
Bash: 'command', BashOutput: 'bash_id', Task: 'description', WebSearch: 'query',
|
|
21
|
+
Bash: 'command', BashOutput: 'bash_id', Task: 'description', Agent: 'description', WebSearch: 'query',
|
|
22
22
|
WebFetch: 'url', Grep: 'pattern', Glob: 'pattern', Skill: 'skill',
|
|
23
23
|
}
|
|
24
|
+
// The runtime names this tool `Agent`; `Task` is the older name for the same call and still
|
|
25
|
+
// appears in transcripts recorded before the rename. Both carry {subagent_type, description,
|
|
26
|
+
// prompt}, so both render as a delegation.
|
|
27
|
+
const isDelegation = name => name === 'Agent' || name === 'Task'
|
|
24
28
|
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))
|
|
25
29
|
const languageFor = file => EXTENSIONS[String(file || '').split('.').pop().toLowerCase()] || null
|
|
26
30
|
const duration = ms => ms == null ? null : ms < 1000 ? `${ms}ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 60000)}m ${Math.round(ms % 60000 / 1000)}s`
|
|
@@ -87,7 +91,8 @@ function toolBody(message) {
|
|
|
87
91
|
const mark = t => t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '▸' : '☐'
|
|
88
92
|
return `<ul class="block-todos">${todos.map(t => `<li class="todo-${escapeHtml(t.status)}"><span aria-hidden="true">${mark(t)}</span>${escapeHtml(t.content || t.activeForm || '')}</li>`).join('')}</ul>`
|
|
89
93
|
}
|
|
90
|
-
if (name
|
|
94
|
+
if (isDelegation(name)) return `<details class="delegation-mandate" data-delegation="mandate"><summary>Mandate</summary>${proseHtml(input.prompt || input.description || 'No mandate recorded.')}</details>`
|
|
95
|
+
if (name === 'Skill') return codeHtml(input.prompt || input.args || input.description || '', 'plaintext')
|
|
91
96
|
if (name === 'AskUserQuestion') return ''
|
|
92
97
|
// The header already shows the main argument, so repeating it as JSON is noise.
|
|
93
98
|
const shown = TARGET_KEYS[name] || 'file_path'
|
|
@@ -95,6 +100,12 @@ function toolBody(message) {
|
|
|
95
100
|
return Object.keys(rest).length ? codeHtml(JSON.stringify(rest, null, 2), 'json') : ''
|
|
96
101
|
}
|
|
97
102
|
function resultHtml(message) {
|
|
103
|
+
if (isDelegation(message.tool)) {
|
|
104
|
+
const report = String(message.result || '')
|
|
105
|
+
if (!report) return `<p class="block-note">${message.status === 'running' ? 'Awaiting report…' : message.status === 'error' ? 'Delegation failed without a report.' : 'No report returned.'}</p>`
|
|
106
|
+
const long = report.length > 1200 || report.split('\n').length > 16
|
|
107
|
+
return `<details class="delegation-report" data-delegation="report"${long ? '' : ' open'}><summary>Returned report${long ? ' · long' : ''}</summary>${proseHtml(report)}${message.truncated ? '<p class="block-note">Report truncated by Fleet.</p>' : ''}</details>`
|
|
108
|
+
}
|
|
98
109
|
if (!message.result) return ''
|
|
99
110
|
const language = message.tool === 'Read' ? languageFor(message.input?.file_path) : message.status === 'error' ? 'plaintext' : null
|
|
100
111
|
const note = message.truncated ? '<p class="block-note">Output truncated by Fleet.</p>' : ''
|
|
@@ -107,10 +118,10 @@ function blockHtml(message, { streaming = false } = {}) {
|
|
|
107
118
|
const icon = ICONS[message.tool] || '▸'
|
|
108
119
|
const meta = [duration(message.ms), clock(message.at)].filter(Boolean).join(' · ')
|
|
109
120
|
const state = message.status === 'error' ? 'is-failed' : message.status === 'running' ? 'is-running' : message.status === 'interrupted' ? 'is-interrupted' : 'is-done'
|
|
110
|
-
const stateLabel = state === 'is-done' ? '' : state.slice(3)
|
|
121
|
+
const stateLabel = state === 'is-done' ? isDelegation(message.tool) ? 'done' : '' : state.slice(3)
|
|
111
122
|
const target = message.target ? `<span class="block-target" title="${escapeHtml(message.target)}">${escapeHtml(message.target)}</span>` : ''
|
|
112
123
|
const auto = message.approval === 'auto' ? '<span class="block-auto" title="Fleet approved this automatically">auto</span>' : ''
|
|
113
|
-
return `<div class="block-head"><span class="block-icon" aria-hidden="true">${icon}</span><span class="block-tool">${escapeHtml(message.tool)}</span>${target}<span class="block-meta">${escapeHtml(meta)}</span>${auto}<span class="block-state ${state}">${stateLabel}</span>${actionsHtml}</div><div class="block-body">${toolBody(message)}${resultHtml(message)}</div>`
|
|
124
|
+
return `<div class="block-head"><span class="block-icon" aria-hidden="true">${icon}</span><span class="block-tool">${isDelegation(message.tool) ? `Delegation · ${escapeHtml(message.input?.subagent_type || 'subagent')}` : escapeHtml(message.tool)}</span>${target}<span class="block-meta">${escapeHtml(meta)}</span>${auto}<span class="block-state ${state}">${stateLabel}</span>${actionsHtml}</div><div class="block-body">${toolBody(message)}${resultHtml(message)}</div>`
|
|
114
125
|
}
|
|
115
126
|
const who = message.role === 'user' ? 'YOU' : 'CLAUDE'
|
|
116
127
|
const icon = message.role === 'user' ? '›' : '✳'
|
|
@@ -152,14 +163,16 @@ function renderBlocks(container, messages, { streamingId = null, onCopy = () =>
|
|
|
152
163
|
element.className = 'block'
|
|
153
164
|
element.dataset.block = message.id
|
|
154
165
|
// Long tool output starts collapsed, the way a long command block does in a terminal.
|
|
155
|
-
if (message.role === 'tool' && (message.result || '').length > 1200) element.classList.add('collapsed')
|
|
166
|
+
if (message.role === 'tool' && !isDelegation(message.tool) && (message.result || '').length > 1200) element.classList.add('collapsed')
|
|
156
167
|
}
|
|
157
168
|
if (element.dataset.sig !== sig) {
|
|
158
169
|
element.dataset.sig = sig
|
|
159
170
|
element.dataset.role = message.role
|
|
160
171
|
element.dataset.tool = message.tool || ''
|
|
161
172
|
element.dataset.status = message.status || ''
|
|
173
|
+
const disclosures = new Map([...element.querySelectorAll('[data-delegation]')].map(el => [el.dataset.delegation, el.open]))
|
|
162
174
|
element.innerHTML = blockHtml(message, { streaming })
|
|
175
|
+
for (const el of element.querySelectorAll('[data-delegation]')) if (disclosures.has(el.dataset.delegation)) el.open = disclosures.get(el.dataset.delegation)
|
|
163
176
|
element.querySelector('[data-collapse]')?.addEventListener('click', event => {
|
|
164
177
|
const collapsed = element.classList.toggle('collapsed')
|
|
165
178
|
event.currentTarget.setAttribute('aria-expanded', String(!collapsed))
|
package/public/control.js
CHANGED
|
@@ -20,10 +20,37 @@ async function initializeControls() {
|
|
|
20
20
|
controlToken=data.token
|
|
21
21
|
if(!$('launch-cwd').value) $('launch-cwd').value=data.defaultCwd
|
|
22
22
|
}
|
|
23
|
+
let launchTeams=null, launchTeamsLoading=false
|
|
24
|
+
function updateLaunchTeam() {
|
|
25
|
+
const team=resumeSource ? null : launchTeams?.find(t=>t.id===$('launch-team')?.value)
|
|
26
|
+
$('launch-title').textContent=resumeSource ? 'Continue this conversation in Fleet.' : team ? 'Give your team a brief.' : 'Give your next task a home.'
|
|
27
|
+
document.querySelector('label[for="launch-prompt"]').textContent=team ? 'Brief for the manager' : 'What are we working on?'
|
|
28
|
+
document.querySelector('.launch-task-note').textContent=team ? `You talk to the ${team.manager || 'manager'}. They delegate to the team and bring the reports back here.` : 'Big ideas, small fixes. Every task starts here.'
|
|
29
|
+
$('launch-prompt').placeholder=team ? 'Describe the goal, boundaries, verification steps, and what a good result looks like.' : 'There’s something I’d love your help with…\n\nDescribe the task, what a good result looks like, and anything your agent should know.'
|
|
30
|
+
if(!$('launch-submit').disabled) $('launch-submit').textContent=team ? 'Launch initiative ↗' : 'Launch agent ↗'
|
|
31
|
+
$('launch-team').disabled=!!resumeSource || launchTeamsLoading || $('launch-submit').disabled
|
|
32
|
+
$('launch-team-note').textContent=resumeSource ? 'Continuing with the existing agent.' : team ? [team.description, `Roles: ${team.roles.map(r=>r.name).join(', ')}.`].filter(Boolean).join(' ') : launchTeamsLoading ? 'Loading teams…' : launchTeams ? 'No team keeps this a single-agent conversation.' : 'Teams unavailable. Reopen this dialog to retry; single agents are still available.'
|
|
33
|
+
}
|
|
34
|
+
async function loadLaunchTeams() {
|
|
35
|
+
if(!$('launch-team')) {
|
|
36
|
+
document.querySelector('.launch-fields').insertAdjacentHTML('afterbegin','<label for="launch-team">Team<select id="launch-team" name="teamId" aria-describedby="launch-team-note"><option value="">No team · single agent</option></select><span class="note" id="launch-team-note" role="status"></span></label>')
|
|
37
|
+
$('launch-team').addEventListener('change',()=>{launchRequestId=null;updateLaunchTeam()})
|
|
38
|
+
}
|
|
39
|
+
if(launchTeams || launchTeamsLoading){updateLaunchTeam();return}
|
|
40
|
+
launchTeamsLoading=true;updateLaunchTeam()
|
|
41
|
+
try {
|
|
42
|
+
const data=await api('/api/teams')
|
|
43
|
+
if(!Array.isArray(data.teams)) throw new Error('Invalid teams')
|
|
44
|
+
launchTeams=data.teams
|
|
45
|
+
for(const team of launchTeams) $('launch-team').add(new Option(team.name,team.id))
|
|
46
|
+
} catch { launchTeams=null }
|
|
47
|
+
finally {launchTeamsLoading=false;updateLaunchTeam()}
|
|
48
|
+
}
|
|
23
49
|
function openLaunch(source=null) {
|
|
24
50
|
resumeSource=source
|
|
25
51
|
$('launch-title').textContent=source ? 'Continue this conversation in Fleet.' : 'Give your next task a home.'
|
|
26
52
|
if(source){$('launch-cwd').value=source.cwd || '';$('launch-form').elements.name.value=source.title || source.name || ''}
|
|
53
|
+
loadLaunchTeams()
|
|
27
54
|
$('launch-cwd').readOnly=!!source
|
|
28
55
|
openModal('launch-backdrop', '[name=prompt]')
|
|
29
56
|
}
|
|
@@ -41,17 +68,17 @@ $('launch-form').addEventListener('submit',async event=>{
|
|
|
41
68
|
const button=$('launch-submit'); if(button.disabled)return
|
|
42
69
|
button.disabled=true;button.textContent='Launching…';$('launch-error').hidden=true
|
|
43
70
|
const form=event.currentTarget
|
|
44
|
-
form.querySelectorAll('input,textarea').forEach(el=>el.disabled=true)
|
|
71
|
+
form.querySelectorAll('input,textarea,select').forEach(el=>el.disabled=true)
|
|
45
72
|
launchRequestId ||= crypto.randomUUID()
|
|
46
73
|
try{
|
|
47
|
-
const data=await api('/api/managed',{cwd:form.elements.cwd.value,name:form.elements.name.value,prompt:form.elements.prompt.value,approvalMode:form.elements.approvalMode.value,model:form.elements.model.value,requestId:launchRequestId,...(resumeSource ? {resumeSessionId:resumeSource.sessionId}: {})})
|
|
74
|
+
const data=await api('/api/managed',{...(form.elements.teamId?.value && !resumeSource ? {teamId:form.elements.teamId.value}: {}),cwd:form.elements.cwd.value,name:form.elements.name.value,prompt:form.elements.prompt.value,approvalMode:form.elements.approvalMode.value,model:form.elements.model.value,requestId:launchRequestId,...(resumeSource ? {resumeSessionId:resumeSource.sessionId}: {})})
|
|
48
75
|
selected=data.session.id;filter='all'
|
|
49
76
|
form.elements.prompt.value='';launchRequestId=null
|
|
50
77
|
closeModal()
|
|
51
|
-
await tick();toast('Agent launched')
|
|
78
|
+
await tick();toast(form.elements.teamId?.value && !resumeSource ? 'Initiative launched' : 'Agent launched')
|
|
52
79
|
if(matchMedia('(max-width:720px)').matches)$('detail').scrollIntoView({block:'start',behavior:'instant'})
|
|
53
80
|
}catch(error){$('launch-error').textContent=error.message;$('launch-error').hidden=false}
|
|
54
|
-
finally{button.disabled=false;button.textContent='Launch agent ↗';form.querySelectorAll('input,textarea').forEach(el=>el.disabled=false)}
|
|
81
|
+
finally{button.disabled=false;button.textContent='Launch agent ↗';form.querySelectorAll('input,textarea,select').forEach(el=>el.disabled=false);if($('launch-team'))updateLaunchTeam()}
|
|
55
82
|
})
|
|
56
83
|
function selectControl(session) {
|
|
57
84
|
const next=session?.managedId || null
|
|
@@ -155,7 +182,11 @@ function renderControl() {
|
|
|
155
182
|
function renderApprovals(approvals) {
|
|
156
183
|
$('approvals').innerHTML=approvals.map(p=>{
|
|
157
184
|
const question=p.tool==='AskUserQuestion'
|
|
158
|
-
|
|
185
|
+
// Inside an initiative, which role wants this is the whole question. "Approve rm?" with
|
|
186
|
+
// no name attached is how an operator ends up approving something the developer asked
|
|
187
|
+
// for while believing the manager did.
|
|
188
|
+
const who=p.role ? ` · ${esc(p.role.toUpperCase())}` : ''
|
|
189
|
+
return `<form class="approval" data-approval="${esc(p.id)}"><div class="eyebrow">${question?'CLAUDE HAS A QUESTION':'APPROVAL REQUIRED'}${who}</div><h4>${esc(p.description || p.tool)}</h4>${p.reason && !question ? `<p class="approval-reason">${esc(p.reason)}</p>` : ''}${question ? (p.input.questions || []).map((q,i)=>`<fieldset><legend>${esc(q.question)}</legend>${(q.options || []).map(o=>`<label class="answer-option"><input type="${q.multiSelect?'checkbox':'radio'}" name="q${i}" value="${esc(o.label)}"><span>${esc(o.label)}${o.description?`<small>${esc(o.description)}</small>`:''}</span></label>`).join('')}<label class="other-answer">Your answer<input type="text" name="other${i}" placeholder="Or type your own answer" maxlength="4000"></label></fieldset>`).join('') : `<pre class="tool-input">${esc(JSON.stringify(p.input,null,2))}</pre>`}<div class="approval-actions"><button class="button" type="button" data-deny="${esc(p.id)}">${question?'Skip question':'Deny'}</button><button class="button resume" type="submit">${question?'Send answer':'Allow once'}</button></div><p class="form-error" role="alert" hidden></p></form>`
|
|
159
190
|
}).join('')
|
|
160
191
|
$('approvals').querySelectorAll('form').forEach(form=>{
|
|
161
192
|
const approval=approvals.find(p=>p.id===form.dataset.approval)
|
package/public/styles.css
CHANGED
|
@@ -452,3 +452,13 @@ body[data-modal]{overflow:hidden}
|
|
|
452
452
|
.topbar .update-pill:disabled{opacity:.72;cursor:default}
|
|
453
453
|
@media(prefers-reduced-motion:no-preference){.update-pill:not([hidden]){animation:update-pill-in .3s ease-out}}
|
|
454
454
|
@keyframes update-pill-in{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
|
|
455
|
+
|
|
456
|
+
.initiative-tag{display:inline-block;max-width:100%;margin-top:6px;padding:2px 6px;border:1px solid var(--line);border-radius:3px;color:var(--muted);font-size:10px;overflow-wrap:anywhere}
|
|
457
|
+
.block[data-tool="Task"],.block[data-tool="Agent"]{border-left:2px solid var(--accent)}
|
|
458
|
+
.block[data-tool="Task"] .block-tool,.block[data-tool="Agent"] .block-tool{white-space:normal;overflow-wrap:anywhere}
|
|
459
|
+
.delegation-mandate,.delegation-report{margin:0 13px;padding:12px 0}
|
|
460
|
+
.delegation-report{border-top:1px solid var(--line)}
|
|
461
|
+
.delegation-mandate>summary,.delegation-report>summary{cursor:pointer;color:var(--muted);font-size:11px;font-weight:600}
|
|
462
|
+
.delegation-mandate>summary:focus-visible,.delegation-report>summary:focus-visible{outline:2px solid var(--accent);outline-offset:4px}
|
|
463
|
+
.delegation-mandate .block-prose,.delegation-report .block-prose{padding:12px 0 0}
|
|
464
|
+
.delegation-mandate .block-plain,.delegation-report .block-plain{white-space:pre-wrap;overflow-wrap:anywhere}
|
package/server.js
CHANGED
|
@@ -12,6 +12,8 @@ const { SearchJobs, warm: warmSearch, WINDOW_DAYS: SEARCH_DAYS } = require('./se
|
|
|
12
12
|
const { Archive } = require('./archive.js')
|
|
13
13
|
const { Updater } = require('./update.js')
|
|
14
14
|
const { defaultCwd } = require('./paths.js')
|
|
15
|
+
const { listTeams } = require('./teams.js')
|
|
16
|
+
const { openDashboard } = require('./open.js')
|
|
15
17
|
const { version: VERSION } = require('./package.json')
|
|
16
18
|
const HOST = '127.0.0.1'
|
|
17
19
|
const MODEL_FALLBACK = [
|
|
@@ -166,6 +168,7 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
|
|
|
166
168
|
return res.end(themeCss(currentTheme()))
|
|
167
169
|
}
|
|
168
170
|
if(url.pathname==='/api/models') return json(res,200,{models:manager.models || MODEL_FALLBACK})
|
|
171
|
+
if(url.pathname==='/api/teams') return json(res,200,{teams:listTeams()})
|
|
169
172
|
if(url.pathname==='/api/sessions') return json(res,200,getSnapshot())
|
|
170
173
|
if(url.pathname==='/api/events') {
|
|
171
174
|
if(clients.size>=20) return json(res,429,{error:'Too many dashboard connections.'})
|
|
@@ -226,7 +229,21 @@ function main(){
|
|
|
226
229
|
child.unref()
|
|
227
230
|
setTimeout(()=>process.exit(0),100).unref()
|
|
228
231
|
}
|
|
229
|
-
try{app=createApp({restart})}catch(error){
|
|
232
|
+
try{app=createApp({restart})}catch(error){
|
|
233
|
+
// Already running is the everyday case, not a crash: put the window the operator
|
|
234
|
+
// asked for on screen and leave quietly. Exiting 1 with no window was the whole
|
|
235
|
+
// reason a second `claude-fleet` looked like a broken one.
|
|
236
|
+
if(error.code==='FLEET_ALREADY_RUNNING'){
|
|
237
|
+
// A lock written by an older Fleet carries no port, so fall back to the one this
|
|
238
|
+
// run would have asked for. Every lock written from here on records the real one.
|
|
239
|
+
const running=error.holder?.port||Number(process.env.PORT||7777)
|
|
240
|
+
const target=`http://${HOST}:${running}`
|
|
241
|
+
console.log(`\n ${error.message} → ${target}\n`)
|
|
242
|
+
if(process.argv.includes('--open')) openDashboard(target,{app:!process.argv.includes('--browser')})
|
|
243
|
+
process.exit(0)
|
|
244
|
+
}
|
|
245
|
+
console.error(error.message);process.exit(1)
|
|
246
|
+
}
|
|
230
247
|
let attempt=0,port=Number(process.env.PORT||7777)
|
|
231
248
|
app.server.on('error',async error=>{
|
|
232
249
|
if(error.code==='EADDRINUSE' && attempt++<10){app.server.listen(++port,HOST);return}
|
|
@@ -234,16 +251,14 @@ function main(){
|
|
|
234
251
|
})
|
|
235
252
|
app.server.on('listening',()=>{
|
|
236
253
|
const url=`http://${HOST}:${app.server.address().port}`
|
|
254
|
+
// Write the port where the next `claude-fleet` will look for it.
|
|
255
|
+
app.manager.recordPort(app.server.address().port)
|
|
237
256
|
console.log(`\n Claude Fleet v${VERSION} → ${url}\n Local dashboard + managed agents · ctrl-c to stop\n`)
|
|
238
257
|
// Index transcripts in the background so the first question does not wait for it.
|
|
239
258
|
setTimeout(()=>warmSearch().catch(()=>{}),1500).unref()
|
|
240
259
|
// And ask npm whether there is a newer Fleet, well after the page has loaded.
|
|
241
260
|
setTimeout(()=>app.updater.check().catch(()=>{}),5000).unref()
|
|
242
|
-
if(process.argv.includes('--open')) {
|
|
243
|
-
const opener=process.platform==='darwin'?'open':'xdg-open'
|
|
244
|
-
const child=require('node:child_process').spawn(opener,[url],{stdio:'ignore'})
|
|
245
|
-
child.on('error',()=>{});child.unref()
|
|
246
|
-
}
|
|
261
|
+
if(process.argv.includes('--open')) openDashboard(url,{app:!process.argv.includes('--browser')})
|
|
247
262
|
})
|
|
248
263
|
app.server.listen(port,HOST)
|
|
249
264
|
let closing=false
|
package/teams.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// A team is a named set of roles. One role is the manager: it owns the main thread, so it
|
|
3
|
+
// owns the conversation with the operator. The others are reachable only through the Agent
|
|
4
|
+
// tool, which is what makes "you talk to the manager" structural rather than a house rule.
|
|
5
|
+
//
|
|
6
|
+
// Role prompts are deliberately project-agnostic. Fleet runs against whatever directory it
|
|
7
|
+
// is pointed at, so a role discovers the repo's own gates instead of carrying someone's.
|
|
8
|
+
|
|
9
|
+
// Every non-manager role gets this appended. Subagents do not know they are subagents, and
|
|
10
|
+
// left unlabelled they write to the operator and try to delegate further.
|
|
11
|
+
const SUBAGENT_RULE = `
|
|
12
|
+
|
|
13
|
+
## You are a subagent
|
|
14
|
+
|
|
15
|
+
You were invoked by the manager of an initiative. You are not talking to a person.
|
|
16
|
+
Your reply goes back to the manager and to nobody else, so never address the operator,
|
|
17
|
+
never ask them a question, and never promise to follow up.
|
|
18
|
+
|
|
19
|
+
You cannot delegate. If the work is larger than your mandate, do the part that is clearly
|
|
20
|
+
yours and say in your report exactly what you left and why. Returning a smaller honest
|
|
21
|
+
result beats returning a larger invented one.`
|
|
22
|
+
|
|
23
|
+
const MANAGER = `You are the manager of an initiative: one goal, a small team, and a single
|
|
24
|
+
conversation with the operator. You are the only member of the team they can hear.
|
|
25
|
+
|
|
26
|
+
## You do not write code
|
|
27
|
+
|
|
28
|
+
You have no edit tools. That is deliberate, not an oversight to work around. Your only way
|
|
29
|
+
to ship is to delegate, and the moment you start patching things yourself with shell
|
|
30
|
+
redirection or heredocs the team stops meaning anything. If a change is one character, it
|
|
31
|
+
is still the developer's change.
|
|
32
|
+
|
|
33
|
+
## Order of work
|
|
34
|
+
|
|
35
|
+
1. **Read before you plan.** Find the project's own instructions (CLAUDE.md, AGENTS.md,
|
|
36
|
+
CONTRIBUTING, README) and any rules scoped to the paths in question. Locate the actual
|
|
37
|
+
files the goal touches. A brief that says "fix the login redirect" maps onto specific
|
|
38
|
+
modules; find them before you split anything.
|
|
39
|
+
|
|
40
|
+
2. **Find the gates.** Every repo has commands that decide whether work is acceptable:
|
|
41
|
+
a test runner, a type check, a linter. Read package.json scripts, the CI workflow, or
|
|
42
|
+
the contributing guide and write down the exact commands. You will hand these to every
|
|
43
|
+
delegate. Guessing at them wastes a whole round.
|
|
44
|
+
|
|
45
|
+
3. **Ask if the goal is genuinely ambiguous.** Now, while it costs one message, rather than
|
|
46
|
+
after three delegations have built the wrong thing. Ambiguity that changes the work is
|
|
47
|
+
worth a question; ambiguity you can resolve by reading is not.
|
|
48
|
+
|
|
49
|
+
4. **Split into tasks that cannot collide.** Each task is one deliverable with a file set
|
|
50
|
+
disjoint from every other task in flight. Two delegates editing the same file is the
|
|
51
|
+
most reliable way to make this whole arrangement fail. If two tasks need the same file,
|
|
52
|
+
they are one task, or they run in sequence.
|
|
53
|
+
|
|
54
|
+
5. **Delegate with a complete mandate.** A delegate inherits none of your conversation and
|
|
55
|
+
none of your reading. Every delegation must carry four things or it will drift:
|
|
56
|
+
- **Objective**: what to change, in which files
|
|
57
|
+
- **Boundaries**: what it must not touch
|
|
58
|
+
- **Gates**: the exact commands that must pass, copied, not described
|
|
59
|
+
- **Done**: how you will know it worked, in terms someone else could check
|
|
60
|
+
|
|
61
|
+
Delegate to the roles on your team and never to yourself. You appear in your own roster
|
|
62
|
+
for mechanical reasons; invoking yourself buys nothing and costs a full context.
|
|
63
|
+
|
|
64
|
+
6. **Have QA verify, and believe QA.** Send finished work to qa before you report it as
|
|
65
|
+
done. A developer's account of its own work is a claim, not evidence. If qa fails the
|
|
66
|
+
work, send it back to the developer with what qa found; do not overrule it because the
|
|
67
|
+
diff looks fine to you.
|
|
68
|
+
|
|
69
|
+
7. **Report once, at the end of the turn.** One message to the operator: what changed, what
|
|
70
|
+
passed, what you deliberately did not do, and what you need from them if anything.
|
|
71
|
+
Not a running commentary.
|
|
72
|
+
|
|
73
|
+
## Finishing
|
|
74
|
+
|
|
75
|
+
When qa passes and the goal is met, commit on the initiative's branch and open a pull
|
|
76
|
+
request with \`gh\`. Use the project's commit convention if it has one. Pushing will stop
|
|
77
|
+
for the operator's approval, which is intended: they get the last look before anything
|
|
78
|
+
leaves the machine.
|
|
79
|
+
|
|
80
|
+
If you cannot finish, say so plainly and say what is blocking. A stalled initiative
|
|
81
|
+
reported honestly is worth more than a green one that is lying.`
|
|
82
|
+
|
|
83
|
+
const DEVELOPER = `You implement one scoped task inside an initiative, end to end.
|
|
84
|
+
|
|
85
|
+
Work only inside the boundaries your mandate gives you. If the fix genuinely requires
|
|
86
|
+
touching a file outside them, stop and report that, rather than reaching for it: the
|
|
87
|
+
boundary probably exists because somebody else is in that file right now.
|
|
88
|
+
|
|
89
|
+
Before you report anything as done, run the gates in your mandate and read the output. Not
|
|
90
|
+
"they should pass", not "the change is small". Run them. If they fail and you cannot fix
|
|
91
|
+
it, that is a legitimate report; a false green is not.
|
|
92
|
+
|
|
93
|
+
Follow the conventions already in the files you are editing: their naming, their error
|
|
94
|
+
handling, their comment density. New code should be hard to pick out of a diff by style
|
|
95
|
+
alone.
|
|
96
|
+
|
|
97
|
+
## Your report
|
|
98
|
+
|
|
99
|
+
- what you changed, file by file
|
|
100
|
+
- the gate commands you ran and what they printed
|
|
101
|
+
- anything you deliberately did not do, and why
|
|
102
|
+
- anything you found that the manager did not know about
|
|
103
|
+
|
|
104
|
+
Keep it short enough to read and specific enough to act on.${SUBAGENT_RULE}`
|
|
105
|
+
|
|
106
|
+
const QA = `You verify work inside an initiative. You are the only reason the manager can
|
|
107
|
+
trust anything, so behave like it.
|
|
108
|
+
|
|
109
|
+
You have no edit tools. You cannot fix what you find, and you should not want to: your
|
|
110
|
+
output is a verdict with evidence, not a patch.
|
|
111
|
+
|
|
112
|
+
## How to verify
|
|
113
|
+
|
|
114
|
+
Start from the original problem, not from the diff. A reviewer who only reads the change
|
|
115
|
+
agrees with the change. Reproduce the behaviour the initiative set out to fix, then check
|
|
116
|
+
whether it is actually fixed.
|
|
117
|
+
|
|
118
|
+
Run the gates yourself. The developer's report is a claim about what happened on their
|
|
119
|
+
turn; re-running costs little and catches the difference between "I ran it" and "it
|
|
120
|
+
passed". Read the output rather than the exit code alone where the two can disagree.
|
|
121
|
+
|
|
122
|
+
Then look for what the mandate did not mention: cases the change breaks, boundaries it
|
|
123
|
+
crossed, tests that assert the implementation instead of the behaviour, error paths that
|
|
124
|
+
are now unreachable.
|
|
125
|
+
|
|
126
|
+
## Your verdict
|
|
127
|
+
|
|
128
|
+
Open with exactly one of **PASS** or **FAIL**, then the evidence.
|
|
129
|
+
|
|
130
|
+
For a FAIL, state what you did, what you expected, what happened instead, and where. Be
|
|
131
|
+
specific enough that the developer can act without asking you anything.
|
|
132
|
+
|
|
133
|
+
Failing work is an ordinary outcome and the most useful thing you produce. Do not soften a
|
|
134
|
+
FAIL into a pass with reservations, and do not pad a PASS with speculative concerns to look
|
|
135
|
+
thorough. If it works, say it works.${SUBAGENT_RULE}`
|
|
136
|
+
|
|
137
|
+
// Subagents must not spawn their own subagents: nothing supervises the result, and a
|
|
138
|
+
// runaway nest is expensive before it is visible.
|
|
139
|
+
const NO_DELEGATION = ['Agent', 'Task']
|
|
140
|
+
const NO_EDITS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
|
|
141
|
+
|
|
142
|
+
const TEAMS = {
|
|
143
|
+
bugfix: {
|
|
144
|
+
id: 'bugfix',
|
|
145
|
+
name: 'Bug fix',
|
|
146
|
+
description: 'A manager who plans and delegates, a developer who implements, and a QA who independently verifies.',
|
|
147
|
+
manager: 'manager',
|
|
148
|
+
roles: {
|
|
149
|
+
manager: {
|
|
150
|
+
description: 'Plans the work, delegates it, and reports back. Never writes code.',
|
|
151
|
+
prompt: MANAGER,
|
|
152
|
+
model: 'opus',
|
|
153
|
+
effort: 'high',
|
|
154
|
+
disallowedTools: NO_EDITS,
|
|
155
|
+
},
|
|
156
|
+
developer: {
|
|
157
|
+
description: 'Implements one scoped task and runs the gates. Use for any change to the code.',
|
|
158
|
+
prompt: DEVELOPER,
|
|
159
|
+
model: 'opus',
|
|
160
|
+
effort: 'high',
|
|
161
|
+
disallowedTools: NO_DELEGATION,
|
|
162
|
+
},
|
|
163
|
+
qa: {
|
|
164
|
+
description: 'Independently verifies finished work and returns PASS or FAIL with evidence. Use before reporting anything as done.',
|
|
165
|
+
prompt: QA,
|
|
166
|
+
model: 'sonnet',
|
|
167
|
+
effort: 'medium',
|
|
168
|
+
disallowedTools: [...NO_EDITS, ...NO_DELEGATION],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getTeam(id) {
|
|
175
|
+
if (!id) return null
|
|
176
|
+
return Object.prototype.hasOwnProperty.call(TEAMS, id) ? TEAMS[id] : null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// What the UI needs to offer a choice. Prompts are large and of no use to the browser.
|
|
180
|
+
function listTeams() {
|
|
181
|
+
return Object.values(TEAMS).map(team => ({
|
|
182
|
+
id: team.id, name: team.name, description: team.description, manager: team.manager,
|
|
183
|
+
roles: Object.entries(team.roles).map(([name, role]) => ({
|
|
184
|
+
name, description: role.description, model: role.model || null,
|
|
185
|
+
})),
|
|
186
|
+
}))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A team becomes two SDK options: `agent` names who holds the main thread, `agents` carries
|
|
190
|
+
// every definition. The manager has to appear in `agents` too, because that is where `agent`
|
|
191
|
+
// resolves the name from; it is not reachable as a subagent of itself by convention rather
|
|
192
|
+
// than by construction, which is why its prompt says so outright.
|
|
193
|
+
function compile(team) {
|
|
194
|
+
if (!team) return null
|
|
195
|
+
if (!team.roles[team.manager]) throw new Error(`Team ${team.id} names a manager role that does not exist.`)
|
|
196
|
+
return { agent: team.manager, agents: { ...team.roles } }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = { TEAMS, getTeam, listTeams, compile, roleNames: team => Object.keys(team.roles) }
|
package/worktree.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// An initiative gets its own git worktree, so a team editing files cannot collide with the
|
|
3
|
+
// operator's own checkout or with another initiative in the same repo. Worktrees live under
|
|
4
|
+
// Fleet's state directory rather than inside the project, for the same reason the archive
|
|
5
|
+
// does: Fleet's bookkeeping is Fleet's business and should be removable in one directory.
|
|
6
|
+
const fs = require('node:fs')
|
|
7
|
+
const path = require('node:path')
|
|
8
|
+
const { execFileSync } = require('node:child_process')
|
|
9
|
+
const { stateDir } = require('./paths')
|
|
10
|
+
|
|
11
|
+
// Arguments always travel as an array. A branch name is operator-derived text and must
|
|
12
|
+
// never reach a shell.
|
|
13
|
+
function git(cwd, args) {
|
|
14
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function repoRoot(cwd) {
|
|
18
|
+
try { return git(cwd, ['rev-parse', '--show-toplevel']) } catch { return null }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The branch an initiative forks from: the checked-out branch, or the commit itself when
|
|
22
|
+
// HEAD is detached, which still gives the worktree something to stand on.
|
|
23
|
+
//
|
|
24
|
+
// A repository with no commits answers `symbolic-ref` perfectly happily with its unborn
|
|
25
|
+
// branch, so the name alone proves nothing. Confirm HEAD resolves to a commit first, or the
|
|
26
|
+
// operator gets `fatal: invalid reference: main` instead of being told the repo is empty.
|
|
27
|
+
function baseRef(root) {
|
|
28
|
+
try { git(root, ['rev-parse', '--verify', '--quiet', 'HEAD']) } catch { return null }
|
|
29
|
+
try {
|
|
30
|
+
const branch = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
|
|
31
|
+
if (branch) return branch
|
|
32
|
+
} catch {}
|
|
33
|
+
try { return git(root, ['rev-parse', 'HEAD']) } catch { return null }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function slugify(value, fallback) {
|
|
37
|
+
const slug = String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
|
38
|
+
return slug || fallback
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function branchExists(root, branch) {
|
|
42
|
+
try { git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]); return true } catch { return false }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Create the worktree for one initiative. Throws with a message meant for the operator:
|
|
46
|
+
// every failure here happens at launch, where there is still a human watching.
|
|
47
|
+
function create({ cwd, id, name }) {
|
|
48
|
+
const root = repoRoot(cwd)
|
|
49
|
+
if (!root) {
|
|
50
|
+
const error = new Error('An initiative needs a git repository: its team works on a branch and finishes with a pull request. Point it at a checkout, or launch a single agent instead.')
|
|
51
|
+
error.status = 400
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
const base = baseRef(root)
|
|
55
|
+
if (!base) {
|
|
56
|
+
const error = new Error('This repository has no commits yet, so there is nothing to branch from.')
|
|
57
|
+
error.status = 400
|
|
58
|
+
throw error
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const short = String(id).slice(0, 8)
|
|
62
|
+
let branch = `initiative/${slugify(name, short)}`
|
|
63
|
+
if (branchExists(root, branch)) branch = `${branch}-${short}`
|
|
64
|
+
|
|
65
|
+
const dir = path.join(stateDir(), 'worktrees', short)
|
|
66
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true, mode: 0o700 })
|
|
67
|
+
if (fs.existsSync(dir)) {
|
|
68
|
+
const error = new Error(`A worktree already exists at ${dir}.`)
|
|
69
|
+
error.status = 409
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try { git(root, ['worktree', 'add', '-b', branch, dir, base]) }
|
|
74
|
+
catch (cause) {
|
|
75
|
+
const error = new Error(`Could not create a worktree for this initiative: ${String(cause.stderr || cause.message).trim().slice(0, 500)}`)
|
|
76
|
+
error.status = 400
|
|
77
|
+
throw error
|
|
78
|
+
}
|
|
79
|
+
return { path: dir, branch, base, repo: root }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Best effort: an initiative whose worktree is gone is still readable, and a half-removed
|
|
83
|
+
// worktree is worse than one left behind for `git worktree prune` to notice.
|
|
84
|
+
function remove(worktree) {
|
|
85
|
+
if (!worktree?.path || !worktree?.repo) return false
|
|
86
|
+
try { git(worktree.repo, ['worktree', 'remove', '--force', worktree.path]); return true }
|
|
87
|
+
catch { return false }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { create, remove, repoRoot, baseRef, slugify }
|