@threadcrumbs/threadnauts 1.3.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/assets/CREDITS.md +1 -4
- package/dist/assets/{index-CASWItKa.js → index-CJXuDA5A.js} +159 -151
- package/dist/assets/index-LNc3c53f.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +3 -3
- package/server/api.mjs +22 -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 -126
- package/dist/assets/index-BzSHs41z.css +0 -1
- package/dist/assets/lighting/README.md +0 -10
- package/dist/assets/lighting/studio_small_09_1k.hdr +0 -0
- package/dist/audio/README.md +0 -25
|
@@ -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
|
+
}
|
package/server/threadgauge.mjs
CHANGED
|
@@ -1,126 +1,129 @@
|
|
|
1
|
-
import { DETAIL_LIMITS, clip, clipBlock } from '@threadcrumbs/common/lib/telemetry-fields'
|
|
2
|
-
|
|
3
|
-
const FIRST_PORT = 5274
|
|
4
|
-
const PORTS_TO_TRY = 20
|
|
5
|
-
const LOOPBACKS = ['127.0.0.1', '::1']
|
|
6
|
-
const PROBE_TIMEOUT_MS = 500
|
|
7
|
-
const SESSIONS_TIMEOUT_MS = 2000
|
|
8
|
-
const HISTORY_TIMEOUT_MS = 8000
|
|
9
|
-
const REUSE_OLDER_MS = 15000
|
|
10
|
-
const REUSE_FOUND_MS = 15000
|
|
11
|
-
const ASK_QUESTIONS = 4
|
|
12
|
-
const ASK_OPTIONS = 8
|
|
13
|
-
const LABEL_MAX = 160
|
|
14
|
-
const STATUS_MAX = 40
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
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
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (ask
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (out
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return list
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return { url:
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
}
|
|
1
|
+
import { DETAIL_LIMITS, clip, clipBlock } from '@threadcrumbs/common/lib/telemetry-fields'
|
|
2
|
+
|
|
3
|
+
const FIRST_PORT = 5274
|
|
4
|
+
const PORTS_TO_TRY = 20
|
|
5
|
+
const LOOPBACKS = ['127.0.0.1', '::1']
|
|
6
|
+
const PROBE_TIMEOUT_MS = 500
|
|
7
|
+
const SESSIONS_TIMEOUT_MS = 2000
|
|
8
|
+
const HISTORY_TIMEOUT_MS = 8000
|
|
9
|
+
const REUSE_OLDER_MS = 15000
|
|
10
|
+
const REUSE_FOUND_MS = 15000
|
|
11
|
+
const ASK_QUESTIONS = 4
|
|
12
|
+
const ASK_OPTIONS = 8
|
|
13
|
+
const LABEL_MAX = 160
|
|
14
|
+
const STATUS_MAX = 40
|
|
15
|
+
const ASK_ID_MAX = 128
|
|
16
|
+
|
|
17
|
+
const originOf = (host, port) => `http://${host.includes(':') ? `[${host}]` : host}:${port}`
|
|
18
|
+
|
|
19
|
+
async function answersAsThreadgauge(origin, fetch) {
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(`${origin}/api/about`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
|
|
22
|
+
return (await response.json())?.app === 'threadgauge'
|
|
23
|
+
} catch {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function locateThreadgauge({ fetch = globalThis.fetch, first = FIRST_PORT, tries = PORTS_TO_TRY, hosts = LOOPBACKS } = {}) {
|
|
29
|
+
const probes = []
|
|
30
|
+
for (let port = first; port < first + tries; port++) for (const host of hosts) probes.push({ port, origin: originOf(host, port) })
|
|
31
|
+
const found = await Promise.all(probes.map(({ origin }) => answersAsThreadgauge(origin, fetch)))
|
|
32
|
+
const index = found.indexOf(true)
|
|
33
|
+
return index === -1 ? null : { url: `http://localhost:${probes[index].port}/`, origin: probes[index].origin }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const findThreadgauge = async (options) => (await locateThreadgauge(options))?.url ?? null
|
|
37
|
+
|
|
38
|
+
const text = (max) => (v) => (typeof v === 'string' ? clip(v, max) : null)
|
|
39
|
+
const block = (max) => (v) => (typeof v === 'string' ? clipBlock(v, max) : null)
|
|
40
|
+
const label = text(LABEL_MAX)
|
|
41
|
+
const words = block(DETAIL_LIMITS.errorText)
|
|
42
|
+
const number = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
|
|
43
|
+
const list = (v, max, keep) => (Array.isArray(v) ? v.filter((x) => x && typeof x === 'object').slice(0, max).map(keep) : null)
|
|
44
|
+
|
|
45
|
+
export const isAskId = (v) => typeof v === 'string' && v.length > 0 && v.length <= ASK_ID_MAX && !/[\u0000-\u001f\u007f]/.test(v)
|
|
46
|
+
|
|
47
|
+
function keepAsk(ask) {
|
|
48
|
+
if (!ask || typeof ask !== 'object') return null
|
|
49
|
+
if (ask.type === 'question') {
|
|
50
|
+
const option = (o) => ({ label: label(o.label) ?? '', description: words(o.description) ?? '' })
|
|
51
|
+
const question = (q) => ({ header: label(q.header) ?? '', text: words(q.text) ?? '', options: list(q.options, ASK_OPTIONS, option) ?? [], multiple: q.multiple === true })
|
|
52
|
+
return { type: 'question', questions: list(ask.questions, ASK_QUESTIONS, question) ?? [] }
|
|
53
|
+
}
|
|
54
|
+
if (ask.type === 'approval') return { type: 'approval', tool: label(ask.tool) ?? '', target: label(ask.target) ?? '', kind: label(ask.kind) ?? 'other' }
|
|
55
|
+
if (ask.type === 'error') return { type: 'error', text: words(ask.text) ?? '' }
|
|
56
|
+
return { type: 'unknown' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SESSION_FIELDS = {
|
|
60
|
+
status: text(STATUS_MAX),
|
|
61
|
+
statusReason: text(STATUS_MAX),
|
|
62
|
+
ask: keepAsk,
|
|
63
|
+
context: (c) => (c && typeof c === 'object' ? { used: number(c.used), limit: number(c.limit) } : null),
|
|
64
|
+
tool: (t) => (t && typeof t === 'object' ? { name: label(t.name) ?? '', startedAt: number(t.startedAt), target: label(t.target) ?? '', kind: label(t.kind) ?? 'other' } : null),
|
|
65
|
+
turnStartedAt: number,
|
|
66
|
+
workingSubagents: (subs) => list(subs, DETAIL_LIMITS.subagents, (s) => ({ id: label(s.id) ?? '', name: label(s.name) ?? '' })),
|
|
67
|
+
awaiting: (threads) => list(threads, DETAIL_LIMITS.subagents, (t) => ({ id: label(t.id) ?? '', title: label(t.title) ?? '' })),
|
|
68
|
+
subagents: number,
|
|
69
|
+
live: (v) => v === true,
|
|
70
|
+
plan: (p) => (p && typeof p === 'object' ? { done: number(p.done), total: number(p.total), current: label(p.current) ?? '' } : null),
|
|
71
|
+
tokens: (t) => (t && typeof t === 'object' ? { output: number(t.output) } : null),
|
|
72
|
+
lines: (l) => (l && typeof l === 'object' ? { added: number(l.added), removed: number(l.removed) } : null),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function keepSessionFields(session) {
|
|
76
|
+
const kept = {}
|
|
77
|
+
for (const [field, keep] of Object.entries(SESSION_FIELDS)) if (field in session) kept[field] = keep(session[field])
|
|
78
|
+
return kept
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function withoutText(ask) {
|
|
82
|
+
if (!ask) return ask
|
|
83
|
+
if (ask.type === 'question') return { type: 'question', questions: [] }
|
|
84
|
+
if (ask.type === 'approval') return { type: 'approval', tool: ask.tool, target: '', kind: ask.kind }
|
|
85
|
+
if (ask.type === 'error') return { type: 'error', text: '' }
|
|
86
|
+
return ask
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function keepForAnotherDevice(kept) {
|
|
90
|
+
const out = { ...kept }
|
|
91
|
+
if ('ask' in out) out.ask = withoutText(out.ask)
|
|
92
|
+
if (out.tool) out.tool = { ...out.tool, target: '' }
|
|
93
|
+
if (out.awaiting) out.awaiting = out.awaiting.map(({ id }) => ({ id, title: '' }))
|
|
94
|
+
if (out.plan) out.plan = { ...out.plan, current: '' }
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const listOf = async (response) => {
|
|
99
|
+
const { sessions = [] } = response.ok ? await response.json() : {}
|
|
100
|
+
return (Array.isArray(sessions) ? sessions : []).filter((s) => s && typeof s.id === 'string')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function threadgaugeSessions({ fetch = globalThis.fetch, find = () => locateThreadgauge({ fetch }), now = Date.now } = {}) {
|
|
104
|
+
let found = { at: -Infinity, where: null }
|
|
105
|
+
let older = { at: -Infinity, origin: null, list: [] }
|
|
106
|
+
async function olderList(origin) {
|
|
107
|
+
if (older.origin === origin && now() - older.at <= REUSE_OLDER_MS) return older.list
|
|
108
|
+
const list = await listOf(await fetch(`${origin}/api/history`, { signal: AbortSignal.timeout(HISTORY_TIMEOUT_MS) }))
|
|
109
|
+
older = { at: now(), origin, list }
|
|
110
|
+
return list
|
|
111
|
+
}
|
|
112
|
+
return async function sessions({ id = null, all = false, anotherDevice = false } = {}) {
|
|
113
|
+
if (now() - found.at > REUSE_FOUND_MS) found = { at: now(), where: await find() }
|
|
114
|
+
const where = found.where
|
|
115
|
+
if (!where) return { url: null, sessions: {} }
|
|
116
|
+
if (!id && !all) return { url: where.url, sessions: {} }
|
|
117
|
+
try {
|
|
118
|
+
const live = await listOf(await fetch(`${where.origin}/api/sessions`, { signal: AbortSignal.timeout(SESSIONS_TIMEOUT_MS) }))
|
|
119
|
+
const listed = new Set(live.map((s) => s.id))
|
|
120
|
+
const everything = all ? [...live, ...(await olderList(where.origin)).filter((s) => !listed.has(s.id))] : live
|
|
121
|
+
const wanted = everything.filter((s) => all || s.id === id)
|
|
122
|
+
const keep = (s) => (anotherDevice ? keepForAnotherDevice(keepSessionFields(s)) : keepSessionFields(s))
|
|
123
|
+
return { url: where.url, sessions: Object.fromEntries(wanted.map((s) => [s.id, keep(s)])) }
|
|
124
|
+
} catch {
|
|
125
|
+
found = { at: -Infinity, where: null }
|
|
126
|
+
return { url: null, sessions: {} }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|