@threadcrumbs/threadgauge 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +34 -0
- package/NOTICES.md +137 -0
- package/README.md +423 -3
- package/bin/threadgauge.mjs +99 -0
- package/dist/assets/atkinson-hyperlegible-mono-latin-ext-wght-normal-CXgNM06D.woff2 +0 -0
- package/dist/assets/atkinson-hyperlegible-mono-latin-wght-normal-CoA7y8X5.woff2 +0 -0
- package/dist/assets/atkinson-hyperlegible-next-latin-ext-wght-normal-C6vrW8VD.woff2 +0 -0
- package/dist/assets/atkinson-hyperlegible-next-latin-wght-normal-BcXVPD7q.woff2 +0 -0
- package/dist/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
- package/dist/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
- package/dist/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
- package/dist/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
- package/dist/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
- package/dist/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
- package/dist/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
- package/dist/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-400-normal-DlzeKIMS.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-500-normal-G0uxJNrM.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-600-normal-BkLiAYu4.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-ext-400-normal-BDztQS4c.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-ext-500-normal-BXdp1BvY.woff2 +0 -0
- package/dist/assets/barlow-semi-condensed-latin-ext-600-normal-BkQ9hN72.woff2 +0 -0
- package/dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
- package/dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-400-normal-BmvZHCvU.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-500-normal-B1UMRPS4.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-600-normal-CdJSnjCb.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-ext-400-normal-BHj09Mzt.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-ext-500-normal-CDI94yGp.woff2 +0 -0
- package/dist/assets/fira-sans-condensed-latin-ext-600-normal-10nHsj_O.woff2 +0 -0
- package/dist/assets/fira-sans-latin-400-normal-a0AnQzuD.woff2 +0 -0
- package/dist/assets/fira-sans-latin-500-normal-Bvbxc8ch.woff2 +0 -0
- package/dist/assets/fira-sans-latin-600-normal-CXKlxLG9.woff2 +0 -0
- package/dist/assets/fira-sans-latin-700-normal-CRhwpWTq.woff2 +0 -0
- package/dist/assets/fira-sans-latin-ext-400-normal-BLG_mAEf.woff2 +0 -0
- package/dist/assets/fira-sans-latin-ext-500-normal-OO1QnNpY.woff2 +0 -0
- package/dist/assets/fira-sans-latin-ext-600-normal-DTQBTt1m.woff2 +0 -0
- package/dist/assets/fira-sans-latin-ext-700-normal-g2Yrd4J5.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-400-normal-DKyD6XsY.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-500-normal-BqaLkUoX.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-600-normal-CRd5VyFf.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-ext-400-normal-BJ9nxTJg.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-ext-500-normal--oYb3bSD.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-condensed-latin-ext-600-normal-CBdNZ9Ci.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-ext-700-normal-aMgXseD4.woff2 +0 -0
- package/dist/assets/inconsolata-latin-ext-wght-normal-C75G6mlG.woff2 +0 -0
- package/dist/assets/inconsolata-latin-wght-normal-DBskYKbm.woff2 +0 -0
- package/dist/assets/index-DGQBjXpa.js +2 -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/main-CEA6P2m0.js +2 -0
- package/dist/assets/preload-helper-BXl3LOEh.js +1 -0
- package/dist/assets/shell-DBhI_m_t.css +1 -0
- package/dist/assets/shell-fZRN8tMb.js +251 -0
- package/dist/assets/source-code-pro-latin-ext-wght-normal-0UZNSh4u.woff2 +0 -0
- package/dist/assets/source-code-pro-latin-wght-normal-BP8Zz55n.woff2 +0 -0
- package/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
- package/dist/assets/source-sans-3-latin-wght-normal-Bo_j9asI.woff2 +0 -0
- package/dist/favicon.svg +17 -0
- package/dist/index.html +16 -0
- package/package.json +75 -6
- package/server/api.mjs +188 -0
- package/server/details.mjs +64 -0
- package/server/history.mjs +65 -0
- package/server/http-server.mjs +87 -0
- package/server/lib/local-request.mjs +41 -0
- package/server/lib/open-url.mjs +16 -0
- package/server/lib/ports.mjs +54 -0
- package/server/license.mjs +100 -0
- package/server/monitor.mjs +136 -0
- package/server/open.mjs +49 -0
- package/server/paths.mjs +104 -0
- package/server/serve.mjs +8 -0
- package/server/threadnauts.mjs +23 -0
- package/server/threads.mjs +12 -0
- package/src/tiers.js +20 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/favicon.svg
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-53 -53 106 106">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="fav-shell" x1="0" y1="-50" x2="0" y2="50" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d9dfe8"/></linearGradient>
|
|
4
|
+
<radialGradient id="fav-face" cx="-9.7" cy="-17.3" r="55" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2f4466"/><stop offset="0.6" stop-color="#141e33"/><stop offset="1" stop-color="#0b1120"/></radialGradient>
|
|
5
|
+
<linearGradient id="fav-arc" x1="0" y1="-50" x2="0" y2="50" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d27d60"/><stop offset="1" stop-color="#b1583a"/></linearGradient>
|
|
6
|
+
<filter id="fav-glow" x="-1" y="-1" width="3" height="3"><feGaussianBlur stdDeviation="3.2"/></filter>
|
|
7
|
+
</defs>
|
|
8
|
+
<circle r="50" fill="url(#fav-shell)" stroke="#121319" stroke-width="3" paint-order="stroke"/>
|
|
9
|
+
<circle r="44.5" fill="#c3cbd8" stroke="#121319" stroke-width="2.4"/>
|
|
10
|
+
<circle r="42.5" fill="url(#fav-face)"/>
|
|
11
|
+
<path d="M-34.3,-12.48 A36.5,36.5 0 0 1 -15.43,-33.08" fill="none" stroke="#fff" stroke-opacity="0.28" stroke-width="4.4" stroke-linecap="round"/>
|
|
12
|
+
<path d="M-21.21,21.21 A30,30 0 1 1 21.21,21.21" fill="none" stroke="#2f4466" stroke-width="8" stroke-linecap="round"/>
|
|
13
|
+
<path d="M-21.21,21.21 A30,30 0 0 1 21.21,-21.21" fill="none" stroke="url(#fav-arc)" stroke-width="8" stroke-linecap="round"/>
|
|
14
|
+
<path d="M0,0 L16.26,-16.26" stroke="#7fcfff" stroke-width="5.5" stroke-linecap="round" filter="url(#fav-glow)" opacity="0.9"/>
|
|
15
|
+
<path d="M0,0 L16.26,-16.26" stroke="#eef8ff" stroke-width="5.5" stroke-linecap="round"/>
|
|
16
|
+
<circle r="7" fill="#e8ecf2" stroke="#121319" stroke-width="2.4" paint-order="stroke"/>
|
|
17
|
+
</svg>
|
package/dist/index.html
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<meta name="color-scheme" content="dark" />
|
|
7
|
+
<meta name="theme-color" content="#0a0b0f" />
|
|
8
|
+
<title>Threadgauge</title>
|
|
9
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg?app=threadgauge" />
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-DGQBjXpa.js"></script>
|
|
11
|
+
<link rel="modulepreload" crossorigin href="/assets/preload-helper-BXl3LOEh.js">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="app"></div>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@threadcrumbs/threadgauge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@threadcrumbs/threadgauge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A live dashboard of every coding agent working on your machine: what it is doing, how full its context is, and what it has cost.",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
7
|
+
"author": "Threadcrumbs LLC",
|
|
8
|
+
"homepage": "https://threadgauge.io",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://threadgauge.io/#contact",
|
|
11
|
+
"email": "hello@threadcrumbs.io"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"claude-code",
|
|
18
|
+
"coding-agent",
|
|
19
|
+
"telemetry",
|
|
20
|
+
"dashboard",
|
|
21
|
+
"developer-tools"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.13"
|
|
25
|
+
},
|
|
26
|
+
"packageManager": "pnpm@11.9.0",
|
|
27
|
+
"bin": {
|
|
28
|
+
"threadgauge": "bin/threadgauge.mjs"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin/",
|
|
32
|
+
"dist/",
|
|
33
|
+
"NOTICES.md",
|
|
34
|
+
"server/",
|
|
35
|
+
"src/tiers.js"
|
|
36
|
+
],
|
|
37
|
+
"os": [
|
|
38
|
+
"darwin",
|
|
39
|
+
"win32",
|
|
40
|
+
"linux"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"dev": "vite",
|
|
44
|
+
"dev:demo": "vite --mode demo",
|
|
45
|
+
"build": "vite build",
|
|
46
|
+
"start": "vite build && node server/serve.mjs",
|
|
47
|
+
"serve": "node server/serve.mjs",
|
|
48
|
+
"test": "node --test \"test/**/*.test.mjs\"",
|
|
49
|
+
"check:layout": "node tools/layout-audit.mjs",
|
|
50
|
+
"prepack": "pnpm run build",
|
|
51
|
+
"auto-publish": "node ../common/tools/release.mjs"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@threadcrumbs/common": "^0.1.5"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@fontsource-variable/atkinson-hyperlegible-mono": "^5.3.0",
|
|
58
|
+
"@fontsource-variable/atkinson-hyperlegible-next": "^5.3.0",
|
|
59
|
+
"@fontsource-variable/fira-code": "^5.3.0",
|
|
60
|
+
"@fontsource-variable/inconsolata": "^5.3.0",
|
|
61
|
+
"@fontsource-variable/inter": "^5.3.0",
|
|
62
|
+
"@fontsource-variable/inter-tight": "^5.3.0",
|
|
63
|
+
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
|
64
|
+
"@fontsource-variable/source-code-pro": "^5.3.1",
|
|
65
|
+
"@fontsource-variable/source-sans-3": "^5.3.1",
|
|
66
|
+
"@fontsource/barlow": "^5.3.0",
|
|
67
|
+
"@fontsource/barlow-semi-condensed": "^5.3.0",
|
|
68
|
+
"@fontsource/fira-sans": "^5.3.0",
|
|
69
|
+
"@fontsource/fira-sans-condensed": "^5.3.0",
|
|
70
|
+
"@fontsource/ibm-plex-mono": "^5.3.0",
|
|
71
|
+
"@fontsource/ibm-plex-sans": "^5.3.0",
|
|
72
|
+
"@fontsource/ibm-plex-sans-condensed": "^5.3.0",
|
|
73
|
+
"vite": "^7.3.7"
|
|
74
|
+
}
|
|
75
|
+
}
|
package/server/api.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises'
|
|
2
|
+
import { createDetails } from './details.mjs'
|
|
3
|
+
import { createHistory } from './history.mjs'
|
|
4
|
+
import { createMonitor } from './monitor.mjs'
|
|
5
|
+
import { createOpener } from './open.mjs'
|
|
6
|
+
import { createPathOpener } from './paths.mjs'
|
|
7
|
+
import { createThreadCache } from './threads.mjs'
|
|
8
|
+
import { findThreadnauts as probeThreadnauts } from './threadnauts.mjs'
|
|
9
|
+
import { isLocalRequest } from './lib/local-request.mjs'
|
|
10
|
+
import { createLicensing } from './license.mjs'
|
|
11
|
+
|
|
12
|
+
const { version } = JSON.parse(await fsp.readFile(new URL('../package.json', import.meta.url), 'utf8'))
|
|
13
|
+
const KEEPALIVE_MS = 20000
|
|
14
|
+
const BODY_LIMIT = 16 * 1024
|
|
15
|
+
|
|
16
|
+
function readJson(req) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
let body = ''
|
|
19
|
+
req.setEncoding('utf8')
|
|
20
|
+
req.on('data', (chunk) => {
|
|
21
|
+
body += chunk
|
|
22
|
+
if (body.length > BODY_LIMIT) {
|
|
23
|
+
reject(new Error('Request body too large'))
|
|
24
|
+
req.destroy()
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
req.on('end', () => {
|
|
28
|
+
try {
|
|
29
|
+
resolve(body ? JSON.parse(body) : {})
|
|
30
|
+
} catch {
|
|
31
|
+
reject(new Error('Request body is not JSON'))
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
req.on('error', reject)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function refusePost(req) {
|
|
39
|
+
if (req.method !== 'POST') return [405, { error: 'Method not allowed' }]
|
|
40
|
+
if (!/^application\/json\s*(;|$)/i.test(req.headers['content-type'] || '')) return [415, { error: 'Threadgauge only takes JSON' }]
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function send(res, status, body) {
|
|
45
|
+
const payload = JSON.stringify(body)
|
|
46
|
+
res.writeHead(status, {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
'Cache-Control': 'no-store',
|
|
49
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
50
|
+
'X-Content-Type-Options': 'nosniff',
|
|
51
|
+
})
|
|
52
|
+
res.end(payload)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stream(req, res, monitor) {
|
|
56
|
+
res.writeHead(200, {
|
|
57
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
58
|
+
'Cache-Control': 'no-store',
|
|
59
|
+
Connection: 'keep-alive',
|
|
60
|
+
'X-Content-Type-Options': 'nosniff',
|
|
61
|
+
})
|
|
62
|
+
const emit = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
63
|
+
res.write('retry: 2000\n\n')
|
|
64
|
+
emit('snapshot', monitor.snapshot())
|
|
65
|
+
const unsubscribe = monitor.subscribe((update) => emit('update', update))
|
|
66
|
+
const keepalive = setInterval(() => res.write(': keepalive\n\n'), KEEPALIVE_MS)
|
|
67
|
+
req.on('close', () => {
|
|
68
|
+
clearInterval(keepalive)
|
|
69
|
+
unsubscribe()
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SESSION_PATH = /^\/api\/sessions\/([^/]+)(?:\/subagents\/([^/]+)|\/(hunks))?$/
|
|
74
|
+
|
|
75
|
+
export function apiMiddleware(
|
|
76
|
+
getMonitor,
|
|
77
|
+
{
|
|
78
|
+
threadsOf = createThreadCache({ ttl: 60_000 }),
|
|
79
|
+
open = createOpener({ threadsOf }),
|
|
80
|
+
openPath = createPathOpener(),
|
|
81
|
+
licensing = createLicensing(),
|
|
82
|
+
findThreadnauts = probeThreadnauts,
|
|
83
|
+
details = createDetails({ getMonitor, resume: open.resume }),
|
|
84
|
+
history = createHistory({ threads: threadsOf }),
|
|
85
|
+
} = {},
|
|
86
|
+
) {
|
|
87
|
+
return async function middleware(req, res, next) {
|
|
88
|
+
const url = new URL(req.url, 'http://localhost')
|
|
89
|
+
if (!url.pathname.startsWith('/api/')) return next ? next() : send(res, 404, { error: 'Not found' })
|
|
90
|
+
|
|
91
|
+
if (!isLocalRequest(req)) {
|
|
92
|
+
return send(res, 403, { error: 'Threadgauge only answers its own page on this machine' })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
if (url.pathname === '/api/open') {
|
|
97
|
+
const refused = refusePost(req)
|
|
98
|
+
if (refused) return send(res, ...refused)
|
|
99
|
+
const body = await readJson(req)
|
|
100
|
+
const listed = (await getMonitor()).snapshot().sessions.find((s) => s.id === body.id)
|
|
101
|
+
const session = listed || (typeof body.id === 'string' ? await history.find(body.id) : null)
|
|
102
|
+
if (!session) return send(res, 404, { ok: false, error: "Threadgauge can't find that thread anymore" })
|
|
103
|
+
const shown = await open(session, body.via === 'terminal' ? 'terminal' : 'app')
|
|
104
|
+
return send(res, shown.ok ? 200 : 400, shown)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (url.pathname === '/api/open-path') {
|
|
108
|
+
const refused = refusePost(req)
|
|
109
|
+
if (refused) return send(res, ...refused)
|
|
110
|
+
const body = await readJson(req)
|
|
111
|
+
const agent = typeof body.agent === 'string' && body.agent ? { subagent: body.agent } : {}
|
|
112
|
+
const found = typeof body.id === 'string' ? await (await getMonitor()).detail(body.id, agent) : null
|
|
113
|
+
if (!found) return send(res, 404, { ok: false, error: "Threadgauge can't find that thread anymore" })
|
|
114
|
+
const { status = 200, ...result } = await openPath({ session: found.session, detail: found.detail, path: body.path, line: body.line, editor: body.editor })
|
|
115
|
+
return send(res, status, result)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (url.pathname === '/api/license') {
|
|
119
|
+
const refused = refusePost(req)
|
|
120
|
+
if (refused) return send(res, ...refused)
|
|
121
|
+
const body = await readJson(req)
|
|
122
|
+
const result = await licensing.activate(body.key)
|
|
123
|
+
return send(res, result.ok ? 200 : 400, result)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (req.method !== 'GET') return send(res, 405, { error: 'Method not allowed' })
|
|
127
|
+
|
|
128
|
+
if (url.pathname === '/api/about') {
|
|
129
|
+
return send(res, 200, { app: 'threadgauge', version })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (url.pathname === '/api/entitlements') {
|
|
133
|
+
return send(res, 200, await licensing.entitlements())
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (url.pathname === '/api/sessions') {
|
|
137
|
+
return send(res, 200, (await getMonitor()).snapshot())
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (url.pathname === '/api/history') {
|
|
141
|
+
const shown = new Set((await getMonitor()).snapshot().sessions.map((s) => s.id))
|
|
142
|
+
return send(res, 200, { sessions: await history.list(shown) })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (url.pathname === '/api/stream') {
|
|
146
|
+
return stream(req, res, await getMonitor())
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (url.pathname === '/api/threadnauts') {
|
|
150
|
+
return send(res, 200, { url: await findThreadnauts() })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const one = SESSION_PATH.exec(url.pathname)
|
|
154
|
+
if (one) {
|
|
155
|
+
let id
|
|
156
|
+
let agent
|
|
157
|
+
try {
|
|
158
|
+
id = decodeURIComponent(one[1])
|
|
159
|
+
agent = one[2] ? decodeURIComponent(one[2]) : null
|
|
160
|
+
} catch {
|
|
161
|
+
return send(res, 400, { error: 'Bad session id' })
|
|
162
|
+
}
|
|
163
|
+
if (one[3]) {
|
|
164
|
+
const found = await details.hunks(id, url.searchParams.get('path') || '', url.searchParams.get('agent'))
|
|
165
|
+
return found ? send(res, 200, found) : send(res, 404, { error: 'Unknown file' })
|
|
166
|
+
}
|
|
167
|
+
const found = agent ? await details.subagent(id, agent) : await details.panel(id)
|
|
168
|
+
return found ? send(res, 200, found) : send(res, 404, { error: agent ? 'Unknown subagent' : 'Unknown session' })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return send(res, 404, { error: 'Unknown endpoint' })
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return send(res, 500, { error: String(err?.message || err) })
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let sharedMonitor = null
|
|
179
|
+
|
|
180
|
+
async function defaultMonitor() {
|
|
181
|
+
sharedMonitor ??= createMonitor()
|
|
182
|
+
await sharedMonitor.start()
|
|
183
|
+
return sharedMonitor
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const middleware = apiMiddleware(defaultMonitor)
|
|
187
|
+
|
|
188
|
+
export const createDevMiddleware = () => apiMiddleware(defaultMonitor, { licensing: createLicensing({ development: true }) })
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
const SIBLING_TTL_MS = 5000
|
|
4
|
+
|
|
5
|
+
export function withoutHunks(detail) {
|
|
6
|
+
if (!detail?.files) return detail
|
|
7
|
+
return { ...detail, files: detail.files.map(({ hunks, ...file }) => ({ ...file, diff: hunks == null ? null : hunks.length })) }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const fullPath = (session, file) => path.resolve(session.cwd || session.projectPath || '', file)
|
|
11
|
+
const keyOf = (file, platform) => (platform === 'win32' ? file.toLowerCase() : file)
|
|
12
|
+
|
|
13
|
+
export function createDetails({ getMonitor, resume = async () => null, now = Date.now, platform = process.platform } = {}) {
|
|
14
|
+
const siblings = new Map()
|
|
15
|
+
|
|
16
|
+
async function siblingFiles(monitor, session) {
|
|
17
|
+
const hit = siblings.get(session.id)
|
|
18
|
+
if (hit && hit.lastActivityAt === session.lastActivityAt && now() - hit.at < SIBLING_TTL_MS) return hit.files
|
|
19
|
+
const found = await monitor.detail(session.id, { filesOnly: true }).catch(() => null)
|
|
20
|
+
const files = new Set((found?.detail?.files || []).map((f) => keyOf(fullPath(session, f.path), platform)))
|
|
21
|
+
siblings.set(session.id, { at: now(), lastActivityAt: session.lastActivityAt, files })
|
|
22
|
+
return files
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function overlapsFor(monitor, session, detail) {
|
|
26
|
+
if (!detail?.files?.length || !(session.cwd || session.projectPath)) return {}
|
|
27
|
+
const others = monitor.snapshot().sessions.filter((s) => s.id !== session.id && (s.cwd || s.projectPath))
|
|
28
|
+
const overlaps = {}
|
|
29
|
+
for (const other of others) {
|
|
30
|
+
const files = await siblingFiles(monitor, other)
|
|
31
|
+
for (const file of detail.files) {
|
|
32
|
+
if (!files.has(keyOf(fullPath(session, file.path), platform))) continue
|
|
33
|
+
overlaps[file.path] = [...(overlaps[file.path] || []), { id: other.id, title: other.title, status: other.status }]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return overlaps
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function panel(id) {
|
|
40
|
+
const monitor = await getMonitor()
|
|
41
|
+
const found = await monitor.detail(id)
|
|
42
|
+
if (!found) return null
|
|
43
|
+
const [command, overlaps] = await Promise.all([
|
|
44
|
+
resume(found.session).catch(() => null),
|
|
45
|
+
overlapsFor(monitor, found.session, found.detail),
|
|
46
|
+
])
|
|
47
|
+
return { session: found.session, detail: withoutHunks(found.detail), resume: command, overlaps }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function subagent(id, agentId) {
|
|
51
|
+
const found = await (await getMonitor()).detail(id, { subagent: agentId })
|
|
52
|
+
if (!found?.detail) return null
|
|
53
|
+
return { session: found.session, detail: withoutHunks(found.detail) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function hunks(id, file, agentId = null) {
|
|
57
|
+
const found = await (await getMonitor()).detail(id, agentId ? { subagent: agentId } : {})
|
|
58
|
+
const entry = found?.detail?.files?.find((f) => f.path === file)
|
|
59
|
+
if (!entry) return null
|
|
60
|
+
return { path: entry.path, line: entry.line ?? null, hunks: entry.hunks ?? null }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { panel, subagent, hunks }
|
|
64
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { HARNESSES } from '@threadcrumbs/common/harnesses'
|
|
2
|
+
import { organizationReader } from '@threadcrumbs/common/orgs'
|
|
3
|
+
import { createThreadCache } from './threads.mjs'
|
|
4
|
+
|
|
5
|
+
const ERROR_MS = 3 * 24 * 60 * 60 * 1000
|
|
6
|
+
const MAX_OLDER = 300
|
|
7
|
+
|
|
8
|
+
export function olderStatus(thread, now) {
|
|
9
|
+
if (thread.needsInput) return 'asking'
|
|
10
|
+
if (thread.hasError && now - thread.lastActivityAt <= ERROR_MS) return 'error'
|
|
11
|
+
if (thread.unread) return 'done'
|
|
12
|
+
return 'idle'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function olderSession(thread, harness, now) {
|
|
16
|
+
return {
|
|
17
|
+
id: thread.id,
|
|
18
|
+
harness,
|
|
19
|
+
title: thread.title || 'Untitled session',
|
|
20
|
+
project: thread.project || 'unknown',
|
|
21
|
+
projectPath: thread.projectPath || '',
|
|
22
|
+
worktree: thread.worktree || '',
|
|
23
|
+
cwd: thread.cwd || '',
|
|
24
|
+
gitBranch: thread.gitBranch || '',
|
|
25
|
+
model: thread.model || '',
|
|
26
|
+
effort: thread.effort || '',
|
|
27
|
+
status: olderStatus(thread, now),
|
|
28
|
+
live: false,
|
|
29
|
+
startedAt: thread.createdAt || 0,
|
|
30
|
+
lastActivityAt: thread.lastActivityAt || 0,
|
|
31
|
+
tool: null,
|
|
32
|
+
tokens: null,
|
|
33
|
+
context: null,
|
|
34
|
+
costUsd: null,
|
|
35
|
+
lines: null,
|
|
36
|
+
plan: null,
|
|
37
|
+
subagents: 0,
|
|
38
|
+
prompts: null,
|
|
39
|
+
older: true,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createHistory({ now = Date.now, harnesses = HARNESSES, threads = createThreadCache({ ttl: 60_000, now }), organizations = organizationReader() } = {}) {
|
|
44
|
+
async function olderThreads() {
|
|
45
|
+
const found = []
|
|
46
|
+
for (const harness of harnesses) {
|
|
47
|
+
if (!(await Promise.resolve(harness.detect()).catch(() => false))) continue
|
|
48
|
+
for (const thread of await threads(harness)) {
|
|
49
|
+
if (!thread.archived) found.push(olderSession(thread, harness.id, now()))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return found
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function list(shown = new Set()) {
|
|
56
|
+
const older = (await olderThreads()).filter((s) => !shown.has(s.id)).sort((a, b) => b.lastActivityAt - a.lastActivityAt).slice(0, MAX_OLDER)
|
|
57
|
+
return organizations(older).catch(() => older)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function find(id) {
|
|
61
|
+
return (await olderThreads()).find((s) => s.id === id) || null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { list, find }
|
|
65
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import fsp from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { middleware } from './api.mjs'
|
|
6
|
+
|
|
7
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const DIST = path.join(here, '..', 'dist')
|
|
9
|
+
|
|
10
|
+
const CSP = [
|
|
11
|
+
"default-src 'self'",
|
|
12
|
+
"script-src 'self'",
|
|
13
|
+
"style-src 'self'",
|
|
14
|
+
"img-src 'self' data:",
|
|
15
|
+
"font-src 'self'",
|
|
16
|
+
"connect-src 'self'",
|
|
17
|
+
"object-src 'none'",
|
|
18
|
+
"base-uri 'none'",
|
|
19
|
+
"form-action 'none'",
|
|
20
|
+
"frame-ancestors 'none'",
|
|
21
|
+
].join('; ')
|
|
22
|
+
|
|
23
|
+
export const SECURITY_HEADERS = {
|
|
24
|
+
'Content-Security-Policy': CSP,
|
|
25
|
+
'X-Content-Type-Options': 'nosniff',
|
|
26
|
+
'Referrer-Policy': 'no-referrer',
|
|
27
|
+
'X-Frame-Options': 'DENY',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TYPES = {
|
|
31
|
+
'.html': 'text/html; charset=utf-8',
|
|
32
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
33
|
+
'.css': 'text/css; charset=utf-8',
|
|
34
|
+
'.json': 'application/json; charset=utf-8',
|
|
35
|
+
'.svg': 'image/svg+xml',
|
|
36
|
+
'.png': 'image/png',
|
|
37
|
+
'.ico': 'image/x-icon',
|
|
38
|
+
'.woff2': 'font/woff2',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveInDist(pathname) {
|
|
42
|
+
const rel = decodeURIComponent(pathname).replace(/^\/+/, '')
|
|
43
|
+
const file = path.resolve(DIST, rel || 'index.html')
|
|
44
|
+
return file === DIST || file.startsWith(DIST + path.sep) ? file : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function serveRequest(req, res) {
|
|
48
|
+
const url = new URL(req.url, 'http://localhost')
|
|
49
|
+
|
|
50
|
+
if (url.pathname.startsWith('/api/')) {
|
|
51
|
+
return middleware(req, res, null)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let file = resolveInDist(url.pathname)
|
|
55
|
+
if (!file) {
|
|
56
|
+
res.writeHead(403, SECURITY_HEADERS).end('Forbidden')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
if ((await fsp.stat(file)).isDirectory()) file = path.join(file, 'index.html')
|
|
61
|
+
} catch {
|
|
62
|
+
file = path.join(DIST, 'index.html')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const body = await fsp.readFile(file)
|
|
67
|
+
const type = TYPES[path.extname(file)] || 'application/octet-stream'
|
|
68
|
+
const cache = file.includes(`${path.sep}assets${path.sep}`)
|
|
69
|
+
? 'public, max-age=31536000, immutable'
|
|
70
|
+
: 'no-cache'
|
|
71
|
+
res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': type, 'Content-Length': body.length, 'Cache-Control': cache })
|
|
72
|
+
res.end(body)
|
|
73
|
+
} catch {
|
|
74
|
+
res.writeHead(404, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' }).end('Not found')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function startServer({ port, host }) {
|
|
79
|
+
const server = http.createServer(serveRequest)
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
server.once('error', reject)
|
|
82
|
+
server.listen(port, host, () => {
|
|
83
|
+
server.off('error', reject)
|
|
84
|
+
resolve(server)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
|
|
3
|
+
export const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
|
|
4
|
+
|
|
5
|
+
for (const addrs of Object.values(os.networkInterfaces())) {
|
|
6
|
+
for (const a of addrs || []) {
|
|
7
|
+
if (a && a.family === 'IPv4' && !a.internal && a.address) LOCAL_HOSTS.add(a.address)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
for (const extra of (process.env.THREADGAUGE_ALLOWED_HOSTS || '').split(',')) {
|
|
12
|
+
const entry = extra.trim()
|
|
13
|
+
const host = hostnameOf(entry) || hostnameOf(`[${entry}]`)
|
|
14
|
+
if (host) LOCAL_HOSTS.add(host)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function hostnameOf(value) {
|
|
18
|
+
if (!value) return ''
|
|
19
|
+
const raw = String(value).includes('://') ? value : `http://${value}`
|
|
20
|
+
try {
|
|
21
|
+
return new URL(raw).hostname.replace(/^\[|\]$/g, '')
|
|
22
|
+
} catch {
|
|
23
|
+
return ''
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isOwnOrigin(origin, host) {
|
|
28
|
+
try {
|
|
29
|
+
const page = new URL(origin)
|
|
30
|
+
return page.protocol === 'http:' && page.host === new URL(`http://${host}`).host
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isLocalRequest(req) {
|
|
37
|
+
if (!LOCAL_HOSTS.has(hostnameOf(req.headers.host))) return false
|
|
38
|
+
const origin = req.headers.origin
|
|
39
|
+
if (origin && origin !== 'null') return isOwnOrigin(origin, req.headers.host)
|
|
40
|
+
return req.method === 'GET' || req.method === 'HEAD'
|
|
41
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
const OPENERS = {
|
|
4
|
+
darwin: ['open'],
|
|
5
|
+
win32: ['rundll32', 'url.dll,FileProtocolHandler'],
|
|
6
|
+
linux: ['xdg-open'],
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function openUrl(url) {
|
|
10
|
+
const opener = OPENERS[process.platform]
|
|
11
|
+
if (!opener) return
|
|
12
|
+
const [cmd, ...args] = opener
|
|
13
|
+
const child = spawn(cmd, [...args, url], { stdio: 'ignore', detached: true })
|
|
14
|
+
child.on('error', () => {})
|
|
15
|
+
child.unref()
|
|
16
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import net from 'node:net'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_PORT = 5274
|
|
4
|
+
export const PORTS_TO_TRY = 20
|
|
5
|
+
|
|
6
|
+
const LOOPBACKS = ['127.0.0.1', '::1']
|
|
7
|
+
|
|
8
|
+
export const originOf = (host, port) => `http://${host.includes(':') ? `[${host}]` : host}:${port}`
|
|
9
|
+
|
|
10
|
+
export function answers(host, port, timeoutMs = 2000) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const socket = net.connect({ host, port })
|
|
13
|
+
const settle = (ok) => {
|
|
14
|
+
socket.destroy()
|
|
15
|
+
resolve(ok)
|
|
16
|
+
}
|
|
17
|
+
socket.setTimeout(timeoutMs, () => settle(false))
|
|
18
|
+
socket.once('connect', () => settle(true))
|
|
19
|
+
socket.once('error', () => settle(false))
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function loopbackHolder(port) {
|
|
24
|
+
const answered = await Promise.all(LOOPBACKS.map((host) => answers(host, port)))
|
|
25
|
+
return LOOPBACKS[answered.indexOf(true)] ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function listenOnFirstFreePort({ first, tries = PORTS_TO_TRY, listen, isOurs, holderOf = loopbackHolder }) {
|
|
29
|
+
const last = Math.min(first + tries - 1, 65535)
|
|
30
|
+
for (let port = first; port <= last; port++) {
|
|
31
|
+
const holder = await holderOf(port)
|
|
32
|
+
if (holder) {
|
|
33
|
+
const origin = originOf(holder, port)
|
|
34
|
+
if (await isOurs(origin)) return { port, server: null, existing: true, origin }
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return { port, server: await listen(port), existing: false }
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err.code !== 'EADDRINUSE') throw err
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { port: 0, server: null, existing: false, last }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function answersAsThreadgauge(origin, timeoutMs = 2000) {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${origin}/api/about`, { signal: AbortSignal.timeout(timeoutMs) })
|
|
49
|
+
const body = await res.json()
|
|
50
|
+
return body?.app === 'threadgauge'
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|