claudecode

package
v0.3.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 21, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Overview

adapter.go —— Claude Code 语义到 executor.Adapter 契约的翻译层。

职责:

  • 五动作编排:Start(物料 → socket → prochost 进程 → 等 init → 投首回合 prompt)、 Send(fifo 续接)、RespondPermission(裁决回发 socket)、Stop(kill + 收摊)、 Events(事件通道)
  • stream 消息 → AdapterEvent 映射:init → progress(SessionID);text_delta → render.log + 节流 progress;result → trailer 分类(ask→question / finish→result / none→兜底 git 实况裁决);handoff_exit 哨兵 → 失败 result
  • 权限:perm.sock 的 ask 回调 → permission 事件(PermissionID = 裸 tool_use_id)

边界:

  • 不写 store、不做审批判断(executor 包级边界):会话 id 经事件交 manager 落库
  • 不做状态机迁移:6 状态迁移全在 manager
  • 不重试、不决策:解析宽容(未知消息 Debug 跳过、绝不 panic)

事件映射以 2026-08-08 真实采样与 2026-08-09 探针为准(claude 2.1.220/2.1.226):

  • 文本载体:stream_event.event.content_block_delta.delta 的 text_delta 是模型 正文增量(thinking_delta 是思考过程,隔离不进 render.log 与回合文本,与 opencode 的 reasoning 隔离一致);assistant.message.content 的 text 块是整块 文本(render.log 已由 delta 写过则不重复追加,只做回合文本累积)
  • 回合收尾:result.subtype=success 时 result.result 即最后一条 assistant 正文, 正是 turn.ParseTrailer 的输入;subtype!=success 时按失败处理
  • 死亡:脚本在进程退出后往 out.jsonl 追加 handoff_exit 哨兵(带退出码), 随事件流天然送达本层,是本 adapter 唯一可靠的死亡信号
  • 权限:完全走 socket 旁路(perm.go),out.jsonl 里那次 mcp__handoff__ask 的 tool_use 只当普通工具调用渲染,不产生 permission 事件(避免同一请求出两次)

perm.go —— 权限裁决 unix socket 服务端。

职责:

  • 在任务目录的 perm.sock 上受理裁决请求(由 claude 拉起的 permission-mcp 进程发来)
  • 把请求以回调交给 adapter(转成 AdapterEvent 进 manager 审批链)
  • 收到裁决后回发给对应连接,放行或拒绝该次工具调用

边界:

  • 不做任何审批判断:批不批由 manager 依协调者应答决定(executor 包级边界)
  • 不认识 claude 的消息格式:只处理本文件定义的两条线协议

为什么用 unix socket 而不是 agentd 的 HTTP 口:被监管的 executor 不该拿到 agentd token;socket 文件落在任务目录内,由**目录本身的访问控制**构成边界, 且无需分配端口。

「访问控制」分平台:unix 上是任务目录的 0700 权限位;Windows 上没有 POSIX 权限位(socket 文件的 mode 恒显示为 Srw-rw-rw-),边界由任务目录的 NTFS ACL 继承提供。**这不是同一句话的两种说法**——换平台时要重新确认边界真的成立, 不能因为 unix 上验过就假定 Windows 上也成立。

AF_UNIX 在 Windows 10 1803+ / Server 2019+ 上原生可用,Go 的 net 包支持它 (src/net/unixsock_posix.go 的构建约束含 windows),B128 已在 Server 2025 上 跨进程实测 Listen/Dial/双向收发全通,且 Close() 会自动删除 socket 文件。

probe.go —— 只读存活探测。

职责:

  • Probe:读恢复凭据,走 Proc.Alive 的既有判据,如实返回存活结论

边界:

  • **绝不写**:不回收执行者进程、不占 runs 位、不碰 store、不发事件。 Resume 这三件事都做(判死后 Kill 是冷恢复不撞名的前置),正是它不能被 status 复用的原因
  • 不重试、不做抖动吸收:一次探测一个结论。抖动误判的代价由调用方承担, 而 status 只读——误判的代价是输出里一行错话,不是一个被错判的任务

proc.go —— Claude Code 进程生命周期管理。

职责:

  • 组 claude 的 argv(headless 双向流式),经 prochost 以 detached 方式拉起
  • 经命名管道 in.fifo 投递指令;stdout 落 out.jsonl,stderr 落 claude.log
  • 死亡判定(out.jsonl 末尾的 handoff_exit 哨兵 + 存活锁)与凭据持久化(proc.json)

边界:

  • 不解析事件:out.jsonl 的解析在 stream.go
  • 不做权限裁决:socket 服务端在 perm.go
  • 不关心进程怎么脱离 agentd:那是 prochost 的事

为什么进程经 prochost 而不是 agentd 直接 fork:agentd 重启或崩溃时子进程 若未脱离会话会被一并回收,正在执行的任务无辜中断;prochost 的 shim 以新会话 拉起并持有存活锁,生命周期与 agentd 解耦——agentd 重启后靠 Alive() 探测重连。

reap.go —— 无内存运行态时的确定性兜底回收。

职责:

  • Reap:按 proc.json 拿 prochost.Handle 并 Kill

边界:

  • 不碰任务状态(adapter 不写 store);回收不掉只返回错误,留不留事件是 manager 的事

resume.go —— 执行恢复:热重连与冷恢复。

职责:

  • Resume:按 ResumeReq 走恢复阶梯,返回实际走到的级别

边界:

  • 不判断「该不该恢复」的业务前提(如是否有未决权限工单)——那需要工单知识, 属 manager(见 manager.go 的 volatilePermitter)
  • 不改任务状态:adapter 不写 store(见 executor 包级边界)

spend.go —— claudecode 的**累计消耗**账目解析。

职责:把 result 行解析成一条 proto.SpendEntry(本轮新增的 token 与花费)。 边界:纯函数,不碰 runState、不发事件、不写日志——接线在 adapter.go。

与同目录 usage.go 的关系:usage.go 解「当前 context 占用」(assistant 消息, 最后一次调用的输入侧),本文件解「一共烧了多少」(result 行,逐轮累加)。 两个口径的公式不同且都容易写错,刻意分文件,不要合并。

stream.go —— out.jsonl 的增量解析(claude stream-json → 内部消息)。

职责:

  • 从指定 offset 起持续读 out.jsonl 的新行,宽容解码为 streamMsg 交回调
  • 维护已消费 offset,供 proc.json 持久化与 agentd 重启后续读
  • 从 stream_event 中提取模型正文增量(只认 text_delta)

边界:

  • 不映射 AdapterEvent、不碰 render.log:那是 adapter.go 的职责
  • 不管进程死活:哨兵行原样交出,判定在 proc.go / adapter.go

为什么轮询文件而不是接管管道:进程由 shim 承载、stdout 落盘, agentd 重启后没有任何管道可继承,文件 + offset 是唯一能跨重启接续的形态。

taskenv.go —— Claude Code 任务环境物料生成。

职责:

  • 生成任务级 settings.json(权限静态分级:ask 收窄危险面、allow 逐条白名单)
  • 生成 mcp.json(把 handoff 内置裁决工具挂到本任务的 perm.sock 上)
  • 渲染首回合 prompt(回合纪律模板复用 turn 共享包,两个 executor 同源)

边界:

  • 不启动进程、不建 socket:进程在 proc.go,socket 服务端在 perm.go
  • 不做权限判断:本文件只生成静态策略,运行期裁决全部经 perm.sock 交 manager
  • settings.json 只放 permissions、**不含任何凭证**——凭证由 claude 自己经 `--setting-sources user` 从真实 `~/.claude/settings.json` 读取(2026-08-09 真机 e2e 实测:不带凭证 env 即跑通,见 spec §5.4);env 注入(B19)是给 代理/自定义 base_url 这类额外环境用的,不是鉴权必要条件。因此 settings.json 可以放心进日志/工单/diff

usage.go —— claudecode 的 token 用量与实际模型名解析。

职责:把 assistant 消息里的 model 与 usage 解析出来。 边界:纯函数,不碰 runState、不发事件、不写日志——接线在 adapter.go。

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func WriteTaskEnv

func WriteTaskEnv(taskDir, taskID, planContent, sockPath, handoffBin, disciplineBlock string) (settingsPath, mcpPath, promptText string, err error)

WriteTaskEnv 在 taskDir 生成 Claude Code 的任务级物料并渲染首回合 prompt。

参数:

  • taskDir: 任务工作目录(须已存在,由 manager 创建,0700)
  • taskID: 任务 ID,写入 prompt 标题行
  • planContent: 实现计划全文,原样嵌入 prompt
  • sockPath: 本任务的权限裁决 socket 路径(perm.go 监听它)
  • handoffBin: handoff 二进制绝对路径,作为裁决 MCP server 的启动命令
  • disciplineBlock: 唯一来自 StartReq 的纪律块正文;taskenv 不自行解析

返回:

  • settingsPath / mcpPath: 生成的两个配置文件路径
  • promptText: 渲染后的首回合 prompt 原文(由 adapter 投递进 fifo)
  • err: 渲染或写文件失败

注意:

  • 重复调用幂等覆盖,Start 失败重试可安全重来
  • 两个配置文件都是 0600:mcp.json 泄露 socket 路径即泄露裁决入口

Types

type Adapter

type Adapter struct {
	// contains filtered or unexported fields
}

Adapter 是 claude 的 executor.Adapter 实现(语义翻译层)。

并发安全:runs 表由 mu 保护;每个任务的运行态(回合累积、事件通道)只被 该任务自己的 streamLoop goroutine 访问,不做跨任务共享。

func New

func New(log *slog.Logger) *Adapter

New 创建 claude adapter。

参数:

  • log: 本模块日志入口(nil 时退回 slog.Default())

func (*Adapter) DenyReasonInBand added in v0.3.0

func (a *Adapter) DenyReasonInBand() bool

DenyReasonInBand 表明本 adapter 把拒绝理由与裁决同帧送达模型。

返回恒为 true:理由进 permDecision.Message,经裁决 socket 回到 cmd/permission_mcp.go,再作为 tool_result 正文当场交给模型。

manager 据此跳过 B50 的带外挂起注入——两条路都走会让模型被同一条理由说两遍。

func (*Adapter) Events

func (a *Adapter) Events(taskID string) <-chan executor.AdapterEvent

Events 返回任务的事件流通道(Start 后可用;Stop 或执行终结后关闭)。

注意(P1-11 同款):任务不在运行(未启动/已 Stop/运行态已随终结注销)时返回 **已关闭的通道**而非 nil——契约是「通道关闭 = 执行终结」,nil 会让 for-range 永久阻塞。

func (*Adapter) Probe

Probe 只读探测 claude 执行器是否仍存活(manager 的 prober 可选接口)。

判据与 Resume 共用同一份 Proc.Alive:存活锁被持有 **且** out.jsonl 无 handoff_exit 哨兵,缺一即视为死亡。判据一旦分叉,status 说的和实际恢复行为 就是两回事。

参数:

  • req: 探测请求(TaskDir 是 proc.json 所在,即 DataDir/tasks/<id>)

返回:

  • Alive=true:执行器仍在,Note 为空
  • Alive=false + Note:已判死,Note 是给协调者看的一句话理由
  • err != nil:探不出结论(恢复凭据缺失/损坏),调用方按 unknown 处理, **不得当成 dead**

func (*Adapter) ProcHandle

func (a *Adapter) ProcHandle(taskID, taskDir string) (prochost.Handle, error)

ProcHandle 交出该任务的进程句柄(来自任务目录的 proc.json)。

参数:

  • taskID: 任务 ID,仅用于日志定位
  • taskDir: 任务目录(凭据所在)

返回:

  • 进程句柄;proc.json 不存在或不可解析时返回错误

注意:本方法**只读**,不探活、不发信号——存活判定与回收分别是 prochost.Alive 与 prochost.Sweep 的职责。agentd 以可选接口消费它 (不实现该方法的 adapter 一律按「无凭据」降级,与 reaper/prober 同款路数)。

func (*Adapter) Reap

func (a *Adapter) Reap(taskID, taskDir string) error

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, reason string) (err error)

RespondPermission 把协调者的权限裁决回发到 perm.sock。

参数:

  • permID: 与 permission 事件中的 PermissionID 一致(manager 还原后的裸 tool_use_id)
  • decision: "once"(批准本次)或 "reject"(拒绝)
  • reason: 协调者给出的拒绝理由;decision 为 reject 且理由非空时,经 permDecision.Message 与裁决同帧送达模型(见下方 DenyReasonInBand)

注意:

  • decision 取值**除 once 外一律 deny**:不认识的裁决绝不当成放行 (与 manager 侧 gateDecision「allow 之外一律 reject」同一条纪律)
  • 找不到挂起请求(进程已死 / 请求已被重试替换)时按 ErrTaskNotRunning 处置

func (*Adapter) Resume

func (a *Adapter) Resume(req executor.ResumeReq) (out executor.ResumeOutcome, err error)

Resume 恢复 agentd 重启前已在执行的任务(manager 的 restorer 可选接口)。

存活判据(本 adapter 与 opencode 的关键差异):prochost 的存活锁**不够**—— claude 自己退出后 shim 会写哨兵再退出,锁与哨兵之间有一个极短窗口锁还在但 进程已死(opencode 靠 HTTP 探活兜住这一点,claude 没有这个面)。统一走 Proc.Alive():存活锁被持有 **且** out.jsonl 无 handoff_exit 哨兵,缺一即死。

参数:

  • req: 恢复请求(TaskDir 是 proc.json 所在,即 DataDir/tasks/<id>; RepoPath 用于重启后重新捕获 git 兜底分类的起点 commit 基线; SessionID 是落库的 claude 会话 id)

返回:

  • Alive=false 时调用方(manager)把任务转 failed 交协调者裁决(保守优于静默)
  • err: 重建失败(proc.json 缺失/损坏、SessionID 为空),视为不可恢复

注意:

  • 恢复出的运行态与 Start 对称:Stop(done 归档)对其同样有效
  • 挂起权限不需要在此重建应答:MCP 子进程(claude 拉起的)会自行重连 perm.sock 重登记同一 tool_use_id,manager 侧 ticket 按 id 幂等去重

func (*Adapter) Send

func (a *Adapter) Send(ctx context.Context, taskID, text string) (err error)

Send 往同一会话续发指令(fifo 续接:上下文完整保留)。

参数:

  • text: 协调者的回答/修改指令,原样透传,不得加工

注意:

  • 进程不在(fifo 无读端,O_NONBLOCK 打开失败)时包装 executor.ErrTaskNotRunning

func (*Adapter) Start

func (a *Adapter) Start(ctx context.Context, req executor.StartReq) (err error)

Start 按「perm server → 任务物料 → prochost 进程 → 等 init → 投首回合 prompt → 会话就绪」流程启动任务执行并立即返回。

参数:

  • ctx: 控制启动阶段的超时/取消(prochost 拉起、等 init 受其约束); 不代表执行生命周期(执行延续到 Stop)
  • req: 任务快照、计划原文与任务工作目录

返回:

  • 任一启动阶段失败返回错误,调用方(manager)应把任务标记 failed

注意:

  • 已建资源(perm server / 执行者进程)在失败时逐级回滚,避免半启动残留
  • req.Env 必须原样透传进 StartProc(B19):漏传编译照过、用户 env 静默失效

func (*Adapter) Stop

func (a *Adapter) Stop(taskID string) (err error)

Stop 终止任务执行:停 socket 受理 → 回收执行者进程组 → 事件通道关闭 → 注销运行态。

注意:

  • 幂等:重复 Stop 不 panic;事件通道只关闭一次(由 streamLoop 的 defer 持有关闭权)
  • kill 失败时仍注销运行态(claude 没有 opencode 那类必须保句柄的端口/凭据回收场景, 进程残留由 handoff stop 兜底,与 opencode Kill 同规则)

type Proc

type Proc struct {
	Handle    prochost.Handle
	TaskDir   string
	SessionID string
}

Proc 描述一个运行中的 claude 进程句柄。

字段说明:

  • Handle: prochost 句柄(shim pid + 存活锁路径),存活与回收都靠它
  • TaskDir: 任务目录(fifo/out.jsonl/claude.log/proc.json 都在其中)
  • SessionID: claude --session-id(agentd 生成,写进 proc.json 供恢复)

func StartProc

func StartProc(ctx context.Context, req StartProcReq, log *slog.Logger) (*Proc, error)

StartProc 备物料、经 prochost 拉起 shim 承载 claude,返回进程句柄。

参数:

  • ctx: 保留以与 adapter 契约一致(当前未参与拉起,启动是子秒级操作); 执行生命周期延续到 Kill
  • req: 进程完整入参(见 StartProcReq)
  • log: 本模块日志入口(进程启动点,日志需要显式传入而非走默认)

返回:

  • 已拉起的进程句柄;任一阶段失败返回错误(此时无残留进程)

注意:

  • 就绪判定(等 system/init)由 adapter 在 stream 层完成,本函数只负责拉起
  • in.fifo 必须先于进程存在:shim 第一件事是 O_RDWR 打开它, fifo 缺失会让整条 claude 命令失败
  • startProcHost 返回后必须等 in.fifo 出现读者(WaitInputReader)才能返回: Start 只代表 shim 已 fork,写端不等到读端会 ENXIO(见 fifoReaderTimeout 的 why)
  • startProcHost 成功之后的一切失败路径必须自行 Kill 回收:调用方 rollback 依赖 r.proc,而 StartProc 失败时返回 nil(见 WaitInputReader 失败处)

func (*Proc) Alive

func (p *Proc) Alive() bool

Alive 检查 claude 是否仍然存活:存活锁被持有 且 out.jsonl 无死亡哨兵。

为什么两条都要:锁只证明 shim 活着;claude 自己退出后 shim 会写哨兵再退出, 两者之间有一个极短窗口锁还在但 claude 已死,哨兵兜住它。 (旧实现这里的第一条是 tmux has-session,而第二窗口的 tail -f 会一直吊着会话, 导致 claude 早死了会话还在——换成锁之后这个假存活来源被连根拔掉。)

func (*Proc) Kill

func (p *Proc) Kill() error

Kill 终止 claude 及其后代(按进程组),幂等。

func (*Proc) WriteInput

func (p *Proc) WriteInput(text string) error

WriteInput 往输入通道投递一条 stream-json user message。

参数:

  • text: 指令原文,原样透传不加工(executor 契约要求)

注意:

  • 投递失败多半意味着执行者已不在(读端消失),调用方据此包装 executor.ErrTaskNotRunning。平台差异(unix 的 ENXIO / Windows 的 ERROR_FILE_NOT_FOUND)由 prochost 吸收,本层不感知

type StartProcReq

type StartProcReq struct {
	RepoPath     string
	TaskID       string
	TaskDir      string
	SessionID    string
	Model        string
	SettingsPath string
	MCPPath      string
	Env          []string
	MarkRoot     string
	Resume       bool
}

StartProcReq 是一次 StartProc 的完整入参。

字段说明:

  • Env 来自 executor.StartReq.Env(manager 按任务执行者从 env 文件解析), 已解析已展开。**不读它会静默失效**:漏传编译照过、用户配的代理/密钥在 shim 里根本不出现——见 Global Constraints 与 proc_test 的钉子测试
  • Resume=true 时启动命令用 --resume(载入既有会话)而非 --session-id (建一个这个 id 的新会话)。两者语义相反,写错的表现是「日志说恢复成功、 模型却什么都不记得」

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL