@threadcrumbs/common 0.0.0-stage → 0.1.1

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 (42) hide show
  1. package/LICENSE.md +34 -0
  2. package/NOTICES.md +33 -0
  3. package/README.md +109 -3
  4. package/harnesses/README.md +213 -0
  5. package/harnesses/antigravity.mjs +269 -0
  6. package/harnesses/claude-code.mjs +578 -0
  7. package/harnesses/codex.mjs +419 -0
  8. package/harnesses/cursor.mjs +199 -0
  9. package/harnesses/hermes.mjs +211 -0
  10. package/harnesses/index.mjs +36 -0
  11. package/harnesses/kilocode.mjs +361 -0
  12. package/harnesses/opencode.mjs +449 -0
  13. package/launch/index.mjs +163 -0
  14. package/launch/macos.mjs +21 -0
  15. package/launch/registry.mjs +52 -0
  16. package/launch/terminal.mjs +213 -0
  17. package/launch/win-terminal.mjs +87 -0
  18. package/launch/windows.mjs +96 -0
  19. package/launch/xdg.mjs +44 -0
  20. package/lib/anthropic-prices.mjs +70 -0
  21. package/lib/claude-desktop.mjs +208 -0
  22. package/lib/claude-transcript.mjs +462 -0
  23. package/lib/cursor-folder.mjs +107 -0
  24. package/lib/fsutil.mjs +191 -0
  25. package/lib/hermes-home.mjs +30 -0
  26. package/lib/jsonl-follower.mjs +45 -0
  27. package/lib/opencode-store.mjs +602 -0
  28. package/lib/sqlite.mjs +29 -0
  29. package/lib/telemetry-fields.mjs +245 -0
  30. package/license/index.mjs +102 -0
  31. package/license/store.mjs +155 -0
  32. package/orgs/index.mjs +157 -0
  33. package/package.json +40 -4
  34. package/telemetry/README.md +148 -0
  35. package/telemetry/antigravity.mjs +268 -0
  36. package/telemetry/claude-code.mjs +370 -0
  37. package/telemetry/codex.mjs +556 -0
  38. package/telemetry/cursor.mjs +276 -0
  39. package/telemetry/hermes.mjs +404 -0
  40. package/telemetry/index.mjs +9 -0
  41. package/telemetry/kilocode.mjs +31 -0
  42. 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
+ }
@@ -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
+ }
@@ -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
+ }