@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.
Files changed (93) hide show
  1. package/LICENSE.md +34 -0
  2. package/NOTICES.md +137 -0
  3. package/README.md +423 -3
  4. package/bin/threadgauge.mjs +99 -0
  5. package/dist/assets/atkinson-hyperlegible-mono-latin-ext-wght-normal-CXgNM06D.woff2 +0 -0
  6. package/dist/assets/atkinson-hyperlegible-mono-latin-wght-normal-CoA7y8X5.woff2 +0 -0
  7. package/dist/assets/atkinson-hyperlegible-next-latin-ext-wght-normal-C6vrW8VD.woff2 +0 -0
  8. package/dist/assets/atkinson-hyperlegible-next-latin-wght-normal-BcXVPD7q.woff2 +0 -0
  9. package/dist/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
  10. package/dist/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
  11. package/dist/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
  12. package/dist/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
  13. package/dist/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
  14. package/dist/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
  15. package/dist/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
  16. package/dist/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
  17. package/dist/assets/barlow-semi-condensed-latin-400-normal-DlzeKIMS.woff2 +0 -0
  18. package/dist/assets/barlow-semi-condensed-latin-500-normal-G0uxJNrM.woff2 +0 -0
  19. package/dist/assets/barlow-semi-condensed-latin-600-normal-BkLiAYu4.woff2 +0 -0
  20. package/dist/assets/barlow-semi-condensed-latin-ext-400-normal-BDztQS4c.woff2 +0 -0
  21. package/dist/assets/barlow-semi-condensed-latin-ext-500-normal-BXdp1BvY.woff2 +0 -0
  22. package/dist/assets/barlow-semi-condensed-latin-ext-600-normal-BkQ9hN72.woff2 +0 -0
  23. package/dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
  24. package/dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  25. package/dist/assets/fira-sans-condensed-latin-400-normal-BmvZHCvU.woff2 +0 -0
  26. package/dist/assets/fira-sans-condensed-latin-500-normal-B1UMRPS4.woff2 +0 -0
  27. package/dist/assets/fira-sans-condensed-latin-600-normal-CdJSnjCb.woff2 +0 -0
  28. package/dist/assets/fira-sans-condensed-latin-ext-400-normal-BHj09Mzt.woff2 +0 -0
  29. package/dist/assets/fira-sans-condensed-latin-ext-500-normal-CDI94yGp.woff2 +0 -0
  30. package/dist/assets/fira-sans-condensed-latin-ext-600-normal-10nHsj_O.woff2 +0 -0
  31. package/dist/assets/fira-sans-latin-400-normal-a0AnQzuD.woff2 +0 -0
  32. package/dist/assets/fira-sans-latin-500-normal-Bvbxc8ch.woff2 +0 -0
  33. package/dist/assets/fira-sans-latin-600-normal-CXKlxLG9.woff2 +0 -0
  34. package/dist/assets/fira-sans-latin-700-normal-CRhwpWTq.woff2 +0 -0
  35. package/dist/assets/fira-sans-latin-ext-400-normal-BLG_mAEf.woff2 +0 -0
  36. package/dist/assets/fira-sans-latin-ext-500-normal-OO1QnNpY.woff2 +0 -0
  37. package/dist/assets/fira-sans-latin-ext-600-normal-DTQBTt1m.woff2 +0 -0
  38. package/dist/assets/fira-sans-latin-ext-700-normal-g2Yrd4J5.woff2 +0 -0
  39. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  40. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  41. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  42. package/dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  43. package/dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  44. package/dist/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  45. package/dist/assets/ibm-plex-sans-condensed-latin-400-normal-DKyD6XsY.woff2 +0 -0
  46. package/dist/assets/ibm-plex-sans-condensed-latin-500-normal-BqaLkUoX.woff2 +0 -0
  47. package/dist/assets/ibm-plex-sans-condensed-latin-600-normal-CRd5VyFf.woff2 +0 -0
  48. package/dist/assets/ibm-plex-sans-condensed-latin-ext-400-normal-BJ9nxTJg.woff2 +0 -0
  49. package/dist/assets/ibm-plex-sans-condensed-latin-ext-500-normal--oYb3bSD.woff2 +0 -0
  50. package/dist/assets/ibm-plex-sans-condensed-latin-ext-600-normal-CBdNZ9Ci.woff2 +0 -0
  51. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  52. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  53. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  54. package/dist/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
  55. package/dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
  56. package/dist/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
  57. package/dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
  58. package/dist/assets/ibm-plex-sans-latin-ext-700-normal-aMgXseD4.woff2 +0 -0
  59. package/dist/assets/inconsolata-latin-ext-wght-normal-C75G6mlG.woff2 +0 -0
  60. package/dist/assets/inconsolata-latin-wght-normal-DBskYKbm.woff2 +0 -0
  61. package/dist/assets/index-DGQBjXpa.js +2 -0
  62. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  63. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  64. package/dist/assets/inter-tight-latin-ext-wght-normal-B4u9PlvR.woff2 +0 -0
  65. package/dist/assets/inter-tight-latin-wght-normal-DX-nOvPD.woff2 +0 -0
  66. package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  67. package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  68. package/dist/assets/main-CEA6P2m0.js +2 -0
  69. package/dist/assets/preload-helper-BXl3LOEh.js +1 -0
  70. package/dist/assets/shell-DBhI_m_t.css +1 -0
  71. package/dist/assets/shell-fZRN8tMb.js +251 -0
  72. package/dist/assets/source-code-pro-latin-ext-wght-normal-0UZNSh4u.woff2 +0 -0
  73. package/dist/assets/source-code-pro-latin-wght-normal-BP8Zz55n.woff2 +0 -0
  74. package/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
  75. package/dist/assets/source-sans-3-latin-wght-normal-Bo_j9asI.woff2 +0 -0
  76. package/dist/favicon.svg +17 -0
  77. package/dist/index.html +16 -0
  78. package/package.json +75 -6
  79. package/server/api.mjs +188 -0
  80. package/server/details.mjs +64 -0
  81. package/server/history.mjs +65 -0
  82. package/server/http-server.mjs +87 -0
  83. package/server/lib/local-request.mjs +41 -0
  84. package/server/lib/open-url.mjs +16 -0
  85. package/server/lib/ports.mjs +54 -0
  86. package/server/license.mjs +100 -0
  87. package/server/monitor.mjs +136 -0
  88. package/server/open.mjs +49 -0
  89. package/server/paths.mjs +104 -0
  90. package/server/serve.mjs +8 -0
  91. package/server/threadnauts.mjs +23 -0
  92. package/server/threads.mjs +12 -0
  93. package/src/tiers.js +20 -0
@@ -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>
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }