@mortiseai/stem 0.0.17 → 0.0.18

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 CHANGED
@@ -674,6 +674,63 @@ mstem-storage/ # 用户空间根(STEM_STORAGE_DIR)
674
674
 
675
675
  ---
676
676
 
677
+ ## Windows 使用说明
678
+
679
+ macOS / Linux 用户可以跳过本节。以下是 Windows(尤其**中文系统 + 独立
680
+ PowerShell / cmd 窗口**,即 conhost 而非 Windows Terminal)上遇到过的问题与开关。
681
+
682
+ ### 画面串行错乱(欢迎横幅 / 思考中 / 状态栏挤在同一行互相覆盖)
683
+
684
+ 原因是「东亚歧义宽度」字符 —— `● · ▁ │ ─ … → — ↑ ◇` 这些状态栏和横幅的主力符号,
685
+ Unicode 规定"上下文不明时按 1 格算"(xterm / iTerm / Windows Terminal 都照办),
686
+ 但**中文 Windows 的 conhost 按控制台字体的全角字形画成 2 格**。渲染器是单元格模型,
687
+ 模型算的宽度和终端实际推进的列数一旦对不上,光标就逐列漂移。
688
+
689
+ 现在会自动判定(裸 conhost + CJK 代码页 → 按 2 格),启动后还会用 DECXCPR 实测一次
690
+ 校准。判定结果记在会话日志的 `[termProfile]` / `[termProfile:probe]` 里。
691
+
692
+ 判错了可以手动压:
693
+
694
+ | 变量 | 作用 |
695
+ | --- | --- |
696
+ | `STEM_AMBIGUOUS_WIDTH=wide\|narrow` | 强制歧义宽度按 2 格 / 1 格算,优先级高于自动判定与探针 |
697
+ | `STEM_CODE_PAGE=936` | 覆盖代码页探测(否则跑 `chcp` 取) |
698
+ | `STEM_COLUMNS` / `STEM_ROWS` | 覆盖终端列/行数,见下 |
699
+
700
+ ### 画面右侧像被截断 / 窗口底部出现水平滚动条
701
+
702
+ conhost 的**屏幕缓冲区**可以比可见**窗口**宽,而 Node 报告的列数取的是缓冲区宽度
703
+ (libuv 用 `dwSize.X`),于是 UI 会往你看不见的列里画。两种解法:
704
+
705
+ 1. PowerShell 窗口 → 右键标题栏 → 属性 → 布局 → 把「屏幕缓冲区大小」的宽度改成与
706
+ 「窗口大小」宽度一致(推荐,一劳永逸);
707
+ 2. 或用 `STEM_COLUMNS=<实际可见列数>` 压住。
708
+
709
+ > 刻意**不认** `COLUMNS` / `LINES`:Git Bash / MSYS 导出的是启动时的静态值,窗口
710
+ > 放大后不更新,拿它当真相源本身就是个 bug。
711
+
712
+ ### 中文变成 `���`
713
+
714
+ 工具输出(PowerShell / Bash)和 MCP server 的 stderr 现在都按正确编码处理:能控制的
715
+ 子进程强制它输出 UTF-8(PowerShell 注入 `[Console]::OutputEncoding`,cmd 前缀
716
+ `chcp 65001`),控制不了的(MCP server)按 OEM 代码页解码。
717
+
718
+ 如果仍有乱码,把 `[termProfile]` 那行里的 `codePage` / `oemEncoding` 一并反馈。
719
+
720
+ ### MCP server 起不来
721
+
722
+ Windows 上命令解析失败时**不会**报 ENOENT —— cross-spawn 会把它交给 `cmd.exe`,
723
+ 于是你只会看到一句本地化的 "不是内部或外部命令"(还是 OEM 编码的)。现在会在
724
+ spawn 前先按 `PATHEXT` 沿 `PATH` 解析一遍,解析不到直接给 `command not found on
725
+ PATH: "uvx"`。常见修法:装上对应工具,或把 `command` 写成 `npx.cmd` 这样带后缀的形式。
726
+
727
+ ### 报障时请带上
728
+
729
+ - `logs/<sessionId>/*.txt`(含 `[termProfile]`,一眼能看出终端画像)
730
+ - 复现时加 `STEM_DEBUG_REPAINTS=1`,日志里会多出全屏重画的归因
731
+ - 画面问题可加 `STEM_TTY_RECORD=<路径>`,把原始终端字节流录下来一并发回 —— 我们能在
732
+ 本地虚拟终端里按不同口径回放,把"猜"变成"量"
733
+
677
734
  ## 技术栈
678
735
 
679
736
  | 维度 | 选择 |
@@ -1,7 +1,7 @@
1
1
  // supervisor 逻辑库(2026-08-12 OOM 治理)— bin/stem.mjs 的看护实现。
2
2
  // 独立成库的唯一理由:入口脚本 import 即执行,smoke 无法直接测;这里的函数
3
3
  // 全部纯逻辑/可注入,scripts/smoke-supervisor.ts 逐项锁语义。
4
- import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
4
+ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
5
5
  import { dirname, join } from 'node:path'
6
6
  import { homedir, tmpdir } from 'node:os'
7
7
  import { spawn, spawnSync } from 'node:child_process'
@@ -95,15 +95,14 @@ export function injectRestartPrompt(queuePath, isZh, crashInfo) {
95
95
  } catch { return false }
96
96
  }
97
97
 
98
- /** 崩溃标记 sidecar(2026-08-13 事故:崩溃现场零证据,连退出信号都没记)。
99
- * queue sidecar 同目录,`<sessionId>.crash.json`;crashes 数组保最近 20 条,
100
- * 下次排查直接看这里而不用翻 supervisor stderr。写失败静默(标记是诊断增强,
101
- * 不能反过来阻断重启) */
102
- export function writeCrashMarker(queuePath, sessionId, crashInfo, nowMs, extras = {}) {
103
- if (typeof queuePath !== 'string' || !queuePath) return false
104
- const markerPath = queuePath.endsWith('.queue.json')
105
- ? queuePath.slice(0, -'.queue.json'.length) + '.crash.json'
106
- : join(dirname(queuePath), `${sessionId || 'session'}.crash.json`)
98
+ /** 崩溃标记(2026-08-13 事故:崩溃现场零证据,连退出信号都没记)。
99
+ * `logs/<sessionId>/crash.json`(2026-08-18 起与该会话的 supervisor.log /
100
+ * debug 日志 / 堆快照同目录归档;此前散在 queue sidecar 目录难找易丢)。
101
+ * crashes 数组保最近 20 条,下次排查直接看这里而不用翻 supervisor stderr
102
+ * 写失败静默(标记是诊断增强,不能反过来阻断重启) */
103
+ export function writeCrashMarker(markerDir, sessionId, crashInfo, nowMs, extras = {}) {
104
+ if (typeof markerDir !== 'string' || !markerDir) return false
105
+ const markerPath = join(markerDir, 'crash.json')
107
106
  let crashes = []
108
107
  try {
109
108
  const parsed = JSON.parse(readFileSync(markerPath, 'utf8'))
@@ -145,17 +144,97 @@ export function stripPermissionModeFlag(argv) {
145
144
  return out
146
145
  }
147
146
 
148
- /** 崩溃堆快照清理:只保留最新一份 Heap.*.heapsnapshot( 24h 连跑占满磁盘)。 */
149
- export function cleanupHeapSnapshots(dir) {
147
+ /** argv 剥离 --model(重启时以通告里的实时会话模型为准 会话内 /model 切过的
148
+ * 模型不在启动 argv 里,原样透传会把模型打回启动值/llms.json default,2026-08-18
149
+ * 事故实证:/model 切到 gpt-5.6-sol 崩溃后自动续跑那一轮换回了兜底模型)。 */
150
+ export function stripModelFlag(argv) {
151
+ const out = []
152
+ for (let i = 0; i < argv.length; i++) {
153
+ const a = argv[i]
154
+ if (a === '--model') {
155
+ if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) i++
156
+ continue
157
+ }
158
+ if (a.startsWith('--model=')) continue
159
+ out.push(a)
160
+ }
161
+ return out
162
+ }
163
+
164
+ /** 崩溃堆快照清理:跨给定目录扫描 Heap.*.heapsnapshot,按 mtime 只保最新
165
+ * keep 份(快照 GB 级,防 24h 连跑占满磁盘)。2026-08-18 起快照按会话归档进
166
+ * logs/<sessionId>/,清理从"cwd 内只留 1 份"改为"cwd + logs/ + 各会话目录
167
+ * 全局保最新 3 份"— 老会话的取证快照不再被下一次崩溃立即冲掉。 */
168
+ export function cleanupHeapSnapshots(dirs, keep = 3) {
169
+ const found = []
170
+ for (const dir of Array.isArray(dirs) ? dirs : [dirs]) {
171
+ let entries
172
+ try { entries = readdirSync(dir).filter(f => /^Heap\..*\.heapsnapshot$/.test(f)) } catch { continue }
173
+ for (const f of entries) {
174
+ const p = join(dir, f)
175
+ try { found.push({ p, t: statSync(p).mtimeMs }) } catch { found.push({ p, t: 0 }) }
176
+ }
177
+ }
178
+ if (found.length <= keep) return
179
+ found.sort((a, b) => b.t - a.t)
180
+ for (const { p } of found.slice(keep)) {
181
+ try { rmSync(p, { force: true }) } catch { /* 占用中等失败静默 */ }
182
+ }
183
+ }
184
+
185
+ /** 遗留平铺会话日志归位:分会话目录改造(2026-08-18)前,debug 日志平铺在
186
+ * logs/ 顶层(`<YYYY-MM-DD_HH-MM>_<sessionId>.txt`)。按文件名内嵌的会话 id
187
+ * 搬进 logs/<sessionId>/ —— 会话目录必须是该会话日志全集(提 bug 只拷一个
188
+ * 目录)。supervisor.log / 无关文件不动;搬失败(占用中)留待下次。 */
189
+ export function relocateLegacySessionLogs(logsDir) {
150
190
  let entries
151
- try { entries = readdirSync(dir).filter(f => /^Heap\..*\.heapsnapshot$/.test(f)) } catch { return }
152
- if (entries.length <= 1) return
153
- const withTime = entries.map(f => {
154
- try { return { f, t: statSync(join(dir, f)).mtimeMs } } catch { return { f, t: 0 } }
155
- }).sort((a, b) => b.t - a.t)
156
- for (const { f } of withTime.slice(1)) {
157
- try { rmSync(join(dir, f), { force: true }) } catch { /* 占用中等失败静默 */ }
191
+ try { entries = readdirSync(logsDir, { withFileTypes: true }) } catch { return [] }
192
+ const moved = []
193
+ for (const e of entries) {
194
+ if (!e.isFile()) continue
195
+ const m = /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}_([A-Za-z0-9-]+)\.txt$/.exec(e.name)
196
+ if (!m) continue
197
+ try {
198
+ const dest = join(logsDir, m[1])
199
+ mkdirSync(dest, { recursive: true })
200
+ renameSync(join(logsDir, e.name), join(dest, e.name))
201
+ moved.push(join(dest, e.name))
202
+ } catch { /* 占用中/权限失败,下次启动再收 */ }
158
203
  }
204
+ return moved
205
+ }
206
+
207
+ /** 堆快照可能出现的目录:cwd(V8 默认 / --diagnostic-dir 建目录失败回落)、
208
+ * logs/(--diagnostic-dir 落点)、logs/ 下各会话归档目录。 */
209
+ export function heapSnapshotDirs(cwd) {
210
+ const logsDir = join(cwd, 'logs')
211
+ const dirs = [cwd, logsDir]
212
+ try {
213
+ for (const e of readdirSync(logsDir, { withFileTypes: true })) {
214
+ if (e.isDirectory()) dirs.push(join(logsDir, e.name))
215
+ }
216
+ } catch { /* logs/ 不存在 */ }
217
+ return dirs
218
+ }
219
+
220
+ /** 崩溃堆快照按会话归档:把散落在 fromDirs(cwd / logs/ 顶层)的
221
+ * Heap.*.heapsnapshot 搬进 logs/<sessionId>/,与该会话的 crash.json /
222
+ * supervisor.log 放一起。搬失败(占用中/跨设备)留在原地由清理兜底。 */
223
+ export function relocateHeapSnapshots(fromDirs, destDir) {
224
+ const moved = []
225
+ for (const dir of fromDirs) {
226
+ if (dir === destDir) continue
227
+ let entries
228
+ try { entries = readdirSync(dir).filter(f => /^Heap\..*\.heapsnapshot$/.test(f)) } catch { continue }
229
+ for (const f of entries) {
230
+ try {
231
+ mkdirSync(destDir, { recursive: true })
232
+ renameSync(join(dir, f), join(destDir, f))
233
+ moved.push(join(destDir, f))
234
+ } catch { /* 留在原地,cleanupHeapSnapshots 兜底 */ }
235
+ }
236
+ }
237
+ return moved
159
238
  }
160
239
 
161
240
  /** 崩溃现场的终端复位:OOM abort 不走 ink 的退出钩子,raw mode / alt screen /
@@ -204,11 +283,9 @@ export function findDiagnosticReport(sinceMs, dir) {
204
283
 
205
284
  /** 把 .ips 报告路径回填到 crash.json 最新一条崩溃条目(报告由 ReportCrash 异步
206
285
  * 生成,writeCrashMarker 时通常还不存在,退避 sleep 后二次回填)。失败静默。 */
207
- export function attachDiagnosticReport(queuePath, sessionId, reportPath) {
208
- if (typeof queuePath !== 'string' || !queuePath || !reportPath) return false
209
- const markerPath = queuePath.endsWith('.queue.json')
210
- ? queuePath.slice(0, -'.queue.json'.length) + '.crash.json'
211
- : join(dirname(queuePath), `${sessionId || 'session'}.crash.json`)
286
+ export function attachDiagnosticReport(markerDir, reportPath) {
287
+ if (typeof markerDir !== 'string' || !markerDir || !reportPath) return false
288
+ const markerPath = join(markerDir, 'crash.json')
212
289
  try {
213
290
  const parsed = JSON.parse(readFileSync(markerPath, 'utf8'))
214
291
  if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.crashes) || parsed.crashes.length === 0) return false
@@ -264,7 +341,10 @@ export async function runSupervisor(distEntry, argv, options = {}) {
264
341
  cwd = process.cwd(),
265
342
  announceFile = join(mkdtempSync(join(tmpdir(), 'stem-supervisor-')), 'session.json'),
266
343
  // supervisor 自身诊断的持久化落点(2026-08-17 事故:崩溃信息只写终端,滚屏即丢)。
267
- supervisorLogPath = join(cwd, 'logs', 'supervisor.log'),
344
+ // 缺省 null = 动态解析:会话已知 logs/<sessionId>/supervisor.log,
345
+ // 未知(死在首次通告前且无 --resume)→ logs/supervisor.log 顶层兜底。
346
+ // 显式传入(smoke 注入)则固定用该路径。
347
+ supervisorLogPath = null,
268
348
  // macOS ReportCrash 报告目录(smoke 可注入假目录)。
269
349
  diagnosticReportsDir = join(homedir(), 'Library', 'Logs', 'DiagnosticReports'),
270
350
  resetTerminalFn = resetTerminal,
@@ -280,11 +360,18 @@ export async function runSupervisor(distEntry, argv, options = {}) {
280
360
 
281
361
  // log() 只写终端;logLine() 同时 append 到 supervisor.log(ISO 时间戳)—
282
362
  // 崩溃路径一律用 logLine,子进程日志闸门(STEM_DEBUG)关着时这里仍有据可查。
363
+ // 路径每次写时解析:resumeId 随通告更新,崩溃日志落到崩溃会话自己的目录。
364
+ const supervisorLogFile = () => {
365
+ if (supervisorLogPath) return supervisorLogPath
366
+ return resumeId
367
+ ? join(cwd, 'logs', resumeId, 'supervisor.log')
368
+ : join(cwd, 'logs', 'supervisor.log')
369
+ }
283
370
  const logFileOnly = text => {
284
- if (!supervisorLogPath) return
371
+ const logPath = supervisorLogFile()
285
372
  try {
286
- mkdirSync(dirname(supervisorLogPath), { recursive: true })
287
- appendFileSync(supervisorLogPath, `${new Date(now()).toISOString()} ${text}\n`)
373
+ mkdirSync(dirname(logPath), { recursive: true })
374
+ appendFileSync(logPath, `${new Date(now()).toISOString()} ${text}\n`)
288
375
  } catch { /* 诊断落盘失败不阻断看护 */ }
289
376
  }
290
377
  const logLine = text => {
@@ -301,6 +388,31 @@ export async function runSupervisor(distEntry, argv, options = {}) {
301
388
  // 崩溃前子进程通告的实时 permission mode(Shift+Tab 切换会更新通告)—
302
389
  // 重启时经 --permission-mode 复原,argv 中的启动值被剥离让位。
303
390
  let permissionModeFromAnnounce = null
391
+ // 崩溃前子进程通告的实时会话模型(--model 启动参数 / 会话内 /model 同一个槽)—
392
+ // null = 子进程从未通告过(死在首次 announce 前)→ 启动 argv 原样透传;
393
+ // '' = 通告了"无覆盖"→ 剥掉启动 argv 里的 --model,回落 llms.json default。
394
+ let modelFromAnnounce = null
395
+
396
+ // 会话生命周期行(2026-08-18 用户要求:健康会话的目录里也要有 supervisor.log,
397
+ // 不能"没崩就空无一物")。会话身份来自通告文件:子进程在每个会话切换点写,
398
+ // supervisor 此前只在崩溃后读 → 这里加轻量轮询(2s,unref)+ 退出时兜底检查,
399
+ // 每个会话(含 /clear 切出的)首次现身即在自己目录写"已启动"行;只写文件
400
+ // 不写终端(TUI 占屏)。同 sid 重复通告/崩溃重启不重复写。
401
+ let announcedSid = null
402
+ let currentPid = null
403
+ const checkAnnounceForLifecycle = () => {
404
+ let parsed = null
405
+ try {
406
+ const p = JSON.parse(readFileSync(announceFile, 'utf8'))
407
+ if (p && p.v === 1 && typeof p.sessionId === 'string') parsed = p
408
+ } catch { return }
409
+ if (!parsed || parsed.sessionId === announcedSid) return
410
+ announcedSid = parsed.sessionId
411
+ resumeId = parsed.sessionId
412
+ logFileOnly(isZh
413
+ ? `stem: 会话已启动(pid ${currentPid ?? '-'})`
414
+ : `stem: session started (pid ${currentPid ?? '-'})`)
415
+ }
304
416
 
305
417
  if (installSignalHandlers) {
306
418
  // Windows CTRL_C_EVENT 广播父子都收:父进程吞掉,让子进程自己处理(单击中断 /
@@ -310,10 +422,16 @@ export async function runSupervisor(distEntry, argv, options = {}) {
310
422
  }
311
423
 
312
424
  for (;;) {
313
- cleanupHeapSnapshots(cwd)
314
- const effectiveBase = permissionModeFromAnnounce ? stripPermissionModeFlag(baseArgs) : baseArgs
425
+ // --diagnostic-dir=logs/ 的落点必须存在:目录缺失时 V8 写快照直接原生断言
426
+ // 崩溃(实测 node 25)。用户中途删掉 logs/ 也要能扛,每轮 spawn 前补建。
427
+ try { mkdirSync(join(cwd, 'logs'), { recursive: true }) } catch { /* 建不了则回落 cwd 默认 */ }
428
+ relocateLegacySessionLogs(join(cwd, 'logs'))
429
+ cleanupHeapSnapshots(heapSnapshotDirs(cwd))
430
+ let effectiveBase = permissionModeFromAnnounce ? stripPermissionModeFlag(baseArgs) : baseArgs
431
+ if (modelFromAnnounce !== null) effectiveBase = stripModelFlag(effectiveBase)
315
432
  const args = [...nodeFlags, distEntry, ...effectiveBase]
316
433
  if (permissionModeFromAnnounce) args.push('--permission-mode', permissionModeFromAnnounce)
434
+ if (modelFromAnnounce) args.push('--model', modelFromAnnounce)
317
435
  if (resumeId) args.push('--resume', resumeId)
318
436
  const child = spawnChild(args, {
319
437
  ...process.env,
@@ -321,22 +439,53 @@ export async function runSupervisor(distEntry, argv, options = {}) {
321
439
  STEM_SESSION_ANNOUNCE_FILE: announceFile,
322
440
  })
323
441
  childAlive = true
442
+ currentPid = child.pid ?? null
443
+ const lifecycleTimer = setInterval(checkAnnounceForLifecycle, 2000)
444
+ lifecycleTimer.unref?.()
324
445
  const { code, signal } = await child.exited
446
+ clearInterval(lifecycleTimer)
325
447
  childAlive = false
448
+ // 兜底:极快退出 / smoke 假子进程 / 轮询没赶上,也不漏启动行。
449
+ checkAnnounceForLifecycle()
326
450
 
327
- if (code === 0) return 0
451
+ if (code === 0) {
452
+ logFileOnly(isZh ? 'stem: 会话正常退出(code=0)' : 'stem: session exited normally (code=0)')
453
+ return 0
454
+ }
328
455
  if (code === EXIT_CODE_CONFIG) {
329
- log(isZh
456
+ logLine(isZh
330
457
  ? 'stem: 配置错误退出(code 78),看护不重启 — 请修正配置后重新运行。'
331
458
  : 'stem: exited with a configuration error (code 78); supervisor will not restart. Fix the configuration and run again.')
332
459
  return EXIT_CODE_CONFIG
333
460
  }
334
461
 
335
- // 崩溃路径。60 秒窗口内连崩 3 次 → 放弃(配置外的持续性故障,重启无益)。
462
+ // 崩溃路径。先读会话通告拿会话身份 所有崩溃落盘(supervisor.log /
463
+ // crash.json / 堆快照)都按 logs/<sessionId>/ 分目录归档,身份必须先到手;
464
+ // 子进程死在通告前 → 沿用现有 resumeId(可能为 null → 落 logs/ 顶层兜底)。
336
465
  const t = now()
337
466
  crashTimes = crashTimes.filter(x => t - x < CRASH_WINDOW_MS)
338
467
  crashTimes.push(t)
339
468
  resetTerminalFn()
469
+ let announce = null
470
+ try {
471
+ const parsed = JSON.parse(readFileSync(announceFile, 'utf8'))
472
+ if (parsed && parsed.v === 1 && typeof parsed.sessionId === 'string') announce = parsed
473
+ } catch { /* ignore */ }
474
+ if (announce) {
475
+ resumeId = announce.sessionId
476
+ if (typeof announce.permissionMode === 'string' && announce.permissionMode) {
477
+ permissionModeFromAnnounce = announce.permissionMode
478
+ }
479
+ // 模型:空串是有效状态(无覆盖),所以只判类型不判真值 — 判真值会让
480
+ // /model default 之后的重启把启动 argv 的旧 --model 又带回来。
481
+ if (typeof announce.model === 'string') modelFromAnnounce = announce.model
482
+ }
483
+ // 本次崩溃的会话归档目录(crash.json / 堆快照 / supervisor.log 共用)。
484
+ const sessionDir = resumeId ? join(cwd, 'logs', resumeId) : null
485
+ // 堆快照按会话归档:V8 落在 --diagnostic-dir(logs/)或 cwd(旧默认 /
486
+ // 建目录失败回落),搬进崩溃会话自己的目录,免得被后续清理当无主快照收走。
487
+ if (sessionDir) relocateHeapSnapshots([cwd, join(cwd, 'logs')], sessionDir)
488
+
340
489
  // stderr 尾部取证:pipe 捕获的原生崩溃转储(FATAL ERROR / abort 栈)。
341
490
  // crash.json 里截 8KB;supervisor.log 无条件记一份 — 即便子进程死在首次
342
491
  // announce 前(crash.json 写不了),这里也有据。
@@ -347,48 +496,44 @@ export async function runSupervisor(distEntry, argv, options = {}) {
347
496
  ? `stem: 崩溃 stderr 尾部(code=${code ?? '-'}, signal=${signal ?? '-'}):\n${stderrTail8k}`
348
497
  : `stem: crash stderr tail (code=${code ?? '-'}, signal=${signal ?? '-'}):\n${stderrTail8k}`)
349
498
  }
350
- if (crashTimes.length >= CRASH_GIVE_UP_COUNT) {
351
- logLine(isZh
352
- ? `stem: 60 秒内连续崩溃 ${crashTimes.length} 次(code=${code ?? '-'}, signal=${signal ?? '-'}),停止自动重启。`
353
- : `stem: crashed ${crashTimes.length} times within 60s (code=${code ?? '-'}, signal=${signal ?? '-'}); giving up on auto-restart.`)
354
- return typeof code === 'number' && code !== 0 ? code : 1
355
- }
356
499
 
357
- // 会话通告(子进程在每个会话切换点写):拿到真实 sessionId sidecar 路径,
358
- // 修补崩溃截断的 JSONL 尾部 + 注入续跑队列。子进程死在通告前 → 沿用现有 resumeId
359
- let announce = null
360
- try {
361
- const parsed = JSON.parse(readFileSync(announceFile, 'utf8'))
362
- if (parsed && parsed.v === 1 && typeof parsed.sessionId === 'string') announce = parsed
363
- } catch { /* ignore */ }
500
+ // JSONL 修补 + 崩溃标记在放弃判定之前:第 3 崩(将放弃)恰是最需要取证的
501
+ // 一次;修补后用户手动 --resume 也不会被截断半行拒载(旧实现两者都缺)
364
502
  if (announce) {
365
- resumeId = announce.sessionId
366
- if (typeof announce.permissionMode === 'string' && announce.permissionMode) {
367
- permissionModeFromAnnounce = announce.permissionMode
368
- }
369
503
  for (const p of Array.isArray(announce.jsonlPaths) ? announce.jsonlPaths : []) {
370
504
  if (typeof p === 'string' && existsSync(p) && repairJsonlTail(p)) {
371
505
  logLine(isZh ? `stem: 已修补崩溃截断的会话记录 ${p}` : `stem: repaired crash-truncated session log ${p}`)
372
506
  break
373
507
  }
374
508
  }
375
- if (typeof announce.queuePath === 'string') {
376
- // .tasks.json / .taskstate.json 的 mtime = 子进程最后一次任务落盘时刻,
377
- // 用于回答"崩溃前它空闲了多久"(本次事故:空闲 8.5 分钟零遥测)。
378
- const sidecarMtime = suffix => {
379
- try { return statSync(announce.queuePath.replace(/\.queue\.json$/, suffix)).mtimeMs } catch { return undefined }
380
- }
381
- writeCrashMarker(announce.queuePath, announce.sessionId, { code, signal }, t, {
382
- stderrTail: stderrTail8k,
383
- args,
384
- nodeVersion: process.version,
385
- stemVersion,
386
- announceUpdatedAtMs: typeof announce.updatedAtMs === 'number' ? announce.updatedAtMs : undefined,
387
- tasksSnapshotMtimeMs: sidecarMtime('.tasks.json'),
388
- taskStateMtimeMs: sidecarMtime('.taskstate.json'),
389
- })
390
- injectRestartPrompt(announce.queuePath, isZh, { code, signal })
509
+ // .tasks.json / .taskstate.json mtime = 子进程最后一次任务落盘时刻,
510
+ // 用于回答"崩溃前它空闲了多久"(本次事故:空闲 8.5 分钟零遥测)。
511
+ const sidecarMtime = suffix => {
512
+ if (typeof announce.queuePath !== 'string') return undefined
513
+ try { return statSync(announce.queuePath.replace(/\.queue\.json$/, suffix)).mtimeMs } catch { return undefined }
391
514
  }
515
+ writeCrashMarker(sessionDir, announce.sessionId, { code, signal }, t, {
516
+ stderrTail: stderrTail8k,
517
+ args,
518
+ nodeVersion: process.version,
519
+ stemVersion,
520
+ announceUpdatedAtMs: typeof announce.updatedAtMs === 'number' ? announce.updatedAtMs : undefined,
521
+ tasksSnapshotMtimeMs: sidecarMtime('.tasks.json'),
522
+ taskStateMtimeMs: sidecarMtime('.taskstate.json'),
523
+ })
524
+ }
525
+
526
+ if (crashTimes.length >= CRASH_GIVE_UP_COUNT) {
527
+ logLine(isZh
528
+ ? `stem: 60 秒内连续崩溃 ${crashTimes.length} 次(code=${code ?? '-'}, signal=${signal ?? '-'}),停止自动重启。`
529
+ : `stem: crashed ${crashTimes.length} times within 60s (code=${code ?? '-'}, signal=${signal ?? '-'}); giving up on auto-restart.`)
530
+ return typeof code === 'number' && code !== 0 ? code : 1
531
+ }
532
+
533
+ // 续跑注入只在还会重启时做:放弃场景注入了反而让下次手动 resume 自动跑
534
+ // 一条"已重启"文案,与事实不符。
535
+ if (announce && typeof announce.queuePath === 'string') {
536
+ injectRestartPrompt(announce.queuePath, isZh, { code, signal })
392
537
  }
393
538
 
394
539
  onCrashCleanupPid(child.pid)
@@ -404,13 +549,12 @@ export async function runSupervisor(distEntry, argv, options = {}) {
404
549
  // 落 supervisor.log。只写文件不写终端(此时新子进程 TUI 已接管屏幕);
405
550
  // unref 不阻退出;只记路径不搬内容。
406
551
  if (process.platform === 'darwin' && signal) {
407
- const queuePathForAttach = announce && typeof announce.queuePath === 'string' ? announce.queuePath : null
408
- const sessionForAttach = announce ? announce.sessionId : null
552
+ const markerDirForAttach = announce ? sessionDir : null
409
553
  const scanTimer = setTimeout(() => {
410
554
  const report = findDiagnosticReport(t, diagnosticReportsDir)
411
555
  if (!report) return
412
556
  logFileOnly(isZh ? `stem: 原生崩溃报告 ${report}` : `stem: native crash report ${report}`)
413
- if (queuePathForAttach) attachDiagnosticReport(queuePathForAttach, sessionForAttach, report)
557
+ if (markerDirForAttach) attachDiagnosticReport(markerDirForAttach, report)
414
558
  }, 8000)
415
559
  scanTimer.unref?.()
416
560
  }
package/bin/stem.mjs CHANGED
@@ -9,9 +9,9 @@
9
9
  // 会话重启。正常退出(exit 0)与配置错误(exit 78)不重启;60 秒内连崩 3 次
10
10
  // 放弃。STEM_NO_SUPERVISOR=1 退回单次直跑;实现与语义锁定见
11
11
  // bin/stem-supervisor-lib.mjs 与 scripts/smoke-supervisor.ts。
12
- import { existsSync, realpathSync } from 'node:fs'
12
+ import { existsSync, mkdirSync, realpathSync } from 'node:fs'
13
13
  import { dirname, join } from 'node:path'
14
- import { devNull } from 'node:os'
14
+ import { devNull, totalmem } from 'node:os'
15
15
  import { fileURLToPath, pathToFileURL } from 'node:url'
16
16
  import { spawnSync } from 'node:child_process'
17
17
 
@@ -38,18 +38,47 @@ function heapConfigured() {
38
38
  )
39
39
  }
40
40
 
41
+ /**
42
+ * 堆上限按机器物理内存推导(2026-08-18 第六次事故)。
43
+ *
44
+ * 此前固定 8192 —— 在提交不上 8GB 的机器上 V8 永远够不到自己的上限:
45
+ * near-heap-limit 回调(含 --heapsnapshot-near-heap-limit)不会执行,一切
46
+ * "距上限还有多远"的判断全是空转,进程改由原生分配失败打死,而 V8 视角
47
+ * 直到最后一刻都"还健康"(事故日志:heap 1.5GB / RSS 1.8GB 时静默死亡,
48
+ * 所有守护零触发)。取物理内存 60%,夹在 [2048, 8192];读不到 totalmem
49
+ * 时退回 4096(比 8192 保守,宁可早撞 V8 上限也别让它够不着)。
50
+ */
51
+ function heapLimitMB() {
52
+ let total = 0
53
+ try { total = totalmem() } catch { total = 0 }
54
+ if (!Number.isFinite(total) || total <= 0) return 4096
55
+ return Math.max(2048, Math.min(8192, Math.floor((total / (1024 * 1024)) * 0.6)))
56
+ }
57
+
41
58
  /** 子进程 node flags:heap 上限(未显式配置时)+ OOM 前堆快照(可关)。 */
42
59
  function childNodeFlags() {
43
60
  const flags = []
44
- if (!heapConfigured()) flags.push('--max-old-space-size=8192')
61
+ if (!heapConfigured()) flags.push(`--max-old-space-size=${heapLimitMB()}`)
45
62
  // OOM 前自动堆快照:定位"未知同步分配风暴"元凶的唯一现实手段(手动
46
63
  // /heapdump 在同步爆发下来不及)。代价是崩溃时写数 GB 快照;supervisor
47
- // 每轮 spawn 前清理只留最新一份。STEM_HEAP_SNAPSHOT=0 关闭。
64
+ // 崩溃后按会话归档进 logs/<sessionId>/,每轮 spawn 前跨目录清理保最新
65
+ // 3 份。STEM_HEAP_SNAPSHOT=0 关闭。
48
66
  if (
49
67
  process.env.STEM_HEAP_SNAPSHOT !== '0' &&
50
68
  !(process.env.NODE_OPTIONS ?? '').includes('--heapsnapshot-near-heap-limit')
51
69
  ) {
52
70
  flags.push('--heapsnapshot-near-heap-limit=1')
71
+ // 快照定向到 <cwd>/logs/(V8 默认写 cwd,散落且被误当项目文件);supervisor
72
+ // 崩溃后再按会话归档进 logs/<sessionId>/。注意 --diagnostic-dir 指向的目录
73
+ // 必须已存在 —— 实测(node 25)目录缺失时 V8 写快照直接原生断言崩溃,
74
+ // 所以先建目录,建失败就不加 flag 回落 cwd 默认。
75
+ if (!(process.env.NODE_OPTIONS ?? '').includes('--diagnostic-dir')) {
76
+ try {
77
+ const diagDir = join(process.cwd(), 'logs')
78
+ mkdirSync(diagDir, { recursive: true })
79
+ flags.push(`--diagnostic-dir=${diagDir}`)
80
+ } catch { /* 建目录失败 → 维持 V8 默认(cwd),supervisor 归档兜底 */ }
81
+ }
53
82
  }
54
83
  return flags
55
84
  }
@@ -78,7 +107,7 @@ if (process.env.STEM_DEV !== '1' && existsSync(distEntry)) {
78
107
  // 把 --max-old-space-size 带给真正的 CLI 进程。
79
108
  const result = spawnSync(
80
109
  process.execPath,
81
- ['--max-old-space-size=8192', distEntry, ...argv],
110
+ [`--max-old-space-size=${heapLimitMB()}`, distEntry, ...argv],
82
111
  { stdio: 'inherit' },
83
112
  )
84
113
  process.exit(result.status ?? 1)