@sergeychuvayev/claude-fleet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +377 -0
- package/archive.js +96 -0
- package/bin/claude-fleet.js +102 -0
- package/build/make-app.sh +110 -0
- package/catalog.js +117 -0
- package/fleet.js +450 -0
- package/managed.js +456 -0
- package/package.json +62 -0
- package/paths.js +64 -0
- package/permissions.js +73 -0
- package/public/app.js +369 -0
- package/public/ask.js +119 -0
- package/public/blocks.js +180 -0
- package/public/control.js +426 -0
- package/public/icons/fleet-192.png +0 -0
- package/public/icons/fleet-512.png +0 -0
- package/public/index.html +48 -0
- package/public/styles.css +454 -0
- package/public/vendor/libs.js +75 -0
- package/search.js +425 -0
- package/server.js +255 -0
- package/theme.js +89 -0
- package/update.js +183 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Builds "Claude Fleet.app": a launcher that starts the server if it is not already
|
|
3
|
+
# running and opens it in a Chrome app window, with no tabs or address bar. The
|
|
4
|
+
# bundle only ever points at 127.0.0.1; it contains no credentials and no copy of
|
|
5
|
+
# the project.
|
|
6
|
+
#
|
|
7
|
+
# Paths are baked in at build time on purpose. An app launched from Finder gets a
|
|
8
|
+
# minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin), so it would not find node under
|
|
9
|
+
# Homebrew or a version manager, nor the npm global bin directory. npm keeps the
|
|
10
|
+
# same bin path across updates, so the bundle survives them and does not need
|
|
11
|
+
# rebuilding when Fleet updates itself.
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
PROJECT="${PROJECT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
|
15
|
+
FLEET_BIN="${FLEET_BIN:-$PROJECT/bin/claude-fleet.js}"
|
|
16
|
+
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
|
|
17
|
+
PORT="${PORT:-7777}"
|
|
18
|
+
# Installed globally, the package lives in node_modules and is replaced on every
|
|
19
|
+
# update, so the bundle goes where the user's own apps live.
|
|
20
|
+
DEST="${APP_DEST:-$HOME/Applications}"
|
|
21
|
+
APP="$DEST/Claude Fleet.app"
|
|
22
|
+
|
|
23
|
+
[ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ] || { echo "Node 22+ was not found on PATH." >&2; exit 1; }
|
|
24
|
+
[ -f "$FLEET_BIN" ] || { echo "Cannot find the Fleet entry point at $FLEET_BIN" >&2; exit 1; }
|
|
25
|
+
|
|
26
|
+
mkdir -p "$DEST"
|
|
27
|
+
rm -rf "$APP"
|
|
28
|
+
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
|
29
|
+
|
|
30
|
+
VERSION="$("$NODE_BIN" -p "require('$PROJECT/package.json').version" 2>/dev/null || echo 0.0.0)"
|
|
31
|
+
|
|
32
|
+
cat > "$APP/Contents/Info.plist" <<PLIST
|
|
33
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
34
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
35
|
+
<plist version="1.0"><dict>
|
|
36
|
+
<key>CFBundleName</key><string>Claude Fleet</string>
|
|
37
|
+
<key>CFBundleDisplayName</key><string>Claude Fleet</string>
|
|
38
|
+
<key>CFBundleIdentifier</key><string>local.claude.fleet</string>
|
|
39
|
+
<key>CFBundleVersion</key><string>$VERSION</string>
|
|
40
|
+
<key>CFBundleShortVersionString</key><string>$VERSION</string>
|
|
41
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
42
|
+
<key>CFBundleExecutable</key><string>fleet</string>
|
|
43
|
+
<key>CFBundleIconFile</key><string>fleet</string>
|
|
44
|
+
<key>LSMinimumSystemVersion</key><string>12.0</string>
|
|
45
|
+
<key>LSUIElement</key><false/>
|
|
46
|
+
<key>NSHighResolutionCapable</key><true/>
|
|
47
|
+
</dict></plist>
|
|
48
|
+
PLIST
|
|
49
|
+
|
|
50
|
+
cat > "$APP/Contents/MacOS/fleet" <<LAUNCHER
|
|
51
|
+
#!/bin/bash
|
|
52
|
+
# Generated by claude-fleet install-app. Rebuild with \`claude-fleet install-app\`.
|
|
53
|
+
NODE="$NODE_BIN"
|
|
54
|
+
FLEET="$FLEET_BIN"
|
|
55
|
+
PORT="\${PORT:-$PORT}"
|
|
56
|
+
LOG="\$HOME/Library/Logs/claude-fleet.log"
|
|
57
|
+
mkdir -p "\$(dirname "\$LOG")"
|
|
58
|
+
|
|
59
|
+
note() { osascript -e "display notification \"\$1\" with title \"Claude Fleet\"" >/dev/null 2>&1 || true; }
|
|
60
|
+
listening() { /usr/sbin/lsof -nP -iTCP:"\$PORT" -sTCP:LISTEN >/dev/null 2>&1; }
|
|
61
|
+
|
|
62
|
+
if ! listening; then
|
|
63
|
+
[ -x "\$NODE" ] || { note "Node was not found. Run: claude-fleet install-app"; exit 1; }
|
|
64
|
+
[ -f "\$FLEET" ] || { note "Fleet is no longer installed here. Reinstall it."; exit 1; }
|
|
65
|
+
# Agents launched from the dashboard start here unless the operator picks a folder.
|
|
66
|
+
DEFAULT_CWD="\$HOME"
|
|
67
|
+
[ -d "\$HOME/projects" ] && DEFAULT_CWD="\$HOME/projects"
|
|
68
|
+
cd "\$DEFAULT_CWD"
|
|
69
|
+
# The GUI PATH is too small to find the claude CLI, node or npm; widen it so the
|
|
70
|
+
# server can prefer your installed Claude and can install its own updates.
|
|
71
|
+
export PATH="\$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:\$(dirname "\$NODE"):\$PATH"
|
|
72
|
+
PORT="\$PORT" CLAUDE_FLEET_DEFAULT_CWD="\$DEFAULT_CWD" nohup "\$NODE" "\$FLEET" start >>"\$LOG" 2>&1 &
|
|
73
|
+
for _ in \$(seq 1 40); do listening && break; sleep 0.25; done
|
|
74
|
+
listening || { note "The server did not start. See \$LOG"; exit 1; }
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
URL="http://127.0.0.1:\$PORT"
|
|
78
|
+
for BROWSER in \\
|
|
79
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \\
|
|
80
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" \\
|
|
81
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
|
|
82
|
+
do
|
|
83
|
+
if [ -x "\$BROWSER" ]; then
|
|
84
|
+
# --app gives a plain window with no tab strip or address bar.
|
|
85
|
+
exec "\$BROWSER" --app="\$URL" --user-data-dir="\$HOME/Library/Application Support/ClaudeFleetApp"
|
|
86
|
+
fi
|
|
87
|
+
done
|
|
88
|
+
# No Chromium browser installed; fall back to the default browser in a normal window.
|
|
89
|
+
exec /usr/bin/open "\$URL"
|
|
90
|
+
LAUNCHER
|
|
91
|
+
chmod +x "$APP/Contents/MacOS/fleet"
|
|
92
|
+
|
|
93
|
+
# Turn the generated PNG into a real .icns so the Dock and Finder show the mark.
|
|
94
|
+
if [ -f "$PROJECT/public/icons/fleet-512.png" ] && command -v iconutil >/dev/null 2>&1; then
|
|
95
|
+
SET="$(mktemp -d)/fleet.iconset"
|
|
96
|
+
mkdir -p "$SET"
|
|
97
|
+
for SIZE in 16 32 64 128 256 512; do
|
|
98
|
+
sips -z "$SIZE" "$SIZE" "$PROJECT/public/icons/fleet-512.png" --out "$SET/icon_${SIZE}x${SIZE}.png" >/dev/null
|
|
99
|
+
sips -z "$((SIZE * 2))" "$((SIZE * 2))" "$PROJECT/public/icons/fleet-512.png" --out "$SET/icon_${SIZE}x${SIZE}@2x.png" >/dev/null
|
|
100
|
+
done
|
|
101
|
+
iconutil -c icns "$SET" -o "$APP/Contents/Resources/fleet.icns"
|
|
102
|
+
rm -rf "$(dirname "$SET")"
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
# Ask Finder to notice the new bundle rather than showing a stale generic icon.
|
|
106
|
+
touch "$APP"
|
|
107
|
+
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$APP" >/dev/null 2>&1 || true
|
|
108
|
+
|
|
109
|
+
echo "Installed: $APP"
|
|
110
|
+
echo "Open it from Spotlight, or keep it in the Dock."
|
package/catalog.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Lists the slash commands and skills a project can invoke, so the composer can
|
|
3
|
+
// offer them. Everything here is read-only and confined to the well-known Claude
|
|
4
|
+
// locations: a project's own `.claude`, the user's `~/.claude`, and the install
|
|
5
|
+
// paths named in the plugin registry. Nothing is executed and no file body is sent.
|
|
6
|
+
const fs = require('node:fs')
|
|
7
|
+
const os = require('node:os')
|
|
8
|
+
const path = require('node:path')
|
|
9
|
+
|
|
10
|
+
const MAX_ENTRIES = 400
|
|
11
|
+
const MAX_HEAD = 4096
|
|
12
|
+
const CACHE_MS = 5000
|
|
13
|
+
const cache = new Map()
|
|
14
|
+
|
|
15
|
+
// Read just enough of a file to parse its YAML front matter.
|
|
16
|
+
function frontMatter(file) {
|
|
17
|
+
let handle
|
|
18
|
+
try {
|
|
19
|
+
handle = fs.openSync(file, 'r')
|
|
20
|
+
const buffer = Buffer.alloc(MAX_HEAD)
|
|
21
|
+
const read = fs.readSync(handle, buffer, 0, MAX_HEAD, 0)
|
|
22
|
+
const head = buffer.subarray(0, read).toString('utf8')
|
|
23
|
+
const match = head.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
24
|
+
if (!match) return {}
|
|
25
|
+
const fields = {}
|
|
26
|
+
const lines = match[1].split('\n')
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
const pair = lines[i].match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/)
|
|
29
|
+
if (!pair) continue
|
|
30
|
+
const value = pair[2].trim()
|
|
31
|
+
// A folded or literal block scalar (`>`, `>-`, `|`) continues on indented lines.
|
|
32
|
+
if (/^[|>][-+]?\d*$/.test(value)) {
|
|
33
|
+
const block = []
|
|
34
|
+
while (i + 1 < lines.length && (!lines[i + 1].trim() || /^\s+\S/.test(lines[i + 1]))) block.push(lines[++i].trim())
|
|
35
|
+
fields[pair[1]] = block.join(' ').trim()
|
|
36
|
+
} else fields[pair[1]] = value.replace(/^["']|["']$/g, '')
|
|
37
|
+
}
|
|
38
|
+
return fields
|
|
39
|
+
} catch { return {} }
|
|
40
|
+
finally { if (handle !== undefined) try { fs.closeSync(handle) } catch {} }
|
|
41
|
+
}
|
|
42
|
+
const clean = (value, max) => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '')
|
|
43
|
+
|
|
44
|
+
function listCommands(directory, { scope, prefix = '' }, out) {
|
|
45
|
+
let entries
|
|
46
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }) } catch { return }
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (out.length >= MAX_ENTRIES) return
|
|
49
|
+
const full = path.join(directory, entry.name)
|
|
50
|
+
// One level of nesting is enough: Claude names those commands `dir:command`.
|
|
51
|
+
if (entry.isDirectory() && !prefix) { listCommands(full, { scope, prefix: `${entry.name}:` }, out); continue }
|
|
52
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
53
|
+
const fields = frontMatter(full)
|
|
54
|
+
out.push({
|
|
55
|
+
name: `${prefix}${entry.name.replace(/\.md$/, '')}`,
|
|
56
|
+
kind: 'command', scope,
|
|
57
|
+
description: clean(fields.description, 160),
|
|
58
|
+
hint: clean(fields['argument-hint'], 60),
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function listSkills(directory, { scope, prefix = '' }, out) {
|
|
63
|
+
let entries
|
|
64
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }) } catch { return }
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (out.length >= MAX_ENTRIES) return
|
|
67
|
+
if (!entry.isDirectory()) continue
|
|
68
|
+
const file = path.join(directory, entry.name, 'SKILL.md')
|
|
69
|
+
if (!fs.existsSync(file)) continue
|
|
70
|
+
const fields = frontMatter(file)
|
|
71
|
+
out.push({
|
|
72
|
+
name: `${prefix}${clean(fields.name, 60) || entry.name}`,
|
|
73
|
+
kind: 'skill', scope,
|
|
74
|
+
description: clean(fields.description, 160),
|
|
75
|
+
hint: '',
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function listPlugins(out) {
|
|
80
|
+
let registry
|
|
81
|
+
try { registry = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'), 'utf8')) }
|
|
82
|
+
catch { return }
|
|
83
|
+
for (const [key, installs] of Object.entries(registry.plugins || {})) {
|
|
84
|
+
const install = Array.isArray(installs) ? installs[0] : null
|
|
85
|
+
if (!install?.installPath) continue
|
|
86
|
+
const plugin = String(key).split('@')[0]
|
|
87
|
+
listCommands(path.join(install.installPath, 'commands'), { scope: 'plugin', prefix: `${plugin}:` }, out)
|
|
88
|
+
listSkills(path.join(install.installPath, 'skills'), { scope: 'plugin', prefix: `${plugin}:` }, out)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function collect(cwd) {
|
|
93
|
+
const cached = cache.get(cwd)
|
|
94
|
+
if (cached && Date.now() - cached.at < CACHE_MS) return cached.entries
|
|
95
|
+
const out = []
|
|
96
|
+
if (cwd) {
|
|
97
|
+
listCommands(path.join(cwd, '.claude', 'commands'), { scope: 'project' }, out)
|
|
98
|
+
listSkills(path.join(cwd, '.claude', 'skills'), { scope: 'project' }, out)
|
|
99
|
+
}
|
|
100
|
+
const home = path.join(os.homedir(), '.claude')
|
|
101
|
+
listCommands(path.join(home, 'commands'), { scope: 'user' }, out)
|
|
102
|
+
listSkills(path.join(home, 'skills'), { scope: 'user' }, out)
|
|
103
|
+
listPlugins(out)
|
|
104
|
+
|
|
105
|
+
// A project entry wins over a user entry of the same name, as Claude resolves it.
|
|
106
|
+
const rank = { project: 0, user: 1, plugin: 2 }
|
|
107
|
+
const byName = new Map()
|
|
108
|
+
for (const entry of out) {
|
|
109
|
+
const previous = byName.get(entry.name)
|
|
110
|
+
if (!previous || rank[entry.scope] < rank[previous.scope]) byName.set(entry.name, entry)
|
|
111
|
+
}
|
|
112
|
+
const entries = [...byName.values()].sort((a, b) => rank[a.scope] - rank[b.scope] || a.name.localeCompare(b.name))
|
|
113
|
+
cache.set(cwd, { at: Date.now(), entries })
|
|
114
|
+
return entries
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { collect, frontMatter }
|
package/fleet.js
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Data layer: reads Claude Code's on-disk session state. Read-only, no writes anywhere.
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
|
|
7
|
+
const CLAUDE_DIR = process.env.CLAUDE_FLEET_DIR || path.join(os.homedir(), '.claude')
|
|
8
|
+
const SESSIONS_DIR = path.join(CLAUDE_DIR, 'sessions')
|
|
9
|
+
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects')
|
|
10
|
+
const SOCKS_DIR = '/tmp/cc-socks'
|
|
11
|
+
|
|
12
|
+
const STALE_MS = 3 * 24 * 60 * 60 * 1000
|
|
13
|
+
const MAX_TAIL_BYTES = 6 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
// --- caches -----------------------------------------------------------------
|
|
16
|
+
const transcriptCache = new Map() // path -> { key, data }
|
|
17
|
+
const branchCache = new Map() // cwd -> { at, branch }
|
|
18
|
+
let indexCache = { at: 0, map: new Map() } // sessionId -> jsonl path
|
|
19
|
+
|
|
20
|
+
function isAlive(pid) {
|
|
21
|
+
if (!pid) return false
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0)
|
|
24
|
+
return true
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return err.code === 'EPERM'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Map sessionId -> transcript file. Rebuilt every 10s; new sessions appear fast enough.
|
|
31
|
+
function transcriptIndex() {
|
|
32
|
+
if (Date.now() - indexCache.at < 10000) return indexCache.map
|
|
33
|
+
const map = new Map()
|
|
34
|
+
let dirs = []
|
|
35
|
+
try {
|
|
36
|
+
dirs = fs.readdirSync(PROJECTS_DIR)
|
|
37
|
+
} catch {
|
|
38
|
+
dirs = []
|
|
39
|
+
}
|
|
40
|
+
for (const dir of dirs) {
|
|
41
|
+
const full = path.join(PROJECTS_DIR, dir)
|
|
42
|
+
let files = []
|
|
43
|
+
try {
|
|
44
|
+
files = fs.readdirSync(full)
|
|
45
|
+
} catch {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
for (const f of files) {
|
|
49
|
+
if (!f.endsWith('.jsonl')) continue
|
|
50
|
+
map.set(f.slice(0, -6), path.join(full, f))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
indexCache = { at: Date.now(), map }
|
|
54
|
+
return map
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The one argument that says what a tool acted on, for a step's label.
|
|
58
|
+
function toolTarget(name, input) {
|
|
59
|
+
if (!input || typeof input !== 'object') return null
|
|
60
|
+
const first = value => (typeof value === 'string' ? value.split('\n')[0].trim().slice(0, 120) : null)
|
|
61
|
+
if (name === 'Bash' || name === 'BashOutput') return first(input.description) || first(input.command)
|
|
62
|
+
if (name === 'Task') return first(input.description)
|
|
63
|
+
if (name === 'WebSearch') return first(input.query)
|
|
64
|
+
if (name === 'WebFetch') return first(input.url)
|
|
65
|
+
if (name === 'Grep' || name === 'Glob') return first(input.pattern)
|
|
66
|
+
if (name === 'Skill') return first(input.skill)
|
|
67
|
+
const filePath = input.file_path || input.path || input.notebook_path
|
|
68
|
+
return typeof filePath === 'string' ? filePath.split('/').slice(-2).join('/').slice(0, 120) : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Which family of work a tool represents. The steps bar colours by this, and a
|
|
72
|
+
// developer reads "mostly inspecting" vs "changing files" vs "running things".
|
|
73
|
+
function toolCategory(name) {
|
|
74
|
+
if (['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'ListMcpResourcesTool', 'ReadMcpResourceTool'].includes(name)) return 'inspect'
|
|
75
|
+
if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(name)) return 'change'
|
|
76
|
+
if (['Bash', 'BashOutput', 'KillShell'].includes(name)) return 'run'
|
|
77
|
+
if (['Task', 'Skill', 'Agent'].includes(name)) return 'delegate'
|
|
78
|
+
if (['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode'].includes(name)) return 'ask'
|
|
79
|
+
return 'other'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Events a transcript entry represents. One entry can yield several: an assistant
|
|
83
|
+
// message may hold text and multiple tool calls; a user entry may carry results.
|
|
84
|
+
// Successful results are dropped, a failed one is kept so it can mark its step.
|
|
85
|
+
function classify(d) {
|
|
86
|
+
const content = d.message && d.message.content
|
|
87
|
+
const out = []
|
|
88
|
+
if (d.type === 'user') {
|
|
89
|
+
if (typeof content === 'string') { if (content.trim()) out.push({ kind: 'user' }); return out }
|
|
90
|
+
if (!Array.isArray(content)) return out
|
|
91
|
+
for (const b of content) {
|
|
92
|
+
if (b.type === 'tool_result' && b.is_error) out.push({ kind: 'error', id: b.tool_use_id || null })
|
|
93
|
+
}
|
|
94
|
+
if (!content.some(b => b.type === 'tool_result') && content.some(b => b.type === 'text' && (b.text || '').trim())) out.push({ kind: 'user' })
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
if (d.type === 'assistant' && Array.isArray(content)) {
|
|
98
|
+
for (const b of content) {
|
|
99
|
+
if (b.type === 'tool_use') out.push({ kind: 'tool', id: b.id || null, tool: b.name || 'Tool', target: toolTarget(b.name, b.input) })
|
|
100
|
+
}
|
|
101
|
+
if (!out.length && content.some(b => b.type === 'text' && (b.text || '').trim())) out.push({ kind: 'answer' })
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The story of the latest turn: the tool steps since the last user message, each
|
|
107
|
+
// marked ok or failed by exact id, plus what is happening right now if anything.
|
|
108
|
+
const MAX_STEPS = 40
|
|
109
|
+
function turnSummary(events, { working = false } = {}) {
|
|
110
|
+
if (!Array.isArray(events) || !events.length) return { steps: [], turnStartedAt: null, current: null, answers: 0 }
|
|
111
|
+
let lastUser = -1
|
|
112
|
+
for (let i = events.length - 1; i >= 0; i--) if (events[i].kind === 'user') { lastUser = i; break }
|
|
113
|
+
const turn = events.slice(lastUser + 1)
|
|
114
|
+
const steps = []
|
|
115
|
+
const byId = new Map()
|
|
116
|
+
let answers = 0
|
|
117
|
+
for (const e of turn) {
|
|
118
|
+
if (e.kind === 'tool') {
|
|
119
|
+
const step = { t: e.tool, k: toolCategory(e.tool), ok: true, at: e.at, target: e.target || null }
|
|
120
|
+
steps.push(step)
|
|
121
|
+
if (e.id) byId.set(e.id, step)
|
|
122
|
+
} else if (e.kind === 'error') {
|
|
123
|
+
const step = e.id && byId.get(e.id)
|
|
124
|
+
if (step) step.ok = false
|
|
125
|
+
else steps.push({ t: e.tool || 'Tool', k: 'other', ok: false, at: e.at, target: null })
|
|
126
|
+
} else if (e.kind === 'answer') answers++
|
|
127
|
+
}
|
|
128
|
+
const lastEvent = turn[turn.length - 1]
|
|
129
|
+
// "Current" only while the session is working and the turn has not ended in text.
|
|
130
|
+
const current = working && steps.length && lastEvent && lastEvent.kind === 'tool' ? steps[steps.length - 1] : null
|
|
131
|
+
return {
|
|
132
|
+
steps: steps.slice(-MAX_STEPS).map(({ t, k, ok, target }) => ({ t, k, ok, target })),
|
|
133
|
+
turnStartedAt: lastUser >= 0 ? events[lastUser].at : (events[0] && events[0].at) || null,
|
|
134
|
+
current: current ? { t: current.t, target: current.target, at: current.at } : null,
|
|
135
|
+
last: !current && steps.length ? (({ t, target, at, ok }) => ({ t, target, at, ok }))(steps[steps.length - 1]) : null,
|
|
136
|
+
answers,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Pull the latest state records out of a transcript. Cached on size+mtime so an
|
|
141
|
+
// unchanged (idle) session costs one stat() per refresh instead of a full read.
|
|
142
|
+
function readTranscript(file) {
|
|
143
|
+
let st
|
|
144
|
+
try {
|
|
145
|
+
st = fs.statSync(file)
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
const key = `${st.size}:${st.mtimeMs}`
|
|
150
|
+
const hit = transcriptCache.get(file)
|
|
151
|
+
if (hit && hit.key === key) return hit.data
|
|
152
|
+
|
|
153
|
+
let text
|
|
154
|
+
try {
|
|
155
|
+
if (st.size <= MAX_TAIL_BYTES) {
|
|
156
|
+
text = fs.readFileSync(file, 'utf8')
|
|
157
|
+
} else {
|
|
158
|
+
const fd = fs.openSync(file, 'r')
|
|
159
|
+
const buf = Buffer.alloc(MAX_TAIL_BYTES)
|
|
160
|
+
fs.readSync(fd, buf, 0, MAX_TAIL_BYTES, st.size - MAX_TAIL_BYTES)
|
|
161
|
+
fs.closeSync(fd)
|
|
162
|
+
text = buf.toString('utf8')
|
|
163
|
+
text = text.slice(text.indexOf('\n') + 1) // drop the partial first line
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const data = {
|
|
170
|
+
latestResponse: null,
|
|
171
|
+
latestResponseAt: null,
|
|
172
|
+
links: [],
|
|
173
|
+
truncated: st.size > MAX_TAIL_BYTES,
|
|
174
|
+
title: null,
|
|
175
|
+
lastPrompt: null,
|
|
176
|
+
permissionMode: null,
|
|
177
|
+
mode: null,
|
|
178
|
+
model: null,
|
|
179
|
+
contextTokens: null,
|
|
180
|
+
outputTokens: 0,
|
|
181
|
+
messages: 0,
|
|
182
|
+
userTurns: 0,
|
|
183
|
+
oneM: false,
|
|
184
|
+
maxContext: 0,
|
|
185
|
+
firstTs: null,
|
|
186
|
+
lastTs: null,
|
|
187
|
+
// Recent events, classified for the row's activity strip. Stamps are kept raw
|
|
188
|
+
// and bucketed against "now" at request time, so the mtime cache stays valid.
|
|
189
|
+
events: [],
|
|
190
|
+
cwd: null,
|
|
191
|
+
gitBranch: null,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const line of text.split('\n')) {
|
|
195
|
+
if (!line || line[0] !== '{') continue
|
|
196
|
+
let d
|
|
197
|
+
try {
|
|
198
|
+
d = JSON.parse(line)
|
|
199
|
+
} catch {
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
if (d.isSidechain) continue
|
|
203
|
+
// The model field always reads "claude-opus-5"; only the transcript text
|
|
204
|
+
// carries the [1m] suffix that distinguishes a 1M-context session.
|
|
205
|
+
if (!data.oneM && line.indexOf('[1m]') !== -1 && /claude-[a-z0-9.-]+\[1m\]/.test(line)) data.oneM = true
|
|
206
|
+
if (d.cwd) data.cwd = d.cwd
|
|
207
|
+
if (d.gitBranch) data.gitBranch = d.gitBranch
|
|
208
|
+
if (d.timestamp) {
|
|
209
|
+
if (!data.firstTs) data.firstTs = d.timestamp
|
|
210
|
+
data.lastTs = d.timestamp
|
|
211
|
+
}
|
|
212
|
+
if (d.timestamp) {
|
|
213
|
+
const at = Date.parse(d.timestamp)
|
|
214
|
+
if (at) for (const event of classify(d)) {
|
|
215
|
+
data.events.push({ at, ...event })
|
|
216
|
+
if (data.events.length > 400) data.events.shift()
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Inspect visible conversation text only, excluding tool arguments and results.
|
|
220
|
+
if (d.type === 'assistant' || d.type === 'user') {
|
|
221
|
+
const content = d.message && d.message.content
|
|
222
|
+
const visible = typeof content === 'string' ? content : Array.isArray(content)
|
|
223
|
+
? content.filter(b => b.type === 'text').map(b => b.text || '').join('\n\n') : ''
|
|
224
|
+
if (d.type === 'assistant' && visible.trim()) {
|
|
225
|
+
data.latestResponse = visible.slice(-12000)
|
|
226
|
+
data.latestResponseAt = d.timestamp || null
|
|
227
|
+
}
|
|
228
|
+
for (const match of visible.matchAll(/https:\/\/(?:github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+|linear\.app\/[A-Za-z0-9_-]+\/issue\/[A-Za-z]+-\d+(?:\/[A-Za-z0-9_-]+)?)/g)) {
|
|
229
|
+
const url = match[0]
|
|
230
|
+
if (!data.links.some(link => link.url === url)) {
|
|
231
|
+
const pr = url.includes('github.com/')
|
|
232
|
+
data.links.push({ url, kind: pr ? 'pr' : 'linear', label: pr ? 'PR #' + url.split('/').pop() : url.match(/issue\/([A-Za-z]+-\d+)/)[1] })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
data.links = data.links.slice(-20)
|
|
236
|
+
}
|
|
237
|
+
switch (d.type) {
|
|
238
|
+
case 'ai-title':
|
|
239
|
+
if (d.aiTitle) data.title = d.aiTitle
|
|
240
|
+
break
|
|
241
|
+
case 'last-prompt':
|
|
242
|
+
if (d.lastPrompt) data.lastPrompt = d.lastPrompt
|
|
243
|
+
break
|
|
244
|
+
case 'permission-mode':
|
|
245
|
+
data.permissionMode = d.permissionMode
|
|
246
|
+
break
|
|
247
|
+
case 'mode':
|
|
248
|
+
data.mode = d.mode
|
|
249
|
+
break
|
|
250
|
+
case 'user':
|
|
251
|
+
data.userTurns++
|
|
252
|
+
data.messages++
|
|
253
|
+
break
|
|
254
|
+
case 'assistant': {
|
|
255
|
+
data.messages++
|
|
256
|
+
const m = d.message || {}
|
|
257
|
+
if (m.model) data.model = m.model
|
|
258
|
+
const u = m.usage
|
|
259
|
+
if (u) {
|
|
260
|
+
data.contextTokens =
|
|
261
|
+
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
|
|
262
|
+
if (data.contextTokens > data.maxContext) data.maxContext = data.contextTokens
|
|
263
|
+
data.outputTokens += u.output_tokens || 0
|
|
264
|
+
}
|
|
265
|
+
break
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
transcriptCache.set(file, { key, data })
|
|
271
|
+
return data
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Resolve the real branch from disk. jsonl gitBranch says "HEAD" inside worktrees.
|
|
275
|
+
function gitBranch(cwd) {
|
|
276
|
+
if (!cwd) return null
|
|
277
|
+
const hit = branchCache.get(cwd)
|
|
278
|
+
if (hit && Date.now() - hit.at < 30000) return hit.branch
|
|
279
|
+
let branch = null
|
|
280
|
+
try {
|
|
281
|
+
let gitPath = path.join(cwd, '.git')
|
|
282
|
+
const st = fs.statSync(gitPath)
|
|
283
|
+
if (st.isFile()) {
|
|
284
|
+
const m = fs.readFileSync(gitPath, 'utf8').match(/gitdir:\s*(.+)/)
|
|
285
|
+
if (m) gitPath = path.resolve(cwd, m[1].trim())
|
|
286
|
+
}
|
|
287
|
+
const head = fs.readFileSync(path.join(gitPath, 'HEAD'), 'utf8').trim()
|
|
288
|
+
branch = head.startsWith('ref: refs/heads/') ? head.slice(16) : head.slice(0, 7)
|
|
289
|
+
} catch {
|
|
290
|
+
branch = null
|
|
291
|
+
}
|
|
292
|
+
branchCache.set(cwd, { at: Date.now(), branch })
|
|
293
|
+
return branch
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function contextLimit(model, oneM, maxSeen) {
|
|
297
|
+
if (oneM) return 1000000
|
|
298
|
+
if (maxSeen > 200000) return 1000000 // usage above the standard window proves the 1M variant
|
|
299
|
+
return 200000
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// A session whose entrypoint is not the CLI was started by a program rather than by
|
|
303
|
+
// a person: an SDK run, a plugin's worker, a background indexer. The registry records
|
|
304
|
+
// no parent, so ownership is resolved through the process tree instead, and left
|
|
305
|
+
// unattributed when the chain leads to a daemon rather than to another session.
|
|
306
|
+
let treeCache = { at: 0, map: new Map() }
|
|
307
|
+
function processTree() {
|
|
308
|
+
if (Date.now() - treeCache.at < 4000) return treeCache.map
|
|
309
|
+
const map = new Map()
|
|
310
|
+
try {
|
|
311
|
+
const out = require('node:child_process').execFileSync('ps', ['-eo', 'pid=,ppid=,comm='], { encoding: 'utf8', timeout: 2000, maxBuffer: 4 << 20 })
|
|
312
|
+
for (const line of out.split('\n')) {
|
|
313
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/)
|
|
314
|
+
if (match) map.set(Number(match[1]), { ppid: Number(match[2]), comm: match[3].split('/').pop() })
|
|
315
|
+
}
|
|
316
|
+
} catch {}
|
|
317
|
+
treeCache = { at: Date.now(), map }
|
|
318
|
+
return map
|
|
319
|
+
}
|
|
320
|
+
function attributeBackground(sessions) {
|
|
321
|
+
const byPid = new Map(sessions.filter(s => s.pid).map(s => [s.pid, s]))
|
|
322
|
+
const tree = processTree()
|
|
323
|
+
for (const session of sessions) {
|
|
324
|
+
if (!session.pid || session.entrypoint === 'cli' || session.entrypoint === null) continue
|
|
325
|
+
session.background = true
|
|
326
|
+
let pid = tree.get(session.pid)?.ppid, hops = 0, comm = null
|
|
327
|
+
while (pid && pid > 1 && hops++ < 12) {
|
|
328
|
+
const owner = byPid.get(pid)
|
|
329
|
+
if (owner && owner !== session) {
|
|
330
|
+
session.spawnedByPid = owner.pid
|
|
331
|
+
session.spawnedByName = owner.name || owner.title || owner.shortId
|
|
332
|
+
break
|
|
333
|
+
}
|
|
334
|
+
comm = tree.get(pid)?.comm || comm
|
|
335
|
+
pid = tree.get(pid)?.ppid
|
|
336
|
+
}
|
|
337
|
+
// No owning session in the chain: name the program that is running it instead.
|
|
338
|
+
if (!session.spawnedByPid) session.spawnedByName = session.cwdShort?.includes('.claude-mem') ? 'claude-mem' : comm || 'a background program'
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function collect() {
|
|
343
|
+
let files = []
|
|
344
|
+
try {
|
|
345
|
+
files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json'))
|
|
346
|
+
} catch {
|
|
347
|
+
files = []
|
|
348
|
+
}
|
|
349
|
+
const index = transcriptIndex()
|
|
350
|
+
const now = Date.now()
|
|
351
|
+
const sessions = []
|
|
352
|
+
|
|
353
|
+
// The registry describes running processes, not saved conversations: Claude
|
|
354
|
+
// removes registrations on exit. Join it with durable transcripts so handoff
|
|
355
|
+
// remains possible after exit and after Fleet itself restarts.
|
|
356
|
+
const registrations = []
|
|
357
|
+
for (const f of files) {
|
|
358
|
+
try {
|
|
359
|
+
const meta = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'))
|
|
360
|
+
if (meta && typeof meta === 'object') registrations.push(meta)
|
|
361
|
+
} catch {}
|
|
362
|
+
}
|
|
363
|
+
const registered = new Set(registrations.map(meta => meta.sessionId))
|
|
364
|
+
for (const [sessionId, file] of index) {
|
|
365
|
+
if (registered.has(sessionId) || file.includes('observer-sessions')) continue
|
|
366
|
+
const t = readTranscript(file)
|
|
367
|
+
if (!t?.cwd || !t.messages || t.cwd.includes('observer-sessions')) continue
|
|
368
|
+
registrations.push({sessionId, cwd:t.cwd, startedAt:t.firstTs, status:'stopped'})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
for (const meta of registrations) {
|
|
372
|
+
const alive = isAlive(meta.pid)
|
|
373
|
+
const file = meta.sessionId ? index.get(meta.sessionId) : null
|
|
374
|
+
const t = file ? readTranscript(file) : null
|
|
375
|
+
|
|
376
|
+
const lastActivity = Math.max(
|
|
377
|
+
meta.updatedAt || 0,
|
|
378
|
+
t && t.lastTs ? Date.parse(t.lastTs) : 0
|
|
379
|
+
)
|
|
380
|
+
const cwd = (t && t.cwd) || meta.cwd || null
|
|
381
|
+
const rawStatus = meta.status || 'unknown'
|
|
382
|
+
|
|
383
|
+
let state
|
|
384
|
+
if (!alive) state = 'dead'
|
|
385
|
+
else if (rawStatus === 'busy') state = 'busy'
|
|
386
|
+
else if (now - lastActivity > STALE_MS) state = 'stale'
|
|
387
|
+
else state = 'idle'
|
|
388
|
+
|
|
389
|
+
const model = t && t.model
|
|
390
|
+
const ctx = t && t.contextTokens
|
|
391
|
+
|
|
392
|
+
sessions.push({
|
|
393
|
+
pid: meta.pid || null,
|
|
394
|
+
sessionId: meta.sessionId,
|
|
395
|
+
shortId: meta.sessionId ? meta.sessionId.slice(0, 8) : null,
|
|
396
|
+
name: meta.name || null,
|
|
397
|
+
state,
|
|
398
|
+
rawStatus,
|
|
399
|
+
alive,
|
|
400
|
+
kind: meta.kind || null,
|
|
401
|
+
entrypoint: meta.entrypoint || null,
|
|
402
|
+
version: meta.version || null,
|
|
403
|
+
cwd,
|
|
404
|
+
cwdShort: cwd ? cwd.replace(os.homedir(), '~') : null,
|
|
405
|
+
branch: gitBranch(cwd),
|
|
406
|
+
latestResponse: (t && t.latestResponse) || null,
|
|
407
|
+
latestResponseAt: (t && t.latestResponseAt) || null,
|
|
408
|
+
links: (t && t.links) || [],
|
|
409
|
+
transcriptTruncated: !!(t && t.truncated),
|
|
410
|
+
title: (t && t.title) || null,
|
|
411
|
+
lastPrompt: (t && t.lastPrompt) || null,
|
|
412
|
+
permissionMode: (t && t.permissionMode) || null,
|
|
413
|
+
model: model ? model + (t && (t.oneM || t.maxContext > 200000) ? ' [1m]' : '') : null,
|
|
414
|
+
contextTokens: ctx || null,
|
|
415
|
+
contextLimit: contextLimit(model, t && t.oneM, (t && t.maxContext) || 0),
|
|
416
|
+
outputTokens: t ? t.outputTokens : 0,
|
|
417
|
+
messages: t ? t.messages : 0,
|
|
418
|
+
userTurns: t ? t.userTurns : 0,
|
|
419
|
+
startedAt: meta.startedAt || null,
|
|
420
|
+
lastActivity: lastActivity || null,
|
|
421
|
+
turn: turnSummary(t && t.events, { working: state === 'busy' }),
|
|
422
|
+
hasSocket: fs.existsSync(path.join(SOCKS_DIR, `${meta.pid}.sock`)),
|
|
423
|
+
resumeCmd: meta.sessionId ? `claude --resume ${meta.sessionId}` : null,
|
|
424
|
+
transcript: file || null,
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
attributeBackground(sessions)
|
|
429
|
+
|
|
430
|
+
const order = { busy: 0, idle: 1, stale: 2, dead: 3 }
|
|
431
|
+
sessions.sort((a, b) => {
|
|
432
|
+
const d = (order[a.state] ?? 9) - (order[b.state] ?? 9)
|
|
433
|
+
return d !== 0 ? d : (b.lastActivity || 0) - (a.lastActivity || 0)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
const counts = { busy: 0, idle: 0, stale: 0, dead: 0 }
|
|
437
|
+
for (const s of sessions) counts[s.state] = (counts[s.state] || 0) + 1
|
|
438
|
+
|
|
439
|
+
return { generatedAt: now, counts, total: sessions.length, sessions }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Look up a transcript by session id without going through the process registry.
|
|
443
|
+
// Both caches apply, so an idle session costs one stat().
|
|
444
|
+
function transcriptFor(sessionId) {
|
|
445
|
+
if (!sessionId) return null
|
|
446
|
+
const file = transcriptIndex().get(sessionId)
|
|
447
|
+
return file ? readTranscript(file) : null
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
module.exports = { collect, gitBranch, transcriptFor, turnSummary, toolTarget, toolCategory }
|