From 3119d486253b0818ddbcfbff309d0f026c12ec9b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 20 Oct 2026 09:33:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor(terminal):=20=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=9B=9E=E5=BD=92=20agentcli=EF=BC=8C?= =?UTF-8?q?=E5=86=85=E6=A0=B8=E4=B8=8D=E5=86=8D=E7=BC=96=E7=A0=81=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户指正调整架构:终端是 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 文本解析、生命周期、上限裁剪) --- assets/docs/en/ARCHITECTURE.md | 29 +++++++++- assets/docs/zh/ARCHITECTURE.md | 24 +++++++- internal/agent/core/agent.go | 28 ---------- internal/agent/core/status.go | 16 ------ internal/kernelmod/agent/mod.go | 1 - internal/kernelmod/pluginhost/mod.go | 4 +- internal/plugin/registry.go | 5 +- internal/plugins/agentcli/plugin.go | 2 +- internal/plugins/all.go | 2 +- internal/plugins/cli/plugin.go | 55 +++++++++++-------- internal/plugins/webui/handler.go | 28 ++++++++-- internal/plugins/webui/handler_chat.go | 9 +++ internal/plugins/webui/handler_terminal.go | 21 ++++--- .../terminal.go} | 48 ++++++++-------- .../terminal_test.go} | 6 +- internal/sdk/plugin.go | 4 -- internal/sdk/terminal.go | 21 +------ 17 files changed, 154 insertions(+), 149 deletions(-) rename internal/{agent/core/terminal_registry.go => pluginview/terminal.go} (81%) rename internal/{agent/core/terminal_registry_test.go => pluginview/terminal_test.go} (98%) diff --git a/assets/docs/en/ARCHITECTURE.md b/assets/docs/en/ARCHITECTURE.md index 8cfbf396..0e6e4d8f 100644 --- a/assets/docs/en/ARCHITECTURE.md +++ b/assets/docs/en/ARCHITECTURE.md @@ -339,7 +339,7 @@ Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter exe 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` / +**never granted to external plugins**: `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 @@ -359,7 +359,7 @@ 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 | +| cli / webui | 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) | @@ -392,6 +392,31 @@ Counter-examples (externalized, because public capabilities suffice): > takes effect**. When externalizing a plugin, remove it from > `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 | Dimension | Built-in Plugin | External Plugin | diff --git a/assets/docs/zh/ARCHITECTURE.md b/assets/docs/zh/ARCHITECTURE.md index 45a27d63..bea6b003 100644 --- a/assets/docs/zh/ARCHITECTURE.md +++ b/assets/docs/zh/ARCHITECTURE.md @@ -327,7 +327,7 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main **只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件,才留在编译期内置清单。** 内部 SDK(`internal/sdk`)比公开 SDK(third_party/homeagent-sdk)多出一批 -**不对外授予**的能力面:`TerminalAPI` / `TrackerAPI` / `StatusAPI` / +**不对外授予**的能力面:`TrackerAPI` / `StatusAPI` / `SupervisorAPI` / `AdapterAPI` / `SelftestAPI` / `ToolAPI`,以及 **`Publish` (事件发布)**。外部插件走 proc 桥的收窄能力集,这些接口在类型层就不存在 (权威清单见 `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 | | healthcheck | SelftestAPI(能构造虚拟内核实例做自检) | | pluginmgr | failback 恢复链必需(`core.agent.failback_plugins` 默认 webui/pluginmgr/recoverydiag) | @@ -371,6 +371,26 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main > ⇒ 内置版永远赢,外部版**装了也不生效**。外置一个插件时,必须同时把它从 > `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 外部插件 | 维度 | 内置插件 | 外部插件 | diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 54b1c878..626661f9 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -199,10 +199,6 @@ type Agent struct { noMergeMarkers map[string]int noMergeMu sync.Mutex - // TerminalRegistry 是终端会话与命令历史的权威视图(“内核开,两个插件接”)。 - // 内核订阅自己的事件总线归并而来;WebUI/CLI 经 KernelStatus 读取。 - terminalReg *TerminalRegistry - // 输入去重:防 webui/GUI 断线重连导致的消息重放 // key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃 lastInput map[string]time.Time @@ -447,13 +443,6 @@ func New(cfg AgentConfig) *Agent { lastInput: make(map[string]time.Time), } - // 终端权威注册表只归**根 agent**(无 ParentID)。驻留子共用同一事件总线, - // 若每个子都建一份并订阅,一次工具调用会被 N+1 份重复记账;而终端本就是 - // 内核级设备,不属于任何单个驻留子。 - if cfg.ParentID == "" { - a.terminalReg = NewTerminalRegistry() - } - // 输入路由:inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent // (设计 §4.1「路由发生在进内核之前」)。io 层不认识 agent,所以在这里把路由器 // 注入进去:插件注入输入时先问它,被别的 agent 接管就不再进本内核队列。 @@ -474,28 +463,11 @@ func (a *Agent) Start() { go a.mergeLoop() go a.reviewLoop() go a.offloadLoop() - a.subscribeTerminalRegistry() a.reembedStaleMedia() a.migrateLegacyGraphMedia() 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() { // 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。 a.StopResidents() diff --git a/internal/agent/core/status.go b/internal/agent/core/status.go index a2c6ed17..eda827a7 100644 --- a/internal/agent/core/status.go +++ b/internal/agent/core/status.go @@ -216,22 +216,6 @@ func collectKernelStatus( 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 驱动的内核状态快照。 func (a *Agent) GetKernelStatus() *KernelStatus { providerName := "" diff --git a/internal/kernelmod/agent/mod.go b/internal/kernelmod/agent/mod.go index 05a3f0e3..d595ed19 100644 --- a/internal/kernelmod/agent/mod.go +++ b/internal/kernelmod/agent/mod.go @@ -256,7 +256,6 @@ func wirePluginSDK(k *kernelboot.KernelState) { k.PluginRegistry.SetStageHost(k.StageHost) k.PluginRegistry.SetIndexer(k.Memory.Indexer) k.PluginRegistry.SetStatusProvider(k.Agent) - k.PluginRegistry.SetTerminalAPI(k.Agent) } // loadPersonality 载入人格(原 cmd/homed 的整块平移)。 diff --git a/internal/kernelmod/pluginhost/mod.go b/internal/kernelmod/pluginhost/mod.go index 05672610..501c551b 100644 --- a/internal/kernelmod/pluginhost/mod.go +++ b/internal/kernelmod/pluginhost/mod.go @@ -30,7 +30,7 @@ func (Module) Name() string { return "plugin-host" } // 而 agent 层实测 **35 秒**(context dense backfill 750 events + 重建索引)。 // // 而插件真正需要的只是 stage-host(Registry + StageHost)与几个底层设施。 -// agent 相关的两项(StatusProvider / TerminalAPI)改为**后补注入**(见 +// agent 相关的 StatusProvider 改为**后补注入**(见 // agent 模块),插件在 Start 阶段不用它们(唯一的 healthcheck 是 nil-guarded)。 func (Module) Requires() []string { return []string{ @@ -281,7 +281,7 @@ func buildSkipSet(c *PluginCatalog, g formGroups) map[string]bool { // injectBaseDeps 注入插件 SDK 的底层依赖(原 wirePluginSDK 的 7 项)。 // -// ★ 这里**刻意不做** SetStatusProvider / SetTerminalAPI —— 它们依赖 agent, +// ★ 这里**刻意不做** SetStatusProvider —— 它依赖 agent, // 由 agent 模块**后补注入**(见其注释)。这不是遗漏,是有意的分层: // 让插件加载不必等 agent 那 35 秒。 func injectBaseDeps(ks *kernelboot.KernelState) { diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 8d122863..cb911ad8 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -113,7 +113,6 @@ type Registry struct { cfg *types.Config stageHost sdk.ToolSource idx *memory.Indexer - termAPI sdk.TerminalAPI // deviceAuth 回答"设备 deviceID 是否已授权给当前 agent"(D4)。 // @@ -282,8 +281,7 @@ func (r *Registry) SetDeviceAuthQuery(fn func(deviceID string) bool) { r.deviceAuth = fn sdk.SetDeviceAuthQuery(fn) } -func (r *Registry) SetIndexer(idx *memory.Indexer) { r.idx = idx } -func (r *Registry) SetTerminalAPI(t sdk.TerminalAPI) { r.termAPI = t } +func (r *Registry) SetIndexer(idx *memory.Indexer) { r.idx = idx } // SetLoadAllowlist 限制 Load 仅装载指定插件名(failback 受限启动用)。 // 空/未设置 = 装载全部。违反白名单的插件(含已注册工厂)一律跳过。 @@ -455,7 +453,6 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { Config: sdk.NewConfig(r.cfg), Tool: sdk.NewTool(r.stageHost, r.iom), Indexer: sdk.NewIndexer(r.idx), - Terminal: r.termAPI, }) } diff --git a/internal/plugins/agentcli/plugin.go b/internal/plugins/agentcli/plugin.go index e31ce271..f51298b0 100644 --- a/internal/plugins/agentcli/plugin.go +++ b/internal/plugins/agentcli/plugin.go @@ -881,7 +881,7 @@ func flushTermStream(s *sdk.PluginSDK, t *TerminalSession) bool { // emitTermState 把终端存活状态作为 EventTerminalOutput 事件上报。 // // 生命周期事件(创建/关闭/退出/超时)必须显式发:终端无输出时 ticker -// 不会发事件,内核的终端权威视图与所有插件(WebUI/CLI)都依赖这些事件 +// 不会发事件,而所有插件(WebUI/CLI)的终端视图都依赖这些事件 // 才能知道终端的存在与终止。payload 带 command 供无 ToolCall 事件的 // 直调路径(CLI /terminal create 经 ToolAPI.ExecuteTool)回填命令名。 // 幂等:重复发同一 running 值不会产生状态跳变。 diff --git a/internal/plugins/all.go b/internal/plugins/all.go index 88d0a8dd..0bdd5065 100644 --- a/internal/plugins/all.go +++ b/internal/plugins/all.go @@ -32,7 +32,7 @@ package plugins // 插件只用公开 SDK 能力 ⇒ **应当外置**(.hmap 安装,独立演进) // // 正例(留内置,各有非它不可的理由): -// cli / webui TerminalAPI/TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI +// cli / webui TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI // healthcheck SelftestAPI(能构造虚拟内核实例) // pluginmgr failback 恢复链必需(webui/pluginmgr/recoverydiag) // seq ToolAPI(工具表直接操作) diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index a324070e..713540a6 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -17,6 +17,7 @@ import ( "github.com/JianFeeeee/HomeAgent/internal/config" "github.com/JianFeeeee/HomeAgent/internal/plugin" + "github.com/JianFeeeee/HomeAgent/internal/pluginview" sdk "github.com/JianFeeeee/HomeAgent/internal/sdk" ) @@ -44,11 +45,13 @@ func init() { } type Plugin struct { - name string - socket string - ln net.Listener - mu sync.Mutex - wg sync.WaitGroup + name string + socket string + ln net.Listener + mu sync.Mutex + wg sync.WaitGroup + termView *pluginview.TerminalView + termUnsubs []func() } 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 { s.SetAutoRestart(true) - // 终端与命令历史的权威视图在内核(「内核开,两个插件接」), - // /terminals 与 /cmd/history 直接从 s.Status().GetKernelStatus() 读, - // 无需在此订阅事件自攒。 + // 终端状态归 agentcli,内核不再维护终端语义(2026-10-20 调整)。 + // 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 划给驻留子"会找不到它。 @@ -1077,37 +1085,29 @@ func (p *Plugin) cmdAgents(conn net.Conn, s *sdk.PluginSDK) { // ======== /terminals /cmd/history /terminal ======== -// 终端与命令历史的权威视图在内核(「内核开,两个插件接」)。 -// -// 内核订自己的事件总线归并 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 空空如也。 +// 终端视图:订阅事件总线攒的(与 WebUI 共用 internal/pluginview 实现)。 +// 状态真值在 agentcli;内核不参与终端语义。 -// cmdTerminals 读内核权威快照,与 WebUI 的 GET /api/v1/terminals 同源。 +// cmdTerminals 返回终端会话快照,与 WebUI 的 GET /api/v1/terminals 同口径。 func (p *Plugin) cmdTerminals(conn net.Conn, s *sdk.PluginSDK) { - t := s.Terminal() - if t == nil { + if p.termView == nil { writeJSONContent(conn, map[string]interface{}{"terminals": []interface{}{}}) return } - terms := t.ListTerminals() + terms, _ := p.termView.Snapshot() if terms == nil { terms = []sdk.TerminalStatus{} } 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) { - t := s.Terminal() - if t == nil { + if p.termView == nil { writeJSONContent(conn, map[string]interface{}{"history": []interface{}{}}) return } - cmds := t.CmdHistory() + _, cmds := p.termView.Snapshot() if cmds == nil { cmds = []sdk.CmdExecStatus{} } @@ -1191,6 +1191,13 @@ func writeLine(conn net.Conn, v interface{}) { } func (p *Plugin) Stop() error { + for _, unsub := range p.termUnsubs { + if unsub != nil { + unsub() + } + } + p.termUnsubs = nil + p.mu.Lock() defer p.mu.Unlock() if p.ln != nil { diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 7f04458e..61811460 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -16,6 +16,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "github.com/JianFeeeee/HomeAgent/internal/pluginview" sdk "github.com/JianFeeeee/HomeAgent/internal/sdk" "net/http" "path" @@ -122,9 +123,13 @@ type Handler struct { kernelCtl sdk.KernelControlAPI pluginMgr sdk.PluginManager status sdk.StatusAPI - term sdk.TerminalAPI llm sdk.LLMAPI + // termView 是插件侧终端视图:订阅事件总线自己攒(内核不再维护终端语义)。 + // 见 internal/pluginview/terminal.go 的职责边界说明。 + termView *pluginview.TerminalView + termUnsubs []func() + // hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。 hostPort string @@ -182,9 +187,8 @@ func NewHandler(s *sdk.PluginSDK) *Handler { tr sdk.TrackerAPI se sdk.SettingsAPI pm sdk.PluginManager - st sdk.StatusAPI - term sdk.TerminalAPI - llm sdk.LLMAPI + st sdk.StatusAPI + llm sdk.LLMAPI // kc 是内核重启/关机通道;nil 表示该部署没接(端点会明确报不可用)。 kc sdk.KernelControlAPI ) @@ -194,7 +198,6 @@ func NewHandler(s *sdk.PluginSDK) *Handler { tm, ks, tr = s.TextMemory(), s.Knowledge(), s.Tracker() se, pm = s.Settings(), s.PluginMgr() st, llm = s.Status(), s.LLM() - term = s.Terminal() kc = s.KernelControl() } h := &Handler{ @@ -212,8 +215,8 @@ func NewHandler(s *sdk.PluginSDK) *Handler { kernelCtl: kc, pluginMgr: pm, status: st, - term: term, llm: llm, + termView: pluginview.NewTerminalView(), sessions: make(map[string]time.Time), loginLimiter: newLoginLimiter(loginMaxFails, loginWindow), pendingIdx: -1, @@ -227,6 +230,19 @@ func NewHandler(s *sdk.PluginSDK) *Handler { chatStop: 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() h.loadChatHistory() if s != nil { diff --git a/internal/plugins/webui/handler_chat.go b/internal/plugins/webui/handler_chat.go index 1f7f11f8..18ee3517 100644 --- a/internal/plugins/webui/handler_chat.go +++ b/internal/plugins/webui/handler_chat.go @@ -390,6 +390,15 @@ func (h *Handler) flushChat() { // 由插件 Stop 调用;不这样做会丢掉最后一轮对话。 func (h *Handler) Close() { 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) <-h.chatLoopDone }) diff --git a/internal/plugins/webui/handler_terminal.go b/internal/plugins/webui/handler_terminal.go index 445bc244..84a57523 100644 --- a/internal/plugins/webui/handler_terminal.go +++ b/internal/plugins/webui/handler_terminal.go @@ -8,35 +8,34 @@ import ( // 终端面:终端会话列表与命令历史接口。 // -// 「内核开,两个插件接」后,终端会话与命令历史的权威视图在内核 -// (internal/agent/core/terminal_registry.go):内核订阅自己的事件总线 -// 归并 EventToolCall(terminal_create/close、cmd_run)与 EventTerminalOutput -// (agentcli 生命周期 + 输出)。WebUI 不再自己订阅事件攒一份——直接读内核 -// 开放的 TerminalAPI(s.Terminal()),与 CLI 同源同口径。 +// 职责边界(2026-10-20 调整):终端状态归 agentcli,内核**不**再维护终端语义 +// (旧的 internal/agent/core/terminal_registry.go 已删除)。WebUI 在 +// NewHandler 里订阅事件总线(EventTerminalOutput + EventToolCall)攒自己的视图, +// CLI 用同一份 internal/pluginview 实现,两个插件的解析口径一致。 // // 为什么不塞进 KernelStatus:那是全量快照,前端每 3 秒轮询 /kernel, // 把每终端最多 64KB 的输出缓冲背进去会让轮询成本爆炸。 -// handleTerminals 返回内核权威的终端会话快照。 +// handleTerminals 返回终端会话快照(本插件从事件总线攒的视图)。 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{}{}}) return } - terms := h.term.ListTerminals() + terms, _ := h.termView.Snapshot() if terms == nil { terms = []sdk.TerminalStatus{} } writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": terms}) } -// handleCmdHistory 返回内核权威的命令执行历史快照。 +// handleCmdHistory 返回命令执行历史快照(本插件从事件总线攒的视图)。 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{}{}}) return } - cmds := h.term.CmdHistory() + _, cmds := h.termView.Snapshot() if cmds == nil { cmds = []sdk.CmdExecStatus{} } diff --git a/internal/agent/core/terminal_registry.go b/internal/pluginview/terminal.go similarity index 81% rename from internal/agent/core/terminal_registry.go rename to internal/pluginview/terminal.go index 67579db7..a5ff626a 100644 --- a/internal/agent/core/terminal_registry.go +++ b/internal/pluginview/terminal.go @@ -1,23 +1,30 @@ -package core +package pluginview import ( "strings" "sync" "time" - "github.com/JianFeeeee/HomeAgent/internal/sdk" + sdk "github.com/JianFeeeee/HomeAgent/internal/sdk" ) -// TerminalRegistry 是**内核侧**的终端会话与命令历史权威视图(「内核开,两个插件接」)。 +// TerminalView 是**插件侧**的终端会话与命令历史视图。 // -// 此前 WebUI 与 CLI 插件各自订阅 EventToolCall / EventTerminalOutput 攒一份 -// 状态:同一件事两份推导,还各自踩过同一个坑(工具 result 是 Go map 文本, -// 断言成 map[string]interface{} 永远失败 → /terminals 空空如也)。 +// 职责边界(2026-10-20 调整):终端逻辑只归 agentcli ——PTY、会话、历史都在它 +// 那里;内核**不**再维护终端语义(旧的 internal/agent/core/terminal_registry.go +// 已删除)。插件要展示终端,就订阅事件总线自己攒视图: // -// 现在权威状态收归内核一份:内核订阅自己的事件总线,把 terminal_* / cmd_run -// 的工具调用与 agentcli 的 terminal_output 事件归并成唯一真相; -// WebUI 和 CLI 都从 s.Status().GetKernelStatus() 读取,不再各自推导。 -type TerminalRegistry struct { +// EventTerminalOutput(agentcli 发)→ 会话输出 + 生命周期 +// EventToolCall (内核发) → cmd_run 历史、terminal_create/close 归并 +// +// 为什么放在一个共享包里而不是各写一份:WebUI 与 CLI 都需要同一份口径, +// 且历史上两份推导踩过同一个坑(工具 result 是 Go map 文本,断言成 +// map[string]interface{} 永远失败 → /terminals 空空如也)。共享实现保证 +// 两边解析口径一致。 +// +// ❗这是**视图**不是权威:状态真值在 agentcli。插件晚于会话启动时会缺早期 +// 会话(下次生命周期事件会补回),不要把这里当持久记录。 +type TerminalView struct { mu sync.Mutex terms map[string]*TermState cmds []CmdExec @@ -50,15 +57,15 @@ const ( maxTermOutput = 64 * 1024 ) -func NewTerminalRegistry() *TerminalRegistry { - return &TerminalRegistry{ +func NewTerminalView() *TerminalView { + return &TerminalView{ terms: make(map[string]*TermState), } } // OnToolCall 归并内核自己发布的 EventToolCall(agent 路径;result 是 Go 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) args, _ := payload["args"].(map[string]interface{}) status, _ := payload["status"].(string) @@ -121,7 +128,7 @@ func (r *TerminalRegistry) OnToolCall(payload map[string]interface{}) { // OnTerminalOutput 归并 agentcli 的 terminal_output 事件(含生命周期事件: // 创建时带 command,关闭/退出/超时带 running=false)。 -func (r *TerminalRegistry) OnTerminalOutput(payload map[string]interface{}) { +func (r *TerminalView) OnTerminalOutput(payload map[string]interface{}) { id, _ := payload["terminal_id"].(string) if id == "" { return @@ -164,19 +171,8 @@ func (r *TerminalRegistry) OnTerminalOutput(payload map[string]interface{}) { 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 的终端列表与命令历史快照(拷贝,调用方可随意改)。 -func (r *TerminalRegistry) Snapshot() ([]sdk.TerminalStatus, []sdk.CmdExecStatus) { +func (r *TerminalView) Snapshot() ([]sdk.TerminalStatus, []sdk.CmdExecStatus) { r.mu.Lock() defer r.mu.Unlock() terms := make([]sdk.TerminalStatus, 0, len(r.terms)) diff --git a/internal/agent/core/terminal_registry_test.go b/internal/pluginview/terminal_test.go similarity index 98% rename from internal/agent/core/terminal_registry_test.go rename to internal/pluginview/terminal_test.go index c2a55bdc..fc44d434 100644 --- a/internal/agent/core/terminal_registry_test.go +++ b/internal/pluginview/terminal_test.go @@ -1,4 +1,4 @@ -package core +package pluginview import "testing" @@ -27,7 +27,7 @@ func TestMapFieldFromMapText(t *testing.T) { } func TestTerminalRegistryLifecycle(t *testing.T) { - r := NewTerminalRegistry() + r := NewTerminalView() // agent 路径:terminal_create 的 result 是 Go map 文本,要能回填 id。 r.OnToolCall(map[string]interface{}{ @@ -94,7 +94,7 @@ func TestTerminalRegistryLifecycle(t *testing.T) { // 终端数超上限时要淘汰,不能无界增长。 func TestTerminalRegistryCap(t *testing.T) { - r := NewTerminalRegistry() + r := NewTerminalView() for i := 0; i < maxTerminals+20; i++ { r.OnTerminalOutput(map[string]interface{}{ "terminal_id": string(rune('a'+i%26)) + "-x", "running": true, diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index dfbfbd43..f05e0c48 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -184,7 +184,6 @@ type PluginSDK struct { config ConfigAPI tool ToolAPI indexer IndexerAPI - terminal TerminalAPI selftestMu sync.Mutex selftest *VirtualInstance @@ -338,7 +337,6 @@ type SDKConfig struct { Config ConfigAPI Tool ToolAPI Indexer IndexerAPI - Terminal TerminalAPI } func New(name string, cfg SDKConfig) *PluginSDK { @@ -390,7 +388,6 @@ func New(name string, cfg SDKConfig) *PluginSDK { config: cfg.Config, tool: cfg.Tool, 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) Tool() ToolAPI { return s.tool } 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{}) { if s.iom != nil { diff --git a/internal/sdk/terminal.go b/internal/sdk/terminal.go index 9686b347..d5eac681 100644 --- a/internal/sdk/terminal.go +++ b/internal/sdk/terminal.go @@ -1,23 +1,8 @@ package sdk -// TerminalAPI 是内核开放的终端会话与命令历史接口(「内核开,两个插件接」)。 -// -// 为什么单开接口而不是塞进 KernelStatus:KernelStatus 是**全量运行态快照**, -// /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 -} - +// 终端 DTO(TerminalStatus / CmdExecStatus)保留:它们只是**线上格式**, +// WebUI 与 CLI 用同一口径返回给前端。终端语义(谁在跑、历史怎么归并) +// 已归属 agentcli 与插件侧视图(internal/pluginview),内核不再有 TerminalAPI。 // TerminalStatus 与 WebUI termState / CLI cliTermState 同一 JSON 口径。 type TerminalStatus struct { ID string `json:"id"`