@threadcrumbs/threadnauts 1.2.0 → 1.4.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/NOTICES.md +96 -0
- package/bin/threadnauts.mjs +13 -7
- package/dist/assets/CREDITS.md +1 -4
- package/dist/assets/arrakesh-B6KYoRjN.svg +1 -0
- package/dist/assets/index-CJXuDA5A.js +2517 -0
- package/dist/assets/index-LNc3c53f.css +1 -0
- package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/assets/inter-tight-latin-ext-wght-normal-B4u9PlvR.woff2 +0 -0
- package/dist/assets/inter-tight-latin-wght-normal-DX-nOvPD.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/assets/shared-CudSvZVV.js +5015 -0
- package/dist/index.html +3 -3
- package/dist/pro-manifest.json +3 -3
- package/package.json +3 -3
- package/server/accents.mjs +27 -0
- package/server/api.mjs +54 -5
- package/server/http-server.mjs +8 -0
- package/server/license.mjs +72 -69
- package/server/pro-content.mjs +2 -1
- package/server/source-checkout.mjs +11 -0
- package/server/threadbare.mjs +252 -0
- package/server/threadgauge.mjs +129 -42
- package/dist/assets/arrakesh-BW9Bb56U.svg +0 -1
- package/dist/assets/index-C46d6g6p.js +0 -2464
- package/dist/assets/index-PzTcyxAK.css +0 -1
- package/dist/assets/lighting/README.md +0 -10
- package/dist/assets/lighting/studio_small_09_1k.hdr +0 -0
- package/dist/assets/shared-DiTtF988.js +0 -4978
- package/dist/audio/README.md +0 -25
package/dist/index.html
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
<meta name="color-scheme" content="dark" />
|
|
7
7
|
<title>Threadnauts</title>
|
|
8
8
|
<link rel="icon" href="/favicon.svg?app=threadnauts" type="image/svg+xml" />
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="modulepreload" crossorigin href="/assets/shared-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-CJXuDA5A.js"></script>
|
|
10
|
+
<link rel="modulepreload" crossorigin href="/assets/shared-CudSvZVV.js">
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-LNc3c53f.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="app"><div class="boot"><div class="inner">
|
package/dist/pro-manifest.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"files": [
|
|
3
3
|
{
|
|
4
|
-
"path": "assets/pro-
|
|
5
|
-
"sha256": "
|
|
6
|
-
"bytes":
|
|
4
|
+
"path": "assets/pro-DirsrB9B.js",
|
|
5
|
+
"sha256": "0040bc8fe354c96a4a81d290bde4a027502f9ea6a42ebe754dccce5c31f2c7df",
|
|
6
|
+
"bytes": 261373
|
|
7
7
|
}
|
|
8
8
|
]
|
|
9
9
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@threadcrumbs/threadnauts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A 3D colony sim where every coding-agent thread on your machine is a little bot building something.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
7
|
-
"author": "Threadcrumbs LLC",
|
|
7
|
+
"author": "Threadcrumbs LLC <hello@threadcrumbs.io> (https://threadcrumbs.io)",
|
|
8
8
|
"homepage": "https://threadnauts.io",
|
|
9
9
|
"bugs": {
|
|
10
10
|
"url": "https://threadnauts.io/#contact",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"auto-publish": "node ../common/tools/release.mjs"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@threadcrumbs/common": "^0.1.
|
|
56
|
+
"@threadcrumbs/common": "^0.1.9"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@gltf-transform/core": "^4.4.2",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const HEX_COLOR = /^#[0-9a-f]{6}$/
|
|
2
|
+
|
|
3
|
+
const asObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {})
|
|
4
|
+
|
|
5
|
+
function asHex(value) {
|
|
6
|
+
if (Number.isInteger(value) && value >= 0 && value <= 0xffffff) return `#${value.toString(16).padStart(6, '0')}`
|
|
7
|
+
if (typeof value === 'string' && HEX_COLOR.test(value.toLowerCase())) return value.toLowerCase()
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function hexColors(...layers) {
|
|
12
|
+
const out = {}
|
|
13
|
+
for (const layer of layers) {
|
|
14
|
+
for (const [name, value] of Object.entries(asObject(layer))) {
|
|
15
|
+
const hex = asHex(value)
|
|
16
|
+
if (hex) Object.defineProperty(out, name, { value: hex, enumerable: true, writable: true, configurable: true })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function accentsAnswer(state) {
|
|
23
|
+
return {
|
|
24
|
+
orgs: hexColors(state?.accentsShown?.orgs, state?.accents?.orgs),
|
|
25
|
+
repos: hexColors(state?.accentsShown?.plots, state?.accents?.plots),
|
|
26
|
+
}
|
|
27
|
+
}
|
package/server/api.mjs
CHANGED
|
@@ -8,7 +8,9 @@ import { openInTerminal } from './lib/terminal.mjs'
|
|
|
8
8
|
import { focusWindowOfPid } from './lib/windows.mjs'
|
|
9
9
|
import { localLicensing } from './license.mjs'
|
|
10
10
|
import { localProContent } from './pro-content.mjs'
|
|
11
|
-
import { findThreadgauge,
|
|
11
|
+
import { findThreadgauge, threadgaugeSessions } from './threadgauge.mjs'
|
|
12
|
+
import { CONTROL_BODY_LIMIT, THREADBARE_RELEASED, checkAnswerBody, checkAsksThread, checkRunBody, threadbareCommander } from './threadbare.mjs'
|
|
13
|
+
import { accentsAnswer } from './accents.mjs'
|
|
12
14
|
import { organizationReader } from '@threadcrumbs/common/orgs'
|
|
13
15
|
import {
|
|
14
16
|
defaultHarness,
|
|
@@ -35,7 +37,9 @@ const proContent = () => (proFiles ??= localProContent())
|
|
|
35
37
|
export const proContentFile = (pathname) => proContent().file(pathname)
|
|
36
38
|
|
|
37
39
|
const withOrganizations = organizationReader()
|
|
38
|
-
const
|
|
40
|
+
const sessionsFromThreadgauge = threadgaugeSessions()
|
|
41
|
+
const commander = threadbareCommander({ pro: () => licensing().opens('threadgauge.pro', THREADBARE_RELEASED) })
|
|
42
|
+
const CONTROL_ROUTES = { '/api/threadgauge/run': [checkRunBody, (body) => commander.run(body)], '/api/threadgauge/answer': [checkAnswerBody, (body) => commander.answer(body)] }
|
|
39
43
|
|
|
40
44
|
let about = null
|
|
41
45
|
const aboutThisApp = () =>
|
|
@@ -82,6 +86,7 @@ const emptyState = () => ({
|
|
|
82
86
|
viewedAt: {},
|
|
83
87
|
trinkets: {},
|
|
84
88
|
accents: {},
|
|
89
|
+
accentsShown: {},
|
|
85
90
|
districts: 0,
|
|
86
91
|
settings: null,
|
|
87
92
|
updatedAt: 0,
|
|
@@ -122,6 +127,7 @@ async function readState() {
|
|
|
122
127
|
viewedAt: asObject(raw.viewedAt),
|
|
123
128
|
trinkets: asObject(raw.trinkets),
|
|
124
129
|
accents: asObject(raw.accents),
|
|
130
|
+
accentsShown: asObject(raw.accentsShown),
|
|
125
131
|
districts: Number(raw.districts) || 0,
|
|
126
132
|
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : null,
|
|
127
133
|
updatedAt: Number(raw.updatedAt) || 0,
|
|
@@ -162,6 +168,7 @@ async function writeState(next) {
|
|
|
162
168
|
viewedAt: asObject(next.viewedAt),
|
|
163
169
|
trinkets: asObject(next.trinkets),
|
|
164
170
|
accents: asObject(next.accents),
|
|
171
|
+
accentsShown: asObject(next.accentsShown),
|
|
165
172
|
districts: Number(next.districts) || 0,
|
|
166
173
|
settings: next.settings && typeof next.settings === 'object' ? next.settings : null,
|
|
167
174
|
updatedAt: Date.now(),
|
|
@@ -421,10 +428,26 @@ function isLocalRequest(req) {
|
|
|
421
428
|
if (!LOCAL_HOSTS.has(hostnameOf(req.headers.host))) return false
|
|
422
429
|
|
|
423
430
|
const origin = req.headers.origin
|
|
424
|
-
if (origin && origin !== 'null') return
|
|
431
|
+
if (origin && origin !== 'null') return isOwnOrigin(origin, req)
|
|
425
432
|
return req.method === 'GET' || req.method === 'HEAD'
|
|
426
433
|
}
|
|
427
434
|
|
|
435
|
+
function isOwnOrigin(origin, req) {
|
|
436
|
+
try {
|
|
437
|
+
const url = new URL(origin)
|
|
438
|
+
const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80)
|
|
439
|
+
return LOCAL_HOSTS.has(hostnameOf(origin)) && port === req.socket.localPort
|
|
440
|
+
} catch {
|
|
441
|
+
return false
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
|
446
|
+
const sendsJson = (req) => /^application\/json\s*(;|$)/i.test(req.headers['content-type'] || '')
|
|
447
|
+
|
|
448
|
+
const LOOPBACK_PEER = /^(127\.|::1$|::ffff:127\.)/
|
|
449
|
+
export const isAnotherDevice = (req) =>!LOOPBACK_PEER.test(req.socket.remoteAddress || '')
|
|
450
|
+
|
|
428
451
|
function readJsonBody(req, limit = 4 * 1024 * 1024) {
|
|
429
452
|
return new Promise((resolve, reject) => {
|
|
430
453
|
let size = 0
|
|
@@ -456,6 +479,9 @@ export async function middleware(req, res, next) {
|
|
|
456
479
|
if (!isLocalRequest(req)) {
|
|
457
480
|
return send(res, 403, { error: 'Threadnauts only answers its own page on this machine' })
|
|
458
481
|
}
|
|
482
|
+
if (WRITE_METHODS.has(req.method) && !sendsJson(req)) {
|
|
483
|
+
return send(res, 415, { error: 'Threadnauts only accepts JSON' })
|
|
484
|
+
}
|
|
459
485
|
|
|
460
486
|
try {
|
|
461
487
|
if (url.pathname === '/api/threads' && req.method === 'GET') {
|
|
@@ -506,12 +532,35 @@ export async function middleware(req, res, next) {
|
|
|
506
532
|
return send(res, 200, await aboutThisApp())
|
|
507
533
|
}
|
|
508
534
|
|
|
535
|
+
if (url.pathname === '/api/accents' && req.method === 'GET') {
|
|
536
|
+
return send(res, 200, accentsAnswer(await readState()))
|
|
537
|
+
}
|
|
538
|
+
|
|
509
539
|
if (url.pathname === '/api/threadgauge' && req.method === 'GET') {
|
|
510
540
|
return send(res, 200, { url: await findThreadgauge() })
|
|
511
541
|
}
|
|
512
542
|
|
|
513
|
-
if (url.pathname === '/api/threadgauge/
|
|
514
|
-
return send(res, 200, await
|
|
543
|
+
if (url.pathname === '/api/threadgauge/sessions' && req.method === 'GET') {
|
|
544
|
+
return send(res, 200, await sessionsFromThreadgauge({ id: url.searchParams.get('id'), all: url.searchParams.has('all'), anotherDevice: isAnotherDevice(req) }))
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (url.pathname === '/api/threadgauge/commander' && req.method === 'GET') {
|
|
548
|
+
return send(res, 200, await commander.state({ anotherDevice: isAnotherDevice(req) }))
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (url.pathname === '/api/threadgauge/asks' && req.method === 'GET') {
|
|
552
|
+
if (isAnotherDevice(req)) return send(res, 403, { error: 'Threadnauts answers asks only from the computer it runs on' })
|
|
553
|
+
const thread = checkAsksThread(url.searchParams.get('thread'))
|
|
554
|
+
if (!thread) return send(res, 400, { error: 'Say which thread' })
|
|
555
|
+
return send(res, 200, await commander.asks({ thread }))
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (Object.hasOwn(CONTROL_ROUTES, url.pathname) && req.method === 'POST') {
|
|
559
|
+
if (isAnotherDevice(req)) return send(res, 403, { error: 'Threadnauts sends prompts and answers only from the computer it runs on' })
|
|
560
|
+
const [check, forward] = CONTROL_ROUTES[url.pathname]
|
|
561
|
+
const body = check(await readJsonBody(req, CONTROL_BODY_LIMIT).catch(() => null))
|
|
562
|
+
if (!body) return send(res, 400, { error: 'Threadnauts sends only a prompt or action to one thread, or an answer to one' })
|
|
563
|
+
return send(res, 200, await forward(body))
|
|
515
564
|
}
|
|
516
565
|
|
|
517
566
|
if (url.pathname === '/api/pro' && req.method === 'GET') {
|
package/server/http-server.mjs
CHANGED
|
@@ -110,6 +110,14 @@ async function serveRequest(req, res) {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
export function heldOnIpv6Loopback(port) {
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const trial = http.createServer()
|
|
116
|
+
trial.once('error', (err) => resolve(err.code === 'EADDRINUSE'))
|
|
117
|
+
trial.listen(port, '::1', () => trial.close(() => resolve(false)))
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
113
121
|
export function startServer({ port, host }) {
|
|
114
122
|
const server = http.createServer((req, res) => {
|
|
115
123
|
serveRequest(req, res).catch(() => {
|
package/server/license.mjs
CHANGED
|
@@ -1,69 +1,72 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
1
|
+
import { paidThrough, renewalDue, unlocks } from '@threadcrumbs/common/license'
|
|
2
|
+
import { cloudUrl, licenseStore, threadcrumbsDataDir, trustedKeys } from '@threadcrumbs/common/license/store'
|
|
3
|
+
import { isSourceCheckout } from './source-checkout.mjs'
|
|
4
|
+
|
|
5
|
+
const ENTITLEMENT = 'threadnauts.pro'
|
|
6
|
+
const CLOUD_TIMEOUT_MS = 15000
|
|
7
|
+
|
|
8
|
+
const runningFromCheckout = isSourceCheckout()
|
|
9
|
+
|
|
10
|
+
export function licensing({ store, cloudUrl, fetch = globalThis.fetch }) {
|
|
11
|
+
const describe = (license, at) => ({
|
|
12
|
+
proThrough: license ? paidThrough(license, ENTITLEMENT, at) : 0,
|
|
13
|
+
renewalDue: Boolean(license) && renewalDue(license, at),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
async function status() {
|
|
17
|
+
const { license, at } = await store.current()
|
|
18
|
+
return describe(license, at)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function ask(route, body) {
|
|
22
|
+
if (!cloudUrl) return { error: 'unavailable' }
|
|
23
|
+
let response
|
|
24
|
+
try {
|
|
25
|
+
response = await fetch(`${cloudUrl}${route}`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
body: JSON.stringify(body),
|
|
29
|
+
signal: AbortSignal.timeout(CLOUD_TIMEOUT_MS),
|
|
30
|
+
})
|
|
31
|
+
} catch {
|
|
32
|
+
return { error: 'offline' }
|
|
33
|
+
}
|
|
34
|
+
const data = await response.json().catch(() => null)
|
|
35
|
+
if (response.ok && typeof data?.license === 'string') return { license: data.license }
|
|
36
|
+
return { error: typeof data?.error === 'string' ? data.error : 'unavailable' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function keep(answer) {
|
|
40
|
+
if (answer.error) return { ...(await status()), error: answer.error }
|
|
41
|
+
const saved = await store.save(answer.license)
|
|
42
|
+
if (!saved.ok) return { ...(await status()), error: 'license_rejected' }
|
|
43
|
+
return describe(saved.license, saved.at)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function activate(key) {
|
|
47
|
+
const trimmed = typeof key === 'string' ? key.trim() : ''
|
|
48
|
+
if (!trimmed) return { ...(await status()), error: 'key_missing' }
|
|
49
|
+
return keep(await ask('/v1/license', { key: trimmed, device: await store.device() }))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function renew() {
|
|
53
|
+
const { token, license, at } = await store.current()
|
|
54
|
+
if (!token || !renewalDue(license, at)) return describe(license, at)
|
|
55
|
+
return keep(await ask('/v1/license/renew', { license: token }))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function opens(entitlement, releasedAt) {
|
|
59
|
+
const { license, at } = await store.current()
|
|
60
|
+
return Boolean(license) && unlocks(license, entitlement, releasedAt, at)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { status, activate, renew, opens }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function localLicensing({ env = process.env, development = runningFromCheckout, fetch } = {}) {
|
|
67
|
+
return licensing({
|
|
68
|
+
store: licenseStore({ dir: threadcrumbsDataDir({ env }), keys: trustedKeys({ env, development }) }),
|
|
69
|
+
cloudUrl: cloudUrl({ env, development }),
|
|
70
|
+
fetch,
|
|
71
|
+
})
|
|
72
|
+
}
|
package/server/pro-content.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path'
|
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import { LICENSE_GRACE, paidThrough } from '@threadcrumbs/common/license'
|
|
7
7
|
import { cloudUrl, licenseStore, threadcrumbsDataDir, trustedKeys } from '@threadcrumbs/common/license/store'
|
|
8
|
+
import { isSourceCheckout } from './source-checkout.mjs'
|
|
8
9
|
|
|
9
10
|
const ENTITLEMENT = 'threadnauts.pro'
|
|
10
11
|
const APP = 'threadnauts'
|
|
@@ -14,7 +15,7 @@ const PAUSE_MS = 20
|
|
|
14
15
|
|
|
15
16
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
16
17
|
const DIST = path.join(here, '..', 'dist')
|
|
17
|
-
const runningFromCheckout =
|
|
18
|
+
const runningFromCheckout = isSourceCheckout()
|
|
18
19
|
|
|
19
20
|
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
|
|
20
21
|
const pause = () => new Promise((resolve) => setTimeout(resolve, PAUSE_MS))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
export const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
6
|
+
|
|
7
|
+
const isDirectory = (file) => fs.statSync(file, { throwIfNoEntry: false })?.isDirectory() ?? false
|
|
8
|
+
|
|
9
|
+
export function isSourceCheckout(root = PACKAGE_ROOT) {
|
|
10
|
+
return fs.existsSync(path.join(root, '.git')) && isDirectory(path.join(root, 'src')) && isDirectory(path.join(root, 'test'))
|
|
11
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { clip } from '@threadcrumbs/common/lib/telemetry-fields'
|
|
4
|
+
import { threadcrumbsDataDir } from '@threadcrumbs/common/license/store'
|
|
5
|
+
import { isAskId, locateThreadgauge } from './threadgauge.mjs'
|
|
6
|
+
|
|
7
|
+
export const APP = 'threadnauts'
|
|
8
|
+
export const THREADBARE_RELEASED = Date.UTC(2026, 10, 9) / 1000
|
|
9
|
+
export const CONTROL_BODY_LIMIT = 16 * 1024
|
|
10
|
+
|
|
11
|
+
const REUSE_FOUND_MS = 15000
|
|
12
|
+
const READ_TIMEOUT_MS = 3000
|
|
13
|
+
const SEND_TIMEOUT_MS = 30000
|
|
14
|
+
const THREAD_MAX = 300
|
|
15
|
+
const VALUE_MAX = 8000
|
|
16
|
+
const OPTIONS_MAX = 8
|
|
17
|
+
const WHY_MAX = 500
|
|
18
|
+
const NAME_MAX = 80
|
|
19
|
+
const PROMPT_MAX = 4000
|
|
20
|
+
const PRESETS_MAX = 120
|
|
21
|
+
|
|
22
|
+
const PRESET_ID = /^[a-z0-9][a-z0-9-]{0,39}$/
|
|
23
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/
|
|
24
|
+
const COMMANDER_ACTIONS = new Set(['approve', 'deny', 'stop', 'retry'])
|
|
25
|
+
const ANSWER_KINDS = new Set(['option', 'allow', 'deny', 'retry'])
|
|
26
|
+
const APPLIES = new Set(['any', 'asking', 'done', 'working', 'error'])
|
|
27
|
+
const STATUSES = new Set(['asking', 'error', 'done', 'working', 'delegating', 'idle'])
|
|
28
|
+
const THREADS_MAX = 100
|
|
29
|
+
const SEEN_MAX = 100
|
|
30
|
+
const RUN_BATCH = 50
|
|
31
|
+
const APPROVALS = new Set(['approve', 'deny'])
|
|
32
|
+
const HELD_KINDS = new Set(['approval', 'question'])
|
|
33
|
+
const ASKS_MAX = 8
|
|
34
|
+
const DELIVERIES = new Set(['now', 'queued', 'resumed'])
|
|
35
|
+
const PASSED_ERRORS = new Set(['pro', 'not-found', 'uncertain', 'changed', 'setup', 'failed', 'bad-request', 'too-large'])
|
|
36
|
+
|
|
37
|
+
export const tokenFile = (dataDir) => path.join(dataDir, 'agent-controls', 'apps', `${APP}.token`)
|
|
38
|
+
|
|
39
|
+
const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
|
|
40
|
+
const onlyKeys = (body, keys) => isPlainObject(body) && Object.keys(body).every((key) => keys.includes(key))
|
|
41
|
+
const isThreadId = (v) => typeof v === 'string' && v.length > 0 && v.length <= THREAD_MAX && v.includes(':') && !CONTROL_CHARS.test(v)
|
|
42
|
+
const isAnswerText = (v) => typeof v === 'string' && v.trim().length > 0 && v.length <= VALUE_MAX && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(v)
|
|
43
|
+
|
|
44
|
+
export const checkAsksThread = (thread) => (isThreadId(thread) ? thread : null)
|
|
45
|
+
|
|
46
|
+
export function keepAsks(answer) {
|
|
47
|
+
const asks = Array.isArray(answer?.asks) ? answer.asks : []
|
|
48
|
+
return {
|
|
49
|
+
can: answer?.can?.answer === true,
|
|
50
|
+
why: typeof answer?.why === 'string' ? clip(answer.why, WHY_MAX) : '',
|
|
51
|
+
asks: asks.filter((ask) => isPlainObject(ask) && isAskId(ask.id) && HELD_KINDS.has(ask.kind)).slice(0, ASKS_MAX).map((ask) => ({ id: ask.id, kind: ask.kind })),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function checkRunBody(body) {
|
|
56
|
+
if (!onlyKeys(body, ['preset', 'threads', 'seen'])) return null
|
|
57
|
+
const { preset, threads } = body
|
|
58
|
+
if (typeof preset !== 'string' || !PRESET_ID.test(preset)) return null
|
|
59
|
+
if (!Array.isArray(threads) || !threads.length || threads.length > THREADS_MAX || !threads.every(isThreadId) || new Set(threads).size !== threads.length) return null
|
|
60
|
+
if (body.seen === undefined) return { preset, threads: [...threads] }
|
|
61
|
+
if (!isPlainObject(body.seen)) return null
|
|
62
|
+
const seen = Object.entries(body.seen)
|
|
63
|
+
if (!seen.length || seen.length > SEEN_MAX || !seen.every(([thread, ask]) => threads.includes(thread) && isAskId(ask))) return null
|
|
64
|
+
return { preset, threads: [...threads], seen: Object.fromEntries(seen) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function checkAnswerBody(body) {
|
|
68
|
+
if (!onlyKeys(body, ['thread', 'answer']) || !isThreadId(body.thread)) return null
|
|
69
|
+
const answer = body.answer
|
|
70
|
+
if (!isPlainObject(answer) || !ANSWER_KINDS.has(answer.kind)) return null
|
|
71
|
+
if (answer.id !== undefined && !isAskId(answer.id)) return null
|
|
72
|
+
const id = answer.id === undefined ? {} : { id: answer.id }
|
|
73
|
+
if (answer.kind !== 'option') return onlyKeys(answer, ['kind', 'id']) ? { thread: body.thread, answer: { kind: answer.kind, ...id } } : null
|
|
74
|
+
if (!onlyKeys(answer, ['kind', 'value', 'id'])) return null
|
|
75
|
+
const value = answer.value
|
|
76
|
+
const fine = isAnswerText(value) || (Array.isArray(value) && value.length > 0 && value.length <= OPTIONS_MAX && value.every(isAnswerText) && value.join('').length <= VALUE_MAX)
|
|
77
|
+
return fine ? { thread: body.thread, answer: { kind: 'option', value: Array.isArray(value) ? [...value] : value, ...id } } : null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const runnable = (preset) => preset.kind === 'prompt' || (preset.kind === 'action' && COMMANDER_ACTIONS.has(preset.action))
|
|
81
|
+
|
|
82
|
+
function keepPreset(raw, builtIn) {
|
|
83
|
+
if (!isPlainObject(raw) || typeof raw.id !== 'string' || !PRESET_ID.test(raw.id)) return null
|
|
84
|
+
const kind = raw.kind === 'action' ? 'action' : raw.kind === 'prompt' ? 'prompt' : null
|
|
85
|
+
if (!kind || typeof raw.name !== 'string' || !raw.name.trim()) return null
|
|
86
|
+
const preset = { id: raw.id, name: clip(raw.name, NAME_MAX), kind, applies: APPLIES.has(raw.applies) ? raw.applies : 'any', confirm: raw.confirm === true, builtIn }
|
|
87
|
+
if (kind === 'action') preset.action = typeof raw.action === 'string' ? raw.action : ''
|
|
88
|
+
if (kind === 'prompt') {
|
|
89
|
+
if (typeof raw.text !== 'string' || !raw.text.trim()) return null
|
|
90
|
+
preset.text = raw.text.slice(0, PROMPT_MAX)
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(raw.when)) preset.when = raw.when.filter((s) => STATUSES.has(s))
|
|
93
|
+
return runnable(preset) ? preset : null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function keepPresets(answer) {
|
|
97
|
+
const list = (items, builtIn) => (Array.isArray(items) ? items.slice(0, PRESETS_MAX).map((p) => keepPreset(p, builtIn)).filter(Boolean) : [])
|
|
98
|
+
return { builtIn: list(answer?.builtIn, true), mine: list(answer?.mine, false) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const outcome = {
|
|
102
|
+
off: () => ({ ok: false, error: 'off', why: 'Turn on “Let Threadnauts send” in Threadgauge’s Settings › Agent controls.' }),
|
|
103
|
+
missing: () => ({ ok: false, error: 'no-threadgauge', why: 'Threadgauge isn’t running.' }),
|
|
104
|
+
old: () => ({ ok: false, error: 'old', why: 'This Threadgauge is too old to send prompts and actions. Update it to send from Threadnauts.' }),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sent(answer) {
|
|
108
|
+
const kept = { ok: true }
|
|
109
|
+
if (DELIVERIES.has(answer?.delivery)) kept.delivery = answer.delivery
|
|
110
|
+
return kept
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function refused(answer, fallback) {
|
|
114
|
+
const error = typeof answer?.error === 'string' ? answer.error : ''
|
|
115
|
+
const kept = { ok: false, error: PASSED_ERRORS.has(error) ? error : 'failed', why: clip(typeof answer?.why === 'string' && answer.why ? answer.why : fallback, WHY_MAX) }
|
|
116
|
+
if (typeof answer?.fix?.text === 'string' && answer.fix.text) kept.fix = { text: clip(answer.fix.text, WHY_MAX) }
|
|
117
|
+
return kept
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function fromThreadgauge(status, answer) {
|
|
121
|
+
if (status >= 200 && status < 300 && answer?.ok !== false) return sent(answer)
|
|
122
|
+
const error = typeof answer?.error === 'string' ? answer.error : ''
|
|
123
|
+
if (status === 403 && (error === 'origin' || error === 'token')) return outcome.off()
|
|
124
|
+
if (status === 404 && error !== 'not-found') return outcome.old()
|
|
125
|
+
return refused(answer, `Threadgauge answered ${status}.`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const UNSAID = { ok: false, error: 'failed', why: 'Threadgauge didn’t say what happened to it.' }
|
|
129
|
+
|
|
130
|
+
export function resultsFromThreadgauge(threads, status, answer) {
|
|
131
|
+
if (!Array.isArray(answer?.results)) {
|
|
132
|
+
const whole = fromThreadgauge(status, answer)
|
|
133
|
+
return threads.map((thread) => ({ thread, ...(whole.ok ? UNSAID : whole) }))
|
|
134
|
+
}
|
|
135
|
+
const said = new Map(answer.results.filter((r) => r && typeof r.thread === 'string').map((r) => [r.thread, r]))
|
|
136
|
+
return threads.map((thread) => {
|
|
137
|
+
const result = said.get(thread)
|
|
138
|
+
return { thread, ...(!result ? UNSAID : result.ok === true ? sent(result) : refused(result, 'Threadgauge didn’t send it.')) }
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function threadbareCommander({ fetch = globalThis.fetch, find = () => locateThreadgauge({ fetch }), dataDir = () => threadcrumbsDataDir(), pro = async () => false, now = Date.now } = {}) {
|
|
143
|
+
let found = { at: -Infinity, where: null }
|
|
144
|
+
const forget = () => (found = { at: -Infinity, where: null })
|
|
145
|
+
|
|
146
|
+
async function where() {
|
|
147
|
+
if (now() - found.at > REUSE_FOUND_MS) found = { at: now(), where: await find() }
|
|
148
|
+
return found.where
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function readToken() {
|
|
152
|
+
try {
|
|
153
|
+
return (await fsp.readFile(tokenFile(dataDir()), 'utf8')).trim()
|
|
154
|
+
} catch {
|
|
155
|
+
return ''
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function read(origin, route, token = null) {
|
|
160
|
+
const response = await fetch(`${origin}${route}`, { ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}), signal: AbortSignal.timeout(READ_TIMEOUT_MS) })
|
|
161
|
+
return { status: response.status, answer: await response.json().catch(() => null) }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function control(origin, route, body, token) {
|
|
165
|
+
const response = await fetch(`${origin}/api/threadbare/${route}`, {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
168
|
+
body: JSON.stringify(body),
|
|
169
|
+
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
170
|
+
})
|
|
171
|
+
return { status: response.status, answer: await response.json().catch(() => null) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function reachable(shape, act) {
|
|
175
|
+
const token = await readToken()
|
|
176
|
+
if (!token) return shape(outcome.off())
|
|
177
|
+
const at = await where()
|
|
178
|
+
if (!at) return shape(outcome.missing())
|
|
179
|
+
try {
|
|
180
|
+
return await act(at.origin, token)
|
|
181
|
+
} catch {
|
|
182
|
+
forget()
|
|
183
|
+
return shape(outcome.missing())
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function state({ anotherDevice = false } = {}) {
|
|
188
|
+
if (anotherDevice) return { state: 'this-computer', url: null }
|
|
189
|
+
const at = await where()
|
|
190
|
+
if (!at) return { state: 'no-threadgauge', url: null }
|
|
191
|
+
let presets
|
|
192
|
+
try {
|
|
193
|
+
presets = await read(at.origin, '/api/threadbare/presets')
|
|
194
|
+
} catch {
|
|
195
|
+
forget()
|
|
196
|
+
return { state: 'no-threadgauge', url: null }
|
|
197
|
+
}
|
|
198
|
+
if (presets.status === 404) return { state: 'old', url: at.url }
|
|
199
|
+
const allowed = Boolean(await readToken())
|
|
200
|
+
return { state: allowed ? 'ready' : 'off', url: at.url, pro: Boolean(await pro()), presets: keepPresets(presets.answer) }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function runBatch(origin, token, body) {
|
|
204
|
+
try {
|
|
205
|
+
const { status, answer: said } = await control(origin, 'run', body, token)
|
|
206
|
+
return resultsFromThreadgauge(body.threads, status, said)
|
|
207
|
+
} catch {
|
|
208
|
+
forget()
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function run({ preset, threads, seen = {} }) {
|
|
214
|
+
const each = (whole) => ({ results: threads.map((thread) => ({ thread, ...whole })) })
|
|
215
|
+
return reachable(each, async (origin, token) => {
|
|
216
|
+
const presets = await read(origin, '/api/threadbare/presets')
|
|
217
|
+
if (presets.status === 404) return each(outcome.old())
|
|
218
|
+
const { builtIn, mine } = keepPresets(presets.answer)
|
|
219
|
+
const found = [...builtIn, ...mine].find((p) => p.id === preset)
|
|
220
|
+
if (!found) return each({ ok: false, error: 'not-found', why: 'That prompt or action isn’t one the Commander can send from Threadgauge now.' })
|
|
221
|
+
const approval = found.kind === 'action' && APPROVALS.has(found.action)
|
|
222
|
+
const sendable = approval ? threads.filter((thread) => Object.hasOwn(seen, thread)) : threads
|
|
223
|
+
const said = new Map()
|
|
224
|
+
let gone = false
|
|
225
|
+
for (let at = 0; at < sendable.length; at += RUN_BATCH) {
|
|
226
|
+
const batch = sendable.slice(at, at + RUN_BATCH)
|
|
227
|
+
const body = approval ? { preset, threads: batch, seen: Object.fromEntries(batch.map((thread) => [thread, seen[thread]])) } : { preset, threads: batch }
|
|
228
|
+
const results = gone ? null : await runBatch(origin, token, body)
|
|
229
|
+
gone = !results
|
|
230
|
+
for (const result of results ?? batch.map((thread) => ({ thread, ...outcome.missing() }))) said.set(result.thread, result)
|
|
231
|
+
}
|
|
232
|
+
const unseen = { ok: false, error: 'changed', why: 'Threadnauts didn’t show what it asks, so it wasn’t answered.' }
|
|
233
|
+
return { results: threads.map((thread) => said.get(thread) ?? { thread, ...unseen }) }
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function answer({ thread, answer: given }) {
|
|
238
|
+
return reachable((whole) => whole, async (origin, token) => {
|
|
239
|
+
const { status, answer: said } = await control(origin, 'answer', { thread, answer: given }, token)
|
|
240
|
+
return fromThreadgauge(status, said)
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function asks({ thread }) {
|
|
245
|
+
return reachable((whole) => whole, async (origin, token) => {
|
|
246
|
+
const { status, answer: said } = await read(origin, `/api/threadbare/reach?thread=${encodeURIComponent(thread)}`, token)
|
|
247
|
+
return status === 200 ? { ok: true, ...keepAsks(said) } : fromThreadgauge(status, said)
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { state, run, answer, asks }
|
|
252
|
+
}
|