@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.
- package/README.md +2 -2
- package/harnesses/claude-code.mjs +578 -578
- package/harnesses/hermes.mjs +2 -1
- package/lib/claude-transcript.mjs +16 -0
- package/lib/opencode-store.mjs +734 -734
- package/license/store.mjs +192 -155
- package/package.json +1 -1
- package/telemetry/README.md +290 -290
- package/telemetry/claude-code.mjs +3 -2
- package/telemetry/codex.mjs +700 -700
- package/telemetry/hermes.mjs +441 -441
- package/ui/rows.css +5 -0
|
@@ -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
|
|
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
|
|
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
|
+
}
|