@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/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-C46d6g6p.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/shared-DiTtF988.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-PzTcyxAK.css">
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">
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "files": [
3
3
  {
4
- "path": "assets/pro-lBnPFibL.js",
5
- "sha256": "e8e7df01da6e616fb37a60ddc2657b63681cfd34adf8fb37116ecad906845a6e",
6
- "bytes": 233030
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.2.0",
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.0"
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, threadgaugeContexts } from './threadgauge.mjs'
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 contextsFromThreadgauge = threadgaugeContexts()
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 LOCAL_HOSTS.has(hostnameOf(origin))
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/contexts' && req.method === 'GET') {
514
- return send(res, 200, await contextsFromThreadgauge())
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') {
@@ -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(() => {
@@ -1,69 +1,72 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import { paidThrough, renewalDue } from '@threadcrumbs/common/license'
5
- import { cloudUrl, licenseStore, threadcrumbsDataDir, trustedKeys } from '@threadcrumbs/common/license/store'
6
-
7
- const ENTITLEMENT = 'threadnauts.pro'
8
- const CLOUD_TIMEOUT_MS = 15000
9
-
10
- const runningFromCheckout = fs.existsSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '.git'))
11
-
12
- export function licensing({ store, cloudUrl, fetch = globalThis.fetch }) {
13
- const describe = (license, at) => ({
14
- proThrough: license ? paidThrough(license, ENTITLEMENT, at) : 0,
15
- renewalDue: Boolean(license) && renewalDue(license, at),
16
- })
17
-
18
- async function status() {
19
- const { license, at } = await store.current()
20
- return describe(license, at)
21
- }
22
-
23
- async function ask(route, body) {
24
- if (!cloudUrl) return { error: 'unavailable' }
25
- let response
26
- try {
27
- response = await fetch(`${cloudUrl}${route}`, {
28
- method: 'POST',
29
- headers: { 'content-type': 'application/json' },
30
- body: JSON.stringify(body),
31
- signal: AbortSignal.timeout(CLOUD_TIMEOUT_MS),
32
- })
33
- } catch {
34
- return { error: 'offline' }
35
- }
36
- const data = await response.json().catch(() => null)
37
- if (response.ok && typeof data?.license === 'string') return { license: data.license }
38
- return { error: typeof data?.error === 'string' ? data.error : 'unavailable' }
39
- }
40
-
41
- async function keep(answer) {
42
- if (answer.error) return { ...(await status()), error: answer.error }
43
- const saved = await store.save(answer.license)
44
- if (!saved.ok) return { ...(await status()), error: 'license_rejected' }
45
- return describe(saved.license, saved.at)
46
- }
47
-
48
- async function activate(key) {
49
- const trimmed = typeof key === 'string' ? key.trim() : ''
50
- if (!trimmed) return { ...(await status()), error: 'key_missing' }
51
- return keep(await ask('/v1/license', { key: trimmed, device: await store.device() }))
52
- }
53
-
54
- async function renew() {
55
- const { token, license, at } = await store.current()
56
- if (!token || !renewalDue(license, at)) return describe(license, at)
57
- return keep(await ask('/v1/license/renew', { license: token }))
58
- }
59
-
60
- return { status, activate, renew }
61
- }
62
-
63
- export function localLicensing({ env = process.env, development = runningFromCheckout, fetch } = {}) {
64
- return licensing({
65
- store: licenseStore({ dir: threadcrumbsDataDir({ env }), keys: trustedKeys({ env, development }) }),
66
- cloudUrl: cloudUrl({ env, development }),
67
- fetch,
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
+ }
@@ -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 = fs.existsSync(path.join(here, '..', '.git'))
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
+ }