@threadcrumbs/common 0.1.8 → 0.1.10

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.
@@ -1,578 +1,578 @@
1
- /**
2
- * Harness adapter: Claude Code (Anthropic) — the desktop app and the CLI together.
3
- *
4
- * Everything that knows the shape of Claude Code's own files lives in this module and in
5
- * `../lib/claude-transcript.mjs`, the transcript parser it shares with the telemetry reader.
6
- * No app's scanner reaches past the adapter interface, so adding another harness
7
- * means writing a sibling of this file rather than editing a scanner. The contract is
8
- * written down in `harnesses/README.md`.
9
- *
10
- * Read-only, without exception. Nothing here writes to Claude Code's files — see the note on
11
- * archiving in `harnesses/README.md`.
12
- *
13
- * Two stores, deliberately merged rather than picked between:
14
- * - the desktop app keeps one JSON record per thread (title, cwd, model, timestamps), and
15
- * leaves a marker behind for each thread deleted in it
16
- * - the CLI keeps the raw transcript, which is the only source for terminal-started work
17
- */
18
- import fsp from 'node:fs/promises'
19
- import path from 'node:path'
20
- import os from 'node:os'
21
- import { SCRATCH_ROOT, claudeDesktopStores, desktopRecordReader, projectOf } from '../lib/claude-desktop.mjs'
22
- import { awaitedPeers, readLiveSessions, stripNoise, transcriptReader, turnEnding } from '../lib/claude-transcript.mjs'
23
- import { exists, findExecutable, listDirs, listFiles, num } from '../lib/fsutil.mjs'
24
-
25
- const HOME = os.homedir()
26
-
27
- /**
28
- * Both roots take an override, which is how the tests fake an install without touching a real
29
- * one. `CLAUDE_CONFIG_DIR` is the CLI's own: a shell that sets it has its transcripts written
30
- * there, so the variable that moves the CLI's home moves where the colony looks for it too.
31
- * `THREADCRUMBS_CLAUDE_DESKTOP_DIR` names the session store itself, the sibling of Cursor's
32
- * `THREADCRUMBS_CURSOR_PROJECTS_DIR`.
33
- */
34
- /** Where the Claude desktop app keeps one JSON record per thread — one store per app profile. */
35
- const DESKTOP_STORES = claudeDesktopStores()
36
- /** The CLI's home: `~/.claude`, unless the CLI itself has been told otherwise. */
37
- const CLI_HOME = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude')
38
- /** Where the CLI keeps the raw transcript: ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl */
39
- const CLI_PROJECTS = path.join(CLI_HOME, 'projects')
40
- /** One file per live CLI process: {pid, sessionId, cwd, ...}. Stale files outlive their pid. */
41
- const CLI_LIVE = path.join(CLI_HOME, 'sessions')
42
-
43
- const threadTranscripts = transcriptReader({ skim: true })
44
- const errandTranscripts = transcriptReader({ skim: true })
45
-
46
- /**
47
- * How recently a session must have done something to count as "active now".
48
- * A live process on its own is not enough: the desktop app pre-warms idle sessions, so
49
- * threads untouched for days still hold a CLI process. Measured against real data, the
50
- * warmed ones sat 16 hours to 3 days idle while genuinely active work was minutes old.
51
- */
52
- const ACTIVE_WINDOW_MS = 30 * 60 * 1000
53
-
54
- /**
55
- * How long a subagent's transcript may sit untouched before its errand counts as abandoned.
56
- * A subagent runs inside its parent's process, so the pid probe the rest of this file uses does
57
- * not exist for it, and one that is thinking writes nothing for minutes at a time — at two or
58
- * three minutes they blink out while still working.
59
- */
60
- const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
61
-
62
- /**
63
- * Every id this adapter hands out is prefixed. `harnesses/README.md` asks for ids unique
64
- * across harnesses, and while two UUIDs will not collide, the colony keys its archive list and
65
- * saved layout on this string — so it is worth being unambiguous rather than merely lucky.
66
- */
67
- const ID = (raw) => `claude-code:${raw}`
68
-
69
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
70
-
71
- /**
72
- * A title from a prompt, for a thread the desktop app never named: its first line that says
73
- * anything, and no more of it than every other harness keeps. The fallback used to hand back
74
- * the whole prompt — a headless orchestrator run opens with three kilobytes of instructions, and six
75
- * of those were a third of every poll's payload, laid out as a title.
76
- */
77
- const TITLE_MAX = 120
78
- export function titleFromPrompt(prompt) {
79
- const line = String(prompt || '')
80
- .split('\n')
81
- .map((text) => text.replace(/\s+/g, ' ').trim())
82
- .find(Boolean)
83
- return line ? line.slice(0, TITLE_MAX) : 'Untitled thread'
84
- }
85
-
86
- const DESKTOP_ID = /^local_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
87
- // The type check matters wherever an id came back from the page: `RegExp.test` stringifies, so a
88
- // one-element array holding a valid id would pass the pattern and then travel on as an array.
89
- const isCliId = (v) => typeof v === 'string' && UUID.test(v)
90
- const isDesktopId = (v) => typeof v === 'string' && DESKTOP_ID.test(v)
91
-
92
- /** A prompt as one line of text: the noise gone and every run of whitespace a single space. */
93
- function cleanPrompt(text) {
94
- return stripNoise(text).replace(/\s+/g, ' ').trim()
95
- }
96
-
97
- /**
98
- * Pull whatever a transcript knows about itself: title, cwd, branch, start time.
99
- * Mirrors the CLI's own title precedence: custom > ai > summary > first prompt.
100
- */
101
- function metaOf(state) {
102
- const prompt = state?.firstPrompt || ''
103
- return {
104
- customTitle: state?.customTitle || '',
105
- aiTitle: state?.aiTitle || '',
106
- summary: state?.summary || '',
107
- firstPrompt: cleanPrompt(prompt),
108
- // Taken from the prompt as the transcript has it: `firstPrompt` has its line breaks
109
- // collapsed, so a title worked out from that could never find the first line.
110
- promptTitle: prompt ? titleFromPrompt(prompt) : '',
111
- cwd: state?.firstCwd || '',
112
- gitBranch: state?.firstBranch || '',
113
- startedAt: state?.startedAt || 0,
114
- }
115
- }
116
-
117
- async function readTranscript(entry) {
118
- try {
119
- return await threadTranscripts.read(entry.file, { size: entry.size })
120
- } catch {
121
- return null
122
- }
123
- }
124
-
125
- /**
126
- * Best-effort reverse of the encoding used for project folder names: `-Users-you-Some-Dir`
127
- * on macOS, `C--Users-you-Some-Dir` on Windows, where the drive's colon became a dash too.
128
- */
129
- function decodeProjectDir(name) {
130
- const drive = /^([A-Za-z])--(.*)$/.exec(name)
131
- if (drive) return `${drive[1]}:\\${drive[2].replace(/-/g, '\\')}`
132
- return name.startsWith('-') ? '/' + name.slice(1).replace(/-/g, '/') : name
133
- }
134
-
135
- /**
136
- * Index every CLI transcript on disk, keyed by session id.
137
- *
138
- * A session id is only unique *per directory* it has ever run in — resuming it from a git
139
- * worktree, or any other second checkout, writes a second `<id>.jsonl` under a different
140
- * `projectDir` rather than moving the first. Without a tiebreaker the later `listDirs` entry
141
- * would win regardless of which copy is actually live, so a worktree's now-stale copy could
142
- * overwrite the real one just by sorting after it. The newer file is always the live one: an
143
- * abandoned copy stops being written to the moment the session moves on.
144
- */
145
- async function scanTranscripts() {
146
- const byId = new Map()
147
- for (const projectDir of await listDirs(CLI_PROJECTS)) {
148
- for (const file of await listFiles(projectDir, (n) => n.endsWith('.jsonl'))) {
149
- const id = path.basename(file, '.jsonl')
150
- let stat
151
- try {
152
- stat = await fsp.stat(file)
153
- } catch {
154
- continue
155
- }
156
- const existing = byId.get(id)
157
- if (existing && existing.mtime >= stat.mtimeMs) continue
158
- byId.set(id, { id, file, projectDir, size: stat.size, mtime: stat.mtimeMs })
159
- }
160
- }
161
- return byId
162
- }
163
-
164
- /**
165
- * Whether a transcript ends with the turn handed back to you.
166
- *
167
- * A live process is not the same thing as work in progress. The CLI holds its process open while
168
- * it sits at the prompt, so "the pid exists and the file moved recently" marks a thread that
169
- * finished four minutes ago and asked you a question as *working* — an astronaut hammering away
170
- * at a thread whose whole point is that it is waiting.
171
- *
172
- * The transcript says which it is. A last assistant message that called a tool is mid-turn; one
173
- * that called nothing has handed the turn back and the reply is yours. `stop_reason` alone will
174
- * not do — it is `end_turn` on a main thread's last message and empty on some others — so what
175
- * the message *called* is the half worth testing.
176
- *
177
- * The shared transcript reader keeps that answer up to date as it reads each transcript's new
178
- * lines, so asking it costs no read of its own.
179
- */
180
- const MID_TURN = { handedBack: false, failed: false, onAgents: false }
181
-
182
- /**
183
- * The subagents a session has out right now, keyed by the session that sent them. Their
184
- * transcripts live one level below the session's own, in
185
- * `<project>/<sessionId>/subagents/agent-<id>.jsonl`.
186
- *
187
- * Only sessions with a live process are walked. Every other session on disk has subagent
188
- * transcripts too, and all of that work would be thrown away: an errand cannot outlive the
189
- * process running it.
190
- */
191
- async function scanSubagents(livePending) {
192
- const byParent = new Map()
193
- const live = await livePending
194
- const cutoff = Date.now() - SUBAGENT_WINDOW_MS
195
- for (const projectDir of live.size ? await listDirs(CLI_PROJECTS) : []) {
196
- for (const sessionDir of await listDirs(projectDir)) {
197
- const parent = path.basename(sessionDir)
198
- if (!live.has(parent)) continue
199
- const dir = path.join(sessionDir, 'subagents')
200
- for (const file of await listFiles(dir, (n) => n.endsWith('.jsonl'))) {
201
- const stat = await fsp.stat(file).catch(() => null)
202
- if (!stat || stat.mtimeMs < cutoff) continue
203
- // A subagent that has handed its answer back is finished however recently it wrote —
204
- // the same question `turnEnding` answers for a thread, put to the errand's own transcript.
205
- const state = await errandTranscripts.read(file, stat, { sidechain: true }).catch(() => null)
206
- if (turnEnding(state).handedBack) continue
207
- const out = byParent.get(parent) || []
208
- out.push({
209
- id: path.basename(file, '.jsonl'),
210
- // The prompt a parent handed its subagent, which is the only thing about an errand
211
- // worth showing. A forked subagent opens with a `fork-context-ref` and inherits its
212
- // parent's context instead of being briefed, so it has no task text to find.
213
- task: cleanPrompt(state?.firstPrompt || ''),
214
- lastActivityAt: stat.mtimeMs,
215
- })
216
- byParent.set(parent, out)
217
- }
218
- }
219
- }
220
- // Errands are many and short-lived, so the reader keeps only the transcripts this pass read.
221
- errandTranscripts.sweep()
222
- return byParent
223
- }
224
-
225
- const readDesktopRecords = desktopRecordReader()
226
-
227
- /** Every thread the desktop app has a record for, and the ids of the ones it has deleted. */
228
- const scanDesktopSessions = () => readDesktopRecords(DESKTOP_STORES)
229
-
230
- /**
231
- * Two desktop records can point at one transcript — resuming a thread that is already
232
- * open makes the app write a second, untitled record. Keep the richer of the two.
233
- */
234
- function mergeThread(existing, next) {
235
- const better = (a, b) => (a && a !== 'Untitled thread' ? a : b || a)
236
- // The titled record is the real thread; an untitled twin is the import ghost. Point
237
- // the canonical id at the real one, but keep both so archiving covers the ghost too.
238
- const keepExisting = existing.titled || !next.titled
239
- return {
240
- ...existing,
241
- ...next,
242
- title: better(existing.title, next.title),
243
- titled: existing.titled || next.titled,
244
- preview: existing.preview || next.preview,
245
- desktopSessionId: keepExisting ? existing.desktopSessionId : next.desktopSessionId,
246
- desktopSessionIds: [...new Set([...existing.desktopSessionIds, ...next.desktopSessionIds])],
247
- bridgeSessionId: existing.bridgeSessionId || next.bridgeSessionId,
248
- model: existing.model || next.model,
249
- effort: existing.effort || next.effort,
250
- gitBranch: existing.gitBranch || next.gitBranch,
251
- cwd: existing.cwd || next.cwd,
252
- createdAt: Math.min(existing.createdAt || Infinity, next.createdAt || Infinity) || 0,
253
- lastActivityAt: Math.max(existing.lastActivityAt || 0, next.lastActivityAt || 0),
254
- lastFocusedAt: Math.max(existing.lastFocusedAt || 0, next.lastFocusedAt || 0),
255
- hasError: existing.hasError || next.hasError,
256
- turnOutcome: existing.turnOutcome || next.turnOutcome,
257
- hasLiveProcess: existing.hasLiveProcess || next.hasLiveProcess,
258
- starred: existing.starred || next.starred,
259
- routine: existing.routine || next.routine,
260
- prState: existing.prState || next.prState,
261
- archived: existing.archived && next.archived,
262
- hasTranscript: existing.hasTranscript || next.hasTranscript,
263
- }
264
- }
265
-
266
- /**
267
- * Fold the adapter's private bookkeeping into the shape the rest of the app sees.
268
- * The session ids stay, but behind `ref` — an opaque blob the browser hands straight
269
- * back on open/archive, so nothing outside this file has to know what a Claude session
270
- * id looks like.
271
- */
272
- function toThread(t) {
273
- const {
274
- desktopSessionId, desktopSessionIds, cliSessionId, bridgeSessionId,
275
- titled, hasLiveProcess, transcriptFile, recordActivityAt, turnOutcome, ...rest
276
- } = t
277
- return {
278
- ...rest,
279
- canOpen: isDesktopId(desktopSessionId) || isCliId(cliSessionId),
280
- // The cwd rides along because resuming from a terminal has to happen in the folder the
281
- // session ran in — the worktree, not the repo root.
282
- ref: { desktopSessionId, desktopSessionIds, cliSessionId, cwd: t.cwd || '' },
283
- }
284
- }
285
-
286
- async function scanThreads() {
287
- // The subagent walk needs the live set, and waiting for it here would serialise scans the
288
- // README says must never block — so it is handed the promise and waits on it itself.
289
- const livePending = readLiveSessions(CLI_LIVE)
290
- const [{ records: desktop, deleted }, transcripts, live, subagents] = await Promise.all([
291
- scanDesktopSessions(),
292
- scanTranscripts(),
293
- livePending,
294
- scanSubagents(livePending),
295
- ])
296
- const states = new Map()
297
- const read = async (entry) => {
298
- const state = await readTranscript(entry)
299
- if (state) states.set(entry.file, state)
300
- return metaOf(state)
301
- }
302
- const byId = new Map()
303
- const add = (thread) => {
304
- const existing = byId.get(thread.id)
305
- byId.set(thread.id, existing ? mergeThread(existing, thread) : thread)
306
- }
307
- const claimed = new Set()
308
-
309
- for (const s of desktop) {
310
- const cliSessionId = s.cliSessionId || ''
311
- const entry = cliSessionId ? transcripts.get(cliSessionId) : null
312
- if (entry) claimed.add(cliSessionId)
313
-
314
- const cwd = s.cwd || s.originCwd || ''
315
- const { projectPath, project, worktree } = projectOf(cwd, s.originCwd)
316
- const meta = entry ? await read(entry) : null
317
- const spokeAt = entry ? states.get(entry.file)?.lastAt || 0 : 0
318
-
319
- add({
320
- id: ID(cliSessionId || s.sessionId),
321
- cliSessionId,
322
- desktopSessionId: s.sessionId || '',
323
- desktopSessionIds: s.sessionId ? [s.sessionId] : [],
324
- titled: Boolean(s.title),
325
- bridgeSessionId: (s.bridgeSessionIds && s.bridgeSessionIds[0]) || '',
326
- title: s.title || meta?.customTitle || meta?.aiTitle || meta?.summary || meta?.promptTitle || 'Untitled thread',
327
- preview: meta?.firstPrompt ? meta.firstPrompt.slice(0, 240) : '',
328
- project,
329
- projectPath,
330
- worktree,
331
- cwd,
332
- gitBranch: meta?.gitBranch || '',
333
- model: s.model || '',
334
- effort: s.effort || '',
335
- createdAt: num(s.createdAt) || meta?.startedAt || 0,
336
- lastActivityAt:
337
- spokeAt ||
338
- Math.max(
339
- num(s.lastActivityAt) || num(s.lastFocusedAt) || num(s.createdAt) || 0,
340
- entry?.mtime || 0
341
- ),
342
- recordActivityAt: num(s.lastActivityAt) || num(s.lastFocusedAt) || num(s.createdAt) || 0,
343
- lastFocusedAt: num(s.lastFocusedAt),
344
- hasLiveProcess: live.has(cliSessionId),
345
- hasError: Boolean(s.error) || s.turnOutcome === 'failed',
346
- turnOutcome: s.turnOutcome || '',
347
- starred: s.isStarred === true,
348
- routine: s.scheduledTaskId || '',
349
- prState: s.prState || '',
350
- archived: s.isArchived === true || s.isArchived === 'True',
351
- hasTranscript: Boolean(entry),
352
- sizeBytes: entry?.size || 0,
353
- transcriptFile: entry?.file || '',
354
- source: 'desktop',
355
- })
356
- }
357
-
358
- // Transcripts with no desktop record — threads started straight from the terminal, and threads
359
- // the app has since deleted.
360
- for (const [id, entry] of transcripts) {
361
- if (claimed.has(id)) continue
362
- const meta = await read(entry)
363
- const cwd = meta.cwd || decodeProjectDir(path.basename(entry.projectDir))
364
- const { projectPath, project, worktree } = projectOf(cwd, '')
365
- add({
366
- id: ID(id),
367
- cliSessionId: id,
368
- desktopSessionId: '',
369
- desktopSessionIds: [],
370
- titled: Boolean(meta.customTitle || meta.aiTitle),
371
- bridgeSessionId: '',
372
- title: meta.customTitle || meta.aiTitle || meta.summary || meta.promptTitle || 'Untitled thread',
373
- preview: meta.firstPrompt ? meta.firstPrompt.slice(0, 240) : '',
374
- project,
375
- projectPath,
376
- worktree,
377
- cwd,
378
- gitBranch: meta.gitBranch,
379
- model: '',
380
- effort: '',
381
- createdAt: meta.startedAt || entry.mtime,
382
- lastActivityAt: states.get(entry.file)?.lastAt || entry.mtime,
383
- lastFocusedAt: 0,
384
- hasLiveProcess: live.has(id),
385
- hasError: false,
386
- turnOutcome: '',
387
- starred: false,
388
- routine: '',
389
- prState: '',
390
- // Deleted in the app, and reported the way an archive made there is. `archived` is the
391
- // read-only field an adapter has for "gone from the harness's own UI", and the colony sends
392
- // the astronaut home for it exactly as it does for an archive of its own — rather than the
393
- // thread simply vanishing from one scan to the next. Only a transcript with no record left
394
- // qualifies: resuming a deleted thread makes the app write a fresh record while the marker
395
- // stays behind, and a record that exists is the newer truth.
396
- archived: deleted.has(id),
397
- hasTranscript: true,
398
- sizeBytes: entry.size,
399
- transcriptFile: entry?.file || '',
400
- source: 'cli',
401
- })
402
- }
403
-
404
- const now = Date.now()
405
-
406
- /**
407
- * Drop the app's empty bookkeeping records.
408
- *
409
- * Resuming a thread makes the desktop app write a second record for the same conversation, and
410
- * one of the two carries the title and the transcript link while the other carries nothing.
411
- * With no `cliSessionId` on the empty one there is no key to merge the pair on, so it survives
412
- * as a thread of its own: an untitled entry with no transcript behind it, standing on the map
413
- * as a nameless twin of a thread you have already dealt with.
414
- *
415
- * A record with no transcript, no title and no live process is not a conversation. The age
416
- * check keeps a genuinely new session — opened seconds ago, nothing written yet — out of it.
417
- */
418
- const NEW_SESSION_MS = 10 * 60 * 1000
419
- const threads = [...byId.values()].filter(
420
- (t) =>
421
- t.hasTranscript ||
422
- t.titled ||
423
- t.hasLiveProcess ||
424
- now - (t.lastActivityAt || t.createdAt || 0) < NEW_SESSION_MS
425
- )
426
-
427
- // Unread = a reply came back after you last looked at it; never opened counts as unread.
428
- // Terminal-only threads have no focus history at all, so "unread" is unknowable — not true.
429
- const handedBackAt = (sessionId) => states.get(transcripts.get(sessionId)?.file)?.handedBackAt || 0
430
- for (const thread of threads) {
431
- const seenAt = handedBackAt(thread.cliSessionId) || (thread.recordActivityAt ?? thread.lastActivityAt)
432
- thread.unread = thread.desktopSessionIds.length > 0 && seenAt > thread.lastFocusedAt
433
- const fresh = now - thread.lastActivityAt < ACTIVE_WINDOW_MS
434
- const reported = live.get(thread.cliSessionId)?.status || ''
435
- const errands = subagents.get(thread.cliSessionId)
436
- const peers = thread.hasLiveProcess ? awaitedPeers(states.get(thread.transcriptFile), live, handedBackAt) : []
437
- const ending =
438
- thread.hasLiveProcess && (fresh || errands || peers.length) && thread.transcriptFile && (!reported || reported === 'idle' || (reported === 'busy' && errands))
439
- ? turnEnding(states.get(thread.transcriptFile))
440
- : MID_TURN
441
- const delegating =
442
- (Boolean(errands) && (reported === '' || reported === 'busy') && (ending.handedBack || ending.onAgents)) ||
443
- (peers.length > 0 && (reported === '' || reported === 'idle') && ending.handedBack)
444
- const waiting = thread.hasLiveProcess && fresh && !delegating && (reported ? reported === 'idle' : ending.handedBack)
445
- thread.running = thread.hasLiveProcess && fresh && !delegating && (reported ? reported === 'busy' : !ending.handedBack)
446
- thread.awaitingSubagents = delegating
447
- thread.needsInput = reported === 'waiting' || (!thread.running && !delegating && thread.turnOutcome === 'blocked')
448
- if (waiting && ending.failed) thread.hasError = true
449
- // A thread that handed the turn back wants you, whether or not the desktop app has ever seen
450
- // it — the only way a terminal-only thread can ask for anything at all.
451
- if (waiting) thread.unread = true
452
- if (errands) thread.subagents = errands
453
- if (peers.length) thread.awaiting = peers.map((p) => ({ id: ID(p.sessionId), title: p.name }))
454
- }
455
- threadTranscripts.sweep()
456
- return threads.map(toThread)
457
- }
458
-
459
- /**
460
- * Where the `claude` CLI is, for a machine that has it but no desktop app to answer the deep
461
- * link, or a page that would rather have a terminal. PATH first, then the places its installers
462
- * put it — never inside an application bundle.
463
- */
464
- const CLI_DIRS = [
465
- path.join(HOME, '.local', 'bin'),
466
- path.join(HOME, '.claude', 'local'),
467
- '/opt/homebrew/bin',
468
- '/usr/local/bin',
469
- '/usr/bin',
470
- ]
471
- const VSCODE_EXTENSION_ID = 'anthropic.claude-code'
472
- const EXTENSION = Object.freeze({
473
- name: 'Claude Code for VS Code',
474
- download: 'https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code',
475
- })
476
- const VSCODE = Object.freeze({ name: 'VS Code', download: 'https://code.visualstudio.com/download' })
477
- const APP = Object.freeze({ name: 'Claude', download: 'https://claude.com/download', also: Object.freeze([EXTENSION]) })
478
-
479
- const vscodeExtensionsDir = () => process.env.THREADCRUMBS_VSCODE_EXTENSIONS_DIR || path.join(HOME, '.vscode', 'extensions')
480
-
481
- async function vscodeExtension() {
482
- const root = vscodeExtensionsDir()
483
- let installed
484
- try {
485
- installed = JSON.parse(await fsp.readFile(path.join(root, 'extensions.json'), 'utf8'))
486
- } catch {
487
- return null
488
- }
489
- const entry = Array.isArray(installed) ? installed.find((e) => String(e?.identifier?.id).toLowerCase() === VSCODE_EXTENSION_ID) : null
490
- const folder = typeof entry?.relativeLocation === 'string' ? path.join(root, path.basename(entry.relativeLocation)) : ''
491
- if (!folder || !(await exists(folder))) return null
492
- const cli = path.join(folder, 'resources', 'native-binary', process.platform === 'win32' ? 'claude.exe' : 'claude')
493
- return { cli: (await exists(cli)) ? cli : null }
494
- }
495
-
496
- const claudeCli = async (extension) => (await findExecutable('claude', CLI_DIRS)) || extension?.cli || null
497
-
498
- const vscodeFolder = (dir) => `vscode://file/${encodeURI(dir.replace(/\\/g, '/'))}`
499
-
500
- function inVsCode(extension, dir, session) {
501
- if (!extension || typeof dir !== 'string' || !path.isAbsolute(dir)) return undefined
502
- const open = `vscode://${VSCODE_EXTENSION_ID}/open${session ? `?${new URLSearchParams({ session })}` : ''}`
503
- return { app: VSCODE, urls: [vscodeFolder(dir), open], note: "Opened in VS Code's Claude Code panel, since the Claude app isn't installed" }
504
- }
505
-
506
- /**
507
- * The pid of the live CLI process behind a session, if any. `readLiveSessions` probes each pid
508
- * before it counts, because the registry's files outlive their pids.
509
- */
510
- async function liveSessionPid(sessionId) {
511
- return (await readLiveSessions(CLI_LIVE)).get(sessionId)?.pid || 0
512
- }
513
-
514
- /**
515
- * Hands the thread back to Claude Code. `epitaxy/<local_…>` *navigates* the desktop app
516
- * to a thread it already has; `resume` *imports* the transcript, which spawns a second
517
- * untitled session and rewrites the .jsonl — so it is only ever the fallback for threads
518
- * the app has never seen. Ids are pattern-checked before they reach the opener.
519
- *
520
- * A terminal-started thread that is still running gets its live pid handed along too: it
521
- * already has a window somewhere on this machine, and fronting that window is a better answer
522
- * than `resume` importing the transcript into the desktop app as a second, untitled session.
523
- * Whether and how to front it is the app's call — Threadnauts does it in `server/lib/windows.mjs`.
524
- */
525
- async function openThread(ref) {
526
- const { desktopSessionId, cliSessionId, cwd } = ref || {}
527
- let url = ''
528
- if (isDesktopId(desktopSessionId)) url = `claude://claude.ai/epitaxy/${desktopSessionId}`
529
- else if (isCliId(cliSessionId)) url = `claude://resume?session=${cliSessionId}`
530
-
531
- // Only threads the desktop app has never seen: for the rest the deep link navigates to the
532
- // tab the app already has, which is exactly the right window to front.
533
- let pid = 0
534
- if (!isDesktopId(desktopSessionId) && isCliId(cliSessionId)) pid = await liveSessionPid(cliSessionId)
535
-
536
- let command
537
- let fallback
538
- if (isCliId(cliSessionId)) {
539
- const extension = await vscodeExtension()
540
- const bin = await claudeCli(extension)
541
- if (bin) command = { argv: [bin, '--resume', cliSessionId], cwd: typeof cwd === 'string' ? cwd : '' }
542
- if (!pid) fallback = inVsCode(extension, cwd, cliSessionId)
543
- }
544
-
545
- if (!url && !command) return { ok: false, error: 'No openable session id on that thread' }
546
- return { ok: true, url, command, pid, app: APP, fallback }
547
- }
548
-
549
- /**
550
- * A brand new thread rooted in a repo — the same `code/new?folder=` deep link Finder's
551
- * "New Claude Code Session Here" quick action uses. Nothing is resumed and nothing is
552
- * written: the desktop app just opens an empty session with that folder as its workspace.
553
- */
554
- async function newSession(dir) {
555
- // The Scratch plot's folder holds the desktop app's own scratch sessions. A session opened on
556
- // it, or the CLI run in it, would work inside the app's data rather than in a repo of yours.
557
- if (SCRATCH_ROOT.test(dir)) {
558
- return { ok: false, error: 'Scratch threads have no folder of their own; start one in the Claude app' }
559
- }
560
-
561
- const url = `claude://code/new?${new URLSearchParams({ folder: dir })}`
562
- const extension = await vscodeExtension()
563
- const bin = await claudeCli(extension)
564
- const command = bin ? { argv: [bin], cwd: dir } : undefined
565
- return { ok: true, url, command, app: APP, fallback: inVsCode(extension, dir, '') }
566
- }
567
-
568
- export default {
569
- id: 'claude-code',
570
- name: 'Claude Code',
571
- app: APP,
572
- /** Only claim this machine if one of the two stores is actually there. */
573
- detect: async () => (await Promise.all(DESKTOP_STORES.map(exists))).some(Boolean) || (await exists(CLI_PROJECTS)),
574
- scanThreads,
575
- openThread,
576
- newSession,
577
- paths: { DESKTOP_STORES, CLI_PROJECTS, CLI_LIVE },
578
- }
1
+ /**
2
+ * Harness adapter: Claude Code (Anthropic) — the desktop app and the CLI together.
3
+ *
4
+ * Everything that knows the shape of Claude Code's own files lives in this module and in
5
+ * `../lib/claude-transcript.mjs`, the transcript parser it shares with the telemetry reader.
6
+ * No app's scanner reaches past the adapter interface, so adding another harness
7
+ * means writing a sibling of this file rather than editing a scanner. The contract is
8
+ * written down in `harnesses/README.md`.
9
+ *
10
+ * Read-only, without exception. Nothing here writes to Claude Code's files — see the note on
11
+ * archiving in `harnesses/README.md`.
12
+ *
13
+ * Two stores, deliberately merged rather than picked between:
14
+ * - the desktop app keeps one JSON record per thread (title, cwd, model, timestamps), and
15
+ * leaves a marker behind for each thread deleted in it
16
+ * - the CLI keeps the raw transcript, which is the only source for terminal-started work
17
+ */
18
+ import fsp from 'node:fs/promises'
19
+ import path from 'node:path'
20
+ import os from 'node:os'
21
+ import { SCRATCH_ROOT, claudeDesktopStores, desktopRecordReader, projectOf } from '../lib/claude-desktop.mjs'
22
+ import { awaitedPeers, readLiveSessions, stripNoise, transcriptReader, turnEnding } from '../lib/claude-transcript.mjs'
23
+ import { exists, findExecutable, listDirs, listFiles, num } from '../lib/fsutil.mjs'
24
+
25
+ const HOME = os.homedir()
26
+
27
+ /**
28
+ * Both roots take an override, which is how the tests fake an install without touching a real
29
+ * one. `CLAUDE_CONFIG_DIR` is the CLI's own: a shell that sets it has its transcripts written
30
+ * there, so the variable that moves the CLI's home moves where the colony looks for it too.
31
+ * `THREADCRUMBS_CLAUDE_DESKTOP_DIR` names the session store itself, the sibling of Cursor's
32
+ * `THREADCRUMBS_CURSOR_PROJECTS_DIR`.
33
+ */
34
+ /** Where the Claude desktop app keeps one JSON record per thread — one store per app profile. */
35
+ const DESKTOP_STORES = claudeDesktopStores()
36
+ /** The CLI's home: `~/.claude`, unless the CLI itself has been told otherwise. */
37
+ const CLI_HOME = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude')
38
+ /** Where the CLI keeps the raw transcript: ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl */
39
+ const CLI_PROJECTS = path.join(CLI_HOME, 'projects')
40
+ /** One file per live CLI process: {pid, sessionId, cwd, ...}. Stale files outlive their pid. */
41
+ const CLI_LIVE = path.join(CLI_HOME, 'sessions')
42
+
43
+ const threadTranscripts = transcriptReader({ skim: true })
44
+ const errandTranscripts = transcriptReader({ skim: true })
45
+
46
+ /**
47
+ * How recently a session must have done something to count as "active now".
48
+ * A live process on its own is not enough: the desktop app pre-warms idle sessions, so
49
+ * threads untouched for days still hold a CLI process. Measured against real data, the
50
+ * warmed ones sat 16 hours to 3 days idle while genuinely active work was minutes old.
51
+ */
52
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
53
+
54
+ /**
55
+ * How long a subagent's transcript may sit untouched before its errand counts as abandoned.
56
+ * A subagent runs inside its parent's process, so the pid probe the rest of this file uses does
57
+ * not exist for it, and one that is thinking writes nothing for minutes at a time — at two or
58
+ * three minutes they blink out while still working.
59
+ */
60
+ const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
61
+
62
+ /**
63
+ * Every id this adapter hands out is prefixed. `harnesses/README.md` asks for ids unique
64
+ * across harnesses, and while two UUIDs will not collide, the colony keys its archive list and
65
+ * saved layout on this string — so it is worth being unambiguous rather than merely lucky.
66
+ */
67
+ const ID = (raw) => `claude-code:${raw}`
68
+
69
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
70
+
71
+ /**
72
+ * A title from a prompt, for a thread the desktop app never named: its first line that says
73
+ * anything, and no more of it than every other harness keeps. The fallback used to hand back
74
+ * the whole prompt — a headless orchestrator run opens with three kilobytes of instructions, and six
75
+ * of those were a third of every poll's payload, laid out as a title.
76
+ */
77
+ const TITLE_MAX = 120
78
+ export function titleFromPrompt(prompt) {
79
+ const line = String(prompt || '')
80
+ .split('\n')
81
+ .map((text) => text.replace(/\s+/g, ' ').trim())
82
+ .find(Boolean)
83
+ return line ? line.slice(0, TITLE_MAX) : 'Untitled thread'
84
+ }
85
+
86
+ const DESKTOP_ID = /^local_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
87
+ // The type check matters wherever an id came back from the page: `RegExp.test` stringifies, so a
88
+ // one-element array holding a valid id would pass the pattern and then travel on as an array.
89
+ const isCliId = (v) => typeof v === 'string' && UUID.test(v)
90
+ const isDesktopId = (v) => typeof v === 'string' && DESKTOP_ID.test(v)
91
+
92
+ /** A prompt as one line of text: the noise gone and every run of whitespace a single space. */
93
+ function cleanPrompt(text) {
94
+ return stripNoise(text).replace(/\s+/g, ' ').trim()
95
+ }
96
+
97
+ /**
98
+ * Pull whatever a transcript knows about itself: title, cwd, branch, start time.
99
+ * Mirrors the CLI's own title precedence: custom > ai > summary > first prompt.
100
+ */
101
+ function metaOf(state) {
102
+ const prompt = state?.firstPrompt || ''
103
+ return {
104
+ customTitle: state?.customTitle || '',
105
+ aiTitle: state?.aiTitle || '',
106
+ summary: state?.summary || '',
107
+ firstPrompt: cleanPrompt(prompt),
108
+ // Taken from the prompt as the transcript has it: `firstPrompt` has its line breaks
109
+ // collapsed, so a title worked out from that could never find the first line.
110
+ promptTitle: prompt ? titleFromPrompt(prompt) : '',
111
+ cwd: state?.firstCwd || '',
112
+ gitBranch: state?.firstBranch || '',
113
+ startedAt: state?.startedAt || 0,
114
+ }
115
+ }
116
+
117
+ async function readTranscript(entry) {
118
+ try {
119
+ return await threadTranscripts.read(entry.file, { size: entry.size })
120
+ } catch {
121
+ return null
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Best-effort reverse of the encoding used for project folder names: `-Users-you-Some-Dir`
127
+ * on macOS, `C--Users-you-Some-Dir` on Windows, where the drive's colon became a dash too.
128
+ */
129
+ function decodeProjectDir(name) {
130
+ const drive = /^([A-Za-z])--(.*)$/.exec(name)
131
+ if (drive) return `${drive[1]}:\\${drive[2].replace(/-/g, '\\')}`
132
+ return name.startsWith('-') ? '/' + name.slice(1).replace(/-/g, '/') : name
133
+ }
134
+
135
+ /**
136
+ * Index every CLI transcript on disk, keyed by session id.
137
+ *
138
+ * A session id is only unique *per directory* it has ever run in — resuming it from a git
139
+ * worktree, or any other second checkout, writes a second `<id>.jsonl` under a different
140
+ * `projectDir` rather than moving the first. Without a tiebreaker the later `listDirs` entry
141
+ * would win regardless of which copy is actually live, so a worktree's now-stale copy could
142
+ * overwrite the real one just by sorting after it. The newer file is always the live one: an
143
+ * abandoned copy stops being written to the moment the session moves on.
144
+ */
145
+ async function scanTranscripts() {
146
+ const byId = new Map()
147
+ for (const projectDir of await listDirs(CLI_PROJECTS)) {
148
+ for (const file of await listFiles(projectDir, (n) => n.endsWith('.jsonl'))) {
149
+ const id = path.basename(file, '.jsonl')
150
+ let stat
151
+ try {
152
+ stat = await fsp.stat(file)
153
+ } catch {
154
+ continue
155
+ }
156
+ const existing = byId.get(id)
157
+ if (existing && existing.mtime >= stat.mtimeMs) continue
158
+ byId.set(id, { id, file, projectDir, size: stat.size, mtime: stat.mtimeMs })
159
+ }
160
+ }
161
+ return byId
162
+ }
163
+
164
+ /**
165
+ * Whether a transcript ends with the turn handed back to you.
166
+ *
167
+ * A live process is not the same thing as work in progress. The CLI holds its process open while
168
+ * it sits at the prompt, so "the pid exists and the file moved recently" marks a thread that
169
+ * finished four minutes ago and asked you a question as *working* — an astronaut hammering away
170
+ * at a thread whose whole point is that it is waiting.
171
+ *
172
+ * The transcript says which it is. A last assistant message that called a tool is mid-turn; one
173
+ * that called nothing has handed the turn back and the reply is yours. `stop_reason` alone will
174
+ * not do — it is `end_turn` on a main thread's last message and empty on some others — so what
175
+ * the message *called* is the half worth testing.
176
+ *
177
+ * The shared transcript reader keeps that answer up to date as it reads each transcript's new
178
+ * lines, so asking it costs no read of its own.
179
+ */
180
+ const MID_TURN = { handedBack: false, failed: false, onAgents: false }
181
+
182
+ /**
183
+ * The subagents a session has out right now, keyed by the session that sent them. Their
184
+ * transcripts live one level below the session's own, in
185
+ * `<project>/<sessionId>/subagents/agent-<id>.jsonl`.
186
+ *
187
+ * Only sessions with a live process are walked. Every other session on disk has subagent
188
+ * transcripts too, and all of that work would be thrown away: an errand cannot outlive the
189
+ * process running it.
190
+ */
191
+ async function scanSubagents(livePending) {
192
+ const byParent = new Map()
193
+ const live = await livePending
194
+ const cutoff = Date.now() - SUBAGENT_WINDOW_MS
195
+ for (const projectDir of live.size ? await listDirs(CLI_PROJECTS) : []) {
196
+ for (const sessionDir of await listDirs(projectDir)) {
197
+ const parent = path.basename(sessionDir)
198
+ if (!live.has(parent)) continue
199
+ const dir = path.join(sessionDir, 'subagents')
200
+ for (const file of await listFiles(dir, (n) => n.endsWith('.jsonl'))) {
201
+ const stat = await fsp.stat(file).catch(() => null)
202
+ if (!stat || stat.mtimeMs < cutoff) continue
203
+ // A subagent that has handed its answer back is finished however recently it wrote —
204
+ // the same question `turnEnding` answers for a thread, put to the errand's own transcript.
205
+ const state = await errandTranscripts.read(file, stat, { sidechain: true }).catch(() => null)
206
+ if (turnEnding(state).handedBack) continue
207
+ const out = byParent.get(parent) || []
208
+ out.push({
209
+ id: path.basename(file, '.jsonl'),
210
+ // The prompt a parent handed its subagent, which is the only thing about an errand
211
+ // worth showing. A forked subagent opens with a `fork-context-ref` and inherits its
212
+ // parent's context instead of being briefed, so it has no task text to find.
213
+ task: cleanPrompt(state?.firstPrompt || ''),
214
+ lastActivityAt: stat.mtimeMs,
215
+ })
216
+ byParent.set(parent, out)
217
+ }
218
+ }
219
+ }
220
+ // Errands are many and short-lived, so the reader keeps only the transcripts this pass read.
221
+ errandTranscripts.sweep()
222
+ return byParent
223
+ }
224
+
225
+ const readDesktopRecords = desktopRecordReader()
226
+
227
+ /** Every thread the desktop app has a record for, and the ids of the ones it has deleted. */
228
+ const scanDesktopSessions = () => readDesktopRecords(DESKTOP_STORES)
229
+
230
+ /**
231
+ * Two desktop records can point at one transcript — resuming a thread that is already
232
+ * open makes the app write a second, untitled record. Keep the richer of the two.
233
+ */
234
+ function mergeThread(existing, next) {
235
+ const better = (a, b) => (a && a !== 'Untitled thread' ? a : b || a)
236
+ // The titled record is the real thread; an untitled twin is the import ghost. Point
237
+ // the canonical id at the real one, but keep both so archiving covers the ghost too.
238
+ const keepExisting = existing.titled || !next.titled
239
+ return {
240
+ ...existing,
241
+ ...next,
242
+ title: better(existing.title, next.title),
243
+ titled: existing.titled || next.titled,
244
+ preview: existing.preview || next.preview,
245
+ desktopSessionId: keepExisting ? existing.desktopSessionId : next.desktopSessionId,
246
+ desktopSessionIds: [...new Set([...existing.desktopSessionIds, ...next.desktopSessionIds])],
247
+ bridgeSessionId: existing.bridgeSessionId || next.bridgeSessionId,
248
+ model: existing.model || next.model,
249
+ effort: existing.effort || next.effort,
250
+ gitBranch: existing.gitBranch || next.gitBranch,
251
+ cwd: existing.cwd || next.cwd,
252
+ createdAt: Math.min(existing.createdAt || Infinity, next.createdAt || Infinity) || 0,
253
+ lastActivityAt: Math.max(existing.lastActivityAt || 0, next.lastActivityAt || 0),
254
+ lastFocusedAt: Math.max(existing.lastFocusedAt || 0, next.lastFocusedAt || 0),
255
+ hasError: existing.hasError || next.hasError,
256
+ turnOutcome: existing.turnOutcome || next.turnOutcome,
257
+ hasLiveProcess: existing.hasLiveProcess || next.hasLiveProcess,
258
+ starred: existing.starred || next.starred,
259
+ routine: existing.routine || next.routine,
260
+ prState: existing.prState || next.prState,
261
+ archived: existing.archived && next.archived,
262
+ hasTranscript: existing.hasTranscript || next.hasTranscript,
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Fold the adapter's private bookkeeping into the shape the rest of the app sees.
268
+ * The session ids stay, but behind `ref` — an opaque blob the browser hands straight
269
+ * back on open/archive, so nothing outside this file has to know what a Claude session
270
+ * id looks like.
271
+ */
272
+ function toThread(t) {
273
+ const {
274
+ desktopSessionId, desktopSessionIds, cliSessionId, bridgeSessionId,
275
+ titled, hasLiveProcess, transcriptFile, recordActivityAt, turnOutcome, ...rest
276
+ } = t
277
+ return {
278
+ ...rest,
279
+ canOpen: isDesktopId(desktopSessionId) || isCliId(cliSessionId),
280
+ // The cwd rides along because resuming from a terminal has to happen in the folder the
281
+ // session ran in — the worktree, not the repo root.
282
+ ref: { desktopSessionId, desktopSessionIds, cliSessionId, cwd: t.cwd || '' },
283
+ }
284
+ }
285
+
286
+ async function scanThreads() {
287
+ // The subagent walk needs the live set, and waiting for it here would serialise scans the
288
+ // README says must never block — so it is handed the promise and waits on it itself.
289
+ const livePending = readLiveSessions(CLI_LIVE)
290
+ const [{ records: desktop, deleted }, transcripts, live, subagents] = await Promise.all([
291
+ scanDesktopSessions(),
292
+ scanTranscripts(),
293
+ livePending,
294
+ scanSubagents(livePending),
295
+ ])
296
+ const states = new Map()
297
+ const read = async (entry) => {
298
+ const state = await readTranscript(entry)
299
+ if (state) states.set(entry.file, state)
300
+ return metaOf(state)
301
+ }
302
+ const byId = new Map()
303
+ const add = (thread) => {
304
+ const existing = byId.get(thread.id)
305
+ byId.set(thread.id, existing ? mergeThread(existing, thread) : thread)
306
+ }
307
+ const claimed = new Set()
308
+
309
+ for (const s of desktop) {
310
+ const cliSessionId = s.cliSessionId || ''
311
+ const entry = cliSessionId ? transcripts.get(cliSessionId) : null
312
+ if (entry) claimed.add(cliSessionId)
313
+
314
+ const cwd = s.cwd || s.originCwd || ''
315
+ const { projectPath, project, worktree } = projectOf(cwd, s.originCwd)
316
+ const meta = entry ? await read(entry) : null
317
+ const spokeAt = entry ? states.get(entry.file)?.lastAt || 0 : 0
318
+
319
+ add({
320
+ id: ID(cliSessionId || s.sessionId),
321
+ cliSessionId,
322
+ desktopSessionId: s.sessionId || '',
323
+ desktopSessionIds: s.sessionId ? [s.sessionId] : [],
324
+ titled: Boolean(s.title),
325
+ bridgeSessionId: (s.bridgeSessionIds && s.bridgeSessionIds[0]) || '',
326
+ title: titleFromPrompt(s.title || meta?.customTitle || meta?.aiTitle || meta?.summary || meta?.promptTitle),
327
+ preview: meta?.firstPrompt ? meta.firstPrompt.slice(0, 240) : '',
328
+ project,
329
+ projectPath,
330
+ worktree,
331
+ cwd,
332
+ gitBranch: meta?.gitBranch || '',
333
+ model: s.model || '',
334
+ effort: s.effort || '',
335
+ createdAt: num(s.createdAt) || meta?.startedAt || 0,
336
+ lastActivityAt:
337
+ spokeAt ||
338
+ Math.max(
339
+ num(s.lastActivityAt) || num(s.lastFocusedAt) || num(s.createdAt) || 0,
340
+ entry?.mtime || 0
341
+ ),
342
+ recordActivityAt: num(s.lastActivityAt) || num(s.lastFocusedAt) || num(s.createdAt) || 0,
343
+ lastFocusedAt: num(s.lastFocusedAt),
344
+ hasLiveProcess: live.has(cliSessionId),
345
+ hasError: Boolean(s.error) || s.turnOutcome === 'failed',
346
+ turnOutcome: s.turnOutcome || '',
347
+ starred: s.isStarred === true,
348
+ routine: s.scheduledTaskId || '',
349
+ prState: s.prState || '',
350
+ archived: s.isArchived === true || s.isArchived === 'True',
351
+ hasTranscript: Boolean(entry),
352
+ sizeBytes: entry?.size || 0,
353
+ transcriptFile: entry?.file || '',
354
+ source: 'desktop',
355
+ })
356
+ }
357
+
358
+ // Transcripts with no desktop record — threads started straight from the terminal, and threads
359
+ // the app has since deleted.
360
+ for (const [id, entry] of transcripts) {
361
+ if (claimed.has(id)) continue
362
+ const meta = await read(entry)
363
+ const cwd = meta.cwd || decodeProjectDir(path.basename(entry.projectDir))
364
+ const { projectPath, project, worktree } = projectOf(cwd, '')
365
+ add({
366
+ id: ID(id),
367
+ cliSessionId: id,
368
+ desktopSessionId: '',
369
+ desktopSessionIds: [],
370
+ titled: Boolean(meta.customTitle || meta.aiTitle),
371
+ bridgeSessionId: '',
372
+ title: titleFromPrompt(meta.customTitle || meta.aiTitle || meta.summary || meta.promptTitle),
373
+ preview: meta.firstPrompt ? meta.firstPrompt.slice(0, 240) : '',
374
+ project,
375
+ projectPath,
376
+ worktree,
377
+ cwd,
378
+ gitBranch: meta.gitBranch,
379
+ model: '',
380
+ effort: '',
381
+ createdAt: meta.startedAt || entry.mtime,
382
+ lastActivityAt: states.get(entry.file)?.lastAt || entry.mtime,
383
+ lastFocusedAt: 0,
384
+ hasLiveProcess: live.has(id),
385
+ hasError: false,
386
+ turnOutcome: '',
387
+ starred: false,
388
+ routine: '',
389
+ prState: '',
390
+ // Deleted in the app, and reported the way an archive made there is. `archived` is the
391
+ // read-only field an adapter has for "gone from the harness's own UI", and the colony sends
392
+ // the astronaut home for it exactly as it does for an archive of its own — rather than the
393
+ // thread simply vanishing from one scan to the next. Only a transcript with no record left
394
+ // qualifies: resuming a deleted thread makes the app write a fresh record while the marker
395
+ // stays behind, and a record that exists is the newer truth.
396
+ archived: deleted.has(id),
397
+ hasTranscript: true,
398
+ sizeBytes: entry.size,
399
+ transcriptFile: entry?.file || '',
400
+ source: 'cli',
401
+ })
402
+ }
403
+
404
+ const now = Date.now()
405
+
406
+ /**
407
+ * Drop the app's empty bookkeeping records.
408
+ *
409
+ * Resuming a thread makes the desktop app write a second record for the same conversation, and
410
+ * one of the two carries the title and the transcript link while the other carries nothing.
411
+ * With no `cliSessionId` on the empty one there is no key to merge the pair on, so it survives
412
+ * as a thread of its own: an untitled entry with no transcript behind it, standing on the map
413
+ * as a nameless twin of a thread you have already dealt with.
414
+ *
415
+ * A record with no transcript, no title and no live process is not a conversation. The age
416
+ * check keeps a genuinely new session — opened seconds ago, nothing written yet — out of it.
417
+ */
418
+ const NEW_SESSION_MS = 10 * 60 * 1000
419
+ const threads = [...byId.values()].filter(
420
+ (t) =>
421
+ t.hasTranscript ||
422
+ t.titled ||
423
+ t.hasLiveProcess ||
424
+ now - (t.lastActivityAt || t.createdAt || 0) < NEW_SESSION_MS
425
+ )
426
+
427
+ // Unread = a reply came back after you last looked at it; never opened counts as unread.
428
+ // Terminal-only threads have no focus history at all, so "unread" is unknowable — not true.
429
+ const handedBackAt = (sessionId) => states.get(transcripts.get(sessionId)?.file)?.handedBackAt || 0
430
+ for (const thread of threads) {
431
+ const seenAt = handedBackAt(thread.cliSessionId) || (thread.recordActivityAt ?? thread.lastActivityAt)
432
+ thread.unread = thread.desktopSessionIds.length > 0 && seenAt > thread.lastFocusedAt
433
+ const fresh = now - thread.lastActivityAt < ACTIVE_WINDOW_MS
434
+ const reported = live.get(thread.cliSessionId)?.status || ''
435
+ const errands = subagents.get(thread.cliSessionId)
436
+ const peers = thread.hasLiveProcess ? awaitedPeers(states.get(thread.transcriptFile), live, handedBackAt) : []
437
+ const ending =
438
+ thread.hasLiveProcess && (fresh || errands || peers.length) && thread.transcriptFile && (!reported || reported === 'idle' || (reported === 'busy' && errands))
439
+ ? turnEnding(states.get(thread.transcriptFile))
440
+ : MID_TURN
441
+ const delegating =
442
+ (Boolean(errands) && (reported === '' || reported === 'busy') && (ending.handedBack || ending.onAgents)) ||
443
+ (peers.length > 0 && (reported === '' || reported === 'idle') && ending.handedBack)
444
+ const waiting = thread.hasLiveProcess && fresh && !delegating && (reported ? reported === 'idle' : ending.handedBack)
445
+ thread.running = thread.hasLiveProcess && fresh && !delegating && (reported ? reported === 'busy' : !ending.handedBack)
446
+ thread.awaitingSubagents = delegating
447
+ thread.needsInput = reported === 'waiting' || (!thread.running && !delegating && thread.turnOutcome === 'blocked')
448
+ if (waiting && ending.failed) thread.hasError = true
449
+ // A thread that handed the turn back wants you, whether or not the desktop app has ever seen
450
+ // it — the only way a terminal-only thread can ask for anything at all.
451
+ if (waiting) thread.unread = true
452
+ if (errands) thread.subagents = errands
453
+ if (peers.length) thread.awaiting = peers.map((p) => ({ id: ID(p.sessionId), title: p.name }))
454
+ }
455
+ threadTranscripts.sweep()
456
+ return threads.map(toThread)
457
+ }
458
+
459
+ /**
460
+ * Where the `claude` CLI is, for a machine that has it but no desktop app to answer the deep
461
+ * link, or a page that would rather have a terminal. PATH first, then the places its installers
462
+ * put it — never inside an application bundle.
463
+ */
464
+ const CLI_DIRS = [
465
+ path.join(HOME, '.local', 'bin'),
466
+ path.join(HOME, '.claude', 'local'),
467
+ '/opt/homebrew/bin',
468
+ '/usr/local/bin',
469
+ '/usr/bin',
470
+ ]
471
+ const VSCODE_EXTENSION_ID = 'anthropic.claude-code'
472
+ const EXTENSION = Object.freeze({
473
+ name: 'Claude Code for VS Code',
474
+ download: 'https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code',
475
+ })
476
+ const VSCODE = Object.freeze({ name: 'VS Code', download: 'https://code.visualstudio.com/download' })
477
+ const APP = Object.freeze({ name: 'Claude', download: 'https://claude.com/download', also: Object.freeze([EXTENSION]) })
478
+
479
+ const vscodeExtensionsDir = () => process.env.THREADCRUMBS_VSCODE_EXTENSIONS_DIR || path.join(HOME, '.vscode', 'extensions')
480
+
481
+ async function vscodeExtension() {
482
+ const root = vscodeExtensionsDir()
483
+ let installed
484
+ try {
485
+ installed = JSON.parse(await fsp.readFile(path.join(root, 'extensions.json'), 'utf8'))
486
+ } catch {
487
+ return null
488
+ }
489
+ const entry = Array.isArray(installed) ? installed.find((e) => String(e?.identifier?.id).toLowerCase() === VSCODE_EXTENSION_ID) : null
490
+ const folder = typeof entry?.relativeLocation === 'string' ? path.join(root, path.basename(entry.relativeLocation)) : ''
491
+ if (!folder || !(await exists(folder))) return null
492
+ const cli = path.join(folder, 'resources', 'native-binary', process.platform === 'win32' ? 'claude.exe' : 'claude')
493
+ return { cli: (await exists(cli)) ? cli : null }
494
+ }
495
+
496
+ const claudeCli = async (extension) => (await findExecutable('claude', CLI_DIRS)) || extension?.cli || null
497
+
498
+ const vscodeFolder = (dir) => `vscode://file/${encodeURI(dir.replace(/\\/g, '/'))}`
499
+
500
+ function inVsCode(extension, dir, session) {
501
+ if (!extension || typeof dir !== 'string' || !path.isAbsolute(dir)) return undefined
502
+ const open = `vscode://${VSCODE_EXTENSION_ID}/open${session ? `?${new URLSearchParams({ session })}` : ''}`
503
+ return { app: VSCODE, urls: [vscodeFolder(dir), open], note: "Opened in VS Code's Claude Code panel, since the Claude app isn't installed" }
504
+ }
505
+
506
+ /**
507
+ * The pid of the live CLI process behind a session, if any. `readLiveSessions` probes each pid
508
+ * before it counts, because the registry's files outlive their pids.
509
+ */
510
+ async function liveSessionPid(sessionId) {
511
+ return (await readLiveSessions(CLI_LIVE)).get(sessionId)?.pid || 0
512
+ }
513
+
514
+ /**
515
+ * Hands the thread back to Claude Code. `epitaxy/<local_…>` *navigates* the desktop app
516
+ * to a thread it already has; `resume` *imports* the transcript, which spawns a second
517
+ * untitled session and rewrites the .jsonl — so it is only ever the fallback for threads
518
+ * the app has never seen. Ids are pattern-checked before they reach the opener.
519
+ *
520
+ * A terminal-started thread that is still running gets its live pid handed along too: it
521
+ * already has a window somewhere on this machine, and fronting that window is a better answer
522
+ * than `resume` importing the transcript into the desktop app as a second, untitled session.
523
+ * Whether and how to front it is the app's call — Threadnauts does it in `server/lib/windows.mjs`.
524
+ */
525
+ async function openThread(ref) {
526
+ const { desktopSessionId, cliSessionId, cwd } = ref || {}
527
+ let url = ''
528
+ if (isDesktopId(desktopSessionId)) url = `claude://claude.ai/epitaxy/${desktopSessionId}`
529
+ else if (isCliId(cliSessionId)) url = `claude://resume?session=${cliSessionId}`
530
+
531
+ // Only threads the desktop app has never seen: for the rest the deep link navigates to the
532
+ // tab the app already has, which is exactly the right window to front.
533
+ let pid = 0
534
+ if (!isDesktopId(desktopSessionId) && isCliId(cliSessionId)) pid = await liveSessionPid(cliSessionId)
535
+
536
+ let command
537
+ let fallback
538
+ if (isCliId(cliSessionId)) {
539
+ const extension = await vscodeExtension()
540
+ const bin = await claudeCli(extension)
541
+ if (bin) command = { argv: [bin, '--resume', cliSessionId], cwd: typeof cwd === 'string' ? cwd : '' }
542
+ if (!pid) fallback = inVsCode(extension, cwd, cliSessionId)
543
+ }
544
+
545
+ if (!url && !command) return { ok: false, error: 'No openable session id on that thread' }
546
+ return { ok: true, url, command, pid, app: APP, fallback }
547
+ }
548
+
549
+ /**
550
+ * A brand new thread rooted in a repo — the same `code/new?folder=` deep link Finder's
551
+ * "New Claude Code Session Here" quick action uses. Nothing is resumed and nothing is
552
+ * written: the desktop app just opens an empty session with that folder as its workspace.
553
+ */
554
+ async function newSession(dir) {
555
+ // The Scratch plot's folder holds the desktop app's own scratch sessions. A session opened on
556
+ // it, or the CLI run in it, would work inside the app's data rather than in a repo of yours.
557
+ if (SCRATCH_ROOT.test(dir)) {
558
+ return { ok: false, error: 'Scratch threads have no folder of their own; start one in the Claude app' }
559
+ }
560
+
561
+ const url = `claude://code/new?${new URLSearchParams({ folder: dir })}`
562
+ const extension = await vscodeExtension()
563
+ const bin = await claudeCli(extension)
564
+ const command = bin ? { argv: [bin], cwd: dir } : undefined
565
+ return { ok: true, url, command, app: APP, fallback: inVsCode(extension, dir, '') }
566
+ }
567
+
568
+ export default {
569
+ id: 'claude-code',
570
+ name: 'Claude Code',
571
+ app: APP,
572
+ /** Only claim this machine if one of the two stores is actually there. */
573
+ detect: async () => (await Promise.all(DESKTOP_STORES.map(exists))).some(Boolean) || (await exists(CLI_PROJECTS)),
574
+ scanThreads,
575
+ openThread,
576
+ newSession,
577
+ paths: { DESKTOP_STORES, CLI_PROJECTS, CLI_LIVE },
578
+ }