@threadcrumbs/common 0.1.9 → 0.1.11
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/README.md +8 -5
- package/harnesses/README.md +214 -213
- package/harnesses/claude-code.mjs +579 -578
- package/lib/claude-transcript.mjs +17 -3
- package/lib/proc-start.mjs +157 -0
- package/license/store.mjs +192 -155
- package/package.json +1 -1
- package/telemetry/claude-code.mjs +3 -2
|
@@ -2,6 +2,7 @@ import fsp from 'node:fs/promises'
|
|
|
2
2
|
import { requestCost } from './anthropic-prices.mjs'
|
|
3
3
|
import { FIXED_PARTS, emptyFixed, emptySizes, setFixed, sizeCall, sizeCompaction, sizePrompt, sizeReply, sizeRequest, sizeResult, sizeResumed } from './context-breakdown.mjs'
|
|
4
4
|
import { listFiles, parseJsonLine, readNewLines } from './fsutil.mjs'
|
|
5
|
+
import { reusedPids } from './proc-start.mjs'
|
|
5
6
|
import {
|
|
6
7
|
DETAIL_LIMITS,
|
|
7
8
|
activityDetail,
|
|
@@ -60,8 +61,9 @@ const pidAlive = (pid) => {
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
export async function readLiveSessions(dir, isAlive = pidAlive) {
|
|
64
|
-
const
|
|
64
|
+
export async function readLiveSessions(dir, isAlive = pidAlive, reused = reusedPids) {
|
|
65
|
+
const records = []
|
|
66
|
+
const dead = []
|
|
65
67
|
for (const file of await listFiles(dir, (n) => n.endsWith('.json'))) {
|
|
66
68
|
let record
|
|
67
69
|
try {
|
|
@@ -69,7 +71,19 @@ export async function readLiveSessions(dir, isAlive = pidAlive) {
|
|
|
69
71
|
} catch {
|
|
70
72
|
continue
|
|
71
73
|
}
|
|
72
|
-
if (!record?.sessionId || !record.pid
|
|
74
|
+
if (!record?.sessionId || !record.pid) continue
|
|
75
|
+
if (isAlive(record.pid)) records.push(record)
|
|
76
|
+
else dead.push(record.pid)
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
reused?.forget?.(dead)
|
|
80
|
+
} catch {}
|
|
81
|
+
const recycled = await Promise.resolve()
|
|
82
|
+
.then(() => reused(records))
|
|
83
|
+
.catch(() => null)
|
|
84
|
+
const live = new Map()
|
|
85
|
+
for (const record of records) {
|
|
86
|
+
if (recycled instanceof Set && recycled.has(record.pid)) continue
|
|
73
87
|
live.set(record.sessionId, { pid: record.pid, status: text(record.status), name: text(record.name), socket: text(record.messagingSocketPath) })
|
|
74
88
|
}
|
|
75
89
|
return live
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import fsp from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
const WAIT_MS = 1000
|
|
7
|
+
const QUERY_MS = 10_000
|
|
8
|
+
const KEPT = 1024
|
|
9
|
+
const USER_HZ = 100
|
|
10
|
+
const LSTART_SLACK_S = 60
|
|
11
|
+
const FILETIME_MAX = 300_000_000_000_000_000n
|
|
12
|
+
const TICKS_BEFORE_1601 = 504_911_232_000_000_000n
|
|
13
|
+
const QUARTER_HOUR = 9_000_000_000n
|
|
14
|
+
const TICK_SLACK = 10n
|
|
15
|
+
const PS = '/bin/ps'
|
|
16
|
+
|
|
17
|
+
const powershell = () => path.win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
18
|
+
|
|
19
|
+
const getProcess = (pids) =>
|
|
20
|
+
`$ErrorActionPreference='SilentlyContinue';foreach($p in Get-Process -Id ${pids.join(',')}){try{'{0} {1}' -f $p.Id,$p.StartTime.ToFileTimeUtc()}catch{}}`
|
|
21
|
+
|
|
22
|
+
function query(file, args, env = {}) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
execFile(file, args, { env: { ...process.env, ...env }, timeout: QUERY_MS, windowsHide: true }, (err, stdout) => resolve(typeof stdout === 'string' ? stdout : ''))
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function startLines(stdout, line) {
|
|
29
|
+
const found = new Map()
|
|
30
|
+
for (const row of stdout.slice(0, stdout.lastIndexOf('\n') + 1).split('\n')) {
|
|
31
|
+
const m = line.exec(row)
|
|
32
|
+
if (m) found.set(Number(m[1]), m[2])
|
|
33
|
+
}
|
|
34
|
+
return found
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lstartSeconds = (value) => Date.parse(`${value} UTC`) / 1000
|
|
38
|
+
|
|
39
|
+
const statTicks = (stat) => stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]
|
|
40
|
+
|
|
41
|
+
async function linuxStarts(pids, read) {
|
|
42
|
+
const boot = Number(/^btime (\d+)$/m.exec(await read('/proc/stat', 'utf8').catch(() => ''))?.[1])
|
|
43
|
+
const found = new Map()
|
|
44
|
+
for (const pid of pids) {
|
|
45
|
+
const ticks = statTicks((await read(`/proc/${pid}/stat`, 'utf8').catch(() => '')) || '')
|
|
46
|
+
if (!/^\d+$/.test(ticks || '')) continue
|
|
47
|
+
found.set(pid, { ticks, seconds: boot > 0 ? boot + Math.floor(Number(ticks) / USER_HZ) : NaN })
|
|
48
|
+
}
|
|
49
|
+
return found
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function processStarts(pids, { platform = process.platform, run = query, read = fsp.readFile } = {}) {
|
|
53
|
+
const asked = [...new Set(pids)].filter((pid) => Number.isSafeInteger(pid) && pid > 0)
|
|
54
|
+
if (!asked.length) return new Map()
|
|
55
|
+
if (platform === 'linux') return linuxStarts(asked, read)
|
|
56
|
+
if (platform === 'win32') {
|
|
57
|
+
const found = startLines(await run(powershell(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', getProcess(asked)]), /^\s*(\d+)\s+(\d+)\s*$/)
|
|
58
|
+
return new Map([...found].map(([pid, filetime]) => [pid, { filetime }]))
|
|
59
|
+
}
|
|
60
|
+
if (platform === 'darwin') {
|
|
61
|
+
const found = startLines(await run(PS, ['-o', 'pid=', '-o', 'lstart=', '-p', asked.join(',')], { LC_ALL: 'C', TZ: 'UTC' }), /^\s*(\d+)\s+(\S.*?)\s*$/)
|
|
62
|
+
return new Map([...found].map(([pid, lstart]) => [pid, { seconds: lstartSeconds(lstart) }]))
|
|
63
|
+
}
|
|
64
|
+
return new Map()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sameFiletime(recorded, actual) {
|
|
68
|
+
if (recorded <= FILETIME_MAX) return recorded === actual
|
|
69
|
+
const offset = (((recorded - TICKS_BEFORE_1601 - actual) % QUARTER_HOUR) + QUARTER_HOUR) % QUARTER_HOUR
|
|
70
|
+
return offset <= TICK_SLACK || QUARTER_HOUR - offset <= TICK_SLACK
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sameStart(recorded, found) {
|
|
74
|
+
if (typeof recorded !== 'string' || !found || typeof found !== 'object') return null
|
|
75
|
+
const value = recorded.trim()
|
|
76
|
+
if (/^\d+$/.test(value)) {
|
|
77
|
+
if (/^\d+$/.test(found.filetime ?? '')) return sameFiletime(BigInt(value), BigInt(found.filetime))
|
|
78
|
+
if (/^\d+$/.test(found.ticks ?? '')) return BigInt(value) === BigInt(found.ticks)
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
const at = lstartSeconds(value)
|
|
82
|
+
if (!Number.isFinite(at) || !Number.isFinite(found.seconds)) return null
|
|
83
|
+
return Math.abs(at - found.seconds) <= LSTART_SLACK_S
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function domainOf(platform) {
|
|
87
|
+
if (platform === 'win32') return `win32:${os.hostname().toLowerCase()}`
|
|
88
|
+
if (platform === 'darwin') return 'darwin'
|
|
89
|
+
if (platform !== 'linux') return null
|
|
90
|
+
const [id, ns] = await Promise.all([
|
|
91
|
+
fsp.readFile('/etc/machine-id', 'utf8').then((t) => t.trim(), () => ''),
|
|
92
|
+
fsp.readlink('/proc/self/ns/pid').catch(() => ''),
|
|
93
|
+
])
|
|
94
|
+
return `linux:${id}:${ns}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const domains = new Map()
|
|
98
|
+
|
|
99
|
+
export function ownPidDomain(platform = process.platform) {
|
|
100
|
+
if (!domains.has(platform)) domains.set(platform, domainOf(platform).catch(() => null))
|
|
101
|
+
return domains.get(platform)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const recordedStart = (record) => [record?.procStartFt, record?.procStart].find((v) => typeof v === 'string' && v.trim())?.trim() || ''
|
|
105
|
+
|
|
106
|
+
function within(promise, ms) {
|
|
107
|
+
let timer
|
|
108
|
+
const late = new Promise((resolve) => {
|
|
109
|
+
timer = setTimeout(resolve, ms)
|
|
110
|
+
})
|
|
111
|
+
return Promise.race([promise, late]).finally(() => clearTimeout(timer))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function reusedPidCheck({ lookup = processStarts, domain = ownPidDomain, waitMs = WAIT_MS } = {}) {
|
|
115
|
+
const known = new Map()
|
|
116
|
+
const forget = (pids) => {
|
|
117
|
+
const gone = new Set(pids)
|
|
118
|
+
if (!gone.size) return
|
|
119
|
+
for (const key of [...known.keys()]) if (gone.has(Number(key.slice(0, key.indexOf(':'))))) known.delete(key)
|
|
120
|
+
}
|
|
121
|
+
return Object.assign(reusedPids, { forget })
|
|
122
|
+
async function reusedPids(records) {
|
|
123
|
+
const own = await Promise.resolve()
|
|
124
|
+
.then(() => domain())
|
|
125
|
+
.catch(() => null)
|
|
126
|
+
if (!own) return new Set()
|
|
127
|
+
const asked = []
|
|
128
|
+
for (const record of records) {
|
|
129
|
+
const start = recordedStart(record)
|
|
130
|
+
const local = record?.pidDomain === undefined || record.pidDomain === own
|
|
131
|
+
if (start && local && Number.isSafeInteger(record.pid) && record.pid > 0) asked.push({ pid: record.pid, start, key: `${record.pid}:${start}` })
|
|
132
|
+
}
|
|
133
|
+
const fresh = asked.filter((a) => !known.has(a.key))
|
|
134
|
+
if (fresh.length) {
|
|
135
|
+
const found = Promise.resolve()
|
|
136
|
+
.then(() => lookup(fresh.map((a) => a.pid)))
|
|
137
|
+
.catch(() => null)
|
|
138
|
+
for (const a of fresh) {
|
|
139
|
+
const entry = { same: null, ready: null }
|
|
140
|
+
entry.ready = found
|
|
141
|
+
.then((starts) => sameStart(a.start, starts instanceof Map ? starts.get(a.pid) : undefined))
|
|
142
|
+
.catch(() => null)
|
|
143
|
+
.then((same) => {
|
|
144
|
+
entry.same = same
|
|
145
|
+
entry.ready = null
|
|
146
|
+
})
|
|
147
|
+
known.set(a.key, entry)
|
|
148
|
+
if (known.size > KEPT) known.delete(known.keys().next().value)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const waiting = asked.map((a) => known.get(a.key)?.ready).filter(Boolean)
|
|
152
|
+
if (waiting.length) await within(Promise.all(waiting), waitMs)
|
|
153
|
+
return new Set(asked.filter((a) => known.get(a.key)?.same === false).map((a) => a.pid))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const reusedPids = reusedPidCheck()
|
package/license/store.mjs
CHANGED
|
@@ -1,155 +1,192 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto'
|
|
2
|
-
import fsp from 'node:fs/promises'
|
|
3
|
-
import os from 'node:os'
|
|
4
|
-
import path from 'node:path'
|
|
5
|
-
import { LICENSE_PUBLIC_KEYS, verifyLicense } from './index.mjs'
|
|
6
|
-
|
|
7
|
-
const FILE = 'license.json'
|
|
8
|
-
const DEVICE = /^[A-Za-z0-9_-]{8,100}$/
|
|
9
|
-
const SEEN_STEP = 3600
|
|
10
|
-
const READ_TRIES = 5
|
|
11
|
-
const RENAME_TRIES = 10
|
|
12
|
-
const PAUSE_MS = 20
|
|
13
|
-
const CLOUD_URL = 'https://threadcrumbs.io/api'
|
|
14
|
-
const LOCAL_CLOUD_URL = 'http://localhost:8787/api'
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
async function
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
await
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import fsp from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { LICENSE_PUBLIC_KEYS, verifyLicense } from './index.mjs'
|
|
6
|
+
|
|
7
|
+
const FILE = 'license.json'
|
|
8
|
+
const DEVICE = /^[A-Za-z0-9_-]{8,100}$/
|
|
9
|
+
const SEEN_STEP = 3600
|
|
10
|
+
const READ_TRIES = 5
|
|
11
|
+
const RENAME_TRIES = 10
|
|
12
|
+
const PAUSE_MS = 20
|
|
13
|
+
const CLOUD_URL = 'https://threadcrumbs.io/api'
|
|
14
|
+
const LOCAL_CLOUD_URL = 'http://localhost:8787/api'
|
|
15
|
+
const CLOUD_TIMEOUT_MS = 15000
|
|
16
|
+
|
|
17
|
+
const pause = () => new Promise((resolve) => setTimeout(resolve, PAUSE_MS))
|
|
18
|
+
const isDevice = (value) => typeof value === 'string' && DEVICE.test(value)
|
|
19
|
+
const finite = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
|
|
20
|
+
|
|
21
|
+
export function threadcrumbsDataDir({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
22
|
+
if (env.THREADCRUMBS_DATA_DIR) return env.THREADCRUMBS_DATA_DIR
|
|
23
|
+
if (platform === 'win32') {
|
|
24
|
+
return path.win32.join(env.APPDATA || path.win32.join(home, 'AppData', 'Roaming'), 'Threadcrumbs')
|
|
25
|
+
}
|
|
26
|
+
if (platform === 'darwin') return path.posix.join(home, 'Library', 'Application Support', 'Threadcrumbs')
|
|
27
|
+
const xdg = env.XDG_DATA_HOME && path.posix.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : ''
|
|
28
|
+
return path.posix.join(xdg || path.posix.join(home, '.local', 'share'), 'threadcrumbs')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function trustedKeys({ env = process.env, development = false } = {}) {
|
|
32
|
+
const extra = development ? env.THREADCRUMBS_LICENSE_KEYS : ''
|
|
33
|
+
if (!extra) return { ...LICENSE_PUBLIC_KEYS }
|
|
34
|
+
let parsed
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(extra)
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error('THREADCRUMBS_LICENSE_KEYS must be JSON: { "<kid>": "<public key>" }')
|
|
39
|
+
}
|
|
40
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !Object.values(parsed).every((k) => typeof k === 'string')) {
|
|
41
|
+
throw new Error('THREADCRUMBS_LICENSE_KEYS must map each key ID to a public key')
|
|
42
|
+
}
|
|
43
|
+
return { ...LICENSE_PUBLIC_KEYS, ...parsed }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function cloudUrl({ env = process.env, development = false } = {}) {
|
|
47
|
+
return (env.THREADCRUMBS_CLOUD_URL || (development ? LOCAL_CLOUD_URL : CLOUD_URL)).replace(/\/+$/, '')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PORTAL_REFUSALS = {
|
|
51
|
+
not_activated: 'Enter your license key on this computer first.',
|
|
52
|
+
offline: "Couldn't reach the license service. Check your connection, then try again.",
|
|
53
|
+
unavailable: "The license service didn't answer as expected. Try again in a minute.",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const refusal = (error) => ({ error, why: PORTAL_REFUSALS[error] })
|
|
57
|
+
|
|
58
|
+
function isHttps(url) {
|
|
59
|
+
try {
|
|
60
|
+
return new URL(url).protocol === 'https:'
|
|
61
|
+
} catch {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function portalUrl({ license, device, fetch = globalThis.fetch, cloud = cloudUrl() } = {}) {
|
|
67
|
+
if (typeof license !== 'string' || !license || !isDevice(device)) return refusal('not_activated')
|
|
68
|
+
if (!cloud) return refusal('unavailable')
|
|
69
|
+
let response
|
|
70
|
+
try {
|
|
71
|
+
response = await fetch(`${cloud}/v1/portal`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'content-type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({ license, device }),
|
|
75
|
+
signal: AbortSignal.timeout(CLOUD_TIMEOUT_MS),
|
|
76
|
+
})
|
|
77
|
+
} catch {
|
|
78
|
+
return refusal('offline')
|
|
79
|
+
}
|
|
80
|
+
const data = await response.json().catch(() => null)
|
|
81
|
+
if (response.ok && typeof data?.url === 'string' && isHttps(data.url)) return { url: data.url }
|
|
82
|
+
if (!response.ok && typeof data?.error === 'string' && typeof data.why === 'string' && data.why) return { error: data.error, why: data.why }
|
|
83
|
+
return refusal('unavailable')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function check(token, keys, device) {
|
|
87
|
+
try {
|
|
88
|
+
return await verifyLicense(token, { keys, device })
|
|
89
|
+
} catch {
|
|
90
|
+
return { ok: false, reason: 'malformed' }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function licenseStore({ dir = threadcrumbsDataDir(), keys = LICENSE_PUBLIC_KEYS, now = () => Date.now() / 1000 } = {}) {
|
|
95
|
+
const file = path.join(dir, FILE)
|
|
96
|
+
|
|
97
|
+
async function read() {
|
|
98
|
+
let text
|
|
99
|
+
try {
|
|
100
|
+
text = await fsp.readFile(file, 'utf8')
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err.code === 'ENOENT') return null
|
|
103
|
+
throw err
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const data = JSON.parse(text)
|
|
107
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {}
|
|
108
|
+
} catch {
|
|
109
|
+
return {}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const serialize = (data) => `${JSON.stringify(data, null, 2)}\n`
|
|
114
|
+
|
|
115
|
+
async function replace(data) {
|
|
116
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
117
|
+
const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
|
|
118
|
+
try {
|
|
119
|
+
await fsp.writeFile(temp, serialize(data), { mode: 0o600 })
|
|
120
|
+
for (let attempt = 1; ; attempt++) {
|
|
121
|
+
try {
|
|
122
|
+
return await fsp.rename(temp, file)
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (attempt >= RENAME_TRIES || !['EPERM', 'EACCES', 'EBUSY'].includes(err.code)) throw err
|
|
125
|
+
await pause()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
await fsp.rm(temp, { force: true })
|
|
130
|
+
throw err
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function device() {
|
|
135
|
+
for (let attempt = 0; attempt < READ_TRIES; attempt++) {
|
|
136
|
+
const data = await read()
|
|
137
|
+
if (isDevice(data?.device)) return data.device
|
|
138
|
+
if (data === null) {
|
|
139
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
140
|
+
try {
|
|
141
|
+
await fsp.writeFile(file, serialize({ device: randomBytes(18).toString('base64url'), license: null, seen: 0 }), { flag: 'wx', mode: 0o600 })
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err.code !== 'EEXIST') throw err
|
|
144
|
+
}
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
await pause()
|
|
148
|
+
}
|
|
149
|
+
await replace({ device: randomBytes(18).toString('base64url'), license: null, seen: 0 })
|
|
150
|
+
const data = await read()
|
|
151
|
+
if (!isDevice(data?.device)) throw new Error(`Can't read or replace ${file}`)
|
|
152
|
+
return data.device
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function update(change) {
|
|
156
|
+
const data = (await read()) ?? {}
|
|
157
|
+
await replace({ ...data, ...change, device: data.device })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function current() {
|
|
161
|
+
const clock = now()
|
|
162
|
+
if ((await read()) === null) return { device: null, token: null, license: null, at: clock }
|
|
163
|
+
const dev = await device()
|
|
164
|
+
const data = (await read()) ?? {}
|
|
165
|
+
const seen = finite(data.seen)
|
|
166
|
+
if (typeof data.license !== 'string') return { device: dev, token: null, license: null, at: Math.max(clock, seen) }
|
|
167
|
+
const checked = await check(data.license, keys, dev)
|
|
168
|
+
if (!checked.ok) return { device: dev, token: null, license: null, reason: checked.reason, at: Math.max(clock, seen) }
|
|
169
|
+
if (clock > seen + SEEN_STEP) await update({ seen: clock }).catch(() => {})
|
|
170
|
+
return { device: dev, token: data.license, license: checked.license, at: Math.max(clock, seen, checked.license.iat) }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function save(token) {
|
|
174
|
+
const dev = await device()
|
|
175
|
+
const checked = await check(token, keys, dev)
|
|
176
|
+
if (!checked.ok) return checked
|
|
177
|
+
const data = (await read()) ?? {}
|
|
178
|
+
const stored = typeof data.license === 'string' ? await check(data.license, keys, dev) : null
|
|
179
|
+
if (stored?.ok && stored.license.iat > checked.license.iat) {
|
|
180
|
+
return { ok: true, license: stored.license, at: Math.max(now(), finite(data.seen), stored.license.iat) }
|
|
181
|
+
}
|
|
182
|
+
await update({ license: token, seen: checked.license.iat })
|
|
183
|
+
return { ok: true, license: checked.license, at: Math.max(now(), checked.license.iat) }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function clear() {
|
|
187
|
+
await device()
|
|
188
|
+
await update({ license: null })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { file, device, current, save, clear }
|
|
192
|
+
}
|
package/package.json
CHANGED
|
@@ -24,6 +24,7 @@ export function claudeCode({
|
|
|
24
24
|
desktop = claudeDesktopStores(),
|
|
25
25
|
now = Date.now,
|
|
26
26
|
isAlive,
|
|
27
|
+
reusedPids,
|
|
27
28
|
} = {}) {
|
|
28
29
|
const projectsDir = path.join(home, 'projects')
|
|
29
30
|
const liveDir = path.join(home, 'sessions')
|
|
@@ -155,7 +156,7 @@ export function claudeCode({
|
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
async function scanSessions() {
|
|
158
|
-
const [live, limits, marks] = await Promise.all([readLiveSessions(liveDir, isAlive), contextLimits(), desktopRecords(desktop).then(desktopMarks)])
|
|
159
|
+
const [live, limits, marks] = await Promise.all([readLiveSessions(liveDir, isAlive, reusedPids), contextLimits(), desktopRecords(desktop).then(desktopMarks)])
|
|
159
160
|
const found = []
|
|
160
161
|
const files = new Map()
|
|
161
162
|
const states = new Map()
|
|
@@ -238,7 +239,7 @@ export function claudeCode({
|
|
|
238
239
|
const stat = file ? await fsp.stat(file).catch(() => null) : null
|
|
239
240
|
if (!stat) return null
|
|
240
241
|
const state = await (known.get(id) === file ? transcripts : olderReader(file)).read(file, stat)
|
|
241
|
-
const [live, limits, marks] = await Promise.all([readLiveSessions(liveDir, isAlive), contextLimits(), desktopRecords(desktop).then(desktopMarks)])
|
|
242
|
+
const [live, limits, marks] = await Promise.all([readLiveSessions(liveDir, isAlive, reusedPids), contextLimits(), desktopRecords(desktop).then(desktopMarks)])
|
|
242
243
|
const said = await opened(file, stat, state, limits)
|
|
243
244
|
if (marks.get(id)?.archived) return null
|
|
244
245
|
const running = live.get(id)
|