@leviyuan/lodestar 0.2.7 → 0.2.9

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/src/sysinfo.ts ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Lightweight host snapshot for the `hi` console panel —— CPU 负载、
3
+ * 内存、根/家目录磁盘、以及当前用户下的 cc-* 系列 systemd 服务。
4
+ *
5
+ * 服务前缀约定: claude code 自己起的常驻进程统一走
6
+ * systemd-run --user --unit=cc-<project>-<purpose> -- <cmd>
7
+ * (见全局 CLAUDE.md 的 background_process_safety 段)。`hi` 面板只列
8
+ * `cc-*` 是要让 daemon 这台机器上的"AI 拉起来的活儿"一眼可见,跟
9
+ * 系统自带 / 第三方服务区分开。
10
+ *
11
+ * 所有数据源都是本机文件 / 系统调用,没有网络往返:
12
+ * /proc/loadavg —— 1m / 5m / 15m
13
+ * /proc/meminfo —— Total / Available
14
+ * statfsSync(path) —— 各挂载点容量
15
+ * /proc/uptime —— monotonic seconds since boot (uptime 推算)
16
+ * systemctl --user show —— cc-* 服务的状态与启动时间
17
+ *
18
+ * 失败可见: 任何一段读不到就把对应字段标 null,卡片层按 null 渲染
19
+ * `_n/a_`,绝不假数据 (no_fallbacks)。
20
+ */
21
+
22
+ import { readFileSync, statfsSync, statSync } from 'node:fs'
23
+ import { cpus, homedir } from 'node:os'
24
+ import { log } from './log'
25
+
26
+ export interface CpuInfo {
27
+ cores: number
28
+ load1: number
29
+ load5: number
30
+ load15: number
31
+ }
32
+
33
+ export interface MemInfo {
34
+ /** Bytes — MemTotal 来自 /proc/meminfo */
35
+ totalBytes: number
36
+ /** Bytes — MemAvailable;比 free + buffers + cached 更准 (考虑 reclaimable) */
37
+ availBytes: number
38
+ usedBytes: number
39
+ /** 0–100 */
40
+ percent: number
41
+ }
42
+
43
+ export interface DiskInfo {
44
+ /** 显示用的标签 ('/' 或 '$HOME') */
45
+ label: string
46
+ /** 实际查询的路径 */
47
+ path: string
48
+ totalBytes: number
49
+ availBytes: number
50
+ usedBytes: number
51
+ /** 0–100;按 used / total */
52
+ percent: number
53
+ }
54
+
55
+ export interface ServiceInfo {
56
+ /** 不带 .service 后缀,贴卡片用 */
57
+ name: string
58
+ /** systemd ActiveState: active | inactive | failed | activating | deactivating */
59
+ active: string
60
+ /** SubState: running | exited | dead | start | stop-sigterm | ... */
61
+ sub: string
62
+ /** 自最近一次进入 active 状态起的秒数。从未活跃过则为 null。
63
+ * 对 active 服务等于 "已运行 X 秒";对 inactive/failed 等于
64
+ * "上次跑起来到现在过了 X 秒"。 */
65
+ lastActiveAgoSec: number | null
66
+ /** 当前 ActiveState 的持续秒数 (StateChangeTimestamp → 现在)。
67
+ * 对 active 服务等于 lastActiveAgoSec;对 inactive 服务等于
68
+ * "已停了多久";对 activating/deactivating 等于"切换中多久"。 */
69
+ stateAgoSec: number | null
70
+ }
71
+
72
+ export interface SysInfo {
73
+ cpu: CpuInfo | null
74
+ mem: MemInfo | null
75
+ disks: DiskInfo[]
76
+ services: ServiceInfo[]
77
+ /** 真的查不到时(systemctl 不存在 / 拒绝)就 null;空数组表示"没有 cc-* 服务"。 */
78
+ servicesError: string | null
79
+ }
80
+
81
+ /** 用户态 systemd-run 服务的统一前缀。改这里要同步改 CLAUDE.md。 */
82
+ export const SERVICE_PREFIX = 'cc-'
83
+
84
+ function readCpu(): CpuInfo | null {
85
+ try {
86
+ const raw = readFileSync('/proc/loadavg', 'utf8').trim().split(/\s+/)
87
+ return {
88
+ cores: cpus().length,
89
+ load1: parseFloat(raw[0] ?? '0'),
90
+ load5: parseFloat(raw[1] ?? '0'),
91
+ load15: parseFloat(raw[2] ?? '0'),
92
+ }
93
+ } catch (e) {
94
+ log(`sysinfo: read /proc/loadavg failed: ${e}`)
95
+ return null
96
+ }
97
+ }
98
+
99
+ function readMem(): MemInfo | null {
100
+ try {
101
+ const raw = readFileSync('/proc/meminfo', 'utf8')
102
+ const find = (k: string): number => {
103
+ const m = raw.match(new RegExp(`^${k}:\\s+(\\d+)\\s*kB`, 'm'))
104
+ return m ? parseInt(m[1]!, 10) * 1024 : 0
105
+ }
106
+ const totalBytes = find('MemTotal')
107
+ const availBytes = find('MemAvailable')
108
+ if (!totalBytes) return null
109
+ const usedBytes = Math.max(0, totalBytes - availBytes)
110
+ return {
111
+ totalBytes, availBytes, usedBytes,
112
+ percent: Math.round((usedBytes / totalBytes) * 100),
113
+ }
114
+ } catch (e) {
115
+ log(`sysinfo: read /proc/meminfo failed: ${e}`)
116
+ return null
117
+ }
118
+ }
119
+
120
+ /** statfsSync 拿到的 blocks/bavail 都按 f_frsize 计算 bytes —— 注意
121
+ * `usedBytes` 用 total - avail (不是 total - free),跟 `df` 的 Use% 列
122
+ * 一致 (排除 root 保留块)。 */
123
+ function readDiskFor(label: string, path: string): DiskInfo | null {
124
+ try {
125
+ const s = statfsSync(path, { bigint: false }) as {
126
+ bsize: number; blocks: number; bavail: number
127
+ }
128
+ const totalBytes = s.blocks * s.bsize
129
+ const availBytes = s.bavail * s.bsize
130
+ const usedBytes = Math.max(0, totalBytes - availBytes)
131
+ if (!totalBytes) return null
132
+ return {
133
+ label, path, totalBytes, availBytes, usedBytes,
134
+ percent: Math.round((usedBytes / totalBytes) * 100),
135
+ }
136
+ } catch (e) {
137
+ log(`sysinfo: statfs ${path} failed: ${e}`)
138
+ return null
139
+ }
140
+ }
141
+
142
+ /** 取 `/` 和 `$HOME`;如果两者属于同一文件系统(同一 device id),
143
+ * 只返回 `/`,避免面板上挂两条一样的数据 (用户在 AskUserQuestion
144
+ * 时知情的选择)。 */
145
+ function readDisks(): DiskInfo[] {
146
+ const out: DiskInfo[] = []
147
+ const root = readDiskFor('/', '/')
148
+ if (root) out.push(root)
149
+ const home = homedir()
150
+ if (home && home !== '/') {
151
+ let homeOnSameFs = false
152
+ try {
153
+ const rs = statSync('/')
154
+ const hs = statSync(home)
155
+ homeOnSameFs = rs.dev === hs.dev
156
+ } catch {}
157
+ if (!homeOnSameFs) {
158
+ const homeDisk = readDiskFor('$HOME', home)
159
+ if (homeDisk) out.push(homeDisk)
160
+ }
161
+ }
162
+ return out
163
+ }
164
+
165
+ /** /proc/uptime 第一个数是 monotonic 自启动以来的秒数。 */
166
+ function readMonotonicSec(): number | null {
167
+ try {
168
+ const raw = readFileSync('/proc/uptime', 'utf8').trim().split(/\s+/)
169
+ return parseFloat(raw[0] ?? '0')
170
+ } catch {
171
+ return null
172
+ }
173
+ }
174
+
175
+ /** 用 Bun.spawn 跑 `systemctl --user`,合并 stdout 并返回 trim 后的字符串。
176
+ * 超时(默认 2s)或非零退出码都返回 null,让调用方走错误分支。 */
177
+ async function runSystemctl(args: string[], timeoutMs = 2000): Promise<string | null> {
178
+ try {
179
+ const proc = Bun.spawn(['systemctl', '--user', ...args], {
180
+ stdout: 'pipe', stderr: 'pipe',
181
+ })
182
+ const timer = setTimeout(() => {
183
+ try { proc.kill('SIGTERM') } catch {}
184
+ }, timeoutMs)
185
+ const text = await new Response(proc.stdout).text()
186
+ const code = await proc.exited
187
+ clearTimeout(timer)
188
+ if (code !== 0) return null
189
+ return text
190
+ } catch (e) {
191
+ log(`sysinfo: systemctl ${args.join(' ')} failed: ${e}`)
192
+ return null
193
+ }
194
+ }
195
+
196
+ /** 列 `${SERVICE_PREFIX}*` 服务并解析:第 1 步用 list-units 拿名字,
197
+ * 第 2 步用单次 show -p 拿状态 + ActiveEnterTimestampMonotonic。两步
198
+ * 都是本地调用,加起来 < 100ms。 */
199
+ async function readServices(): Promise<{ services: ServiceInfo[]; error: string | null }> {
200
+ // list-units 输出每行: "<unit> <load> <active> <sub> <description>"。
201
+ // --all 把 inactive 也列出来 (用户停过的服务也值得在面板看到)。
202
+ // 加 --plain --no-legend 关掉表格修饰和 footer,方便机器解析。
203
+ const listOut = await runSystemctl([
204
+ 'list-units', '--type=service', '--all', '--no-legend', '--plain', `${SERVICE_PREFIX}*`,
205
+ ])
206
+ if (listOut === null) {
207
+ return { services: [], error: 'systemctl 不可用' }
208
+ }
209
+ const lines = listOut.split('\n').map(l => l.trim()).filter(l => l.length > 0)
210
+ if (lines.length === 0) return { services: [], error: null }
211
+
212
+ const names: string[] = []
213
+ for (const line of lines) {
214
+ // 第一列是 unit 全名,可能带前缀●;.service 后缀去掉。
215
+ const cols = line.replace(/^●\s*/, '').split(/\s+/)
216
+ const unit = cols[0]
217
+ if (!unit) continue
218
+ if (!unit.startsWith(SERVICE_PREFIX)) continue
219
+ if (!unit.endsWith('.service')) continue
220
+ names.push(unit)
221
+ }
222
+ if (names.length === 0) return { services: [], error: null }
223
+
224
+ // 一次性 show 多个 unit:每个 unit 输出一段属性,段之间空行分隔。
225
+ // ActiveEnter = 最近一次进入 active 的时刻 (即使现在已 inactive 也保留);
226
+ // StateChange = 当前 ActiveState 进入时刻。两者对 active 服务相同,
227
+ // 对 inactive 服务分别是"上次活跃"与"停了多久"。
228
+ const showOut = await runSystemctl([
229
+ 'show', ...names,
230
+ '-p', 'Id',
231
+ '-p', 'ActiveState',
232
+ '-p', 'SubState',
233
+ '-p', 'ActiveEnterTimestampMonotonic',
234
+ '-p', 'StateChangeTimestampMonotonic',
235
+ ])
236
+ if (showOut === null) return { services: [], error: 'systemctl show 失败' }
237
+
238
+ const monotonicNowSec = readMonotonicSec()
239
+ const blocks = showOut.split(/\n\s*\n/)
240
+ const services: ServiceInfo[] = []
241
+ const ageFrom = (microStr: string | undefined): number | null => {
242
+ const micro = parseInt(microStr ?? '0', 10)
243
+ if (micro <= 0 || monotonicNowSec === null) return null
244
+ return Math.max(0, monotonicNowSec - micro / 1_000_000)
245
+ }
246
+ for (const block of blocks) {
247
+ const props: Record<string, string> = {}
248
+ for (const line of block.split('\n')) {
249
+ const eq = line.indexOf('=')
250
+ if (eq <= 0) continue
251
+ props[line.slice(0, eq)] = line.slice(eq + 1)
252
+ }
253
+ const id = props.Id ?? ''
254
+ if (!id.startsWith(SERVICE_PREFIX) || !id.endsWith('.service')) continue
255
+ services.push({
256
+ name: id.replace(/\.service$/, ''),
257
+ active: props.ActiveState ?? 'unknown',
258
+ sub: props.SubState ?? '',
259
+ lastActiveAgoSec: ageFrom(props.ActiveEnterTimestampMonotonic),
260
+ stateAgoSec: ageFrom(props.StateChangeTimestampMonotonic),
261
+ })
262
+ }
263
+ services.sort((a, b) => a.name.localeCompare(b.name))
264
+ return { services, error: null }
265
+ }
266
+
267
+ export async function readSysInfo(): Promise<SysInfo> {
268
+ const cpu = readCpu()
269
+ const mem = readMem()
270
+ const disks = readDisks()
271
+ const { services, error } = await readServices()
272
+ return { cpu, mem, disks, services, servicesError: error }
273
+ }