refactor(terminal): 终端逻辑回归 agentcli,内核不再编码终端语义

按用户指正调整架构:终端是 agentcli 的职责,内核不该有终端逻辑;插件与
agentcli 的联动应走事件总线,而不是内核内部组件。

## 删掉内核里的终端语义

`internal/agent/core/terminal_registry.go`(280 行)整体删除。它做了两件不该
内核做的事:定义「什么是终端会话」、从 EventToolCall 的 Go map 文本里解析
terminal id / command 并归并会话与命令历史。连带移除:

- Agent 的 terminalReg 字段、构造、subscribeTerminalRegistry 订阅
- StatusAPI 上的 ListTerminals / CmdHistory
- sdk.TerminalAPI + PluginSDK.Terminal() + Registry.SetTerminalAPI 及接线
- 对应测试(迁到插件侧视图包)

保留 `sdk.TerminalStatus` / `CmdExecStatus`:它们只是线上 JSON 格式,WebUI/CLI
返回给前端时口径一致。

## 插件侧订阅事件总线自攒视图

新增 `internal/pluginview`(TerminalView):把原先内核那份归并逻辑原样搬过来,
由插件自己持有。数据源仍是两条总线事件:

    EventTerminalOutput(agentcli 发)→ 会话输出 + 生命周期
    EventToolCall      (内核发)    → cmd_run 历史、terminal_create/close 归并

WebUI 在 NewHandler 订阅、Close 退订;CLI 在 Start 订阅、Stop 退订。
两边共用同一实现,避免历史上「两份推导各踩一次 Go map 文本」的坑。
视图不是权威:真值在 agentcli,插件晚于会话启动会缺早期会话(注释写明)。

## agentcli 保持内置

它现在仍需要 `Publish`(进程内事件总线写入)——外部插件没有上行事件通道
(`EventPublish` 在 withheldCapabilities 里,proc 只有 events.subscribe)。
按判定标准留内置;将来要外置,须先开一条受控的插件→内核上行事件通道,
本次刻意不开(用户决定:先保持内置)。

## 文档

中英 ARCHITECTURE 新增「终端归属」小节:说明职责边界、两条事件、视图非权威、
以及 agentcli 为何仍内置;内置判定表里 cli/webui 去掉 TerminalAPI 一项。

## 验收(本机实例实测)

- 内核编译通过,删除后 `go build ./... && go vet ./...` 干净,`go test ./internal/...` 全绿
- 实例重启后:让 agent 开终端跑 `echo term-view-test` →
  WebUI `GET /api/v1/terminals` 返回 term_1(output 正确、running=false)
  CLI `/terminals`(unix socket)返回同一份;
  两处都是从事件总线自攒,内核侧无终端代码参与
- cmd 历史分支由迁过来的单测覆盖(Go map 文本解析、生命周期、上限裁剪)
This commit is contained in:
root
2026-10-20 09:33:08 +08:00
parent b9097986c8
commit 3119d48625
17 changed files with 154 additions and 149 deletions

View File

@ -339,7 +339,7 @@ Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter exe
capabilities — API surfaces the public SDK does not have.** capabilities — API surfaces the public SDK does not have.**
The internal SDK (`internal/sdk`) exposes several capability surfaces that are The internal SDK (`internal/sdk`) exposes several capability surfaces that are
**never granted to external plugins**: `TerminalAPI` / `TrackerAPI` / **never granted to external plugins**: `TrackerAPI` /
`StatusAPI` / `SupervisorAPI` / `AdapterAPI` / `SelftestAPI` / `ToolAPI`, and `StatusAPI` / `SupervisorAPI` / `AdapterAPI` / `SelftestAPI` / `ToolAPI`, and
**`Publish` (event publishing)**. External plugins run over the proc bridge's **`Publish` (event publishing)**. External plugins run over the proc bridge's
narrowed capability set where those interfaces simply do not exist at the type narrowed capability set where those interfaces simply do not exist at the type
@ -359,7 +359,7 @@ Positive examples (kept built-in, each for a reason it cannot do without):
| Plugin | Internal capability it needs | | Plugin | Internal capability it needs |
|---|---| |---|---|
| cli / webui | TerminalAPI (the /terminal command and terminal panel), TrackerAPI, StatusAPI, SupervisorAPI, AdapterAPI | | cli / webui | TrackerAPI, StatusAPI, SupervisorAPI, AdapterAPI |
| agentcli | **EventPublish**: reports terminal output and lifecycle via `Publish(EventTerminalOutput)`; the public SDK has no Publish | | 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) | | 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) | | pluginmgr | required by the failback recovery chain (`core.agent.failback_plugins` defaults to webui/pluginmgr/recoverydiag) |
@ -392,6 +392,31 @@ Counter-examples (externalized, because public capabilities suffice):
> takes effect**. When externalizing a plugin, remove it from > takes effect**. When externalizing a plugin, remove it from
> `internal/plugins/all.go` (the single authoritative list) in the same change. > `internal/plugins/all.go` (the single authoritative list) in the same change.
### Terminal ownership: agentcli owns it, the kernel does not (2026-10-20)
Terminal logic belongs solely to `agentcli` (PTY, session lifecycle, output
buffering, history). The kernel **no longer** models terminal semantics — the
former `internal/agent/core/terminal_registry.go` is gone, because encoding
"what a terminal is and how to merge sessions out of tool calls" inside the
kernel was a layering mistake.
Plugins that need a terminal panel (WebUI / CLI) **subscribe to the event bus and
build their own view**:
| Event | Publisher | Purpose |
|---|---|---|
| `EventTerminalOutput` | agentcli | session output, create/close/timeout (`running` flips) |
| `EventToolCall` | kernel | `cmd_run` history, `terminal_create/close` merge |
WebUI and CLI share the `internal/pluginview` implementation so both parse
identically (historically two independent derivations each hit the same
"tool result is Go map text" bug). The view is not authoritative: the truth is in
agentcli, so a plugin starting after a session will miss its early events.
agentcli therefore needs `Publish` (not available to external plugins) and stays
built-in under the rule above; externalizing it would first require a controlled
plugin-to-kernel event channel, which is deliberately not opened today.
### Built-in vs External Plugins ### Built-in vs External Plugins
| Dimension | Built-in Plugin | External Plugin | | Dimension | Built-in Plugin | External Plugin |

View File

@ -327,7 +327,7 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
**只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件,才留在编译期内置清单。** **只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件,才留在编译期内置清单。**
内部 SDK(`internal/sdk`)比公开 SDK(third_party/homeagent-sdk)多出一批 内部 SDK(`internal/sdk`)比公开 SDK(third_party/homeagent-sdk)多出一批
**不对外授予**的能力面:`TerminalAPI` / `TrackerAPI` / `StatusAPI` / **不对外授予**的能力面:`TrackerAPI` / `StatusAPI` /
`SupervisorAPI` / `AdapterAPI` / `SelftestAPI` / `ToolAPI`,以及 **`Publish` `SupervisorAPI` / `AdapterAPI` / `SelftestAPI` / `ToolAPI`,以及 **`Publish`
(事件发布)**。外部插件走 proc 桥的收窄能力集,这些接口在类型层就不存在 (事件发布)**。外部插件走 proc 桥的收窄能力集,这些接口在类型层就不存在
(权威清单见 `internal/plugin/proc/capability.go` 的 `withheldCapabilities`)。 (权威清单见 `internal/plugin/proc/capability.go` 的 `withheldCapabilities`)。
@ -343,7 +343,7 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
| 插件 | 依赖的内部能力 | | 插件 | 依赖的内部能力 |
|---|---| |---|---|
| cli / webui | TerminalAPI(/terminal 与终端面板)、TrackerAPI、StatusAPI、SupervisorAPI、AdapterAPI | | cli / webui | TrackerAPI、StatusAPI、SupervisorAPI、AdapterAPI |
| agentcli | **EventPublish**:靠 `Publish(EventTerminalOutput)` 上报终端输出与生命周期,公开 SDK 无 Publish | | agentcli | **EventPublish**:靠 `Publish(EventTerminalOutput)` 上报终端输出与生命周期,公开 SDK 无 Publish |
| healthcheck | SelftestAPI(能构造虚拟内核实例做自检) | | healthcheck | SelftestAPI(能构造虚拟内核实例做自检) |
| pluginmgr | failback 恢复链必需(`core.agent.failback_plugins` 默认 webui/pluginmgr/recoverydiag) | | pluginmgr | failback 恢复链必需(`core.agent.failback_plugins` 默认 webui/pluginmgr/recoverydiag) |
@ -371,6 +371,26 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
> ⇒ 内置版永远赢,外部版**装了也不生效**。外置一个插件时,必须同时把它从 > ⇒ 内置版永远赢,外部版**装了也不生效**。外置一个插件时,必须同时把它从
> `internal/plugins/all.go` 移除(该文件是唯一权威清单)。 > `internal/plugins/all.go` 移除(该文件是唯一权威清单)。
### 终端归属:agentcli 全权,内核不碰(2026-10-20 调整)
终端逻辑只归 `agentcli`(PTY、会话生命周期、输出缓冲、历史)。内核**不再**维护
终端语义:曾经的 `internal/agent/core/terminal_registry.go` 已删除 —— 那是在内核里
编码「什么是终端、怎么从工具调用里归并会话」,属于职责错位。
插件侧(WebUI / CLI)需要终端面板时,**订阅事件总线自己攒视图**:
| 事件 | 发布者 | 用途 |
|---|---|---|
| `EventTerminalOutput` | agentcli | 会话输出、创建/关闭/超时(`running` 翻转) |
| `EventToolCall` | 内核 | `cmd_run` 历史、`terminal_create/close` 归并 |
WebUI 与 CLI 共用 `internal/pluginview` 的实现(同一解析口径,避免历史上两份
推导各踩一次「工具 result 是 Go map 文本」的坑)。视图不是权威:状态真值在
agentcli,插件晚于会话启动时会缺早期会话。
agentcli 因此需要 `Publish`(外部插件无此能力),按判定标准保持内置;
将来若要外置,需要先给外部插件开一条**受控的上行事件**通道(当前刻意不开)。
### 内置插件 vs 外部插件 ### 内置插件 vs 外部插件
| 维度 | 内置插件 | 外部插件 | | 维度 | 内置插件 | 外部插件 |

View File

@ -199,10 +199,6 @@ type Agent struct {
noMergeMarkers map[string]int noMergeMarkers map[string]int
noMergeMu sync.Mutex noMergeMu sync.Mutex
// TerminalRegistry 是终端会话与命令历史的权威视图(“内核开,两个插件接”)。
// 内核订阅自己的事件总线归并而来;WebUI/CLI 经 KernelStatus 读取。
terminalReg *TerminalRegistry
// 输入去重:防 webui/GUI 断线重连导致的消息重放 // 输入去重:防 webui/GUI 断线重连导致的消息重放
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃 // key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
lastInput map[string]time.Time lastInput map[string]time.Time
@ -447,13 +443,6 @@ func New(cfg AgentConfig) *Agent {
lastInput: make(map[string]time.Time), lastInput: make(map[string]time.Time),
} }
// 终端权威注册表只归**根 agent**(无 ParentID)。驻留子共用同一事件总线,
// 若每个子都建一份并订阅,一次工具调用会被 N+1 份重复记账;而终端本就是
// 内核级设备,不属于任何单个驻留子。
if cfg.ParentID == "" {
a.terminalReg = NewTerminalRegistry()
}
// 输入路由:inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent // 输入路由:inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent
// (设计 §4.1「路由发生在进内核之前」)。io 层不认识 agent,所以在这里把路由器 // (设计 §4.1「路由发生在进内核之前」)。io 层不认识 agent,所以在这里把路由器
// 注入进去:插件注入输入时先问它,被别的 agent 接管就不再进本内核队列。 // 注入进去:插件注入输入时先问它,被别的 agent 接管就不再进本内核队列。
@ -474,28 +463,11 @@ func (a *Agent) Start() {
go a.mergeLoop() go a.mergeLoop()
go a.reviewLoop() go a.reviewLoop()
go a.offloadLoop() go a.offloadLoop()
a.subscribeTerminalRegistry()
a.reembedStaleMedia() a.reembedStaleMedia()
a.migrateLegacyGraphMedia() a.migrateLegacyGraphMedia()
log.Printf("[agent] %s started, waiting for IO interrupts", a.id) log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
} }
// subscribeTerminalRegistry 让内核的终端/命令历史权威视图归并事件流。
//
// 内核自己发 EventToolCall(agent 路径),agentcli 发 EventTerminalOutput
// (含生命周期事件)。两者都进这份唯一真相,WebUI/CLI 不再各自推导。
func (a *Agent) subscribeTerminalRegistry() {
if a.eventBus == nil || a.terminalReg == nil {
return
}
a.eventBus.Subscribe(events.EventToolCall, func(ev *events.Event) {
a.terminalReg.OnToolCall(ev.Payload)
})
a.eventBus.Subscribe(events.EventTerminalOutput, func(ev *events.Event) {
a.terminalReg.OnTerminalOutput(ev.Payload)
})
}
func (a *Agent) Stop() { func (a *Agent) Stop() {
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。 // 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
a.StopResidents() a.StopResidents()

View File

@ -216,22 +216,6 @@ func collectKernelStatus(
return status return status
} }
// ListTerminals / CmdHistory 实现 sdk.TerminalAPI,把内核对插件开放的终端
// 接口委派给权威注册表(webui / cli 两个插件都读这里,不再各自订阅推导)。
func (a *Agent) ListTerminals() []sdk.TerminalStatus {
if a.terminalReg == nil {
return nil
}
return a.terminalReg.ListTerminals()
}
func (a *Agent) CmdHistory() []sdk.CmdExecStatus {
if a.terminalReg == nil {
return nil
}
return a.terminalReg.CmdHistory()
}
// GetKernelStatus 返回 Agent 驱动的内核状态快照。 // GetKernelStatus 返回 Agent 驱动的内核状态快照。
func (a *Agent) GetKernelStatus() *KernelStatus { func (a *Agent) GetKernelStatus() *KernelStatus {
providerName := "" providerName := ""

View File

@ -256,7 +256,6 @@ func wirePluginSDK(k *kernelboot.KernelState) {
k.PluginRegistry.SetStageHost(k.StageHost) k.PluginRegistry.SetStageHost(k.StageHost)
k.PluginRegistry.SetIndexer(k.Memory.Indexer) k.PluginRegistry.SetIndexer(k.Memory.Indexer)
k.PluginRegistry.SetStatusProvider(k.Agent) k.PluginRegistry.SetStatusProvider(k.Agent)
k.PluginRegistry.SetTerminalAPI(k.Agent)
} }
// loadPersonality 载入人格(原 cmd/homed 的整块平移)。 // loadPersonality 载入人格(原 cmd/homed 的整块平移)。

View File

@ -30,7 +30,7 @@ func (Module) Name() string { return "plugin-host" }
// 而 agent 层实测 **35 秒**(context dense backfill 750 events + 重建索引)。 // 而 agent 层实测 **35 秒**(context dense backfill 750 events + 重建索引)。
// //
// 而插件真正需要的只是 stage-host(Registry + StageHost)与几个底层设施。 // 而插件真正需要的只是 stage-host(Registry + StageHost)与几个底层设施。
// agent 相关的两项(StatusProvider / TerminalAPI)改为**后补注入**(见 // agent 相关的 StatusProvider 改为**后补注入**(见
// agent 模块),插件在 Start 阶段不用它们(唯一的 healthcheck 是 nil-guarded)。 // agent 模块),插件在 Start 阶段不用它们(唯一的 healthcheck 是 nil-guarded)。
func (Module) Requires() []string { func (Module) Requires() []string {
return []string{ return []string{
@ -281,7 +281,7 @@ func buildSkipSet(c *PluginCatalog, g formGroups) map[string]bool {
// injectBaseDeps 注入插件 SDK 的底层依赖(原 wirePluginSDK 的 7 项)。 // injectBaseDeps 注入插件 SDK 的底层依赖(原 wirePluginSDK 的 7 项)。
// //
// ★ 这里**刻意不做** SetStatusProvider / SetTerminalAPI —— 它们依赖 agent, // ★ 这里**刻意不做** SetStatusProvider —— 它依赖 agent,
// 由 agent 模块**后补注入**(见其注释)。这不是遗漏,是有意的分层: // 由 agent 模块**后补注入**(见其注释)。这不是遗漏,是有意的分层:
// 让插件加载不必等 agent 那 35 秒。 // 让插件加载不必等 agent 那 35 秒。
func injectBaseDeps(ks *kernelboot.KernelState) { func injectBaseDeps(ks *kernelboot.KernelState) {

View File

@ -113,7 +113,6 @@ type Registry struct {
cfg *types.Config cfg *types.Config
stageHost sdk.ToolSource stageHost sdk.ToolSource
idx *memory.Indexer idx *memory.Indexer
termAPI sdk.TerminalAPI
// deviceAuth 回答"设备 deviceID 是否已授权给当前 agent"(D4)。 // deviceAuth 回答"设备 deviceID 是否已授权给当前 agent"(D4)。
// //
@ -282,8 +281,7 @@ func (r *Registry) SetDeviceAuthQuery(fn func(deviceID string) bool) {
r.deviceAuth = fn r.deviceAuth = fn
sdk.SetDeviceAuthQuery(fn) sdk.SetDeviceAuthQuery(fn)
} }
func (r *Registry) SetIndexer(idx *memory.Indexer) { r.idx = idx } func (r *Registry) SetIndexer(idx *memory.Indexer) { r.idx = idx }
func (r *Registry) SetTerminalAPI(t sdk.TerminalAPI) { r.termAPI = t }
// SetLoadAllowlist 限制 Load 仅装载指定插件名(failback 受限启动用)。 // SetLoadAllowlist 限制 Load 仅装载指定插件名(failback 受限启动用)。
// 空/未设置 = 装载全部。违反白名单的插件(含已注册工厂)一律跳过。 // 空/未设置 = 装载全部。违反白名单的插件(含已注册工厂)一律跳过。
@ -455,7 +453,6 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
Config: sdk.NewConfig(r.cfg), Config: sdk.NewConfig(r.cfg),
Tool: sdk.NewTool(r.stageHost, r.iom), Tool: sdk.NewTool(r.stageHost, r.iom),
Indexer: sdk.NewIndexer(r.idx), Indexer: sdk.NewIndexer(r.idx),
Terminal: r.termAPI,
}) })
} }

View File

@ -881,7 +881,7 @@ func flushTermStream(s *sdk.PluginSDK, t *TerminalSession) bool {
// emitTermState 把终端存活状态作为 EventTerminalOutput 事件上报。 // emitTermState 把终端存活状态作为 EventTerminalOutput 事件上报。
// //
// 生命周期事件(创建/关闭/退出/超时)必须显式发:终端无输出时 ticker // 生命周期事件(创建/关闭/退出/超时)必须显式发:终端无输出时 ticker
// 不会发事件,内核的终端权威视图与所有插件(WebUI/CLI)都依赖这些事件 // 不会发事件,而所有插件(WebUI/CLI)的终端视图都依赖这些事件
// 才能知道终端的存在与终止。payload 带 command 供无 ToolCall 事件的 // 才能知道终端的存在与终止。payload 带 command 供无 ToolCall 事件的
// 直调路径(CLI /terminal create 经 ToolAPI.ExecuteTool)回填命令名。 // 直调路径(CLI /terminal create 经 ToolAPI.ExecuteTool)回填命令名。
// 幂等:重复发同一 running 值不会产生状态跳变。 // 幂等:重复发同一 running 值不会产生状态跳变。

View File

@ -32,7 +32,7 @@ package plugins
// 插件只用公开 SDK 能力 ⇒ **应当外置**(.hmap 安装,独立演进) // 插件只用公开 SDK 能力 ⇒ **应当外置**(.hmap 安装,独立演进)
// //
// 正例(留内置,各有非它不可的理由): // 正例(留内置,各有非它不可的理由):
// cli / webui TerminalAPI/TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI // cli / webui TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI
// healthcheck SelftestAPI(能构造虚拟内核实例) // healthcheck SelftestAPI(能构造虚拟内核实例)
// pluginmgr failback 恢复链必需(webui/pluginmgr/recoverydiag) // pluginmgr failback 恢复链必需(webui/pluginmgr/recoverydiag)
// seq ToolAPI(工具表直接操作) // seq ToolAPI(工具表直接操作)

View File

@ -17,6 +17,7 @@ import (
"github.com/JianFeeeee/HomeAgent/internal/config" "github.com/JianFeeeee/HomeAgent/internal/config"
"github.com/JianFeeeee/HomeAgent/internal/plugin" "github.com/JianFeeeee/HomeAgent/internal/plugin"
"github.com/JianFeeeee/HomeAgent/internal/pluginview"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk" sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
) )
@ -44,11 +45,13 @@ func init() {
} }
type Plugin struct { type Plugin struct {
name string name string
socket string socket string
ln net.Listener ln net.Listener
mu sync.Mutex mu sync.Mutex
wg sync.WaitGroup wg sync.WaitGroup
termView *pluginview.TerminalView
termUnsubs []func()
} }
func New(name, socketPath string) *Plugin { func New(name, socketPath string) *Plugin {
@ -62,9 +65,14 @@ func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true) s.SetAutoRestart(true)
// 终端与命令历史的权威视图在内核(「内核开,两个插件接」), // 终端状态归 agentcli,内核不再维护终端语义(2026-10-20 调整)。
// /terminals 与 /cmd/history 直接从 s.Status().GetKernelStatus() 读, // CLI 与 WebUI 用同一份 internal/pluginview 实现订阅事件总线,
// 无需在此订阅事件自攒。 // /terminals 与 /cmd/history 读自己攒的视图。
p.termView = pluginview.NewTerminalView()
p.termUnsubs = append(p.termUnsubs,
s.Subscribe(sdk.EventTerminalOutput, func(evt *sdk.Event) { p.termView.OnTerminalOutput(evt.Payload) }),
s.Subscribe(sdk.EventToolCall, func(evt *sdk.Event) { p.termView.OnToolCall(evt.Payload) }),
)
// inputch 先登记:本插件既用 "cli" 作输出目标,也用它注入输入(终端行)。 // inputch 先登记:本插件既用 "cli" 作输出目标,也用它注入输入(终端行)。
// 输入侧必须显式登记,否则"把 inputch 划给驻留子"会找不到它。 // 输入侧必须显式登记,否则"把 inputch 划给驻留子"会找不到它。
@ -1077,37 +1085,29 @@ func (p *Plugin) cmdAgents(conn net.Conn, s *sdk.PluginSDK) {
// ======== /terminals /cmd/history /terminal ======== // ======== /terminals /cmd/history /terminal ========
// 终端与命令历史的权威视图在内核(「内核开,两个插件接」)。 // 终端视图:订阅事件总线攒的(与 WebUI 共用 internal/pluginview 实现)。
// // 状态真值在 agentcli;内核不参与终端语义。
// 内核订自己的事件总线归并 EventToolCall(terminal_create/close、cmd_run)
// 与 EventTerminalOutput(agentcli 生命周期 + 输出),产出唯一真相挂在
// TerminalAPI(见 internal/agent/core/terminal_registry.go)。
// WebUI 与 CLI 都从 s.Status().GetKernelStatus() 读同一份,不再各自订阅推导——
// 之前两份推导还各自踩过同一个坑:工具 result 是 Go map 文本,断言成
// map[string]interface{} 永远失败 → /terminals 空空如也。
// cmdTerminals 读内核权威快照,与 WebUI 的 GET /api/v1/terminals 同源。 // cmdTerminals 返回终端会话快照,与 WebUI 的 GET /api/v1/terminals 同口径。
func (p *Plugin) cmdTerminals(conn net.Conn, s *sdk.PluginSDK) { func (p *Plugin) cmdTerminals(conn net.Conn, s *sdk.PluginSDK) {
t := s.Terminal() if p.termView == nil {
if t == nil {
writeJSONContent(conn, map[string]interface{}{"terminals": []interface{}{}}) writeJSONContent(conn, map[string]interface{}{"terminals": []interface{}{}})
return return
} }
terms := t.ListTerminals() terms, _ := p.termView.Snapshot()
if terms == nil { if terms == nil {
terms = []sdk.TerminalStatus{} terms = []sdk.TerminalStatus{}
} }
writeJSONContent(conn, map[string]interface{}{"terminals": terms}) writeJSONContent(conn, map[string]interface{}{"terminals": terms})
} }
// cmdCmdHistory 读内核权威快照,与 WebUI 的 GET /api/v1/cmd/history 同源。 // cmdCmdHistory 返回命令历史快照,与 WebUI 的 GET /api/v1/cmd/history 同口径。
func (p *Plugin) cmdCmdHistory(conn net.Conn, s *sdk.PluginSDK) { func (p *Plugin) cmdCmdHistory(conn net.Conn, s *sdk.PluginSDK) {
t := s.Terminal() if p.termView == nil {
if t == nil {
writeJSONContent(conn, map[string]interface{}{"history": []interface{}{}}) writeJSONContent(conn, map[string]interface{}{"history": []interface{}{}})
return return
} }
cmds := t.CmdHistory() _, cmds := p.termView.Snapshot()
if cmds == nil { if cmds == nil {
cmds = []sdk.CmdExecStatus{} cmds = []sdk.CmdExecStatus{}
} }
@ -1191,6 +1191,13 @@ func writeLine(conn net.Conn, v interface{}) {
} }
func (p *Plugin) Stop() error { func (p *Plugin) Stop() error {
for _, unsub := range p.termUnsubs {
if unsub != nil {
unsub()
}
}
p.termUnsubs = nil
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.ln != nil { if p.ln != nil {

View File

@ -16,6 +16,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/JianFeeeee/HomeAgent/internal/pluginview"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk" sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
"net/http" "net/http"
"path" "path"
@ -122,9 +123,13 @@ type Handler struct {
kernelCtl sdk.KernelControlAPI kernelCtl sdk.KernelControlAPI
pluginMgr sdk.PluginManager pluginMgr sdk.PluginManager
status sdk.StatusAPI status sdk.StatusAPI
term sdk.TerminalAPI
llm sdk.LLMAPI llm sdk.LLMAPI
// termView 是插件侧终端视图:订阅事件总线自己攒(内核不再维护终端语义)。
// 见 internal/pluginview/terminal.go 的职责边界说明。
termView *pluginview.TerminalView
termUnsubs []func()
// hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。 // hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。
hostPort string hostPort string
@ -182,9 +187,8 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
tr sdk.TrackerAPI tr sdk.TrackerAPI
se sdk.SettingsAPI se sdk.SettingsAPI
pm sdk.PluginManager pm sdk.PluginManager
st sdk.StatusAPI st sdk.StatusAPI
term sdk.TerminalAPI llm sdk.LLMAPI
llm sdk.LLMAPI
// kc 是内核重启/关机通道;nil 表示该部署没接(端点会明确报不可用)。 // kc 是内核重启/关机通道;nil 表示该部署没接(端点会明确报不可用)。
kc sdk.KernelControlAPI kc sdk.KernelControlAPI
) )
@ -194,7 +198,6 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
tm, ks, tr = s.TextMemory(), s.Knowledge(), s.Tracker() tm, ks, tr = s.TextMemory(), s.Knowledge(), s.Tracker()
se, pm = s.Settings(), s.PluginMgr() se, pm = s.Settings(), s.PluginMgr()
st, llm = s.Status(), s.LLM() st, llm = s.Status(), s.LLM()
term = s.Terminal()
kc = s.KernelControl() kc = s.KernelControl()
} }
h := &Handler{ h := &Handler{
@ -212,8 +215,8 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
kernelCtl: kc, kernelCtl: kc,
pluginMgr: pm, pluginMgr: pm,
status: st, status: st,
term: term,
llm: llm, llm: llm,
termView: pluginview.NewTerminalView(),
sessions: make(map[string]time.Time), sessions: make(map[string]time.Time),
loginLimiter: newLoginLimiter(loginMaxFails, loginWindow), loginLimiter: newLoginLimiter(loginMaxFails, loginWindow),
pendingIdx: -1, pendingIdx: -1,
@ -227,6 +230,19 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
chatStop: make(chan struct{}), chatStop: make(chan struct{}),
chatLoopDone: make(chan struct{}), chatLoopDone: make(chan struct{}),
} }
// 订阅事件总线攒终端视图(内核不再维护终端语义):
// EventTerminalOutput —— agentcli 的输出与生命周期
// EventToolCall —— cmd_run 历史、terminal_create/close 归并
if s != nil {
h.termUnsubs = append(h.termUnsubs,
s.Subscribe(sdk.EventTerminalOutput, func(ev *sdk.Event) {
h.termView.OnTerminalOutput(ev.Payload)
}),
s.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) {
h.termView.OnToolCall(ev.Payload)
}),
)
}
go h.chatPersistLoop() go h.chatPersistLoop()
h.loadChatHistory() h.loadChatHistory()
if s != nil { if s != nil {

View File

@ -390,6 +390,15 @@ func (h *Handler) flushChat() {
// 由插件 Stop 调用;不这样做会丢掉最后一轮对话。 // 由插件 Stop 调用;不这样做会丢掉最后一轮对话。
func (h *Handler) Close() { func (h *Handler) Close() {
h.chatCloseOnce.Do(func() { h.chatCloseOnce.Do(func() {
// 先退订事件总线:插件被 disable/reload 时必须解绑,
// 否则闭包继续持有 Handler(泄漏)并往已关闭的视图里写。
for _, unsub := range h.termUnsubs {
if unsub != nil {
unsub()
}
}
h.termUnsubs = nil
safeclose.Safe(&h.chatStopOnce2, h.chatStop) safeclose.Safe(&h.chatStopOnce2, h.chatStop)
<-h.chatLoopDone <-h.chatLoopDone
}) })

View File

@ -8,35 +8,34 @@ import (
// 终端面:终端会话列表与命令历史接口。 // 终端面:终端会话列表与命令历史接口。
// //
// 「内核开,两个插件接」后,终端会话与命令历史的权威视图在内核 // 职责边界(2026-10-20 调整):终端状态归 agentcli,内核**不**再维护终端语义
// (internal/agent/core/terminal_registry.go):内核订阅自己的事件总线 // (旧的 internal/agent/core/terminal_registry.go 已删除)。WebUI 在
// 归并 EventToolCall(terminal_create/close、cmd_run)与 EventTerminalOutput // NewHandler 里订阅事件总线(EventTerminalOutput + EventToolCall)攒自己的视图,
// (agentcli 生命周期 + 输出)。WebUI 不再自己订阅事件攒一份——直接读内核 // CLI 用同一份 internal/pluginview 实现,两个插件的解析口径一致。
// 开放的 TerminalAPI(s.Terminal()),与 CLI 同源同口径。
// //
// 为什么不塞进 KernelStatus:那是全量快照,前端每 3 秒轮询 /kernel, // 为什么不塞进 KernelStatus:那是全量快照,前端每 3 秒轮询 /kernel,
// 把每终端最多 64KB 的输出缓冲背进去会让轮询成本爆炸。 // 把每终端最多 64KB 的输出缓冲背进去会让轮询成本爆炸。
// handleTerminals 返回内核权威的终端会话快照。 // handleTerminals 返回终端会话快照(本插件从事件总线攒的视图)。
func (h *Handler) handleTerminals(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleTerminals(w http.ResponseWriter, r *http.Request) {
if h.term == nil { if h.termView == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": []interface{}{}}) writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": []interface{}{}})
return return
} }
terms := h.term.ListTerminals() terms, _ := h.termView.Snapshot()
if terms == nil { if terms == nil {
terms = []sdk.TerminalStatus{} terms = []sdk.TerminalStatus{}
} }
writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": terms}) writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": terms})
} }
// handleCmdHistory 返回内核权威的命令执行历史快照。 // handleCmdHistory 返回命令执行历史快照(本插件从事件总线攒的视图)。
func (h *Handler) handleCmdHistory(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleCmdHistory(w http.ResponseWriter, r *http.Request) {
if h.term == nil { if h.termView == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"history": []interface{}{}}) writeJSON(w, http.StatusOK, map[string]interface{}{"history": []interface{}{}})
return return
} }
cmds := h.term.CmdHistory() _, cmds := h.termView.Snapshot()
if cmds == nil { if cmds == nil {
cmds = []sdk.CmdExecStatus{} cmds = []sdk.CmdExecStatus{}
} }

View File

@ -1,23 +1,30 @@
package core package pluginview
import ( import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/JianFeeeee/HomeAgent/internal/sdk" sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
) )
// TerminalRegistry 是**内核侧**的终端会话与命令历史权威视图(「内核开,两个插件接」)。 // TerminalView 是**插件侧**的终端会话与命令历史视图。
// //
// 此前 WebUI 与 CLI 插件各自订阅 EventToolCall / EventTerminalOutput 攒一份 // 职责边界(2026-10-20 调整):终端逻辑只归 agentcli ——PTY、会话、历史都在它
// 状态:同一件事两份推导,还各自踩过同一个坑(工具 result 是 Go map 文本, // 那里;内核**不**再维护终端语义(旧的 internal/agent/core/terminal_registry.go
// 断言成 map[string]interface{} 永远失败 → /terminals 空空如也)。 // 已删除)。插件要展示终端,就订阅事件总线自己攒视图:
// //
// 现在权威状态收归内核一份:内核订阅自己的事件总线,把 terminal_* / cmd_run // EventTerminalOutput(agentcli 发)→ 会话输出 + 生命周期
// 的工具调用与 agentcli 的 terminal_output 事件归并成唯一真相; // EventToolCall (内核发) → cmd_run 历史、terminal_create/close 归并
// WebUI 和 CLI 都从 s.Status().GetKernelStatus() 读取,不再各自推导。 //
type TerminalRegistry struct { // 为什么放在一个共享包里而不是各写一份:WebUI 与 CLI 都需要同一份口径,
// 且历史上两份推导踩过同一个坑(工具 result 是 Go map 文本,断言成
// map[string]interface{} 永远失败 → /terminals 空空如也)。共享实现保证
// 两边解析口径一致。
//
// ❗这是**视图**不是权威:状态真值在 agentcli。插件晚于会话启动时会缺早期
// 会话(下次生命周期事件会补回),不要把这里当持久记录。
type TerminalView struct {
mu sync.Mutex mu sync.Mutex
terms map[string]*TermState terms map[string]*TermState
cmds []CmdExec cmds []CmdExec
@ -50,15 +57,15 @@ const (
maxTermOutput = 64 * 1024 maxTermOutput = 64 * 1024
) )
func NewTerminalRegistry() *TerminalRegistry { func NewTerminalView() *TerminalView {
return &TerminalRegistry{ return &TerminalView{
terms: make(map[string]*TermState), terms: make(map[string]*TermState),
} }
} }
// OnToolCall 归并内核自己发布的 EventToolCall(agent 路径;result 是 Go map // OnToolCall 归并内核自己发布的 EventToolCall(agent 路径;result 是 Go map
// 文本,id/command 从 args 或 map 文本里回填)。 // 文本,id/command 从 args 或 map 文本里回填)。
func (r *TerminalRegistry) OnToolCall(payload map[string]interface{}) { func (r *TerminalView) OnToolCall(payload map[string]interface{}) {
tool, _ := payload["tool"].(string) tool, _ := payload["tool"].(string)
args, _ := payload["args"].(map[string]interface{}) args, _ := payload["args"].(map[string]interface{})
status, _ := payload["status"].(string) status, _ := payload["status"].(string)
@ -121,7 +128,7 @@ func (r *TerminalRegistry) OnToolCall(payload map[string]interface{}) {
// OnTerminalOutput 归并 agentcli 的 terminal_output 事件(含生命周期事件: // OnTerminalOutput 归并 agentcli 的 terminal_output 事件(含生命周期事件:
// 创建时带 command,关闭/退出/超时带 running=false)。 // 创建时带 command,关闭/退出/超时带 running=false)。
func (r *TerminalRegistry) OnTerminalOutput(payload map[string]interface{}) { func (r *TerminalView) OnTerminalOutput(payload map[string]interface{}) {
id, _ := payload["terminal_id"].(string) id, _ := payload["terminal_id"].(string)
if id == "" { if id == "" {
return return
@ -164,19 +171,8 @@ func (r *TerminalRegistry) OnTerminalOutput(payload map[string]interface{}) {
r.mu.Unlock() r.mu.Unlock()
} }
// ListTerminals / CmdHistory 实现 sdk.TerminalAPI(内核对插件开放的终端接口)。
func (r *TerminalRegistry) ListTerminals() []sdk.TerminalStatus {
terms, _ := r.Snapshot()
return terms
}
func (r *TerminalRegistry) CmdHistory() []sdk.CmdExecStatus {
_, cmds := r.Snapshot()
return cmds
}
// Snapshot 返回加过 Uptime 的终端列表与命令历史快照(拷贝,调用方可随意改)。 // Snapshot 返回加过 Uptime 的终端列表与命令历史快照(拷贝,调用方可随意改)。
func (r *TerminalRegistry) Snapshot() ([]sdk.TerminalStatus, []sdk.CmdExecStatus) { func (r *TerminalView) Snapshot() ([]sdk.TerminalStatus, []sdk.CmdExecStatus) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
terms := make([]sdk.TerminalStatus, 0, len(r.terms)) terms := make([]sdk.TerminalStatus, 0, len(r.terms))

View File

@ -1,4 +1,4 @@
package core package pluginview
import "testing" import "testing"
@ -27,7 +27,7 @@ func TestMapFieldFromMapText(t *testing.T) {
} }
func TestTerminalRegistryLifecycle(t *testing.T) { func TestTerminalRegistryLifecycle(t *testing.T) {
r := NewTerminalRegistry() r := NewTerminalView()
// agent 路径:terminal_create 的 result 是 Go map 文本,要能回填 id。 // agent 路径:terminal_create 的 result 是 Go map 文本,要能回填 id。
r.OnToolCall(map[string]interface{}{ r.OnToolCall(map[string]interface{}{
@ -94,7 +94,7 @@ func TestTerminalRegistryLifecycle(t *testing.T) {
// 终端数超上限时要淘汰,不能无界增长。 // 终端数超上限时要淘汰,不能无界增长。
func TestTerminalRegistryCap(t *testing.T) { func TestTerminalRegistryCap(t *testing.T) {
r := NewTerminalRegistry() r := NewTerminalView()
for i := 0; i < maxTerminals+20; i++ { for i := 0; i < maxTerminals+20; i++ {
r.OnTerminalOutput(map[string]interface{}{ r.OnTerminalOutput(map[string]interface{}{
"terminal_id": string(rune('a'+i%26)) + "-x", "running": true, "terminal_id": string(rune('a'+i%26)) + "-x", "running": true,

View File

@ -184,7 +184,6 @@ type PluginSDK struct {
config ConfigAPI config ConfigAPI
tool ToolAPI tool ToolAPI
indexer IndexerAPI indexer IndexerAPI
terminal TerminalAPI
selftestMu sync.Mutex selftestMu sync.Mutex
selftest *VirtualInstance selftest *VirtualInstance
@ -338,7 +337,6 @@ type SDKConfig struct {
Config ConfigAPI Config ConfigAPI
Tool ToolAPI Tool ToolAPI
Indexer IndexerAPI Indexer IndexerAPI
Terminal TerminalAPI
} }
func New(name string, cfg SDKConfig) *PluginSDK { func New(name string, cfg SDKConfig) *PluginSDK {
@ -390,7 +388,6 @@ func New(name string, cfg SDKConfig) *PluginSDK {
config: cfg.Config, config: cfg.Config,
tool: cfg.Tool, tool: cfg.Tool,
indexer: cfg.Indexer, indexer: cfg.Indexer,
terminal: cfg.Terminal,
} }
} }
@ -437,7 +434,6 @@ func (s *PluginSDK) Tracker() TrackerAPI { return s.tracker }
func (s *PluginSDK) Config() ConfigAPI { return s.config } func (s *PluginSDK) Config() ConfigAPI { return s.config }
func (s *PluginSDK) Tool() ToolAPI { return s.tool } func (s *PluginSDK) Tool() ToolAPI { return s.tool }
func (s *PluginSDK) Indexer() IndexerAPI { return s.indexer } func (s *PluginSDK) Indexer() IndexerAPI { return s.indexer }
func (s *PluginSDK) Terminal() TerminalAPI { return s.terminal }
func (s *PluginSDK) InjectInput(source, channel, eventType string, payload map[string]interface{}) { func (s *PluginSDK) InjectInput(source, channel, eventType string, payload map[string]interface{}) {
if s.iom != nil { if s.iom != nil {

View File

@ -1,23 +1,8 @@
package sdk package sdk
// TerminalAPI 是内核开放的终端会话与命令历史接口(「内核开,两个插件接」)。 // 终端 DTO(TerminalStatus / CmdExecStatus)保留:它们只是**线上格式**,
// // WebUI 与 CLI 用同一口径返回给前端。终端语义(谁在跑、历史怎么归并)
// 为什么单开接口而不是塞进 KernelStatus:KernelStatus 是**全量运行态快照**, // 已归属 agentcli 与插件侧视图(internal/pluginview),内核不再有 TerminalAPI。
// /api/v1/kernel 与前端总览页每 3 秒轮询一次;把每个终端最多 64KB 的输出
// 缓冲塞进去,会让每次轮询都背一份终端全屏内容。终端输出属于**按需拉取**的
// 明细,只该在 /terminals 被访问时取。
//
// 权威状态由内核维护(internal/agent/core/terminal_registry.go):内核订阅
// 自己的事件总线,归并 EventToolCall(terminal_create/close、cmd_run)与
// EventTerminalOutput(agentcli 生命周期 + 输出),产出唯一真相。
// WebUI 与 CLI 都从这里读,不再各自订阅推导。
type TerminalAPI interface {
// ListTerminals 返回终端会话快照(含输出缓冲与运行状态)。
ListTerminals() []TerminalStatus
// CmdHistory 返回命令执行历史快照(cmd_run,最近 100 条)。
CmdHistory() []CmdExecStatus
}
// TerminalStatus 与 WebUI termState / CLI cliTermState 同一 JSON 口径。 // TerminalStatus 与 WebUI termState / CLI cliTermState 同一 JSON 口径。
type TerminalStatus struct { type TerminalStatus struct {
ID string `json:"id"` ID string `json:"id"`