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