Research threads: /branch into a new VS Code window with a scoped brief, /find threads from verbatim hooks, /threads tree, bookmarks, /connect pass

A Claude Code mod for discursive, multi-agent research. You go deep on a tangent, wander off, and later want to reconnect it to an earlier idea. This mod keeps each line of thought as a thread you can branch, pause, find again and pick back up, without one long conversation swallowing everything.
At the prompt of a terminal session:
/plugin install threads --marketplace mshark1/research-threads
From a shell:
claude plugin install threads --marketplace mshark1/research-threads
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with mshark1/research-threads, then Add plugin.
| Command | What it does | ||
|---|---|---|---|
/threads | The whole thread tree, numbered, with your opening line under each thread | ||
/find <fragment> | Find a thread from something you half-remember; shows your own matching lines | ||
| `/thread <n\ | id>` | One thread: how you got there, where it went, bookmark, latest hooks | |
/split <spark> | Bookmark this thread, write a brief, and start the new thread in its own session (--new skips the "looks like an existing thread" check) | ||
| `/pickup <n\ | id> [full]` | Switch to a thread: a fresh agent from its bookmark, or full to resume its last session | |
| `/thread-here [title] [--under <n\ | id>]` | Turn the current session into a thread | |
/bookmark | Rewrite where this thread left off | ||
/pin [text] | Save the selection (or text) as a hook | ||
/link <from> <to> · /link #<n> | Link two threads, or accept a /connect suggestion | ||
/connect | On demand only: a model suggests links between threads as questions; also lists threads paused for over two weeks | ||
| `/thread-archive <n\ | id>` | Move a thread to Threads/.archive (its branches move up a level) | |
| `/thread-open agent\ | tab\ | window` | Where /split and /pickup start the new session |
<n> is a position in the last /threads or /find list.
~/Documents/Projects/Threads/<id>/. Each folder holds thread.json (parent, links, sessions), hooks.jsonl (verbatim lines), bookmark.md, BRIEF.md, and a CLAUDE.md that keeps the thread's agent scoped to the thread./split starts the new session:agent (default): a background session (claude --bg), which you switch to in the agent view (claude agents).window: a new VS Code window on the thread folder. Claude starts there through a folder-open task, which needs "task.allowAutomaticTasks": "on".tab: a Claude Code extension tab. This was never seen working.claude once in the Threads folder and accept the trust prompt.cmd, code and explorer.exe.Claude Code with function-hook mods (2.1.30x or later).
hooks/register.tsx 634 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 ancestry,
5 bestPerThread,
6 briefFor,
7 children,
8 CLAUDE_MD,
9 clip,
10 type Hook,
11 mark,
12 parseJson,
13 search,
14 slug,
15 type Thread,
16 tree,
17} from './lib'
18
19type $ = EngineInterface
20type Suggestion = { a: string; b: string; question: string }
21
22const COMMANDS = {
23 split: ['Split a new thread off this one into its own Claude tab or window', '<spark> [--new]'],
24 find: ['Find a thread from a half-remembered fragment', '<fragment>'],
25 threads: ['Show the thread tree', ''],
26 thread: ['Show one thread: path, bookmark, hooks', '<id>'],
27 pickup: ['Pick a paused thread back up in a new window', '<id> [full]'],
28 bookmark: ["Refresh this thread's where-I-left-off", ''],
29 pin: ['Pin the selection (or text) as a verbatim hook', '[text]'],
30 link: ['Link two threads (or accept a /connect suggestion)', '<from> <to> | #<n>'],
31 connect: ['Run a connections pass over all bookmarks', ''],
32 'thread-open': ['Where /split and /pickup open: background agent, Claude tab, or new window', 'agent|tab|window'],
33 'thread-archive': ['Archive a thread (moved to Threads/.archive; its children move up a level)', '<n|id>'],
34 'thread-here': ['Make this session a thread (optionally under another)', '[title] [--under <n|id>]'],
35} as const
36
37// A suggested-restart match has to beat this score (see lib.search).
38const RESTART_SCORE = 2.2
39
40const BOOKMARK_ASK = `Write a bookmark for this conversation so a fresh agent (or the user, weeks from now) can pick it back up exactly where it stands. Plain markdown, at most 12 short lines, under these bold labels:
41**In flight:** the question or idea being worked on right now.
42**Next:** what was about to be tried.
43**Picture:** the key image, result or claim in mind (say whether it is proven, observed in a sim, or a hunch).
44**Artifacts:** files and artifact links that matter, with one-line purposes.
45Do not summarise the whole history.`
46
47export const register: Register = on => {
48 on('session.start', async ($, e, next) => {
49 const started = await next(e)
50 const names: Record<string, string> = {}
51 for (const [name, [description, argumentHint]] of Object.entries(COMMANDS)) {
52 const spec = argumentHint ? { name, description, argumentHint } : { name, description }
53 // A built-in may own the plain name; fall back to a prefixed one.
54 names[name] = await $.command
55 .register(spec)
56 .then(r => r.command)
57 .catch(() => $.command.register({ ...spec, name: `t-${name}` }).then(r => r.command))
58 }
59 await $.store.set('commandNames', names)
60 await adopt($, e.cwd).catch(() => undefined)
61 return started
62 })
63
64 // The user's own messages are the default hooks, kept verbatim.
65 on('prompt.submit', async ($, e, next) => {
66 if (!e.command && !e.isMeta && e.text.trim()) {
67 const id = await currentId($)
68 if (id) await addHook($, id, { at: now(), text: e.text, by: 'user' }).catch(() => undefined)
69 // A tab opened by /split or /pickup starts in the project folder; its first prompt names the brief.
70 else await adoptByBrief($, e.text).catch(() => undefined)
71 }
72 return next(e)
73 }).catch(($, e, next) => next(e))
74
75 on('session.end', async ($, e, next) => {
76 const id = await currentId($)
77 if (id && e.reason !== 'clear') {
78 const t = await load($, id)
79 if (t) {
80 await bookmarkFromFork($, id).catch(() => undefined)
81 await save($, { ...t, status: 'paused' })
82 }
83 }
84 return next(e)
85 })
86
87 on('command.run', { command: ['split', 't-split'] }, async ($, e) => ({ text: await split($, e.args) })).catch(oops)
88 on('command.run', { command: ['find', 't-find'] }, async ($, e) => ({ text: await find($, e.args, e.presentation.columns) })).catch(oops)
89 on('command.run', { command: ['threads', 't-threads'] }, async ($, e) => ({ text: await showTree($, e.presentation.columns) })).catch(oops)
90 on('command.run', { command: ['thread', 't-thread'] }, async ($, e) => ({ text: await showThread($, e.args.trim(), e.presentation.columns) })).catch(oops)
91 on('command.run', { command: ['pickup', 't-pickup'] }, async ($, e) => ({ text: await pickup($, e.args) })).catch(oops)
92 on('command.run', { command: ['bookmark', 't-bookmark'] }, async $ => ({ text: await refreshBookmark($) })).catch(oops)
93 on('command.run', { command: ['thread-open', 't-thread-open'] }, async ($, e) => ({ text: await setOpenIn($, e.args.trim()) })).catch(oops)
94 on('command.run', { command: ['thread-here', 't-thread-here'] }, async ($, e) => ({ text: await threadHere($, e.args.trim()) })).catch(oops)
95 on('command.run', { command: ['thread-archive', 't-thread-archive'] }, async ($, e) => ({ text: await archive($, e.args.trim()) })).catch(oops)
96 on('command.run', { command: ['pin', 't-pin'] }, async ($, e) => ({ text: await pin($, e.args) })).catch(oops)
97 on('command.run', { command: ['link', 't-link'] }, async ($, e) => ({ text: await link($, e.args) })).catch(oops)
98 on('command.run', { command: ['connect', 't-connect'] }, async $ => ({ text: await connect($) })).catch(oops)
99}
100
101// A failed command still answers, with the reason.
102const oops = (_$: $, _e: unknown, next: { error: { kind: string; message?: string } }) => ({
103 text: `threads: that failed (${next.error.message ?? next.error.kind}).`,
104})
105
106// ---- storage ----
107
108async function root($: $): Promise<string> {
109 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.').replace(/\\/g, '/')
110 return `${home}/Documents/Projects/Threads`
111}
112
113async function dir($: $, id: string): Promise<string> {
114 return `${await root($)}/${id}`
115}
116
117async function load($: $, id: string): Promise<Thread | undefined> {
118 const text = await $.fs.read(`${await dir($, id)}/thread.json`).catch(() => undefined)
119 return text === undefined ? undefined : (JSON.parse(text) as Thread)
120}
121
122async function save($: $, t: Thread): Promise<void> {
123 await $.fs.write(`${await dir($, t.id)}/thread.json`, JSON.stringify({ ...t, updated: now() }, null, 2) + '\n')
124}
125
126async function all($: $): Promise<Record<string, Thread>> {
127 const base = await root($)
128 const entries = await $.fs.list(base).catch(() => [])
129 const out: Record<string, Thread> = {}
130 for (const entry of entries) {
131 if (entry.kind !== 'dir') continue
132 const t = await load($, entry.name)
133 if (t) out[t.id] = t
134 }
135 return out
136}
137
138async function hooks($: $, id: string): Promise<Hook[]> {
139 const text = await $.fs.read(`${await dir($, id)}/hooks.jsonl`).catch(() => '')
140 return text
141 .split('\n')
142 .filter(Boolean)
143 .map(line => JSON.parse(line) as Hook)
144}
145
146async function addHook($: $, id: string, hook: Hook): Promise<void> {
147 const path = `${await dir($, id)}/hooks.jsonl`
148 const prior = await $.fs.read(path).catch(() => '')
149 await $.fs.write(path, prior + JSON.stringify(hook) + '\n')
150}
151
152async function bookmarkOf($: $, id: string): Promise<string | undefined> {
153 return $.fs.read(`${await dir($, id)}/bookmark.md`).catch(() => undefined)
154}
155
156async function bookmarks($: $, ids: string[]): Promise<Record<string, string>> {
157 const out: Record<string, string> = {}
158 for (const id of ids) {
159 const b = await bookmarkOf($, id)
160 if (b) out[id] = b
161 }
162 return out
163}
164
165async function sessionMap($: $): Promise<Record<string, string>> {
166 return ((await $.store.get('sessions')) as Record<string, string> | undefined) ?? {}
167}
168
169async function bind($: $, sessionId: string, threadId: string): Promise<void> {
170 await $.store.set('sessions', { ...(await sessionMap($)), [sessionId]: threadId })
171}
172
173async function currentId($: $): Promise<string | undefined> {
174 const sessionId = await $.session.id()
175 const bound = (await sessionMap($))[sessionId]
176 if (bound) return bound
177 // Otherwise the most recently touched thread that lists this session.
178 const owner = Object.values(await all($))
179 .filter(t => t.sessions.some(s => s.id === sessionId))
180 .sort((a, b) => b.updated.localeCompare(a.updated))[0]
181 if (owner) await bind($, sessionId, owner.id)
182 return owner?.id
183}
184
185// A session whose first prompt reads a thread's BRIEF.md belongs to that thread.
186async function adoptByBrief($: $, text: string): Promise<void> {
187 const base = (await root($)).toLowerCase()
188 const flat = text.replace(/\\/g, '/')
189 const at = flat.toLowerCase().indexOf(base + '/')
190 if (at < 0) return
191 const m = flat.slice(at + base.length + 1).match(/^([^/\s]+)\/BRIEF\.md/i)
192 const t = m ? await load($, m[1]!) : undefined
193 if (!t) return
194 const sessionId = await $.session.id()
195 await bind($, sessionId, t.id)
196 await save($, { ...t, status: 'active', sessions: [...t.sessions, { id: sessionId, cwd: await $.session.cwd(), at: now() }] })
197}
198
199// A session started inside a thread folder belongs to that thread.
200async function adopt($: $, cwd: string): Promise<void> {
201 const base = (await root($)).toLowerCase()
202 const here = cwd.replace(/\\/g, '/')
203 if (!here.toLowerCase().startsWith(base + '/')) return
204 const id = here.slice(base.length + 1).split('/')[0]!
205 const t = await load($, id)
206 if (!t) return
207 const sessionId = await $.session.id()
208 await bind($, sessionId, id)
209 const sessions = t.sessions.some(s => s.id === sessionId) ? t.sessions : [...t.sessions, { id: sessionId, cwd, at: now() }]
210 await save($, { ...t, status: 'active', sessions })
211}
212
213async function uniqueId($: $, title: string): Promise<string> {
214 const base = slug(title)
215 let id = base
216 for (let n = 2; await $.fs.exists(`${await dir($, id)}/thread.json`); n++) id = `${base}-${n}`
217 return id
218}
219
220async function create($: $, fields: Omit<Thread, 'id' | 'links' | 'created' | 'updated' | 'status'>): Promise<Thread> {
221 const id = await uniqueId($, fields.title)
222 const t: Thread = { id, links: [], created: now(), updated: now(), status: 'active', ...fields }
223 const folder = await dir($, id)
224 if (!t.workDir) t.workDir = folder
225 await save($, t)
226 await $.fs.write(`${folder}/CLAUDE.md`, CLAUDE_MD)
227 return t
228}
229
230// ---- model calls ----
231
232async function title($: $, text: string): Promise<string> {
233 const r = await $.model.complete({
234 model: 'haiku',
235 effort: 'low',
236 maxTokens: 40,
237 timeoutMs: 15000,
238 prompt: `Give a 2-4 word title for a research thread that starts with this message. Reply with the title alone, no quotes.\n\n<message>\n${text}\n</message>`,
239 })
240 return r.isAnswered && r.text.trim() ? clip(r.text.trim().replace(/^["']|["']$/g, ''), 40) : clip(text, 30)
241}
242
243async function bookmarkFromFork($: $, id: string): Promise<string | undefined> {
244 const r = await $.model.fork({ prompt: BOOKMARK_ASK })
245 if (!r.isAnswered) return undefined
246 await $.fs.write(`${await dir($, id)}/bookmark.md`, r.text.trim() + '\n')
247 return r.text.trim()
248}
249
250// ---- split: start an agent on the thread, in a Claude tab here or in a new VS Code window ----
251
252type Launch = { prompt: string } | { session: string }
253
254type OpenIn = 'agent' | 'tab' | 'window'
255
256const OPEN_IN: Record<OpenIn, string> = {
257 agent: 'a background session in the agent view (claude agents)',
258 tab: 'a Claude Code tab in the current VS Code window',
259 window: "a new VS Code window on the thread's folder",
260}
261
262async function openIn($: $): Promise<OpenIn> {
263 const mode = await $.store.get('openIn')
264 return mode === 'tab' || mode === 'window' ? mode : 'agent'
265}
266
267async function setOpenIn($: $, mode: string): Promise<string> {
268 if (!(mode in OPEN_IN)) return `/split and /pickup open ${OPEN_IN[await openIn($)]}. Change it with /thread-open agent|tab|window.`
269 await $.store.set('openIn', mode)
270 return `/split and /pickup now open ${OPEN_IN[mode as OpenIn]}.`
271}
272
273async function openSplit($: $, t: Thread, launch: Launch, cwd: string): Promise<string> {
274 const folder = await dir($, t.id)
275 const win = (p: string) => p.replace(/\//g, '\\')
276 const claudeArgs = 'session' in launch ? ['--resume', launch.session] : [launch.prompt]
277 if (cwd !== folder && !('session' in launch)) claudeArgs.unshift('--add-dir', win(folder))
278 const manual = `cd "${win(cwd)}" && claude ${claudeArgs.map(a => (/\s/.test(a) ? `"${a}"` : a)).join(' ')}`
279
280 const mode = await openIn($)
281 if (mode === 'agent') {
282 // A background session shows up in `claude agents`; switch to it there.
283 const name = `thread: ${t.title}`
284 const bg = await $.process
285 .run(['claude', '--bg', '-n', name, ...claudeArgs], { cwd: win(cwd), timeoutMs: 60000 })
286 .catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: String(error) }))
287 const said = `${bg.stdout}\n${bg.stderr}`.trim()
288 if (/not trusted/i.test(said)) {
289 return `Claude Code hasn't trusted ${win(cwd)} yet. Run claude there once and accept the trust prompt, then /pickup ${t.id}.`
290 }
291 return bg.exitCode === 0
292 ? `Started "${name}" in the background. Open the agent view (claude agents) to switch to it, or: claude attach "${name}"`
293 : `Couldn't start a background session (${clip(said, 200)}). Start it yourself:\n ${manual}`
294 }
295
296 if (mode === 'tab') {
297 // The Claude Code extension's own link opens a new Claude tab in the focused window.
298 const query = 'session' in launch ? `session=${encodeURIComponent(launch.session)}` : `prompt=${encodeURIComponent(launch.prompt)}`
299 const opened = await $.process.run(['explorer.exe', `vscode://anthropic.claude-code/open?${query}`], { timeoutMs: 20000 }).catch(() => undefined)
300 return opened
301 ? `Opened a Claude tab in this VS Code window. If the prompt is only filled in, press Enter.\n /thread-open window switches back to separate windows. If no tab appeared, run:\n ${manual}`
302 : `Couldn't open a Claude tab. Start it yourself:\n ${manual}`
303 }
304
305 const tasks = {
306 version: '2.0.0',
307 tasks: [
308 {
309 label: `claude: ${t.title}`,
310 type: 'process',
311 command: 'claude',
312 args: claudeArgs,
313 options: { cwd: win(cwd) },
314 runOptions: { runOn: 'folderOpen' },
315 presentation: { reveal: 'always', panel: 'dedicated', focus: true },
316 problemMatcher: [],
317 },
318 ],
319 }
320 await $.fs.write(`${folder}/.vscode/tasks.json`, JSON.stringify(tasks, null, 2) + '\n')
321 const opened = await $.process.run(['cmd', '/c', 'code', win(folder)], { timeoutMs: 20000 }).catch(() => undefined)
322 return opened?.exitCode === 0
323 ? `Opened a new VS Code window on ${win(folder)}.\n If claude doesn't start there, set "task.allowAutomaticTasks": "on" or run:\n ${manual}`
324 : `Couldn't open VS Code. Start it yourself:\n ${manual}`
325}
326
327async function startPrompt($: $, t: Thread): Promise<string> {
328 return `Read ${await dir($, t.id)}/BRIEF.md, then tell me in a few lines where this thread starts from and what you would look at first.`
329}
330
331// Make this session a thread: its first message is the spark, all of the user's messages its hooks.
332async function threadFromSession($: $, opts: { title?: string; parent?: string; fallbackSpark?: string }): Promise<Thread> {
333 const mine = (await $.session.messages()).filter(m => m.role === 'user' && m.text.trim() && !m.text.startsWith('<'))
334 const first = mine[0]?.text ?? opts.fallbackSpark ?? ''
335 const cwd = await $.session.cwd()
336 const t = await create($, {
337 title: opts.title || (await title($, first)),
338 spark: first,
339 parent: opts.parent ?? null,
340 workDir: cwd.replace(/\\/g, '/'),
341 sessions: [{ id: await $.session.id(), cwd, at: now() }],
342 })
343 for (const m of mine) await addHook($, t.id, { at: now(), text: m.text, by: 'user' })
344 await bind($, await $.session.id(), t.id)
345 return t
346}
347
348// ---- commands ----
349
350async function threadHere($: $, args: string): Promise<string> {
351 const already = await currentId($)
352 if (already) return `This session is already the thread [${already}]. /thread for its details.`
353 const under = args.match(/(?:^|\s)--under\s+(\S+)/)
354 const threads = await all($)
355 const parent = under ? await resolve($, under[1]!) : undefined
356 if (parent && !threads[parent]) return `No thread "${under![1]}" to put it under. /threads to look.`
357 const name = args.replace(/(?:^|\s)--under\s+\S+/, '').trim()
358 const t = await threadFromSession($, { title: name || undefined, parent })
359 const hookCount = (await hooks($, t.id)).length
360 const marked = await bookmarkFromFork($, t.id).catch(() => undefined)
361 return [
362 `This session is now the thread ${t.title} [${t.id}]${parent ? ` ← under ${threads[parent]!.title}` : ''}`,
363 ` spark: "${clip(t.spark, 90)}"`,
364 ` ${hookCount} of your messages saved as hooks; ${marked ? 'bookmark written' : 'no bookmark yet (run /bookmark)'}.`,
365 ].join('\n')
366}
367
368async function split($: $, args: string): Promise<string> {
369 const isForced = /(^|\s)--new(\s|$)/.test(args)
370 let spark = args.replace(/(^|\s)--new(\s|$)/, ' ').trim()
371 if (!spark) spark = (await $.ui.selection())?.text.trim() ?? ''
372 if (!spark) {
373 const mine = (await $.session.messages()).filter(m => m.role === 'user' && m.text.trim() && !m.text.startsWith('<'))
374 spark = mine.at(-1)?.text.trim() ?? ''
375 }
376 if (!spark) return 'Give the spark: /split <what started this>'
377
378 const threads = await all($)
379 if (!isForced) {
380 const match = await restartMatch($, spark, threads)
381 if (match) return match
382 }
383
384 // The thread we're leaving: this session's, or a new one for an untracked session.
385 let parentId = await currentId($)
386 if (!parentId) {
387 const parent = await threadFromSession($, { fallbackSpark: spark })
388 parentId = parent.id
389 threads[parentId] = parent
390 }
391
392 const fork = await $.model.fork({
393 prompt: `The user is splitting a new research thread off this conversation. Its spark, in their words:\n<spark>\n${spark}\n</spark>\nReply with JSON only: {"bookmark": "...", "handoff": "..."}.\n"bookmark": ${BOOKMARK_ASK}\n"handoff": what a fresh agent starting the new thread needs from THIS conversation to pursue the spark, at most 10 lines of markdown. Only what bears on the spark.`,
394 })
395 const parsed = fork.isAnswered ? parseJson<{ bookmark?: string; handoff?: string }>(fork.text) : undefined
396 if (parsed?.bookmark) await $.fs.write(`${await dir($, parentId)}/bookmark.md`, parsed.bookmark.trim() + '\n')
397
398 const child = await create($, { title: await title($, spark), spark, parent: parentId, workDir: '', sessions: [] })
399 threads[child.id] = child
400 await addHook($, child.id, { at: now(), text: spark, by: 'user' })
401 const path = ancestry(child.id, threads)
402 const brief = briefFor(child, threads, await bookmarks($, path.map(p => p.id)), parsed?.handoff)
403 await $.fs.write(`${await dir($, child.id)}/BRIEF.md`, brief)
404 const opened = await openSplit($, child, { prompt: await startPrompt($, child) }, await dir($, child.id))
405
406 return [
407 `Bookmarked: ${threads[parentId]!.title}${parsed?.bookmark ? '' : ' (no bookmark: the fork call failed; run /bookmark)'}`,
408 `New thread: ${child.title} [${child.id}] ← from ${threads[parentId]!.title}`,
409 ` "${clip(spark, 90)}"`,
410 ` brief: spark + ${path.length - 1} ancestor bookmark(s), ~${Math.round(brief.length / 4)} tokens`,
411 opened,
412 ].join('\n')
413}
414
415async function restartMatch($: $, spark: string, threads: Record<string, Thread>): Promise<string | undefined> {
416 const corpus = await corpusOf($, threads)
417 const titles = Object.fromEntries(Object.values(threads).map(t => [t.id, t.title]))
418 // One shared word ("test") is a coincidence, not a restart.
419 const top = bestPerThread(search(spark, corpus, titles), 2).filter(h => h.score >= RESTART_SCORE && h.shared >= 3)
420 if (!top.length) return undefined
421 const names = (await $.store.get('commandNames')) as Record<string, string> | undefined
422 const lines = ['This looks like a thread you already started:']
423 for (const h of top) {
424 const t = threads[h.thread]!
425 lines.push(` ${t.title} ${mark(t)} [${t.id}]`, ` "${clip(h.hook.text, 90)}"`)
426 }
427 lines.push(
428 '',
429 `/${names?.pickup ?? 'pickup'} ${top[0]!.thread} to resume it, /${names?.link ?? 'link'} to connect, or /${names?.split ?? 'split'} --new ${clip(spark, 30)} to start fresh anyway.`,
430 )
431 return lines.join('\n')
432}
433
434async function corpusOf($: $, threads: Record<string, Thread>): Promise<{ thread: string; hook: Hook }[]> {
435 const corpus: { thread: string; hook: Hook }[] = []
436 for (const id of Object.keys(threads)) for (const hook of await hooks($, id)) corpus.push({ thread: id, hook })
437 return corpus
438}
439
440// `/thread 3` and `/pickup 3` mean the 3rd entry of the last /threads or /find listing.
441async function resolve($: $, ref: string): Promise<string> {
442 const n = ref.match(/^#?(\d+)$/)
443 if (!n) return ref
444 const listing = ((await $.store.get('listing')) as string[] | undefined) ?? []
445 return listing[Number(n[1]) - 1] ?? ref
446}
447
448async function find($: $, fragment: string, columns: number): Promise<string> {
449 if (!fragment.trim()) return 'What do you remember? /find <fragment>'
450 const threads = await all($)
451 const titles = Object.fromEntries(Object.values(threads).map(t => [t.id, t.title]))
452 const hits = bestPerThread(search(fragment, await corpusOf($, threads), titles), 6)
453 if (!hits.length) return `Nothing matched "${fragment}". Try other words you remember, or /threads for the whole tree.`
454 await $.store.set('listing', hits.map(h => h.thread))
455 const room = Math.max(40, columns - 8)
456 const lines = [`Matches for "${clip(fragment, 40)}":`]
457 hits.forEach((h, i) => {
458 const t = threads[h.thread]!
459 lines.push(clip(`${i + 1}. ${t.title} ${mark(t)} [${t.id}]`, room), ` "${clip(h.hook.text, room - 5)}"`)
460 })
461 lines.push('', '/thread <n> for its path and bookmark, /pickup <n> to switch to it.')
462 return lines.join('\n')
463}
464
465async function showTree($: $, columns: number | undefined): Promise<string> {
466 const threads = await all($)
467 if (!Object.keys(threads).length) return 'No threads yet. /split <spark> starts one.'
468 const first: Record<string, string> = {}
469 for (const id of Object.keys(threads)) first[id] = (await hooks($, id))[0]?.text ?? threads[id]!.spark
470 const { text, order } = tree(threads, first, columns ?? 100)
471 await $.store.set('listing', order)
472 return [
473 `${order.length} threads. /thread <n> to look closer, /pickup <n> to switch to one.`,
474 '',
475 text,
476 '',
477 '● active ⏸ paused ↩ your links (* = from a connections pass)',
478 ].join('\n')
479}
480
481async function showThread($: $, ref: string, columns: number): Promise<string> {
482 const threads = await all($)
483 const id = await resolve($, ref)
484 const room = Math.max(40, columns - 8)
485 const t = threads[id || ((await currentId($)) ?? '')]
486 if (!t) return id ? `No thread "${id}". /find or /threads to look.` : 'This session is not in a thread. /thread <id>'
487 const path = ancestry(t.id, threads)
488 const kids = children(t.id, threads)
489 const backs = Object.values(threads).filter(o => o.links.some(l => l.to === t.id))
490 const hs = await hooks($, t.id)
491 return [
492 `${t.title} ${mark(t)} [${t.id}]`,
493 ` how you got here: ${path.map(p => p.title).join(' → ')}`,
494 ` where it went: ${kids.length ? kids.map(k => `→ ${k.title}`).join(', ') : '(nothing yet)'}`,
495 ...(t.links.length || backs.length
496 ? [` links: ${[...t.links.map(l => threads[l.to]?.title ?? l.to), ...backs.map(b => b.title)].join(', ')}`]
497 : []),
498 '',
499 'Left off:',
500 ((await bookmarkOf($, t.id)) ?? '(no bookmark yet)').trim().replace(/^/gm, ' '),
501 '',
502 `Hooks (${hs.length}, latest last):`,
503 ...hs.slice(-5).map(h => ` ${h.by === 'pin' ? '📌' : '›'} "${clip(h.text, room - 6)}"`),
504 '',
505 `/pickup ${t.id} to resume here (fresh agent from the bookmark), /pickup ${t.id} full to reopen its last session.`,
506 ].join('\n')
507}
508
509async function pickup($: $, args: string): Promise<string> {
510 const [ref = '', mode] = args.trim().split(/\s+/)
511 const threads = await all($)
512 const id = await resolve($, ref)
513 const t = threads[id]
514 if (!t) return `No thread "${id}". /find <fragment> to look for it.`
515 if (mode === 'full') {
516 const last = t.sessions.at(-1)
517 if (!last) return `${t.title} has no recorded session. /pickup ${t.id} starts a fresh agent from its bookmark.`
518 return openSplit($, t, { session: last.id }, last.cwd)
519 }
520 const path = ancestry(t.id, threads)
521 await $.fs.write(`${await dir($, t.id)}/BRIEF.md`, briefFor(t, threads, await bookmarks($, path.map(p => p.id))))
522 const opened = await openSplit($, t, { prompt: await startPrompt($, t) }, t.workDir || (await dir($, t.id)))
523 return `Picking up ${t.title}.\n${opened}`
524}
525
526async function refreshBookmark($: $): Promise<string> {
527 const id = await currentId($)
528 if (!id) return 'This session is not in a thread yet. /split makes it one.'
529 const text = await bookmarkFromFork($, id)
530 return text ? `Bookmark saved for ${id}:\n${text.replace(/^/gm, ' ')}` : 'The bookmark call failed; try again.'
531}
532
533async function pin($: $, args: string): Promise<string> {
534 const id = await currentId($)
535 if (!id) return 'This session is not in a thread yet. /split makes it one.'
536 const text = args.trim() || (await $.ui.selection())?.text.trim()
537 if (!text) return 'Select a line (or pass text) to pin.'
538 await addHook($, id, { at: now(), text, by: 'pin' })
539 return `Pinned to ${id}: "${clip(text, 100)}"`
540}
541
542// Archive rather than delete: the folder moves to Threads/.archive, so it can come back.
543async function archive($: $, ref: string): Promise<string> {
544 if (!ref) return 'Usage: /thread-archive <n|id>. Numbers come from the last /threads or /find.'
545 const threads = await all($)
546 const id = await resolve($, ref)
547 const t = threads[id]
548 if (!t) return `No thread "${id}". /threads to look.`
549
550 const from = (await dir($, id)).replace(/\//g, '\\')
551 const shelf = `${await root($)}/.archive`
552 await $.fs.write(`${shelf}/README.md`, 'Archived threads. Move a folder back up one level to restore it.\n')
553 const to = `${shelf}/${id}-${now().slice(0, 10)}`.replace(/\//g, '\\')
554 const moved = await $.process.run(['cmd', '/c', 'move', from, to], { timeoutMs: 20000 }).catch(() => undefined)
555 if (moved?.exitCode !== 0) {
556 return `Couldn't move ${from} (${clip(`${moved?.stdout ?? ''} ${moved?.stderr ?? ''}`.trim() || 'unknown error', 120)}). If a session is running in that folder, stop it first (claude agents).`
557 }
558
559 // Keep the tree whole: children move up to the archived thread's parent; links to it go.
560 const kids = children(id, threads)
561 for (const other of Object.values(threads)) {
562 if (other.id === id) continue
563 const isKid = other.parent === id
564 const links = other.links.filter(l => l.to !== id)
565 if (isKid || links.length !== other.links.length) await save($, { ...other, parent: isKid ? t.parent : other.parent, links })
566 }
567 const map = await sessionMap($)
568 await $.store.set('sessions', Object.fromEntries(Object.entries(map).filter(([, tid]) => tid !== id)))
569
570 const up = t.parent ? threads[t.parent]?.title : 'the top level'
571 return [
572 `Archived ${t.title} [${id}] to ${to}.`,
573 ...(kids.length ? [` ${kids.map(k => k.title).join(', ')} moved up to ${up}.`] : []),
574 ' To restore it, move the folder back into Threads.',
575 ].join('\n')
576}
577
578async function link($: $, args: string): Promise<string> {
579 const threads = await all($)
580 const pick = args.trim().match(/^#(\d+)$/)
581 let from: string, to: string, by: 'user' | 'pass', note: string | undefined
582 if (pick) {
583 const s = (((await $.store.get('suggestions')) as Suggestion[] | undefined) ?? [])[Number(pick[1]) - 1]
584 if (!s) return `No suggestion #${pick[1]}. /connect lists them.`
585 ;({ a: from, b: to, question: note } = s)
586 by = 'pass'
587 } else {
588 const parts = args.trim().split(/\s+/)
589 if (parts.length < 2) return 'Usage: /link <from-id> <to-id>, or /link #<n> to accept a /connect suggestion.'
590 ;[from, to] = parts as [string, string]
591 by = 'user'
592 }
593 const a = threads[from]
594 if (!a || !threads[to]) return `Unknown thread: ${!a ? from : to}`
595 if (!a.links.some(l => l.to === to)) await save($, { ...a, links: [...a.links, { to, by, ...(note ? { note } : {}) }] })
596 return `Linked ${a.title} ↩ ${threads[to]!.title}${by === 'pass' ? ' (from a connections pass)' : ''}`
597}
598
599async function connect($: $): Promise<string> {
600 const threads = await all($)
601 const ids = Object.keys(threads)
602 if (ids.length < 2) return 'Need at least two threads.'
603 const marks = await bookmarks($, ids)
604 const cards: string[] = []
605 for (const t of Object.values(threads)) {
606 const hs = (await hooks($, t.id)).filter(h => h.by === 'pin' || h.text.length > 40)
607 const quotes = [hs[0], ...hs.slice(1).filter(h => h.by === 'pin').slice(0, 2)].filter(Boolean) as Hook[]
608 const edges = [t.parent ? `parent ${t.parent}` : '', ...t.links.map(l => `linked ${l.to}`)].filter(Boolean).join('; ')
609 cards.push(`## ${t.id}: ${t.title}${edges ? ` (${edges})` : ''}\n${quotes.map(q => `> ${clip(q.text, 300)}`).join('\n')}\n${clip(marks[t.id] ?? '', 900)}`)
610 }
611 const r = await $.model.complete({
612 model: 'sonnet',
613 maxTokens: 1500,
614 timeoutMs: 90000,
615 prompt: `These are threads from the user's research graph. Find up to 5 connections between threads that are NOT already parent/child or linked: places where one thread's idea, mechanism or result might bear on another. Phrase each as a short question to the user, not a conclusion; they decide. Reply with a JSON array only: [{"a": "<id>", "b": "<id>", "question": "..."}].\n\n${cards.join('\n\n')}`,
616 })
617 const list = (r.isAnswered ? parseJson<Suggestion[]>(r.text) : undefined)?.filter(s => threads[s.a] && threads[s.b]) ?? []
618 if (!list.length) return r.isAnswered ? 'No new connections suggested.' : `The connections pass failed (${r.reason}).`
619 await $.store.set('suggestions', list)
620 const stale = Object.values(threads)
621 .filter(t => t.status === 'paused' && Date.now() - Date.parse(t.updated) > 14 * 864e5)
622 .map(t => t.title)
623 return [
624 ...list.map((s, i) => `${i + 1}. ${threads[s.a]!.title} ↔ ${threads[s.b]!.title}\n ${s.question}`),
625 '',
626 '/link #<n> to accept one; the rest are dropped at the next pass.',
627 ...(stale.length ? ['', `Paused for over two weeks: ${stale.join(', ')}`] : []),
628 ].join('\n')
629}
630
631function now(): string {
632 return new Date().toISOString()
633}
634hooks/lib.ts 191 lines1// Pure helpers for the threads mod: no `$`, so tests can import them.
2
3export type Link = { to: string; by: 'user' | 'pass'; note?: string }
4
5export type SessionRef = { id: string; cwd: string; at: string }
6
7export type Thread = {
8 id: string
9 title: string
10 spark: string
11 parent: string | null
12 links: Link[]
13 status: 'active' | 'paused'
14 created: string
15 updated: string
16 workDir: string
17 sessions: SessionRef[]
18}
19
20export type Hook = { at: string; text: string; by: 'user' | 'pin' }
21
22export type Hit = { thread: string; hook: Hook; score: number; shared: number }
23
24const STOP = new Set(
25 ('a an and are as at be been but by can could do does did for from had has have how i if in into is it its ' +
26 'just like me more my not of on or so some that the their then there these they this to was we were what ' +
27 'when where which while who why will with would you your about also really think want get going one')
28 .split(' '),
29)
30
31export function tokens(text: string): string[] {
32 return (text.toLowerCase().match(/[a-z0-9]+/g) ?? [])
33 .filter(t => t.length > 2 && !STOP.has(t))
34 .map(stem)
35}
36
37// Crude stem so "circles"/"circle", "coupled"/"coupling" meet.
38function stem(t: string): string {
39 return t.replace(/(ings|ing|ed|es|s)$/, '') || t
40}
41
42export function slug(title: string): string {
43 const base = title
44 .toLowerCase()
45 .replace(/[^a-z0-9]+/g, '-')
46 .replace(/^-+|-+$/g, '')
47 .slice(0, 40)
48 return base || 'thread'
49}
50
51// Rank verbatim hooks against a half-remembered fragment, by shared rare words.
52export function search(fragment: string, corpus: { thread: string; hook: Hook }[], titles: Record<string, string> = {}): Hit[] {
53 const query = new Set(tokens(fragment))
54 if (query.size === 0) return []
55 const df = new Map<string, number>()
56 const docs = corpus.map(c => new Set(tokens(c.hook.text + ' ' + (titles[c.thread] ?? ''))))
57 for (const d of docs) for (const t of d) df.set(t, (df.get(t) ?? 0) + 1)
58 const n = corpus.length
59 const hits: Hit[] = []
60 corpus.forEach((c, i) => {
61 let score = 0
62 let shared = 0
63 for (const t of query) {
64 if (!docs[i]!.has(t)) continue
65 score += Math.log(1 + n / (df.get(t) ?? 1))
66 shared++
67 }
68 if (score > 0) hits.push({ ...c, score: score / Math.sqrt(query.size), shared })
69 })
70 return hits.sort((a, b) => b.score - a.score)
71}
72
73// Best hit per thread, so one chatty thread doesn't crowd the list.
74export function bestPerThread(hits: Hit[], limit: number): Hit[] {
75 const seen = new Set<string>()
76 const out: Hit[] = []
77 for (const h of hits) {
78 if (seen.has(h.thread)) continue
79 seen.add(h.thread)
80 out.push(h)
81 if (out.length === limit) break
82 }
83 return out
84}
85
86export function clip(text: string, width: number): string {
87 const flat = text.replace(/\s+/g, ' ').trim()
88 return flat.length <= width ? flat : flat.slice(0, width - 1) + '…'
89}
90
91export function ancestry(id: string, all: Record<string, Thread>): Thread[] {
92 const path: Thread[] = []
93 let at: Thread | undefined = all[id]
94 while (at && !path.includes(at)) {
95 path.unshift(at)
96 at = at.parent ? all[at.parent] : undefined
97 }
98 return path
99}
100
101export function children(id: string, all: Record<string, Thread>): Thread[] {
102 return Object.values(all)
103 .filter(t => t.parent === id)
104 .sort((a, b) => a.created.localeCompare(b.created))
105}
106
107export function mark(t: Thread): string {
108 return t.status === 'active' ? '●' : '⏸'
109}
110
111// Room the transcript takes for its own gutter beside a command's output.
112const GUTTER = 8
113
114// Each node gets a number (its place in this listing) so `/pickup 3` works.
115export function tree(all: Record<string, Thread>, firstHook: Record<string, string>, width = 100): { text: string; order: string[] } {
116 const roots = Object.values(all)
117 .filter(t => !t.parent || !all[t.parent])
118 .sort((a, b) => a.created.localeCompare(b.created))
119 const room = Math.max(40, width - GUTTER)
120 const lines: string[] = []
121 const order: string[] = []
122 const walk = (t: Thread, prefix: string, isLast: boolean, isRoot: boolean) => {
123 order.push(t.id)
124 const branch = isRoot ? '' : isLast ? '└─ ' : '├─ '
125 const links = t.links.length ? ` ↩ ${t.links.map(l => l.to + (l.by === 'pass' ? '*' : '')).join(', ')}` : ''
126 const head = `${prefix}${branch}${order.length}. ${t.title} ${mark(t)} [${t.id}]${links}`
127 lines.push(head.length <= room ? head : head.slice(0, room - 1) + '…')
128 const childPrefix = prefix + (isRoot ? '' : isLast ? ' ' : '│ ')
129 const kids = children(t.id, all)
130 const quote = firstHook[t.id]
131 if (quote) {
132 const lead = `${childPrefix}${kids.length ? '│ ' : ' '}`
133 lines.push(`${lead}"${clip(quote, Math.max(20, room - lead.length - 2))}"`)
134 }
135 kids.forEach((k, i) => walk(k, childPrefix, i === kids.length - 1, false))
136 }
137 roots.forEach(r => walk(r, '', true, true))
138 return { text: lines.join('\n'), order }
139}
140
141// Pull the first JSON object or array out of a model reply.
142export function parseJson<T>(text: string): T | undefined {
143 const start = text.search(/[[{]/)
144 if (start < 0) return undefined
145 for (let end = text.length; end > start; end--) {
146 const ch = text[end - 1]
147 if (ch !== '}' && ch !== ']') continue
148 try {
149 return JSON.parse(text.slice(start, end)) as T
150 } catch {
151 // keep shrinking
152 }
153 }
154 return undefined
155}
156
157export function briefFor(t: Thread, all: Record<string, Thread>, bookmarks: Record<string, string>, handoff?: string): string {
158 const path = ancestry(t.id, all)
159 const out = [
160 `# Brief: ${t.title}`,
161 '',
162 `Thread \`${t.id}\`. Path: ${path.map(p => p.title).join(' → ')}`,
163 '',
164 '## Spark (verbatim, from the user)',
165 '',
166 t.spark
167 .split('\n')
168 .map(l => `> ${l}`)
169 .join('\n'),
170 '',
171 ]
172 if (handoff) out.push('## Handed over from the parent conversation', '', handoff.trim(), '')
173 if (bookmarks[t.id]) out.push('## Where this thread left off', '', bookmarks[t.id]!.trim(), '')
174 const elders = path.slice(0, -1).reverse()
175 if (elders.length) {
176 out.push('## Where the threads it came from left off', '')
177 for (const e of elders) out.push(`### ${e.title} (\`${e.id}\`)`, '', (bookmarks[e.id] ?? '(no bookmark yet)').trim(), '')
178 }
179 return out.join('\n')
180}
181
182export const CLAUDE_MD = `# Research thread
183
184This folder is one thread in the user's research graph. Start by reading BRIEF.md.
185
186- Stay inside this thread. Don't read sibling thread folders (\`../*\`) unless the user asks; that's the point of the split.
187- The user likes discursive tangents and makes the connections between threads themselves. Don't volunteer links to other threads.
188- Put work files for this thread in this folder.
189- \`/bookmark\` refreshes where-I-left-off, \`/split <spark>\` splits off a new thread, \`/find <fragment>\` locates an old one.
190`
191