@ucsandman/legcli 0.9.0 → 0.11.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/CHANGELOG.md +146 -0
- package/README.md +110 -11
- package/bin/leg.mjs +78 -15
- package/docs/ERRORS.md +187 -0
- package/docs/README.md +3 -1
- package/docs/ROADMAP-v2.md +24 -11
- package/docs/VOCABULARY.md +1 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +20 -1
- package/docs/cli-contracts.md +50 -17
- package/docs/configuration.md +56 -5
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +11 -4
- package/scripts/probe.mjs +2 -1
- package/src/accounts.mjs +5 -2
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +85 -13
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +38 -1
- package/src/board/board.js +14 -2
- package/src/board/history.js +377 -0
- package/src/board/index.html +55 -0
- package/src/board/sessions.js +49 -7
- package/src/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -0
- package/src/redact.mjs +23 -5
- package/src/server.mjs +272 -28
- package/src/sessions.mjs +9 -0
- package/src/share.mjs +66 -6
- package/src/taps/claude.mjs +11 -4
- package/src/taps/grok.mjs +4 -0
- package/src/taps/mod.mjs +340 -0
- package/src/usage.mjs +21 -5
- package/src/worktree.mjs +1 -1
|
@@ -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 ?? '')) }
|