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 响应就说明 进程还在监听。
providercarry.go —— 从用户权威 grok 配置里搬运自定义 provider 定义。
职责:
- 从 ~/.grok/config.toml 的文本里抽出 [model.*] 段与整个 [models] 段 (default 单独摘出,因为它另有优先级;其余键原样搬走)
- 只做文本切段,不解析 TOML、不改写任何字段值
边界:
- 不写文件:结果交 WriteTaskEnv 织进任务级 config.toml
- 不判断字段含义、不识别密钥:段内字节原样搬运
- 不搬 [ui] / [permission] / [cli]:那三段永远以 handoff 为准,搬过来 等于让用户的个人配置覆盖任务级权限隔离
为什么不引入 TOML 库:本仓零 TOML 依赖,且 WriteTaskEnv 本身就是手写字符串 拼接。「解析 + 再序列化」会重排键、丢掉用户注释,生成的 config 不再一眼可读; 原样搬字节连注释都保得住。代价是自己认段边界,已知边界见 extractProviderConfig。
日志纪律:只打段名与条数,任何情况下不打段内容、不打字段值——[model.*] 段里 有 api_key。与 authsync.go 文件头「不打 token 值」同源。
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 次失败才判死,吸收抖动不误杀。
spend.go —— grok 的**累计消耗**账目解析。
职责:把 session/prompt 响应的 result._meta 解析成一条 proto.SpendEntry。 边界:纯函数,不碰 runState、不发事件、不写日志——接线在 adapter.go。
与同目录 usage.go 的关系(**grok 是四家里最容易搞错的一家**):同一条 ACP 线 上有两套命名,且缓存的算法**相反**——
- usage.go 取 _x.ai/session_notification 的 response_completed,字段是 snake_case(input_tokens / cache_read_input_tokens),**不含**缓存要相加, 解的是「当前 context 占用」;
- 本文件取 session/prompt 响应的 _meta,字段是 camelCase (inputTokens / cachedReadTokens),**已含**缓存要相减, 解的是「整个回合消耗了多少」。
按字段名模糊匹配必错,grok 官方文档已确认这是它有意为之的两套投影。
symlinkcap.go —— grok 的符号链接能力探测。
职责:回答「本机现在能不能建符号链接」,供 agentd 决定是否注册 grok。
边界:
- 只探测,不注册、不报错到调用方之外:结论由 agentd 呈现
- 不缓存:agentd 启动时探一次即可,权限在运行中变化不是要覆盖的场景
为什么 grok 需要这个而别的执行器不需要:grok 给每个任务建一条指向用户 auth 文件的符号链接(taskenv.go 建、authsync.go 周期复位),而 Windows 上 建符号链接需要 SeCreateSymbolicLinkPrivilege(管理员)或开发者模式。
为什么不改成复制文件绕开:软链的意义是 auth 文件只有一份权威副本。改成 复制后,grok 在任务里刷新 token 写的是副本,用户那份与任务那份各自漂移, 且这种不一致是静默的——正是 B26 那一整类问题。宁可诚实拒绝,不静默降级。
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 层分级仍成立。
usage.go —— grok 的 token 用量与实际模型名解析。
职责:把两条 _x.ai/* 私有通知解析成 proto.Usage 与模型名/窗口。 边界:纯函数,不碰 runState、不发事件、不写日志——接线在 adapter.go。
为什么用量在私有通知上:grok 的 ACP 线把用量放在 _x.ai/session_notification 与 _x.ai/models/update 上,标准的 session/update 变体一个都不带计数。
Index ¶
- func EnsureAuthLink(homeDir string) error
- func SymlinkCapability(probeDir string) (supported bool, reason string)
- 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 SymlinkCapability ¶ added in v0.3.0
SymlinkCapability 探测本机是否具备创建符号链接的能力。
参数:probeDir 为探测目录(用 DataDir,它一定存在且可写)
返回:
- supported: 是否可用
- reason: 不可用的原因,**含可行动的处置建议**;可用时为空串
注意:探测会在 probeDir 下建一个临时软链再删掉,正常路径不留任何残留。
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: 任务级模型;空则退回权威配置的 default,两者都空时不写 default (此时 [models] 段仍可能因搬来的辅助旋钮而存在)
返回: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"(拒绝)
- reason: ACP 的 outcome 只有 optionId,带不了消息,本 adapter 忽略(spec §2.5)
返回:
- 任务不在运行中、或挂起表查不到该 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, markRoot, 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 一起消失。)