@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 +63 -24
- package/bin/stem-env-lib.mjs +20 -0
- package/bin/stem-launcher-lib.mjs +3 -0
- package/bin/stem-maintenance-launcher.mjs +40 -0
- package/bin/stem-supervisor-lib.mjs +197 -29
- package/bin/stem.mjs +6 -0
- package/dist/cli.mjs +1372 -1233
- package/dist/daemon.mjs +1614 -1470
- package/dist/evo-service.mjs +517 -0
- package/dist/evo-worker.mjs +862 -0
- package/dist/maintenance.mjs +29 -0
- package/dist/mcp.mjs +1643 -1499
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
> **Stem** — MortiseAI 构建的 Self-Evolving AI Agent,基于 **MSCE**(MSC Engine)的自我进化智能体
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
四种启动模式(详见[「业务模式」](#业务模式)):
|
|
6
6
|
|
|
7
|
-
- **Agent 模式** —
|
|
8
|
-
- **Coding 模式** —
|
|
9
|
-
- **FDE 模式** —
|
|
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
|
-
### 业务模式
|
|
533
|
+
### 业务模式
|
|
512
534
|
|
|
513
|
-
Stem
|
|
535
|
+
Stem 的四种启动模式:
|
|
514
536
|
|
|
515
537
|
| 模式 | 启动命令 | 状态 | 说明 | 核心能力 |
|
|
516
538
|
|---|---|---|---|---|
|
|
517
539
|
| Agent | `stem` / `stem --agent` | ✅ 开放(默认) | 面向通用智能体形态 | 业务图谱记忆 |
|
|
518
|
-
| Coding | `stem --coding` |
|
|
519
|
-
| FDE | `stem --fde` |
|
|
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
|
-
|
|
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
|
-
-
|
|
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` |
|
|
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
|
|
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
|
|
747
|
+
`<cwd>/mstem-storage/platform/common/session/<sessionId>.jsonl`(`--user` 时落用户空间
|
|
712
748
|
`<cwd>/mstem-storage/platform/<user>/session/`);升级前写在
|
|
713
|
-
`~/.mstem/projects/<项目>/`
|
|
714
|
-
|
|
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
|
-
|
|
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
|
|
818
|
-
- **`mstem-storage/`** —
|
|
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/
|
|
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
|
-
- 会话聊天文件默认落
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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(
|
|
577
|
-
? `stem:
|
|
578
|
-
|
|
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
|
-
|
|
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
|
|
660
|
-
|
|
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 可关闭看护)`
|