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