@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
package/server/api.mjs ADDED
@@ -0,0 +1,540 @@
1
+ import fsp from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { spawn } from 'node:child_process'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { schemeHasHandler, schemeOf } from './lib/xdg.mjs'
7
+ import { openInTerminal } from './lib/terminal.mjs'
8
+ import { focusWindowOfPid } from './lib/windows.mjs'
9
+ import { localLicensing } from './license.mjs'
10
+ import { localProContent } from './pro-content.mjs'
11
+ import { findThreadgauge, threadgaugeContexts } from './threadgauge.mjs'
12
+ import { organizationReader } from '@threadcrumbs/common/orgs'
13
+ import {
14
+ defaultHarness,
15
+ harnessStatus,
16
+ linkedWorktree,
17
+ newSession as harnessNewSession,
18
+ openThread as harnessOpenThread,
19
+ scanThreads,
20
+ } from './scan.mjs'
21
+
22
+ const here = path.dirname(fileURLToPath(import.meta.url))
23
+ const CHECKOUT_DIR = path.join(here, '..')
24
+ const DATA_DIR = process.env.THREADNAUTS_DATA_DIR || path.join(here, '..', 'data')
25
+ const STATE_FILE = path.join(DATA_DIR, 'colony.json')
26
+
27
+ const STATE_VERSION = 2
28
+ const LICENSE_BODY_LIMIT = 4096
29
+
30
+ let licenses = null
31
+ const licensing = () => (licenses ??= localLicensing())
32
+
33
+ let proFiles = null
34
+ const proContent = () => (proFiles ??= localProContent())
35
+ export const proContentFile = (pathname) => proContent().file(pathname)
36
+
37
+ const withOrganizations = organizationReader()
38
+ const contextsFromThreadgauge = threadgaugeContexts()
39
+
40
+ let about = null
41
+ const aboutThisApp = () =>
42
+ (about ??= fsp.readFile(path.join(CHECKOUT_DIR, 'package.json'), 'utf8').then((text) => ({ app: 'threadnauts', version: JSON.parse(text).version })))
43
+
44
+ /**
45
+ * v1 keyed everything on a bare session id, because Claude Code was the only harness and its
46
+ * ids are UUIDs. Adapters now prefix (`claude-code:…`, `codex:…`) so two harnesses can never
47
+ * name the same thread, which means a v1 file's archive list no longer matches anything.
48
+ *
49
+ * Only Claude Code ever wrote a bare id, so the rewrite is unambiguous. One shot, on read.
50
+ */
51
+ const BARE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
52
+ const migrateId = (id) => (BARE_UUID.test(id) ? `claude-code:${id}` : id)
53
+
54
+ function migrate(raw) {
55
+ if (Number(raw.version) >= 2) return raw
56
+ const keys = (o) => Object.fromEntries(Object.entries(asObject(o)).map(([k, v]) => [migrateId(k), v]))
57
+ return {
58
+ ...raw,
59
+ archived: asArray(raw.archived).map(migrateId),
60
+ archivedAt: keys(raw.archivedAt),
61
+ opened: asArray(raw.opened).map(migrateId),
62
+ seen: keys(raw.seen),
63
+ viewedAt: keys(raw.viewedAt),
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Colony state is only ever the things the *game* invents — which plot a project got,
69
+ * what a thread's building looks like, what you archived, which repos you took off the map.
70
+ * The threads themselves stay
71
+ * read-only: this file is the only thing Threadnauts writes, anywhere.
72
+ */
73
+ const emptyState = () => ({
74
+ version: STATE_VERSION,
75
+ archived: [],
76
+ archivedAt: {},
77
+ opened: [],
78
+ plots: {},
79
+ otherPlots: {},
80
+ seen: {},
81
+ hiddenProjects: [],
82
+ viewedAt: {},
83
+ trinkets: {},
84
+ accents: {},
85
+ districts: 0,
86
+ settings: null,
87
+ updatedAt: 0,
88
+ })
89
+
90
+ const asObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {})
91
+ const asArray = (v) => (Array.isArray(v) ? v : [])
92
+
93
+ async function readState() {
94
+ try {
95
+ const raw = migrate(JSON.parse(await fsp.readFile(STATE_FILE, 'utf8')))
96
+ return {
97
+ version: STATE_VERSION,
98
+ archived: asArray(raw.archived),
99
+ archivedAt: asObject(raw.archivedAt),
100
+ opened: asArray(raw.opened),
101
+ plots: asObject(raw.plots),
102
+ otherPlots: asObject(raw.otherPlots),
103
+ seen: asObject(raw.seen),
104
+ hiddenProjects: asArray(raw.hiddenProjects).map(String).filter(Boolean),
105
+ viewedAt: asObject(raw.viewedAt),
106
+ trinkets: asObject(raw.trinkets),
107
+ accents: asObject(raw.accents),
108
+ districts: Number(raw.districts) || 0,
109
+ settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : null,
110
+ updatedAt: Number(raw.updatedAt) || 0,
111
+ }
112
+ } catch {
113
+ return emptyState()
114
+ }
115
+ }
116
+
117
+ /**
118
+ * One writer: the browser owns this file and PUTs it whole. Nothing on the server writes it —
119
+ * if anything did, the next save from a page holding older state would silently drop every
120
+ * archive made since that page loaded.
121
+ */
122
+ /**
123
+ * Writes are serialised through one chain, and each gets its own temp file.
124
+ *
125
+ * Both halves matter and neither is theoretical. A shared `colony.json.tmp` means two saves
126
+ * landing together race on the rename and one throws ENOENT — a 500 the page has no idea what
127
+ * to do with, so the save is simply lost. And read-then-write is not atomic across an `await`,
128
+ * so without the chain two callers can both pass the version check below before either writes.
129
+ */
130
+ let writeQueue = Promise.resolve()
131
+ let tmpSeq = 0
132
+ const serialise = (fn) => (writeQueue = writeQueue.then(fn, fn))
133
+
134
+ async function writeState(next) {
135
+ const state = {
136
+ version: STATE_VERSION,
137
+ archived: asArray(next.archived),
138
+ archivedAt: asObject(next.archivedAt),
139
+ opened: asArray(next.opened),
140
+ plots: asObject(next.plots),
141
+ otherPlots: asObject(next.otherPlots),
142
+ seen: asObject(next.seen),
143
+ hiddenProjects: asArray(next.hiddenProjects).map(String).filter(Boolean),
144
+ viewedAt: asObject(next.viewedAt),
145
+ trinkets: asObject(next.trinkets),
146
+ accents: asObject(next.accents),
147
+ districts: Number(next.districts) || 0,
148
+ settings: next.settings && typeof next.settings === 'object' ? next.settings : null,
149
+ updatedAt: Date.now(),
150
+ }
151
+ await fsp.mkdir(DATA_DIR, { recursive: true })
152
+ const tmp = `${STATE_FILE}.${process.pid}.${++tmpSeq}.tmp`
153
+ try {
154
+ // colony.json names the repos you work in and where they are: the owner's business, not
155
+ // every account on the machine's. A fresh file per write, so the mode always applies.
156
+ await fsp.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 })
157
+ await fsp.rename(tmp, STATE_FILE)
158
+ } catch (err) {
159
+ await fsp.rm(tmp, { force: true }).catch(() => {})
160
+ throw err
161
+ }
162
+ return state
163
+ }
164
+
165
+ /**
166
+ * Hand a `harness://…` deep link, or a folder, to whatever opens things on this OS. The
167
+ * opener gets an argument list, never a shell string.
168
+ *
169
+ * Only `present()` calls this, and no harness knowledge ever reaches it: an adapter says what it
170
+ * wants opened and this decides how, which is the seam that keeps the harness adapters swappable.
171
+ *
172
+ * macOS's `open(1)` does both jobs, and `xdg-open` is the Linux equivalent. On Windows the
173
+ * equivalent is ShellExecute, reached through `rundll32 url.dll,FileProtocolHandler`: a
174
+ * registered protocol URL goes to its app and a folder opens in Explorer, with the argument
175
+ * passed through untouched. Two more obvious routes were tried and rejected — `explorer.exe
176
+ * <url>` silently drops any URL that carries a query string, so `code/new?folder=…` never
177
+ * arrived, and `cmd /c start` parses its own argument line, where the `%3A%5C` escapes in that
178
+ * same link are exactly what it expands.
179
+ *
180
+ * The spawn is guarded because the opener may simply not be installed — a headless Linux box
181
+ * has no `xdg-open` — and an unhandled `error` event on a child process takes the whole server
182
+ * down. The error is ignored and the page is not told: there is nothing it could do with the
183
+ * error, and the scan path must never depend on whether presentation worked.
184
+ */
185
+ const OPENERS = {
186
+ darwin: ['open'],
187
+ win32: ['rundll32', 'url.dll,FileProtocolHandler'],
188
+ linux: ['xdg-open'],
189
+ }
190
+
191
+ export function launch(target) {
192
+ const opener = OPENERS[process.platform]
193
+ if (!opener) return
194
+ const [cmd, ...args] = opener
195
+ const child = spawn(cmd, [...args, target], { stdio: 'ignore', detached: true })
196
+ child.on('error', () => {})
197
+ child.unref()
198
+ }
199
+
200
+ /**
201
+ * A folder is openable only if it is still on this machine and still a directory. Paths
202
+ * arrive from the page, which got them from a scan that may be minutes old — a repo that
203
+ * has since been moved or deleted must fail here rather than hand the opener a dead path.
204
+ * Absolute is judged by `path.isAbsolute` rather than a leading `/`, which no Windows path has.
205
+ */
206
+ async function resolveFolder(folder) {
207
+ if (typeof folder !== 'string' || !path.isAbsolute(folder)) return null
208
+ const dir = path.resolve(folder)
209
+ const stat = await fsp.stat(dir).catch(() => null)
210
+ return stat && stat.isDirectory() ? dir : null
211
+ }
212
+
213
+ /**
214
+ * `command.cwd` came from the page — inside `ref`, or as the folder itself — so it gets the same
215
+ * check as any other folder the page names. There is no fallback directory on purpose:
216
+ * `claude --resume` looks a session up under the folder it ran in, and a terminal that opens on
217
+ * "No conversation found" and closes is worse than an error toast.
218
+ */
219
+ async function runInTerminal(command) {
220
+ if (!command.cwd) return { ok: false, error: 'That thread has no folder on record to resume in' }
221
+ const cwd = await resolveFolder(command.cwd)
222
+ if (!cwd) return { ok: false, error: 'The folder that thread ran in is not on this machine any more' }
223
+ // A folder that exists but cannot be entered fails inside every terminal alike, and the
224
+ // terminal gets the blame; say what is actually wrong instead.
225
+ const enterable = await fsp.access(cwd, fsp.constants.X_OK).then(() => true, () => false)
226
+ if (!enterable) return { ok: false, error: 'The folder that thread ran in cannot be entered' }
227
+ const opened = await openInTerminal(command.argv, cwd)
228
+ return opened.ok ? { ok: true, via: 'terminal' } : opened
229
+ }
230
+
231
+ /**
232
+ * Show a harness's answer to "open this" — `{ ok, url, command }` — the way the page asked for
233
+ * it, and say truthfully whether anything happened.
234
+ *
235
+ * A page asking for a terminal never gets the desktop app instead, even when the CLI is missing:
236
+ * an app window appearing after choosing a terminal reads as the setting being ignored, where an
237
+ * error toast reads as something to fix.
238
+ *
239
+ * Otherwise macOS and Windows hand the URL to the opener: a scheme the harness's app registers is
240
+ * always answered there, so nothing is probed. Linux is the platform where the URL may have
241
+ * nowhere to go — the desktop app is optional and often absent, and `xdg-open` on a scheme nobody
242
+ * claims exits with status 0 and opens nothing, which used to reach the page as "Opened". So
243
+ * there the scheme is checked first; failing that, the harness's own CLI runs in a terminal, from
244
+ * the `command` the adapter offered alongside the URL; failing that, the page is told so.
245
+ */
246
+ export async function present(result, via = 'app') {
247
+ // Only the reason reaches the page: a failure may still carry the adapter's command.
248
+ if (!result || !result.ok) return { ok: false, error: result?.error || 'Nothing to open' }
249
+
250
+ if (via === 'terminal') {
251
+ if (!result.command) {
252
+ return {
253
+ ok: false,
254
+ error:
255
+ 'That harness’s CLI was not found on this machine — install it, or set “Open threads in” back to the desktop app',
256
+ }
257
+ }
258
+ return runInTerminal(result.command)
259
+ }
260
+
261
+ // A `pid` names a live process whose thread already has a window on this machine — a session
262
+ // running in a terminal right now. Fronting that window is tried before the URL, because the
263
+ // URL for exactly these threads is `resume`, which imports the transcript into the desktop app
264
+ // as a second, untitled session: "open" would fork the thread without telling anyone. Only when
265
+ // no window can be found — the terminal is on another desktop, the process is detached, the walk
266
+ // reached the desktop app itself — does the URL run as before.
267
+ if (result.pid && (await focusWindowOfPid(result.pid))) return { ok: true, focused: true }
268
+
269
+ if (process.platform !== 'linux') {
270
+ if (!result.url) return { ok: false, error: 'That harness has no deep link to open on this platform' }
271
+ launch(result.url)
272
+ // A note is the adapter saying it opened *something* — the repo rather than the thread.
273
+ return { ok: true, url: result.url, note: result.note }
274
+ }
275
+
276
+ if (result.url && (await schemeHasHandler(result.url))) {
277
+ launch(result.url)
278
+ return { ok: true, url: result.url }
279
+ }
280
+ if (result.command) return runInTerminal(result.command)
281
+ const scheme = schemeOf(result.url)
282
+ return {
283
+ ok: false,
284
+ error: scheme
285
+ ? `Nothing on this machine opens ${scheme}:// links, and there is no CLI command to run instead`
286
+ : 'Nothing on this machine can open that',
287
+ }
288
+ }
289
+
290
+ const viaOf = (body) => (body?.via === 'terminal' ? 'terminal' : 'app')
291
+
292
+ /**
293
+ * Mark the threads the colony has retired.
294
+ *
295
+ * Nothing is written anywhere. Threadnauts used to set `isArchived` on the desktop app's own
296
+ * session record, and it did land on disk — but the app serves from the copy it loaded at
297
+ * launch, so the thread stayed put in its own list until the next restart, and the app would
298
+ * rewrite the record from memory whenever it touched the thread. Papering over that took a
299
+ * re-assert on every poll, a `ps` sweep to guess whether the app had re-read the file, and a
300
+ * *pending* state for the gap between the two — a lot of machinery for something that still
301
+ * looked broken to anyone with the app open.
302
+ *
303
+ * So the colony keeps its own list and that is all it does. Archiving in the harness's own UI
304
+ * still sends the bot home, because the scan reads that flag; archiving here is the
305
+ * colony's own business. Nothing outside `data/colony.json` is ever written.
306
+ */
307
+ async function reconcileArchived(threads) {
308
+ const state = await readState()
309
+ if (!state.archived.length) return threads
310
+ const wanted = new Set(state.archived)
311
+
312
+ /**
313
+ * An archive is remembered by the thread id the page saw, but that id is only the *canonical*
314
+ * one. A thread the desktop app knows and the CLI has not written a transcript for is keyed on
315
+ * its desktop record; the moment a transcript appears it re-keys to that session's UUID, and a
316
+ * list keyed on the old string stops matching. The thread is shown as unarchived again, which
317
+ * reads as the archive having failed.
318
+ *
319
+ * So the ids inside `ref` count too. They are opaque to everything else here — this only ever
320
+ * asks whether a string it already holds appears among them.
321
+ */
322
+ const archived = (thread) => {
323
+ if (wanted.has(thread.id)) return true
324
+ const ref = thread.ref
325
+ if (!ref || typeof ref !== 'object') return false
326
+ for (const value of Object.values(ref)) {
327
+ if (typeof value === 'string') {
328
+ if (value && wanted.has(value)) return true
329
+ } else if (Array.isArray(value)) {
330
+ for (const v of value) if (typeof v === 'string' && v && wanted.has(v)) return true
331
+ }
332
+ }
333
+ return false
334
+ }
335
+
336
+ return threads.map((t) => (archived(t) ? { ...t, archived: true } : t))
337
+ }
338
+
339
+ function send(res, status, body) {
340
+ const payload = JSON.stringify(body)
341
+ res.writeHead(status, {
342
+ 'Content-Type': 'application/json',
343
+ 'Cache-Control': 'no-store',
344
+ 'Content-Length': Buffer.byteLength(payload),
345
+ 'X-Content-Type-Options': 'nosniff',
346
+ })
347
+ res.end(payload)
348
+ }
349
+
350
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
351
+
352
+ // The machine's own LAN addresses count as local too, so the colony can be
353
+ // served to the home network with THREADNAUTS_HOST set. Harmless when bound
354
+ // to loopback (those hosts can't reach the server anyway), and the Host +
355
+ // Origin pairing still stops DNS rebinding and CSRF exactly as before.
356
+ for (const addrs of Object.values(os.networkInterfaces())) {
357
+ for (const a of addrs || []) {
358
+ if (a && a.family === 'IPv4' && !a.internal && a.address) LOCAL_HOSTS.add(a.address)
359
+ }
360
+ }
361
+
362
+ // Under WSL2 the Windows host's own address is not one of this machine's interfaces,
363
+ // so a phone that reaches the Windows side arrives with a Host the loop above never
364
+ // learned. THREADNAUTS_ALLOWED_HOSTS names those extra addresses, comma separated,
365
+ // and they get the same Host and Origin treatment as the rest. Each is read the way a
366
+ // request's Host is, so `My-PC`, `192.0.2.10:5274` and `[fd00::1]` match what arrives; a
367
+ // bare IPv6 address is not a valid URL host until it is bracketed.
368
+ for (const extra of (process.env.THREADNAUTS_ALLOWED_HOSTS || '').split(',')) {
369
+ const entry = extra.trim()
370
+ const host = hostnameOf(entry) || hostnameOf(`[${entry}]`)
371
+ if (host) LOCAL_HOSTS.add(host)
372
+ }
373
+
374
+ /** Hostname out of a `Host:` or `Origin:` value, with the port and any brackets stripped. */
375
+ function hostnameOf(value) {
376
+ if (!value) return ''
377
+ const raw = String(value).includes('://') ? value : `http://${value}`
378
+ try {
379
+ return new URL(raw).hostname.replace(/^\[|\]$/g, '')
380
+ } catch {
381
+ return ''
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Only a page this server itself served may drive it. Two checks, against two different
387
+ * attacks, both of which a localhost server with an `open`-the-desktop-app button is a
388
+ * genuinely attractive target for:
389
+ *
390
+ * - **Host** stops DNS rebinding. Binding to 127.0.0.1 is not on its own enough: an
391
+ * attacker who points `evil.com` at 127.0.0.1 reaches us *as a same-origin page*, and
392
+ * can then read every response. The rebound request still carries `Host: evil.com`.
393
+ * - **Origin** stops CSRF. A cross-site `fetch` with a `text/plain` body is not
394
+ * preflighted, so without this check any page you happened to be visiting could POST
395
+ * here — spawning sessions, opening Finder windows, or wiping the colony layout —
396
+ * even though it could never read the reply.
397
+ *
398
+ * A state-changing request with no `Origin` at all is refused: browsers always send one on
399
+ * POST/PUT, so its absence means the caller is not the page. That does mean a bare `curl`
400
+ * POST is rejected; pass `-H 'Origin: http://localhost:5274'` if you are scripting this.
401
+ */
402
+ function isLocalRequest(req) {
403
+ if (!LOCAL_HOSTS.has(hostnameOf(req.headers.host))) return false
404
+
405
+ const origin = req.headers.origin
406
+ if (origin && origin !== 'null') return LOCAL_HOSTS.has(hostnameOf(origin))
407
+ return req.method === 'GET' || req.method === 'HEAD'
408
+ }
409
+
410
+ function readJsonBody(req, limit = 4 * 1024 * 1024) {
411
+ return new Promise((resolve, reject) => {
412
+ let size = 0
413
+ const chunks = []
414
+ req.on('data', (c) => {
415
+ size += c.length
416
+ if (size > limit) {
417
+ reject(new Error('Body too large'))
418
+ req.destroy()
419
+ return
420
+ }
421
+ chunks.push(c)
422
+ })
423
+ req.on('end', () => {
424
+ try {
425
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'))
426
+ } catch (err) {
427
+ reject(err)
428
+ }
429
+ })
430
+ req.on('error', reject)
431
+ })
432
+ }
433
+
434
+ export async function middleware(req, res, next) {
435
+ const url = new URL(req.url, 'http://localhost')
436
+ if (!url.pathname.startsWith('/api/')) return next ? next() : send(res, 404, { error: 'Not found' })
437
+
438
+ if (!isLocalRequest(req)) {
439
+ return send(res, 403, { error: 'Threadnauts only answers its own page on this machine' })
440
+ }
441
+
442
+ try {
443
+ if (url.pathname === '/api/threads' && req.method === 'GET') {
444
+ const threads = await withOrganizations(await reconcileArchived(await scanThreads()))
445
+ // A harness that is present but cannot read its own store says so here, rather than
446
+ // appearing healthy in the list while contributing no threads.
447
+ const warnings = (await harnessStatus()).filter((h) => h.detected && h.error).map((h) => h.error)
448
+ const worktreePath = (await linkedWorktree(CHECKOUT_DIR)) ? CHECKOUT_DIR : ''
449
+ return send(res, 200, { threads, scannedAt: Date.now(), warnings, worktreePath })
450
+ }
451
+
452
+ if (url.pathname === '/api/harnesses' && req.method === 'GET') {
453
+ return send(res, 200, { harnesses: await harnessStatus() })
454
+ }
455
+
456
+ if (url.pathname === '/api/state' && req.method === 'GET') {
457
+ return send(res, 200, await readState())
458
+ }
459
+
460
+ /**
461
+ * Optimistic concurrency, so a second tab cannot paste over the first one's work.
462
+ *
463
+ * `baseUpdatedAt` is the version the caller last agreed with. If the file no longer carries
464
+ * it, the caller's whole-file body describes a colony that no longer exists — so the disk
465
+ * state comes back with a 409 and the page merges against it. Merging here was the other
466
+ * option and it is the wrong place: the server has no idea which of two `plots` layouts a
467
+ * person actually dragged.
468
+ *
469
+ * The test is inequality rather than "older than", because a colony file also moves
470
+ * *backwards* — restored from a backup, edited by hand — and a page open across that holds
471
+ * a base newer than disk, which sails through a greater-than check and pastes the
472
+ * pre-restore colony straight back.
473
+ *
474
+ * A missing or zero base is a first write and is allowed: nothing to lose on a fresh
475
+ * install, and it keeps the endpoint drivable from `curl`.
476
+ */
477
+ if (url.pathname === '/api/state' && req.method === 'PUT') {
478
+ const body = await readJsonBody(req)
479
+ const base = Number(body.baseUpdatedAt) || 0
480
+ return serialise(async () => {
481
+ const current = await readState()
482
+ if (base && current.updatedAt !== base) return send(res, 409, current)
483
+ return send(res, 200, await writeState(body))
484
+ })
485
+ }
486
+
487
+ if (url.pathname === '/api/about' && req.method === 'GET') {
488
+ return send(res, 200, await aboutThisApp())
489
+ }
490
+
491
+ if (url.pathname === '/api/threadgauge' && req.method === 'GET') {
492
+ return send(res, 200, { url: await findThreadgauge() })
493
+ }
494
+
495
+ if (url.pathname === '/api/threadgauge/contexts' && req.method === 'GET') {
496
+ return send(res, 200, await contextsFromThreadgauge())
497
+ }
498
+
499
+ if (url.pathname === '/api/pro' && req.method === 'GET') {
500
+ return send(res, 200, await proContent().status())
501
+ }
502
+
503
+ if (url.pathname === '/api/license' && req.method === 'GET') {
504
+ return send(res, 200, await licensing().status())
505
+ }
506
+
507
+ if (url.pathname === '/api/license' && req.method === 'POST') {
508
+ const body = await readJsonBody(req, LICENSE_BODY_LIMIT)
509
+ return send(res, 200, await licensing().activate(body.key))
510
+ }
511
+
512
+ if (url.pathname === '/api/license/renew' && req.method === 'POST') {
513
+ return send(res, 200, await licensing().renew())
514
+ }
515
+
516
+ if (url.pathname === '/api/open' && req.method === 'POST') {
517
+ const body = await readJsonBody(req)
518
+ const shown = await present(await harnessOpenThread(body.harness, body.ref), viaOf(body))
519
+ return send(res, shown.ok ? 200 : 400, shown)
520
+ }
521
+
522
+ if ((url.pathname === '/api/new-session' || url.pathname === '/api/reveal') && req.method === 'POST') {
523
+ const body = await readJsonBody(req)
524
+ const dir = await resolveFolder(body.folder)
525
+ if (!dir) return send(res, 400, { ok: false, error: 'That folder is not on this machine any more' })
526
+
527
+ if (url.pathname === '/api/reveal') {
528
+ launch(dir)
529
+ return send(res, 200, { ok: true })
530
+ }
531
+ const harness = body.harness || (await defaultHarness())
532
+ const shown = await present(await harnessNewSession(harness, dir), viaOf(body))
533
+ return send(res, shown.ok ? 200 : 400, shown)
534
+ }
535
+
536
+ return send(res, 404, { error: 'Unknown endpoint' })
537
+ } catch (err) {
538
+ return send(res, 500, { error: String(err && err.message ? err.message : err) })
539
+ }
540
+ }
@@ -0,0 +1,127 @@
1
+ import http from 'node:http'
2
+ import fsp from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { middleware, proContentFile } from './api.mjs'
6
+
7
+ const here = path.dirname(fileURLToPath(import.meta.url))
8
+ const DIST = path.join(here, '..', 'dist')
9
+
10
+ /**
11
+ * What the page is allowed to do, said once by the server so a slip in the page cannot widen it.
12
+ *
13
+ * The colony draws text it did not write — thread titles are people's prompts, previews are
14
+ * their first messages — and every place that text reaches `innerHTML` escapes it. This is the
15
+ * backstop for the day one does not: a script that is not the bundle does not run, the page
16
+ * cannot be framed, and nothing on it can post a form or fetch anywhere but here. Everything
17
+ * listed is something the page actually does — one module script, style attributes set from
18
+ * code, textures and the screenshot as data: and blob: URLs, models and audio from `/assets`.
19
+ * `connect-src` allows blob: because three's GLTFLoader unpacks a model's embedded textures
20
+ * into blob: URLs and fetches them back — without it every tree on the map is white. Nothing
21
+ * inline, no workers, no third party. The dev server (`vite`) is not this file and keeps its HMR.
22
+ */
23
+ const CSP = [
24
+ "default-src 'self'",
25
+ "script-src 'self'",
26
+ "style-src 'self'",
27
+ "style-src-attr 'unsafe-inline'",
28
+ "img-src 'self' data: blob:",
29
+ "media-src 'self' blob:",
30
+ "font-src 'self' data:",
31
+ "connect-src 'self' blob:",
32
+ "object-src 'none'",
33
+ "base-uri 'none'",
34
+ "form-action 'none'",
35
+ "frame-ancestors 'none'",
36
+ ].join('; ')
37
+
38
+ const SECURITY_HEADERS = {
39
+ 'Content-Security-Policy': CSP,
40
+ 'X-Content-Type-Options': 'nosniff',
41
+ 'Referrer-Policy': 'no-referrer',
42
+ 'X-Frame-Options': 'DENY',
43
+ }
44
+
45
+ const TYPES = {
46
+ '.html': 'text/html; charset=utf-8',
47
+ '.js': 'text/javascript; charset=utf-8',
48
+ '.css': 'text/css; charset=utf-8',
49
+ '.json': 'application/json; charset=utf-8',
50
+ '.svg': 'image/svg+xml',
51
+ '.png': 'image/png',
52
+ '.ico': 'image/x-icon',
53
+ '.woff2': 'font/woff2',
54
+ }
55
+
56
+ /** Resolve inside dist/ only — a request can never climb out with `..`. */
57
+ function resolveInDist(pathname) {
58
+ const rel = pathname.replace(/^\/+/, '')
59
+ const file = path.resolve(DIST, rel || 'index.html')
60
+ return file === DIST || file.startsWith(DIST + path.sep) ? file : null
61
+ }
62
+
63
+ async function serveRequest(req, res) {
64
+ let url
65
+ let pathname
66
+ try {
67
+ url = new URL(req.url, 'http://localhost')
68
+ pathname = decodeURIComponent(url.pathname)
69
+ } catch {
70
+ res.writeHead(400, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' }).end('Bad request')
71
+ return
72
+ }
73
+
74
+ if (url.pathname.startsWith('/api/')) {
75
+ return middleware(req, res, null)
76
+ }
77
+
78
+ const pro = url.pathname.startsWith('/assets/') ? await proContentFile(url.pathname) : null
79
+ if (pro) {
80
+ res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': TYPES['.js'], 'Content-Length': pro.length, 'Cache-Control': 'private, max-age=31536000, immutable' })
81
+ res.end(pro)
82
+ return
83
+ }
84
+
85
+ let file = resolveInDist(pathname)
86
+ if (!file) {
87
+ res.writeHead(403, SECURITY_HEADERS).end('Forbidden')
88
+ return
89
+ }
90
+ try {
91
+ if ((await fsp.stat(file)).isDirectory()) file = path.join(file, 'index.html')
92
+ } catch {
93
+ if (url.pathname.startsWith('/assets/')) {
94
+ res.writeHead(404, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' }).end('Not found')
95
+ return
96
+ }
97
+ file = path.join(DIST, 'index.html') // SPA fallback
98
+ }
99
+
100
+ try {
101
+ const body = await fsp.readFile(file)
102
+ const type = TYPES[path.extname(file)] || 'application/octet-stream'
103
+ const cache = path.relative(DIST, file).startsWith(`assets${path.sep}`)
104
+ ? 'public, max-age=31536000, immutable'
105
+ : 'no-cache'
106
+ res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': type, 'Content-Length': body.length, 'Cache-Control': cache })
107
+ res.end(body)
108
+ } catch {
109
+ res.writeHead(404, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' }).end('Not found')
110
+ }
111
+ }
112
+
113
+ export function startServer({ port, host }) {
114
+ const server = http.createServer((req, res) => {
115
+ serveRequest(req, res).catch(() => {
116
+ if (!res.headersSent) res.writeHead(500, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' })
117
+ res.end()
118
+ })
119
+ })
120
+ return new Promise((resolve, reject) => {
121
+ server.once('error', reject)
122
+ server.listen(port, host, () => {
123
+ server.off('error', reject)
124
+ resolve(server)
125
+ })
126
+ })
127
+ }