@threadcrumbs/threadnauts 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +10 -0
- package/NOTICES.md +683 -0
- package/README.md +163 -2
- package/bin/threadnauts.mjs +114 -0
- package/dist/assets/CREDITS.md +19 -0
- package/dist/assets/aldargon-B_VubjBf.svg +1 -0
- package/dist/assets/arrakesh-BW9Bb56U.svg +1 -0
- package/dist/assets/atoll-mfMgWjQE.svg +1 -0
- package/dist/assets/autumn-Cvk8t_-3.svg +1 -0
- package/dist/assets/bamboo-KEAHdFas.svg +1 -0
- package/dist/assets/basalt-g767Blh3.svg +1 -0
- package/dist/assets/baut.glb +0 -0
- package/dist/assets/beach-q2rexblq.svg +1 -0
- package/dist/assets/callisto-bug4x0L8.svg +1 -0
- package/dist/assets/cancri-BOebEtMP.svg +1 -0
- package/dist/assets/canyon-FF3hqLaT.svg +1 -0
- package/dist/assets/ceres-BeB8pWjM.svg +1 -0
- package/dist/assets/challenger-Bg9wWAtc.svg +1 -0
- package/dist/assets/crystal-bk_pmXWn.svg +1 -0
- package/dist/assets/dagmora-Co5gg-3x.svg +1 -0
- package/dist/assets/deadstar-x-dSincY.svg +1 -0
- package/dist/assets/desert-Bkozg-2y.svg +1 -0
- package/dist/assets/dragonnest-DExsIn2w.svg +1 -0
- package/dist/assets/draugr-ChEwvDI7.svg +1 -0
- package/dist/assets/enceladus-BlSNfFYi.svg +1 -0
- package/dist/assets/endral-DQ-u4-N6.svg +1 -0
- package/dist/assets/europa-Cgids_PV.svg +1 -0
- package/dist/assets/fjord-rGF4e-MG.svg +1 -0
- package/dist/assets/forest-BVWgbcSU.svg +1 -0
- package/dist/assets/forest.glb +0 -0
- package/dist/assets/frozenlake-B8282a2Z.svg +1 -0
- package/dist/assets/ganymede-Dg4iJbs2.svg +1 -0
- package/dist/assets/garden-TQmDzzU5.svg +1 -0
- package/dist/assets/glimmer-BIK9hzb3.svg +1 -0
- package/dist/assets/halley-B32BiHtn.svg +1 -0
- package/dist/assets/hd189733b-E4qM1zIs.svg +1 -0
- package/dist/assets/hollow-DKKw0GKy.svg +1 -0
- package/dist/assets/horth-DPuhph1g.svg +1 -0
- package/dist/assets/iapetus-CMxb57ov.svg +1 -0
- package/dist/assets/index-C-3KPMaV.css +1 -0
- package/dist/assets/index-xHGM7fhg.js +2381 -0
- package/dist/assets/io-C7TKKMjC.svg +1 -0
- package/dist/assets/j1407b-Dx2Qblb7.svg +1 -0
- package/dist/assets/jakhara-BLl7oBmq.svg +1 -0
- package/dist/assets/jupiter-8ezAafsg.svg +1 -0
- package/dist/assets/kaladorn-CkaAJJ7a.svg +1 -0
- package/dist/assets/kashiwa-_WrMlYhf.svg +1 -0
- package/dist/assets/kepler16b-CdRP9SpW.svg +1 -0
- package/dist/assets/kepler186f-Bosuwusi.svg +1 -0
- package/dist/assets/lanternnight-Bde4tTOv.svg +1 -0
- package/dist/assets/lighting/README.md +10 -0
- package/dist/assets/lighting/studio_small_09_1k.hdr +0 -0
- package/dist/assets/lt2230-CtZQ_E_7.svg +1 -0
- package/dist/assets/lt4260-BfDP2hby.svg +1 -0
- package/dist/assets/mars-MRN7bCwO.svg +1 -0
- package/dist/assets/mercury-Dxhy2buH.svg +1 -0
- package/dist/assets/middangard-TZnEvlji.svg +1 -0
- package/dist/assets/milners-Bbcl_qzx.svg +1 -0
- package/dist/assets/miranda-DeKynADL.svg +1 -0
- package/dist/assets/moon-trBMXXaP.svg +1 -0
- package/dist/assets/moors-h4HR2QJC.svg +1 -0
- package/dist/assets/muspel-CbnFgpIq.svg +1 -0
- package/dist/assets/nabora-C_M0gUvC.svg +1 -0
- package/dist/assets/nature.glb +0 -0
- package/dist/assets/north-CCQ_abLA.svg +1 -0
- package/dist/assets/ocean-xTY8Tv7N.svg +1 -0
- package/dist/assets/oz-ClTxqgDh.svg +1 -0
- package/dist/assets/pandora-vc20uhuY.svg +1 -0
- package/dist/assets/phobos-DKRMWpmg.svg +1 -0
- package/dist/assets/pluto-Bv-I3Adh.svg +1 -0
- package/dist/assets/proxima-CJWGm2yh.svg +1 -0
- package/dist/assets/qubitron-B9yy8CKB.svg +1 -0
- package/dist/assets/redspot-CV5z9nSH.svg +1 -0
- package/dist/assets/ring-CWw9HQNl.svg +1 -0
- package/dist/assets/sakura-BaPBQo7U.svg +1 -0
- package/dist/assets/saturnrings-C0bWB6Nk.svg +1 -0
- package/dist/assets/savanna-3fK5T_wl.svg +1 -0
- package/dist/assets/shared-B_T8JlmU.js +4919 -0
- package/dist/assets/sky-B_jCrmWW.svg +1 -0
- package/dist/assets/spacebase.glb +0 -0
- package/dist/assets/sugarvale-CNIezmWw.svg +1 -0
- package/dist/assets/tatawin-B4kGS6XH.svg +1 -0
- package/dist/assets/terra-BpxwvhON.svg +1 -0
- package/dist/assets/titan-DRcbXkdn.svg +1 -0
- package/dist/assets/titanic-D6TQMoe4.svg +1 -0
- package/dist/assets/trappist-DegjcuQ8.svg +1 -0
- package/dist/assets/triton-DXbtxCV7.svg +1 -0
- package/dist/assets/tundra-D7lIxFPP.svg +1 -0
- package/dist/assets/undersea-rehq9Wmh.svg +1 -0
- package/dist/assets/valley-DVlve4ea.svg +1 -0
- package/dist/assets/venus-OIZfT1Sa.svg +1 -0
- package/dist/assets/vespera-CXGN-RGx.svg +1 -0
- package/dist/assets/worldtree-wqKc2Sb_.svg +1 -0
- package/dist/assets/yavora4-D1u-Riyb.svg +1 -0
- package/dist/audio/README.md +25 -0
- package/dist/favicon.svg +5 -0
- package/dist/index.html +25 -0
- package/dist/pro-manifest.json +9 -0
- package/package.json +63 -4
- package/server/api.mjs +537 -0
- package/server/http-server.mjs +127 -0
- package/server/lib/terminal.mjs +213 -0
- package/server/lib/user-data.mjs +11 -0
- package/server/lib/win-terminal.mjs +88 -0
- package/server/lib/windows.mjs +95 -0
- package/server/lib/xdg.mjs +44 -0
- package/server/license.mjs +69 -0
- package/server/pro-content.mjs +146 -0
- package/server/scan.mjs +187 -0
- package/server/serve.mjs +7 -0
- package/server/threadgauge.mjs +42 -0
package/server/scan.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness-agnostic thread scanning.
|
|
3
|
+
*
|
|
4
|
+
* This module knows nothing about any particular agent harness: it asks every harness that
|
|
5
|
+
* is present on this machine for its threads, stamps each one with which harness it came
|
|
6
|
+
* from, and hands back a single list sorted by recency. Everything harness-specific lives
|
|
7
|
+
* in `@threadcrumbs/common/harnesses` — see `harnesses/README.md` in the common repo.
|
|
8
|
+
*/
|
|
9
|
+
import fsp from 'node:fs/promises'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { HARNESSES, detectedHarnesses, harnessById } from '@threadcrumbs/common/harnesses'
|
|
12
|
+
|
|
13
|
+
/** `\\?\C:\…` to `C:\…`. Only where a drive follows: `\\?\UNC\server\share` is a different animal. */
|
|
14
|
+
const dropLongPrefix = (p) => (/^\\\\\?\\[A-Za-z]:[\\/]/.test(p) ? p.slice(4) : p)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A project's ground is keyed on its name, and a name is the last segment of its path — so two
|
|
18
|
+
* checkouts of the same repo, `~/workspaces/1/foo` and `~/workspaces/2/foo`, are both "foo".
|
|
19
|
+
* Left alone they share one plot and their threads become indistinguishable, which is wrong for
|
|
20
|
+
* anyone keeping parallel copies instead of using worktrees.
|
|
21
|
+
*
|
|
22
|
+
* Where a name is ambiguous, grow it leftward along the path until it is not: `1/foo` and
|
|
23
|
+
* `2/foo`. Only names that actually collide are touched, and that restraint is the point — the
|
|
24
|
+
* name is also the key a saved layout is stored under, so disambiguating unconditionally would
|
|
25
|
+
* move every plot on everybody's map to fix something most people never hit.
|
|
26
|
+
*/
|
|
27
|
+
export function disambiguateProjects(threads) {
|
|
28
|
+
// Windows hands the same checkout back as `c:\…` from one transcript and `C:\…` from
|
|
29
|
+
// another: the CLI's project-directory encoding keeps whatever case the drive letter was
|
|
30
|
+
// given. Those are one path, not two — and counted as two they make an unambiguous name look
|
|
31
|
+
// ambiguous, which renames a plot on a machine that has no collision at all.
|
|
32
|
+
//
|
|
33
|
+
// Codex hands the same checkout back a third way: with the extended-length prefix, as
|
|
34
|
+
// `\\?\C:\…`. That does not begin with a drive letter, so the case fold below never reached
|
|
35
|
+
// it and one folder on disk arrived here as two paths — enough to make `geh` look ambiguous
|
|
36
|
+
// against itself and rename both plots to their full absolute paths. Drop the prefix first,
|
|
37
|
+
// but only where a drive follows it: `\\?\UNC\server\share` is a different animal, and
|
|
38
|
+
// folding its first character would be wrong.
|
|
39
|
+
const canonical = (p) => {
|
|
40
|
+
const s = dropLongPrefix(p)
|
|
41
|
+
return /^[A-Za-z]:[\\/]/.test(s) ? s[0].toLowerCase() + s.slice(1) : s
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const pathsByName = new Map()
|
|
45
|
+
for (const t of threads) {
|
|
46
|
+
if (!t.project) continue
|
|
47
|
+
if (!pathsByName.has(t.project)) pathsByName.set(t.project, new Set())
|
|
48
|
+
pathsByName.get(t.project).add(canonical(t.projectPath || ''))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const renames = new Map()
|
|
52
|
+
for (const [name, paths] of pathsByName) {
|
|
53
|
+
if (paths.size < 2) continue
|
|
54
|
+
const list = [...paths]
|
|
55
|
+
// Both separators. A Windows path splits on neither otherwise, leaving a single "segment"
|
|
56
|
+
// that is the whole absolute path — which then becomes the plot's name on the map.
|
|
57
|
+
const segments = list.map((p) => p.split(/[\\/]/).filter(Boolean))
|
|
58
|
+
const deepest = Math.max(...segments.map((s) => s.length))
|
|
59
|
+
|
|
60
|
+
// Take one more trailing segment until every path in the group reads differently. Paths
|
|
61
|
+
// that differ at all must separate by `deepest`, so this always terminates. A thread with
|
|
62
|
+
// no path at all cannot be told apart by one, so it keeps the bare name and the others
|
|
63
|
+
// move around it.
|
|
64
|
+
const labelAt = (segs, depth) => (segs.length ? segs.slice(-depth).join('/') : name)
|
|
65
|
+
let depth = 1
|
|
66
|
+
let labels = segments.map((segs) => labelAt(segs, depth))
|
|
67
|
+
while (new Set(labels).size < list.length && depth < deepest) {
|
|
68
|
+
depth += 1
|
|
69
|
+
labels = segments.map((segs) => labelAt(segs, depth))
|
|
70
|
+
}
|
|
71
|
+
list.forEach((path, i) => renames.set(`${name}\u0000${path}`, labels[i]))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!renames.size) return threads
|
|
75
|
+
return threads.map((t) => {
|
|
76
|
+
const next = renames.get(`${t.project || ''}\u0000${canonical(t.projectPath || '')}`)
|
|
77
|
+
return next && next !== t.project ? { ...t, project: next } : t
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A linked git worktree belongs on its repository's plot.
|
|
83
|
+
*
|
|
84
|
+
* Claude Code's own `.claude/worktrees/<name>` is recognised by its adapter from the path alone.
|
|
85
|
+
* A harness that keeps worktrees somewhere of its own is not: Codex puts them under
|
|
86
|
+
* `~/.codex/worktrees/<id>/<repo>`, which gave the repo a second plot under its own name, and the
|
|
87
|
+
* disambiguation above then renamed both plots to tell them apart. The checkout itself says where
|
|
88
|
+
* it belongs — a linked worktree's `.git` is a file reading `gitdir: <repo>/.git/worktrees/<name>`
|
|
89
|
+
* — so that is read, rather than any harness's folder layout guessed at. A submodule's `.git`
|
|
90
|
+
* file points into `.git/modules` instead, and is left alone.
|
|
91
|
+
*
|
|
92
|
+
* Kept per folder for the life of the process: a checkout does not change which repository it
|
|
93
|
+
* belongs to, and the scan runs on a short poll.
|
|
94
|
+
*/
|
|
95
|
+
const linkedWorktrees = new Map()
|
|
96
|
+
export async function linkedWorktree(dir) {
|
|
97
|
+
if (linkedWorktrees.has(dir)) return linkedWorktrees.get(dir)
|
|
98
|
+
let found = null
|
|
99
|
+
try {
|
|
100
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(await fsp.readFile(path.join(dir, '.git'), 'utf8'))
|
|
101
|
+
const gitdir = m ? path.resolve(dir, m[1]) : ''
|
|
102
|
+
const at = gitdir.search(/[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/)
|
|
103
|
+
if (at > 0) found = { root: gitdir.slice(0, at), name: path.basename(gitdir) }
|
|
104
|
+
} catch {
|
|
105
|
+
/* `.git` is a directory, or there is none: not a linked worktree */
|
|
106
|
+
}
|
|
107
|
+
linkedWorktrees.set(dir, found)
|
|
108
|
+
return found
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A worktree that has since been removed has no `.git` left to read. One made by Claude Code still
|
|
113
|
+
* says where it belonged, because it sits inside the repository at `.claude/worktrees/<name>` —
|
|
114
|
+
* and other harnesses get run in those too.
|
|
115
|
+
*/
|
|
116
|
+
const NESTED_WORKTREE = /^(.+?)[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)[\\/]?$/
|
|
117
|
+
|
|
118
|
+
/** Threads an adapter already placed in a worktree are left as they are. */
|
|
119
|
+
export async function placeWorktrees(threads) {
|
|
120
|
+
return Promise.all(
|
|
121
|
+
threads.map(async (t) => {
|
|
122
|
+
if (t.worktree || !t.projectPath || !path.isAbsolute(t.projectPath)) return t
|
|
123
|
+
const nested = NESTED_WORKTREE.exec(t.projectPath)
|
|
124
|
+
const linked = (await linkedWorktree(t.projectPath)) || (nested && { root: nested[1], name: nested[2] })
|
|
125
|
+
if (!linked) return t
|
|
126
|
+
return { ...t, project: path.basename(linked.root), projectPath: linked.root, worktree: linked.name }
|
|
127
|
+
})
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Every thread from every detected harness.
|
|
133
|
+
*
|
|
134
|
+
* A harness that throws is skipped rather than allowed to take the scan down with it: one
|
|
135
|
+
* broken adapter should cost you that harness's threads, not the whole colony.
|
|
136
|
+
*/
|
|
137
|
+
export async function scanThreads() {
|
|
138
|
+
const harnesses = await detectedHarnesses()
|
|
139
|
+
const lists = await Promise.all(
|
|
140
|
+
harnesses.map(async (h) => {
|
|
141
|
+
try {
|
|
142
|
+
const threads = await h.scanThreads()
|
|
143
|
+
return threads.map((t) => ({ ...t, harness: h.id, harnessName: h.name }))
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.warn(`scan: harness "${h.id}" failed to scan —`, err?.message || err)
|
|
146
|
+
return []
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
)
|
|
150
|
+
// The prefix goes before anything reads the path, so a plot's folder is one a person would type.
|
|
151
|
+
const flat = lists.flat().map((t) => (t.projectPath ? { ...t, projectPath: dropLongPrefix(t.projectPath) } : t))
|
|
152
|
+
const threads = disambiguateProjects(await placeWorktrees(flat))
|
|
153
|
+
threads.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
|
154
|
+
return threads
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** What the HUD shows in the harness list: who is installed, and what they can do. */
|
|
158
|
+
export async function harnessStatus() {
|
|
159
|
+
const detected = new Set((await detectedHarnesses()).map((h) => h.id))
|
|
160
|
+
return Promise.all(
|
|
161
|
+
HARNESSES.map(async (h) => ({
|
|
162
|
+
id: h.id,
|
|
163
|
+
name: h.name,
|
|
164
|
+
detected: detected.has(h.id),
|
|
165
|
+
// Optional. An adapter that can see its harness but cannot read it — wrong Node, a store
|
|
166
|
+
// it does not understand — says why here instead of failing silently on every poll.
|
|
167
|
+
error: h.diagnostic ? await h.diagnostic().catch(() => '') : '',
|
|
168
|
+
}))
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The harness to use when a caller has not said — the first one present on this machine. */
|
|
173
|
+
export async function defaultHarness() {
|
|
174
|
+
const [first] = await detectedHarnesses()
|
|
175
|
+
return first?.id || ''
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const dispatch = (harnessId) => {
|
|
179
|
+
const h = harnessById(harnessId)
|
|
180
|
+
if (!h) throw new Error(`Unknown harness "${harnessId}"`)
|
|
181
|
+
return h
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Both may be async: an adapter that has to look for a CLI on disk cannot answer synchronously. */
|
|
185
|
+
export const openThread = async (harnessId, ref) => dispatch(harnessId).openThread(ref)
|
|
186
|
+
|
|
187
|
+
export const newSession = async (harnessId, dir) => dispatch(harnessId).newSession(dir)
|
package/server/serve.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const FIRST_PORT = 5274
|
|
2
|
+
const PORTS_TO_TRY = 20
|
|
3
|
+
const LOOPBACKS = ['127.0.0.1', '::1']
|
|
4
|
+
const PROBE_TIMEOUT_MS = 500
|
|
5
|
+
const CONTEXT_TIMEOUT_MS = 2000
|
|
6
|
+
const REUSE_FOUND_MS = 15000
|
|
7
|
+
|
|
8
|
+
const originOf = (host, port) => `http://${host.includes(':') ? `[${host}]` : host}:${port}`
|
|
9
|
+
|
|
10
|
+
async function answersAsThreadgauge(origin, fetch) {
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetch(`${origin}/api/about`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
|
|
13
|
+
return (await response.json())?.app === 'threadgauge'
|
|
14
|
+
} catch {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function findThreadgauge({ fetch = globalThis.fetch, first = FIRST_PORT, tries = PORTS_TO_TRY, hosts = LOOPBACKS } = {}) {
|
|
20
|
+
const probes = []
|
|
21
|
+
for (let port = first; port < first + tries; port++) for (const host of hosts) probes.push({ port, origin: originOf(host, port) })
|
|
22
|
+
const found = await Promise.all(probes.map(({ origin }) => answersAsThreadgauge(origin, fetch)))
|
|
23
|
+
const index = found.indexOf(true)
|
|
24
|
+
return index === -1 ? null : `http://localhost:${probes[index].port}/`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function threadgaugeContexts({ fetch = globalThis.fetch, find = () => findThreadgauge({ fetch }), now = Date.now } = {}) {
|
|
28
|
+
let found = { url: null, at: -Infinity }
|
|
29
|
+
return async function contexts() {
|
|
30
|
+
if (now() - found.at > REUSE_FOUND_MS) found = { url: await find(), at: now() }
|
|
31
|
+
if (!found.url) return { url: null, contexts: {} }
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(`${found.url}api/sessions`, { signal: AbortSignal.timeout(CONTEXT_TIMEOUT_MS) })
|
|
34
|
+
const { sessions = [] } = response.ok ? await response.json() : {}
|
|
35
|
+
const known = sessions.filter((s) => s.id && s.context).map((s) => [s.id, { used: s.context.used, limit: s.context.limit }])
|
|
36
|
+
return { url: found.url, contexts: Object.fromEntries(known) }
|
|
37
|
+
} catch {
|
|
38
|
+
found = { url: null, at: -Infinity }
|
|
39
|
+
return { url: null, contexts: {} }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|