@threadcrumbs/common 0.0.0-stage → 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.
Files changed (41) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +108 -3
  3. package/harnesses/README.md +213 -0
  4. package/harnesses/antigravity.mjs +269 -0
  5. package/harnesses/claude-code.mjs +578 -0
  6. package/harnesses/codex.mjs +419 -0
  7. package/harnesses/cursor.mjs +199 -0
  8. package/harnesses/hermes.mjs +211 -0
  9. package/harnesses/index.mjs +36 -0
  10. package/harnesses/kilocode.mjs +361 -0
  11. package/harnesses/opencode.mjs +449 -0
  12. package/launch/index.mjs +163 -0
  13. package/launch/macos.mjs +21 -0
  14. package/launch/registry.mjs +52 -0
  15. package/launch/terminal.mjs +213 -0
  16. package/launch/win-terminal.mjs +87 -0
  17. package/launch/windows.mjs +96 -0
  18. package/launch/xdg.mjs +44 -0
  19. package/lib/anthropic-prices.mjs +70 -0
  20. package/lib/claude-desktop.mjs +208 -0
  21. package/lib/claude-transcript.mjs +462 -0
  22. package/lib/cursor-folder.mjs +107 -0
  23. package/lib/fsutil.mjs +191 -0
  24. package/lib/hermes-home.mjs +30 -0
  25. package/lib/jsonl-follower.mjs +45 -0
  26. package/lib/opencode-store.mjs +602 -0
  27. package/lib/sqlite.mjs +29 -0
  28. package/lib/telemetry-fields.mjs +245 -0
  29. package/license/index.mjs +101 -0
  30. package/license/store.mjs +155 -0
  31. package/orgs/index.mjs +157 -0
  32. package/package.json +39 -4
  33. package/telemetry/README.md +148 -0
  34. package/telemetry/antigravity.mjs +268 -0
  35. package/telemetry/claude-code.mjs +370 -0
  36. package/telemetry/codex.mjs +556 -0
  37. package/telemetry/cursor.mjs +276 -0
  38. package/telemetry/hermes.mjs +404 -0
  39. package/telemetry/index.mjs +9 -0
  40. package/telemetry/kilocode.mjs +31 -0
  41. package/telemetry/opencode.mjs +36 -0
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Harness adapter: Hermes Agent — the terminal and chat apps' session store.
3
+ *
4
+ * Reads the agent's own session store — the `sessions` table in
5
+ * `~/.hermes/state.db`, opened read-only — plus a first-user-message preview
6
+ * per session. One SQL pass per scan; a 500-session store answers in
7
+ * milliseconds, so no mtime cache is needed.
8
+ *
9
+ * Hermes sessions live in the terminal and chat apps, which have no deep
10
+ * link to hand back: `openThread` / `newSession` say so per the interface
11
+ * and the UI greys those buttons out. The harness's own `archived` flag is
12
+ * read and never written; archiving here is recorded in the colony alone.
13
+ */
14
+ import { DatabaseSync } from 'node:sqlite'
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+ import { resolveHermesHome } from '../lib/hermes-home.mjs'
19
+
20
+ export { resolveHermesHome } from '../lib/hermes-home.mjs'
21
+
22
+ const HOME = os.homedir()
23
+
24
+ const HERMES_HOME = resolveHermesHome()
25
+ const MAIN_DB = path.join(HERMES_HOME, 'state.db')
26
+ const PROFILES_DIR = path.join(HERMES_HOME, 'profiles')
27
+
28
+ /** Every bot that owns sessions: the default profile plus each named profile
29
+ * with its own session store. Pilot name doubles as the astronaut's identity. */
30
+ function pilotDBs() {
31
+ const dbs = [{ pilot: 'main', file: MAIN_DB }]
32
+ let dirs = []
33
+ try {
34
+ dirs = fs.readdirSync(PROFILES_DIR, { withFileTypes: true })
35
+ } catch {
36
+ return dbs
37
+ }
38
+ for (const d of dirs) {
39
+ if (!d.isDirectory()) continue
40
+ const file = path.join(PROFILES_DIR, d.name, 'state.db')
41
+ try {
42
+ fs.accessSync(file, fs.constants.R_OK)
43
+ dbs.push({ pilot: d.name, file })
44
+ } catch {
45
+ /* profile never ran — no astronaut until it has sessions */
46
+ }
47
+ }
48
+ return dbs
49
+ }
50
+
51
+ const openRead = (file) => new DatabaseSync(file, { readOnly: true })
52
+
53
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
54
+ const FORK_MARKERS = ['_branched_from', '_delegate_from', '_reset_from']
55
+
56
+ function forkMarker(modelConfig) {
57
+ try {
58
+ const config = JSON.parse(modelConfig || '{}')
59
+ return FORK_MARKERS.find((key) => config?.[key] != null) || ''
60
+ } catch {
61
+ return ''
62
+ }
63
+ }
64
+
65
+ function toThread(row, pilot, chain, running) {
66
+ const root = row.git_repo_root || row.cwd || ''
67
+ // Sessions run from the agent home (or with no cwd) are all the same
68
+ // project — don't let basename case/dirname split one bot into many.
69
+ const home = HOME
70
+ const isHome = !root || root === home || root === home + '/.hermes'
71
+ const project = isHome ? 'Hermes' : path.basename(root)
72
+ // Keep projectPath canonical too: the colony keys plots on (name, path),
73
+ // so three spellings of home would be re-split into three plots downstream.
74
+ const projectPath = isHome ? home : root
75
+ const createdAt = Math.round((chain.root.started_at || 0) * 1000)
76
+ const lastActivityAt = Math.round(((row.last_activity_at || row.ended_at || row.started_at) || 0) * 1000)
77
+ const tokens = chain.tokens
78
+ return {
79
+ id: `hermes:${pilot}:${chain.root.id}`,
80
+ pilot,
81
+ title: row.title || chain.root.title || 'Untitled thread',
82
+ preview: (chain.root.first_user || row.first_user || '').trim().slice(0, 280),
83
+ project,
84
+ projectPath,
85
+ worktree: '',
86
+ cwd: row.cwd || '',
87
+ gitBranch: row.git_branch || '',
88
+ model: row.model || '',
89
+ effort: '',
90
+ createdAt,
91
+ lastActivityAt,
92
+ lastFocusedAt: 0,
93
+ running,
94
+ unread: !running && row.last_read_at != null && (row.last_activity_at || row.started_at || 0) > row.last_read_at,
95
+ hasError: false,
96
+ archived: row.archived === 1,
97
+ sizeBytes: tokens > 0 ? tokens * 4 : chain.messages * 500,
98
+ source: row.source || '',
99
+ canOpen: false,
100
+ ref: { sessionId: row.id, pilot },
101
+ }
102
+ }
103
+
104
+ const tableColumns = (db, table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((r) => r.name))
105
+
106
+ function liveTurns(db) {
107
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name))
108
+ if (!tables.has('session_turn_leases')) return null
109
+ const now = Date.now() / 1000
110
+ return new Set(
111
+ db.prepare('SELECT conversation_id FROM session_turn_leases WHERE expires_at > ?').all(now).map((r) => r.conversation_id)
112
+ )
113
+ }
114
+
115
+ function threadsFrom(db, pilot) {
116
+ const cols = tableColumns(db, 'sessions')
117
+ const optional = ['parent_session_id', 'end_reason', 'model_config', 'last_activity_description']
118
+ const sql = THREAD_SQL.replace(
119
+ 'SELECT s.id,',
120
+ `SELECT ${optional.map((c) => (cols.has(c) ? `s.${c}` : `NULL AS ${c}`)).join(', ')}, s.id,`
121
+ )
122
+ const rows = db.prepare(sql).all()
123
+ const leased = liveTurns(db)
124
+ const now = Date.now()
125
+ const isRunning = (row, root) => {
126
+ if (leased) return leased.has(root.id)
127
+ const fresh = now - Math.round((row.last_activity_at || row.started_at || 0) * 1000) < ACTIVE_WINDOW_MS
128
+ if (cols.has('last_activity_description')) return Boolean(row.last_activity_description) && fresh
129
+ return row.ended_at == null && fresh
130
+ }
131
+
132
+ const byId = new Map(rows.map((r) => [r.id, r]))
133
+ const continues = (row) => {
134
+ const parent = row.parent_session_id ? byId.get(row.parent_session_id) : null
135
+ return parent && parent.end_reason === 'compression' && !forkMarker(row.model_config) ? parent : null
136
+ }
137
+ const continued = new Set(rows.map(continues).filter(Boolean).map((p) => p.id))
138
+
139
+ const out = []
140
+ for (const row of rows) {
141
+ if (continued.has(row.id)) continue
142
+ if (forkMarker(row.model_config) === '_delegate_from') continue
143
+ const chain = { root: row, tokens: 0, messages: 0 }
144
+ for (let at = row, hops = 0; at && hops < rows.length; at = continues(at), hops++) {
145
+ chain.root = at
146
+ chain.tokens += (at.input_tokens || 0) + (at.output_tokens || 0)
147
+ chain.messages += at.message_count || 0
148
+ }
149
+ out.push(toThread(row, pilot, chain, isRunning(row, chain.root)))
150
+ }
151
+ return out
152
+ }
153
+
154
+ async function detect() {
155
+ try {
156
+ openRead(MAIN_DB).close()
157
+ return true
158
+ } catch {
159
+ return false
160
+ }
161
+ }
162
+
163
+ const THREAD_SQL = `
164
+ SELECT s.id, s.title, s.model, s.source, s.cwd, s.git_branch, s.git_repo_root,
165
+ s.started_at, s.ended_at, s.message_count,
166
+ s.input_tokens, s.output_tokens, s.archived,
167
+ s.last_activity_at, s.last_read_at,
168
+ (SELECT substr(m.content, 1, 280) FROM messages m
169
+ WHERE m.session_id = s.id AND m.role = 'user' AND m.active = 1
170
+ ORDER BY m.id ASC LIMIT 1) AS first_user
171
+ FROM sessions s
172
+ -- Cron executions are scheduled runs, not threads: each one would
173
+ -- stand on the map as an astronaut nobody ever talks to. Skip them.
174
+ WHERE s.hidden = 0 AND s.source != 'cron'
175
+ ORDER BY COALESCE(s.last_activity_at, s.ended_at, s.started_at) DESC
176
+ `
177
+
178
+ async function scanThreads() {
179
+ const out = []
180
+ for (const { pilot, file } of pilotDBs()) {
181
+ let db
182
+ try {
183
+ db = openRead(file)
184
+ } catch {
185
+ continue
186
+ }
187
+ try {
188
+ out.push(...threadsFrom(db, pilot))
189
+ } finally {
190
+ db.close()
191
+ }
192
+ }
193
+ return out
194
+ }
195
+
196
+ function openThread() {
197
+ return { ok: false, error: 'Hermes sessions live in the terminal and chat apps — there is no link to open.' }
198
+ }
199
+
200
+ function newSession() {
201
+ return { ok: false, error: 'Hermes sessions start in the terminal, not from the colony.' }
202
+ }
203
+
204
+ export default {
205
+ id: 'hermes',
206
+ name: 'Hermes',
207
+ detect,
208
+ scanThreads,
209
+ openThread,
210
+ newSession,
211
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The harness registry.
3
+ *
4
+ * Adding support for another agent harness means writing one module next to this file and
5
+ * adding it to the list below. Nothing else in the codebase needs to change — the scanner,
6
+ * the API and the browser all talk to harnesses only through the interface documented in
7
+ * `harnesses/README.md`.
8
+ */
9
+ import antigravity from './antigravity.mjs'
10
+ import claudeCode from './claude-code.mjs'
11
+ import codex from './codex.mjs'
12
+ import cursor from './cursor.mjs'
13
+ import hermes from './hermes.mjs'
14
+ import kilocode from './kilocode.mjs'
15
+ import opencode from './opencode.mjs'
16
+
17
+ export const HARNESSES = [claudeCode, codex, cursor, antigravity, hermes, kilocode, opencode]
18
+
19
+ export const harnessById = (id) => HARNESSES.find((h) => h.id === id) || null
20
+
21
+ /**
22
+ * Which harnesses have data on this machine. Detection is per-scan rather than cached at
23
+ * boot so that installing one while the colony is running is picked up on the next poll.
24
+ */
25
+ export async function detectedHarnesses() {
26
+ const flags = await Promise.all(
27
+ HARNESSES.map(async (h) => {
28
+ try {
29
+ return await h.detect()
30
+ } catch {
31
+ return false
32
+ }
33
+ })
34
+ )
35
+ return HARNESSES.filter((_, i) => flags[i])
36
+ }
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Harness adapter: Kilo Code — sessions in a local SQLite database.
3
+ *
4
+ * The current extension (rebuilt on the OpenCode server) keeps everything in
5
+ * `kilo.db` (WAL mode, so concurrent readers are safe while the app runs):
6
+ * `~/.local/share/kilo/kilo.db` on every OS, overridable with `$KILO_DB`.
7
+ * Only top-level sessions count as threads — child rows with `parent_id` set
8
+ * are the task tool's subagents, and Kilo's own history filters them the same
9
+ * way. Including them would stand hundreds of astronauts on the map that
10
+ * nobody ever talked to.
11
+ *
12
+ * The pre-rebuild extension kept per-task directories under VS Code's
13
+ * `globalStorage/kilocode.kilo-code/tasks/` instead. That store is not read:
14
+ * no install observed here still writes it, and guessing at its shape would
15
+ * be worse than leaving it out and saying so.
16
+ *
17
+ * Read-only, without exception, and no subprocess anywhere. The extension
18
+ * registers no URI handler for a single session, so opening a thread reveals
19
+ * its repo folder in VS Code — where the Kilo sidebar and its session list
20
+ * live — and starting one does the same for a fresh folder.
21
+ */
22
+ import path from 'node:path'
23
+ import os from 'node:os'
24
+ import { exists, num } from '../lib/fsutil.mjs'
25
+
26
+ const HOME = os.homedir()
27
+ const APP = Object.freeze({ name: 'VS Code', download: 'https://code.visualstudio.com/download' })
28
+
29
+ /**
30
+ * Where `kilo.db` lives. `$KILO_DB` wins when it names a file that is
31
+ * actually there — otherwise the XDG path, then the fixed default the docs
32
+ * give for every OS (including `%USERPROFILE%\.local\share\kilo\kilo.db` on
33
+ * Windows). Resolved per call so pointing the var at a fixture (or
34
+ * installing the app) is picked up on the next poll.
35
+ */
36
+ async function dbPath() {
37
+ const override = process.env.KILO_DB
38
+ // When set, the override is the whole answer: a missing file means "absent",
39
+ // not "fall back to the default and read a database the user did not name".
40
+ // That is also what makes fixture tests isolate from the real store.
41
+ if (typeof override === 'string' && override) return (await exists(override)) ? override : ''
42
+ const xdg = process.env.XDG_DATA_HOME
43
+ if (typeof xdg === 'string' && xdg) {
44
+ const p = path.join(xdg, 'kilo', 'kilo.db')
45
+ if (await exists(p)) return p
46
+ }
47
+ const shared = path.join(HOME, '.local', 'share', 'kilo', 'kilo.db')
48
+ if (await exists(shared)) return shared
49
+ return ''
50
+ }
51
+
52
+ /**
53
+ * `node:sqlite` is imported lazily and its absence is survivable.
54
+ *
55
+ * It needs Node 22.13, which `package.json` asks for — but asking is not
56
+ * enforcing, and a top level import would take the whole server down on an
57
+ * older Node rather than costing one harness. This way every other harness
58
+ * keeps working and `diagnostic()` explains the gap.
59
+ */
60
+ let sqlitePromise
61
+ const sqliteApi = () => (sqlitePromise ??= import('node:sqlite').catch(() => null))
62
+
63
+ /** Prefixed, per the contract in `harnesses/README.md`. */
64
+ const ID = (raw) => `kilocode:${raw}`
65
+
66
+ /** Kilo writes nothing when it is killed, so an open turn needs a time bound too. */
67
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
68
+ const CONTINUING = new Set(['tool-calls'])
69
+
70
+ const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim()
71
+
72
+ function parseModel(raw) {
73
+ if (!raw) return { model: '', effort: '' }
74
+ try {
75
+ const m = typeof raw === 'string' ? JSON.parse(raw) : raw
76
+ return {
77
+ model: typeof m?.id === 'string' ? m.id : '',
78
+ effort: typeof m?.variant === 'string' ? m.variant : ''
79
+ }
80
+ } catch {
81
+ return { model: '', effort: '' }
82
+ }
83
+ }
84
+
85
+ function projectOf(directory) {
86
+ const dir = typeof directory === 'string' ? directory : ''
87
+ if (!dir) return { projectPath: '', project: 'unknown', cwd: '' }
88
+ // Both separators: a Windows directory arrives with forward slashes in this
89
+ // store, and `basename` on one OS must still read a path written on another.
90
+ const base = path.basename(dir.replace(/\\/g, '/'))
91
+ return { projectPath: dir, project: base || 'unknown', cwd: dir }
92
+ }
93
+
94
+ /** First user text per session never changes, so it is kept forever. */
95
+ const previewCache = new Map()
96
+
97
+ async function firstUserText(db, sessionId) {
98
+ if (previewCache.has(sessionId)) return previewCache.get(sessionId)
99
+ let out = ''
100
+ try {
101
+ const msgs = db
102
+ .prepare(`SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created ASC LIMIT 8`)
103
+ .all(sessionId)
104
+ for (const m of msgs) {
105
+ let role = ''
106
+ try {
107
+ role = JSON.parse(m.data)?.role || ''
108
+ } catch {
109
+ continue
110
+ }
111
+ if (role !== 'user') continue
112
+ const parts = db
113
+ .prepare(`SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC LIMIT 8`)
114
+ .all(m.id)
115
+ for (const p of parts) {
116
+ try {
117
+ const d = JSON.parse(p.data)
118
+ if (d?.type === 'text' && typeof d.text === 'string' && clean(d.text)) {
119
+ out = clean(d.text)
120
+ break
121
+ }
122
+ } catch {
123
+ /* a part mid-write — skip it */
124
+ }
125
+ }
126
+ if (out) break
127
+ }
128
+ } catch {
129
+ out = ''
130
+ }
131
+ previewCache.set(sessionId, out)
132
+ return out
133
+ }
134
+
135
+ /** Costly facts kept against `time_updated`, so an unchanged session is read once. */
136
+ const factsCache = new Map()
137
+
138
+ /**
139
+ * Whether an error-status tool part means the run failed.
140
+ *
141
+ * The user stopping the turn is not the turn failing — only a genuine failure
142
+ * reddens an astronaut.
143
+ */
144
+ function isRealError(raw) {
145
+ let text = ''
146
+ try {
147
+ const d = typeof raw === 'string' ? JSON.parse(raw) : raw
148
+ const state = d?.state || {}
149
+ text = `${state.error || ''}\n${state.output || ''}`
150
+ } catch {
151
+ return true
152
+ }
153
+ return !/user rejected permission|permission.{0,20}denied|denied.{0,20}permission|execution aborted|aborted|cancelled/i.test(text)
154
+ }
155
+
156
+ async function sessionFacts(db, sessionId, timeUpdated) {
157
+ const hit = factsCache.get(sessionId)
158
+ if (hit && hit.timeUpdated === timeUpdated) return hit.facts
159
+ const facts = { running: false, hasError: false, sizeBytes: 0 }
160
+ try {
161
+ const msgBytes = db.prepare(`SELECT COALESCE(SUM(LENGTH(data)), 0) AS n FROM message WHERE session_id = ?`).get(sessionId)
162
+ const partBytes = db.prepare(`SELECT COALESCE(SUM(LENGTH(data)), 0) AS n FROM part WHERE session_id = ?`).get(sessionId)
163
+ facts.sizeBytes = num(msgBytes?.n) + num(partBytes?.n)
164
+ } catch {
165
+ facts.sizeBytes = 0
166
+ }
167
+ try {
168
+ const last = db
169
+ .prepare(`SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created DESC, rowid DESC LIMIT 1`)
170
+ .get(sessionId)
171
+ if (last?.data) {
172
+ const d = JSON.parse(last.data)
173
+ const completed = d?.time?.completed
174
+ const finished = typeof d?.finish === 'string' && d.finish
175
+ const msgError = typeof d?.error?.name === 'string' ? d.error.name : ''
176
+ const aborted = /abort/i.test(msgError)
177
+ // A trailing user message means the model speaks next — whatever the
178
+ // process is doing, it is not waiting on anyone. A turn that ended in
179
+ // error or abort is over too, even when it carries no finish stamp. A
180
+ // reply that finished on tool calls is completed, but its turn goes on.
181
+ const continuing = !msgError && CONTINUING.has(finished)
182
+ const open = d?.role === 'user' || (d?.role === 'assistant' && !msgError && (continuing || (!completed && !finished)))
183
+ facts.running = open && Date.now() - num(timeUpdated) < ACTIVE_WINDOW_MS
184
+ if (d?.role === 'assistant' && !open && (completed || finished || msgError)) {
185
+ if (aborted) {
186
+ // The user stopped the turn. Same as pressing escape elsewhere:
187
+ // an abandoned turn is not a failed one.
188
+ facts.hasError = false
189
+ } else if (msgError) {
190
+ // The turn itself failed (provider/auth error) — that is what the
191
+ // red eyes are for, even when no single tool part takes the blame.
192
+ facts.hasError = true
193
+ } else {
194
+ // Only the last turn counts: a historic tool error must not redden
195
+ // an astronaut forever. And a turn the *user* stopped is not a
196
+ // failure — a rejected permission or an aborted call is this
197
+ // harness's version of pressing escape.
198
+ const candidates = db
199
+ .prepare(
200
+ `SELECT data FROM part WHERE message_id IN (SELECT id FROM message WHERE session_id = ? ORDER BY time_created DESC LIMIT 3) AND data LIKE '%"status":"error"%' LIMIT 5`
201
+ )
202
+ .all(sessionId)
203
+ facts.hasError = candidates.some((row) => isRealError(row?.data))
204
+ }
205
+ }
206
+ }
207
+ } catch {
208
+ /* mid-write, or gone */
209
+ }
210
+ factsCache.set(sessionId, { timeUpdated, facts })
211
+ return facts
212
+ }
213
+
214
+ async function scanThreads() {
215
+ const file = await dbPath()
216
+ if (!file) return []
217
+ const sqlite = await sqliteApi()
218
+ if (!sqlite?.DatabaseSync) return []
219
+
220
+ let db
221
+ try {
222
+ db = new sqlite.DatabaseSync(file, { readOnly: true })
223
+ } catch {
224
+ // A WAL database whose shared-memory file cannot be used refuses a
225
+ // read-only open. Losing one harness beats losing the scan.
226
+ return []
227
+ }
228
+ try {
229
+ const tables = new Set(
230
+ db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name)
231
+ )
232
+ if (!tables.has('session') || !tables.has('message') || !tables.has('part')) return []
233
+ // Undocumented private state that drifts between versions — probe every
234
+ // column before naming it, or one renamed column costs the whole harness.
235
+ const cols = new Set(db.prepare(`PRAGMA table_info(session)`).all().map((r) => r.name))
236
+ for (const need of ['id', 'directory', 'title', 'time_created', 'time_updated']) {
237
+ if (!cols.has(need)) return []
238
+ }
239
+ const rows = db
240
+ .prepare(
241
+ `SELECT id, directory, title, agent, model, time_created, time_updated, time_archived FROM session ${cols.has('parent_id') ? 'WHERE parent_id IS NULL' : ''} ORDER BY time_updated DESC`
242
+ )
243
+ .all()
244
+ const out = []
245
+ for (const r of rows) {
246
+ if (typeof r.id !== 'string' || !r.id) continue
247
+ const { projectPath, project, cwd } = projectOf(r.directory)
248
+ const { model, effort } = parseModel(r.model)
249
+ const prompt = await firstUserText(db, r.id)
250
+ const title = clean(r.title) || prompt || 'Untitled thread'
251
+ const facts = await sessionFacts(db, r.id, num(r.time_updated))
252
+ out.push({
253
+ id: ID(r.id),
254
+ title: title.slice(0, 120),
255
+ preview: prompt.slice(0, 240),
256
+ project,
257
+ projectPath,
258
+ // Kilo has no worktree concept of its own, and guessing one from the
259
+ // path would put a branch name on a thread that never had one.
260
+ worktree: '',
261
+ cwd,
262
+ gitBranch: '',
263
+ model,
264
+ effort,
265
+ createdAt: num(r.time_created),
266
+ lastActivityAt: num(r.time_updated),
267
+ // No focus history, so "have you looked at this" is unknowable — not false.
268
+ lastFocusedAt: 0,
269
+ unread: false,
270
+ running: facts.running,
271
+ hasError: facts.hasError,
272
+ starred: false,
273
+ routine: '',
274
+ prState: '',
275
+ archived: r.time_archived !== null && r.time_archived !== undefined,
276
+ // Bytes, like every other harness: the field is a shared log scale
277
+ // across the whole map, and a token count would make Kilo buildings
278
+ // taller than Claude ones for the same work.
279
+ sizeBytes: facts.sizeBytes,
280
+ source: typeof r.agent === 'string' ? r.agent : '',
281
+ // No per-session link exists, but the repo folder opens in VS Code —
282
+ // where the Kilo sidebar and its session list live — so the button
283
+ // stays live and does the most useful true thing instead of greying out.
284
+ canOpen: true,
285
+ ref: { sessionId: r.id, cwd }
286
+ })
287
+ }
288
+ return out
289
+ } catch {
290
+ return []
291
+ } finally {
292
+ try {
293
+ db.close()
294
+ } catch {
295
+ /* already gone */
296
+ }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * The extension registers no URI handler for a single session, so the thread
302
+ * is handed back as its repo folder in VS Code — the same `vscode://file/`
303
+ * offer as `newSession`. The Kilo sidebar and its session list are one click
304
+ * from there, which beats a greyed-out button that only explains itself.
305
+ */
306
+ function openThread(ref) {
307
+ const id = ref?.sessionId
308
+ // The type check matters wherever an id came back from the page:
309
+ // `String([validId])` is that id, and it must not travel on as an array.
310
+ if (typeof id !== 'string' || !id) {
311
+ return { ok: false, error: 'No openable Kilo Code session id on that thread' }
312
+ }
313
+ const cwd = ref?.cwd
314
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) {
315
+ return { ok: false, error: 'That thread has no folder on record to open' }
316
+ }
317
+ return { ok: true, url: `vscode://file/${encodeURI(cwd.replace(/\\/g, '/'))}`, app: APP }
318
+ }
319
+
320
+ /**
321
+ * `vscode://file/<path>` is answered by VS Code itself, where the Kilo
322
+ * sidebar lives — the same offer the Cursor adapter makes with its own
323
+ * scheme. `encodeURI` keeps the drive colon and the separators while
324
+ * escaping the spaces a repo path usually carries.
325
+ */
326
+ function newSession(dir) {
327
+ if (typeof dir !== 'string' || !path.isAbsolute(dir)) {
328
+ return { ok: false, error: 'That folder is not somewhere Kilo Code can open' }
329
+ }
330
+ const url = `vscode://file/${encodeURI(dir.replace(/\\/g, '/'))}`
331
+ return { ok: true, url, app: APP }
332
+ }
333
+
334
+ async function detect() {
335
+ return Boolean(await dbPath())
336
+ }
337
+
338
+ /**
339
+ * Why a present Kilo Code might still look thin. Without this the old-Node case
340
+ * is invisible: the database is simply skipped, every thread is missing, and
341
+ * nothing says why.
342
+ */
343
+ async function diagnostic() {
344
+ if (!(await dbPath())) return ''
345
+ if (!(await sqliteApi())?.DatabaseSync) {
346
+ return `Kilo Code threads need Node 22.13 or newer for their sessions (running ${process.versions.node})`
347
+ }
348
+ return ''
349
+ }
350
+
351
+ export default {
352
+ id: 'kilocode',
353
+ name: 'Kilo Code',
354
+ app: APP,
355
+ detect,
356
+ diagnostic,
357
+ scanThreads,
358
+ openThread,
359
+ newSession,
360
+ paths: {}
361
+ }