mio-agent-runtime 0.11.0 → 0.12.0

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
@@ -25,19 +25,20 @@ mio config llm --clear Remove the stored LLM config (env vars still apply)
25
25
  mio config path Print the config.json path
26
26
  mio mcp Start Mio MCP server (stdio)
27
27
  mio install <host> Install Mio into a host (codex|opencode|workbuddy|hermes|claude)
28
- mio status Show runtime and adapter status
28
+ mio status Show runtime and adapter status (also reports pending auto-claims)
29
29
  mio agents List installed host adapters
30
30
  mio agents list List observed agents (from agents.jsonl, --project X)
31
31
  mio agents report Report per-agent task/memory/reuse telemetry (--agent X, --project Y)
32
32
  mio agents register --agent-id X Register an observed agent (--yes to apply; previews by default)
33
+ mio agents evaluation ADR-017 evaluation metrics: route adoption, behaviour change, recall quality, data hygiene (--project X, --since ISO)
33
34
  mio evolution status Show composed evolution module health
34
35
  mio evolution report Cross-agent evolution report: ecosystem, agents, memory health, suggestions (--period 24h|7d|30d|all)
35
- mio evolution shadow record Record a shadow comparison sample
36
- mio evolution dual-write record Record a dual-write comparison sample
37
- mio evolution cutover readiness Assess shadow/dual-write cutover readiness
38
- mio evolution authority plan Preview a gated authority switch plan
39
- mio evolution migration plan Preview state migration diffs
40
- mio evolution cutover apply --dry-run Dry-run a cutover plan without switching authority
36
+ mio evolution shadow record Record a shadow comparison sample (--legacy/--modular JSON; both required)
37
+ mio evolution dual-write record Record a dual-write comparison sample (--legacy-result/--modular-result JSON; required)
38
+ mio evolution cutover readiness Assess shadow/dual-write cutover readiness (--min-shadow-runs/--max-mismatch-rate)
39
+ mio evolution authority plan Preview a gated authority switch plan (--readiness JSON; required)
40
+ mio evolution migration plan Preview state migration diffs (--legacy-records/--modular-records JSON; required)
41
+ mio evolution cutover apply --dry-run Dry-run a cutover plan (--plan JSON and --dry-run are both required)
41
42
  mio observe Watch WorkBuddy transcripts and auto-ingest task outcomes (foreground)
42
43
  mio observe --start|--stop|--status|--once Manage the background observer daemon
43
44
  mio recall "<query>" Search Mio memory from the terminal (--project/--scope/--kind/--tags/--limit)
@@ -77,9 +78,28 @@ mio --json evolution status Machine-readable evolution module health
77
78
  mio --version Print the version and exit (-V)
78
79
  ```
79
80
 
81
+ ## 输出流与退出码
82
+
83
+ 所有命令族共用同一套约定(12 个族一致,且每个族都有测试钉住):
84
+
85
+ | 情形 | stdout | stderr | 退出码 |
86
+ |---|---|---|---|
87
+ | 子命令写错 / 必填参数缺失 | **空** | 报错行 + 完整用法 | `1` |
88
+ | 裸命令(如 `mio memory`) | 用法 | 空 | `1` |
89
+ | `--help` | 用法 | 空 | `0` |
90
+
91
+ - **失败时 stdout 必须为空**,因为 `mio --json` 的调用方读的是 stdout——用法文本落在那里
92
+ 会被当成 payload 去解析。所以缺参时先给出原因(`--legacy is required`),紧接着把用法与
93
+ 示例一起打到 stderr。
94
+ - **裸命令不是错误**,只是"没说清要做什么",所以用法走 stdout;但仍以 `1` 结束,
95
+ 这样脚本不会把"什么都没做"当成成功。它打印的用法与 `<命令> --help` 完全相同,
96
+ 区别只在退出码(`1` vs `0`)。
97
+ - **例外**:`mio config` 与 `mio agents` 的裸命令是**真命令**(显示配置 / 列 agent),退出 `0`。
98
+ - `mio help` 与 `<命令> --help` 都走 stdout 并退出 `0`。
99
+
80
100
  ## MCP 工具
81
101
 
82
- MCP 服务端在 5 大域共暴露 48 个工具:
102
+ MCP 服务端在 5 大域共暴露 49 个工具:
83
103
 
84
104
  ### 记忆(11)
85
105
  `mio.memory.query` · `mio.memory.record` · `mio.memory.archive` · `mio.memory.merge` · `mio.memory.migrate` · `mio.memory.analyze` · `mio.memory.forget` · `mio.experience.list` · `mio.experience.confirm` · `mio.experience.reuse` · `mio.policy.check`
@@ -95,8 +115,8 @@ MCP 服务端在 5 大域共暴露 48 个工具:
95
115
  ### 创意引擎(4)
96
116
  `mio.creativity.generate` · `mio.creativity.ferment` · `mio.creativity.list` · `mio.creativity.status`
97
117
 
98
- ### 任务、Agent 与演化(15)
99
- `mio.task.route` · `mio.task.record_outcome` · `mio.agent.register` · `mio.agent.list` · `mio.agent.report` · `mio.host.capabilities` · `mio.evolution.status` · `mio.evolution.report` · `mio.evolution.shadow.record` · `mio.evolution.dual_write.record` · `mio.evolution.cutover.readiness` · `mio.evolution.cutover.apply` · `mio.evolution.migration.plan` · `mio.evolution.authority.plan` · `mio.phase0.report`
118
+ ### 任务、Agent 与演化(16)
119
+ `mio.task.route` · `mio.task.record_outcome` · `mio.agent.register` · `mio.agent.list` · `mio.agent.report` · `mio.agent.evaluation` · `mio.host.capabilities` · `mio.evolution.status` · `mio.evolution.report` · `mio.evolution.shadow.record` · `mio.evolution.dual_write.record` · `mio.evolution.cutover.readiness` · `mio.evolution.cutover.apply` · `mio.evolution.migration.plan` · `mio.evolution.authority.plan` · `mio.phase0.report`
100
120
 
101
121
  ## 包
102
122
 
@@ -460,7 +480,7 @@ mio observer trends --base-dir /path/to/.local/observer
460
480
  说明:
461
481
 
462
482
  - **两者都委托给与 MCP 服务端完全相同的共享实现** —— `server/insight-store.js` 与 `server/observer-store.js`。这是 `memory-store.js` / `experience-store.js` / `policy-store.js` / `creativity-engine.js` / `agent-store.js` 一路沿用的同一个模式:一份实现、两个入口,因此不可能各自漂移。
463
- - **只有 `insight generate` 仍是 MCP 专有**(`mio.insight.generate`):它要调 LLM,CLI 把它当作未知子命令拒绝。`observer collect` / `observer ferment` 此前也被一起挡在 CLI 之外,理由是「它们属于 daemon」——这个理由不成立:`observe/observer.js` 那条 daemon 只 tail WorkBuddy 的 transcript,从不调用这两者。现在它们有了终端入口,默认走全部已配置源:
483
+ - **`insight generate` 曾经是 MCP 专有**(`mio.insight.generate`):它要调 LLM,CLI 一度把它当作未知子命令拒绝——现在四个 `insight` 子命令在终端上都有入口;`--memory` 与 `--summary` 至少给一个,否则在**任何 LLM 调用之前**就拒绝。`observer collect` / `observer ferment` 此前也被一起挡在 CLI 之外,理由是「它们属于 daemon」——这个理由不成立:`observe/observer.js` 那条 daemon 只 tail WorkBuddy 的 transcript,从不调用这两者。现在它们有了终端入口,默认走全部已配置源:
464
484
 
465
485
  ```bash
466
486
  mio observer collect # 全部已配置源
@@ -509,6 +529,72 @@ Note: this advances the cursor; the next digest returns only newer events.
509
529
  - **`@akemi-mio/insight` 是可选依赖。** 未安装时 `mio insight status` 会失败并提示 `@akemi-mio/insight not installed`,而不是报告一个「看起来没有洞察」的全零结果——全零会掩盖「引擎根本没装」这件事。
510
530
  - **观察管线的空目录是正常状态。** 管线没跑过时,`observer status` 各阶段计数为 0 并提示 "No pipeline data yet.",其余视图显示 "No ... found.",都是有效输出而非错误。
511
531
 
532
+ ## 演化(evolution)
533
+
534
+ `mio.evolution.*` 一族也是「一份实现、两个入口」,但它和前面几节有一处形态差异:
535
+ **参数是内联的 JSON 快照,不是文件路径。** 这是这一族最常被误用的地方——`--legacy` 指的是
536
+ 「旧实现这次跑出来的结果」,不是某个待读取的文件。
537
+
538
+ ```bash
539
+ mio evolution status
540
+ mio evolution report --period 7d
541
+
542
+ # 影子比对:把「旧实现」和「模块化实现」各自的输出喂进来做深比较
543
+ mio evolution shadow record --legacy '{"modules":7}' --modular '{"modules":7}'
544
+ mio evolution shadow record --project akemi-mio --label "status parity" \
545
+ --legacy '{"modules":7}' --modular '{"modules":9}'
546
+
547
+ # 双写样本:--authoritative 声明谁是权威(省略即 legacy),两边各给一份写入结果
548
+ mio evolution dual-write record --authoritative legacy \
549
+ --legacy-result '{"id":"mem-1","ok":true}' \
550
+ --modular-result '{"id":"mem-1","ok":true}'
551
+
552
+ # 就绪度读的是上面两个台账,不是你「觉得」跑了几次
553
+ mio evolution cutover readiness
554
+ mio evolution cutover readiness --min-shadow-runs 5 --max-mismatch-rate 0.1
555
+ ```
556
+
557
+ ```text
558
+ Shadow comparison shadow_1789912345678_ab12cd: mismatch
559
+ diffs=1
560
+
561
+ Cutover readiness for akemi-mio: fail
562
+ shadow=2 dual-write=0
563
+ reasons: shadow samples 2/5; shadow mismatch rate 0.5; dual-write has no samples
564
+ ```
565
+
566
+ 各子命令的参数:
567
+
568
+ | 子命令 | 必填 | 可选 |
569
+ |---|---|---|
570
+ | `status` | 无 | 无 |
571
+ | `report` | 无 | `--period`(`24h` / `7d` / `30d` / `all`)、`--project` |
572
+ | `shadow record` | `--legacy <json>`、`--modular <json>` | `--project`、`--label`、`--input <json>` |
573
+ | `dual-write record` | `--legacy-result <json>`、`--modular-result <json>` | `--authoritative`(`legacy` 或 `modular`,默认 `legacy`)、`--project`、`--label`、`--record <json>` |
574
+ | `cutover readiness` | 无 | `--project`、`--min-shadow-runs N`、`--max-mismatch-rate N` |
575
+ | `authority plan` | `--readiness <json>` | `--from`、`--to` |
576
+ | `migration plan` | `--legacy-records <json>`、`--modular-records <json>` | 无 |
577
+ | `cutover apply` | `--dry-run` **和** `--plan <json>` | `--project` |
578
+
579
+ 说明:
580
+
581
+ - **缺参时会先给出原因(`--legacy is required`),紧接着打印完整用法与示例**,包括「参数是内联
582
+ JSON」这件事。用法写到 stderr、stdout 保持为空(这是**全命令族的约定**,见上面
583
+ [输出流与退出码](#输出流与退出码) 一节)——所以 `mio --json` 的调用方拿到的是空输出,
584
+ 而不是把用法文本当成 JSON 去解析。值不是合法 JSON 时同理,报 `--legacy must be valid JSON`。
585
+ 子命令写漏了(如 `mio evolution cutover`)会明确说缺哪个:`needs 'readiness' or 'apply'`。
586
+ - **`cutover apply` 是 dry-run only,而且 `--dry-run` 必须显式写上。** 只给 `--plan` 会被拒绝
587
+ (`authority switch apply is dry-run only; pass dryRun: true ...`),只给 `--dry-run` 则报
588
+ `--plan is required`。它**不会真的切换权威**,返回里 `applied` 恒为 `false`。
589
+ - **记样本会立刻影响 `cutover readiness`。** 记录落在 `<MIO_HOME>/evolution_shadow.jsonl` 与
590
+ `<MIO_HOME>/evolution_dual_write.jsonl`,`readiness` 读的就是这两个文件,所以**别拿测试数据
591
+ 往真实 `MIO_HOME` 里写**:`shadow mismatch rate` 会被污染,之后真正的切换判据跟着失真。
592
+ 想试跑就把 `MIO_HOME` 指到临时目录。
593
+ - 两边深比较相等 → `matched`;不等 → `mismatch` 并给出 `diffs=N`。
594
+ - `--project` 省略时取当前 git 仓库名,与 `mio recall` / `mio policy check` 的默认口径一致。
595
+ - **`--json` 是全局 flag,必须写在子命令前面**:`mio --json evolution cutover readiness`。
596
+ 写在后面不生效。
597
+
512
598
  ## Agent
513
599
 
514
600
  本仓库里有两种不同的「agent」概念,CLI 把它们分开以免混淆:
@@ -533,6 +619,62 @@ codex (mcp) idle
533
619
 
534
620
  两个子命令都委托给 `server/agent-store.js`——与 `mio.agent.list` / `mio.agent.report` / `mio.agent.register` MCP 工具相同的存储,因此终端与 MCP 服务端报告一致的遥测,不可能漂移。`list` 读取 `agents.jsonl`;`report` 叠加 trace/memory/reuse 的交叉引用。
535
621
 
622
+ ### 评估期指标(`mio agents evaluation`)
623
+
624
+ ADR-017 用四项指标决定控制平面是继续扩展还是回滚。实现见
625
+ `server/evaluation-store.js`,MCP 侧为 `mio.agent.evaluation`。
626
+
627
+ | 指标 | 口径 | 数据来源 |
628
+ |---|---|---|
629
+ | 路由采纳率 | 命中且被采纳 / 命中 | `queries.jsonl` ⚠️ |
630
+ | 行为改变率 | confirmed 且 behaviorChanged / 全部 reuse | `experience_reuse.jsonl` |
631
+ | 召回质量 | 结果导向 reuse 的查询 / 有结果的查询 | `queries.jsonl` ⚠️ |
632
+ | 数据卫生 | 未确认 auto-claim / 全部 reuse | `experience_reuse.jsonl` |
633
+
634
+ **无样本时输出 `no data`,而不是 `0%`**:`0%` 的意思是「测过了、很差」,
635
+ `no data` 才是「没测」。`--json` 下对应 `null`,客户端必须区分这两种情况。
636
+
637
+ **报告会带出 Phase 0 基线作对照。** ADR 要求这些数字「对照 Phase 0 基线
638
+ 2026-08-17」来读,但基线此前只作为散文存在于 ADR 里,报告给的是没有参照点的裸值。
639
+ 现在 `baseline` 字段(`--json`)与输出里的 `baseline …` 行会并列给出基线值。
640
+ 基线以 `observed / required` 形式陈述(与 `phase0.js` 的 `current/required` 渲染一致)。
641
+ 这一条对 `verified` 尤其重要:它已从基线的 6 跌到 0,裸看只是一句「0」,
642
+ 对照读才是「比起步时少了 6、跌破门槛」。
643
+
644
+ ⚠️ **前两项指标读的是一个设计上就会变空的缓冲区**。`queries.jsonl` 不是评估日志,
645
+ 而是 auto-claim 的关联缓冲(见 `server/query-log.js`):最多 200 条,超过
646
+ `expiresAt`(默认 1 小时)就被清理,`retention.js` 也把它列在 `EXPIRY_BASED_FILES`。
647
+ 所以这两项在实际使用中**几乎总是 `no data`**——不是因为路由没人用,而是因为它的证据
648
+ 被有意设计成不长期保留。报告里会打印一句说明,避免读者把「缓冲区为空」误读成
649
+ 「功能没被使用」。
650
+
651
+ ⚠️ **行为改变率停在 0% 通常是确认纪律问题,不是功能问题**。未确认的 auto-claim
652
+ 既不进记忆排序也不进任务路由,是「哑重」;`mio.experience.confirm` 会把
653
+ `behaviorChanged` 置为 true,从而把它们转入有效证据。真实数据核查发现:确认这一步
654
+ 在生产里从未被执行过(全部 reuse 记录的 `confirmed` 字段缺省),于是行为改变率恒为
655
+ 0%、数据卫生恒为「93% 未确认」。因此 `mio status` 现在会直接报出全局待确认数以及
656
+ 其中**跨 Agent** 的条数:
657
+
658
+ ```
659
+ mio status
660
+ mio experience list --status pending # 看具体是哪些
661
+ mio experience confirm --ids a,b,c # 确认(见下方口径)
662
+ ```
663
+
664
+ ⚠️ **但「待确认 20 条」不等于「该确认 20 条」,不要批量全确认。** ADR-017 的确认口径
665
+ (`docs/adr-017-mio-agent-control-plane.md`,原文出现两次)是:
666
+
667
+ > 仅**跨 Agent 且真实改变行为**才 `experience.confirm`;**同 Agent / 弱关联 / 自报
668
+ > 一律不确认**,避免数据污染。
669
+
670
+ 同 Agent 的 auto-claim(如 `codex` -> `codex`)在定义上就不可能满足这条,所以原始
671
+ pending 数**高估了可确认的积压**。`mio status` 因此同时报出跨 Agent 的条数,例如
672
+ `Pending auto-claims: 20 (12 cross-agent)`——那 8 条同 Agent 的**不应被确认**。
673
+
674
+ 跨 Agent 也只是**必要条件**:确认前仍须人工核对该 target agent 的真实输出是否
675
+ **明确引用**了 source 记忆并据此改变方案。把不合条件的记录一并 confirm,正是这条
676
+ ADR 要防的数据污染,而且会让行为改变率这个指标本身失真。
677
+
536
678
  ### 注册(`mio agents register`)
537
679
 
538
680
  `register` 是这一组里唯一的**写操作**,因此沿用与 `mio memory archive` 相同的约定:
@@ -46,6 +46,34 @@ function hermesBin() {
46
46
  return null
47
47
  }
48
48
 
49
+ // spawnSync() with a piped stdin fails on Windows with EBUSY before the child
50
+ // even starts: status is null and stderr is empty, so every call below used to
51
+ // return null and the Hermes adapter silently reported "not installed".
52
+ // `stdio[0] = 'ignore'` fixes the calls that read no stdin. When there IS a
53
+ // prompt answer to deliver it has to travel through a real file descriptor:
54
+ // passing `input` alongside a non-pipe stdin makes Node drop the payload with
55
+ // no error at all, which would leave `hermes mcp add` waiting forever for an
56
+ // answer that never arrives -- worse than the crash it replaces.
57
+ function spawnWithOptionalInput(command, args, opts) {
58
+ const { input, ...rest } = opts
59
+ if (input === undefined) {
60
+ return spawnSync(command, args, { ...rest, stdio: ['ignore', 'pipe', 'pipe'] })
61
+ }
62
+ const file = path.join(os.tmpdir(), 'mio-hermes-stdin-' + process.pid + '-' + Date.now())
63
+ fs.writeFileSync(file, input)
64
+ const fd = fs.openSync(file, 'r')
65
+ try {
66
+ return spawnSync(command, args, { ...rest, stdio: [fd, 'pipe', 'pipe'] })
67
+ } finally {
68
+ try {
69
+ fs.closeSync(fd)
70
+ } catch (_) {}
71
+ try {
72
+ fs.unlinkSync(file)
73
+ } catch (_) {}
74
+ }
75
+ }
76
+
49
77
  function runHermes(args) {
50
78
  const bin = hermesBin()
51
79
  // `hermes mcp add` asks to confirm tool enablement interactively; pre-answer
@@ -55,8 +83,8 @@ function runHermes(args) {
55
83
  try {
56
84
  const isCmdShim = Boolean(bin && /\.(cmd|bat)$/i.test(bin))
57
85
  res = bin
58
- ? spawnSync(bin, args, { encoding: 'utf8', timeout: 60000, windowsHide: true, input: input, shell: isCmdShim })
59
- : spawnSync('hermes', args, { encoding: 'utf8', timeout: 60000, shell: true, windowsHide: true, input: input })
86
+ ? spawnWithOptionalInput(bin, args, { encoding: 'utf8', timeout: 60000, windowsHide: true, input: input, shell: isCmdShim })
87
+ : spawnWithOptionalInput('hermes', args, { encoding: 'utf8', timeout: 60000, shell: true, windowsHide: true, input: input })
60
88
  } catch (_) {
61
89
  return null
62
90
  }
@@ -65,8 +93,29 @@ function runHermes(args) {
65
93
  }
66
94
 
67
95
  function isInstalled() {
68
- const out = runHermes(['mcp', 'list'])
69
- return Boolean(out && out.indexOf(SERVER_NAME) !== -1)
96
+ // Read Hermes's own config instead of asking the binary. `hermes mcp list`
97
+ // costs ~1.8 s here (measured: Hermes is a packaged Python executable with a
98
+ // slow cold start), and listHostCapabilities() calls this once per host, so
99
+ // the moment the spawn started working again `mio.host.capabilities` went
100
+ // from ~3 ms to ~2 s. Hermes records registered MCP servers in config.yaml
101
+ // under `mcp_servers:`, so the file answers the same question in ~1 ms.
102
+ //
103
+ // Anchored on the YAML key rather than a plain substring: the config also
104
+ // contains the server script path, which includes "mio-intelligence-mcp" and
105
+ // would make a loose contains() report "installed" for a config that merely
106
+ // mentions the path.
107
+ //
108
+ // Trade-off, deliberately accepted: if a future Hermes stores MCP config
109
+ // somewhere this does not read, this reports false. That is no worse than
110
+ // what it did for a long time -- the spawn used to fail silently, so the
111
+ // answer was always false regardless.
112
+ const cfg = hermesConfigPath()
113
+ if (!fs.existsSync(cfg)) return false
114
+ try {
115
+ return new RegExp('^\\s*' + SERVER_NAME + '\\s*:', 'm').test(fs.readFileSync(cfg, 'utf8'))
116
+ } catch (_) {
117
+ return false
118
+ }
70
119
  }
71
120
 
72
121
  function install({ node, serverScript, home, workspace, project }) {
@@ -112,7 +161,12 @@ function install({ node, serverScript, home, workspace, project }) {
112
161
 
113
162
  function isHermesOnPath() {
114
163
  try {
115
- const res = spawnSync('where', ['hermes'], { encoding: 'utf8', shell: true })
164
+ // stdio[0] must not be a pipe: see spawnWithOptionalInput above.
165
+ const res = spawnSync('where', ['hermes'], {
166
+ encoding: 'utf8',
167
+ shell: true,
168
+ stdio: ['ignore', 'pipe', 'pipe'],
169
+ })
116
170
  return res.status === 0 && /hermes/.test(res.stdout || '')
117
171
  } catch (_) {
118
172
  return false
@@ -1,62 +1,67 @@
1
- 'use strict'
2
-
3
- const fs = require('fs')
4
- const { spawnSync } = require('child_process')
5
-
6
- // Windows only: returns the earliest creation time (epoch ms) of a running
7
- // process whose ExecutablePath contains `exePathFragment`, or null when the
8
- // host app is not running / detection is unavailable.
9
- function earliestRunningSince(exePathFragment) {
10
- if (process.platform !== 'win32') return null
11
- try {
12
- const escaped = String(exePathFragment).replace(/'/g, "''")
13
- const script =
14
- "Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like '*" +
15
- escaped +
16
- "*' } | ForEach-Object { [long]($_.CreationDate.ToUniversalTime() - [datetime]'1970-01-01').TotalMilliseconds }"
17
- const res = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
18
- encoding: 'utf8',
19
- timeout: 10000,
20
- windowsHide: true,
21
- })
22
- if (res.error || res.status !== 0) return null
23
- const times = (res.stdout || '')
24
- .split(/\r?\n/)
25
- .map((line) => line.trim())
26
- .filter(Boolean)
27
- .map(Number)
28
- .filter((value) => Number.isFinite(value) && value > 0)
29
- if (times.length === 0) return null
30
- return Math.min(...times)
31
- } catch (_) {
32
- return null
33
- }
34
- }
35
-
36
- // Returns a human-readable warning when the host app is currently running from
37
- // a config that predates its process start (i.e. a restart is required to load
38
- // the updated Mio MCP config). Returns null when no restart is needed.
39
- function restartWarning({ hostLabel, exeName, exePathFragment, configPath }) {
40
- const since = earliestRunningSince(exePathFragment)
41
- if (since === null) return null
42
- let mtime = null
43
- try {
44
- mtime = fs.statSync(configPath).mtimeMs
45
- } catch (_) {
46
- return null
47
- }
48
- if (!(mtime > since)) return null
49
- const started = new Date(since).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'
50
- return (
51
- hostLabel +
52
- ' is running with an older Mio config (app started ' +
53
- started +
54
- ', config updated after). Fully quit ' +
55
- hostLabel +
56
- ' (end all ' +
57
- exeName +
58
- ' processes) and relaunch to load the updated Mio MCP config.'
59
- )
60
- }
61
-
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const { spawnSync } = require('child_process')
5
+
6
+ // Windows only: returns the earliest creation time (epoch ms) of a running
7
+ // process whose ExecutablePath contains `exePathFragment`, or null when the
8
+ // host app is not running / detection is unavailable.
9
+ function earliestRunningSince(exePathFragment) {
10
+ if (process.platform !== 'win32') return null
11
+ try {
12
+ const escaped = String(exePathFragment).replace(/'/g, "''")
13
+ const script =
14
+ "Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like '*" +
15
+ escaped +
16
+ "*' } | ForEach-Object { [long]($_.CreationDate.ToUniversalTime() - [datetime]'1970-01-01').TotalMilliseconds }"
17
+ // stdio[0] must not be a pipe. On Windows a piped stdin makes spawnSync
18
+ // fail with EBUSY before powershell starts: status is null and stderr is
19
+ // empty, so this probe returned null every time and the "host is running an
20
+ // older config" warning never fired. powershell reads no stdin here.
21
+ const res = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
22
+ encoding: 'utf8',
23
+ timeout: 10000,
24
+ windowsHide: true,
25
+ stdio: ['ignore', 'pipe', 'pipe'],
26
+ })
27
+ if (res.error || res.status !== 0) return null
28
+ const times = (res.stdout || '')
29
+ .split(/\r?\n/)
30
+ .map((line) => line.trim())
31
+ .filter(Boolean)
32
+ .map(Number)
33
+ .filter((value) => Number.isFinite(value) && value > 0)
34
+ if (times.length === 0) return null
35
+ return Math.min(...times)
36
+ } catch (_) {
37
+ return null
38
+ }
39
+ }
40
+
41
+ // Returns a human-readable warning when the host app is currently running from
42
+ // a config that predates its process start (i.e. a restart is required to load
43
+ // the updated Mio MCP config). Returns null when no restart is needed.
44
+ function restartWarning({ hostLabel, exeName, exePathFragment, configPath }) {
45
+ const since = earliestRunningSince(exePathFragment)
46
+ if (since === null) return null
47
+ let mtime = null
48
+ try {
49
+ mtime = fs.statSync(configPath).mtimeMs
50
+ } catch (_) {
51
+ return null
52
+ }
53
+ if (!(mtime > since)) return null
54
+ const started = new Date(since).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'
55
+ return (
56
+ hostLabel +
57
+ ' is running with an older Mio config (app started ' +
58
+ started +
59
+ ', config updated after). Fully quit ' +
60
+ hostLabel +
61
+ ' (end all ' +
62
+ exeName +
63
+ ' processes) and relaunch to load the updated Mio MCP config.'
64
+ )
65
+ }
66
+
62
67
  module.exports = { earliestRunningSince, restartWarning }