@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.
- package/LICENSE.md +7 -0
- package/README.md +108 -3
- package/harnesses/README.md +213 -0
- package/harnesses/antigravity.mjs +269 -0
- package/harnesses/claude-code.mjs +578 -0
- package/harnesses/codex.mjs +419 -0
- package/harnesses/cursor.mjs +199 -0
- package/harnesses/hermes.mjs +211 -0
- package/harnesses/index.mjs +36 -0
- package/harnesses/kilocode.mjs +361 -0
- package/harnesses/opencode.mjs +449 -0
- package/launch/index.mjs +163 -0
- package/launch/macos.mjs +21 -0
- package/launch/registry.mjs +52 -0
- package/launch/terminal.mjs +213 -0
- package/launch/win-terminal.mjs +87 -0
- package/launch/windows.mjs +96 -0
- package/launch/xdg.mjs +44 -0
- package/lib/anthropic-prices.mjs +70 -0
- package/lib/claude-desktop.mjs +208 -0
- package/lib/claude-transcript.mjs +462 -0
- package/lib/cursor-folder.mjs +107 -0
- package/lib/fsutil.mjs +191 -0
- package/lib/hermes-home.mjs +30 -0
- package/lib/jsonl-follower.mjs +45 -0
- package/lib/opencode-store.mjs +602 -0
- package/lib/sqlite.mjs +29 -0
- package/lib/telemetry-fields.mjs +245 -0
- package/license/index.mjs +101 -0
- package/license/store.mjs +155 -0
- package/orgs/index.mjs +157 -0
- package/package.json +39 -4
- package/telemetry/README.md +148 -0
- package/telemetry/antigravity.mjs +268 -0
- package/telemetry/claude-code.mjs +370 -0
- package/telemetry/codex.mjs +556 -0
- package/telemetry/cursor.mjs +276 -0
- package/telemetry/hermes.mjs +404 -0
- package/telemetry/index.mjs +9 -0
- package/telemetry/kilocode.mjs +31 -0
- 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
|
+
}
|