Documentation
¶
Overview ¶
acp.go —— ACP(Agent Client Protocol)的 WebSocket JSON-RPC 双向客户端。
职责:
- 维护一条到 grok agent serve 的 WS 连接,跑 JSON-RPC 2.0 双向消息
- 我方请求(initialize/session.*)按 id 匹配响应;session/prompt 用 CallAsync 异步等待(它要跑完一整个回合才响应)
- 对方通知(session/update 及 _x.ai/* 私有通知)经 OnNotify 分发
- 对方请求(session/request_permission)经 OnPermission 上抛,应答可延迟 任意久后经 Reply 回发——协调者可能过夜才裁决(spec §5.1 实测 20min 无超时)
边界:
- 不认识 ACP 的业务语义(不知道什么是权限、什么是回合),只做协议管道; 语义翻译在 adapter.go
- 不重连:重连策略属 adapter 的生命周期决策,本层只在连接死亡时 OnClosed 通知
adapter.go —— ACP 语义到 executor.Adapter 契约的翻译层。
职责:
- 把 StartServe / DialACP / initialize / session.new / session.prompt 编排成 Adapter 的五动作
- ACP 消息 → AdapterEvent 映射:session/request_permission → permission 事件; agent_message_chunk 累积成回合正文(thought 与 tool_call 只进 render.log); session/prompt 的响应(stopReason)作回合边界 → turn.ParseTrailer 分类
- 可见性:回合文本增量追加到 <taskDir>/render.log,供 handoff attach 旁观
边界:
- 不写 store、不做审批判断(见 executor.go 包级边界):会话 id 等持久化诉求 经事件(progress「会话就绪」/ Result.SessionID)交 manager 落库
- 不做任务状态机迁移:6 状态迁移完全由 manager 负责
与 opencode adapter 的两处结构性差异:
- 回合边界是 session/prompt 的**响应**而非从 idle 事件推断,因此不需要 opencode 的 idleGrace 去抖与 scheduleIdle/resolveIdle/cancelPendingIdle 竞态处理
- 权限是阻塞式 JSON-RPC 请求,需维护 permID → 请求 id 的挂起表(见 perm.go)
authsync.go —— grok 任务级 home 里凭据副本的收编写回。
职责:
- 发现 <taskDir>/grokhome/auth.json 从软链变成了普通文件(grok 刚在这里刷新过)
- 逐账号键比 expires_at,把严格更新的条目收编进权威副本 ~/.grok/auth.json
- 收尾把任务侧恢复成软链,复位「权威副本只有一份」的不变量
边界:
- 不起 goroutine:调用点是已有的 per-task 看门狗(resume.go 的 watchdog)
- 不解析条目内部结构:条目按 json.RawMessage 整体搬运,只读 expires_at 一个 字段用于比较——grok 升级改字段名不会让我们把用户凭据写残
- 不负责首次建链:那是 EnsureAuthLink 的职责,本文件只在发现破链时复位
为什么是「允许出现副本、及时收编」而不是「禁止出现副本」:grok 刷新令牌时替换 的是目录项(rename 或 unlink+create),软链和硬链都拦不住,禁止不了。详见 docs/superpowers/specs/2026-08-09-handoff-grok-credential-ownership-design.md §2。
日志纪律:只打账号键、expires_at、任务 id,任何情况下不打 token 值。
perm.go —— ACP 权限门:挂起表、裁决回发与连接断开时的处置。
职责:
- 暂存 toolCallId → ACP 请求 id,等 manager 的裁决回来后经 Reply 回发
- 把 handoff 的 once/reject 翻译为 ACP 的 allow-once/reject-once
- 连接断开时作废全部挂起项并告知调用方
边界:
- 不做审批判断:批不批由 manager 依协调者/审批者的应答决定,本层只转发
- 不碰 store:工单、黑名单、升级链全在 manager
为什么需要挂起表(opencode 没有):ACP 的权限是 agent→client 的**阻塞式 JSON-RPC 请求**,应答必须带原请求 id 回发;而 opencode 的权限应答是一次 独立的 HTTP POST,无需保留连接级状态。
为什么断开即作废且不再尝试救回:spike 实测 WS 断开后重连 + session/load 成功,但未决的权限请求**不会被重发**,grok 侧那次工具调用永久卡在等应答。 此时假装恢复成功比直接失败更危险——任务会静止而无人知晓。
probe.go —— 只读存活探测。
职责:
- Probe:读 proc.json,走 Proc.Alive 的既有判据(存活锁 + HTTP 应答), 如实返回存活结论
边界:
- **绝不写**:不回收执行者进程、不动凭据软链、不碰 store、不发事件
- 不打印 Proc.Secret / WSURL:两者都含 secret,绝不进日志
proc.go —— grok agent serve 的进程生命周期:prochost 托管、探活、恢复凭据落盘。
职责:
- StartServe:选空闲端口、生成随机 secret、写任务物料、经 prochost 拉起 serve、 HTTP 探活等就绪、落 proc.json
- Alive/Kill/LogTail:存活探测、回收、脱敏后的诊断尾部
- ReadServeInfo:从 proc.json 重建 Proc,供 agentd 重启后 Resume
边界:
- 不说 ACP、不解析事件:协议在 acp.go,语义在 adapter.go
- 不做重试决策:探活失败只如实返回,重试与判死节奏归 adapter 的看门狗
为什么存活判据是「存活锁 + HTTP 端口探活」:锁证明 shim 还在,HTTP 证明 serve 本身还在应答。serve 崩了但 shim 尚未收尸的窗口由 HTTP 这条兜住;反过来锁没了 也就没有进程可探。grok serve 的根路径返回 404——能收到任何 HTTP 响应就说明 进程还在监听。
reap.go —— 无内存运行态时的确定性兜底回收。
职责:
- Reap:按 proc.json 拿 prochost.Handle 并 Kill
边界:
- 不碰任务状态(adapter 不写 store);回收不掉只返回错误,留不留事件是 manager 的事
resume.go —— agentd 重启后的执行恢复与 serve 存活看门狗。
职责:
- Resume:读 serve.json → 端口探活 → 修复 auth 软链 → 重连 ACP → session/load → 重建事件循环
- watchdog:周期探活,判死时以脱敏的 serve.log 尾部产出 failed result
边界:
- 不判断「该不该恢复」的业务前提(如是否有未决权限工单)——那需要工单知识, 属 manager(见 manager.go 的 volatilePermitter)
看门狗节奏与 opencode 同规格:活跃期 200ms 高频,连续 fastProbes 次成功且无 新事件后降到 2s(任务挂夜里时省下每天数十万次探活),任一失败立即回高频; 连续 failThreshold 次失败才判死,吸收抖动不误杀。
taskenv.go —— grok 任务环境物料生成:任务级 GROK_HOME 与权限配置。
职责:
- WriteTaskEnv:建 <taskDir>/grokhome 并写 config.toml(钉死 permission_mode 与第 0 层分级规则、注入任务级模型)
- EnsureAuthLink:幂等地把 grokhome/auth.json 指向真实 ~/.grok/auth.json (serve 启动脚本与 secret 注入已随 tmux 拆除,改由 proc.go 的 Spec.Env 承担)
边界:
- 不起进程、不连网络:进程在 proc.go,协议在 acp.go
- 不读用户的真实 grok 配置(除 auth.json 软链外一律纯净)
为什么任务级 GROK_HOME 是必需而非可选:用户真实 ~/.grok/config.toml 常见 permission_mode = "always-approve",直接沿用等于所有工具调用自动放行、 permission 事件永不产生——审批门全废。任务级 home 把它钉死为 "default"。
为什么权限规则表比 opencode 短:grok 内建按 && / || / ; / 管道分段识别只读 命令并自动放行(ls/cat/git status/grep/rg 等),且 `ls && rm -rf /` 会拆开、 rm 段仍然拦。opencode 那张以 "*": "allow" 收尾的表是手工补的等价物,这里 只需补 ask 危险模式与 allow 编辑放行。
已知泄漏(关不掉):grok 无视 GROK_HOME,仍从真实 HOME 读 ~/.claude/settings*.json 与 ~/.claude/skills。缓解是 grok 的求值为 deny > ask > allow 跨源生效——本文件 写的 ask 压得过用户个人 allowlist 的 allow,第 0 层分级仍成立。
Index ¶
- func EnsureAuthLink(homeDir string) error
- func SyncAuthToAuthority(homeDir string, log *slog.Logger) error
- func WriteTaskEnv(taskDir, model string) (homeDir string, err error)
- type ACPClient
- func (c *ACPClient) Call(ctx context.Context, method string, params any) (json.RawMessage, error)
- func (c *ACPClient) CallAsync(method string, params any) (<-chan ACPResult, error)
- func (c *ACPClient) Close() error
- func (c *ACPClient) Notify(method string, params any) error
- func (c *ACPClient) Reply(reqID json.RawMessage, result any) error
- type ACPHandler
- type ACPResult
- type Adapter
- func (a *Adapter) Events(taskID string) <-chan executor.AdapterEvent
- func (a *Adapter) PermissionsVolatile() bool
- func (a *Adapter) Probe(req executor.ProbeReq) (executor.ProbeOutcome, error)
- func (a *Adapter) ProcHandle(taskID, taskDir string) (prochost.Handle, error)
- func (a *Adapter) Reap(taskID, taskDir string) error
- func (a *Adapter) RespondPermission(ctx context.Context, taskID, permID, decision string) (err error)
- func (a *Adapter) Resume(req executor.ResumeReq) (out executor.ResumeOutcome, err error)
- func (a *Adapter) Send(ctx context.Context, taskID, text string) error
- func (a *Adapter) Start(ctx context.Context, req executor.StartReq) (err error)
- func (a *Adapter) Stop(taskID string) error
- type Proc
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func EnsureAuthLink ¶
EnsureAuthLink 幂等地把 <homeDir>/auth.json 指向真实 ~/.grok/auth.json。
为什么必须可修复而非一次性建立:spike 实测任务级 home 的软链会在 token 刷新 前后消失,随后 session/new 直接返回 Authentication required。Start 与 Resume 都调本函数,成本为零。
为什么用软链而非拷贝:拷贝会让每个任务 home 各自持有凭据并独立刷新,而刷新 令牌轮换可能反噬用户本人的登录态——凭据只应有一个权威副本。
func SyncAuthToAuthority ¶
SyncAuthToAuthority 跑一轮凭据巡检:把任务 home 里 grok 自行刷新出的新凭据 收编进权威副本,并把任务侧恢复成软链。
参数:
- homeDir: 任务级 GROK_HOME,即 <taskDir>/grokhome
- log: 日志入口;nil 时退回 slog.Default()。调用方应传入已带 task 字段的 logger(本函数不认识 task id)
返回:**仅在「本轮确实该写回却写失败」时返回错误**。其余情况(无事可做、 任务侧损坏、权威侧缺失或损坏)都返回 nil——它们是可接受的稳态,不该让调用方 的看门狗把它当异常。
注意:
- 绝大多数轮次在第一个 lstat 就返回,成本是一次系统调用,可以放心高频调用;
- 「复位软链」与「收编」是两件独立的事:只要发现任务侧不是软链就该复位, 哪怕本轮没收编到任何东西(陈旧拷贝留着会让任务下次临期必死)。两处例外 写在下面的分支注释里。
func WriteTaskEnv ¶
WriteTaskEnv 建任务级 GROK_HOME 并写入权限配置,返回该 home 目录路径。
参数:
- taskDir: 任务工作目录(须已存在,由调用方保证)
- model: 任务级模型;空则不写 [models] 段,用 grok 自身默认
返回:grokhome 目录路径;建目录或写文件失败时返回错误
注意:重复调用幂等覆盖,调用方可安全重试
Types ¶
type ACPClient ¶
type ACPClient struct {
// contains filtered or unexported fields
}
ACPClient 是一条 ACP 连接。并发安全:nextID/pending 由 mu 保护; 写连接由 writeMu 串行化(websocket 不允许并发写)。
func DialACP ¶
DialACP 连接 ACP 端点并启动读循环。
参数:
- ctx: 仅控制握手阶段;连接生命周期延续到 Close
- wsURL: 形如 ws://127.0.0.1:<port>/ws?server-key=<secret>
- h: 回调面(不得为 nil)
- log: 日志入口(nil 退回 slog.Default())
注意:wsURL 含 secret,**日志里绝不能打印它**(本函数只记录 host 与 path)。
func (*ACPClient) CallAsync ¶
CallAsync 发起请求并立即返回结果通道。
为什么需要它:session/prompt 要跑完一整个回合(可能几十分钟)才响应, Start 必须立即返回,不能阻塞在这上面。
type ACPHandler ¶
type ACPHandler interface {
// OnNotify 收到对方通知(无 id 的消息)
OnNotify(method string, params json.RawMessage)
// OnAskQuestion 收到 _x.ai/ask_user_question(对方请求,**必须应答**)。
// 不应答会让 session/prompt 永不返回、任务永久静止(spec §4.2.3 / §5.3(c) 实测)。
OnAskQuestion(reqID json.RawMessage, params json.RawMessage)
// OnPermission 收到 session/request_permission(对方请求,需应答)。
// reqID 原样保存,裁决回来后经 Reply 回发。
OnPermission(reqID json.RawMessage, params json.RawMessage)
// OnClosed 连接终止(err 为终止原因,正常关闭时为 nil)
OnClosed(err error)
}
ACPHandler 是 adapter 侧的回调面。实现方必须假定回调在读循环 goroutine 上 触发:**不得在回调里做阻塞操作**,否则会卡住整条连接的消息消费。
type ACPResult ¶
type ACPResult struct {
Result json.RawMessage
Err error
}
ACPResult 是一次异步调用的终局(二选一)。
type Adapter ¶
type Adapter struct {
// contains filtered or unexported fields
}
Adapter 是 grok 的 executor.Adapter 实现。
并发安全:runs 表由 mu 保护;每个任务的运行态只被该任务自己的回调路径访问。
func (*Adapter) Events ¶
func (a *Adapter) Events(taskID string) <-chan executor.AdapterEvent
Events 返回该任务的事件流通道(Start 后可用)。通道关闭表示执行终结。
func (*Adapter) PermissionsVolatile ¶
PermissionsVolatile 表明本 adapter 的权限请求随连接消亡。
manager 据此在 agentd 重启后拒绝恢复「尚有未决权限工单」的任务——实测 session/load 只恢复会话历史,不恢复未决授权请求(见 spec §5.2)。
func (*Adapter) Probe ¶
Probe 只读探测 grok serve 是否仍存活(manager 的 prober 可选接口)。
判据与 Resume 共用同一份 Proc.Alive:存活锁被持有且端口收到任何 HTTP 响应 (含 404)。
返回:
- err != nil:探不出结论(proc.json 缺失/损坏),调用方按 unknown 处理
func (*Adapter) ProcHandle ¶
ProcHandle 交出该任务的进程句柄(来自任务目录的 proc.json)。
参数:
- taskID: 任务 ID,仅用于日志定位
- taskDir: 任务目录(凭据所在)
返回:
- 进程句柄;proc.json 不存在或不可解析时返回错误
注意:本方法**只读**,不探活、不发信号——存活判定与回收分别是 prochost.Alive 与 prochost.Sweep 的职责。agentd 以可选接口消费它 (不实现该方法的 adapter 一律按「无凭据」降级,与 reaper/prober 同款路数)。
func (*Adapter) Reap ¶
Reap 在没有内存运行态时按 proc.json 兜底回收 executor 侧资源。
回收顺序:读 proc.json 拿 Handle → prochost.Kill(内部先试锁,锁空闲直接成功)。
为什么不再有「确定性命名兜底」:旧实现在 proc.json 缺失时退到 tmux 会话名 handoff-<id8>,因为会话名可由 taskID 推导。锁+pid 无法从 taskID 推导, proc.json 缺失就是真的无据可查——如实报错交协调者,不猜。
返回:Handle 对应的进程本就不在时返回 nil——目标是「确保它没了」, 不是「确保我杀了它」。
func (*Adapter) RespondPermission ¶
func (a *Adapter) RespondPermission(ctx context.Context, taskID, permID, decision string) (err error)
RespondPermission 应答 grok 的权限请求。
参数:
- taskID: 目标任务
- permID: 权限请求 id(即 ACP 的 toolCallId,裸值不带命名空间前缀)
- decision: "once"(批准本次)或 "reject"(拒绝)
返回:
- 任务不在运行中、或挂起表查不到该 permID 时,包装 executor.ErrTaskNotRunning ——两者都意味着「executor 侧那次请求已经不在了」,调用方据此转失败交协调者, 而不是当作可重试的瞬时错误
func (*Adapter) Resume ¶
Resume 尝试恢复一个 agentd 重启前已在执行的任务。
参数:
- req: 恢复请求(TaskDir 是 serve.json 与 grokhome 所在,即 DataDir/tasks/<id>; RepoPath 是 ACP session/load 的 cwd;SessionID 是落库的会话 id)
返回:
- Alive=true:serve 存活、ACP 已重连、会话已载入、事件流已重建; Mode 说明走到的级别(reattach 热重连 / cold 冷恢复 / fresh 新会话)
- Alive=false:serve 已不在且不允许冷恢复、或凭据缺失,调用方据此转 failed 交协调者
type Proc ¶
type Proc struct {
Handle prochost.Handle `json:"handle"`
TaskDir string `json:"task_dir"` // 任务目录
Port int `json:"port"`
Secret string `json:"secret"`
}
Proc 是一个 grok serve 实例的句柄与恢复凭据。
注意:Secret 字段是明文 secret,序列化后的 proc.json 必须 0600, 且任何日志/错误文本输出前都要经 LogTail 之类的脱敏路径。
func ReadServeInfo ¶
ReadServeInfo 从任务目录读回 Proc,供 agentd 重启后 Resume。
func StartServe ¶
func StartServe(ctx context.Context, repoPath, taskID, taskDir, model string, env []string, log *slog.Logger) (*Proc, error)
StartServe 经 prochost 拉起一个任务专属的 grok serve 并等其就绪。
参数:
- ctx: 控制启动阶段的超时/取消
- repoPath: 任务工作目录(serve 的 cwd)
- taskID: 任务 ID(日志与 proc.json 定位)
- taskDir: 任务物料目录
- model: 任务级模型(空=用 grok 默认)
- env: 注入到 serve 进程的环境变量(形如 KEY=VALUE,已由 manager 解析展开); 命中 protectedEnvKeys 的条目会被 handoff 自身注入覆盖并打 WARN
返回:就绪的 Proc;任一步失败返回错误(错误携带脱敏后的 serve.log 尾部)
func (*Proc) Alive ¶
Alive 检查 grok serve 是否仍然存活:存活锁被持有 且 HTTP 端口有应答。
为什么第一条是锁而不是端口:锁是本地文件操作、微秒级,端口探测要走网络栈 且失败时要等超时。锁判死就没必要再探端口了。 (旧实现第一条是 tmux has-session,而会话里第二窗口的 tail -f 会一直活着、 serve 早死了会话依然存在——那个假存活来源已随 tmux 一起消失。)