@threadcrumbs/common 0.1.10 → 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.
@@ -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 live = new Map()
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 || !isAlive(record.pid)) continue
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadcrumbs/common",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "description": "Code the Threadcrumbs apps share, starting with the readers for each coding-agent harness.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -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)