diff --git a/cmd/waiter/builtin.go b/cmd/waiter/builtin.go index 7850b25..d39ddf7 100644 --- a/cmd/waiter/builtin.go +++ b/cmd/waiter/builtin.go @@ -47,6 +47,9 @@ Server commands (local 与 remote 行为一致): /adapters remove remove a Lua adapter /network network status + LLM endpoints /runtime scheduler / residents / channel topology + /terminals list terminal sessions + /cmd/history command execution history + /terminal create|write|read|close … (local mode; calls agentcli tools) /persona show persona (/persona set default|custom|later [text]) /agents list agents /chat send to agent @@ -353,6 +356,34 @@ Any other text is sent to the agent directly.`) } return true + case cmd == "/terminals": + if rc := state.RemoteConn(); rc != nil { + d, _ := rc.DoAPI("GET", "/api/v1/terminals", "") + printJSON(out, d) + } else { + state.Send("/terminals") + } + return true + + case cmd == "/cmd/history": + if rc := state.RemoteConn(); rc != nil { + d, _ := rc.DoAPI("GET", "/api/v1/cmd/history", "") + printJSON(out, d) + } else { + state.Send("/cmd/history") + } + return true + + case cmd == "/terminal" || strings.HasPrefix(cmd, "/terminal "): + if rc := state.RemoteConn(); rc != nil { + // 远端 WebUI 没有“开终端”的 REST 端点(终端由 agentcli 工具创建), + // 不静默当聊天发出去,直接说明。 + fmt.Fprintln(out, "remote 模式暂不支持终端操作;请在 local 模式或让 agent 调 terminal_* 工具") + } else { + state.Send(cmd) + } + return true + case cmd == "/runtime": if rc := state.RemoteConn(); rc != nil { d, _ := rc.DoAPI("GET", "/api/v1/runtime", "") diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index 4915d02..2744361 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" "sync" + "time" "gitcode.com/JianFeeeee/HomeAgent/internal/config" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" @@ -49,6 +50,32 @@ type Plugin struct { ln net.Listener mu sync.Mutex wg sync.WaitGroup + + // 终端会话与命令历史:订阅内核事件攒出来的,与 WebUI 同源同口径。 + // 不是 WebUI 私有数据——它也是订 EventToolCall/EventTerminalOutput 自己攒的。 + termMu sync.Mutex + termStates map[string]*cliTermState + cmdMu sync.Mutex + cmdHistory []cliCmdExec +} + +// cliTermState 与 webui 的 termState 同字段(/terminals 输出口径)。 +type cliTermState struct { + ID string `json:"id"` + Command string `json:"command"` + Running bool `json:"running"` + Output string `json:"output"` + CreatedAt string `json:"created_at"` +} + +// cliCmdExec 与 webui 的 CmdExec 同字段(/cmd/history 输出口径)。 +type cliCmdExec struct { + Command string `json:"command"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + Status string `json:"status"` + Time string `json:"time"` } func New(name, socketPath string) *Plugin { @@ -62,6 +89,8 @@ func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { s.SetAutoRestart(true) + p.termStates = make(map[string]*cliTermState) + p.subscribeToolEvents(s) // inputch 先登记:本插件既用 "cli" 作输出目标,也用它注入输入(终端行)。 // 输入侧必须显式登记,否则"把 inputch 划给驻留子"会找不到它。 @@ -320,6 +349,12 @@ func (p *Plugin) handleBuiltin(conn net.Conn, line string, s *sdk.PluginSDK) boo p.cmdRuntime(conn, s) case "/persona": p.cmdPersona(conn, parts, s) + case "/terminals": + p.cmdTerminals(conn) + case "/cmd/history": + p.cmdCmdHistory(conn) + case "/terminal": + p.cmdTerminal(conn, parts, s) default: return false } @@ -358,6 +393,9 @@ func (p *Plugin) cmdHelp(conn net.Conn) { /network 网络状态与 LLM 端点 /runtime 调度器/驻留子/通道拓扑快照 /persona 当前人格设定(/persona set [内容] 修改) + /terminals 终端会话列表(与 WebUI /terminals 同源) + /cmd/history 命令执行历史(cmd_run) + /terminal create|write|read|close … 创建/写入/读取/关闭终端(调 agentcli 工具) /agents 列出 Agent 其他文本直接发送给 Agent 处理。`, @@ -942,6 +980,183 @@ func (p *Plugin) cmdAgents(conn net.Conn, s *sdk.PluginSDK) { writeJSONContent(conn, map[string]interface{}{"agents": []interface{}{map[string]string{"id": ks.AgentID, "state": "running"}}}) } +// ======== /terminals /cmd/history /terminal ======== + +// subscribeToolEvents 订阅内核工具与终端事件,维护命令历史与终端会话视图。 +// +// 这两份数据不是 WebUI 插件私有的:WebUI 也是订阅同样的 EventToolCall / +// EventTerminalOutput 自己攒出来的(见 handler_chat.go 的 handleToolEvent、 +// handler_terminal.go 的 subscribeTerminalStream)。事件面本就是 SDK 对内部 +// 插件开放的,所以 CLI 能做到同口径,不需要新增内核接口。 +func (p *Plugin) subscribeToolEvents(s *sdk.PluginSDK) { + s.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) { + payload := ev.Payload + tool, _ := payload["tool"].(string) + args, _ := payload["args"].(map[string]interface{}) + status, _ := payload["status"].(string) + switch tool { + case "cmd_run": + cmd := "" + if args != nil { + cmd, _ = args["command"].(string) + } + p.cmdMu.Lock() + p.cmdHistory = append(p.cmdHistory, cliCmdExec{ + Command: cmd, Status: status, Time: time.Now().Format(time.RFC3339), + }) + if len(p.cmdHistory) > 100 { + p.cmdHistory = p.cmdHistory[len(p.cmdHistory)-100:] + } + p.cmdMu.Unlock() + case "terminal_create": + id := "" + if args != nil { + id, _ = args["id"].(string) + } + if id == "" { + // agent 调用时不知道生成的 id,从工具结果中回填(同 WebUI) + if res, ok := payload["result"].(map[string]interface{}); ok { + id, _ = res["id"].(string) + } + } + if id == "" { + return + } + cmd := "" + if args != nil { + cmd, _ = args["command"].(string) + } + p.termMu.Lock() + if old, ok := p.termStates[id]; ok { + old.Command = cmd + old.Running = true + } else { + p.termStates[id] = &cliTermState{ + ID: id, Command: cmd, Running: true, + CreatedAt: time.Now().Format(time.RFC3339), + } + } + p.termMu.Unlock() + case "terminal_close": + id := "" + if args != nil { + id, _ = args["id"].(string) + } + if id != "" { + p.termMu.Lock() + if t, ok := p.termStates[id]; ok { + t.Running = false + } + p.termMu.Unlock() + } + } + }) + s.Subscribe(sdk.EventTerminalOutput, func(ev *sdk.Event) { + payload := ev.Payload + id, _ := payload["terminal_id"].(string) + if id == "" { + return + } + output, _ := payload["output"].(string) + running, _ := payload["running"].(bool) + p.termMu.Lock() + ts, ok := p.termStates[id] + if !ok { + ts = &cliTermState{ID: id, CreatedAt: time.Now().Format(time.RFC3339)} + p.termStates[id] = ts + } + ts.Running = running + if output != "" { + const maxTermOutput = 64 * 1024 + if len(ts.Output)+len(output) > maxTermOutput { + excess := len(ts.Output) + len(output) - maxTermOutput + if len(ts.Output) > excess { + ts.Output = ts.Output[excess:] + } else { + ts.Output = "" + } + } + ts.Output += output + } + p.termMu.Unlock() + }) +} + +// cmdTerminals 与 WebUI 的 GET /api/v1/terminals 同口径。 +func (p *Plugin) cmdTerminals(conn net.Conn) { + p.termMu.Lock() + list := make([]*cliTermState, 0, len(p.termStates)) + for _, t := range p.termStates { + list = append(list, t) + } + p.termMu.Unlock() + writeJSONContent(conn, map[string]interface{}{"terminals": list}) +} + +// cmdCmdHistory 与 WebUI 的 GET /api/v1/cmd/history 同口径。 +func (p *Plugin) cmdCmdHistory(conn net.Conn) { + p.cmdMu.Lock() + out := make([]cliCmdExec, len(p.cmdHistory)) + copy(out, p.cmdHistory) + p.cmdMu.Unlock() + writeJSONContent(conn, map[string]interface{}{"history": out}) +} + +// cmdTerminal 通过 ToolAPI.ExecuteTool 调 agentcli 的终端工具。 +// +// 终端本体属于 agentcli 插件(terminal_create/write/read/close),SDK 的 +// ToolAPI.ExecuteTool 已允许跨插件调用工具,所以 CLI 不必新增接口就能开/写/读/关。 +func (p *Plugin) cmdTerminal(conn net.Conn, parts []string, s *sdk.PluginSDK) { + if len(parts) < 2 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /terminal create <命令> | write <输入> | read | close "}) + return + } + tools := s.Tool() + if tools == nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": "tool api not available"}) + return + } + switch parts[1] { + case "create": + if len(parts) < 3 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /terminal create <命令>"}) + return + } + res, err := tools.ExecuteTool("terminal_create", map[string]interface{}{"command": strings.Join(parts[2:], " ")}) + if err != nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()}) + return + } + writeJSONContent(conn, res) + case "write": + if len(parts) < 4 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /terminal write <输入>"}) + return + } + res, err := tools.ExecuteTool("terminal_write", map[string]interface{}{ + "id": parts[2], "input": strings.Join(parts[3:], " "), + }) + if err != nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()}) + return + } + writeJSONContent(conn, res) + case "read", "close": + if len(parts) < 3 { + writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /terminal " + parts[1] + " "}) + return + } + res, err := tools.ExecuteTool("terminal_"+parts[1], map[string]interface{}{"id": parts[2]}) + if err != nil { + writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()}) + return + } + writeJSONContent(conn, res) + default: + writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /terminal create|write|read|close …"}) + } +} + // ======== helpers ======== // truncateOneLine 将多行文本压成单行并按 rune 截断,用于事件结果预览。