@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,100 @@
1
+ import { paidThrough, renewalDue, unlocks } from '@threadcrumbs/common/license'
2
+ import { cloudUrl, licenseStore, trustedKeys } from '@threadcrumbs/common/license/store'
3
+ import { ENTITLEMENT, PRO_FEATURES, devEntitlements } from '../src/tiers.js'
4
+
5
+ const RENEW_RETRY_MS = 60 * 60 * 1000
6
+ const TIMEOUT_MS = 15000
7
+ const KEY_LIMIT = 200
8
+
9
+ const CLOUD_ERRORS = {
10
+ bad_request: "That doesn't look like a license key.",
11
+ key_not_valid: "Polar doesn't recognize that key. Copy the whole key from your receipt. It starts with THREADCRUMBS-.",
12
+ device_limit: 'That key is already on as many computers as it allows. Remove one in your Polar customer portal, then try again.',
13
+ polar_unavailable: "Polar didn't answer. Try again in a minute.",
14
+ }
15
+
16
+ export function entitlementsFor(license, at, proFeatures = PRO_FEATURES) {
17
+ const features = Object.fromEntries(
18
+ Object.entries(proFeatures).map(([key, feature]) => [key, Boolean(license) && unlocks(license, ENTITLEMENT, feature.releasedAt, at)]),
19
+ )
20
+ return { pro: Boolean(license) && paidThrough(license, ENTITLEMENT, at) >= at, features }
21
+ }
22
+
23
+ function summary(license, at) {
24
+ if (!license) return null
25
+ return {
26
+ paidThrough: paidThrough(license, ENTITLEMENT, at) || null,
27
+ renewed: license.iat,
28
+ expires: license.exp,
29
+ entitlements: Object.keys(license.ent),
30
+ }
31
+ }
32
+
33
+ export function createLicensing({
34
+ development = false,
35
+ env = process.env,
36
+ store = licenseStore({ keys: trustedKeys({ env, development }) }),
37
+ cloud = cloudUrl({ env, development }),
38
+ fetch = globalThis.fetch,
39
+ clock = Date.now,
40
+ } = {}) {
41
+ let renewing = null
42
+ let triedAt = -Infinity
43
+
44
+ async function post(path, body) {
45
+ const res = await fetch(`${cloud}${path}`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify(body),
49
+ signal: AbortSignal.timeout(TIMEOUT_MS),
50
+ })
51
+ return { status: res.status, data: await res.json().catch(() => ({})) }
52
+ }
53
+
54
+ function renewIfDue({ license, token, at }) {
55
+ if (!cloud || !license || renewing || !renewalDue(license, at) || clock() - triedAt < RENEW_RETRY_MS) return
56
+ triedAt = clock()
57
+ renewing = post('/v1/license/renew', { license: token })
58
+ .then(({ status, data }) => (status === 200 && typeof data.license === 'string' ? store.save(data.license) : null))
59
+ .catch(() => null)
60
+ .finally(() => {
61
+ renewing = null
62
+ })
63
+ }
64
+
65
+ async function entitlements() {
66
+ const now = await store.current()
67
+ renewIfDue(now)
68
+ const open = development ? devEntitlements() : entitlementsFor(now.license, now.at)
69
+ return { ...open, license: summary(now.license, now.at), activation: Boolean(cloud) }
70
+ }
71
+
72
+ async function activate(key) {
73
+ const trimmed = typeof key === 'string' ? key.trim() : ''
74
+ if (!trimmed || trimmed.length > KEY_LIMIT) return { ok: false, error: CLOUD_ERRORS.bad_request }
75
+ if (!cloud) return { ok: false, error: "This copy of Threadgauge can't activate a license yet." }
76
+ let reply
77
+ try {
78
+ reply = await post('/v1/license', { key: trimmed, device: await store.device() })
79
+ } catch (err) {
80
+ return { ok: false, error: `Couldn't reach the license service: ${err.message}` }
81
+ }
82
+ const { status, data } = reply
83
+ if (status !== 200 || typeof data.license !== 'string') {
84
+ return { ok: false, error: CLOUD_ERRORS[data.error] || data.message || `The license service answered ${status}` }
85
+ }
86
+ const saved = await store.save(data.license)
87
+ if (!saved.ok) {
88
+ return { ok: false, error: saved.reason === 'unknown key' ? "That license was signed by a key this copy of Threadgauge doesn't trust." : "That license didn't check out." }
89
+ }
90
+ const result = await entitlements()
91
+ const covered = Boolean(result.license?.entitlements.includes(ENTITLEMENT))
92
+ return { ok: true, entitlements: result, note: covered ? null : "That key is on for this computer, but its plan doesn't include Threadgauge Pro. You can switch plans in your Polar customer portal and keep the same key." }
93
+ }
94
+
95
+ async function idle() {
96
+ await renewing
97
+ }
98
+
99
+ return { entitlements, activate, idle }
100
+ }
@@ -0,0 +1,136 @@
1
+ import fs from 'node:fs'
2
+ import { organizationReader } from '@threadcrumbs/common/orgs'
3
+ import { TELEMETRY } from '@threadcrumbs/common/telemetry'
4
+
5
+ const SETTLE_MS = 250
6
+ const HEARTBEAT_MS = 5000
7
+
8
+ export function createMonitor({ harnesses = TELEMETRY, settleMs = SETTLE_MS, heartbeatMs = HEARTBEAT_MS, organizations = organizationReader() } = {}) {
9
+ const listeners = new Set()
10
+ const watched = new Map()
11
+ let sessions = new Map()
12
+ let harnessStatus = []
13
+ let scannedAt = 0
14
+ let ready = null
15
+ let running = null
16
+ let rescan = false
17
+ let settleTimer = null
18
+ let heartbeat = null
19
+ let scannedOnce
20
+ const firstScan = new Promise((resolve) => (scannedOnce = resolve))
21
+
22
+ async function scanOnce() {
23
+ const found = []
24
+ const status = []
25
+ for (const harness of harnesses) {
26
+ let detected = false
27
+ let error = ''
28
+ try {
29
+ detected = Boolean(await harness.detect())
30
+ if (detected) found.push(...(await harness.scanSessions()))
31
+ } catch (err) {
32
+ error = String(err?.message || err)
33
+ }
34
+ status.push({ id: harness.id, name: harness.name, detected, error })
35
+ }
36
+ const tagged = await organizations(found).catch(() => found)
37
+ const next = new Map(tagged.map((session) => [session.id, { session, json: JSON.stringify(session) }]))
38
+
39
+ const upserts = [...next.values()].filter((e) => sessions.get(e.session.id)?.json !== e.json).map((e) => e.session)
40
+ const removed = [...sessions.keys()].filter((id) => !next.has(id))
41
+ const harnessesChanged = JSON.stringify(status) !== JSON.stringify(harnessStatus)
42
+ sessions = next
43
+ harnessStatus = status
44
+ scannedAt = Date.now()
45
+ watchRoots()
46
+
47
+ if (!upserts.length && !removed.length && !harnessesChanged) return
48
+ const update = { upserts, removed, scannedAt }
49
+ if (harnessesChanged) update.harnesses = status
50
+ for (const listener of listeners) listener(update)
51
+ }
52
+
53
+ function scan() {
54
+ if (running) {
55
+ rescan = true
56
+ return running
57
+ }
58
+ running = (async () => {
59
+ do {
60
+ rescan = false
61
+ try {
62
+ await scanOnce()
63
+ } finally {
64
+ scannedOnce()
65
+ }
66
+ } while (rescan)
67
+ })().finally(() => {
68
+ running = null
69
+ })
70
+ return running
71
+ }
72
+
73
+ function scheduleScan() {
74
+ if (settleTimer) return
75
+ settleTimer = setTimeout(() => {
76
+ settleTimer = null
77
+ scan()
78
+ }, settleMs)
79
+ }
80
+
81
+ function watchRoots() {
82
+ for (const harness of harnesses) {
83
+ for (const root of harness.watchRoots?.() || []) {
84
+ if (watched.has(root) || !fs.existsSync(root)) continue
85
+ try {
86
+ const watcher = fs.watch(root, { recursive: true, persistent: false }, scheduleScan)
87
+ watcher.on('error', () => {
88
+ watcher.close()
89
+ watched.delete(root)
90
+ })
91
+ watched.set(root, watcher)
92
+ } catch {}
93
+ }
94
+ }
95
+ }
96
+
97
+ return {
98
+ start() {
99
+ if (!ready) {
100
+ ready = Promise.race([scan(), firstScan])
101
+ heartbeat = setInterval(scan, heartbeatMs)
102
+ heartbeat.unref()
103
+ }
104
+ return ready
105
+ },
106
+ stop() {
107
+ clearInterval(heartbeat)
108
+ clearTimeout(settleTimer)
109
+ for (const watcher of watched.values()) watcher.close()
110
+ watched.clear()
111
+ listeners.clear()
112
+ },
113
+ scan,
114
+ async detail(id, options = {}) {
115
+ const entry = sessions.get(id)
116
+ if (!entry) {
117
+ const harness = harnesses.find((h) => String(id).startsWith(`${h.id}:`))
118
+ return harness?.readSession ? harness.readSession(id, options) : null
119
+ }
120
+ const harness = harnesses.find((h) => h.id === entry.session.harness)
121
+ const detail = harness?.sessionDetail ? await harness.sessionDetail(id, options) : null
122
+ return { session: entry.session, detail }
123
+ },
124
+ snapshot() {
125
+ return {
126
+ sessions: [...sessions.values()].map((e) => e.session),
127
+ harnesses: harnessStatus,
128
+ scannedAt,
129
+ }
130
+ },
131
+ subscribe(listener) {
132
+ listeners.add(listener)
133
+ return () => listeners.delete(listener)
134
+ },
135
+ }
136
+ }
@@ -0,0 +1,49 @@
1
+ import path from 'node:path'
2
+ import { harnessById } from '@threadcrumbs/common/harnesses'
3
+ import { present } from '@threadcrumbs/common/launch'
4
+ import { createThreadCache } from './threads.mjs'
5
+ const PLAIN_ARG = /^[\w@%+=:,./\\-]+$/
6
+
7
+ const quoteFor = (windows) => (arg) => {
8
+ const value = String(arg)
9
+ if (PLAIN_ARG.test(value)) return value
10
+ return windows ? `'${value.replace(/['\u2018\u2019\u201A\u201B]/g, (q) => q + q)}'` : `'${value.replace(/'/g, `'\\''`)}'`
11
+ }
12
+
13
+ export function commandLine({ argv, cwd }, { platform = process.platform } = {}) {
14
+ if (!Array.isArray(argv) || !argv.length) return null
15
+ const windows = platform === 'win32'
16
+ const quote = quoteFor(windows)
17
+ const bin = (windows ? path.win32 : path.posix).basename(String(argv[0])).replace(/\.(exe|cmd|bat|ps1)$/i, '')
18
+ const run = [bin, ...argv.slice(1)].map(quote).join(' ')
19
+ if (!cwd) return run
20
+ return windows ? `cd ${quote(cwd)}; ${run}` : `cd ${quote(cwd)} && ${run}`
21
+ }
22
+
23
+ export function createOpener({ harnessOf = harnessById, show = present, now = Date.now, platform = process.platform, threadsOf = createThreadCache({ now }) } = {}) {
24
+ async function lookUp(session) {
25
+ const harness = harnessOf(session.harness)
26
+ if (!harness?.openThread) return { harness: null, thread: null, ref: null }
27
+ let thread = (await threadsOf(harness, false)).find((t) => t.id === session.id)
28
+ if (!thread) thread = (await threadsOf(harness, true)).find((t) => t.id === session.id)
29
+ const native = session.id.slice(session.id.indexOf(':') + 1)
30
+ const ref = thread?.ref || { sessionId: native, cliSessionId: native, cwd: session.cwd || '' }
31
+ return { harness, thread, ref }
32
+ }
33
+
34
+ async function openSession(session, via = 'app') {
35
+ const { harness, thread, ref } = await lookUp(session)
36
+ if (!harness) return { ok: false, error: `Threadgauge can't open ${session.harness} threads` }
37
+ if (thread?.canOpen === false) return { ok: false, error: 'That thread has nothing its harness can open' }
38
+ return show(await harness.openThread(ref), via)
39
+ }
40
+
41
+ openSession.resume = async function resume(session) {
42
+ const { harness, thread, ref } = await lookUp(session)
43
+ if (!harness || thread?.canOpen === false) return null
44
+ const result = await harness.openThread(ref).catch(() => null)
45
+ return result?.command ? commandLine({ argv: result.command.argv, cwd: result.command.cwd || session.cwd }, { platform }) : null
46
+ }
47
+
48
+ return openSession
49
+ }
@@ -0,0 +1,104 @@
1
+ import { spawn } from 'node:child_process'
2
+ import fsp from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { launch } from '@threadcrumbs/common/launch'
5
+
6
+ export const EDITORS = {
7
+ vscode: { name: 'VS Code', scheme: 'vscode' },
8
+ cursor: { name: 'Cursor', scheme: 'cursor' },
9
+ windsurf: { name: 'Windsurf', scheme: 'windsurf' },
10
+ zed: { name: 'Zed', scheme: 'zed' },
11
+ system: { name: 'the default app', scheme: null },
12
+ }
13
+
14
+ const keyOf = (file, platform) => (platform === 'win32' ? file.toLowerCase() : file)
15
+
16
+ const WINDOWS_RUNNABLE = new Set([
17
+ '.exe', '.com', '.bat', '.cmd', '.scr', '.pif', '.cpl', '.msc', '.dll', '.sys',
18
+ '.js', '.jse', '.vbs', '.vbe', '.wsf', '.wsh', '.wsc', '.sct',
19
+ '.hta', '.chm',
20
+ '.ps1', '.psm1', '.psd1', '.ps1xml', '.psc1', '.pssc', '.cdxml',
21
+ '.py', '.pyw', '.pyz', '.pyzw', '.pyc', '.pyo',
22
+ '.lnk', '.url', '.scf', '.library-ms', '.searchconnector-ms', '.settingcontent-ms',
23
+ '.msi', '.msp', '.mst', '.msix', '.msixbundle', '.appx', '.appxbundle', '.appinstaller', '.application',
24
+ '.reg', '.inf', '.jar', '.gadget', '.diagcab', '.xll', '.website',
25
+ ])
26
+ const MAC_RUNNABLE = new Set(['.app', '.command', '.tool', '.terminal', '.workflow', '.action', '.pkg', '.mpkg', '.jar', '.fileloc'])
27
+
28
+ export function runsWhenOpened(file, { platform = process.platform, pathext = process.env.PATHEXT } = {}) {
29
+ if (platform === 'win32') {
30
+ const name = file.replace(/[. ]+$/, '')
31
+ if (name.lastIndexOf(':') > 1) return true
32
+ const ext = path.extname(name).toLowerCase()
33
+ const listed = (pathext || '').toLowerCase().split(';').filter(Boolean)
34
+ return WINDOWS_RUNNABLE.has(ext) || listed.includes(ext)
35
+ }
36
+ if (platform === 'darwin') return MAC_RUNNABLE.has(path.extname(file).toLowerCase())
37
+ return false
38
+ }
39
+
40
+ export const REVEALERS = { win32: 'File Explorer', darwin: 'Finder' }
41
+
42
+ export function revealFile(file, { platform = process.platform, run = spawn, open = launch } = {}) {
43
+ const [cmd, args, options] =
44
+ platform === 'win32'
45
+ ? ['explorer.exe', [`/select,"${file}"`], { windowsVerbatimArguments: true }]
46
+ : platform === 'darwin'
47
+ ? ['open', ['-R', file], {}]
48
+ : [null]
49
+ if (!cmd) return open(path.dirname(file))
50
+ const child = run(cmd, args, { ...options, stdio: 'ignore', detached: true })
51
+ child.on?.('error', () => {})
52
+ child.unref?.()
53
+ }
54
+
55
+ export function editorUrl(editor, file, line = null, { platform = process.platform } = {}) {
56
+ const scheme = EDITORS[editor]?.scheme
57
+ if (!scheme) return file
58
+ const forward = platform === 'win32' ? file.replace(/\\/g, '/') : file
59
+ const encoded = encodeURI(forward).replace(/#/g, '%23').replace(/\?/g, '%3F')
60
+ const at = Number.isInteger(line) && line > 0 ? `:${line}` : ''
61
+ return `${scheme}://file${encoded.startsWith('/') ? '' : '/'}${encoded}${at}`
62
+ }
63
+
64
+ export function touchedPaths(session, detail, { platform = process.platform } = {}) {
65
+ const base = session.cwd || session.projectPath || ''
66
+ const allowed = new Map()
67
+ const add = (file, kind) => {
68
+ if (!file || !base) return
69
+ const full = path.resolve(base, file)
70
+ if (!allowed.has(keyOf(full, platform))) allowed.set(keyOf(full, platform), { full, kind })
71
+ }
72
+ add(session.cwd, 'folder')
73
+ add(session.projectPath, 'folder')
74
+ add(detail?.source, 'file')
75
+ for (const file of detail?.files || []) add(file.path, 'file')
76
+ for (const call of detail?.activity || []) add(call.path, 'file')
77
+ for (const part of detail?.contextBreakdown?.parts || []) {
78
+ if (part.id === 'memory') for (const item of part.items || []) if (/\.md$/i.test(item.path || '')) add(item.path, 'file')
79
+ }
80
+ return { base, allowed }
81
+ }
82
+
83
+ export function createPathOpener({ open = launch, reveal = (file) => revealFile(file, { platform }), stat = fsp.stat, platform = process.platform, pathext = process.env.PATHEXT } = {}) {
84
+ return async function openPath({ session, detail, path: wanted, line = null, editor = 'vscode' }) {
85
+ const { base, allowed } = touchedPaths(session, detail, { platform })
86
+ if (!base) return { ok: false, status: 404, error: "Threadgauge doesn't know this thread's folder" }
87
+ const full = path.resolve(base, typeof wanted === 'string' && wanted ? wanted : base)
88
+ const entry = allowed.get(keyOf(full, platform))
89
+ if (!entry) return { ok: false, status: 403, error: "Threadgauge only opens files and folders this thread touched" }
90
+ const found = await stat(entry.full).catch(() => null)
91
+ if (!found) return { ok: false, status: 404, error: `${path.basename(entry.full)} isn't there anymore` }
92
+ if (found.isDirectory()) {
93
+ open(entry.full)
94
+ return { ok: true, kind: 'folder', path: entry.full }
95
+ }
96
+ const chosen = editor in EDITORS ? editor : 'vscode'
97
+ if (chosen === 'system' && runsWhenOpened(entry.full, { platform, pathext })) {
98
+ reveal(entry.full)
99
+ return { ok: true, kind: 'file', path: entry.full, editor: REVEALERS[platform] || 'the file manager', revealed: true }
100
+ }
101
+ open(editorUrl(chosen, entry.full, Number.isInteger(line) ? line : null, { platform }))
102
+ return { ok: true, kind: 'file', path: entry.full, editor: EDITORS[chosen].name }
103
+ }
104
+ }
@@ -0,0 +1,8 @@
1
+ import { startServer } from './http-server.mjs'
2
+ import { DEFAULT_PORT } from './lib/ports.mjs'
3
+
4
+ const PORT = Number(process.env.PORT) || DEFAULT_PORT
5
+ const HOST = process.env.THREADGAUGE_HOST || '127.0.0.1'
6
+
7
+ await startServer({ port: PORT, host: HOST })
8
+ console.log(`Threadgauge → http://${HOST}:${PORT}`)
@@ -0,0 +1,23 @@
1
+ const FIRST_PORT = 5274
2
+ const PORTS_TO_TRY = 20
3
+ const LOOPBACKS = ['127.0.0.1', '::1']
4
+ const PROBE_TIMEOUT_MS = 500
5
+
6
+ const originOf = (host, port) => `http://${host.includes(':') ? `[${host}]` : host}:${port}`
7
+
8
+ async function answersAsThreadnauts(origin, fetch) {
9
+ try {
10
+ const response = await fetch(`${origin}/api/about`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
11
+ return (await response.json())?.app === 'threadnauts'
12
+ } catch {
13
+ return false
14
+ }
15
+ }
16
+
17
+ export async function findThreadnauts({ fetch = globalThis.fetch, first = FIRST_PORT, tries = PORTS_TO_TRY, hosts = LOOPBACKS } = {}) {
18
+ const probes = []
19
+ for (let port = first; port < first + tries; port++) for (const host of hosts) probes.push({ port, origin: originOf(host, port) })
20
+ const found = await Promise.all(probes.map(({ origin }) => answersAsThreadnauts(origin, fetch)))
21
+ const index = found.indexOf(true)
22
+ return index === -1 ? null : `http://localhost:${probes[index].port}/`
23
+ }
@@ -0,0 +1,12 @@
1
+ const THREADS_TTL_MS = 30_000
2
+
3
+ export function createThreadCache({ now = Date.now, ttl = THREADS_TTL_MS } = {}) {
4
+ const threads = new Map()
5
+ return async function threadsOf(harness, fresh = false) {
6
+ const hit = threads.get(harness.id)
7
+ if (!fresh && hit && now() - hit.at < ttl) return hit.list
8
+ const list = await harness.scanThreads().catch(() => [])
9
+ threads.set(harness.id, { at: now(), list })
10
+ return list
11
+ }
12
+ }
package/src/tiers.js ADDED
@@ -0,0 +1,20 @@
1
+ export const ENTITLEMENT = 'threadgauge.pro'
2
+
3
+ export const UPGRADE_URL = 'https://threadgauge.io/pro'
4
+
5
+ export const released = (year, month, day) => Date.UTC(year, month - 1, day) / 1000
6
+
7
+ // Anything Threadgauge reads from this machine without acting is free, older threads included
8
+ // (2026-11-08), so no feature is Pro yet. Threadbare's controls are listed here when they ship,
9
+ // each as { releasedAt: released(year, month, day), title, text }.
10
+ export const PRO_FEATURES = {}
11
+
12
+ function every(open) {
13
+ return Object.fromEntries(Object.keys(PRO_FEATURES).map((key) => [key, open]))
14
+ }
15
+
16
+ export const freeEntitlements = () => ({ pro: false, features: every(false) })
17
+
18
+ export const devEntitlements = () => ({ pro: true, features: every(true) })
19
+
20
+ export const featureOpen = (entitlements, feature) => entitlements?.features?.[feature] === true