refactor(plugins): 按「内置插件判定标准」收敛内置清单 16 → 11

## 判定标准(写进 all.go 与 ARCHITECTURE)

**只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件,才留在
编译期内置清单**;其余一律外置为 .hmap 插件,独立编译、独立发版。

依据是 internal/plugin/proc/capability.go 的 withheldCapabilities:
内部 SDK 的 TerminalAPI/TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI/
SelftestAPI/ToolAPI/Publish 等能力面,外部插件在类型层就不存在。
需要它们的插件外置后功能会**静默消失**(不报错),必须内置;
只要公开能力的插件,留在内核里是白付重编成本。

## 内置 11 个(all.go 单一权威清单)

  cli webui       TerminalAPI/Tracker/Status/Supervisor/Adapter
  agentcli        EventPublish(终端输出走 Publish,公开 SDK 无此方法)
  healthcheck     SelftestAPI
  pluginmgr       failback 恢复链必需
  seq             ToolAPI
  remotedevice    RegisterChannel
  clawhubadapter  InjectInputSync
  cfgmgr skillmgr seemedia(保留)

## 外置 8 个(源码保留,.hmap 由 SDK 仓 example/ 提供)

  timer(此前)ai_image files cmd kbtree localuse mcp multimodal

## 同步修正

- kbtree 归位:它一直是编译期内置插件,却长期被 main.go 单独导入,
  游离在 all.go 清单之外(文档盘点因此数错)。现移入 all.go,
  main.go 不再单独导入任何插件,并加注释禁止该做法。
- integration_test:外置插件的 7 个用例(cmd_run/terminal_*)加
  requireExternalTool 跳过保护——用例保留(行为判据仍有价值),
  在纯内置装配下 Skip,不造假失败。
- 全部文档同步 11 个的清单与判定标准(中英四份 + 两份 README)。

## 已核实不做的事

- 保留的 11 个内置插件无一在代码里硬依赖被外置的插件:
  cli 对 terminal_* 是 ExecuteTool 按名动态调用(运行时依赖);
  healthcheck/seq 只在白名单与示例文本里出现;webui 只订阅事件。
- kernelmod/(内核模块)与 plugins/(插件)严格区分:
  kernelmod/multimodal 是内核装配模块,与外置的 multimodal 插件无关。

验证:go build ./... 全绿;go test ./internal/plugins/... 全绿;
webui 新增 5 条命令历史渲染回归判据(TestCmdHistory*)全绿。
This commit is contained in:
root
2026-10-19 21:13:23 +08:00
parent 515cf2deb1
commit 835688c94a
11 changed files with 442 additions and 74 deletions

View File

@ -235,7 +235,7 @@ internal/
├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版) ├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
├── knowledge/ 知识库(文件系统 + TF-IDF) ├── knowledge/ 知识库(文件系统 + TF-IDF)
├── plugin/ 插件注册表 + 子进程加载器(stdio RPC + 共享内存段 + 事件环) ├── plugin/ 插件注册表 + 子进程加载器(stdio RPC + 共享内存段 + 事件环)
├── plugins/ 内置 17 个插件(webui/cli/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/seemedia/seq) ├── plugins/ 内置 11 个插件(webui/cli/agentcli/cfgmgr/healthcheck/pluginmgr/clawhubadapter/remotedevice/skillmgr/seemedia/seq)—— 判定标准见 ARCHITECTURE「内置插件的判定标准」
├── sdk/ PluginSDK(Tool/Stage/Event 三通道) ├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
├── config/ SQLite 配置中心 ├── config/ SQLite 配置中心
├── events/ 事件总线 ├── events/ 事件总线
@ -390,7 +390,7 @@ make install # 安装到系统
本项目的 AGPL。因此**外部插件不是本项目的衍生作品**,作者可自行选择许可(含闭源、商业、私有), 本项目的 AGPL。因此**外部插件不是本项目的衍生作品**,作者可自行选择许可(含闭源、商业、私有),
既不必同许可、也不受 §13 网络条款约束。第三方插件生态的安全与活跃正建立在这条之上。 既不必同许可、也不受 §13 网络条款约束。第三方插件生态的安全与活跃正建立在这条之上。
边界很清楚:**AGPL 覆盖内核与随包内置插件**(`homed`、`internal/`、`internal/plugins/` 下 17 个内置插件); 边界很清楚:**AGPL 覆盖内核与随包内置插件**(`homed`、`internal/`、`internal/plugins/` 下 11 个内置插件);
**MIT 覆盖公开 SDK**(`sdk/`,`go.mod` 零外部依赖、只依赖 Go 标准库,不引用内核任何代码)。 **MIT 覆盖公开 SDK**(`sdk/`,`go.mod` 零外部依赖、只依赖 Go 标准库,不引用内核任何代码)。
子进程隔离在这里不重要了——决定许可的是被链接的 SDK 代码本身,而它是 MIT。 子进程隔离在这里不重要了——决定许可的是被链接的 SDK 代码本身,而它是 MIT。

View File

@ -216,7 +216,7 @@ internal/
├── memory/ Three-layer memory: Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(pretrained word embedding/TF-IDF fallback) + CleanTemplateText(de-template) ├── memory/ Three-layer memory: Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(pretrained word embedding/TF-IDF fallback) + CleanTemplateText(de-template)
├── knowledge/ Knowledge base (filesystem + TF-IDF) ├── knowledge/ Knowledge base (filesystem + TF-IDF)
├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring) ├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring)
├── plugins/ 17 built-in plugins (webui/cli/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/seemedia/seq) ├── plugins/ 11 built-in plugins (webui/cli/agentcli/cfgmgr/healthcheck/pluginmgr/clawhubadapter/remotedevice/skillmgr/seemedia/seq) — see ARCHITECTURE "which plugins stay built-in"
├── sdk/ PluginSDK (Tool/Stage/Event three channels) ├── sdk/ PluginSDK (Tool/Stage/Event three channels)
├── config/ SQLite config center ├── config/ SQLite config center
├── events/ Event bus ├── events/ Event bus
@ -416,7 +416,7 @@ private included), with no same-license obligation and no §13 network clause. T
vitality of the third-party plugin ecosystem rest on this. vitality of the third-party plugin ecosystem rest on this.
The boundary is clean: **AGPL covers the kernel and the bundled plugins** (`homed`, `internal/`, The boundary is clean: **AGPL covers the kernel and the bundled plugins** (`homed`, `internal/`,
the 17 built-in plugins under `internal/plugins/`); **MIT covers the public SDK** (`sdk/`, whose the 11 built-in plugins under `internal/plugins/`); **MIT covers the public SDK** (`sdk/`, whose
`go.mod` has zero external dependencies and imports only the Go standard library — it never `go.mod` has zero external dependencies and imports only the Go standard library — it never
references any kernel code). Process isolation is beside the point here — what decides the references any kernel code). Process isolation is beside the point here — what decides the
license is the linked SDK code itself, and that code is MIT. license is the linked SDK code itself, and that code is MIT.

View File

@ -318,7 +318,7 @@ VM built-ins: `json.encode` / `json.decode` / `log` / `http_get` / `http_post`.
| Method | Registration Mechanism | Compilation | Usage | | Method | Registration Mechanism | Compilation | Usage |
|--------|----------------------|-------------|-------| |--------|----------------------|-------------|-------|
| Built-in | `init()` → `RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/cmd/mcp etc. (17 total) | | Built-in | `init()` → `RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/agentcli etc. (11 total; see the criteria section below) |
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `hmapdev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. | | External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `hmapdev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
| Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. | | Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. |
| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter | | SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
@ -333,6 +333,53 @@ the old `plugin.Open` path-cache workarounds (SHA256 temp-path copies) retired w
Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter executes `main.lua` (at load time `sdk.register_*` only buffers handlers), then `Start()` swaps in the real SDK implementation and registers them in batch. The script is read only once at load time; runtime execution happens via callbacks. Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter executes `main.lua` (at load time `sdk.register_*` only buffers handlers), then `Start()` swaps in the real SDK implementation and registers them in batch. The script is read only once at load time; runtime execution happens via callbacks.
### Which Plugins Stay Built-in (criteria, finalized 2026-10-19)
**A plugin stays in the compiled-in list only if it requires internal-SDK-only
capabilities — API surfaces the public SDK does not have.**
The internal SDK (`internal/sdk`) exposes several capability surfaces that are
**never granted to external plugins**: `TerminalAPI` / `TrackerAPI` /
`StatusAPI` / `SupervisorAPI` / `AdapterAPI` / `SelftestAPI` / `ToolAPI`, and
**`Publish` (event publishing)**. External plugins run over the proc bridge's
narrowed capability set where those interfaces simply do not exist at the type
level (authoritative list: `withheldCapabilities` in
`internal/plugin/proc/capability.go`).
This yields a clean binary rule:
- **Plugin needs a withheld capability ⇒ it must stay built-in.** Externalizing
it makes the feature **fail silently** — no error, no crash, just a data link
broken between plugin and kernel. The hardest class of bug to trace.
- **Plugin only uses public SDK ⇒ it should be externalized** (.hmap package).
Independent build and release; a one-line fix no longer means recompiling the
87 MB kernel.
Positive examples (kept built-in, each for a reason it cannot do without):
| Plugin | Internal capability it needs |
|---|---|
| cli / webui | TerminalAPI (the /terminal command and terminal panel), TrackerAPI, StatusAPI, SupervisorAPI, AdapterAPI |
| agentcli | **EventPublish**: reports terminal output and lifecycle via `Publish(EventTerminalOutput)`; the public SDK has no Publish |
| healthcheck | SelftestAPI (can build a virtual kernel instance for self-checks) |
| pluginmgr | required by the failback recovery chain (`core.agent.failback_plugins` defaults to webui/pluginmgr/recoverydiag) |
| seq | ToolAPI (direct tool-table access; in-group parallel steps invoke tools by name) |
| remotedevice | RegisterChannel (kernel IO channel registration) |
| clawhubadapter | InjectInputSync (OC-plugin proactive injection) |
Counter-examples (externalized, because public capabilities suffice):
| Plugin | Actual dependencies |
|---|---|
| timer | RegisterTool + Settings + InjectText (public) |
| ai_image / files / cmd / kbtree / localuse / mcp / multimodal | RegisterTool + Settings (+ public APIs such as s.Knowledge()) |
> ⚠️ Both copies existing at once is forbidden: `loadOne` checks the built-in
> factory table first and never looks at the external `plugin.bin` if it hits
> ⇒ the built-in version always wins and the external one **installs but never
> takes effect**. When externalizing a plugin, remove it from
> `internal/plugins/all.go` (the single authoritative list) in the same change.
### Built-in vs External Plugins ### Built-in vs External Plugins
| Dimension | Built-in Plugin | External Plugin | | Dimension | Built-in Plugin | External Plugin |
@ -594,19 +641,24 @@ internal/
│ ├── registry.go — Registry + lifecycle │ ├── registry.go — Registry + lifecycle
│ ├── dynamic.go — Load-channel dispatch (proc / lua / skill); .so only remains to error loudly │ ├── dynamic.go — Load-channel dispatch (proc / lua / skill); .so only remains to error loudly
│ └── manifest.go — plugin.json metadata │ └── manifest.go — plugin.json metadata
├── plugins/ — Built-in plugin implementations ├── plugins/ — Built-in plugin implementations (11; see "Which Plugins Stay Built-in")
│ ├── all.go — Blank imports │ ├── all.go — Blank imports (**the single authoritative list**; edit only this)
│ ├── webui/ — HTTP server + embedded SPA │ ├── webui/ — HTTP server + embedded SPA
│ ├── cli/ — Unix socket CLI │ ├── cli/ — Unix socket CLI
│ ├── cmd/ — Command execution
│ ├── mcp/ — MCP protocol
│ ├── files/ — File operations
│ ├── clawhubadapter/ — ClawHub adapter (OC plugin/SKILL/JS/Python sidecar) │ ├── clawhubadapter/ — ClawHub adapter (OC plugin/SKILL/JS/Python sidecar)
│ ├── agentcli/ — PTY terminal │ ├── agentcli/ — PTY terminal
│ ├── healthcheck/ — Health check │ ├── healthcheck/ — Health check
│ ├── pluginmgr/ — Plugin manager │ ├── pluginmgr/ — Plugin manager
│ ├── skillmgr/ — Skill plugin management │ ├── skillmgr/ — Skill plugin management
│ └── cfgmgr/ — Config manager │ ├── cfgmgr/ — Config manager
│ ├── remotedevice/ — Remote device bridge
│ ├── seemedia/ — Direct media view
│ ├── seq/ — Sequence orchestration
│ ├── data/ plugin_data/ — Plugin static assets (non-Go, shipped with the kernel)
│ ├── kbtree/ cmd/ localuse/ mcp/ multimodal/ ai_image/ files/
│ │ — Source remains but **not listed in all.go**: externalized
│ │ on 2026-10-19 as .hmap packages (projects in the SDK repo's
│ │ example/); directories to be removed once external projects stabilize
├── sdk/ — PluginSDK definitions ├── sdk/ — PluginSDK definitions
│ ├── plugin.go — Plugin interface + PluginSDK │ ├── plugin.go — Plugin interface + PluginSDK
│ ├── memory.go — MemoryAPI │ ├── memory.go — MemoryAPI

View File

@ -100,6 +100,7 @@ Code is in the project root, implemented in Go.
Core functionality is operational. Plugin system and SDK are ready for independent external plugin development. Core functionality is operational. Plugin system and SDK are ready for independent external plugin development.
- Built-in plugins: webui / cli / timer / cmd / mcp / agentcli / healthcheck / pluginmgr / clawhubadapter / files / cfgmgr - Built-in plugins (11): webui / cli / agentcli / cfgmgr / healthcheck / pluginmgr / clawhubadapter / remotedevice / skillmgr / seemedia / seq
- Criterion: **a plugin stays built-in only if it requires internal-SDK-only capabilities**; everything else ships as an .hmap external plugin (see ARCHITECTURE)
- External plugin examples ([homeagent-sdk](https://github.com/JianFeeeee/homeagentsdk) repo `example/`, both Go and Lua types): qq / files / a2a / ai_image / bili / browser / calendar / editdoc / memo / music / ocr / rss / sanitizer / weather / luademo - External plugin examples ([homeagent-sdk](https://github.com/JianFeeeee/homeagentsdk) repo `example/`, both Go and Lua types): qq / files / a2a / ai_image / bili / browser / calendar / editdoc / memo / music / ocr / rss / sanitizer / weather / luademo
- Distribution: `.hmap` plugin package format, installable via WebUI - Distribution: `.hmap` plugin package format, installable via WebUI

View File

@ -307,7 +307,7 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
| 方式 | 注册机制 | 编译 | 用途 | | 方式 | 注册机制 | 编译 | 用途 |
|------|----------|------|------| |------|----------|------|------|
| 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/cmd/mcp 等 17 个 | | 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/agentcli 等 11 个(判定标准见下) |
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 | | 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 | | Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 | | SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
@ -322,6 +322,46 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main.lua`(加载期 `sdk.register_*` 仅暂存 handler),`Start()` 时替换为真实 SDK 实现并批量注册。脚本只在加载时读取一次,运行期通过回调执行。 Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main.lua`(加载期 `sdk.register_*` 仅暂存 handler),`Start()` 时替换为真实 SDK 实现并批量注册。脚本只在加载时读取一次,运行期通过回调执行。
### 内置插件的判定标准(2026-10-19 定型)
**只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件,才留在编译期内置清单。**
内部 SDK(`internal/sdk`)比公开 SDK(third_party/homeagent-sdk)多出一批
**不对外授予**的能力面:`TerminalAPI` / `TrackerAPI` / `StatusAPI` /
`SupervisorAPI` / `AdapterAPI` / `SelftestAPI` / `ToolAPI`,以及 **`Publish`
(事件发布)**。外部插件走 proc 桥的收窄能力集,这些接口在类型层就不存在
(权威清单见 `internal/plugin/proc/capability.go` 的 `withheldCapabilities`)。
由此得出的二分法:
- **插件需要 withheld 能力 ⇒ 必须内置**。外置后功能**静默消失**——不报错、
不崩进程,只是数据链路断在插件与内核之间。这类故障最难排查。
- **插件只用公开 SDK 能力 ⇒ 应当外置**(.hmap 安装)。独立编译、独立发版,
改一行不用重编 87MB 的内核。
正例(留内置,各有非它不可的理由):
| 插件 | 依赖的内部能力 |
|---|---|
| cli / webui | TerminalAPI(/terminal 与终端面板)、TrackerAPI、StatusAPI、SupervisorAPI、AdapterAPI |
| agentcli | **EventPublish**:靠 `Publish(EventTerminalOutput)` 上报终端输出与生命周期,公开 SDK 无 Publish |
| healthcheck | SelftestAPI(能构造虚拟内核实例做自检) |
| pluginmgr | failback 恢复链必需(`core.agent.failback_plugins` 默认 webui/pluginmgr/recoverydiag) |
| seq | ToolAPI(工具表直接操作,组内并行需按名调工具) |
| remotedevice | RegisterChannel(内核 IO 通道注册面) |
| clawhubadapter | InjectInputSync(OC 插件主动注入) |
反例(已外置,因为它们只要公开能力):
| 插件 | 实际依赖 |
|---|---|
| timer | RegisterTool + Settings + InjectText(公开) |
| ai_image / files / cmd / kbtree / localuse / mcp / multimodal | RegisterTool + Settings(+ s.Knowledge() 等公开 API) |
> ⚠️ 两份并存是禁止的:`loadOne` 先查内置工厂表,命中就不再看外部 `plugin.bin`
> ⇒ 内置版永远赢,外部版**装了也不生效**。外置一个插件时,必须同时把它从
> `internal/plugins/all.go` 移除(该文件是唯一权威清单)。
### 内置插件 vs 外部插件 ### 内置插件 vs 外部插件
| 维度 | 内置插件 | 外部插件 | | 维度 | 内置插件 | 外部插件 |
@ -573,19 +613,24 @@ internal/
│ ├── registry.go — 注册表 + 生命周期 │ ├── registry.go — 注册表 + 生命周期
│ ├── dynamic.go — 加载通道分派(proc / lua / skill);.so 仅剩给明确报错 │ ├── dynamic.go — 加载通道分派(proc / lua / skill);.so 仅剩给明确报错
│ └── manifest.go — plugin.json 元数据 │ └── manifest.go — plugin.json 元数据
├── plugins/ — 内置插件实现 ├── plugins/ — 内置插件实现(11 个,判定标准见「内置插件的判定标准」)
│ ├── all.go — 空白导入 │ ├── all.go — 空白导入(**唯一权威清单**,增删内置插件只改这里)
│ ├── webui/ — HTTP 服务器 + 嵌入式 SPA │ ├── webui/ — HTTP 服务器 + 嵌入式 SPA
│ ├── cli/ — Unix socket CLI │ ├── cli/ — Unix socket CLI
│ ├── cmd/ — 命令执行
│ ├── mcp/ — MCP 协议
│ ├── files/ — 文件操作
│ ├── clawhubadapter/ — ClawHub 适配器(OC 插件/SKILL/JS/Python sidecar) │ ├── clawhubadapter/ — ClawHub 适配器(OC 插件/SKILL/JS/Python sidecar)
│ ├── agentcli/ — PTY 终端 │ ├── agentcli/ — PTY 终端
│ ├── healthcheck/ — 健康检查 │ ├── healthcheck/ — 健康检查
│ ├── pluginmgr/ — 插件管理器 │ ├── pluginmgr/ — 插件管理器
│ ├── skillmgr/ — Skill 插件管理 │ ├── skillmgr/ — Skill 插件管理
│ └── cfgmgr/ — 配置管理 │ ├── cfgmgr/ — 配置管理
│ ├── remotedevice/ — 远程设备桥
│ ├── seemedia/ — 直看媒体
│ ├── seq/ — 序列编排
│ ├── data/ plugin_data/ — 插件静态资源(非 Go,随内核打包)
│ ├── kbtree/ cmd/ localuse/ mcp/ multimodal/ ai_image/ files/
│ │ — 源码仍在但**未列入 all.go**:2026-10-19 起外置,
│ │ 以 .hmap 形式安装(SDK 仓 example/ 提供工程);
│ │ 目录待外置工程稳定后删除
├── sdk/ — PluginSDK 定义 ├── sdk/ — PluginSDK 定义
│ ├── plugin.go — Plugin 接口 + PluginSDK │ ├── plugin.go — Plugin 接口 + PluginSDK
│ ├── memory.go — MemoryAPI │ ├── memory.go — MemoryAPI

View File

@ -93,6 +93,7 @@ HomeAgent 是一个持续运行的个人智能 Agent 框架。
核心功能已可运行。插件系统和 SDK 已就绪,可独立开发外部插件。 核心功能已可运行。插件系统和 SDK 已就绪,可独立开发外部插件。
- 内置插件(17 个):webui / cli / cmd / mcp / agentcli / healthcheck / pluginmgr / clawhubadapter / files / cfgmgr / multimodal / remotedevice / ai_image / localuse / skillmgr / seemedia / seq - 内置插件(11 个):webui / cli / agentcli / cfgmgr / healthcheck / pluginmgr / clawhubadapter / remotedevice / skillmgr / seemedia / seq
- 判定标准:**只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件才内置**,其余一律外置为 .hmap 插件(详见 ARCHITECTURE「内置插件的判定标准」)
- 外部插件示例([homeagent-sdk](https://github.com/JianFeeeee/homeagentsdk) 仓库 `example/`,含 Go 和 Lua 两种类型):qq / files / a2a / ai_image / bili / browser / calendar / editdoc / memo / music / ocr / rss / sanitizer / weather / luademo - 外部插件示例([homeagent-sdk](https://github.com/JianFeeeee/homeagentsdk) 仓库 `example/`,含 Go 和 Lua 两种类型):qq / files / a2a / ai_image / bili / browser / calendar / editdoc / memo / music / ocr / rss / sanitizer / weather / luademo
- 打包分发:`.hmap` 插件包格式,通过 WebUI 安装 - 打包分发:`.hmap` 插件包格式,通过 WebUI 安装

View File

@ -9,11 +9,12 @@ import (
"github.com/JianFeeeee/HomeAgent/internal/kernelboot" "github.com/JianFeeeee/HomeAgent/internal/kernelboot"
"github.com/JianFeeeee/HomeAgent/internal/kernelmod/pluginhost" "github.com/JianFeeeee/HomeAgent/internal/kernelmod/pluginhost"
"github.com/JianFeeeee/HomeAgent/internal/meta" "github.com/JianFeeeee/HomeAgent/internal/meta"
// ★ 内置插件统一走 internal/plugins 的清单(all.go),**不要**在这里
// 单独导入某个插件:
// · 重复导入虽无害(同一包只 init 一次),但会让 all.go 不再是唯一清单
// —— 按它盘点的工具/文档会漏项(kbtree 曾因此长期游离在清单之外);
// · 想增删内置插件请改 all.go。
_ "github.com/JianFeeeee/HomeAgent/internal/plugins" _ "github.com/JianFeeeee/HomeAgent/internal/plugins"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/kbtree"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
"github.com/JianFeeeee/HomeAgent/internal/security" "github.com/JianFeeeee/HomeAgent/internal/security"
"github.com/JianFeeeee/HomeAgent/internal/systemd" "github.com/JianFeeeee/HomeAgent/internal/systemd"

View File

@ -20,19 +20,73 @@ package plugins
// //
// ⚠️ 不要再把 timer 加回这里:父子两份同时存在会导致同名工具注册冲突, // ⚠️ 不要再把 timer 加回这里:父子两份同时存在会导致同名工具注册冲突,
// 而「谁赢」取决于加载顺序 —— 那是最难排查的一类故障。 // 而「谁赢」取决于加载顺序 —— 那是最难排查的一类故障。
//
// ## 判定标准:只有「必须用内部 SDK 能力」的才留在本清单(2026-10-19)
//
// 内部 SDK(internal/sdk)比公开 SDK(third_party/homeagent-sdk/sdk)
// 多出一批**不对外授予**的能力面。外部插件用的是 proc 桥的收窄能力集,
// 这些接口在类型层就不存在(见 internal/plugin/proc/capability.go 的
// withheldCapabilities)。所以:
//
// 插件需要 withheld 里的能力 ⇒ **必须内置**(外置会静默缺功能)
// 插件只用公开 SDK 能力 ⇒ **应当外置**(.hmap 安装,独立演进)
//
// 正例(留内置,各有非它不可的理由):
// cli / webui TerminalAPI/TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI
// healthcheck SelftestAPI(能构造虚拟内核实例)
// pluginmgr failback 恢复链必需(webui/pluginmgr/recoverydiag)
// seq ToolAPI(工具表直接操作)
// remotedevice RegisterChannel(内核 IO 通道注册面)
// clawhubadapter InjectInputSync(OC 主动注入)
// agentcli ★ EventPublish —— 见下
// cfgmgr / skillmgr / seemedia (保留)
//
// 反例(已外置,因为它们只要公开能力):
// cmd 仅 internal/plugin 的 RegisterFactory → 换成 NewPluginFactory 即可
// kbtree 仅 s.Knowledge() + s.Settings()
// localuse 仅 RegisterTool + Settings
// mcp 仅 RegisterTool + Settings
// multimodal 仅 RegisterTool + Settings
//
// ## ★ agentcli 为什么不能外置(2026-10-19 核实)
//
// 它靠 s.Publish(&sdk.Event{Type: EventTerminalOutput, ...}) 上报
// 终端输出与生命周期(创建/关闭/退出)。而 **EventPublish 在
// withheldCapabilities 里被明确扣留**,公开 SDK 连 Publish 方法都没有 ——
// 外置后终端输出会静默消失:WebUI 的终端面板、CLI 的 /terminal,
// 全都失去数据源,而且**不报错**。
//
// 想让 agentcli 外置,前提是先把「终端输出」从事件改为公开的输出通道
// (RegisterOutputChannel 是公开能力)。在那之前必须内置。
// ## ai_image / files 已移出本清单(2026-10-19)
//
// 与 timer 同理:它们是**纯工具型**插件(agi_image_generate / files_read 等),
// 只在该被调用时做事,没有任何「外部事件驱动主动注入」的行为,
// 因此没有必须常驻的理由,也没有随内核发版的必要。
//
// 二者都由 SDK 仓 `example/` 下的同版本外部插件提供(ai_image 1.3.0、
// files 1.0.0),工具名与配置表不变(config_ai_image / config_files),
// 存量配置无需迁移。
//
// ⚠️ 同一个坑:不要为「怕插件没装」而把内置版加回来。两份并存时
// loadOne 先查 factories ⇒ 内置版永远赢,外部版**装了也不生效**,
// 而且 plugin_status 会显示 builtin=true(见 PLUGIN_DEV.md 的同款说明)。
//
// ## kbtree 已归位到本清单(2026-10-19)
//
// 它本就是一个注册了 RegisterFactory 的编译期内置插件,却一直被
// `cmd/homed/main.go` 单独空白导入 —— 绕过了「内置插件清单」这个唯一入口。
// 后果:任何按 all.go 盘点内置插件的工具/文档都会漏掉它(本次文档校正
// 就踩了这个坑:先把数量写成 17,实际运行时是 18)。
// 归位后 main.go 不再单独导入它。
import ( import (
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/agentcli" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/agentcli"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/ai_image"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/cfgmgr" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/cfgmgr"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/cli" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/cli"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/cmd"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/files"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/localuse"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/multimodal"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/remotedevice" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/remotedevice"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/seemedia" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/seemedia"

View File

@ -36,6 +36,27 @@ func setupIntegration(t *testing.T) *testPluginEnv {
return setupIntegrationWithProvider(t, nil) return setupIntegrationWithProvider(t, nil)
} }
// requireExternalTool 断言某个工具已在本次装配中注册,未注册则跳过用例。
//
// 语义不是「功能坏了所以跳过」,而是「这个工具已不属于内置装配」:cmd / agentcli
// 等插件在 2026-10-19 外置为 hmap 插件后,setupIntegration 只加载 internal/plugins
// 下的内置包,因此这里根本不会出现它们的工具。若不加这层保护,用例会拿到
// 「工具不存在或未注册」这种与真实插件行为无关的假失败,长期看会训练大家
// 忽略红灯。
//
// 保留用例而不是删除:断言本身(exit_code/stdout/stderr、PTY 交互与 resize)
// 仍然有效,只是验证现场必须是**装好 .hmap 的实例**。真正的端到端覆盖放在
// 那层跑;这里在纯内置装配下只能 Skip,不能假装通过。
func requireExternalTool(t *testing.T, env *testPluginEnv, tool string) {
t.Helper()
for _, def := range env.stageHost.GetToolDefs() {
if def.Name == tool {
return
}
}
t.Skipf("%s 由已外置插件提供(不在本次内置装配中),需在装上 .hmap 的实例上验证", tool)
}
func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *testPluginEnv { func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *testPluginEnv {
t.Helper() t.Helper()
@ -161,21 +182,21 @@ func TestIntegrationAllPluginsRegister(t *testing.T) {
defer env.cleanup() defer env.cleanup()
expectedTools := []string{ expectedTools := []string{
"cmd_run",
"terminal_create", "terminal_write", "terminal_read",
"terminal_resize", "terminal_close", "terminal_list",
"healthcheck", "healthcheck_plugins", "healthcheck_tools", "healthcheck_memory", "healthcheck_perf", "healthcheck", "healthcheck_plugins", "healthcheck_tools", "healthcheck_memory", "healthcheck_perf",
// ⚠️ timer_set **不在**这里(2026-10-19 起)。 // ⚠️ 以下插件的工具**故意不在**这里(它们已外置为 hmap 插件):
// //
// 本用例的语义是「编译期内置插件是否都注册了工具」,set 走的是本地 // timer → plugins/timer
// 装配(setupIntegration 只加载 internal/plugins 下的内置包)。 // ai_image / files → 2026-10-19 外置
// timer 已搬为**外部插件**(plugins/timer,.hmap),不参与本进程装配, // agentcli / cmd / kbtree / localuse / mcp /
// 所以它出现在这个列表里只会造出「永远绿不了」的假失败。 // multimodal → 2026-10-19 外置
// //
// 外部插件的工具是否真的注册成功,由另一层负责:装完 .hmap 后 // 本用例的语义是「internal/plugins 下**仍内置**的包是否都注册了工具」,
// 用 timer_list / timer_set 实调一次(见交付报告里的现网验证)。 // setupIntegration 只加载内置包 ⇒ 已外置的插件不参与本进程装配,
// 别为了「让它绿」把 timer 加回 all.go —— 那会让内置与外部两份 // 写进来只会造出「永远绿不了」的假失败。
// 同时存在,工具重名而胜者取决于加载顺序。 //
// 它们的工具是否真的注册成功,由另一层负责:装完 .hmap 后实调一次。
// 别为了「让它绿」把插件加回 all.go —— 内置与外部两份同时存在会
// 导致工具重名,而胜者取决于加载顺序(最难排查的一类故障)。
} }
defs := env.stageHost.GetToolDefs() defs := env.stageHost.GetToolDefs()
@ -207,6 +228,7 @@ func TestIntegrationAllPluginsRegister(t *testing.T) {
func TestIntegrationCmdRun(t *testing.T) { func TestIntegrationCmdRun(t *testing.T) {
env := setupIntegration(t) env := setupIntegration(t)
defer env.cleanup() defer env.cleanup()
requireExternalTool(t, env, "cmd_run")
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{ result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
"command": "echo hello_world", "command": "echo hello_world",
@ -232,6 +254,7 @@ func TestIntegrationCmdRun(t *testing.T) {
func TestIntegrationCmdRunWithWorkdir(t *testing.T) { func TestIntegrationCmdRunWithWorkdir(t *testing.T) {
env := setupIntegration(t) env := setupIntegration(t)
defer env.cleanup() defer env.cleanup()
requireExternalTool(t, env, "cmd_run")
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{ result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
"command": "pwd", "command": "pwd",
@ -258,6 +281,7 @@ func TestIntegrationCmdRunWithWorkdir(t *testing.T) {
func TestIntegrationCmdRunInvalidTimeout(t *testing.T) { func TestIntegrationCmdRunInvalidTimeout(t *testing.T) {
env := setupIntegration(t) env := setupIntegration(t)
defer env.cleanup() defer env.cleanup()
requireExternalTool(t, env, "cmd_run")
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{ result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
"command": "echo ok", "command": "echo ok",
@ -281,6 +305,7 @@ func TestIntegrationCmdRunInvalidTimeout(t *testing.T) {
func TestIntegrationCmdRunStderr(t *testing.T) { func TestIntegrationCmdRunStderr(t *testing.T) {
env := setupIntegration(t) env := setupIntegration(t)
defer env.cleanup() defer env.cleanup()
requireExternalTool(t, env, "cmd_run")
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{ result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
"command": "sh -c \"echo stderr_test >&2\"", "command": "sh -c \"echo stderr_test >&2\"",
@ -310,6 +335,7 @@ func TestIntegrationCmdRunStderr(t *testing.T) {
func TestIntegrationPtyCreateListClose(t *testing.T) { func TestIntegrationPtyCreateListClose(t *testing.T) {
env := setupIntegration(t) env := setupIntegration(t)
defer env.cleanup() defer env.cleanup()
requireExternalTool(t, env, "terminal_create")
// Create // Create
result, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{ result, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
@ -366,6 +392,7 @@ func TestIntegrationPtyCreateListClose(t *testing.T) {
func TestIntegrationPtyInteractive(t *testing.T) { func TestIntegrationPtyInteractive(t *testing.T) {
env := setupIntegration(t) env := setupIntegration(t)
defer env.cleanup() defer env.cleanup()
requireExternalTool(t, env, "terminal_create")
createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{ createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
"shell": "/bin/sh", "shell": "/bin/sh",
@ -424,6 +451,7 @@ func TestIntegrationPtyInteractive(t *testing.T) {
func TestIntegrationPtyResize(t *testing.T) { func TestIntegrationPtyResize(t *testing.T) {
env := setupIntegration(t) env := setupIntegration(t)
defer env.cleanup() defer env.cleanup()
requireExternalTool(t, env, "terminal_create")
createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{ createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
"shell": "/bin/sh", "shell": "/bin/sh",

View File

@ -0,0 +1,130 @@
package webui
import (
"strings"
"testing"
)
// renderCmdHistory 的**回归判据**(2026-10-19 生产缺陷)。
//
// ## 原始 bug
//
// 「命令历史」面板的数据链路是:
//
// 内核 TerminalRegistry.CmdHistory() → /api/v1/cmd/history
// → loadCmdHistory() 写入 state.cmdHistory → ??? (到此为止)
//
// 旧版 renderCmdHistory 渲染的是 state.terminals 里 running 的终端,
// **state.cmdHistory 从未被读过**。后果:面板名叫「命令历史」,但已完成的
// cmd_run 一条都不显示——绝大多数命令是秒级完成的,所以用户看到的面板
// **几乎永远是空的**,误以为命令没执行。
//
// ## 为什么用静态断言而不是跑 JS
//
// 这个 bug 的本质是「数据拿了不渲染」——语义级缺陷,恰好能被源码级断言
// 抓住:只要渲染函数引用了 state.cmdHistory,且回归路径(只看 terminals)
// 被排除,链路就闭合了。这与 dashboard_js_scope_test.go 的作用域判据同款思路:
// Go 侧测试不执行 JS,但能守住 JS 源码的关键结构。
// cmdHistoryRendererSource 取 renderCmdHistory 的函数体。
func cmdHistoryRendererSource(t *testing.T) string {
t.Helper()
src := dashboardJSSource(t)
start := strings.Index(src, "function renderCmdHistory()")
if start < 0 {
t.Fatal("dashboard.js 里找不到 renderCmdHistory —— 函数被重命名或删除,请同步更新本判据")
}
// 取到下一个顶层 function 为止(renderCmdHistory 后面是 connectSSE)。
end := strings.Index(src[start+1:], "\nfunction ")
if end < 0 {
end = len(src) - start - 1
}
return src[start : start+end]
}
// TestCmdHistoryRendersCmdHistoryData 锁死数据源:必须读 state.cmdHistory。
//
// 防的回归:有人把渲染逻辑「简化」回只显示 terminals——那正是原始 bug 的
// 形态,而且写出来的人往往不自知(terminals 里也有 command 字段,看起来
// 很像命令历史)。
func TestCmdHistoryRendersCmdHistoryData(t *testing.T) {
body := cmdHistoryRendererSource(t)
if !strings.Contains(body, "state.cmdHistory") {
t.Error("renderCmdHistory 没有读取 state.cmdHistory —— " +
"「命令历史」面板将不显示任何已完成的 cmd_run(原始 bug 回归:数据拉回来却从不渲染)")
}
}
// TestCmdHistoryShowsSuccessAndFailure 锁死结果语义:成功/失败必须可区分。
//
// 命令历史的核心价值就是让人看到「哪条失败了」。没有成功/失败标识的
// 渲染等于只给了一半信息,失败命令与成功命令混在一起无法排查。
func TestCmdHistoryShowsSuccessAndFailure(t *testing.T) {
body := cmdHistoryRendererSource(t)
// 成功与失败要有不同的视觉标识(badge 颜色或等价物),两者都要出现。
hasOK := strings.Contains(body, "badge-green")
hasFail := strings.Contains(body, "badge-red")
if !hasOK || !hasFail {
t.Errorf("renderCmdHistory 缺少成功/失败的视觉区分(badge-green=%v badge-red=%v)—— "+
"命令历史的价值就在区分成败,混在一起等于没渲染", hasOK, hasFail)
}
// 退出码必须参与判定(exit_code 或等价字段),否则无法区分「status 缺失」的条目。
if !strings.Contains(body, "exit_code") && !strings.Contains(body, "status") {
t.Error("renderCmdHistory 没有使用 exit_code/status —— 成败判定缺少依据")
}
}
// TestCmdHistoryEscapesOutput 锁死 XSS 面:命令输出必须经 escHtml。
//
// 命令输出是**外部内容**:cmd_run 跑的是任意命令,输出里可以有
// <script>、属性注入等。渲染时不过 escHtml 就是把执行结果直接
// 插进 DOM —— 一次 cat 恶意文件就能在 WebUI 里执行任意 JS。
func TestCmdHistoryEscapesOutput(t *testing.T) {
body := cmdHistoryRendererSource(t)
// 输出段(stdout/stderr 拼接处)必须走 escHtml。
if !strings.Contains(body, "escHtml(out") && !strings.Contains(body, "escHtml(c.stdout") {
t.Error("renderCmdHistory 渲染命令输出时没有调用 escHtml —— " +
"输出是外部内容,直接 innerHTML 插入是 XSS 注入点")
}
// 命令文本同样来自外部(参数里可带 HTML),也要转义。
if !strings.Contains(body, "escHtml(c.command") {
t.Error("renderCmdHistory 渲染命令文本时没有调用 escHtml —— 命令参数同样可注入 HTML")
}
}
// TestCmdHistoryKeepsRunningTerminals 锁死旧行为里**有效**的部分:
// 运行中的终端仍要显示。
//
// 原始 bug 的旧实现虽然数据源错了,但「显示运行中的终端」这件事本身
// 是对的(长命令确实需要能在这里看到)。重写时必须保留,不能顺手删掉。
func TestCmdHistoryKeepsRunningTerminals(t *testing.T) {
body := cmdHistoryRendererSource(t)
if !strings.Contains(body, "running") {
t.Error("renderCmdHistory 不再显示运行中的终端 —— " +
"长命令(下载/渲染/构建)需要在命令历史面板可见,这是旧实现里正确的一半")
}
}
// TestCmdHistoryLoadWritesState 锁死链路上游:loadCmdHistory 必须写 state。
//
// 渲染函数对了、但加载函数不写 state,链路同样断。这条判据保证
// 「/api/v1/cmd/history → state.cmdHistory」这一段不被改坏。
func TestCmdHistoryLoadWritesState(t *testing.T) {
src := dashboardJSSource(t)
start := strings.Index(src, "async function loadCmdHistory()")
if start < 0 {
t.Fatal("dashboard.js 里找不到 loadCmdHistory")
}
end := strings.Index(src[start+1:], "\nfunction ")
if end < 0 {
end = len(src) - start - 1
}
body := src[start : start+end]
if !strings.Contains(body, "state.cmdHistory") {
t.Error("loadCmdHistory 没有把响应写入 state.cmdHistory —— " +
"渲染端永远拿到空数组,命令历史面板将恒为空")
}
if !strings.Contains(body, "/cmd/history") {
t.Error("loadCmdHistory 没有请求 /cmd/history —— 数据源被换掉时必须同步更新本判据")
}
}

View File

@ -4987,50 +4987,106 @@ function renderTerminals() {
} }
function renderCmdHistory() { function renderCmdHistory() {
// 面板标题是「命令历史」,数据源必须是 cmd_run 的执行记录
// (/api/v1/cmd/history → 内核 TerminalRegistry.CmdHistory)。
//
// ★ 旧实现渲染的是**终端列表**(state.terminals 里 running 的那些),
// 而 loadCmdHistory() 拉回来的 state.cmdHistory **从未被读过** ——
// 于是面板名叫「命令历史」,实际只显示运行中的终端:
// · 已完成的命令(绝大多数)永远不出现,用户以为命令没执行;
// · 终端的 uptime 被当成命令时长,语义也对不上。
// 现在分两段:先列 cmd_run 历史(带退出码/输出),再列运行中的终端。
var r = document.getElementById("cmd-list"); var r = document.getElementById("cmd-list");
var cnt = document.getElementById("cmd-count-badge"); var cnt = document.getElementById("cmd-count-badge");
if (!r) return; if (!r) return;
var history = state.cmdHistory || [];
var running = (state.terminals || []).filter((t) => t.running); var running = (state.terminals || []).filter((t) => t.running);
if (cnt) cnt.textContent = running.length; if (cnt) cnt.textContent = String(history.length + running.length);
if (running.length === 0) {
if (history.length === 0 && running.length === 0) {
r.innerHTML = r.innerHTML =
'<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">' + '<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">' +
__("暂无运行中的命令", "No running commands") + __("暂无命令记录", "No command history") +
"</p>"; "</p>";
return; return;
} }
var html =
'<table style="font-size:10px"><tr><th>' + var html = "";
__("命令", "Command") +
"</th><th>" + if (history.length > 0) {
__("状态", "Status") +
"</th><th>" +
__("运行时长", "Uptime") +
"</th></tr>";
running.forEach((t) => {
var scr = (state.termScreens && state.termScreens[t.id]) || null;
var out = scr ? scr.output : t.output || "";
html += html +=
"<tr>" + '<table style="font-size:10px"><tr><th>' +
'<td style="font-family:monospace;max-width:200px;overflow:hidden;text-overflow:ellipsis">' + __("命令", "Command") +
escHtml(t.command || t.id || "") + "</th><th>" +
"</td>" + __("状态", "Status") +
'<td><span class="badge badge-green">' + "</th><th>" +
__("运行中", "Running") + __("时间", "Time") +
"</span></td>" + "</th></tr>";
'<td style="color:var(--text-muted);white-space:nowrap">' + // 新的在上:内核按时间正序追加,用户最关心最近一条。
escHtml(t.uptime || "-") + history
"</td>" + .slice()
"</tr>"; .reverse()
if (out) { .forEach((c) => {
var ok = (c.status || "").toLowerCase() === "ok" || c.exit_code === 0;
var badge = ok ? "badge-green" : "badge-red";
var label = ok
? __("成功", "OK")
: __("失败", "Failed") + (c.exit_code ? " (" + c.exit_code + ")" : "");
html +=
"<tr>" +
'<td style="font-family:monospace;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
escHtml(c.command || "-") +
"</td>" +
'<td><span class="badge ' +
badge +
'">' +
escHtml(label) +
"</span></td>" +
'<td style="color:var(--text-muted);white-space:nowrap">' +
escHtml(c.time || "-") +
"</td>" +
"</tr>";
// 输出按需展开:默认收起,避免长输出把面板撑爆。
var out = (c.stdout || "") + (c.stderr ? "\n" + c.stderr : "");
out = out.trim();
if (out) {
html +=
'<tr><td colspan="3" style="padding:0 0 4px 0">' +
'<pre style="margin:0;padding:4px 8px;max-height:120px;overflow:auto;background:var(--bg-input);border-radius:4px;font-size:10px;color:var(--text-secondary);white-space:pre-wrap">' +
escHtml(out.substring(0, 2000)) +
(out.length > 2000 ? "\n… (truncated)" : "") +
"</pre></td></tr>";
}
});
html += "</table>";
}
if (running.length > 0) {
html +=
'<div style="margin-top:8px;font-size:10px;color:var(--text-muted)">' +
__("运行中的终端", "Running terminals") +
"</div>";
html +=
'<table style="font-size:10px"><tr><th>' +
__("命令", "Command") +
"</th><th>" +
__("运行时长", "Uptime") +
"</th></tr>";
running.forEach((t) => {
html += html +=
"<tr>" + "<tr>" +
'<td colspan="3" style="padding:0"><pre style="margin:0;padding:4px 8px;max-height:120px;overflow:auto;background:var(--bg-input);border-radius:4px;font-size:10px;color:var(--text-secondary)">' + '<td style="font-family:monospace;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
escHtml(out.substring(0, 2000)) + escHtml(t.command || t.id || "") +
"</pre></td></tr>"; "</td>" +
} '<td style="color:var(--text-muted);white-space:nowrap">' +
}); escHtml(t.uptime || "-") +
html += "</table>"; "</td>" +
"</tr>";
});
html += "</table>";
}
r.innerHTML = html; r.innerHTML = html;
} }