@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.
@@ -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 }