Files
HomeAgent/internal/plugins/agentcli/plugin.go
JianFeeeee d1959cbe80 feat(core): 注入行为的记忆/裁剪标志位落地 + jieba 词库内嵌 + Windows 改走 WSL
配套 SDK 提交:homeagent-sdk ba49dfd(公开 API 纯追加,无签名变更)。
本仓第三方的库镜像同步至该版本,以保证全新 clone 能编译。

## 1. 注入标志位(内核侧)

- 7 条注入路径(排队/中断/同步 × 纯文本/带媒体 + 旧 NoMem 变体)解析并转发
  no_memory / context_policy / cleaner_name;策略在入口**校验**,
  非法值报错而不是静默降级成 none(降级会让调用方以为自己声明的裁剪在生效)。
- 新增 validateContextPolicy(与 tool.register 同一套规则)与 pubSdkInjectOpts。
- input.register 不再手写字段白名单重建 ChannelDef,改为整体传递 + 补 ContextPolicy。
- io 层:applyInjectOpts 把标志位写进事件 payload,仅非零时写
  (零值与旧 payload 逐字节一致,事件订阅方与旧内核都不受影响)。
- ioAdapter / procCore / internal-sdk 别名补齐六个 *Opts 实现。

## 2. 修掉「输入无条件裁剪」这个真缺陷

eventloop 此前对**每条非中断输入**都调 `context.Prune(...)`:破坏性(低相关事件被
归档移出上下文)且无法从调用点看出是谁触发的。改为 pruneOnInput/pruneDeclared:

  优先级:注入点声明(payload.context_policy)> 通道声明(ChannelDef.ContextPolicy)
          > 默认**不裁剪**

查询向量仍取清洗后的内容;新增 cleanInputFor 解析清洗文本,优先级为
注入点声明的 cleaner(cleaner_name)> 按 source 查到的通道 cleaner > 原文,
名字查不到时**记日志再回退**(注入是 fire-and-forget,插件看不到错误,
至少要在内核日志留下「你声明的清洗没生效」的痕迹)。

## 3. jieba 词库内嵌(修「猜 GOMODCACHE → 静默失效」)

原 jiebaDictDir() 去猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块
缓存 → GetJieba() 返回 nil → 分词/关键词提取/NLP 依存解析(进而 doc→graph 三元组
抽取)/静态词向量 tokenizer **一律静默返回空列表**,只有一行日志。本机看起来正常
只因开发机与生产机重合、恰好有那份缓存。

现在词库随二进制分发:internal/memory/jiebadict/ 5 文件约 11.6MB + go:embed,
按**内容哈希**命名缓存目录落盘(词库升级不复用旧文件),已齐全则跳过写入。
模块缓存降为兜底。homed 体积 32MB。

顺带确认(并有测试佐证):gojieba 的 Tag() 不需要 pos_dict/ 目录——
cppjieba 的 PosTagger 从主词典每行的词性列取 tag。

## 4. homed 放弃 Windows 原生,改走 WSL2

插件体系依赖「继承的 fd」+「统一共享内存区的段内偏移解引用」,Windows 既无 fd
继承语义,其句柄模型也无法表达后者;强行适配等于再维护一套平台专属 ABI
(C ABI 时代三套 ABI 并存曾导致改写型插件在某平台静默失效)。

- cmd/homed/platform_{windows,other}.go:原生 Windows 启动即拒绝并打印 WSL2 指引。
- internal/plugin/proc/shmalloc_windows.go:allocShm 直接返回「请用 WSL2」,
  **不返回半可用的段**(与 shmalloc_other.go 同风格:未支持平台显式报错);
  procEnvForShm 返回 nil。顺手修掉两处长期编译错误
  (cryptorand→rand、h.evData→h.unified.evtData),使 GOOS=windows 至少能编译。
  注:homed 本就编不出 Windows——internal/memory 依赖 cgo-only 的 gojieba。
- deploy/packaging/installer.nsi:不再安装 homed.exe/initconfig.exe,改为携带
  **linux payload** 并调用新的 install-via-wsl.ps1;退出码 20/21 表示
  「需先装 WSL/发行版」,走指引而非报错。
- deploy/packaging/windows/install-via-wsl.ps1(新):检测 WSL → 引导安装 →
  确保 WSL2 → 送包进发行版 → 在 WSL 内按 Linux 方式安装。**复用 Linux 包与
  linux/setup.sh**,不另写一套安装逻辑;落点与 deb 布局统一
  (/usr/bin/homed + /usr/lib/homeagent/setup.sh)。
- deploy/packaging/linux/setup.sh:API Key 允许 HOMEAGENT_API_KEY 覆盖
  (否则安装器界面显示一份、config.db 里另一份 → 登录不上)。
- deploy/packaging/build.sh:windows 目标只构建 waiter + gui,并新增
  stage_linux_payload 把 Linux 包暂存给安装器;homed/initconfig 在 windows
  目标下明确拒绝。

## 5. 插件调用点统一写明意图

- webui 的 OpenAI 兼容端点(固定提示词模板)→ InjectTextSyncNoMemory。
- agentcli 的 5 处纯状态通知(已启动/超时/执行结束/进程退出/读取结束)→ NoMemory;
  **带输出**的 2 处(定时反馈、有新输出)刻意保留记忆并注明理由。
- timer 的定时提醒 → NoMemory(中断本来也隐含 NoMemory,这里是写明意图)。

## 6. 版本

meta.Version 仍为 1.2.0(main 是下一个未发布中版本);
SDKCompatibleVersion 1.1.0 → **1.2.0**(本内核已实现 SDK 1.2.0 全部新增方法)。

## 测试

- core:默认不裁剪(无声明/none/空)、通道 opt-in、注入点双向覆盖通道、
  nil context/io 安全、cleaner 优先级与未知名回退。
- io:零值 opts 与历史 payload 逐键相同;text/中断/媒体三类注入标志位都落到
  payload;旧方法仍生效。
- proc:validateContextPolicy 只接受 ""/none/prune,报错含位置与实际值;
  **跨进程** e2e——testdata 插件经 io.injectText 送出三个标志位,断言它们穿过 RPC
  到达内核。
- memory:模块缓存不可见时内嵌词库仍可用(分词与 POS 内容词均非空)、
  落盘幂等、内容哈希稳定。

验证:go build ./... / go vet ./... / go vet -tags onnxruntime ./...
      go test -short ./internal/memory/... ./internal/nlp/... ./internal/plugin/...
      ./internal/agent/{core,io}/... ./pkg/...
2026-09-11 20:31:50 +08:00

1159 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build linux || windows
package agentcli
import (
"bytes"
"fmt"
"log"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
const (
DefaultTimeout = 5 * time.Minute
ReadBufSize = 4096
MaxOutputBuffer = 128 * 1024
DefaultNotifyBytes = 2048 // 积累 2KB 未读输出再通知
DefaultNotifyInterval = 2 * time.Second // 同一终端两次通知的最小间隔(兜底)
)
// ptyTerm 抽象平台终端后端Linux PTY / Windows ConPTY
type ptyTerm interface {
Read(buf []byte) (int, error)
WriteString(s string) (int, error)
Resize(rows, cols uint16) error
Running() bool
Kill() error
Close() error
}
// terminalRunning 判断终端后端进程是否仍在运行:优先走平台实现,
// 否则回落到 exec.Cmd 的 ProcessState仅 Linux 传统路径)。
func terminalRunning(t *TerminalSession) bool {
if p, ok := t.session.(interface {
Running() bool
}); ok && p.Running() {
return true
}
return t.cmd != nil && (t.cmd.ProcessState == nil || !t.cmd.ProcessState.Exited())
}
// terminalWatch 终端提醒规则(由 terminal_watch 工具设置)。
type terminalWatch struct {
interval time.Duration // 固定时间反馈间隔0 禁用
onExit bool // 命令执行结束提醒(默认 true
bufferBytes int // 该终端专用缓冲阈值字节0 使用全局 notify_bytes
quiet bool // 静默模式:不随输出流通知,仅定时反馈/结束提醒/空闲汇总
}
type TerminalSession struct {
id string
command string
cmd *exec.Cmd
session ptyTerm
mu sync.Mutex
buf bytes.Buffer
createdAt time.Time
timeout time.Duration
closed bool
stopCh chan struct{}
done chan struct{}
// 通知节流字段
unreadBytes int // 最近一次通知后积累的未读字节数
lastNotify time.Time // 最近一次通知时间
lastData time.Time // 最近一次读到的数据时间(用于判定输出停止)
lastFeedback time.Time // 最近一次定时反馈时间
backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍
watch terminalWatch // 该终端的提醒规则
// 实时画面推流terminal_output 事件)
stream bytes.Buffer // 待推送的增量输出,由 readLoop 每 200ms flush 一次
}
func (t *TerminalSession) Write(input string) (int, error) {
return t.session.WriteString(input)
}
func (t *TerminalSession) Read(buf []byte) (int, error) {
return t.session.Read(buf)
}
func (t *TerminalSession) Resize(rows, cols uint16) error {
return t.session.Resize(rows, cols)
}
func (t *TerminalSession) Close() {
t.mu.Lock()
if t.closed {
t.mu.Unlock()
return
}
t.closed = true
t.mu.Unlock()
close(t.stopCh)
// 先终止进程各平台实现Linux 信号 / Windows TerminateProcess幂等再释放资源。
// 不能依赖 cmd.Process.Kill()Windows 后端 cmd.Process 为占位(仅 Pid
if t.session != nil {
_ = t.session.Kill()
}
t.session.Close()
<-t.done
}
func (t *TerminalSession) ReadOutput() string {
t.mu.Lock()
defer t.mu.Unlock()
return t.buf.String()
}
func (t *TerminalSession) ReadAndClearOutput() string {
t.mu.Lock()
defer t.mu.Unlock()
s := t.buf.String()
t.buf.Reset()
return s
}
func (t *TerminalSession) appendOutput(data []byte) {
t.mu.Lock()
defer t.mu.Unlock()
if t.buf.Len()+len(data) > MaxOutputBuffer {
excess := t.buf.Len() + len(data) - MaxOutputBuffer
if t.buf.Len() > excess {
t.buf.Next(excess)
} else {
t.buf.Reset()
}
}
t.buf.Write(data)
// 同步追加到实时画面推流缓冲(最大 64KB超出丢弃最旧部分
const maxStream = 64 * 1024
if t.stream.Len()+len(data) > maxStream {
excess := t.stream.Len() + len(data) - maxStream
if t.stream.Len() > excess {
t.stream.Next(excess)
} else {
t.stream.Reset()
}
}
t.stream.Write(data)
}
func (t *TerminalSession) IsExpired() bool {
return time.Since(t.createdAt) >= t.timeout
}
type Plugin struct {
name string
mu sync.Mutex
wg sync.WaitGroup
stopCh chan struct{}
sessions map[string]*TerminalSession
nextID int
defaultTimeout time.Duration
notifyBytes int
notifyInterval time.Duration
}
func New(name string) *Plugin {
return &Plugin{
name: name,
stopCh: make(chan struct{}),
sessions: make(map[string]*TerminalSession),
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "default_timeout", Type: "string", DisplayName: "默认终端超时",
Description: "终端自动关闭的默认时间,例如 5m, 10m, 30m, 1h默认 5m",
Default: "5m",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "notify_bytes", Type: "int", DisplayName: "通知阈值字节数",
Description: "累积多少字节未读输出后发送通知(默认 2048",
Default: "2048",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "notify_interval", Type: "string", DisplayName: "通知最小间隔",
Description: "同一终端两次通知的最小时间间隔,如 2s, 5s默认 2s",
Default: "2s",
})
if v, _ := s.Settings().Get("default_timeout"); v != nil {
if s, ok := v.(string); ok && s != "" {
if d, err := time.ParseDuration(s); err == nil {
p.defaultTimeout = d
}
}
}
if p.defaultTimeout <= 0 {
p.defaultTimeout = DefaultTimeout
}
if v, _ := s.Settings().Get("notify_bytes"); v != nil {
if i, ok := v.(float64); ok && i > 0 {
p.notifyBytes = int(i)
}
}
if p.notifyBytes <= 0 {
p.notifyBytes = DefaultNotifyBytes
}
if v, _ := s.Settings().Get("notify_interval"); v != nil {
if s, ok := v.(string); ok && s != "" {
if d, err := time.ParseDuration(s); err == nil {
p.notifyInterval = d
}
}
}
if p.notifyInterval <= 0 {
p.notifyInterval = DefaultNotifyInterval
}
s.RegisterTool("terminal_create", sdk.ToolDef{
Name: "terminal_create",
Description: "创建一个新的交互式终端会话。返回终端 ID后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +
"通知模式通过 notify 参数选择(默认 exitexit=仅命令执行结束后提醒一次interval=定时反馈(如 interval=30s 每 30 秒反馈一次状态摘要);" +
"buffer=未读输出积累到指定字节数后提醒(如 buffer=8192多个模式用逗号组合如 interval=30s,buffer=8192。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"command": map[string]interface{}{
"type": "string",
"description": "要执行的命令(默认 bash。如需运行特定程序直接传入即可例如vim /tmp/test.txt",
},
"notify": map[string]interface{}{
"type": "string",
"description": "通知模式可选exit默认命令结束后提醒interval=时长(定时反馈,如 30s/1mbuffer=字节数(缓冲阈值提醒);可逗号组合",
},
"timeout": map[string]interface{}{
"type": "string",
"description": "终端自动关闭时间,例如 5m, 10m, 30m, 1h默认 5m",
},
"rows": map[string]interface{}{
"type": "integer",
"description": "终端行数(默认 24",
},
"cols": map[string]interface{}{
"type": "integer",
"description": "终端列数(默认 80",
},
},
},
}, func(args map[string]interface{}) (interface{}, error) {
return p.handleCreate(s, args)
})
s.RegisterTool("terminal_write", sdk.ToolDef{
Name: "terminal_write",
Description: "向指定终端发送输入。支持普通文本和特殊键(通过 key 参数传入。特殊键包括enter, tab, escape, ctrl_a~ctrl_z, alt_a~alt_z, f1~f12, up, down, left, right, home, end, backspace, delete, page_up, page_down。普通文本传入 input 参数即可。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
"description": "终端 ID来自 terminal_create 的返回值",
},
"input": map[string]interface{}{
"type": "string",
"description": "要发送的文本内容(普通文字直接输入)",
},
"key": map[string]interface{}{
"type": "string",
"description": "特殊按键enter, tab, escape, ctrl_a~ctrl_z, alt_a~alt_z, f1~f12, up, down, left, right, home, end, backspace, delete, page_up, page_down",
},
},
"required": []string{"id"},
},
}, func(args map[string]interface{}) (interface{}, error) {
return p.handleWrite(s, args)
})
s.RegisterTool("terminal_read", sdk.ToolDef{
Name: "terminal_read",
Description: "读取指定终端的输出。mode=new默认返回自上次读取以来的新输出并清空缓冲mode=now 返回终端当前显示的全部屏幕内容(不清空缓冲)。如需持续监控请多次调用。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
"description": "终端 ID",
},
"mode": map[string]interface{}{
"type": "string",
"description": "读取模式new默认新输出并清空缓冲或 now当前屏幕全部内容不清理",
},
"clear": map[string]interface{}{
"type": "boolean",
"description": "读取后是否清除缓冲区(默认与 mode 一致new 清除now 不清除)",
},
},
"required": []string{"id"},
},
}, func(args map[string]interface{}) (interface{}, error) {
return p.handleRead(args)
})
s.RegisterTool("terminal_resize", sdk.ToolDef{
Name: "terminal_resize",
Description: "调整指定终端的尺寸(行数和列数)。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
"description": "终端 ID",
},
"rows": map[string]interface{}{
"type": "integer",
"description": "行数",
},
"cols": map[string]interface{}{
"type": "integer",
"description": "列数",
},
},
"required": []string{"id"},
},
}, func(args map[string]interface{}) (interface{}, error) {
return p.handleResize(args)
})
s.RegisterTool("terminal_close", sdk.ToolDef{
Name: "terminal_close",
Description: "关闭指定终端会话。释放资源。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
"description": "终端 ID",
},
},
"required": []string{"id"},
},
}, func(args map[string]interface{}) (interface{}, error) {
return p.handleClose(args)
})
s.RegisterTool("terminal_list", sdk.ToolDef{
Name: "terminal_list",
Description: "列出所有活跃的终端会话及其状态。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, func(args map[string]interface{}) (interface{}, error) {
return p.handleList()
})
s.RegisterTool("terminal_watch", sdk.ToolDef{
Name: "terminal_watch",
Description: "为指定终端设置提醒规则,避免长时间运行任务(编译/下载/构建等)的输出造成通知风暴。" +
"可选规则interval=固定时间反馈(每隔该时长向 agent 反馈一次终端状态摘要);" +
"on_exit=命令执行结束提醒buffer_bytes=未读输出积累到该字节数时提醒一次;" +
"quiet=静默模式(抑制随输出流的通知,仅保留定时反馈与结束提醒,推荐长任务使用)。" +
"未提供的字段保持原值clear=true 清除全部规则。默认 on_exit=true。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
"description": "终端 ID来自 terminal_create 的返回值",
},
"interval": map[string]interface{}{
"type": "string",
"description": "固定时间反馈间隔,如 30s, 1m, 5m可选0 禁用)",
},
"on_exit": map[string]interface{}{
"type": "boolean",
"description": "命令执行结束时是否提醒(默认 true",
},
"buffer_bytes": map[string]interface{}{
"type": "integer",
"description": "未读输出积累阈值(字节),达到后提醒一次(可选,默认全局 2048",
},
"quiet": map[string]interface{}{
"type": "boolean",
"description": "静默模式:不随输出流通知,仅保留定时反馈与结束提醒(推荐编译/下载等长任务)",
},
"clear": map[string]interface{}{
"type": "boolean",
"description": "清除该终端全部提醒规则(恢复默认行为)",
},
},
"required": []string{"id"},
},
}, func(args map[string]interface{}) (interface{}, error) {
return p.handleWatch(args)
})
p.wg.Add(1)
go p.cleanupLoop(s)
return nil
}
func (p *Plugin) Stop() error {
close(p.stopCh)
p.wg.Wait()
p.mu.Lock()
for _, t := range p.sessions {
t.Close()
}
p.sessions = nil
p.mu.Unlock()
return nil
}
func (p *Plugin) nextIDLocked() string {
p.nextID++
return fmt.Sprintf("term_%d", p.nextID)
}
func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) {
command, _ := args["command"].(string)
if command == "" {
command = defaultShell()
}
timeoutStr, _ := args["timeout"].(string)
timeout := p.defaultTimeout
if timeoutStr != "" {
if d, err := time.ParseDuration(timeoutStr); err == nil {
timeout = d
}
}
// SSH 命令自动使用更长的超时5 分钟)
if timeoutStr == "" && (strings.HasPrefix(command, "ssh ") || strings.HasPrefix(command, "ssh -")) {
timeout = 5 * time.Minute
}
rows := uint16(24)
cols := uint16(80)
if r, ok := args["rows"].(float64); ok && r > 0 {
rows = uint16(r)
}
if c, ok := args["cols"].(float64); ok && c > 0 {
cols = uint16(c)
}
// 通知模式:默认 exit命令执行结束后提醒一次
// 支持 interval=30s / buffer=8192 / quiet可逗号组合。
watch := terminalWatch{onExit: true, quiet: true}
if notifyStr, ok := args["notify"].(string); ok && notifyStr != "" {
watch = parseNotifyMode(notifyStr, watch)
}
term, cmd, err := newCommandPty(command, rows, cols)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("创建终端失败: %v", err)}, nil
}
session := &TerminalSession{
id: "",
command: command,
cmd: cmd,
session: term,
createdAt: time.Now(),
timeout: timeout,
stopCh: make(chan struct{}),
done: make(chan struct{}),
watch: watch,
}
p.mu.Lock()
id := p.nextIDLocked()
session.id = id
p.sessions[id] = session
p.mu.Unlock()
p.wg.Add(1)
go p.readLoop(session, s)
log.Printf("[agentcli] created terminal %s: command=%q timeout=%v rows=%d cols=%d", id, command, timeout, rows, cols)
return map[string]interface{}{
"id": id,
"status": "created",
"command": command,
"timeout": timeout.String(),
"rows": rows,
"cols": cols,
"notify_mode": notifyModeString(watch),
}, nil
}
// notifyModeString 输出可读的通知模式描述。
func notifyModeString(w terminalWatch) string {
var parts []string
if w.onExit {
parts = append(parts, "exit")
}
if w.interval > 0 {
parts = append(parts, "interval="+w.interval.String())
}
if w.bufferBytes > 0 {
parts = append(parts, fmt.Sprintf("buffer=%d", w.bufferBytes))
}
if len(parts) == 0 {
return "quiet"
}
return strings.Join(parts, ",")
}
func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
return map[string]interface{}{"error": "id is required"}, nil
}
p.mu.Lock()
session, ok := p.sessions[id]
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
}
input, _ := args["input"].(string)
key, _ := args["key"].(string)
var data []byte
if key != "" {
b, err := mapKey(key)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
data = b
} else if input != "" {
data = []byte(input)
} else {
return map[string]interface{}{"error": "需要提供 input 或 key 参数"}, nil
}
n, err := session.Write(string(data))
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("写入失败: %v", err)}, nil
}
return map[string]interface{}{
"status": "ok",
"bytes": n,
"terminal": id,
}, nil
}
// parseNotifyMode 解析 notify 参数并合并进 watch。
// 支持exit / quiet / interval=时长 / buffer=字节数,逗号分隔组合。
func parseNotifyMode(s string, base terminalWatch) terminalWatch {
w := base
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
kv := strings.SplitN(part, "=", 2)
key := strings.TrimSpace(kv[0])
val := ""
if len(kv) == 2 {
val = strings.TrimSpace(kv[1])
}
switch key {
case "exit":
w.onExit = true
w.quiet = false
case "quiet", "silent":
w.quiet = true
case "interval":
if d, err := time.ParseDuration(val); err == nil && d > 0 {
w.interval = d
}
case "buffer":
var n int
if _, err := fmt.Sscanf(val, "%d", &n); err == nil && n > 0 {
w.bufferBytes = n
}
}
}
return w
}
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
return map[string]interface{}{"error": "id is required"}, nil
}
mode, _ := args["mode"].(string)
clear := mode != "now"
if v, ok := args["clear"].(bool); ok {
clear = v
}
p.mu.Lock()
session, ok := p.sessions[id]
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
}
var output string
session.mu.Lock()
if clear {
output = session.buf.String()
session.buf.Reset()
// 实时画面推流缓冲同步清空,避免 terminal_output 事件与读取结果重复
session.stream.Reset()
} else {
output = session.buf.String()
}
session.mu.Unlock()
if output == "" {
if mode == "now" {
output = "[终端当前无屏幕内容]"
} else {
output = "[终端无新输出]"
}
}
return map[string]interface{}{
"status": "ok",
"terminal": id,
"mode": mode,
"output": output,
"running": terminalRunning(session),
"uptime": time.Since(session.createdAt).String(),
}, nil
}
func (p *Plugin) handleResize(args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
return map[string]interface{}{"error": "id is required"}, nil
}
rows, okRows := args["rows"].(float64)
cols, okCols := args["cols"].(float64)
if !okRows || !okCols {
return map[string]interface{}{"error": "rows 和 cols 为必填"}, nil
}
p.mu.Lock()
session, ok := p.sessions[id]
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
}
if err := session.Resize(uint16(rows), uint16(cols)); err != nil {
return map[string]interface{}{"error": fmt.Sprintf("调整尺寸失败: %v", err)}, nil
}
return map[string]interface{}{
"status": "ok",
"terminal": id,
"rows": rows,
"cols": cols,
}, nil
}
func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
return map[string]interface{}{"error": "id is required"}, nil
}
p.mu.Lock()
session, ok := p.sessions[id]
if ok {
delete(p.sessions, id)
}
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
}
session.Close()
log.Printf("[agentcli] closed terminal %s", id)
return map[string]interface{}{
"status": "closed",
"terminal": id,
}, nil
}
func (p *Plugin) handleWatch(args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
return map[string]interface{}{"error": "id is required"}, nil
}
p.mu.Lock()
session, ok := p.sessions[id]
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
}
session.mu.Lock()
if v, ok := args["clear"].(bool); ok && v {
session.watch = terminalWatch{onExit: true}
} else {
if v, ok := args["interval"].(string); ok && v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
session.watch.interval = d
}
}
if v, ok := args["on_exit"].(bool); ok {
session.watch.onExit = v
}
if v, ok := args["buffer_bytes"].(float64); ok && v >= 0 {
session.watch.bufferBytes = int(v)
}
if v, ok := args["quiet"].(bool); ok {
session.watch.quiet = v
}
if session.watch.interval == 0 && session.watch.bufferBytes == 0 && !session.watch.quiet {
session.watch.onExit = true
}
}
w := session.watch
session.mu.Unlock()
log.Printf("[agentcli] watch updated for %s: %+v", id, w)
return map[string]interface{}{
"status": "ok",
"terminal": id,
"interval": w.interval.String(),
"on_exit": w.onExit,
"buffer_bytes": w.bufferBytes,
"quiet": w.quiet,
}, nil
}
func (p *Plugin) handleList() (interface{}, error) {
p.mu.Lock()
defer p.mu.Unlock()
type termInfo struct {
ID string `json:"id"`
Command string `json:"command"`
Uptime string `json:"uptime"`
ExpiresIn string `json:"expires_in"`
Running bool `json:"running"`
}
var terms []termInfo
for _, t := range p.sessions {
running := terminalRunning(t)
remaining := t.timeout - time.Since(t.createdAt)
if remaining < 0 {
remaining = 0
}
terms = append(terms, termInfo{
ID: t.id,
Command: t.command,
Uptime: time.Since(t.createdAt).Round(time.Second).String(),
ExpiresIn: remaining.Round(time.Second).String(),
Running: running,
})
}
if terms == nil {
terms = []termInfo{}
}
return map[string]interface{}{
"status": "ok",
"count": len(terms),
"terminals": terms,
}, nil
}
func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
defer p.wg.Done()
defer close(t.done)
// reader 协程独享这个读缓冲:结果随 readResult 携带,
// readLoop 不再从其中做 copy见 reader 注释,那是对共享缓冲
// 的并发读写,-race 实测触发)。
buf := make([]byte, ReadBufSize)
pollInterval := 200 * time.Millisecond
readCh := make(chan readResult, 4)
go p.reader(t, buf, readCh)
// 实时画面推流 ticker每 200ms 批量发布一次 terminal_output 事件
flushTicker := time.NewTicker(200 * time.Millisecond)
defer flushTicker.Stop()
// 立即发送首次"终端已启动"通知,让 agent 感知存在。
// 用 NoMemory这是状态提示不是对话内容。不关掉的话每开一个终端都会
// 在记忆里留下一条"[终端 X 已启动]",把真实内容挤掉。
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id),
sdk.InjectOptions{NoMemory: true})
now := time.Now()
t.mu.Lock()
t.lastNotify = now
t.lastData = now
t.lastFeedback = now
t.mu.Unlock()
// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低
hardNotifyBytes := 64 * 1024
hardNotifyInterval := 10 * time.Second
// 输出停止判定:超过该时长无新数据则视为输出停止
quietLatency := 2 * time.Second
for {
if t.IsExpired() {
log.Printf("[agentcli] terminal %s expired after %v", t.id, t.timeout)
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已超时关闭(%s]", t.id, t.timeout),
sdk.InjectOptions{NoMemory: true})
p.mu.Lock()
delete(p.sessions, t.id)
p.mu.Unlock()
return
}
if !terminalRunning(t) {
if t.watch.onExit {
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的命令已执行结束]", t.id),
sdk.InjectOptions{NoMemory: true})
} else {
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id),
sdk.InjectOptions{NoMemory: true})
}
p.mu.Lock()
delete(p.sessions, t.id)
p.mu.Unlock()
return
}
// 固定时间反馈watch.interval > 0 时每隔该时长主动反馈一次状态摘要
t.mu.Lock()
if t.watch.interval > 0 && time.Since(t.lastFeedback) >= t.watch.interval {
t.lastFeedback = time.Now()
t.lastNotify = t.lastFeedback
unread := t.unreadBytes
t.unreadBytes = 0
preview := previewTail(t.buf.String(), 120)
t.mu.Unlock()
s.InjectText("agentcli", "agentcli",
// 刻意**不**用 NoMemory这条带上终端真实输出preview
// 属于该记的内容。只有纯状态通知才关记忆。
fmt.Sprintf("[终端 %s 定时反馈: 运行中, 期间新输出约 %d 字节]\n%s", t.id, unread, preview))
continue
}
t.mu.Unlock()
select {
case <-t.stopCh:
return
case <-flushTicker.C:
// 批量推送终端实时画面增量(独立 ticker避免被高密度数据饿死
var streamData string
t.mu.Lock()
if t.stream.Len() > 0 {
streamData = t.stream.String()
t.stream.Reset()
}
t.mu.Unlock()
if streamData != "" {
s.Publish(&sdk.Event{
Type: sdk.EventTerminalOutput,
Source: "agentcli",
Payload: map[string]interface{}{"terminal_id": t.id, "output": streamData, "running": terminalRunning(t)},
Timestamp: time.Now().UnixMilli(),
})
}
case r := <-readCh:
if r.err != nil {
// 读取错误/EOF → 立即通知(进程可能已结束)
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 读取结束: %v]", t.id, r.err),
sdk.InjectOptions{NoMemory: true})
return
}
if r.n > 0 {
t.appendOutput(r.data)
// 缓冲阈值通知(仅当 agent 显式选择 buffer 模式,或未读积累达到硬上限)。
// 默认模式(仅 exit 提醒)下不随输出流通知,杜绝通知风暴。
t.mu.Lock()
t.lastData = time.Now()
t.unreadBytes += r.n
bufThr := t.watch.bufferBytes
if bufThr <= 0 {
bufThr = p.notifyBytes
}
minInterval := p.notifyInterval
if t.watch.interval > 0 {
minInterval = t.watch.interval
}
// 风暴退避:距上次通知不足 1s 说明输出极速,通知间隔翻倍(上限 30s
if time.Since(t.lastNotify) < time.Second && t.unreadBytes >= bufThr {
if t.backoff == 0 {
t.backoff = minInterval
} else if t.backoff < 30*time.Second {
t.backoff *= 2
if t.backoff > 30*time.Second {
t.backoff = 30 * time.Second
}
}
}
interval := t.backoff + minInterval
isHard := t.watch.bufferBytes <= 0 && t.unreadBytes >= hardNotifyBytes
if isHard && hardNotifyInterval > interval {
interval = hardNotifyInterval
}
need := t.unreadBytes >= bufThr && time.Since(t.lastNotify) >= interval
if need {
t.lastNotify = time.Now()
t.unreadBytes = 0
preview := previewTail(t.buf.String(), 200)
t.mu.Unlock()
s.InjectText("agentcli", "agentcli",
// 同样刻意保留记忆preview 是终端新输出,是真实内容。
fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
} else {
t.mu.Unlock()
}
}
case <-time.After(pollInterval):
// 空闲轮询:输出已停止时复位退避
t.mu.Lock()
if t.backoff > 0 && time.Since(t.lastData) >= quietLatency {
t.backoff = 0
}
t.mu.Unlock()
}
}
}
// previewTail 返回 s 末尾最多 n 字符,并转义控制字符保证可读。
func previewTail(s string, n int) string {
if len(s) > n {
s = s[len(s)-n:]
}
return sanitizePreview(s)
}
type readResult struct {
n int
data []byte
err error
}
// reader 从终端读取输出并通过 channel 交给 readLoop。
//
// 读到的数据**随结果一起传**而不是复用外层共享的 buf
// reader 是唯一写 buf 的 goroutinereadLoop 又常在 reader 尚未
// 写完下一段时就从 buf[:r.n] 做 copy——同一个 shared buf 被并发
// 读写就是 data race-race 实测触发)。改为每个结果自带切片后,
// 读与拷贝天然隔离,不再共享可变状态。
func (p *Plugin) reader(t *TerminalSession, buf []byte, ch chan<- readResult) {
for {
n, err := t.session.Read(buf)
var data []byte
if n > 0 {
data = make([]byte, n)
copy(data, buf[:n])
}
select {
case ch <- readResult{n, data, err}:
case <-t.stopCh:
return
}
if err != nil {
return
}
}
}
func (p *Plugin) cleanupLoop(s *sdk.PluginSDK) {
defer p.wg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
// 先持锁筛选出待关闭的终终并移出 map再在锁外逐个关闭。
// 旧实现在持锁期间 go func 调 term.Close()(内部会 Kill 进程并等
// <-t.done临界区被拉长且与 TerminalSession 的退出路径交错。
var expired []*TerminalSession
p.mu.Lock()
for id, t := range p.sessions {
switch {
case t.IsExpired():
log.Printf("[agentcli] cleanup: terminal %s expired", id)
case !terminalRunning(t):
log.Printf("[agentcli] cleanup: terminal %s process exited", id)
default:
continue
}
delete(p.sessions, id)
expired = append(expired, t)
}
p.mu.Unlock()
for _, t := range expired {
go func(term *TerminalSession) { term.Close() }(t)
}
}
}
}
func mapKey(key string) ([]byte, error) {
key = strings.ToLower(key)
switch key {
case "enter":
return []byte{0x0D}, nil
case "tab":
return []byte{0x09}, nil
case "escape", "esc":
return []byte{0x1B}, nil
case "backspace":
return []byte{0x7F}, nil
case "delete":
return []byte{0x1B, 0x5B, 0x33, 0x7E}, nil
case "home":
return []byte{0x1B, 0x5B, 0x48}, nil
case "end":
return []byte{0x1B, 0x5B, 0x46}, nil
case "page_up":
return []byte{0x1B, 0x5B, 0x35, 0x7E}, nil
case "page_down":
return []byte{0x1B, 0x5B, 0x36, 0x7E}, nil
case "up":
return []byte{0x1B, 0x5B, 0x41}, nil
case "down":
return []byte{0x1B, 0x5B, 0x42}, nil
case "right":
return []byte{0x1B, 0x5B, 0x43}, nil
case "left":
return []byte{0x1B, 0x5B, 0x44}, nil
}
if strings.HasPrefix(key, "ctrl_") && len(key) == 6 {
c := key[5]
if c >= 'a' && c <= 'z' {
return []byte{byte(c - 'a' + 1)}, nil
}
}
if strings.HasPrefix(key, "alt_") && len(key) == 5 {
c := key[4]
if c >= 'a' && c <= 'z' {
return []byte{0x1B, c}, nil
}
}
if strings.HasPrefix(key, "f") && len(key) <= 4 {
var n int
if _, err := fmt.Sscanf(key, "f%d", &n); err == nil && n >= 1 && n <= 12 {
return functionKeyCode(n), nil
}
}
return nil, fmt.Errorf("不支持的特殊按键: %s", key)
}
func functionKeyCode(n int) []byte {
switch n {
case 1:
return []byte{0x1B, 0x5B, 0x50}
case 2:
return []byte{0x1B, 0x5B, 0x51}
case 3:
return []byte{0x1B, 0x5B, 0x52}
case 4:
return []byte{0x1B, 0x5B, 0x53}
case 5:
return []byte{0x1B, 0x5B, 0x31, 0x35, 0x7E}
case 6:
return []byte{0x1B, 0x5B, 0x31, 0x37, 0x7E}
case 7:
return []byte{0x1B, 0x5B, 0x31, 0x38, 0x7E}
case 8:
return []byte{0x1B, 0x5B, 0x31, 0x39, 0x7E}
case 9:
return []byte{0x1B, 0x5B, 0x32, 0x30, 0x7E}
case 10:
return []byte{0x1B, 0x5B, 0x32, 0x31, 0x7E}
case 11:
return []byte{0x1B, 0x5B, 0x32, 0x32, 0x7E}
case 12:
return []byte{0x1B, 0x5B, 0x32, 0x34, 0x7E}
default:
return []byte{}
}
}
func isTimeoutError(err error) bool {
if err == nil {
return false
}
if opErr, ok := err.(*os.PathError); ok {
err = opErr.Err
}
if syscallErr, ok := err.(syscall.Errno); ok {
return syscallErr == syscall.EAGAIN || syscallErr == syscall.EWOULDBLOCK || syscallErr == syscall.ETIMEDOUT
}
return strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "would block")
}
func init() {
plugin.RegisterFactory("agentcli", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
plugin.RegisterPluginMeta("agentcli", "终端交互", "Agent CLI")
}
func sanitizePreview(s string) string {
var buf bytes.Buffer
for _, r := range s {
if r == '\n' {
buf.WriteString("\\n")
} else if r == '\r' {
buf.WriteString("\\r")
} else if r == '\t' {
buf.WriteString("\\t")
} else if r >= 32 && r <= 126 {
buf.WriteRune(r)
} else if r == 0x1B {
buf.WriteString("^[")
} else if r < 32 {
fmt.Fprintf(&buf, "^%c", r+'A'-1)
} else {
buf.WriteRune(r)
}
}
return buf.String()
}