@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,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness adapter: OpenCode — sessions in a local SQLite database.
|
|
3
|
+
*
|
|
4
|
+
* Primary store is `opencode.db` (WAL mode, so concurrent readers are safe while
|
|
5
|
+
* the app runs): `~/.local/share/opencode/opencode.db`, overridable with
|
|
6
|
+
* `$OPENCODE_DB`. Only top-level sessions count as threads — child rows with
|
|
7
|
+
* `parent_id` set are the task tool's subagents, and OpenCode's own session list
|
|
8
|
+
* filters them the same way. Including them would stand hundreds of astronauts
|
|
9
|
+
* on the map that nobody ever talked to.
|
|
10
|
+
*
|
|
11
|
+
* Read-only, without exception, and no subprocess anywhere. There is no
|
|
12
|
+
* per-session deep link upstream, so opening a thread is honestly refused and
|
|
13
|
+
* starting one goes through the registered `opencode://new-session` handler.
|
|
14
|
+
*/
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import os from 'node:os'
|
|
17
|
+
import { exists, findExecutable, num } from '../lib/fsutil.mjs'
|
|
18
|
+
|
|
19
|
+
const HOME = os.homedir()
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where `opencode.db` lives. `$OPENCODE_DB` wins when it names a file that is
|
|
23
|
+
* actually there — otherwise the XDG path, then the macOS Application Support
|
|
24
|
+
* fallback on darwin only. Resolved per call so pointing the var at a fixture
|
|
25
|
+
* (or installing the app) is picked up on the next poll. A relative
|
|
26
|
+
* `$OPENCODE_DB` is relative to OpenCode's data folder, as OpenCode reads it.
|
|
27
|
+
*/
|
|
28
|
+
async function dbPath() {
|
|
29
|
+
const named = process.env.OPENCODE_DB
|
|
30
|
+
const xdg = process.env.XDG_DATA_HOME
|
|
31
|
+
const dataDir = typeof xdg === 'string' && xdg ? path.join(xdg, 'opencode') : path.join(HOME, '.local', 'share', 'opencode')
|
|
32
|
+
const override = typeof named === 'string' && named ? path.resolve(dataDir, named) : ''
|
|
33
|
+
// When set, the override is the whole answer: a missing file means "absent",
|
|
34
|
+
// not "fall back to the default and read a database the user did not name".
|
|
35
|
+
// That is also what makes fixture tests isolate from the real store.
|
|
36
|
+
if (override) return (await exists(override)) ? override : ''
|
|
37
|
+
if (typeof xdg === 'string' && xdg) {
|
|
38
|
+
const p = path.join(xdg, 'opencode', 'opencode.db')
|
|
39
|
+
if (await exists(p)) return p
|
|
40
|
+
}
|
|
41
|
+
const shared = path.join(HOME, '.local', 'share', 'opencode', 'opencode.db')
|
|
42
|
+
if (await exists(shared)) return shared
|
|
43
|
+
if (process.platform === 'darwin') {
|
|
44
|
+
const mac = path.join(HOME, 'Library', 'Application Support', 'opencode', 'opencode.db')
|
|
45
|
+
if (await exists(mac)) return mac
|
|
46
|
+
}
|
|
47
|
+
return ''
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `node:sqlite` is imported lazily and its absence is survivable.
|
|
52
|
+
*
|
|
53
|
+
* It needs Node 22.13, which `package.json` asks for — but asking is not
|
|
54
|
+
* enforcing, and a top level import would take the whole server down on an
|
|
55
|
+
* older Node rather than costing one harness. This way every other harness
|
|
56
|
+
* keeps working and `diagnostic()` explains the gap.
|
|
57
|
+
*/
|
|
58
|
+
let sqlitePromise
|
|
59
|
+
const sqliteApi = () => (sqlitePromise ??= import('node:sqlite').catch(() => null))
|
|
60
|
+
|
|
61
|
+
/** Prefixed, per the contract in `harnesses/README.md`. */
|
|
62
|
+
const ID = (raw) => `opencode:${raw}`
|
|
63
|
+
|
|
64
|
+
/** OpenCode writes nothing when it is killed, so an open turn needs a time bound too. */
|
|
65
|
+
const ACTIVE_WINDOW_MS = 30 * 60 * 1000
|
|
66
|
+
const CONTINUING = new Set(['tool-calls', 'unknown'])
|
|
67
|
+
|
|
68
|
+
const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim()
|
|
69
|
+
|
|
70
|
+
function parseModel(raw) {
|
|
71
|
+
if (!raw) return { model: '', effort: '' }
|
|
72
|
+
try {
|
|
73
|
+
const m = typeof raw === 'string' ? JSON.parse(raw) : raw
|
|
74
|
+
return {
|
|
75
|
+
model: typeof m?.id === 'string' ? m.id : '',
|
|
76
|
+
effort: typeof m?.variant === 'string' ? m.variant : ''
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
return { model: '', effort: '' }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function projectOf(directory) {
|
|
84
|
+
const dir = typeof directory === 'string' ? directory : ''
|
|
85
|
+
if (!dir) return { projectPath: '', project: 'unknown', cwd: '' }
|
|
86
|
+
// Both separators: a Windows directory arrives with either, and `basename`
|
|
87
|
+
// on one OS must still read a path written on another (tests use posix).
|
|
88
|
+
const base = path.basename(dir.replace(/\\/g, '/'))
|
|
89
|
+
return { projectPath: dir, project: base || 'unknown', cwd: dir }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** First user text per session never changes, so it is kept forever. */
|
|
93
|
+
const previewCache = new Map()
|
|
94
|
+
|
|
95
|
+
async function firstUserText(db, sessionId) {
|
|
96
|
+
if (previewCache.has(sessionId)) return previewCache.get(sessionId)
|
|
97
|
+
let out = ''
|
|
98
|
+
try {
|
|
99
|
+
const msgs = db
|
|
100
|
+
.prepare(`SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created ASC LIMIT 8`)
|
|
101
|
+
.all(sessionId)
|
|
102
|
+
for (const m of msgs) {
|
|
103
|
+
let role = ''
|
|
104
|
+
try {
|
|
105
|
+
role = JSON.parse(m.data)?.role || ''
|
|
106
|
+
} catch {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (role !== 'user') continue
|
|
110
|
+
const parts = db
|
|
111
|
+
.prepare(`SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC LIMIT 8`)
|
|
112
|
+
.all(m.id)
|
|
113
|
+
for (const p of parts) {
|
|
114
|
+
try {
|
|
115
|
+
const d = JSON.parse(p.data)
|
|
116
|
+
if (d?.type === 'text' && typeof d.text === 'string' && clean(d.text)) {
|
|
117
|
+
out = clean(d.text)
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
/* a part mid-write — skip it */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (out) break
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
out = ''
|
|
128
|
+
}
|
|
129
|
+
previewCache.set(sessionId, out)
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Costly facts kept against `time_updated`, so an unchanged session is read once. */
|
|
134
|
+
const factsCache = new Map()
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether an error-status tool part means the run failed.
|
|
138
|
+
*
|
|
139
|
+
* Seen in production: `The user rejected permission to use this specific tool
|
|
140
|
+
* call.` and `Tool execution aborted` — both are the user stopping the turn,
|
|
141
|
+
* not the turn failing. Only a genuine failure reddens an astronaut.
|
|
142
|
+
*/
|
|
143
|
+
function isRealError(raw) {
|
|
144
|
+
let text = ''
|
|
145
|
+
try {
|
|
146
|
+
const d = typeof raw === 'string' ? JSON.parse(raw) : raw
|
|
147
|
+
const state = d?.state || {}
|
|
148
|
+
text = `${state.error || ''}\n${state.output || ''}`
|
|
149
|
+
} catch {
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
return !/user rejected permission|permission.{0,20}denied|denied.{0,20}permission|execution aborted|aborted|cancelled/i.test(text)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function sessionFacts(db, sessionId, timeUpdated) {
|
|
156
|
+
const hit = factsCache.get(sessionId)
|
|
157
|
+
if (hit && hit.timeUpdated === timeUpdated) return hit.facts
|
|
158
|
+
const facts = { running: false, hasError: false, sizeBytes: 0 }
|
|
159
|
+
try {
|
|
160
|
+
const msgBytes = db.prepare(`SELECT COALESCE(SUM(LENGTH(data)), 0) AS n FROM message WHERE session_id = ?`).get(sessionId)
|
|
161
|
+
const partBytes = db.prepare(`SELECT COALESCE(SUM(LENGTH(data)), 0) AS n FROM part WHERE session_id = ?`).get(sessionId)
|
|
162
|
+
facts.sizeBytes = num(msgBytes?.n) + num(partBytes?.n)
|
|
163
|
+
} catch {
|
|
164
|
+
facts.sizeBytes = 0
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const last = db
|
|
168
|
+
.prepare(`SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created DESC, rowid DESC LIMIT 1`)
|
|
169
|
+
.get(sessionId)
|
|
170
|
+
if (last?.data) {
|
|
171
|
+
const d = JSON.parse(last.data)
|
|
172
|
+
const completed = d?.time?.completed
|
|
173
|
+
const finished = typeof d?.finish === 'string' && d.finish
|
|
174
|
+
const msgError = typeof d?.error?.name === 'string' ? d.error.name : ''
|
|
175
|
+
const aborted = /abort/i.test(msgError)
|
|
176
|
+
// A trailing user message means the model speaks next — whatever the
|
|
177
|
+
// process is doing, it is not waiting on anyone. A turn that ended in
|
|
178
|
+
// error or abort is over too, even when it carries no finish stamp. A
|
|
179
|
+
// reply that finished on tool calls is completed, but its turn goes on.
|
|
180
|
+
const continuing = !msgError && CONTINUING.has(finished)
|
|
181
|
+
const open = d?.role === 'user' || (d?.role === 'assistant' && !msgError && (continuing || (!completed && !finished)))
|
|
182
|
+
facts.running = open && Date.now() - num(timeUpdated) < ACTIVE_WINDOW_MS
|
|
183
|
+
if (d?.role === 'assistant' && !open && (completed || finished || msgError)) {
|
|
184
|
+
if (aborted) {
|
|
185
|
+
// The user stopped the turn. Same as pressing escape elsewhere:
|
|
186
|
+
// an abandoned turn is not a failed one.
|
|
187
|
+
facts.hasError = false
|
|
188
|
+
} else if (msgError) {
|
|
189
|
+
// The turn itself failed (provider/auth error) — that is what the
|
|
190
|
+
// red eyes are for, even when no single tool part takes the blame.
|
|
191
|
+
facts.hasError = true
|
|
192
|
+
} else {
|
|
193
|
+
// Only the last turn counts: a historic tool error must not redden
|
|
194
|
+
// an astronaut forever. And a turn the *user* stopped is not a
|
|
195
|
+
// failure — a rejected permission or an aborted call is this
|
|
196
|
+
// harness's version of pressing escape.
|
|
197
|
+
const candidates = db
|
|
198
|
+
.prepare(
|
|
199
|
+
`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`
|
|
200
|
+
)
|
|
201
|
+
.all(sessionId)
|
|
202
|
+
facts.hasError = candidates.some((row) => isRealError(row?.data))
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
/* mid-write, or gone */
|
|
208
|
+
}
|
|
209
|
+
factsCache.set(sessionId, { timeUpdated, facts })
|
|
210
|
+
return facts
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function toThread(r, prompt, facts, seen = { unread: false, lastFocusedAt: 0 }) {
|
|
214
|
+
const { projectPath, project, cwd } = projectOf(r.directory)
|
|
215
|
+
const { model, effort } = parseModel(r.model)
|
|
216
|
+
const title = clean(r.title) || prompt || 'Untitled thread'
|
|
217
|
+
return {
|
|
218
|
+
id: ID(r.id),
|
|
219
|
+
title: title.slice(0, 120),
|
|
220
|
+
preview: prompt.slice(0, 240),
|
|
221
|
+
project,
|
|
222
|
+
projectPath,
|
|
223
|
+
// OpenCode has no worktree concept of its own, and guessing one from
|
|
224
|
+
// the path would put a branch name on a thread that never had one.
|
|
225
|
+
worktree: '',
|
|
226
|
+
cwd,
|
|
227
|
+
gitBranch: '',
|
|
228
|
+
model,
|
|
229
|
+
effort,
|
|
230
|
+
createdAt: num(r.time_created),
|
|
231
|
+
lastActivityAt: num(r.time_updated),
|
|
232
|
+
// Legacy sessions have no focus history, so for them "have you looked at this" is unknowable — not false.
|
|
233
|
+
lastFocusedAt: seen.lastFocusedAt,
|
|
234
|
+
unread: seen.unread,
|
|
235
|
+
running: facts.running,
|
|
236
|
+
hasError: facts.hasError,
|
|
237
|
+
starred: false,
|
|
238
|
+
routine: '',
|
|
239
|
+
prState: '',
|
|
240
|
+
archived: r.time_archived !== null && r.time_archived !== undefined,
|
|
241
|
+
// Bytes, like every other harness: the field is a shared log scale
|
|
242
|
+
// across the whole map, and a token count would make OpenCode
|
|
243
|
+
// buildings taller than Claude ones for the same work.
|
|
244
|
+
sizeBytes: facts.sizeBytes,
|
|
245
|
+
source: typeof r.agent === 'string' ? r.agent : '',
|
|
246
|
+
canOpen: false,
|
|
247
|
+
ref: { sessionId: r.id, cwd }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const columnsOf = (db, table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((r) => r.name))
|
|
252
|
+
|
|
253
|
+
async function scanLegacySessions(db) {
|
|
254
|
+
// Undocumented private state that drifts between versions — probe every
|
|
255
|
+
// column before naming it, or one renamed column costs the whole harness.
|
|
256
|
+
const cols = columnsOf(db, 'session')
|
|
257
|
+
for (const need of ['id', 'directory', 'title', 'time_created', 'time_updated']) {
|
|
258
|
+
if (!cols.has(need)) return []
|
|
259
|
+
}
|
|
260
|
+
const rows = db
|
|
261
|
+
.prepare(
|
|
262
|
+
`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`
|
|
263
|
+
)
|
|
264
|
+
.all()
|
|
265
|
+
const out = []
|
|
266
|
+
for (const r of rows) {
|
|
267
|
+
if (typeof r.id !== 'string' || !r.id) continue
|
|
268
|
+
const prompt = await firstUserText(db, r.id)
|
|
269
|
+
out.push(toThread(r, prompt, await sessionFacts(db, r.id, num(r.time_updated))))
|
|
270
|
+
}
|
|
271
|
+
return out
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const TURN_MESSAGES = `'user', 'assistant', 'shell', 'synthetic', 'compaction', 'idle'`
|
|
275
|
+
|
|
276
|
+
async function sessionMessageFacts(db, r) {
|
|
277
|
+
const key = `session_v2:${r.id}`
|
|
278
|
+
const timeUpdated = num(r.time_updated)
|
|
279
|
+
const hit = factsCache.get(key)
|
|
280
|
+
if (hit && hit.timeUpdated === timeUpdated) return hit.facts
|
|
281
|
+
const facts = { running: false, hasError: false, sizeBytes: 0 }
|
|
282
|
+
try {
|
|
283
|
+
facts.sizeBytes = num(db.prepare(`SELECT COALESCE(SUM(LENGTH(data)), 0) AS n FROM session_message WHERE session_id = ?`).get(r.id)?.n)
|
|
284
|
+
const last = db
|
|
285
|
+
.prepare(`SELECT type, data FROM session_message WHERE session_id = ? AND type IN (${TURN_MESSAGES}) ORDER BY seq DESC LIMIT 1`)
|
|
286
|
+
.get(r.id)
|
|
287
|
+
if (last) {
|
|
288
|
+
const ended = last.type === 'idle'
|
|
289
|
+
facts.running = !ended && Date.now() - timeUpdated < ACTIVE_WINDOW_MS
|
|
290
|
+
let outcome = typeof r.idle_outcome === 'string' ? r.idle_outcome : ''
|
|
291
|
+
if (ended && !outcome) {
|
|
292
|
+
try {
|
|
293
|
+
outcome = JSON.parse(last.data)?.outcome || ''
|
|
294
|
+
} catch {
|
|
295
|
+
outcome = ''
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
facts.hasError = ended && outcome === 'failed'
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
/* mid-write, or gone */
|
|
302
|
+
}
|
|
303
|
+
factsCache.set(key, { timeUpdated, facts })
|
|
304
|
+
return facts
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function firstPrompt(db, sessionId) {
|
|
308
|
+
const key = `session_v2:${sessionId}`
|
|
309
|
+
if (previewCache.has(key)) return previewCache.get(key)
|
|
310
|
+
let out = ''
|
|
311
|
+
try {
|
|
312
|
+
const row = db.prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq ASC LIMIT 1`).get(sessionId)
|
|
313
|
+
if (row?.data) out = clean(JSON.parse(row.data)?.text)
|
|
314
|
+
} catch {
|
|
315
|
+
out = ''
|
|
316
|
+
}
|
|
317
|
+
if (out) previewCache.set(key, out)
|
|
318
|
+
return out
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function scanSessions(db) {
|
|
322
|
+
const cols = columnsOf(db, 'session_v2')
|
|
323
|
+
for (const need of ['id', 'directory', 'time_created', 'time_updated']) {
|
|
324
|
+
if (!cols.has(need)) return []
|
|
325
|
+
}
|
|
326
|
+
const pick = (name) => (cols.has(name) ? name : `NULL AS ${name}`)
|
|
327
|
+
const rows = db
|
|
328
|
+
.prepare(
|
|
329
|
+
`SELECT id, directory, ${['title', 'agent', 'model', 'time_archived', 'time_idle', 'time_viewed', 'idle_outcome'].map(pick).join(', ')}, time_created, time_updated FROM session_v2 ${cols.has('parent_id') ? 'WHERE parent_id IS NULL' : ''} ORDER BY time_updated DESC`
|
|
330
|
+
)
|
|
331
|
+
.all()
|
|
332
|
+
const out = []
|
|
333
|
+
for (const r of rows) {
|
|
334
|
+
if (typeof r.id !== 'string' || !r.id) continue
|
|
335
|
+
const facts = await sessionMessageFacts(db, r)
|
|
336
|
+
const viewed = num(r.time_viewed)
|
|
337
|
+
const seen = { lastFocusedAt: viewed, unread: !facts.running && viewed > 0 && num(r.time_idle) > viewed }
|
|
338
|
+
out.push(toThread(r, await firstPrompt(db, r.id), facts, seen))
|
|
339
|
+
}
|
|
340
|
+
return out
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function scanThreads() {
|
|
344
|
+
const file = await dbPath()
|
|
345
|
+
if (!file) return []
|
|
346
|
+
const sqlite = await sqliteApi()
|
|
347
|
+
if (!sqlite?.DatabaseSync) return []
|
|
348
|
+
|
|
349
|
+
let db
|
|
350
|
+
try {
|
|
351
|
+
db = new sqlite.DatabaseSync(file, { readOnly: true })
|
|
352
|
+
} catch {
|
|
353
|
+
// A WAL database whose shared-memory file cannot be used refuses a
|
|
354
|
+
// read-only open. Losing one harness beats losing the scan.
|
|
355
|
+
return []
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const tables = new Set(
|
|
359
|
+
db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name)
|
|
360
|
+
)
|
|
361
|
+
const byId = new Map()
|
|
362
|
+
if (tables.has('session') && tables.has('message') && tables.has('part')) {
|
|
363
|
+
for (const t of await scanLegacySessions(db)) byId.set(t.id, t)
|
|
364
|
+
}
|
|
365
|
+
if (tables.has('session_v2') && tables.has('session_message')) {
|
|
366
|
+
for (const t of await scanSessions(db)) {
|
|
367
|
+
const legacy = byId.get(t.id)
|
|
368
|
+
if (!legacy || t.lastActivityAt >= legacy.lastActivityAt) byId.set(t.id, t)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return [...byId.values()]
|
|
372
|
+
} catch {
|
|
373
|
+
return []
|
|
374
|
+
} finally {
|
|
375
|
+
try {
|
|
376
|
+
db.close()
|
|
377
|
+
} catch {
|
|
378
|
+
/* already gone */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* OpenCode registers `opencode://open-project` and `opencode://new-session`
|
|
385
|
+
* and nothing that addresses a single session — inventing a route would be a
|
|
386
|
+
* link that silently does nothing. Revealing the session list is the honest
|
|
387
|
+
* offer, and the UI greys the button and shows this instead.
|
|
388
|
+
*/
|
|
389
|
+
function openThread(ref) {
|
|
390
|
+
const id = ref?.sessionId
|
|
391
|
+
// The type check matters wherever an id came back from the page:
|
|
392
|
+
// `String([validId])` is that id, and it must not travel on as an array.
|
|
393
|
+
if (typeof id !== 'string' || !id) {
|
|
394
|
+
return { ok: false, error: 'No openable OpenCode session id on that thread' }
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
error: 'OpenCode has no link to a single session — open the repo and pick it from the session list.'
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Where the `opencode` CLI is, for a machine that needs the terminal fallback.
|
|
404
|
+
* PATH first, then the places its installers put it — never inside an
|
|
405
|
+
* application bundle.
|
|
406
|
+
*/
|
|
407
|
+
const CLI_DIRS = [path.join(HOME, '.local', 'bin'), path.join(HOME, '.opencode', 'bin'), '/usr/local/bin', '/usr/bin']
|
|
408
|
+
const cliBinary = () => findExecutable('opencode', CLI_DIRS)
|
|
409
|
+
|
|
410
|
+
const APP = Object.freeze({ name: 'OpenCode', download: 'https://opencode.ai/download' })
|
|
411
|
+
|
|
412
|
+
async function newSession(dir) {
|
|
413
|
+
if (typeof dir !== 'string' || !path.isAbsolute(dir)) {
|
|
414
|
+
return { ok: false, error: 'That folder is not somewhere OpenCode can open' }
|
|
415
|
+
}
|
|
416
|
+
const url = `opencode://new-session?${new URLSearchParams({ directory: dir })}`
|
|
417
|
+
const bin = await cliBinary()
|
|
418
|
+
const command = bin ? { argv: [bin], cwd: dir } : undefined
|
|
419
|
+
return { ok: true, url, command, app: APP }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function detect() {
|
|
423
|
+
return Boolean(await dbPath())
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Why a present OpenCode might still look thin. Without this the old-Node case
|
|
428
|
+
* is invisible: the database is simply skipped, every thread is missing, and
|
|
429
|
+
* nothing says why.
|
|
430
|
+
*/
|
|
431
|
+
async function diagnostic() {
|
|
432
|
+
if (!(await dbPath())) return ''
|
|
433
|
+
if (!(await sqliteApi())?.DatabaseSync) {
|
|
434
|
+
return `OpenCode threads need Node 22.13 or newer for their sessions (running ${process.versions.node})`
|
|
435
|
+
}
|
|
436
|
+
return ''
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export default {
|
|
440
|
+
id: 'opencode',
|
|
441
|
+
name: 'OpenCode',
|
|
442
|
+
app: APP,
|
|
443
|
+
detect,
|
|
444
|
+
diagnostic,
|
|
445
|
+
scanThreads,
|
|
446
|
+
openThread,
|
|
447
|
+
newSession,
|
|
448
|
+
paths: {}
|
|
449
|
+
}
|
package/launch/index.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening a thread on this machine: the one part of this package that starts processes.
|
|
3
|
+
*
|
|
4
|
+
* A harness adapter in `../harnesses/` answers "open this thread" with `{ ok, url, command, pid }`
|
|
5
|
+
* and starts nothing itself. `present()` turns that answer into an action: it fronts the window a
|
|
6
|
+
* running session already has, hands a deep link to the OS, or runs the harness's CLI in a new
|
|
7
|
+
* terminal. The readers in `../harnesses/`, `../telemetry/`, and `../lib/` never import this
|
|
8
|
+
* folder, and `test/read-only.test.mjs` checks that.
|
|
9
|
+
*/
|
|
10
|
+
import fsp from 'node:fs/promises'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { openAndConfirm } from './macos.mjs'
|
|
14
|
+
import { schemeRegistered } from './registry.mjs'
|
|
15
|
+
import { openInTerminal } from './terminal.mjs'
|
|
16
|
+
import { focusWindowOfPid } from './windows.mjs'
|
|
17
|
+
import { schemeHasHandler, schemeOf } from './xdg.mjs'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hand a `harness://…` deep link, or a folder, to whatever opens things on this OS. The opener
|
|
21
|
+
* gets an argument list, never a shell string.
|
|
22
|
+
*
|
|
23
|
+
* macOS's `open(1)` does both jobs, and `xdg-open` is the Linux equivalent. On Windows it is
|
|
24
|
+
* ShellExecute, reached through `rundll32 url.dll,FileProtocolHandler`: a registered protocol URL
|
|
25
|
+
* goes to its app and a folder opens in Explorer, with the argument passed through unchanged. Two
|
|
26
|
+
* other routes were tried and rejected. `explorer.exe <url>` drops any URL that has a query
|
|
27
|
+
* string, so `code/new?folder=…` never arrived, and `cmd /c start` parses its own argument line,
|
|
28
|
+
* where it expands the `%3A%5C` escapes in that same link.
|
|
29
|
+
*
|
|
30
|
+
* The spawn is guarded because the opener may not be installed (a headless Linux machine has no
|
|
31
|
+
* `xdg-open`), and an unhandled `error` event on a child process stops the whole server.
|
|
32
|
+
*/
|
|
33
|
+
const OPENERS = {
|
|
34
|
+
darwin: ['open'],
|
|
35
|
+
win32: ['rundll32', 'url.dll,FileProtocolHandler'],
|
|
36
|
+
linux: ['xdg-open'],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function launch(target) {
|
|
40
|
+
const opener = OPENERS[process.platform]
|
|
41
|
+
if (!opener) return
|
|
42
|
+
const [cmd, ...args] = opener
|
|
43
|
+
const child = spawn(cmd, [...args, target], { stdio: 'ignore', detached: true })
|
|
44
|
+
child.on('error', () => {})
|
|
45
|
+
child.unref()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A folder can be opened only if it is still on this machine and still a directory. Paths come
|
|
50
|
+
* from a scan that may be minutes old, so a repo that has since been moved or deleted fails here
|
|
51
|
+
* rather than handing the opener a path that no longer exists. Absolute is judged by
|
|
52
|
+
* `path.isAbsolute` rather than a leading `/`, which no Windows path has.
|
|
53
|
+
*/
|
|
54
|
+
export async function resolveFolder(folder) {
|
|
55
|
+
if (typeof folder !== 'string' || !path.isAbsolute(folder)) return null
|
|
56
|
+
const dir = path.resolve(folder)
|
|
57
|
+
const stat = await fsp.stat(dir).catch(() => null)
|
|
58
|
+
return stat && stat.isDirectory() ? dir : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `command.cwd` gets the same check as any other folder. There is no fallback directory on
|
|
63
|
+
* purpose: `claude --resume` looks a session up under the folder it ran in, and a terminal that
|
|
64
|
+
* opens on "No conversation found" and closes is worse than an error message.
|
|
65
|
+
*/
|
|
66
|
+
async function runInTerminal(command) {
|
|
67
|
+
if (!command.cwd) return { ok: false, error: 'That thread has no folder on record to resume in' }
|
|
68
|
+
const cwd = await resolveFolder(command.cwd)
|
|
69
|
+
if (!cwd) return { ok: false, error: 'The folder that thread ran in is not on this machine any more' }
|
|
70
|
+
const enterable = await fsp.access(cwd, fsp.constants.X_OK).then(() => true, () => false)
|
|
71
|
+
if (!enterable) return { ok: false, error: 'The folder that thread ran in cannot be entered' }
|
|
72
|
+
const opened = await openInTerminal(command.argv, cwd)
|
|
73
|
+
return opened.ok ? { ok: true, via: 'terminal' } : opened
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Carry out a harness's answer to "open this" (`{ ok, url, command, pid }`) the way the caller
|
|
78
|
+
* asked for it, and report whether anything happened.
|
|
79
|
+
*
|
|
80
|
+
* A caller asking for a terminal never gets the desktop app instead, even when the CLI is missing:
|
|
81
|
+
* an app window appearing after choosing a terminal looks like the setting being ignored, while an
|
|
82
|
+
* error message says what to fix.
|
|
83
|
+
*
|
|
84
|
+
* Otherwise the URL goes to the opener only once something on this machine answers its scheme,
|
|
85
|
+
* and the adapter's `fallback` links are tried the same way when nothing does. When neither
|
|
86
|
+
* opens, Linux runs the harness's own CLI in a terminal if the adapter offered one, and every
|
|
87
|
+
* other case returns `missing`, naming the app to install. DECISIONS.md has the why.
|
|
88
|
+
*/
|
|
89
|
+
export async function present(
|
|
90
|
+
result,
|
|
91
|
+
via = 'app',
|
|
92
|
+
{ platform = process.platform, answers = schemeAnswered, open = openUrl, wait = pause } = {}
|
|
93
|
+
) {
|
|
94
|
+
if (!result || !result.ok) return { ok: false, error: result?.error || 'Nothing to open' }
|
|
95
|
+
|
|
96
|
+
if (via === 'terminal') {
|
|
97
|
+
if (!result.command) return { ok: false, error: "That harness's CLI was not found on this machine" }
|
|
98
|
+
return runInTerminal(result.command)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A `pid` names a live process whose thread already has a window on this machine, such as a
|
|
102
|
+
// session running in a terminal. Fronting that window is tried before the URL, because the URL
|
|
103
|
+
// for these threads is `resume`, which imports the transcript into the desktop app as a second,
|
|
104
|
+
// untitled session. Only when no window can be found does the URL run.
|
|
105
|
+
if (result.pid && (await focusWindowOfPid(result.pid))) return { ok: true, focused: true }
|
|
106
|
+
|
|
107
|
+
if (!result.url) {
|
|
108
|
+
if (platform === 'linux' && result.command) return runInTerminal(result.command)
|
|
109
|
+
return { ok: false, error: 'That harness has no deep link to open on this platform' }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const { urls, note } of routesOf(result)) {
|
|
113
|
+
const [first, ...rest] = urls
|
|
114
|
+
if ((await answers(first, platform)) === false) continue
|
|
115
|
+
if ((await open(first, platform)) === false) continue
|
|
116
|
+
for (const url of rest) {
|
|
117
|
+
await wait(FALLBACK_STEP_MS)
|
|
118
|
+
await open(url, platform)
|
|
119
|
+
}
|
|
120
|
+
return { ok: true, url: urls.at(-1), note }
|
|
121
|
+
}
|
|
122
|
+
if (platform === 'linux' && result.command) return runInTerminal(result.command)
|
|
123
|
+
return missingApp(result)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const FALLBACK_STEP_MS = 2000
|
|
127
|
+
|
|
128
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
129
|
+
|
|
130
|
+
function routesOf(result) {
|
|
131
|
+
const routes = [{ urls: [result.url], note: result.note }]
|
|
132
|
+
const { urls, note } = result.fallback || {}
|
|
133
|
+
if (Array.isArray(urls) && urls.length && urls.every((url) => typeof url === 'string' && schemeOf(url))) {
|
|
134
|
+
routes.push({ urls, note: typeof note === 'string' ? note : undefined })
|
|
135
|
+
}
|
|
136
|
+
return routes
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const appLink = (app) => ({ name: typeof app?.name === 'string' ? app.name : '', download: typeof app?.download === 'string' ? app.download : '' })
|
|
140
|
+
|
|
141
|
+
async function schemeAnswered(url, platform) {
|
|
142
|
+
if (platform === 'linux') return schemeHasHandler(url)
|
|
143
|
+
if (platform === 'win32') return schemeRegistered(schemeOf(url))
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function openUrl(url, platform) {
|
|
148
|
+
if (platform === 'darwin') return openAndConfirm(url)
|
|
149
|
+
launch(url)
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function missingApp(result) {
|
|
154
|
+
const scheme = schemeOf(result.url)
|
|
155
|
+
const { name, download } = appLink(result.app)
|
|
156
|
+
const also = Array.isArray(result.app?.also) ? result.app.also.map(appLink).filter((app) => app.name && app.download) : []
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
error: name ? `Opening this needs ${name}, which isn't on this computer` : `Nothing on this computer opens ${scheme}:// links`,
|
|
160
|
+
missing: { name, download, scheme, also },
|
|
161
|
+
terminal: Boolean(result.command),
|
|
162
|
+
}
|
|
163
|
+
}
|
package/launch/macos.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
const OPEN_TIMEOUT_MS = 5000
|
|
4
|
+
|
|
5
|
+
export function openAndConfirm(url, { timeoutMs = OPEN_TIMEOUT_MS } = {}) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const child = spawn('open', [url], { stdio: 'ignore' })
|
|
8
|
+
const timer = setTimeout(() => {
|
|
9
|
+
child.unref()
|
|
10
|
+
resolve(null)
|
|
11
|
+
}, timeoutMs)
|
|
12
|
+
child.on('error', () => {
|
|
13
|
+
clearTimeout(timer)
|
|
14
|
+
resolve(null)
|
|
15
|
+
})
|
|
16
|
+
child.on('exit', (code) => {
|
|
17
|
+
clearTimeout(timer)
|
|
18
|
+
resolve(code === 0)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import fsp from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile)
|
|
7
|
+
const REG = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'reg.exe')
|
|
8
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*$/
|
|
9
|
+
const REGISTRY_TIMEOUT_MS = 5000
|
|
10
|
+
|
|
11
|
+
async function regQuery(args) {
|
|
12
|
+
try {
|
|
13
|
+
const { stdout } = await execFileAsync(REG, ['query', ...args], { timeout: REGISTRY_TIMEOUT_MS, windowsHide: true })
|
|
14
|
+
return stdout
|
|
15
|
+
} catch (err) {
|
|
16
|
+
if (err.code === 1) return null
|
|
17
|
+
throw err
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function defaultValue(output) {
|
|
22
|
+
for (const line of String(output || '').split(/\r?\n/)) {
|
|
23
|
+
const value = /\sREG_(?:EXPAND_)?SZ\s+(.*\S)\s*$/.exec(line)
|
|
24
|
+
if (value) return value[1]
|
|
25
|
+
}
|
|
26
|
+
return ''
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function programOf(command, env = process.env) {
|
|
30
|
+
const text = String(command || '').trim()
|
|
31
|
+
const program = /^"([^"]+)"/.exec(text)?.[1] || /^(\S+\.exe)(?:\s|$)/i.exec(text)?.[1]
|
|
32
|
+
if (!program) return ''
|
|
33
|
+
const expanded = program.replace(/%([^%]+)%/g, (whole, name) => env[name] ?? whole)
|
|
34
|
+
if (expanded.includes('%') || !/^[\x20-\x7e]+$/.test(expanded) || !path.win32.isAbsolute(expanded)) return ''
|
|
35
|
+
return expanded
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function schemeRegistered(scheme, { query = regQuery, stat = fsp.stat, env = process.env } = {}) {
|
|
39
|
+
if (!SCHEME.test(scheme)) return false
|
|
40
|
+
const key = `HKCR\\${scheme}`
|
|
41
|
+
try {
|
|
42
|
+
if ((await query([key, '/v', 'URL Protocol'])) == null) return false
|
|
43
|
+
const program = programOf(defaultValue(await query([`${key}\\shell\\open\\command`, '/ve'])), env)
|
|
44
|
+
if (!program) return true
|
|
45
|
+
return await stat(program).then(
|
|
46
|
+
() => true,
|
|
47
|
+
(err) => err.code !== 'ENOENT' && err.code !== 'ENOTDIR'
|
|
48
|
+
)
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|