@ucsandman/legcli 0.9.0 → 0.10.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,161 @@
1
+ // Claude Code discovery — what `~/.claude` (CLAUDE_CONFIG_DIR) keeps, read only.
2
+ // Observed live, Claude Code 2.1.237–2.1.273 (2026-09-16, 798 transcripts):
3
+ // projects/<encoded cwd>/<sessionId>.jsonl one transcript per session. Only
4
+ // user/assistant/attachment/system lines carry cwd, gitBranch, sessionId,
5
+ // version and timestamp; control lines carry type + sessionId + payload:
6
+ // `custom-title` {customTitle} and `ai-title` {aiTitle} (repeated as the
7
+ // title changes, the LAST one is current), `worktree-state`
8
+ // {worktreeSession:{originalCwd, worktreePath, worktreeBranch, originalBranch}},
9
+ // `history-suppression` (Claude Code hides that session itself). Sidechain
10
+ // (subagent) lines and the <sessionId>/ directory beside the file
11
+ // (subagents/, tool-results/) are not the conversation and are not listed.
12
+ // history.jsonl {display, timestamp, project, sessionId} per prompt:
13
+ // the cheap turn count and the fallback title, one pass, keyed by sessionId.
14
+ // sessions/<pid>.json {pid, sessionId, cwd, status} for a running process:
15
+ // the only "is it live" signal, and it goes stale, so the pid is checked.
16
+ // The project directory name is Claude's own (lossy) encoding of the cwd and
17
+ // is never decoded here: the cwd comes from the transcript lines themselves.
18
+ import { join } from 'node:path'
19
+ import { LAYOUT } from '../../accounts.mjs'
20
+ import { pidAlive } from '../../sessions.mjs'
21
+ import { messagesFromLines } from '../../taps/claude.mjs'
22
+ import { readHead, readTail, jsonLines, line, isoOrNull, isoFromMs, safeList, safeStat, safeRead, PROMPT_MAX } from '../common.mjs'
23
+
24
+ export const name = 'claude'
25
+ export const label = 'Claude Code'
26
+ export const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
27
+
28
+ export function root(homes = {}) { return homes.claude ?? LAYOUT.claude.home() }
29
+
30
+ function textOf(content) {
31
+ if (typeof content === 'string') return content
32
+ if (Array.isArray(content)) return content.filter((c) => c?.type === 'text' && c.text).map((c) => c.text).join('\n')
33
+ return ''
34
+ }
35
+
36
+ // One transcript's metadata from its head and tail; null when the file is not
37
+ // a conversation (no line names a session).
38
+ function parseTranscript(path, st) {
39
+ const head = jsonLines(readHead(path))
40
+ const tail = jsonLines(readTail(path))
41
+ let sessionId = null; let cwd = null; let branch = null; let version = null; let startedAt = null; let firstPrompt = null
42
+ const titles = { custom: null, ai: null }
43
+ let worktree = null; let hidden = false
44
+ const control = (j) => {
45
+ if (j.type === 'custom-title' && typeof j.customTitle === 'string') titles.custom = line(j.customTitle)
46
+ else if (j.type === 'ai-title' && typeof j.aiTitle === 'string') titles.ai = line(j.aiTitle)
47
+ else if (j.type === 'worktree-state' && j.worktreeSession && typeof j.worktreeSession.worktreePath === 'string') worktree = { path: j.worktreeSession.worktreePath, branch: j.worktreeSession.worktreeBranch ?? null, original_cwd: j.worktreeSession.originalCwd ?? null, original_branch: j.worktreeSession.originalBranch ?? null }
48
+ else if (j.type === 'history-suppression') hidden = true
49
+ }
50
+ for (const j of head) {
51
+ if (!sessionId && typeof j.sessionId === 'string') sessionId = j.sessionId
52
+ control(j)
53
+ if (j.type !== 'user' && j.type !== 'assistant') continue
54
+ if (j.isSidechain) continue
55
+ if (!cwd && typeof j.cwd === 'string') cwd = j.cwd
56
+ if (!branch && typeof j.gitBranch === 'string' && j.gitBranch) branch = j.gitBranch
57
+ if (!version && typeof j.version === 'string') version = j.version
58
+ if (!startedAt && j.timestamp) startedAt = isoOrNull(j.timestamp)
59
+ if (!firstPrompt && j.type === 'user') {
60
+ const text = textOf(j.message?.content).trim()
61
+ if (text && !/^<[a-z-]+>/.test(text)) firstPrompt = line(text, PROMPT_MAX)
62
+ }
63
+ }
64
+ // the tail is where the current title and the latest worktree move sit
65
+ for (const j of tail) control(j)
66
+ if (!sessionId) return null
67
+ let updatedAt = null
68
+ for (const j of [...tail].reverse()) { if (j.timestamp) { updatedAt = isoOrNull(j.timestamp); if (updatedAt) break } }
69
+ return {
70
+ native_id: sessionId,
71
+ cwd, branch,
72
+ title: titles.custom ?? titles.ai ?? firstPrompt ?? null,
73
+ started_at: startedAt ?? isoFromMs(st.birthtimeMs) ?? isoFromMs(st.mtimeMs),
74
+ updated_at: updatedAt ?? isoFromMs(st.mtimeMs),
75
+ transcript_path: path,
76
+ size_bytes: st.size,
77
+ native: { version, kind: 'transcript', hidden, worktree },
78
+ }
79
+ }
80
+
81
+ // history.jsonl → prompts per session (count, first display), re-read only
82
+ // when the file changed.
83
+ function promptIndex(home, prev) {
84
+ const f = join(home, 'history.jsonl')
85
+ const st = safeStat(f)
86
+ if (!st) return { key: null, map: {} }
87
+ const key = `${st.mtimeMs}:${st.size}`
88
+ if (prev?.key === key) return prev
89
+ const map = {}
90
+ for (const j of jsonLines(safeRead(f))) {
91
+ if (typeof j.sessionId !== 'string') continue
92
+ const cur = map[j.sessionId] ?? (map[j.sessionId] = { count: 0, first: null })
93
+ cur.count += 1
94
+ if (!cur.first && typeof j.display === 'string' && j.display && !j.display.startsWith('/') && !j.display.startsWith('[Pasted')) cur.first = line(j.display, PROMPT_MAX)
95
+ }
96
+ return { key, map }
97
+ }
98
+
99
+ // sessions/<pid>.json → id → status for every process still alive.
100
+ export function liveIds(home) {
101
+ const out = new Map()
102
+ for (const d of safeList(join(home, 'sessions'))) {
103
+ if (!d.isFile() || !d.name.endsWith('.json')) continue
104
+ const j = (() => { try { return JSON.parse(safeRead(join(home, 'sessions', d.name)) ?? '') } catch { return null } })()
105
+ if (j && typeof j.sessionId === 'string' && pidAlive(j.pid)) out.set(j.sessionId, typeof j.status === 'string' ? j.status : 'busy')
106
+ }
107
+ return out
108
+ }
109
+
110
+ // { entries: { [transcript path]: { mtime, size, record } }, aux, scanned, parsed }
111
+ export function scan({ home, prev = {} }) {
112
+ const projects = join(home, 'projects')
113
+ const entries = {}
114
+ let scanned = 0; let parsed = 0
115
+ const before = prev.entries ?? {}
116
+ for (const dir of safeList(projects)) {
117
+ if (!dir.isDirectory()) continue // a junction or symlink is never followed
118
+ const pdir = join(projects, dir.name)
119
+ for (const f of safeList(pdir)) {
120
+ if (!f.isFile() || !f.name.endsWith('.jsonl')) continue
121
+ const path = join(pdir, f.name)
122
+ const st = safeStat(path)
123
+ if (!st) continue
124
+ scanned += 1
125
+ const old = before[path]
126
+ if (old && old.mtime === st.mtimeMs && old.size === st.size) { entries[path] = old; continue }
127
+ const record = parseTranscript(path, st)
128
+ parsed += 1
129
+ if (record) entries[path] = { mtime: st.mtimeMs, size: st.size, record }
130
+ }
131
+ }
132
+ const prompts = promptIndex(home, prev.aux?.prompts)
133
+ const live = liveIds(home)
134
+ for (const e of Object.values(entries)) {
135
+ const r = e.record
136
+ const p = prompts.map[r.native_id]
137
+ r.turns = p?.count ?? null
138
+ if (!r.title && p?.first) r.title = p.first
139
+ r.live = live.has(r.native_id)
140
+ r.native.status = live.get(r.native_id) ?? null
141
+ }
142
+ return { entries, aux: { prompts }, scanned, parsed }
143
+ }
144
+
145
+ // The last messages, from the tail of the file only: a transcript can run to
146
+ // tens of megabytes and the drawer wants eight lines of it.
147
+ export function messages(record, limit = 8) {
148
+ const text = readTail(record.transcript_path, 4 * 1024 * 1024)
149
+ return messagesFromLines(text.split('\n'), limit)
150
+ }
151
+
152
+ // `claude --resume <session-id>` (claude --help, 2.1.273: "-r, --resume [value]
153
+ // Resume a conversation by session ID"). The picker filters by cwd, and the
154
+ // id form does not, but the conversation's files live in its cwd, so that is
155
+ // where Leg starts it.
156
+ export function resume(record) {
157
+ if (!ID_RE.test(record.native_id)) return { supported: false, reason: 'the session id is not one claude --resume accepts' }
158
+ return { supported: true, agent: 'claude', args: ['--resume', record.native_id] }
159
+ }
160
+
161
+ export const transcript = 'supported'
@@ -0,0 +1,133 @@
1
+ // Codex discovery — what `~/.codex` (CODEX_HOME) keeps, read only.
2
+ // Observed live, codex-cli 0.84.0–0.154.0 (2026-09-16, 358 rollouts):
3
+ // sessions/YYYY/MM/DD/rollout-<local stamp>-<uuid>.jsonl one thread per file;
4
+ // the first line is session_meta {id (this thread), session_id (the root
5
+ // thread), cwd, timestamp (UTC), cli_version, originator, source, thread_source,
6
+ // git {branch, commit_hash, repository_url}} (src/taps/codex.mjs readMeta
7
+ // reads it bounded). A subagent thread has source {subagent: {...}} (older
8
+ // versions: thread_source "subagent") and is listed only on request.
9
+ // `base_instructions.text` is the system prompt and never surfaces.
10
+ // session_index.jsonl {id, thread_name, updated_at}: the title Codex gave
11
+ // (sparse: not every thread is in it).
12
+ // history.jsonl {session_id, ts (seconds), text} per prompt: the turn count.
13
+ // Codex keeps no "live" marker Leg can read; `live` stays null (unknown).
14
+ import { join } from 'node:path'
15
+ import { LAYOUT } from '../../accounts.mjs'
16
+ import { readMeta, parseLines } from '../../taps/codex.mjs'
17
+ import { readHead, readTail, jsonLines, line, isoOrNull, isoFromMs, safeList, safeStat, safeRead, PROMPT_MAX } from '../common.mjs'
18
+
19
+ export const name = 'codex'
20
+ export const label = 'Codex'
21
+ export const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
22
+
23
+ export function root(homes = {}) { return homes.codex ?? LAYOUT.codex.home() }
24
+
25
+ // only what a human said and what the agent answered: a `developer` message
26
+ // is injected harness text (AGENTS.md, model notices), never shown
27
+ function conversationLines(lines) {
28
+ return lines.filter((l) => {
29
+ if (!l.includes('"role"')) return true
30
+ try { const j = JSON.parse(l); const p = j.payload ?? {}; return !(j.type === 'response_item' && p.type === 'message' && p.role !== 'user' && p.role !== 'assistant') } catch { return false }
31
+ })
32
+ }
33
+
34
+ function parseRollout(path, st) {
35
+ const headText = readHead(path)
36
+ let meta = readMeta(path)
37
+ if (!meta) { // a BOM or a torn first line: try the bounded head ourselves
38
+ const first = jsonLines(headText.split('\n')[0] ?? '')[0]
39
+ meta = first?.type === 'session_meta' ? first.payload : null
40
+ }
41
+ if (!meta || typeof meta.id !== 'string') return null
42
+ const parsed = parseLines(conversationLines(headText.split('\n').slice(1)))
43
+ const first = parsed.messages.find((m) => m.role === 'user')
44
+ let updatedAt = null
45
+ for (const j of jsonLines(readTail(path)).reverse()) { if (j.timestamp) { updatedAt = isoOrNull(j.timestamp); if (updatedAt) break } }
46
+ const subagent = (typeof meta.source === 'object' && meta.source !== null && Boolean(meta.source.subagent)) || meta.thread_source === 'subagent'
47
+ return {
48
+ native_id: meta.id,
49
+ cwd: typeof meta.cwd === 'string' ? meta.cwd : null,
50
+ branch: typeof meta.git?.branch === 'string' ? meta.git.branch : null,
51
+ title: first ? line(first.text, PROMPT_MAX) : null, // session_index wins below when it names this thread
52
+ started_at: isoOrNull(meta.timestamp) ?? isoFromMs(st.birthtimeMs) ?? isoFromMs(st.mtimeMs),
53
+ updated_at: updatedAt ?? isoFromMs(st.mtimeMs),
54
+ transcript_path: path,
55
+ size_bytes: st.size,
56
+ native: {
57
+ version: meta.cli_version ?? null, originator: meta.originator ?? null, kind: subagent ? 'subagent' : 'thread', subagent,
58
+ root_id: typeof meta.session_id === 'string' ? meta.session_id : null,
59
+ parent_id: meta.parent_thread_id ?? meta.source?.subagent?.thread_spawn?.parent_thread_id ?? null,
60
+ commit: meta.git?.commit_hash ?? null, remote: meta.git?.repository_url ?? null,
61
+ },
62
+ }
63
+ }
64
+
65
+ function fileIndex(f, prev, fold) {
66
+ const st = safeStat(f)
67
+ if (!st) return { key: null, map: {} }
68
+ const key = `${st.mtimeMs}:${st.size}`
69
+ if (prev?.key === key) return prev
70
+ const map = {}
71
+ for (const j of jsonLines(safeRead(f))) fold(map, j)
72
+ return { key, map }
73
+ }
74
+
75
+ export function scan({ home, prev = {} }) {
76
+ const root = join(home, 'sessions')
77
+ const entries = {}
78
+ let scanned = 0; let parsed = 0
79
+ const before = prev.entries ?? {}
80
+ const digits = (d) => d.isDirectory() && /^\d+$/.test(d.name)
81
+ for (const y of safeList(root).filter(digits)) {
82
+ for (const m of safeList(join(root, y.name)).filter(digits)) {
83
+ for (const d of safeList(join(root, y.name, m.name)).filter(digits)) {
84
+ const dir = join(root, y.name, m.name, d.name)
85
+ for (const f of safeList(dir)) {
86
+ if (!f.isFile() || !f.name.startsWith('rollout-') || !f.name.endsWith('.jsonl')) continue
87
+ const path = join(dir, f.name)
88
+ const st = safeStat(path)
89
+ if (!st) continue
90
+ scanned += 1
91
+ const old = before[path]
92
+ if (old && old.mtime === st.mtimeMs && old.size === st.size) { entries[path] = old; continue }
93
+ const record = parseRollout(path, st)
94
+ parsed += 1
95
+ if (record) entries[path] = { mtime: st.mtimeMs, size: st.size, record }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ const titles = fileIndex(join(home, 'session_index.jsonl'), prev.aux?.titles, (map, j) => { if (typeof j.id === 'string' && typeof j.thread_name === 'string') map[j.id] = line(j.thread_name) })
101
+ const turns = fileIndex(join(home, 'history.jsonl'), prev.aux?.turns, (map, j) => { if (typeof j.session_id === 'string') map[j.session_id] = (map[j.session_id] ?? 0) + 1 })
102
+ for (const e of Object.values(entries)) {
103
+ const r = e.record
104
+ if (titles.map[r.native_id]) r.title = titles.map[r.native_id]
105
+ r.turns = turns.map[r.native_id] ?? null
106
+ r.live = null
107
+ }
108
+ return { entries, aux: { titles, turns }, scanned, parsed }
109
+ }
110
+
111
+ export function messages(record, limit = 8) {
112
+ const text = readTail(record.transcript_path, 4 * 1024 * 1024)
113
+ // task_complete repeats the last agent message the response_item already
114
+ // carried: one turn, one row
115
+ const out = []
116
+ for (const m of parseLines(conversationLines(text.split('\n').filter(Boolean))).messages) {
117
+ const prev = out[out.length - 1]
118
+ if (prev && prev.role === m.role && prev.text === m.text) continue
119
+ out.push(m)
120
+ }
121
+ return out.slice(-limit)
122
+ }
123
+
124
+ // `codex resume <SESSION_ID>` (codex resume --help, 0.154.0: "Session id (UUID)
125
+ // or session name"; an explicit id bypasses the cwd-filtered picker). A
126
+ // subagent thread is not a session a human can resume.
127
+ export function resume(record) {
128
+ if (record.native?.subagent) return { supported: false, reason: 'a subagent thread belongs to the thread that spawned it' }
129
+ if (!ID_RE.test(record.native_id)) return { supported: false, reason: 'the thread id is not one codex resume accepts' }
130
+ return { supported: true, agent: 'codex', args: ['resume', record.native_id] }
131
+ }
132
+
133
+ export const transcript = 'supported'
@@ -0,0 +1,94 @@
1
+ // GitHub Copilot CLI discovery — what `~/.copilot` keeps, read only. Copilot
2
+ // is a provider Leg can DISCOVER without being one it can supervise or hand
3
+ // off to: it lists here, its transcript reads here, and `continue` says no.
4
+ // Observed live, copilot 1.0.80 (2026-09-16):
5
+ // session-state/<sessionId>/workspace.yaml flat `key: value` lines: id, cwd,
6
+ // git_root, repository, branch, name, user_named, created_at, updated_at
7
+ // session-state/<sessionId>/events.jsonl {type, data, id, timestamp, parentId};
8
+ // user.message {data.content} and assistant.message {data.content} are the
9
+ // conversation; session.start.data.context carries cwd, gitRoot, branch
10
+ // session.db beside them is SQLite and is not opened.
11
+ import { join } from 'node:path'
12
+ import { homedir } from 'node:os'
13
+ import { readTail, jsonLines, line, isoOrNull, isoFromMs, safeList, safeStat, safeRead } from '../common.mjs'
14
+
15
+ export const name = 'copilot'
16
+ export const label = 'Copilot CLI'
17
+ export const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
18
+
19
+ export function root(homes = {}) { return homes.copilot ?? process.env.COPILOT_HOME ?? join(homedir(), '.copilot') }
20
+
21
+ // the flat YAML copilot writes: one `key: value` per line, no nesting, no quoting
22
+ function flatYaml(text) {
23
+ const out = {}
24
+ for (const raw of String(text ?? '').split(/\r?\n/)) {
25
+ const m = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(raw)
26
+ if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '')
27
+ }
28
+ return out
29
+ }
30
+
31
+ function parseSession(dir, st) {
32
+ const y = flatYaml(safeRead(join(dir, 'workspace.yaml')))
33
+ if (typeof y.id !== 'string' || !y.id) return null
34
+ const events = join(dir, 'events.jsonl')
35
+ const est = safeStat(events)
36
+ return {
37
+ native_id: y.id,
38
+ cwd: y.cwd || null,
39
+ branch: y.branch || null,
40
+ title: line(y.name ?? '') || null,
41
+ started_at: isoOrNull(y.created_at) ?? isoFromMs(st.birthtimeMs) ?? isoFromMs(st.mtimeMs),
42
+ updated_at: isoOrNull(y.updated_at) ?? isoFromMs(est?.mtimeMs) ?? isoFromMs(st.mtimeMs),
43
+ transcript_path: est ? events : null,
44
+ size_bytes: est?.size ?? 0,
45
+ turns: null,
46
+ live: null,
47
+ native: { version: null, kind: 'session', repository: y.repository || null, git_root: y.git_root || null, user_named: y.user_named === 'true' },
48
+ }
49
+ }
50
+
51
+ export function scan({ home, prev = {} }) {
52
+ const root = join(home, 'session-state')
53
+ const entries = {}
54
+ let scanned = 0; let parsed = 0
55
+ const before = prev.entries ?? {}
56
+ for (const d of safeList(root)) {
57
+ if (!d.isDirectory()) continue
58
+ const dir = join(root, d.name)
59
+ const ws = join(dir, 'workspace.yaml')
60
+ const st = safeStat(ws)
61
+ if (!st) continue
62
+ scanned += 1
63
+ const old = before[ws]
64
+ // the yaml's updated_at moves with the session, so its stat is the key
65
+ if (old && old.mtime === st.mtimeMs && old.size === st.size) { entries[ws] = old; continue }
66
+ const record = parseSession(dir, st)
67
+ parsed += 1
68
+ if (record) entries[ws] = { mtime: st.mtimeMs, size: st.size, record }
69
+ }
70
+ return { entries, aux: {}, scanned, parsed }
71
+ }
72
+
73
+ export function messagesFromLines(lines, limit = 8) {
74
+ const out = []
75
+ for (const j of jsonLines(lines.join('\n'))) {
76
+ if (j.type !== 'user.message' && j.type !== 'assistant.message') continue
77
+ const text = String(j.data?.content ?? '').trim()
78
+ if (!text) continue
79
+ out.push({ role: j.type === 'user.message' ? 'user' : 'assistant', text: text.slice(0, 1500), ts: j.timestamp ?? null })
80
+ }
81
+ return out.slice(-limit)
82
+ }
83
+
84
+ export function messages(record, limit = 8) {
85
+ if (!record.transcript_path) return []
86
+ return messagesFromLines(readTail(record.transcript_path, 4 * 1024 * 1024).split('\n'), limit)
87
+ }
88
+
89
+ // copilot --help (1.0.80) has --continue (the most recent session) and
90
+ // --connect[=sessionId] (a remote session); no resume-by-id Leg has verified,
91
+ // and copilot is not an agent `leg <agent>` supervises.
92
+ export function resume() { return { supported: false, reason: 'copilot is not an agent Leg supervises, and no resume-by-id flag is verified' } }
93
+
94
+ export const transcript = 'supported'
@@ -0,0 +1,138 @@
1
+ // Grok discovery — what `~/.grok` (GROK_HOME) keeps, read only.
2
+ // Observed live, grok 4.6 build CLI (2026-09-16):
3
+ // sessions/<url-encoded cwd>/<session-id>/summary.json {info:{id,cwd},
4
+ // created_at, updated_at, last_active_at, generated_title, session_summary,
5
+ // git_root_dir, head_branch, head_commit, num_chat_messages, current_model_id}
6
+ // sessions/<url-encoded cwd>/<session-id>/chat_history.jsonl lines
7
+ // {type:'user', content:[{type:'text',text}]} and {type:'assistant',
8
+ // content:'…'} (plus system and reasoning lines, skipped)
9
+ // sessions/<url-encoded cwd>/prompt_history.jsonl {timestamp, session_id,
10
+ // prompt} per prompt: the turn count and the first prompt
11
+ // active_sessions.json the sessions a running grok has open (an array;
12
+ // empty when none is running)
13
+ // The directory name is grok's own encoding of the cwd and is never decoded:
14
+ // summary.json names the cwd itself.
15
+ import { join, resolve } from 'node:path'
16
+ import { LAYOUT } from '../../accounts.mjs'
17
+ import { readTail, jsonLines, line, isoOrNull, isoFromMs, safeList, safeStat, safeRead, PROMPT_MAX } from '../common.mjs'
18
+
19
+ export const name = 'grok'
20
+ export const label = 'Grok'
21
+ export const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
22
+
23
+ export function root(homes = {}) { return homes.grok ?? LAYOUT.grok.home() }
24
+
25
+ function readJson(p) { try { return JSON.parse(safeRead(p) ?? '') } catch { return null } }
26
+
27
+ function parseSummary(dir, st) {
28
+ const j = readJson(join(dir, 'summary.json'))
29
+ const id = j?.info?.id ?? j?.id
30
+ if (!j || typeof id !== 'string') return null
31
+ const gitRoot = typeof j.git_root_dir === 'string' ? resolve(j.git_root_dir) : null
32
+ return {
33
+ native_id: id,
34
+ cwd: typeof j.info?.cwd === 'string' ? j.info.cwd : null,
35
+ branch: typeof j.head_branch === 'string' ? j.head_branch : null,
36
+ title: line(j.generated_title ?? j.session_summary ?? '') || null,
37
+ started_at: isoOrNull(j.created_at) ?? isoFromMs(st.birthtimeMs),
38
+ updated_at: isoOrNull(j.last_active_at ?? j.updated_at) ?? isoFromMs(st.mtimeMs),
39
+ transcript_path: join(dir, 'chat_history.jsonl'),
40
+ size_bytes: safeStat(join(dir, 'chat_history.jsonl'))?.size ?? 0,
41
+ // session_kind: absent for an interactive session, "subagent" (listed only
42
+ // on request), "headless" (listed: a human asked for it)
43
+ native: { version: j.chat_format_version ?? null, model: j.current_model_id ?? null, git_root_dir: gitRoot, head_commit: j.head_commit ?? null, messages: Number.isFinite(j.num_chat_messages) ? j.num_chat_messages : null, kind: typeof j.session_kind === 'string' ? j.session_kind : 'session', subagent: j.session_kind === 'subagent' },
44
+ }
45
+ }
46
+
47
+ // prompt_history.jsonl for one cwd dir → { [session_id]: { count, first } }
48
+ function promptIndex(f, prev) {
49
+ const st = safeStat(f)
50
+ if (!st) return { key: null, map: {} }
51
+ const key = `${st.mtimeMs}:${st.size}`
52
+ if (prev?.key === key) return prev
53
+ const map = {}
54
+ for (const j of jsonLines(safeRead(f))) {
55
+ if (typeof j.session_id !== 'string') continue
56
+ const cur = map[j.session_id] ?? (map[j.session_id] = { count: 0, first: null })
57
+ cur.count += 1
58
+ if (!cur.first && typeof j.prompt === 'string' && !j.prompt.startsWith('/')) cur.first = line(j.prompt, PROMPT_MAX)
59
+ }
60
+ return { key, map }
61
+ }
62
+
63
+ export function liveIds(home) {
64
+ const j = readJson(join(home, 'active_sessions.json'))
65
+ const out = new Set()
66
+ const push = (v) => { if (typeof v === 'string') out.add(v); else if (v && typeof v === 'object') { const id = v.id ?? v.session_id ?? v.info?.id; if (typeof id === 'string') out.add(id) } }
67
+ if (Array.isArray(j)) j.forEach(push)
68
+ else if (j && typeof j === 'object') { for (const [k, v] of Object.entries(j)) { if (Array.isArray(v)) v.forEach(push); else push(k) } }
69
+ return out
70
+ }
71
+
72
+ export function scan({ home, prev = {} }) {
73
+ const root = join(home, 'sessions')
74
+ const entries = {}
75
+ const prompts = {}
76
+ let scanned = 0; let parsed = 0
77
+ const before = prev.entries ?? {}
78
+ for (const cwdDir of safeList(root)) {
79
+ if (!cwdDir.isDirectory()) continue
80
+ const cdir = join(root, cwdDir.name)
81
+ const pi = promptIndex(join(cdir, 'prompt_history.jsonl'), prev.aux?.prompts?.[cwdDir.name])
82
+ prompts[cwdDir.name] = pi
83
+ for (const s of safeList(cdir)) {
84
+ if (!s.isDirectory()) continue
85
+ const dir = join(cdir, s.name)
86
+ const summary = join(dir, 'summary.json')
87
+ const st = safeStat(summary)
88
+ if (!st) continue
89
+ scanned += 1
90
+ const old = before[summary]
91
+ let entry = old && old.mtime === st.mtimeMs && old.size === st.size ? old : null
92
+ if (!entry) {
93
+ const record = parseSummary(dir, st)
94
+ parsed += 1
95
+ if (!record) continue
96
+ entry = { mtime: st.mtimeMs, size: st.size, record }
97
+ }
98
+ const p = pi.map[entry.record.native_id]
99
+ entry.record.turns = p?.count ?? null
100
+ if (!entry.record.title) entry.record.title = p?.first ?? null
101
+ entries[summary] = entry
102
+ }
103
+ }
104
+ const live = liveIds(home)
105
+ for (const e of Object.values(entries)) e.record.live = live.has(e.record.native_id)
106
+ return { entries, aux: { prompts }, scanned, parsed }
107
+ }
108
+
109
+ function textOf(content) {
110
+ if (typeof content === 'string') return content
111
+ if (Array.isArray(content)) return content.filter((c) => c?.type === 'text' && c.text).map((c) => c.text).join('\n')
112
+ return ''
113
+ }
114
+
115
+ export function messagesFromLines(lines, limit = 8) {
116
+ const out = []
117
+ for (const j of jsonLines(lines.join('\n'))) {
118
+ if (j.type !== 'user' && j.type !== 'assistant') continue
119
+ const text = textOf(j.content).trim()
120
+ if (!text || /^<[a-z_-]+>/i.test(text)) continue
121
+ out.push({ role: j.type, text: text.slice(0, 1500), ts: j.timestamp ?? null })
122
+ }
123
+ return out.slice(-limit)
124
+ }
125
+
126
+ export function messages(record, limit = 8) {
127
+ return messagesFromLines(readTail(record.transcript_path, 4 * 1024 * 1024).split('\n'), limit)
128
+ }
129
+
130
+ // `grok --resume <SESSION_ID>` (grok --help: "-r, --resume [<SESSION_ID_OR_TITLE>]
131
+ // Resume a session by ID or title"; a UUID always means the id).
132
+ export function resume(record) {
133
+ if (record.native?.subagent) return { supported: false, reason: 'a subagent session belongs to the session that spawned it' }
134
+ if (!ID_RE.test(record.native_id)) return { supported: false, reason: 'the session id is not one grok --resume accepts' }
135
+ return { supported: true, agent: 'grok', args: ['--resume', record.native_id] }
136
+ }
137
+
138
+ export const transcript = 'supported'
@@ -0,0 +1,116 @@
1
+ // worktrees — every checkout Leg can see, in one list: what git lists for each
2
+ // repository it knows about, what its own sessions and cards cut, and what
3
+ // the discovered conversations were working in. Read only: nothing here
4
+ // prunes, removes or touches a worktree; src/worktree.mjs keeps that job, for
5
+ // Leg's own worktrees only, unchanged.
6
+ import { existsSync, statSync } from 'node:fs'
7
+ import { execFileSync } from 'node:child_process'
8
+ import { listSessions, isActive } from '../sessions.mjs'
9
+ import { listCards } from '../store.mjs'
10
+ import { parseWorktreeList } from '../worktree.mjs'
11
+ import { listHistory } from './index.mjs'
12
+ import { repoNameOf, canonOrNull } from './common.mjs'
13
+
14
+ export const STALE_DAYS = 14
15
+ export const DIRTY_LIMIT = 40
16
+ export const REPO_LIMIT = 40
17
+
18
+ const LEG_DIR_RE = /[\\/]\.(?:leg|baton)-worktrees[\\/]([^\\/]+)$/i
19
+
20
+ function dirtyOf(path, timeoutMs = 5000) {
21
+ try {
22
+ const out = execFileSync('git', ['-c', 'core.fsmonitor=', '-c', 'core.hooksPath=', '-C', path, 'status', '--porcelain'], { windowsHide: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, MSYS_NO_PATHCONV: '1' }, stdio: ['ignore', 'pipe', 'ignore'] })
23
+ return out.split(/\r?\n/).filter(Boolean).length
24
+ } catch { return null }
25
+ }
26
+
27
+ // git's own list for one repository, stderr dropped: a repo that is not one
28
+ // any more ("fatal: not a git repository") is an empty list, not a line on the
29
+ // operator's terminal.
30
+ function gitWorktrees(repo, timeoutMs = 5000) {
31
+ try {
32
+ const out = execFileSync('git', ['-c', 'core.fsmonitor=', '-c', 'core.hooksPath=', '-C', repo, 'worktree', 'list', '--porcelain'], { windowsHide: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, MSYS_NO_PATHCONV: '1' }, stdio: ['ignore', 'pipe', 'ignore'] })
33
+ return parseWorktreeList(out)
34
+ } catch { return [] }
35
+ }
36
+
37
+ const mtimeOf = (p) => { try { return statSync(p).mtimeMs } catch { return null } }
38
+ const at = (iso) => (iso ? Date.parse(iso) || 0 : 0)
39
+
40
+ // { worktrees: [...], repos, ts }. `dirty` runs git status on up to
41
+ // `dirtyLimit` existing checkouts (null past the cap, so the board never waits
42
+ // on two hundred of them). `records`/`sessions`/`cards` are for tests.
43
+ export function listWorktrees({ dirty = true, dirtyLimit = DIRTY_LIMIT, repoLimit = REPO_LIMIT, repo = null, sessions = null, cards = null, records = null, homes = null, now = Date.now() } = {}) {
44
+ const sess = sessions ?? listSessions()
45
+ const crd = cards ?? listCards()
46
+ const recs = records ?? listHistory({ limit: 0, homes, includeSubagents: true }).records
47
+ const repos = new Map()
48
+ const addRepo = (p) => { const c = canonOrNull(p); if (c && !repos.has(c) && existsSync(p)) repos.set(c, p) }
49
+ for (const s of sess) addRepo(s.repo)
50
+ for (const c of crd) addRepo(c.repo)
51
+ for (const r of recs) addRepo(r.repo)
52
+ const only = repo ? canonOrNull(repo) : null
53
+ if (only) for (const k of [...repos.keys()]) if (k !== only) repos.delete(k)
54
+
55
+ const rows = new Map() // canon path → row
56
+ const row = (path, repoPath, extra = {}) => {
57
+ const key = canonOrNull(path)
58
+ if (!key) return null
59
+ if (only && canonOrNull(repoPath) !== only) return null
60
+ if (!rows.has(key)) rows.set(key, { path, repo: repoPath, repo_name: repoNameOf(repoPath), branch: null, head: null, exists: existsSync(path), main: false, listed_by_git: false, owner: { kind: 'external' }, conversations: { count: 0, latest: [], last_at: null }, dirty: null, stale: false, orphaned: false })
61
+ return Object.assign(rows.get(key), extra)
62
+ }
63
+ let repoCount = 0
64
+ for (const [, repoPath] of repos) {
65
+ if (repoCount >= repoLimit) break
66
+ repoCount += 1
67
+ gitWorktrees(repoPath).forEach((w, i) => row(w.path, repoPath, { branch: w.branch, head: w.head, listed_by_git: true, main: i === 0 && canonOrNull(w.path) === canonOrNull(repoPath) }))
68
+ }
69
+ // checkouts git no longer lists (deleted by hand, pruned) but a record still names
70
+ for (const s of sess) if (s.worktree?.path && s.repo) row(s.worktree.path, s.repo, { branch: rows.get(canonOrNull(s.worktree.path))?.branch ?? s.worktree.branch ?? null })
71
+ for (const c of crd) if (c.worktree && c.repo) row(c.worktree, c.repo)
72
+ for (const r of recs) if (r.worktree?.path && r.repo) row(r.worktree.path, r.repo, { branch: rows.get(canonOrNull(r.worktree.path))?.branch ?? r.worktree.branch ?? null })
73
+
74
+ // owners: a session's own worktree, a card's, or the main checkout
75
+ const byCard = new Map(crd.map((c) => [c.card_id, c]))
76
+ for (const s of sess) {
77
+ if (!s.worktree?.path) continue
78
+ const r = rows.get(canonOrNull(s.worktree.path))
79
+ if (!r) continue
80
+ const prior = r.owner.kind === 'session' ? sess.find((x) => x.session_id === r.owner.id) : null
81
+ // a live session outranks a finished one on the same path
82
+ if (!prior || (!isActive(prior) && isActive(s))) r.owner = { kind: 'session', id: s.session_id, agent: s.agent, status: s.status, live: isActive(s), updated_at: s.updated_at ?? null }
83
+ }
84
+ for (const r of rows.values()) {
85
+ if (r.owner.kind !== 'external') continue
86
+ const m = LEG_DIR_RE.exec(r.path)
87
+ if (m && byCard.has(m[1])) { const c = byCard.get(m[1]); r.owner = { kind: 'card', id: c.card_id, status: c.status, live: ['running', 'handing_off'].includes(c.status), updated_at: c.updated_at ?? null }; continue }
88
+ if (r.main) r.owner = { kind: 'checkout' }
89
+ else if (m) r.orphaned = true
90
+ }
91
+ // which conversations point here
92
+ const convs = new Map()
93
+ for (const rec of recs) {
94
+ const key = canonOrNull(rec.worktree?.path) ?? (rec.repo && !rec.worktree ? canonOrNull(rec.repo) : null)
95
+ if (!key || !rows.has(key)) continue
96
+ if (!convs.has(key)) convs.set(key, [])
97
+ convs.get(key).push(rec)
98
+ }
99
+ for (const [key, list] of convs) {
100
+ list.sort((a, b) => at(b.updated_at) - at(a.updated_at))
101
+ rows.get(key).conversations = { count: list.length, last_at: list[0]?.updated_at ?? null, latest: list.slice(0, 3).map((r) => ({ id: r.id, provider: r.provider, managed: r.managed, live: r.live, title: r.title, updated_at: r.updated_at })) }
102
+ }
103
+ // dirty and stale
104
+ let checked = 0
105
+ for (const r of rows.values()) {
106
+ if (r.exists && dirty && checked < dirtyLimit) { r.dirty = dirtyOf(r.path); checked += 1 }
107
+ const liveOwner = Boolean(r.owner.live)
108
+ const last = Math.max(at(r.conversations.last_at), at(r.owner.updated_at), r.exists ? (mtimeOf(r.path) ?? 0) : 0)
109
+ r.last_activity_at = last ? new Date(last).toISOString() : null
110
+ r.stale = r.exists && !r.main && !liveOwner && (!last || now - last > STALE_DAYS * 86400000)
111
+ }
112
+ const out = [...rows.values()].sort((a, b) => String(a.repo).localeCompare(String(b.repo)) || (a.main ? -1 : b.main ? 1 : 0) || a.path.localeCompare(b.path))
113
+ return { worktrees: out, repos: repos.size, dirty_checked: checked, ts: new Date(now).toISOString() }
114
+ }
115
+
116
+ export function underLegWorktrees(path) { return LEG_DIR_RE.test(String(path ?? '')) }