@mortiseai/stem 0.0.27 → 0.0.28

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
@@ -2,11 +2,14 @@
2
2
 
3
3
  > **Stem** — MortiseAI 构建的 Self-Evolving AI Agent,基于 **MSCE**(MSC Engine)的自我进化智能体
4
4
 
5
- 核心三种业务模式(详见[「业务模式」](#业务模式--agent----coding----fde)):
5
+ 四种启动模式(详见[「业务模式」](#业务模式)):
6
6
 
7
- - **Agent 模式** — 面向通用智能体形态,默认模式,当前唯一开放。启动:`stem` / `stem --agent`;核心能力:业务图谱记忆
8
- - **Coding 模式** — 面向编码工作流的形态(暂未开放)。启动:`stem --coding`;核心能力:业务图谱记忆,基于 MortiseAI Spec Code Engine 编码
9
- - **FDE 模式** — 面向企业交付场景的形态(暂未开放)。启动:`stem --fde`;核心能力:业务图谱记忆,私有图谱记忆,基于 MortiseAI Spec Code Engine 编码
7
+ - **Agent 模式** — 面向通用智能体形态,默认模式。启动:`stem` / `stem --agent`;核心能力:业务图谱记忆
8
+ - **Coding 模式** — 面向编码工作流。启动:`stem --coding`;核心能力:业务图谱记忆,基于 MortiseAI Spec Code Engine 编码
9
+ - **FDE 模式** — 面向企业交付场景。启动:`stem --fde`;核心能力:业务图谱记忆、私有图谱记忆,基于 MortiseAI Spec Code Engine 编码
10
+ - **EVO 模式** — 独立洞察 Session,固定简化 Welcome。启动或重连:`stem --evo`;显示 `Stem EVO.` 或 `Stem EVO. Code`
11
+
12
+ **EVO 进化模式**:`/stem-evo on --stem` 生成自我进化方案,`/stem-evo on --stem code` 生成方案并执行源码进化;`/stem-evo on --biz <空间名>` 生成同名业务空间的方案,`/stem-evo on --biz --use <空间名>` 将方案转为可供场景应用的 Wiki/Case/Onto 资产。在来源 CLI 选择目标和策略,`stem --evo` 打开独立视图,窗口内用 `/evolve <新要求>` 更新当前方向并同步 `evo.directions.stem/biz`。方案写入 `.mstem/platform/evo/`,业务方案按空间隔离。详见 [EVO 运行说明](docs/evo-v2-runtime.md)。
10
13
 
11
14
  ---
12
15
 
@@ -22,6 +25,22 @@ stem
22
25
  ```
23
26
  macOS / Linux / Windows 均可,仅需 Node >= 22,无需 bun。
24
27
 
28
+ 通过 OSS 配置包初始化当前项目(将 `<密码>` 替换为发布方提供的 6~128 位字母或数字,去掉尖括号):
29
+
30
+ ```text
31
+ stem init --platform --<密码>
32
+ stem init --p --<密码> # 与 --platform 等效
33
+ stem init --8ycwl8 --<密码>
34
+ ```
35
+
36
+ `--platform` 是平台参数,可简写为 `--p`(例如 `/init --p --<密码>`);其他作用域参数按租户码处理。下载版本与当前安装的 CLI 一致,分别备份并替换 `.mstem/platform/` 或 `.mstem/tenant/8ycwl8/`。客户端无需 `.env` 或 OSS 工具。密码按 MD5 → scrypt → AES-256-GCM 使用,命令行密码优先于环境密钥。密码可能留在终端历史和进程参数中;程序日志不记录密码。完整说明见 [初始化与升级](docs/stem-maintenance.md)。
37
+
38
+ 在交互界面中使用 `/init --platform --<密码>`(简写 `/init --p --<密码>`)安装平台配置,或 `/init --<租户号> --<密码>` 安装租户配置;安装成功后在仓库根目录生成基础 `STEM.md`,已有文件保留。安装和文件创建均在本地执行,不调用模型或上下文压缩。参数回显隐藏,安装成功后 CLI 自动重启并加载配置,保留当前会话,不触发模型续跑。无参数 `/init` 仅生成或完善项目指南 `STEM.md`;`/init --help` 查看两种用法。
39
+
40
+ ### 升级
41
+
42
+ 在交互界面执行 `/upgrade` 检查并升级 Stem。当前版本与最新版本相同或更高时不做变更。有新版时先升级 CLI,再询问是否更新当前平台和租户配置;选择更新后输入隐藏的配置密码。未发布的租户配置会在结果中列出并保留原配置,其余项目继续更新。完成后自动重启并恢复当前会话。详见 [初始化与升级](docs/stem-maintenance.md)。
43
+
25
44
  ### 退出
26
45
  在 Welcome 屏按 **q** · **Esc** · 或 **Ctrl+C**。
27
46
 
@@ -124,6 +143,7 @@ stem --telemetry-url ws://127.0.0.1:8123/ws/manual
124
143
  - **要改持久默认模型,就改 `llms.json` 的 `default`** —— 没有别的持久化入口
125
144
  - `/model current` 可查看当前模型与来源
126
145
  - `/fast [on|off]` 默认关闭,仅当前选中模型在 `llms.json` 声明有效 `fast_service_tier` 时可用;不带参数时切换。该字段只声明能力,不会自动开启 fast
146
+ - `/effort` 和 `/fast` 只修改当前会话,并更新未来新建会话的默认值;其他已有会话保留自己的设置。`/resume`、`--resume` 和崩溃恢复读取该会话保存的值,`/clear`、`/branch` 新建会话时使用最新默认值。旧版本未保存独立偏好的会话在首次恢复时按当时默认值初始化。
127
147
  - 能力配置按模型级 > 平台级解析:模型未配置则继承平台,模型显式 `false` 禁用。未声明能力时,包括 OpenAI 官方端点,均不默认申请 `priority`;手动输入 `/fast` 会提示不可用且不修改开关
128
148
  - OpenAI 协议请求在 fast 生效后发送配置的 `service_tier`。如果配置了 `roles.fast`,只有选中模型和目标模型均声明能力时才切换;目标无能力时保持当前模型。`/fast off` 同时撤销服务档位和模型切换
129
149
  - Footer 仅在有能力时显示 `快速模式 : 关闭` / `快速模式 : 开启`;无能力时整个片段及分隔符隐藏,补全和 `/help` 也隐藏 `/fast`。额度与 fast 均不显示时,整行隐藏
@@ -150,6 +170,8 @@ stem --telemetry-url ws://127.0.0.1:8123/ws/manual
150
170
  | `reasoning_effort_levels` | 否 | 该模型支持的推理强度数组,可用值:`none` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max` / `ultra`。`/effort` 只接受当前模型列出的档位;不写则保持原方案 `low` / `medium` / `high` / `max` |
151
171
  | `reasoning_effort` | 否 | 推理策略:`auto` 按任务和执行反馈自动分配;具体档位表示固定默认值;`provider` 关闭应用层分配、完全跟随供应商。**不写等同 `auto`** |
152
172
 
173
+ Advisor 顾问复核统一默认使用 `max / 120秒`,普通和复杂复核仅在证据包容量上区分,可通过运行项目的 `advisor.effort`、`advisor.timeoutMs` 覆盖。可选复核失败保留主任务验证状态,必需复核失败标记未完成。详见 [Advisor 复核策略](docs/advisor-review.md)。
174
+
153
175
  > `auto` 是 Stem 策略状态,不会原样发给 API。主会话会把它解析成具体档位:严格的上一轮增量调整使用低档,普通任务使用中档,规划/跨模块复杂任务使用高档;工具失败或连续空搜索会升档,修改校验通过后的收尾会降档。用户显式 `/effort <档位>` 始终优先,`/effort auto` 清除会话覆写并恢复模型策略。
154
176
  >
155
177
  > 协议投递按能力降级:OpenAI 模型显式配置 `reasoning_effort` 或 `reasoning_effort_levels` 时发送具体原生档位;Anthropic/兼容网关已配置 `thinking` 时按档位调整开关和预算;能力未知时仅注入 system prompt,不盲发未知字段。即使完全未配置而默认进入 auto,也不会因此让兼容网关收到不支持的参数。
@@ -508,22 +530,29 @@ Chrome 回归测试默认关闭。要让当前工程每次启动 Stem 后都允
508
530
  >
509
531
  > 计费:前 2000 次调用免费,之后按次计。`/bailian tools` 只列工具不计费,是验证连通性最便宜的一步。
510
532
 
511
- ### 业务模式 · `--agent` / `--coding` / `--fde`
533
+ ### 业务模式
512
534
 
513
- Stem 的三种核心业务模式:
535
+ Stem 的四种启动模式:
514
536
 
515
537
  | 模式 | 启动命令 | 状态 | 说明 | 核心能力 |
516
538
  |---|---|---|---|---|
517
539
  | Agent | `stem` / `stem --agent` | ✅ 开放(默认) | 面向通用智能体形态 | 业务图谱记忆 |
518
- | Coding | `stem --coding` | 暂未开放 | 面向编码工作流的形态 | 业务图谱记忆 · 基于 MortiseAI Spec Code Engine 编码 |
519
- | FDE | `stem --fde` | 暂未开放 | 面向企业交付场景的形态 | 业务图谱记忆 · 私有图谱记忆 · 基于 MortiseAI Spec Code Engine 编码 |
540
+ | Coding | `stem --coding` | 开放 | 面向编码工作流的形态 | 业务图谱记忆 · 基于 MortiseAI Spec Code Engine 编码 |
541
+ | FDE | `stem --fde` | 开放 | 面向企业交付场景的形态 | 业务图谱记忆 · 私有图谱记忆 · 基于 MortiseAI Spec Code Engine 编码 |
542
+ | EVO | `stem --evo` / `stem evo attach` | ✅ 开放 | 持续显示来源 Session 的进化任务 | Plan 方案;Code 源码;Use 业务资产应用 |
520
543
 
521
- 三选一布尔开关,缺省回退 env `STEM_DEF_MODE`,默认 `agent`。显式请求 `--coding` / `--fde` 会拒绝启动并提示(不做静默回退)。
544
+ 缺省读取 env `STEM_DEF_MODE`,默认 `agent`。`--evo` 与其他启动模式互斥。EVO 是独立进化视图;来源 Session 使用 `/stem-evo on --stem [code]` 或 `/stem-evo on --biz [--use] <空间名>` 选择目标与策略。
522
545
 
523
546
  ### 会话 · `--resume`
524
547
 
525
548
  `--resume <id>` / `-r <id>` / `--resume=<id>`:恢复指定会话(退出提示里有 `--resume <id>` 可直接复制),复用原会话日志文件续写。
526
549
 
550
+ 底部耗时按会话累计各轮执行时间,手动恢复和 OOM 自动重启均接续原值;退出期间和轮次之间的空闲时间不计入。执行中每秒保存计时快照,轮次结束、切换会话及正常退出时立即保存;强制终止时恢复最近一次快照。旧会话首次恢复会从已有日志补回已记录的轮次耗时。`/clear` 新建会话从零计时,`/branch` 继承分叉时的累计值。
551
+
552
+ 恢复采用严格模式:历史不存在、为空、损坏或读取失败时停止启动并记录原因,不会降级为新会话。内存保护和异常崩溃的自动重启必须取得有效会话通告;会话历史或续接队列保存失败时停止自动重启,保留原会话供排查。
553
+
554
+ 内存告警在下一次模型请求或任务完成时执行回收,无需手动输入“继续”触发。回收后余量仍不足时自动重启:中断中的任务继续执行,已完成任务只恢复会话,不再注入续跑指令;原有待执行队列保持顺序。最终回复与会话记录在退出前保存。
555
+
527
556
  ### 多租户隔离 · `--tenant`
528
557
 
529
558
  `--tenant <code>`(或 `--tenant=<code>`)以租户模式启动:**全部落盘状态**按租户
@@ -609,7 +638,9 @@ Stem 的三种核心业务模式:
609
638
  - 依赖模型能力位:`llms.json` 模型条目的 `vision` 字段。`vision: false` 的模型粘贴时直接拦截提示;粘贴后切到不支持的模型,发送前自动剥离图片并提示「已省略 N 张图片」。
610
639
  - 剪贴板读取优先用 `pngpaste`(brew 可选安装,更快),否则走系统 `osascript` — 首次调用可能弹 macOS 自动化权限框,拒绝后图片粘贴不可用(等同剪贴板无图)。
611
640
  - 超限图片(>5MB 或长边 >8000px)自动用系统 `sips` 缩放到长边 1568px。
612
- - 已知限制:`--resume` 恢复的历史会话中图片不回灌,模型只见 `[Image #N]` 字面。
641
+ - 图片在进入模型前保存到当前会话存储目录的 `<sessionId>/images/`,按 `1.png、2.jpg、3.webp…` 累计编号;重启后继续编号,相同内容复用缓存。用户附件与工具返回的图片都走这条路径。
642
+ - 内存回收只卸载图片载荷,保留可读取的文件引用;`--resume` 后会提供图片索引,模型需要时通过 `Read` 指定缓存文件恢复看图能力,不会一次性把全部图片重新载入内存。`images/index.jsonl` 保存编号、来源和文件信息;升级前未缓存的历史图片仍需重新提供。
643
+ - 图片索引元数据按会话与文件版本缓存,新增图片或索引变化时刷新;注入模型的最近图片目录限制为 4000 字符,较早图片仍可通过完整索引查找。
613
644
 
614
645
  ### 运行中继续输入:排队与直接插入
615
646
 
@@ -684,7 +715,12 @@ CLI 子进程的运行时开关统一放 settings 级联的 **`env` 段**(不再
684
715
  | `STEM_DEBUG` | `1` = 渲染 tool-use/tool-result 调试卡片 + Debug banner |
685
716
  | `STEM_ACTIVITY_MODE` | 任务过程密度:`normal`(默认,紧凑语义步骤、错误/小 diff/验证结果智能展开)\|`quiet`(只显示状态 spinner)\|`detailed`(同时预览全部工具结果);也可运行时使用 `/statusline activity quiet\|normal\|detailed`,`STEM_DEBUG=1` 仍使用原始调试卡 |
686
717
  | `STEM_API_RETRY_INTERVAL_MS` | LLM 网络错误重试间隔(默认 60000,最多 10 次) |
687
- | `STEM_API_TIMEOUT_MS` | LLM 请求全局超时(平台未配 `timeoutMs` 时生效,默认 600000) |
718
+ | `STEM_API_TIMEOUT_MS` | JSON 请求总时限及 SSE 首次有效输出时限(平台未配 `timeoutMs` 时生效,默认 180000);SSE 开始输出后由空闲时限和独立总时限管理 |
719
+ | `STEM_API_HEADERS_TIMEOUT_MS` | 等待 HTTP 响应头的时限(默认 180000,且不超过请求时限) |
720
+ | `STEM_API_FIRST_EVENT_TIMEOUT_MS` | 等待首个 SSE 数据事件的时限(默认 180000);SSE 心跳不续期,首次有效输出仍受请求时限约束 |
721
+ | `STREAM_IDLE_TIMEOUT_MS` | SSE 有效输出之间的空闲时限(默认 180000,且不超过请求时限);正文、思考、工具参数增量续期,空帧及 SSE 心跳不续期 |
722
+ | `STEM_API_RECOVERY_WINDOW_MS` | 首次可恢复失败后共享的重试恢复窗口(默认 180000);退避和后续尝试共同消耗此窗口,设为 0 禁用扩展恢复 |
723
+ | `STEM_API_STREAM_MAX_DURATION_MS` | 单次 SSE 请求的独立总时限(默认 1800000,即 30 分钟),有效输出也不能延长 |
688
724
  | `STEM_AUTO_COMPACT_TIMEOUT_MS` | 自动压缩摘要的独立硬截止(默认 60000);到时中止辅助摘要并继续主任务,不继承 LLM 的全局 10 分钟超时 |
689
725
  | `STEM_ADAPTIVE_TASK_CONTRACT_MODE` | 复杂/批量改动任务的自适应执行合同:`enforce`(默认,深度理解后硬门控并逐条绑定用户明确约束)、`observe`(同样规划但只审计不阻断)、`off`(关闭);未完成时回复独立的“继续/continue/resume”会恢复 scope、约束、步骤和原任务预算,版本化快照可随同一 workspace/session 的 `--resume` 恢复;磁盘恢复始终要求重新验收,若目标文件指纹漂移则退回深度理解并重提合同。合同验收命令失败时会在原回合提供有上限的自动修复机会并把推理升到 `xhigh`/`max`,成功重跑后自动清账,连续失败达到上限才允许收口为阻塞。运行中的 Bash 会对合同目标做前后指纹检查:发生修改即清除旧验收并要求干净重跑,后台启动的命令不会直接形成验收证据;验收还绑定 workspace 根目录、拒绝未写进 verifier 的隐藏 `cwd`/`env` 覆写,并只接受与 verifier 完整一致的 Bash 命令,需要子目录、环境变量或复合步骤时须原样写为 `cd path && ...` / `VAR=value ...`。Bash、PowerShell 与未声明只读的 MCP 工具还会经过有界 Git mutation manifest:既有脏文件在调用期间再次变化也可识别;发现合同范围外路径时不自动回滚或归因,而是冻结旧合同、升到 `max` 重新理解并要求带 `revisionReason` 重提合同。普通新请求会丢弃旧快照;旧开关 `STEM_ADAPTIVE_TASK_CONTRACT=0` 仍等价于 `off`。可运行 `bun run smoke:task-contract` 做无网络状态机检查 |
690
726
  | `STEM_PERMISSION_MODE` | 工具权限模式 `default\|allow\|plan\|bypassPermissions\|...` |
@@ -695,7 +731,7 @@ CLI 子进程的运行时开关统一放 settings 级联的 **`env` 段**(不再
695
731
  | `STEM_HUD_SHOW_USAGE` / `STEM_HUD_SHOW_WEEK` | HUD 用量行 / 本周行显示开关 |
696
732
  | `STEM_TELEMETRY` | `1` = 无头模式开启 telemetry 观测帧(`--sdk-telemetry` 的 env 形式) |
697
733
  | `STEM_TELEMETRY_WS` | telemetry WS tap 地址 |
698
- | `STEM_STORAGE_DIR` | 存储根(缺省 `<cwd>/mstem-storage`;优先于 settings `storage.dir`)。用户空间自动追加 `platform/<user>` 或 `tenant/<code>/<user>`;项目级独立作用域 `wiki/`、`plans/` 直接位于根下。Group 数据(session/causal/distill/evolve)落 STEM_DIR 项目配置根 |
734
+ | `STEM_STORAGE_DIR` | 存储根(缺省 `<cwd>/mstem-storage`;优先于 settings `storage.dir`)。用户空间自动追加 `platform/<user>` 或 `tenant/<code>/<user>`;项目级独立作用域 `wiki/`、`plans/` 直接位于根下。session 自动追加 `platform/<user或common>/session` 或 `tenant/<code>/<user或common>/session`;Group 洞察数据(causal/distill/evolve)落 STEM_DIR 项目配置根 |
699
735
  | `STEM_DIR` | 项目配置根(缺省 `<cwd>/.mstem`;取值形态与作用域追加规则同 `STEM_STORAGE_DIR`) |
700
736
  | `STEM_TENANT` | **内部通道,勿手工设置** — 只由 `--tenant` flag 写入,env 段里的值会被入口忽略 |
701
737
  | `STEM_USER` | **内部通道,勿手工设置** — 只由 `--user` flag 写入,env 段里的值会被入口忽略 |
@@ -708,10 +744,14 @@ CLI 子进程的运行时开关统一放 settings 级联的 **`env` 段**(不再
708
744
  `STEM_STORAGE_DIR` / `STEM_DIR` 的取值支持两种形态:绝对路径、相对路径
709
745
  (相对当前工程路径 cwd 解析)——**不做 `~` home 展开**,`~/...` 不会被特殊
710
746
  处理,直接按相对路径拼接。会话聊天文件默认落
711
- `<cwd>/.mstem/platform/session/<sessionId>.jsonl`(`--user` 时落用户空间
747
+ `<cwd>/mstem-storage/platform/common/session/<sessionId>.jsonl`(`--user` 时落用户空间
712
748
  `<cwd>/mstem-storage/platform/<user>/session/`);升级前写在
713
- `~/.mstem/projects/<项目>/` 或 `mstem-storage/<scope>/common/` 的历史会话
714
- 经惰性迁移仍可被 `/resume` 找到并续写原文件。
749
+ `.mstem/<scope>/session/` 或 `~/.mstem/projects/<项目>/` 的历史会话
750
+ 仍可被 `/resume` 找到并续写原文件;新会话统一写入存储根。
751
+
752
+ 关闭文件日志时,工具调用跳过输入和结果预览;开启时对预览的层级、条数及字符数设限,图片载荷不进入预览。内存日志包含 `heapTotalMB`,流式采样使用正在执行的会话;`contextTextChars`、`imageBase64Chars` 和 `estimatedImageBytes` 分别表示文本长度、图片编码长度和估算的解码大小,不等同于实际 RSS。回收日志记录这些指标的前后变化。
753
+
754
+ 目录级 `Grep content` 最多返回 24 条、约 4000 字符的匹配记录(含路径);指定单个文件时保留 80 条、16000 字符预算。截断结果会提示按已找到的文件和行号收窄或批量读取。最终回复中的校验成功声明会检查本轮已修改文件的版本:后续修改、外部改动或失败的命令会使旧证据失效,需要重跑相关检查或明确披露未重新校验;这一检查也适用于未激活 TaskContract 的轮次。
715
755
 
716
756
  ---
717
757
 
@@ -801,12 +841,11 @@ agent 侧配套工具(MCP 同样暴露为 `tool_ontology_*`):`OntologySchema`(tr
801
841
  /stem-case derive [--apply] # 待建清单 → 本体提案草案 + 页面待办;--apply 让 agent 经 OntologyPropose / WikiReview 落成正式项
802
842
  /stem-case run [--judge] [--kind k] # agent 逐题用 WikiSearch / OntologyGraph 作答 → CaseScore score(确定性打分 + 归因)→ 全部打完自动收尾
803
843
  /stem-case report [runId] [--finish] # 套件分 / 按 kind 通过率 / 归因分布 / 逐题明细 / 与上次 run 对比 / 是否已过期(页面或本体变了)
804
- /stem-case evolve <目标分> <最大轮数> # run → 修根因(补页 / 提案 / 改标注)→ 再 run,直到达标(prompt 驱动循环;不改用例凑分)
805
844
  ```
806
845
 
807
846
  三种起步顺序都支持:用例先行(init/import → derive → `/stem-onto init` + merge → `/stem-wiki import` → run)、知识先行(基于类树 + 访谈生成 → run)、交替演化(evolve)。`/stem-onto status` 与 system prompt 会显示最近分数与最弱 kind,引导 import / classify 优先补短板。agent 工具:`CaseScore`(list / get / add / score / status;MCP `tool_case_score`)。prompt 指令块可用 `.mstem/skills/case-<init|import|add|run|derive|evolve>/SKILL.md` 覆盖。
808
847
 
809
- **独立面板**:`run` / `evolve` 发起后,输入框上方出现黄金用例面板(`src/stem-ui/CasePanel.tsx`,数据源 `stem-utils/casePanelStore`):逐题 ✓/✗/spinner、每题分数条与归因、套件分、按 kind 汇总、evolve 的轮次轨迹(R1 → R2 …);Agent 每次调 `CaseScore score` 即时刷新;Ctrl+C / Esc 标记中断并冻结为快照;`/stem-case hide` 关闭。它与洞察模式(`/stem-causal` / `/stem-distill` / `/stem-evolve` 的 InsightPanel)**零耦合**——自有 store、自有组件、自有生命周期,不新增 InsightMode。
848
+ **独立面板**:`/stem-case run` 发起后显示逐题进度、分数与归因、套件分和分类汇总;`CaseScore score` 即时刷新,Ctrl+C / Esc 中断并保留快照,`/stem-case hide` 关闭。
810
849
 
811
850
  ---
812
851
 
@@ -814,8 +853,8 @@ agent 侧配套工具(MCP 同样暴露为 `tool_ontology_*`):`OntologySchema`(tr
814
853
 
815
854
  两个配置根均落在目标项目目录下,可用 env `STEM_DIR` / `STEM_STORAGE_DIR` 重定向(见「运行时开关」):
816
855
 
817
- - **`.mstem/`** — 项目配置根:配置文件 + Group 数据(session/causal/distill/evolve 等)
818
- - **`mstem-storage/`** — 存储根:承载 `--user` 用户隔离数据,以及独立作用域的 wiki/plan 项目产物;不放配置文件
856
+ - **`.mstem/`** — 项目配置根:配置文件 + Group 洞察数据(causal/distill/evolve 等)
857
+ - **`mstem-storage/`** — 存储根:承载所有作用域的 session、`--user` 用户隔离数据,以及独立作用域的 wiki/plan 项目产物;不放配置文件
819
858
 
820
859
  ```
821
860
  .mstem/ # 项目配置根(STEM_DIR)
@@ -824,7 +863,7 @@ agent 侧配套工具(MCP 同样暴露为 `tool_ontology_*`):`OntologySchema`(tr
824
863
  │ ├── settings.local.json # 个人项目级配置(应 gitignore;env 段放运行时开关)
825
864
  │ ├── llms.json # 多平台 LLM 目录(见「模型目录」节)
826
865
  │ ├── mcp.json # MCP server 配置
827
- │ └── agents/ causal/ distill/ evolve/ memory/ plugins/ session/ skills/ workflows/
866
+ │ └── agents/ causal/ distill/ evolve/ memory/ plugins/ skills/ workflows/
828
867
  └── tenant/<租户代码>/ # tenant 作用域:结构同 platform,首启自动建目录
829
868
  └── … # 但配置文件不播种,需手工放置
830
869
 
@@ -832,16 +871,16 @@ mstem-storage/ # 存储根(STEM_STORAGE_DIR)
832
871
  ├── wiki/ # 独立作用域的 Wiki(不插 platform/tenant/user)
833
872
  ├── plans/ # plan file,与 wiki 同级
834
873
  ├── platform/
835
- │ ├── common/ # 未指定 --user 时的公共用户空间
874
+ │ ├── common/ # 未指定 --user 时的公共用户空间,session/ 位于其下
836
875
  │ └── <用户ID>/ # stem --user <用户ID> 的用户空间
837
- │ └── agents/ causal/ … workflows/(九个子目录同上)
876
+ │ └── agents/ causal/ … workflows/ session/(含用户会话目录)
838
877
  └── tenant/<租户代码>/
839
- ├── common/ # 租户内公共用户空间
878
+ ├── common/ # 租户内公共用户空间,session/ 位于其下
840
879
  └── <用户ID>/ # stem --tenant <code> --user <id> 的用户空间
841
880
  ```
842
881
 
843
882
  - 四个作用域(platform / platform+user / tenant / tenant+user)两两绝对隔离,详见「多租户隔离」「用户隔离」两节
844
- - 会话聊天文件默认落 `.mstem/platform/session/<sessionId>.jsonl`;`--user` 时落 `mstem-storage/platform/<用户ID>/session/`
883
+ - 会话聊天文件默认落 `mstem-storage/platform/common/session/<sessionId>.jsonl`;`--user` 时落 `mstem-storage/platform/<用户ID>/session/`
845
884
  - settings 级联:会话覆盖 > 当前 `.mstem/<scope>/settings.local.json` > `settings.json`;不读取家目录配置。
846
885
  - 新增设置写当前项目 `settings.local.json`,已有设置写回所属项目层;取消设置会清除项目两层中的该项。
847
886
  - 骨架补齐与配置播种统一走 `./update_pro.sh`(幂等,绝不覆盖已有文件,见「空间初始化」节)
@@ -0,0 +1,20 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { parseEnv } from 'node:util'
4
+
5
+ /** Load .env without executing shell code; shell values and earlier directories take precedence. */
6
+ export function loadStemEnv(directories, env = process.env) {
7
+ if (env.STEM_SKIP_DOTENV === '1') return env
8
+ for (const directory of directories) {
9
+ const path = join(directory, '.env')
10
+ if (!existsSync(path)) continue
11
+ let values
12
+ try { values = parseEnv(readFileSync(path, 'utf8')) } catch {
13
+ throw new Error(`无法读取环境配置文件: ${path}`)
14
+ }
15
+ for (const [key, value] of Object.entries(values)) {
16
+ if (env[key] === undefined) env[key] = value
17
+ }
18
+ }
19
+ return env
20
+ }
@@ -52,6 +52,9 @@ export function shouldSupervise(argv, options = {}) {
52
52
  if (env[SUPERVISOR_OFF_ENV] === '1') return false
53
53
  if (env.STEM_SUPERVISED === '1') return false
54
54
  if (!stdoutIsTTY || !stdinIsTTY) return false
55
+ // EVO views reconnect to a durable coordinator and have no source conversation for supervisor recovery.
56
+ if (argv.includes('--evo') || argv[0] === 'evo'
57
+ || (env.STEM_DEF_MODE?.toLowerCase() === 'evo' && !argv.some(arg => ['--agent', '--coding', '--fde'].includes(arg)))) return false
55
58
  const nonInteractive = new Set(['--help', '-h', '--version', '-v', '--print'])
56
59
  return !argv.some(a => nonInteractive.has(a) || a === '--sdk-url' || a.startsWith('--sdk-url='))
57
60
  }
@@ -0,0 +1,40 @@
1
+ import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
2
+ import { basename, dirname, join } from 'node:path'
3
+ import { tmpdir } from 'node:os'
4
+ import { spawnSync } from 'node:child_process'
5
+ import { loadStemEnv } from './stem-env-lib.mjs'
6
+
7
+ export function isMaintenanceCommand(args) {
8
+ return ['init', 'upgrade', 'update', '--upgrade', '--update'].includes(args[0])
9
+ }
10
+
11
+ export function launchMaintenance(rootDir, args) {
12
+ const callerDir = process.env.CALLER_DIR || process.cwd()
13
+ const scratchRoot = tmpdir()
14
+ let scratch
15
+ try {
16
+ const help = isMaintenanceCommand(args) && args.length === 2 && ['--help', '-h'].includes(args[1])
17
+ const env = help ? { ...process.env } : loadStemEnv([callerDir, rootDir], { ...process.env })
18
+ const entry = join(rootDir, 'dist', 'maintenance.mjs')
19
+ if (!existsSync(entry)) throw new Error('缺少维护命令产物,请在源码仓库运行 bun run build:maintenance,或重新安装 CLI。')
20
+ scratch = mkdtempSync(join(scratchRoot, 'stem-maintenance-'))
21
+ const copied = join(scratch, 'maintenance.mjs')
22
+ copyFileSync(entry, copied)
23
+ const version = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')).version
24
+ const result = spawnSync(process.execPath, [copied], {
25
+ cwd: scratch,
26
+ input: JSON.stringify({ args, cwd: callerDir, version }),
27
+ env,
28
+ stdio: ['pipe', 'inherit', 'inherit'], windowsHide: true,
29
+ })
30
+ if (result.error) console.error(`stem: 无法启动维护命令: ${result.error.message}`)
31
+ return result.status ?? 1
32
+ } catch (error) {
33
+ console.error(`stem: ${error instanceof Error ? error.message : String(error)}`)
34
+ return 1
35
+ } finally {
36
+ if (scratch && dirname(scratch) === scratchRoot && basename(scratch).startsWith('stem-maintenance-')) {
37
+ try { rmSync(scratch, { recursive: true, force: true }) } catch { /* OS may still hold a handle */ }
38
+ }
39
+ }
40
+ }
@@ -2,21 +2,31 @@
2
2
  // 独立成库的唯一理由:入口脚本 import 即执行,smoke 无法直接测;这里的函数
3
3
  // 全部纯逻辑/可注入,scripts/smoke-supervisor.ts 逐项锁语义。
4
4
  import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
5
- import { dirname, join, resolve } from 'node:path'
5
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
6
6
  import { homedir } from 'node:os'
7
7
  import { spawn, spawnSync } from 'node:child_process'
8
+ import { createHash } from 'node:crypto'
8
9
 
9
10
  // 配置类错误的专用退出码(stem-entry-cli 侧同值):见 78 不重启。
10
11
  export const EXIT_CODE_CONFIG = 78
11
12
  // Child completed an in-process memory cleanup, persisted resumable state, and asks the
12
13
  // supervisor to replace the allocator/process without classifying the event as a crash.
13
14
  export const EXIT_CODE_MEMORY_RESTART = 75
15
+ export const EXIT_CODE_CONFIGURATION_RESTART = 76
14
16
  export const CRASH_WINDOW_MS = 60_000
15
17
  export const CRASH_GIVE_UP_COUNT = 3
16
18
  export const RESTART_BACKOFF_MAX_MS = 10_000
17
19
 
18
20
  const pad2 = n => String(n).padStart(2, '0')
19
21
 
22
+ function readSessionAnnouncement(file) {
23
+ try {
24
+ const value = JSON.parse(readFileSync(file, 'utf8'))
25
+ return value?.v === 1 && typeof value.sessionId === 'string' &&
26
+ /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value.sessionId) ? value : null
27
+ } catch { return null }
28
+ }
29
+
20
30
  /** 与子进程默认日志布局一致:日期分桶,桶内按本地时分秒 + sessionId 排序。 */
21
31
  export function buildSessionLogDir(cwd, sessionId, startedAt = new Date()) {
22
32
  const d = startedAt instanceof Date ? startedAt : new Date(startedAt)
@@ -42,10 +52,16 @@ export function stripResumeFlag(argv) {
42
52
  const a = argv[i]
43
53
  if (a === '--resume' || a === '-r') {
44
54
  const next = argv[i + 1]
45
- if (next !== undefined && !next.startsWith('-')) { resumeId = next; i++ }
55
+ if (next !== undefined && !next.startsWith('-') && next.trim()) { resumeId = next; i++ }
56
+ else out.push(a) // Let the child reject a bare --resume; never start a fresh conversation.
57
+ continue
58
+ }
59
+ if (a.startsWith('--resume=')) {
60
+ const id = a.slice('--resume='.length)
61
+ if (id.trim()) resumeId = id
62
+ else out.push(a)
46
63
  continue
47
64
  }
48
- if (a.startsWith('--resume=')) { resumeId = a.slice('--resume='.length); continue }
49
65
  out.push(a)
50
66
  }
51
67
  return { baseArgs: out, resumeId }
@@ -92,8 +108,8 @@ export function restartPrompt(isZh, crashInfo) {
92
108
 
93
109
  export function memoryRestartPrompt(isZh) {
94
110
  return isZh
95
- ? '[系统] 内存保护已完成清理并受控重启。本会话对话、todo 与任务现场已从磁盘恢复。请先核对当前 todo 和实际文件状态,然后从未完成项继续执行;不要重复已完成的工作,也不要只回复确认性文字。'
96
- : '[system] Memory protection completed cleanup and a controlled restart. The conversation, todo, and task state were restored from disk. Verify the current todo and actual files, then continue from the unfinished work. Do not redo completed work or reply with an acknowledgement only.'
111
+ ? '[系统] 内存保护已完成清理并受控重启。本会话对话、todo 与任务现场已从磁盘恢复。图片载荷已卸载,需要再次看图时请根据会话图片索引使用 Read 读取缓存文件。请先核对当前 todo 和实际文件状态,然后从未完成项继续执行;不要重复已完成的工作,也不要只回复确认性文字。'
112
+ : '[system] Memory protection completed cleanup and a controlled restart. The conversation, todo, and task state were restored from disk. Image payloads were unloaded; use the session image index and Read on a cached file when visual understanding is needed again. Verify the current todo and actual files, then continue from the unfinished work. Do not redo completed work or reply with an acknowledgement only.'
97
113
  }
98
114
 
99
115
  // 注入队列的去重前缀:文案带崩溃原因(逐次可变),不能再按全串相等去重。
@@ -136,6 +152,54 @@ export function injectMemoryRestartPrompt(queuePath, isZh) {
136
152
  return injectResumePrompt(queuePath, memoryRestartPrompt(isZh))
137
153
  }
138
154
 
155
+ /** A completed task needs only a fresh process; preserve real queued prompts without adding work. */
156
+ export function prepareIdleRestartQueue(queuePath) {
157
+ let parsed
158
+ try { parsed = JSON.parse(readFileSync(queuePath, 'utf8')) }
159
+ catch (error) { return error.code === 'ENOENT' }
160
+ if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.queue) ||
161
+ parsed.queue.some(prompt => typeof prompt !== 'string')) return false
162
+ try {
163
+ writeFileSync(queuePath, JSON.stringify({ ...parsed, cleanExit: true }, null, 2) + '\n')
164
+ return true
165
+ } catch { return false }
166
+ }
167
+
168
+ export const prepareCompletedMemoryRestartQueue = prepareIdleRestartQueue
169
+
170
+ export function crashRestartResumesTask(announce, pid) {
171
+ const state = announce.requestState
172
+ if (state === undefined) return true // Compatibility with children predating request-state announcements.
173
+ if (!state || state.version !== 1 || !Number.isSafeInteger(state.pid) || state.pid <= 0 ||
174
+ state.pid !== pid || typeof state.active !== 'boolean') {
175
+ throw new Error('invalid request state announcement')
176
+ }
177
+ return state.active
178
+ }
179
+
180
+ export function memoryRestartResumesTask(announce, pid) {
181
+ const intent = announce.memoryRestart
182
+ if (intent === undefined) return true // Legacy children always requested active-task recovery.
183
+ if (!intent || intent.version !== 1 || !Number.isSafeInteger(intent.pid) || intent.pid <= 0 ||
184
+ intent.pid !== pid || typeof intent.resumeTask !== 'boolean') {
185
+ throw new Error('invalid memory restart announcement')
186
+ }
187
+ return intent.resumeTask
188
+ }
189
+
190
+ export function evoResumeHandoffFor(announce, pid, isZh) {
191
+ const h = announce.evoHandoff
192
+ if (h === undefined) return '' // Older sources still recover conservatively as interrupted turns.
193
+ if (!h || h.version !== 1 || h.sourceSessionId !== announce.sessionId || h.pid !== pid ||
194
+ !Number.isSafeInteger(h.pid) || h.pid <= 0 || typeof h.logicalTurnId !== 'string' ||
195
+ !h.logicalTurnId.length || h.logicalTurnId.length > 300 ||
196
+ typeof h.handoffId !== 'string' || !/^[a-f0-9-]{36}$/.test(h.handoffId) ||
197
+ typeof h.artifactHash !== 'string' || !/^[a-f0-9]{64}$/.test(h.artifactHash)) {
198
+ throw new Error('invalid evo source handoff announcement')
199
+ }
200
+ return JSON.stringify({ handoff: h, requestHash: createHash('sha256').update(memoryRestartPrompt(isZh)).digest('hex') })
201
+ }
202
+
139
203
  /** 崩溃标记(2026-08-13 事故:崩溃现场零证据,连退出信号都没记)。
140
204
  * 落日期/时间会话目录的 crash.json(与该会话的 supervisor.log /
141
205
  * debug 日志 / 堆快照同目录归档;此前散在 queue sidecar 目录难找易丢)。
@@ -422,9 +486,11 @@ export async function runSupervisor(distEntry, argv, options = {}) {
422
486
  },
423
487
  installSignalHandlers = true,
424
488
  } = options
425
- const launchPrefixArgs = Array.isArray(childPrefixArgs)
489
+ let launchPrefixArgs = Array.isArray(childPrefixArgs)
426
490
  ? [...childPrefixArgs]
427
491
  : [...nodeFlags, distEntry]
492
+ let launchCommand = childCommand
493
+ let launchCwd = childCwd
428
494
  const supervisorStartedAt = new Date(now())
429
495
 
430
496
  // log() 只写终端;logLine() 同时 append 到 supervisor.log(ISO 时间戳)—
@@ -458,6 +524,11 @@ export async function runSupervisor(distEntry, argv, options = {}) {
458
524
  let activeSessionLogDir = null
459
525
  let crashTimes = []
460
526
  let memoryRestartTimes = []
527
+ let configurationRestartTimes = []
528
+ let expectedResumePath = ''
529
+ let automaticResume = false
530
+ let evoResumeHandoff = ''
531
+ let configurationReload = false
461
532
  let childAlive = false
462
533
  // 崩溃前子进程通告的实时 permission mode(Shift+Tab 切换会更新通告)—
463
534
  // 重启时经 --permission-mode 复原,argv 中的启动值被剥离让位。
@@ -475,11 +546,7 @@ export async function runSupervisor(distEntry, argv, options = {}) {
475
546
  let announcedSid = null
476
547
  let currentPid = null
477
548
  const checkAnnounceForLifecycle = () => {
478
- let parsed = null
479
- try {
480
- const p = JSON.parse(readFileSync(announceFile, 'utf8'))
481
- if (p && p.v === 1 && typeof p.sessionId === 'string') parsed = p
482
- } catch { return }
549
+ const parsed = readSessionAnnouncement(announceFile)
483
550
  if (!parsed) return
484
551
  // logDir 由子进程计算,包含精确到秒的会话起点;旧版通告没有该字段时
485
552
  // 才由 supervisor 以自身启动时间构造兼容落点。
@@ -513,11 +580,22 @@ export async function runSupervisor(distEntry, argv, options = {}) {
513
580
  if (permissionModeFromAnnounce) args.push('--permission-mode', permissionModeFromAnnounce)
514
581
  if (modelFromAnnounce) args.push('--model', modelFromAnnounce)
515
582
  if (resumeId) args.push('--resume', resumeId)
583
+ // Each child must publish its own identity; a previous child's file is not evidence.
584
+ try { rmSync(announceFile, { force: true }) } catch {
585
+ logLine('stem: cannot clear stale session announcement; automatic launch stopped.')
586
+ return EXIT_CODE_CONFIG
587
+ }
516
588
  const child = spawnChild(args, {
517
589
  ...process.env,
518
590
  STEM_SUPERVISED: '1',
519
591
  STEM_SESSION_ANNOUNCE_FILE: announceFile,
520
- }, { command: childCommand, cwd: childCwd })
592
+ STEM_RESUME_EXPECTED_ID: automaticResume ? resumeId : '',
593
+ STEM_RESUME_EXPECTED_PATH: automaticResume ? expectedResumePath : '',
594
+ STEM_EVO_RESUME_HANDOFF: evoResumeHandoff,
595
+ STEM_CONFIGURATION_RELOAD: configurationReload ? '1' : '',
596
+ }, { command: launchCommand, cwd: launchCwd })
597
+ evoResumeHandoff = '' // Only the immediately following controlled restart receives the capability.
598
+ configurationReload = false
521
599
  childAlive = true
522
600
  currentPid = child.pid ?? null
523
601
  const lifecycleTimer = setInterval(checkAnnounceForLifecycle, 2000)
@@ -532,29 +610,79 @@ export async function runSupervisor(distEntry, argv, options = {}) {
532
610
  logFileOnly(isZh ? 'stem: 会话正常退出(code=0)' : 'stem: session exited normally (code=0)')
533
611
  return 0
534
612
  }
613
+ if (signal === 'SIGINT' || signal === 'SIGTERM' || code === 130 || code === 143) {
614
+ logFileOnly(isZh ? 'stem: 用户结束会话,看护不重启。' : 'stem: session terminated; supervisor will not restart.')
615
+ return signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : code
616
+ }
535
617
  if (code === EXIT_CODE_CONFIG) {
536
618
  logLine(isZh
537
619
  ? 'stem: 配置错误退出(code 78),看护不重启 — 请修正配置后重新运行。'
538
620
  : 'stem: exited with a configuration error (code 78); supervisor will not restart. Fix the configuration and run again.')
539
621
  return EXIT_CODE_CONFIG
540
622
  }
623
+ if (code === EXIT_CODE_CONFIGURATION_RESTART) {
624
+ const t = now()
625
+ configurationRestartTimes = configurationRestartTimes.filter(x => t - x < CRASH_WINDOW_MS)
626
+ configurationRestartTimes.push(t)
627
+ resetTerminalFn()
628
+ const announce = readSessionAnnouncement(announceFile)
629
+ if (!announce || announce.requestState?.pid !== child.pid || announce.requestState?.active !== false ||
630
+ typeof announce.queuePath !== 'string' || !announce.queuePath ||
631
+ configurationRestartTimes.length >= CRASH_GIVE_UP_COUNT) {
632
+ logLine(isZh ? 'stem: 无法安全自动重启,请重新启动 CLI 加载配置。'
633
+ : 'stem: automatic restart is unavailable; restart the CLI to load configuration.')
634
+ return EXIT_CODE_CONFIGURATION_RESTART
635
+ }
636
+ if (!prepareIdleRestartQueue(announce.queuePath)) {
637
+ logLine(isZh ? 'stem: 会话队列保存失败,请重新启动 CLI 加载配置。'
638
+ : 'stem: session queue could not be saved; restart the CLI to load configuration.')
639
+ return EXIT_CODE_CONFIG
640
+ }
641
+ if (announce.upgrade) {
642
+ try {
643
+ const entry = announce.upgrade.entry
644
+ if (typeof entry !== 'string' || !isAbsolute(entry) || basename(entry) !== 'cli.mjs' || !existsSync(entry)) throw new Error('invalid entry')
645
+ const pkg = JSON.parse(readFileSync(join(dirname(entry), '..', 'package.json'), 'utf8'))
646
+ if (pkg.name !== '@mortiseai/stem' || pkg.version !== announce.upgrade.version) throw new Error('invalid version')
647
+ launchPrefixArgs = [...nodeFlags, entry]
648
+ launchCommand = process.execPath
649
+ launchCwd = cwd
650
+ } catch {
651
+ logLine(isZh ? 'stem: 无法确认新版 CLI 入口,请重新运行 stem。' : 'stem: cannot verify the upgraded CLI entry; run stem again.')
652
+ return EXIT_CODE_CONFIG
653
+ }
654
+ }
655
+ resumeId = announce.sessionId
656
+ automaticResume = true
657
+ expectedResumePath = (Array.isArray(announce.jsonlPaths) ? announce.jsonlPaths : [])
658
+ .find(p => typeof p === 'string' && existsSync(p)) ?? ''
659
+ // Load model and permission defaults from the new configuration.
660
+ baseArgs = stripPermissionModeFlag(stripModelFlag(baseArgs))
661
+ permissionModeFromAnnounce = null
662
+ modelFromAnnounce = null
663
+ evoResumeHandoff = ''
664
+ configurationReload = true
665
+ onCrashCleanupPid(child.pid)
666
+ logFileOnly('stem: configuration installed; restarting CLI.')
667
+ await sleep(250)
668
+ continue
669
+ }
541
670
  if (code === EXIT_CODE_MEMORY_RESTART) {
542
671
  const t = now()
543
672
  memoryRestartTimes = memoryRestartTimes.filter(x => t - x < CRASH_WINDOW_MS)
544
673
  memoryRestartTimes.push(t)
545
674
  resetTerminalFn()
546
- let announce = null
547
- try {
548
- const parsed = JSON.parse(readFileSync(announceFile, 'utf8'))
549
- if (parsed && parsed.v === 1 && typeof parsed.sessionId === 'string') announce = parsed
550
- } catch { /* ignore */ }
551
- if (!announce || typeof announce.queuePath !== 'string') {
675
+ const announce = readSessionAnnouncement(announceFile)
676
+ if (!announce || typeof announce.queuePath !== 'string' || !announce.queuePath) {
552
677
  logLine(isZh
553
678
  ? 'stem: 子进程请求内存恢复重启,但没有有效会话通告;为避免丢失上下文,已停止自动重启。'
554
679
  : 'stem: child requested a memory recovery restart without a valid session announcement; automatic restart stopped to avoid losing context.')
555
680
  return EXIT_CODE_MEMORY_RESTART
556
681
  }
557
682
  resumeId = announce.sessionId
683
+ automaticResume = true
684
+ expectedResumePath = (Array.isArray(announce.jsonlPaths) ? announce.jsonlPaths : [])
685
+ .find(p => typeof p === 'string' && existsSync(p)) ?? ''
558
686
  if (typeof announce.permissionMode === 'string' && announce.permissionMode) {
559
687
  permissionModeFromAnnounce = announce.permissionMode
560
688
  }
@@ -571,11 +699,29 @@ export async function runSupervisor(distEntry, argv, options = {}) {
571
699
  : `stem: memory recovery restart requested ${memoryRestartTimes.length} times within 60s; giving up on auto-restart.`)
572
700
  return EXIT_CODE_MEMORY_RESTART
573
701
  }
574
- injectMemoryRestartPrompt(announce.queuePath, isZh)
702
+ let resumeTask
703
+ try {
704
+ resumeTask = memoryRestartResumesTask(announce, child.pid)
705
+ evoResumeHandoff = resumeTask ? evoResumeHandoffFor(announce, child.pid, isZh) : ''
706
+ }
707
+ catch (error) {
708
+ logLine(`stem: ${error.message}; automatic restart stopped.`)
709
+ return EXIT_CODE_CONFIG
710
+ }
711
+ const queuePrepared = resumeTask
712
+ ? injectMemoryRestartPrompt(announce.queuePath, isZh)
713
+ : prepareIdleRestartQueue(announce.queuePath)
714
+ if (!queuePrepared) {
715
+ logLine(isZh ? `stem: 会话 ${resumeId} 的续接队列写入失败,已停止自动重启。`
716
+ : `stem: failed to persist the recovery queue for session ${resumeId}; restart stopped.`)
717
+ return EXIT_CODE_CONFIG
718
+ }
575
719
  onCrashCleanupPid(child.pid)
576
- logLine(isZh
577
- ? `stem: 内存清理后仍处于硬水位,已保存会话 ${resumeId},正在受控重启并自动续接…`
578
- : `stem: memory remained above the hard limit after cleanup; session ${resumeId} was saved and will resume after a controlled restart…`)
720
+ logLine(resumeTask
721
+ ? (isZh ? `stem: 内存清理后余量不足,已保存会话 ${resumeId},正在重启并续接任务…`
722
+ : `stem: memory headroom remains insufficient; session ${resumeId} was saved and will resume after restart…`)
723
+ : (isZh ? `stem: 任务已完成,正在优化内存并恢复会话 ${resumeId}…`
724
+ : `stem: task completed; optimizing memory and restoring session ${resumeId}…`))
579
725
  await sleep(250)
580
726
  continue
581
727
  }
@@ -587,13 +733,12 @@ export async function runSupervisor(distEntry, argv, options = {}) {
587
733
  crashTimes = crashTimes.filter(x => t - x < CRASH_WINDOW_MS)
588
734
  crashTimes.push(t)
589
735
  resetTerminalFn()
590
- let announce = null
591
- try {
592
- const parsed = JSON.parse(readFileSync(announceFile, 'utf8'))
593
- if (parsed && parsed.v === 1 && typeof parsed.sessionId === 'string') announce = parsed
594
- } catch { /* ignore */ }
736
+ const announce = readSessionAnnouncement(announceFile)
595
737
  if (announce) {
596
738
  resumeId = announce.sessionId
739
+ automaticResume = true
740
+ expectedResumePath = (Array.isArray(announce.jsonlPaths) ? announce.jsonlPaths : [])
741
+ .find(p => typeof p === 'string' && existsSync(p)) ?? ''
597
742
  if (typeof announce.permissionMode === 'string' && announce.permissionMode) {
598
743
  permissionModeFromAnnounce = announce.permissionMode
599
744
  }
@@ -656,12 +801,35 @@ export async function runSupervisor(distEntry, argv, options = {}) {
656
801
 
657
802
  // 续跑注入只在还会重启时做:放弃场景注入了反而让下次手动 resume 自动跑
658
803
  // 一条"已重启"文案,与事实不符。
659
- if (announce && typeof announce.queuePath === 'string') {
660
- injectRestartPrompt(announce.queuePath, isZh, { code, signal })
804
+ if (!announce || typeof announce.queuePath !== 'string' || !announce.queuePath) {
805
+ logLine(isZh
806
+ ? `stem: 进程异常退出(code=${code ?? '-'}, signal=${signal ?? '-'}),没有有效会话通告;严格恢复已停止,不会新建会话。`
807
+ : `stem: process exited (code=${code ?? '-'}, signal=${signal ?? '-'}), but no valid session announcement exists; strict recovery stopped without creating a new conversation.`)
808
+ onCrashCleanupPid(child.pid)
809
+ return EXIT_CODE_CONFIG
810
+ }
811
+ let resumeTask
812
+ try { resumeTask = crashRestartResumesTask(announce, child.pid) }
813
+ catch (error) {
814
+ logLine(`stem: ${error.message}; automatic restart stopped.`)
815
+ onCrashCleanupPid(child.pid)
816
+ return EXIT_CODE_CONFIG
817
+ }
818
+ const queuePrepared = resumeTask
819
+ ? injectRestartPrompt(announce.queuePath, isZh, { code, signal })
820
+ : prepareIdleRestartQueue(announce.queuePath)
821
+ if (!queuePrepared) {
822
+ logLine(isZh ? `stem: 会话 ${resumeId} 的续接队列写入失败,已停止自动重启。`
823
+ : `stem: failed to persist the recovery queue for session ${resumeId}; restart stopped.`)
824
+ return EXIT_CODE_CONFIG
661
825
  }
662
826
 
663
827
  onCrashCleanupPid(child.pid)
664
828
 
829
+ logFileOnly(resumeTask
830
+ ? (isZh ? 'stem: 恢复在途任务,已注入一条续接指令。' : 'stem: recovering interrupted task with one continuation prompt.')
831
+ : (isZh ? 'stem: 当前无在途任务,仅恢复会话与既有队列。' : 'stem: no active task; restoring the session and existing queue.'))
832
+
665
833
  const delayMs = Math.min(1000 * 2 ** (crashTimes.length - 1), RESTART_BACKOFF_MAX_MS)
666
834
  logLine(isZh
667
835
  ? `stem: 进程异常退出(code=${code ?? '-'}, signal=${signal ?? '-'}),${Math.round(delayMs / 1000)}s 后自动恢复会话${resumeId ? ` ${resumeId}` : ''}…(STEM_SUPERVISOR_OFF=1 可关闭看护)`