@leviyuan/lodestar 0.2.8 → 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/README.md +48 -0
- package/daemon.ts +2 -0
- package/package.json +1 -1
- package/src/cards/console.ts +352 -0
- package/src/cards/elements.ts +22 -0
- package/src/cards/turn.ts +530 -0
- package/src/cards.ts +29 -795
- package/src/claude-process.ts +26 -4
- package/src/config.ts +16 -1
- package/src/feishu.ts +14 -47
- package/src/notify.ts +132 -0
- package/src/session-ask.ts +165 -0
- package/src/session-permission.ts +136 -0
- package/src/session-tools.ts +233 -0
- package/src/session-types.ts +91 -0
- package/src/session.ts +173 -642
- package/src/sysinfo.ts +273 -0
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
|
+
}
|