sintbot 1.3.0 → 1.4.1

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 (81) hide show
  1. package/CHANGELOG.en.json +50 -0
  2. package/CHANGELOG.json +50 -0
  3. package/README.md +138 -20
  4. package/README.zh-CN.md +144 -20
  5. package/assets/design-styles/acid-pop.md +155 -0
  6. package/assets/design-styles/archive-index.md +161 -0
  7. package/assets/design-styles/art-deco.md +164 -0
  8. package/assets/design-styles/cinematic-noir.md +178 -0
  9. package/assets/design-styles/editorial.md +121 -0
  10. package/assets/design-styles/geometric-pop.md +122 -0
  11. package/assets/design-styles/hand-drawn.md +117 -0
  12. package/assets/design-styles/islamic-geometry.md +155 -0
  13. package/assets/design-styles/liquid-glass.md +183 -0
  14. package/assets/design-styles/neo-chinese.md +136 -0
  15. package/assets/design-styles/precision-mono.md +156 -0
  16. package/assets/design-styles/quiet-luxury.md +163 -0
  17. package/assets/design-styles/riso-print.md +116 -0
  18. package/assets/design-styles/soft-friendly.md +174 -0
  19. package/assets/design-styles/swiss-grid.md +156 -0
  20. package/assets/design-styles/tech-dark.md +182 -0
  21. package/bin/sintbot-v2.js +291 -263
  22. package/docs/AI-ASSISTED-INSTALL.md +8 -8
  23. package/docs/install.md +80 -48
  24. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
  25. package/docs/zh-CN/install.md +80 -48
  26. package/install.sh +34 -81
  27. package/package.json +60 -5
  28. package/src/agent/claudecode.js +32 -3
  29. package/src/agent/claudemodels.js +60 -0
  30. package/src/agent/claudestatus.js +74 -0
  31. package/src/agent/claudeusage.js +200 -0
  32. package/src/agent/codexapp.js +21 -4
  33. package/src/agent/codexcode.js +8 -3
  34. package/src/agent/codexstatus.js +181 -0
  35. package/src/agent/privatecore.js +1 -1
  36. package/src/agent/privatecore.wasm +0 -0
  37. package/src/c2configcontrol.js +214 -0
  38. package/src/c2console.js +149 -0
  39. package/src/c2incident.js +125 -0
  40. package/src/c2install.js +247 -37
  41. package/src/c2migration.js +100 -0
  42. package/src/c2multigroupcapacity.js +103 -0
  43. package/src/c2multigroupservice.js +418 -0
  44. package/src/cdp.js +254 -0
  45. package/src/config.js +13 -2
  46. package/src/core/c2durablejournal.js +26 -9
  47. package/src/core/c2durableruntime.js +104 -1
  48. package/src/core/c2inputrejection.js +45 -0
  49. package/src/core/c2localeffectexecutor.js +12 -1
  50. package/src/core/c2payloadhandles.js +11 -0
  51. package/src/core/c2persistrelation.js +20 -0
  52. package/src/core/c2persiststate.js +28 -2
  53. package/src/core/c2platformrecoverysender.js +1 -1
  54. package/src/core/c2sourcequarantine.js +56 -1
  55. package/src/core/c2terminalprune.js +2 -1
  56. package/src/core/engineslotsadapter.js +108 -0
  57. package/src/core/issues.js +185 -0
  58. package/src/core/ledger.js +30 -0
  59. package/src/core/proxy.js +424 -0
  60. package/src/core/store.js +12 -0
  61. package/src/core/v2engine.js +427 -31
  62. package/src/design.js +5161 -0
  63. package/src/gitrepo.js +150 -0
  64. package/src/leftover.js +174 -0
  65. package/src/platform/slack.js +1294 -0
  66. package/src/platform/wecom.js +961 -0
  67. package/src/platform/wecomcli.js +128 -0
  68. package/src/platform/wecomname.js +43 -0
  69. package/src/platform/wecompoll.js +241 -0
  70. package/src/preview.js +581 -0
  71. package/src/projectmemory.js +175 -0
  72. package/src/ui/auth.js +31 -0
  73. package/src/ui/browser.html +392 -0
  74. package/src/ui/browserroutes.js +234 -0
  75. package/src/ui/console.html +5719 -0
  76. package/src/ui/dashboard.js +168 -0
  77. package/src/ui/feishulogin.js +136 -0
  78. package/src/ui/serverbase.js +2371 -0
  79. package/src/ui/sessions.js +78 -0
  80. package/src/ui/takeover.js +108 -0
  81. package/src/usage.js +144 -0
package/src/gitrepo.js ADDED
@@ -0,0 +1,150 @@
1
+ // 群项目 ↔ git 远端关联(控制台「仓库地址」字段)。
2
+ //
3
+ // 场景:项目代码已经在本地 workdir 里,operator 在代码平台上为它新建了一个**空**仓库,
4
+ // 填地址是为了之后把本地项目推上去。所以这里**不做 clone** —— 远端没东西可拉。控制台只
5
+ // 负责建立关联(git init + remote origin,幂等),真正的 add/commit/push 交给 agent
6
+ // (它有 workdir、有判断力,该决定 .gitignore / commit message / 推哪些文件)。
7
+ //
8
+ // 安全:仓库地址是控制台传来的自由文本,而 git 的部分 transport 会执行命令 ——
9
+ // `git ls-remote 'ext::sh -c <任意命令>'` 就是 RCE,`--upload-pack=` 同理。控制台虽已
10
+ // 鉴权,仍按 fail-closed 做协议白名单:只放行 SSH 两种写法,其余一律拒。所有 git 调用
11
+ // 走 execFile(数组参数、不经 shell),不存在拼接注入。
12
+ const { execFile } = require('node:child_process')
13
+ const fs = require('node:fs')
14
+ const path = require('node:path')
15
+
16
+ // scp 写法: git@host:org/repo.git · URL 写法: ssh://git@host[:port]/org/repo.git
17
+ const SSH_SCP = /^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[A-Za-z0-9._~-][A-Za-z0-9._~/-]*$/
18
+ const SSH_URL = /^ssh:\/\/[A-Za-z0-9._-]+@[A-Za-z0-9._-]+(:\d{1,5})?\/[A-Za-z0-9._~/-]+$/
19
+
20
+ function validateRepoUrl(url) {
21
+ const u = String(url == null ? '' : url).trim()
22
+ if (!u) return { ok: false, error: '仓库地址为空' }
23
+ // 以 - 开头会被 git 当成选项 —— 挡在正则之前,错误信息才说得清
24
+ if (u.startsWith('-')) return { ok: false, error: '仓库地址不能以 - 开头' }
25
+ if (/^ext::/i.test(u)) return { ok: false, error: '拒绝 ext:: 地址(该 transport 会执行任意命令)' }
26
+ if (/^(file|git):/i.test(u)) return { ok: false, error: '只支持 SSH 地址,不支持 file:// / git:// ' }
27
+ if (/^https?:/i.test(u)) return { ok: false, error: '只支持 SSH 地址 — HTTPS 要把令牌写进 URL 或存明文凭据,易随 git remote -v / 日志泄露' }
28
+ if (!SSH_SCP.test(u) && !SSH_URL.test(u)) {
29
+ return { ok: false, error: '不是合法的 SSH 仓库地址,应形如 git@codeup.aliyun.com:org/repo.git' }
30
+ }
31
+ return { ok: true, url: u }
32
+ }
33
+
34
+ // 只带 git 用得上的 env。BatchMode/GIT_TERMINAL_PROMPT 是硬要求:无头环境下任何交互
35
+ // 提示(输密码、确认 host key)都会把请求挂死 —— sbot 的头号纪律是绝不挂起。
36
+ function gitEnv() {
37
+ const SAFE = ['PATH', 'PATHEXT', 'SystemRoot', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'LANG', 'LC_ALL', 'TEMP', 'TMP']
38
+ const env = {}
39
+ for (const k of SAFE) if (process.env[k] !== undefined) env[k] = process.env[k]
40
+ env.GIT_TERMINAL_PROMPT = '0'
41
+ env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10'
42
+ return env
43
+ }
44
+
45
+ function git(cwd, args, timeout = 20000) {
46
+ return new Promise((resolve) => {
47
+ execFile('git', args, { cwd, timeout, windowsHide: true, env: gitEnv(), maxBuffer: 2 * 1024 * 1024 }, (err, stdout, stderr) => {
48
+ const out = ((stdout || '') + (stderr ? (stdout ? '\n' : '') + stderr : '')).trim()
49
+ resolve({ ok: !err, out, code: err ? (err.code ?? null) : 0 })
50
+ })
51
+ })
52
+ }
53
+
54
+ // 把 git/ssh 的英文错误翻成 operator 能照着做的一句话。凭据/主机密钥是这条链路最常见的两种挂法。
55
+ function humanizeGitError(out) {
56
+ const s = String(out || '')
57
+ if (/Permission denied \(publickey|Permission denied, please try again/i.test(s)) {
58
+ // ssh 对「有 key 但没被接受」和「压根没有 key 可提供」返回同一句 Permission denied (publickey),
59
+ // 但两者修法完全不同 ⇒ 两种都点出来,别让人以为一定是加错了账号。
60
+ return '凭据不通:这台机器没有能被该主机接受的 SSH key — 可能是①这台机器上还没配 key(~/.ssh 里没有对应私钥,或 ~/.ssh/config 没指向它),或②公钥没加到对应账号 / 该账号对本仓库无权限'
61
+ }
62
+ if (/Host key verification failed|No ED25519 host key is known|authenticity of host/i.test(s)) {
63
+ return '主机密钥未预置:在该机器上先跑 ssh-keyscan <host> >> ~/.ssh/known_hosts(无头环境无法交互确认)'
64
+ }
65
+ if (/Repository path not found|repository .* not found|does not appear to be a git repository|找不到代码库/i.test(s)) {
66
+ return '仓库不存在,或当前凭据对它无权限(远端对无权限的库通常也报「找不到」)'
67
+ }
68
+ if (/Could not resolve hostname|Name or service not known/i.test(s)) return '域名解析失败:检查网络 / 代理设置'
69
+ if (/Connection timed out|Operation timed out|timed out/i.test(s)) return '连接超时:检查网络 / 防火墙,或该主机的 SSH 端口不通'
70
+ if (/Connection refused/i.test(s)) return '连接被拒:该主机的 SSH 端口不可达'
71
+ return s.split('\n').filter(Boolean).slice(-2).join(' · ') || '未知错误'
72
+ }
73
+
74
+ // 路径比对一律过 realpath:git 的 --show-toplevel 返回**解析过符号链接**的真实路径,而
75
+ // 配置里的 workdir 常是带软链的写法(macOS 的 /var→/private/var、或 ~/Projects 是软链)。
76
+ // 直接 resolve 比对会把「本目录自己的仓库」误判成「嵌套在别人的仓库里」。
77
+ const realpath = (p) => { try { return fs.realpathSync(p) } catch { return path.resolve(p) } }
78
+
79
+ // workdir 的 git 现状:是不是仓库、origin 指向哪、当前分支(空仓库也能拿到)。
80
+ async function repoStatus(workdir) {
81
+ if (!workdir || !fs.existsSync(workdir)) return { isRepo: false, origin: '', branch: '' }
82
+ // --git-dir 存在 ≠ 就是本目录的仓库(可能是父目录的);用 rev-parse 拿顶层再比对
83
+ const top = await git(workdir, ['rev-parse', '--show-toplevel'])
84
+ if (!top.ok) return { isRepo: false, origin: '', branch: '' }
85
+ const isSelf = realpath(top.out) === realpath(workdir)
86
+ if (!isSelf) return { isRepo: false, origin: '', branch: '', nestedIn: top.out }
87
+ const o = await git(workdir, ['remote', 'get-url', 'origin'])
88
+ const b = await git(workdir, ['symbolic-ref', '--short', 'HEAD']) // 空仓库(无 commit)也返回默认分支名
89
+ return { isRepo: true, origin: o.ok ? o.out : '', branch: b.ok ? b.out : '' }
90
+ }
91
+
92
+ // 建立关联(幂等):不是仓库就 git init,然后 origin add/set-url。
93
+ // 分支不指定 —— 跟随该机器的 init.defaultBranch,不替 operator 拍板 main/master。
94
+ async function linkRepo(workdir, url) {
95
+ const v = validateRepoUrl(url)
96
+ if (!v.ok) return v
97
+ if (!workdir || !fs.existsSync(workdir)) return { ok: false, error: `workdir 不存在: ${workdir}` }
98
+ let st = await repoStatus(workdir)
99
+ if (st.nestedIn) return { ok: false, error: `该目录在另一个 git 仓库内(${st.nestedIn}),拒绝在此 git init` }
100
+ let initialized = false
101
+ if (!st.isRepo) {
102
+ const r = await git(workdir, ['init'])
103
+ if (!r.ok) return { ok: false, error: 'git init 失败:' + humanizeGitError(r.out) }
104
+ initialized = true
105
+ st = await repoStatus(workdir)
106
+ }
107
+ const r2 = st.origin
108
+ ? await git(workdir, ['remote', 'set-url', 'origin', v.url])
109
+ : await git(workdir, ['remote', 'add', 'origin', v.url])
110
+ if (!r2.ok) return { ok: false, error: '设置 origin 失败:' + humanizeGitError(r2.out) }
111
+ const after = await repoStatus(workdir)
112
+ return { ok: true, url: v.url, initialized, replaced: !!st.origin && st.origin !== v.url, branch: after.branch }
113
+ }
114
+
115
+ // —— 轻量状态(纯 fs,不 spawn git)——
116
+ // 群卡右上角的「仓库 ✓」标签要反映**真实**关联,不能只回显配置里的字符串(origin 被手改/删掉时
117
+ // 标签还绿着 = 骗人)。但 buildState 每个 API 响应都跑一遍、per-group spawn git 扛不住,所以
118
+ // 这里直接读 .git/config —— 一次小文件读,和 existsSync 同量级。
119
+ function readOriginFromGitConfig(workdir) {
120
+ try {
121
+ const gitPath = path.join(workdir, '.git')
122
+ const st = fs.statSync(gitPath)
123
+ if (!st.isDirectory()) return null // .git 是文件(worktree/submodule)—— 少见,不猜,交给慢路径
124
+ const text = fs.readFileSync(path.join(gitPath, 'config'), 'utf8')
125
+ const sec = text.match(/\[remote\s+"origin"\][^[]*/) // 该 section 到下一个 [ 之前
126
+ if (!sec) return ''
127
+ const u = sec[0].match(/^\s*url\s*=\s*(.+)$/m)
128
+ return u ? u[1].trim() : ''
129
+ } catch { return null } // 不是仓库 / 读不动
130
+ }
131
+
132
+ // '' = 没配仓库(不显示标签) · ok = origin 与配置一致 · missing = 已不是 git 仓库 · drift = origin 对不上
133
+ function quickLinkState(workdir, configuredRepo) {
134
+ if (!configuredRepo) return ''
135
+ const origin = readOriginFromGitConfig(workdir)
136
+ if (origin === null) return 'missing'
137
+ return origin === configuredRepo ? 'ok' : 'drift'
138
+ }
139
+
140
+ // 连通性 + 权限探测:ls-remote 不落地任何东西,是最轻的「这把凭据能不能用」的问法。
141
+ async function testRemote(url) {
142
+ const v = validateRepoUrl(url)
143
+ if (!v.ok) return v
144
+ const r = await git(process.cwd(), ['ls-remote', '--heads', v.url], 25000)
145
+ if (!r.ok) return { ok: false, error: humanizeGitError(r.out) }
146
+ const branches = r.out.split('\n').filter((l) => /refs\/heads\//.test(l)).length
147
+ return { ok: true, branches, empty: branches === 0 }
148
+ }
149
+
150
+ module.exports = { validateRepoUrl, repoStatus, linkRepo, testRemote, humanizeGitError, readOriginFromGitConfig, quickLinkState }
@@ -0,0 +1,174 @@
1
+ // 遗留进程:agent 轮次结束后仍活着的后台 shell(v0.11.108)。
2
+ //
3
+ // 为什么要有这一层:claude 子进程退了,它在轮中用后台方式起的 shell(轮询循环 / nohup 的脚本 /
4
+ // 卡住的 expect)并不跟着退 —— 父进程没了就挂到 pid 1 下面,而且 Claude Code 给每条 Bash 开的是
5
+ // **自己的进程组**(pgid = 自身 pid),runner 轮次结束的树杀抓不到它们。2026-10-09 在生产机上翻出
6
+ // 三个:其中一条 `until node -e …; do sleep 5; done` 每 5 秒调一次线上云函数,跑了 35 天,零 CPU、
7
+ // 零报警,一个月前某次 ps 输出里出现过也没人当回事。缺的不是围栏(四个群 1.27 万次 Bash 调用里
8
+ // 后台循环零次 —— 不常见),是**看得见**:doctor 一行 + 控制台概览一区 + 一键结束。
9
+ //
10
+ // 判据(三条同时成立才算):
11
+ // ① ppid === 1(父进程已退,被 launchd / init 收养)
12
+ // ② 命令行带 `shell-snapshots/snapshot-`(Claude Code 的 Bash 工具起 shell 时 source 的快照,
13
+ // 是「这条 shell 是 agent 起的」唯一可靠标记;别的进程不会带它)
14
+ // ③ 存活 ≥ minAgeSec(默认 1 小时):刚结束的轮里还在收尾的后台命令不该被报;一小时还在跑的
15
+ // 几乎都是忘了停的循环 / 卡住的等待(轮次墙钟上限才 90 分钟)
16
+ // ⚠ 只报与「一键结束」,**不自动杀** —— 偶尔也有 agent 故意留下的东西(比如起了个 dev server
17
+ // 在看),杀不杀是 operator 的判断;自动杀等于把一次误判变成一次事故。
18
+ // ⚠ Windows 没有 ps、也没有这套 shell 快照 ⇒ supported:false,一行不占(与 browser 行同款:
19
+ // 没有的东西不吓人)。
20
+ // 纯函数(解析 / 判据 / 摘要)与 I/O(ps / kill)分开,前者单测钉死;ps 与 kill 都可注入。
21
+
22
+ const { execFile } = require('node:child_process')
23
+ const { redact } = require('./core/redact') // 出站脱敏:命令行里可能有 agent 写进去的密钥
24
+
25
+ const IS_WIN = process.platform === 'win32'
26
+ const SNAPSHOT_RE = /shell-snapshots[\\/]snapshot-/
27
+ const DEFAULT_MIN_AGE_SEC = 60 * 60
28
+ const PS_ARGS = ['-axo', 'pid=,ppid=,etime=,command=']
29
+
30
+ // ps 的 etime 形态:`ss`?不会;`mm:ss` / `hh:mm:ss` / `dd-hh:mm:ss`。解析不了返回 null(别当 0,
31
+ // 0 会让「存活多久」这条判据静默失效 —— 所有行都当刚起的,永远报不出来)。
32
+ function parseEtime(s) {
33
+ const m = String(s || '').trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d{1,2}):(\d{2})$/)
34
+ if (!m) return null
35
+ const [, d, h, mi, se] = m
36
+ return (Number(d || 0) * 86400) + (Number(h || 0) * 3600) + (Number(mi) * 60) + Number(se)
37
+ }
38
+
39
+ // `ps -axo pid=,ppid=,etime=,command=` 的输出 → 行记录。command 可能含任意空白,只按前三列切。
40
+ function parsePs(text) {
41
+ const rows = []
42
+ for (const line of String(text || '').split('\n')) {
43
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/)
44
+ if (!m) continue
45
+ rows.push({ pid: Number(m[1]), ppid: Number(m[2]), ageSec: parseEtime(m[3]), command: m[4] })
46
+ }
47
+ return rows
48
+ }
49
+
50
+ function isLeftover(row, minAgeSec = DEFAULT_MIN_AGE_SEC) {
51
+ if (!row || row.ppid !== 1) return false
52
+ if (!SNAPSHOT_RE.test(String(row.command || ''))) return false
53
+ return row.ageSec != null && row.ageSec >= minAgeSec
54
+ }
55
+
56
+ // 把 Claude Code 那段 shell 前导(source 快照 && setopt … && { unalias } && eval '<命令>' < /dev/null
57
+ // && pwd -P >| /tmp/claude-xxxx-cwd)剥掉,只留 agent 真正写的那条命令 —— 控制台 / doctor 要给人看的
58
+ // 是「它在干什么」,不是机器路径(零工具名:快照路径、/tmp 落点都是实现细节)。剥不干净就退回
59
+ // 粗剪:去掉快照前导之前的部分;再不行原样截断。
60
+ function summarizeCommand(cmd, max = 100) {
61
+ let s = String(cmd || '')
62
+ const ev = s.match(/\beval '([\s\S]*)' < \/dev\/null/)
63
+ // eval 单引号串里嵌的单引号有两种写法:'\'' 与 '"'"',都还原成 '
64
+ if (ev) s = ev[1].replace(/'\\''/g, "'").replace(/'"'"'/g, "'")
65
+ else {
66
+ const i = s.search(SNAPSHOT_RE)
67
+ if (i >= 0) {
68
+ // 快照之后的 `… || true && ` 链是前导;取最后一个 `&& ` 之后、`< /dev/null` 之前的段
69
+ const tail = s.slice(i).replace(/ < \/dev\/null[\s\S]*$/, '')
70
+ const parts = tail.split(/ \|\| true && /)
71
+ s = parts[parts.length - 1].replace(/^\{[^}]*\}\s*&&\s*/, '')
72
+ }
73
+ }
74
+ s = s.replace(/\s+/g, ' ').trim()
75
+ return s.length > max ? s.slice(0, max - 1) + '…' : s
76
+ }
77
+
78
+ function describeAge(sec) {
79
+ if (sec == null) return '?'
80
+ if (sec >= 86400) return `${Math.floor(sec / 86400)} 天`
81
+ if (sec >= 3600) return `${Math.floor(sec / 3600)} 小时`
82
+ return `${Math.max(1, Math.floor(sec / 60))} 分钟`
83
+ }
84
+
85
+ function runPs() {
86
+ return new Promise((resolve, reject) => {
87
+ execFile('ps', PS_ARGS, { maxBuffer: 16 * 1024 * 1024 }, (err, out) => (err ? reject(err) : resolve(out)))
88
+ })
89
+ }
90
+
91
+ // 列出遗留进程。{ supported, rows:[{pid, ageSec, age, command, summary}], error? }
92
+ // opts.ps 注入(测试);opts.minAgeSec 判据;opts.isWin 注入平台(测试)。
93
+ async function list(opts = {}) {
94
+ const isWin = opts.isWin == null ? IS_WIN : !!opts.isWin
95
+ if (isWin) return { supported: false, rows: [] }
96
+ let out
97
+ try { out = await (opts.ps || runPs)() } catch (e) { return { supported: true, rows: [], error: (e && e.message) || String(e) } }
98
+ // command / summary 都过一遍出站脱敏:agent 写的后台循环可能带 `-H "Authorization: Bearer …"`,
99
+ // 这两个字段会进 doctor stdout / 控制台 / 审计账本
100
+ const rows = parsePs(out).filter((r) => isLeftover(r, opts.minAgeSec)).map((r) => ({
101
+ pid: r.pid, ageSec: r.ageSec, age: describeAge(r.ageSec), command: redact(r.command), summary: redact(summarizeCommand(r.command)),
102
+ })).sort((a, b) => b.ageSec - a.ageSec)
103
+ return { supported: true, rows }
104
+ }
105
+
106
+ // 带 TTL 的列表:控制台每次 /api/state 都会问,ps 一次几十毫秒,30 秒内复用。
107
+ let CACHE = { at: 0, val: null }
108
+ async function cachedList(opts = {}) {
109
+ const ttl = opts.ttlMs == null ? 30_000 : opts.ttlMs
110
+ const now = (opts.now || Date.now)()
111
+ if (CACHE.val && now - CACHE.at < ttl) return CACHE.val
112
+ const val = await list(opts)
113
+ CACHE = { at: now, val }
114
+ return val
115
+ }
116
+ function clearCache() { CACHE = { at: 0, val: null } }
117
+
118
+ // pid 的全部后代(按 ppid 链广度优先),用于组杀失败时兜底。
119
+ function descendants(rows, pid) {
120
+ const out = []; const seen = new Set([pid]); const q = [pid]
121
+ while (q.length) {
122
+ const p = q.shift()
123
+ for (const r of rows) if (r.ppid === p && !seen.has(r.pid)) { seen.add(r.pid); out.push(r.pid); q.push(r.pid) }
124
+ }
125
+ return out
126
+ }
127
+
128
+ // 结束一组遗留进程。三条纪律(对抗审查 10-09 逮出来的):
129
+ // ① **动手前按身份复核**:只对此刻仍满足「ppid 1 + 快照标记」的 pid 发信号(不看年龄),其余归 skipped。
130
+ // 名单是分钟级之前的快照,pid 会回绕复用 —— 最坏是新起的一轮 Bash shell 顶了这个号、它正好也是
131
+ // 组长,照单 SIGKILL -pid 会把活着的轮次整组掐掉。
132
+ // ② 先整个进程组 SIGTERM(真实 wrapper 自成一组,子 sleep / expect 一起收),宽限后 SIGKILL 兜底
133
+ // (实测 expect 不理 SIGTERM);**组杀打不到(不是组长 / 组已散)就本体 + 全部后代逐个发**,只杀外层
134
+ // zsh 会让真正在打接口的 node / curl 被 pid 1 收养、从此没标记再也看不见。
135
+ // ③ 杀完清缓存,调用方不必记得。ESRCH(已经不在)不算失败。ps / kill / sleep 可注入。
136
+ async function kill(pids, opts = {}) {
137
+ const k = opts.kill || ((pid, sig) => process.kill(pid, sig))
138
+ const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
139
+ const graceMs = opts.graceMs == null ? 1500 : opts.graceMs
140
+ const targets = [...new Set(pids.map(Number).filter((p) => Number.isInteger(p) && p > 1))]
141
+ let rows
142
+ try { rows = parsePs(await (opts.ps || runPs)()) } catch (e) {
143
+ return { killed: [], failed: targets.map((pid) => ({ pid, error: 'ps: ' + ((e && e.message) || String(e)) })), skipped: [] }
144
+ }
145
+ const byPid = new Map(rows.map((r) => [r.pid, r]))
146
+ const skipped = targets.filter((pid) => !isLeftover(byPid.get(pid), 0))
147
+ const live = targets.filter((pid) => !skipped.includes(pid))
148
+ const failed = []
149
+ const singles = new Map() // 组杀失败的 pid → 要逐个发信号的 [本体, ...后代](按动手前的 ps 快照算)
150
+ const send = (pid, sig) => {
151
+ if (!singles.has(pid)) { try { k(-pid, sig); return } catch {} singles.set(pid, [pid, ...descendants(rows, pid)]) }
152
+ for (const q of singles.get(pid)) {
153
+ try { k(q, sig) } catch (e) { if (!(e && e.code === 'ESRCH')) failed.push({ pid: q, sig, error: (e && e.message) || String(e) }) }
154
+ }
155
+ }
156
+ for (const pid of live) send(pid, 'SIGTERM')
157
+ if (live.length && graceMs > 0) await sleep(graceMs)
158
+ for (const pid of live) send(pid, 'SIGKILL')
159
+ clearCache()
160
+ const failedPids = new Set(failed.map((f) => f.pid))
161
+ return { killed: live.filter((p) => !failedPids.has(p)), failed, skipped }
162
+ }
163
+
164
+ // doctor / 控制台健康行共用的一句话。零工具名:不写命令、不写路径。
165
+ function describe(res) {
166
+ if (!res || !res.supported) return null
167
+ if (res.error) return { status: 'warn', text: `⚠ 查不了(${res.error})` }
168
+ if (!res.rows.length) return { status: 'ok', text: '无 —— 对话轮次结束后没有留下后台进程' }
169
+ const oldest = res.rows[0]
170
+ const heads = res.rows.slice(0, 3).map((r) => `${r.age}前起:${r.summary.slice(0, 60)}`).join(' · ')
171
+ return { status: 'warn', text: `⚠ ${res.rows.length} 个后台进程在对话轮次结束后仍在跑(最老 ${oldest.age}):${heads}${res.rows.length > 3 ? ' · …' : ''} —— 它们可能还在反复打接口 / 占端口,到概览里看一眼并结束` }
172
+ }
173
+
174
+ module.exports = { parseEtime, parsePs, isLeftover, summarizeCommand, describeAge, descendants, list, cachedList, clearCache, kill, describe, DEFAULT_MIN_AGE_SEC, SNAPSHOT_RE, PS_ARGS }