@threadcrumbs/threadnauts 0.0.0-stage → 1.1.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.
Files changed (111) hide show
  1. package/LICENSE.md +34 -0
  2. package/NOTICES.md +683 -0
  3. package/README.md +162 -2
  4. package/bin/threadnauts.mjs +114 -0
  5. package/dist/assets/CREDITS.md +19 -0
  6. package/dist/assets/aldargon-B_VubjBf.svg +1 -0
  7. package/dist/assets/arrakesh-BW9Bb56U.svg +1 -0
  8. package/dist/assets/atoll-mfMgWjQE.svg +1 -0
  9. package/dist/assets/autumn-Cvk8t_-3.svg +1 -0
  10. package/dist/assets/bamboo-KEAHdFas.svg +1 -0
  11. package/dist/assets/basalt-g767Blh3.svg +1 -0
  12. package/dist/assets/baut.glb +0 -0
  13. package/dist/assets/beach-q2rexblq.svg +1 -0
  14. package/dist/assets/callisto-bug4x0L8.svg +1 -0
  15. package/dist/assets/cancri-BOebEtMP.svg +1 -0
  16. package/dist/assets/canyon-FF3hqLaT.svg +1 -0
  17. package/dist/assets/ceres-BeB8pWjM.svg +1 -0
  18. package/dist/assets/challenger-Bg9wWAtc.svg +1 -0
  19. package/dist/assets/crystal-bk_pmXWn.svg +1 -0
  20. package/dist/assets/dagmora-Co5gg-3x.svg +1 -0
  21. package/dist/assets/deadstar-x-dSincY.svg +1 -0
  22. package/dist/assets/desert-Bkozg-2y.svg +1 -0
  23. package/dist/assets/dragonnest-DExsIn2w.svg +1 -0
  24. package/dist/assets/draugr-ChEwvDI7.svg +1 -0
  25. package/dist/assets/enceladus-BlSNfFYi.svg +1 -0
  26. package/dist/assets/endral-DQ-u4-N6.svg +1 -0
  27. package/dist/assets/europa-Cgids_PV.svg +1 -0
  28. package/dist/assets/fjord-rGF4e-MG.svg +1 -0
  29. package/dist/assets/forest-BVWgbcSU.svg +1 -0
  30. package/dist/assets/forest.glb +0 -0
  31. package/dist/assets/frozenlake-B8282a2Z.svg +1 -0
  32. package/dist/assets/ganymede-Dg4iJbs2.svg +1 -0
  33. package/dist/assets/garden-TQmDzzU5.svg +1 -0
  34. package/dist/assets/glimmer-BIK9hzb3.svg +1 -0
  35. package/dist/assets/halley-B32BiHtn.svg +1 -0
  36. package/dist/assets/hd189733b-E4qM1zIs.svg +1 -0
  37. package/dist/assets/hollow-DKKw0GKy.svg +1 -0
  38. package/dist/assets/horth-DPuhph1g.svg +1 -0
  39. package/dist/assets/iapetus-CMxb57ov.svg +1 -0
  40. package/dist/assets/index-BSPNlXcc.js +2514 -0
  41. package/dist/assets/index-DoVfuOrP.css +1 -0
  42. package/dist/assets/io-C7TKKMjC.svg +1 -0
  43. package/dist/assets/j1407b-Dx2Qblb7.svg +1 -0
  44. package/dist/assets/jakhara-BLl7oBmq.svg +1 -0
  45. package/dist/assets/jupiter-8ezAafsg.svg +1 -0
  46. package/dist/assets/kaladorn-CkaAJJ7a.svg +1 -0
  47. package/dist/assets/kashiwa-_WrMlYhf.svg +1 -0
  48. package/dist/assets/kepler16b-CdRP9SpW.svg +1 -0
  49. package/dist/assets/kepler186f-Bosuwusi.svg +1 -0
  50. package/dist/assets/lanternnight-Bde4tTOv.svg +1 -0
  51. package/dist/assets/lighting/README.md +10 -0
  52. package/dist/assets/lighting/studio_small_09_1k.hdr +0 -0
  53. package/dist/assets/lt2230-CtZQ_E_7.svg +1 -0
  54. package/dist/assets/lt4260-BfDP2hby.svg +1 -0
  55. package/dist/assets/mars-MRN7bCwO.svg +1 -0
  56. package/dist/assets/mercury-Dxhy2buH.svg +1 -0
  57. package/dist/assets/middangard-TZnEvlji.svg +1 -0
  58. package/dist/assets/milners-Bbcl_qzx.svg +1 -0
  59. package/dist/assets/miranda-DeKynADL.svg +1 -0
  60. package/dist/assets/moon-trBMXXaP.svg +1 -0
  61. package/dist/assets/moors-h4HR2QJC.svg +1 -0
  62. package/dist/assets/muspel-CbnFgpIq.svg +1 -0
  63. package/dist/assets/nabora-C_M0gUvC.svg +1 -0
  64. package/dist/assets/nature.glb +0 -0
  65. package/dist/assets/north-CCQ_abLA.svg +1 -0
  66. package/dist/assets/ocean-xTY8Tv7N.svg +1 -0
  67. package/dist/assets/oz-ClTxqgDh.svg +1 -0
  68. package/dist/assets/pandora-vc20uhuY.svg +1 -0
  69. package/dist/assets/phobos-DKRMWpmg.svg +1 -0
  70. package/dist/assets/pluto-Bv-I3Adh.svg +1 -0
  71. package/dist/assets/proxima-CJWGm2yh.svg +1 -0
  72. package/dist/assets/qubitron-B9yy8CKB.svg +1 -0
  73. package/dist/assets/redspot-DbATwjpD.svg +1 -0
  74. package/dist/assets/ring-CWw9HQNl.svg +1 -0
  75. package/dist/assets/sakura-BaPBQo7U.svg +1 -0
  76. package/dist/assets/saturnrings-C0bWB6Nk.svg +1 -0
  77. package/dist/assets/savanna-3fK5T_wl.svg +1 -0
  78. package/dist/assets/shared-CgqOYNxw.js +4919 -0
  79. package/dist/assets/sky-B_jCrmWW.svg +1 -0
  80. package/dist/assets/spacebase.glb +0 -0
  81. package/dist/assets/sugarvale-CNIezmWw.svg +1 -0
  82. package/dist/assets/tatawin-B4kGS6XH.svg +1 -0
  83. package/dist/assets/terra-BpxwvhON.svg +1 -0
  84. package/dist/assets/titan-DRcbXkdn.svg +1 -0
  85. package/dist/assets/titanic-D6TQMoe4.svg +1 -0
  86. package/dist/assets/trappist-DegjcuQ8.svg +1 -0
  87. package/dist/assets/triton-DXbtxCV7.svg +1 -0
  88. package/dist/assets/tundra-D7lIxFPP.svg +1 -0
  89. package/dist/assets/undersea-rehq9Wmh.svg +1 -0
  90. package/dist/assets/valley-DVlve4ea.svg +1 -0
  91. package/dist/assets/venus-OIZfT1Sa.svg +1 -0
  92. package/dist/assets/vespera-CXGN-RGx.svg +1 -0
  93. package/dist/assets/worldtree-wqKc2Sb_.svg +1 -0
  94. package/dist/assets/yavora4-D1u-Riyb.svg +1 -0
  95. package/dist/audio/README.md +25 -0
  96. package/dist/favicon.svg +5 -0
  97. package/dist/index.html +25 -0
  98. package/dist/pro-manifest.json +9 -0
  99. package/package.json +63 -4
  100. package/server/api.mjs +540 -0
  101. package/server/http-server.mjs +127 -0
  102. package/server/lib/terminal.mjs +213 -0
  103. package/server/lib/user-data.mjs +11 -0
  104. package/server/lib/win-terminal.mjs +88 -0
  105. package/server/lib/windows.mjs +95 -0
  106. package/server/lib/xdg.mjs +44 -0
  107. package/server/license.mjs +69 -0
  108. package/server/pro-content.mjs +146 -0
  109. package/server/scan.mjs +187 -0
  110. package/server/serve.mjs +7 -0
  111. package/server/threadgauge.mjs +42 -0
@@ -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)
@@ -0,0 +1,7 @@
1
+ import { startServer } from './http-server.mjs'
2
+
3
+ const PORT = Number(process.env.PORT) || 5274
4
+ const HOST = process.env.THREADNAUTS_HOST || '127.0.0.1'
5
+
6
+ await startServer({ port: PORT, host: HOST })
7
+ console.log(`Threadnauts → http://${HOST}:${PORT}`)
@@ -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
+ }