first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# 单 agent 任务协议
你是独立的任务执行 agent,完成 /blackboard/task.md 中的任务。任务信息通过工作区与黑板目录交换。
## 目录结构
| 路径 | 用途 |
| --- | --- |
| /blackboard/task.md | 任务描述,每轮都要重读,勿修改 |
| /blackboard/workspace/ | 你的工作区(你的 cwd),所有产物写在这里 |
| /blackboard/CLAUDE.md | 本协议(副本在你的工作区,勿修改) |
| /blackboard/DONE | 完成标记,存在即表示任务已交付完成,只用 blackboard 工具 done 创建 |
| /blackboard/logs/ | 每轮运行日志(supervisor 维护) |
| /blackboard/sessions/ | 会话 IDsupervisor 维护,供跨轮恢复) |
| /blackboard/result.json | 任务结果(supervisor 结束时生成,勿手动修改) |
## 工具说明
你有三个 blackboard MCP 工具(命名空间 mcp__blackboard__):
- **read**(无参数或 path):读取黑板。无 path 返回全局快照(task.md / messages / workspace / DONE 状态);有 path 返回指定文件内容。
- **post**file, content):原子追加一条进度消息到 messages/<file>。file 必须是裸 .md 名(如 round-1-a.md)。
- **done**summary):原子创建 DONE 标记,summary 写最终交付总结。只能调用一次。
## 每轮流程
1. **读取**:用 blackboard read 读取 /blackboard/task.md(或直接 read 无参看全局快照),明确任务与提交规则。
2. **评估**:检查工作区已有产物,判断进度与缺口,不重复已完成工作。
3. **执行**:推进任务——执行命令、读写文件、编写产物到 /blackboard/workspace/。
4. **提交**:拿到 flag 的任务,立即按 task.md 中约定的规则提交,并把提交响应与得分写入产物。
5. **判定**:全部工作完成、交付物完整后,用 blackboard done 创建 DONEsummary 为最终交付总结,含关键结果/flag/提交响应/产物清单)。DONE 只能创建一次;宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 done 标记并写明「未解出」与已尝试内容。
## 红线
- 不直接写 /blackboard/DONE——必须走 blackboard done 工具,保证原子创建。
- 不伪造实验、命令或提交结果;如实记录。
- 不修改 /blackboard/task.md、/blackboard/CLAUDE.md 与 /blackboard/result.json。
+75
View File
@@ -0,0 +1,75 @@
# Claude Code 协作镜像(单 agent 版,与 blackboard 协议适配)
#
# 构建(在仓库根目录执行,构建上下文为 claude code/ 目录):
# cd /mnt/e/Code/Go/awesomeProject/agent
# DOCKER_BUILDKIT=0 docker build -f "claude code/Dockerfile" -t claude-coop "claude code/"
#
# 基础镜像用 node:22-alpine(自带 node 22 + npm,满足 Claude Code 的 Node 要求)。
# 在其上装系统工具(curl/wget/git/jq/openssl/ripgrep)和 python3 + 常用库,
# 再全局安装 @anthropic-ai/claude-code 和 MCP SDK,最后放入协作组件
#blackboard MCP server / supervisor / prompts)。
#
# 与 pi-coop 的差异:
# - 用 claude-code 替代 pi-coding-agent
# - blackboard 工具从 pi extension 改为独立 MCP serverstdio 协议)
# - 模型配置走 ANTHROPIC_* 环境变量(不是 pi provider 体系)
FROM node:22-alpine
# ---- 系统工具 + Python ----
# 先把 apk 源换成清华镜像并用 httpnode:22-alpine 的 Alpine 3.24 用官方 CDN
# 拉 APKINDEX 会报 TLS: unspecified error,改 http 绕过;装上 ca-certificates 后
# TLS 即恢复正常)。之后 apk add 装系统工具 + python3。
RUN sed -i 's|https://dl-cdn.alpinelinux.org|http://mirrors.tuna.tsinghua.edu.cn|g' /etc/apk/repositories \
&& apk add --no-cache \
bash ca-certificates git ripgrep \
curl wget jq openssl file \
python3 py3-pip
# ---- Python 常用库 ----
# 用清华 PyPI 镜像加速;--no-cache-dir 避免膨胀;--break-system-packages 放行 Alpine pip 全局安装。
RUN pip3 install --no-cache-dir --break-system-packages \
-i https://pypi.tuna.tsinghua.edu.cn/simple \
requests urllib3 certifi idna charset-normalizer \
cryptography pyOpenSSL paramiko pyjwt \
PyYAML beautifulsoup4 lxml \
python-dotenv click tqdm \
numpy pandas \
httpx aiohttp websockets dnspython \
psutil pillow
# ---- 全局安装 Claude Code ----
# 使用 npmmirror 镜像加速国内下载。
# @anthropic-ai/claude-code:提供 claude CLIheadless 模式入口)
RUN npm install -g \
--registry=https://registry.npmmirror.com \
@anthropic-ai/claude-code@latest
# ---- 协作组件 ----
# blackboard MCP server 放 /mcpsupervisor 与 prompts 放默认路径。
RUN mkdir -p /mcp /prompts /blackboard
COPY blackboard-server.mjs /mcp/blackboard-server.mjs
COPY mcp-servers.json /mcp-servers.json
COPY supervisor.sh /usr/local/bin/supervisor.sh
COPY AGENTS.md /prompts/AGENTS.md
COPY prompts /prompts
RUN chmod +x /usr/local/bin/supervisor.sh
# ---- MCP SDK 本地安装 ----
# ESM 模块解析要求依赖在脚本所在目录的 node_modules 内(全局安装的包不会被
# ESM import 解析到,NODE_PATH 也只对 CommonJS require 生效)。因此在 /mcp
# 目录本地安装 @modelcontextprotocol/sdk,让 blackboard-server.mjs 能 import 到。
RUN cd /mcp && npm init -y >/dev/null 2>&1 \
&& npm install \
--registry=https://registry.npmmirror.com \
@modelcontextprotocol/sdk@latest
# ---- 非 root 用户 ----
# node:22-alpine 自带 node 用户(uid 1000),直接复用,与 pi-coop 的 uid 1000 对齐,
# 便于 blackboard 挂载卷权限一致。
# 注意:--dangerously-skip-permissions 不能以 root 运行,必须用非 root 用户。
RUN chown -R node:node /mcp /prompts /blackboard /mcp-servers.json
USER node
WORKDIR /blackboard
ENTRYPOINT ["/usr/local/bin/supervisor.sh"]
+70
View File
@@ -0,0 +1,70 @@
# claude-coop 镜像构建与运行说明
## 构建镜像
在仓库根目录(`agent/`)执行:
```bash
DOCKER_BUILDKIT=0 docker build -f "claude code/Dockerfile" -t claude-coop "claude code/"
```
> 构建上下文是 `claude code/` 目录(注意路径含空格,需引号包裹)。
## 镜像内容
| 组件 | 说明 |
| --- | --- |
| 基础镜像 | `node:22-alpine`node 22 + npm |
| 编码 agent | `@anthropic-ai/claude-code`claude CLIheadless 模式入口) |
| MCP runtime | `@modelcontextprotocol/sdk`blackboard MCP server 依赖) |
| 系统工具 | curl wget git jq openssl ripgrep file |
| Python | python3 + 常用库(requests/cryptography/paramiko/PyYAML/numpy 等) |
| blackboard 工具 | MCP server`/mcp/blackboard-server.mjs`),提供 read/post/done 三个工具 |
## 与 pi-coop 的差异
| 维度 | pi-coop | claude-coop |
| --- | --- | --- |
| 编码 agent | pi@earendil-works/pi-coding-agent | Claude Code@anthropic-ai/claude-code |
| 工具注册 | pi extension-e flag | MCP server--mcp-config |
| 模型配置 | provider 体系(--provider coop --model X | ANTHROPIC_* 环境变量 |
| 协议支持 | openai + anthropic | 仅 anthropicClaude Code 限制) |
| session 提取 | NDJSON 首行 session header | JSON result 的 session_id 字段 |
| session 恢复 | --session-id | --resume |
| 权限 | 无(pi 无权限弹窗) | --dangerously-skip-permissionsheadless 必需,非 root |
## 环境变量契约(与 pi-coop 一致)
| 变量 | 说明 | 必填 |
| --- | --- | --- |
| MODEL | 模型名(如 deepseek-v4-flash | 是 |
| BASE_URL | Anthropic 兼容 API base-url(不带 /v1 | 是 |
| API_KEY | API key | 是 |
| PROTOCOL | 协议(claude-coop 仅支持 anthropic | 否 |
| TASK | 任务描述(或挂载 $BB/task.md | 是 |
| BLACKBOARD | 黑板目录(默认 /blackboard | 否 |
| ROUND_MAX | 最大轮次(默认 10 | 否 |
| TIMEOUT | 单轮超时秒数(默认 600) | 否 |
| FAIL_MODE | agent 失败时:stop(默认)/ continue | 否 |
## 手动测试
```bash
# 测试镜像工具链
docker run --rm --entrypoint sh claude-coop -c 'which claude node python3 curl wget jq; claude --version'
# 测试 blackboard MCP server
docker run --rm --entrypoint sh claude-coop -c 'BB=/tmp/bb mkdir -p /tmp/bb && BB=/tmp/bb node /mcp/blackboard-server.mjs & sleep 1 && kill %1'
```
## 控制层适配
控制层 `tools_coop.go``coopImage` 常量当前为 `"pi-coop"`。如需切换到 claude-coop
```go
const coopImage = "claude-coop"
```
环境变量注入逻辑无需改动(MODEL/BASE_URL/API_KEY/PROTOCOL 契约一致),但需注意:
- Claude Code 仅支持 Anthropic 协议端点,`coopBaseURL` 对 anthropic 不再补 `/v1`(已修复)
- `PROTOCOL=anthropic` 时,`BASE_URL` 应为 `https://api.deepseek.com/anthropic`
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env node
/**
* blackboard MCP server:让 Claude Code 适配 blackboard 协作协议。
*
* 与 pi-coop/coop.ts 的 blackboard 工具行为对齐:
* - read 无 path → 全局快照(task.md / messages / workspace / DONE 状态)
* 有 path → 单文件内容
* - post 原子追加一条消息到 messages/<file>O_APPENDPOSIX 下并发不交错)
* - done 原子创建 DONE 标记(O_CREAT|O_EXCL,保证唯一创建)
*
* 路径安全:所有传入路径都做 traversal 校验,拒绝绝对路径与 .. 越界。
*
* 通信协议:MCP stdioJSON-RPC 2.0)。Claude Code 通过 --mcp-config 加载本 server
* 工具命名空间为 mcp__blackboard__read / mcp__blackboard__post / mcp__blackboard__done。
*
* 环境变量:
* BB 黑板根目录(默认 /blackboard,由 supervisor 注入)
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import {
openSync,
closeSync,
writeFileSync,
appendFileSync,
readFileSync,
readdirSync,
statSync,
mkdirSync,
existsSync,
} from "node:fs";
import { join, resolve, sep, isAbsolute, normalize, basename } from "node:path";
// clean 等价于 normalize:规范化路径(消除 . 和 ..,合并分隔符)。
// pi 的 coop.ts 用了内部 cleanNode 标准 path 模块对应方法是 normalize。
const clean = normalize;
// 单条 post 上限:保证单次原子写,且不撑爆上下文(与 pigo / pi-coop 一致)。
const MAX_POST_BYTES = 32 * 1024;
// 单文件 read 上限。
const MAX_READ_BYTES = 32 * 1024;
const bbRoot = process.env.BB ?? "/blackboard";
// ---- 路径安全:safePath 把相对路径解析到 blackboard 根内,拒绝绝对路径与 .. 越界 ----
function safePath(p) {
const trimmed = (p ?? "").trim();
if (trimmed === "") throw new Error("empty path");
const c = clean(trimmed);
if (isAbsolute(c)) throw new Error(`path "${p}" must be relative to the blackboard root`);
const rootClean = clean(bbRoot);
const full = join(rootClean, c);
if (full !== rootClean && !full.startsWith(rootClean + sep)) {
throw new Error(`path "${p}" escapes the blackboard root`);
}
return full;
}
function truncate(s, max) {
return s.length > max ? s.slice(0, max) + "\n...<truncated>" : s;
}
// ---- blackboard 工具实现 ----
// read:无 path → 全局快照;有 path → 单文件内容。
function readAction(pathParam) {
const p = pathParam != null ? String(pathParam) : "";
if (p.trim() !== "") return readFile(p);
const b = [];
// task.md
try {
const data = readFileSync(join(bbRoot, "task.md"), "utf8");
b.push("# task.md\n" + truncate(data, MAX_READ_BYTES) + "\n");
} catch {
b.push("# task.md\n<missing>\n");
}
// messages/
b.push("\n# messages/ (" + listNames("messages").join(", ") + ")\n");
for (const name of listListing("messages")) b.push(" - " + name + "\n");
// workspace/
b.push("\n# workspace/ (your workspace)\n");
for (const name of listListing("workspace")) b.push(" - " + name + "\n");
// DONE
let doneText = "";
try {
doneText = truncate(readFileSync(join(bbRoot, "DONE"), "utf8"), 4096);
} catch {}
b.push("\n# DONE\n");
b.push(doneText ? "EXISTS:\n" + doneText + "\n" : "<not created yet — cooperation is still in progress>\n");
// environment
const env = [];
for (const k of ["BB", "ROUND", "NAME"]) {
const v = (process.env[k] ?? "").trim();
if (v) env.push(` ${k}=${v}`);
}
if (env.length) b.push("\n# environment\n" + env.join("\n") + "\n");
return b.join("");
}
function readFile(p) {
const full = safePath(p);
let info;
try {
info = statSync(full);
} catch (e) {
throw new Error(`blackboard read: ${p}: ${e instanceof Error ? e.message : String(e)}`);
}
if (info.isDirectory()) throw new Error(`blackboard read: ${p} is a directory; only files can be read`);
const data = readFileSync(full, "utf8");
return "# " + p + "\n" + truncate(data, MAX_READ_BYTES);
}
// post:原子追加一条消息到 messages/<file>。file 必须是裸 *.md 名。
function postAction(fileParam, contentParam) {
const name = String(fileParam ?? "").trim();
if (!validMessageName(name)) {
throw new Error('blackboard post: file must be a bare name ending in .md (e.g. "round-1-a.md"), no path separators, no ".."');
}
const content = String(contentParam ?? "").trim();
if (content === "") throw new Error("blackboard post: content must not be empty");
if (Buffer.byteLength(content) > MAX_POST_BYTES) {
throw new Error(`blackboard post: content too large (${Buffer.byteLength(content)} bytes, max ${MAX_POST_BYTES})`);
}
const dir = join(bbRoot, "messages");
mkdirSync(dir, { recursive: true });
// O_APPEND 单次写:POSIX 下并发 post 不会交错字节。
appendFileSync(join(dir, name), content + "\n");
return `Message appended to messages/${name}`;
}
// done:原子创建 DONE 标记。'wx' = O_CREAT|O_EXCL|O_WRONLY,保证唯一创建。
function doneAction(summaryParam) {
const summary = String(summaryParam ?? "").trim();
if (summary === "") throw new Error("blackboard done: summary must not be empty (include the final delivery summary)");
const header = "Blackboard cooperation DONE\ncreated: " + new Date().toISOString() + "\n\n";
const target = join(bbRoot, "DONE");
let fd;
try {
fd = openSync(target, "wx");
} catch (e) {
if (existsSync(target)) {
const existing = readFileSync(target, "utf8");
throw new Error("blackboard done: DONE already exists — cooperation already finished:\n" + truncate(existing, 4096));
}
throw e;
}
try {
writeFileSync(fd, header + summary);
} finally {
closeSync(fd);
}
return "DONE marker created. Cooperation finished.";
}
// 校验 post 文件名:裸 *.md,无分隔符,无 . / ..
function validMessageName(name) {
if (name === "" || !name.endsWith(".md")) return false;
if (/[\\/]/.test(name) || name === "." || name === "..") return false;
const base = name.slice(0, -3);
if (base === "" || base.startsWith(".") || base.includes("..")) return false;
return true;
}
function listNames(sub) {
try {
return readdirSync(join(bbRoot, sub)).sort();
} catch {
return [];
}
}
function listListing(sub) {
try {
return readdirSync(join(bbRoot, sub))
.sort()
.map((name) => {
try {
const info = statSync(join(bbRoot, sub, name));
if (info.isDirectory()) return `${name} (dir)`;
return `${name} (${info.size} bytes, ${info.mtime.toISOString().slice(11, 19)})`;
} catch {
return name;
}
});
} catch {
return [];
}
}
// ---- MCP server ----
const server = new Server(
{ name: "blackboard", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
// 列出工具:三个 action 拆成独立工具,便于 Claude Code 自动批准白名单匹配。
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "read",
description:
"Read the task blackboard used by the coop runner. " +
"Without a path: returns a global snapshot (task.md, messages listing, workspace listing, DONE state). " +
"With a path like \"workspace/exploit.py\": returns the contents of that one file. " +
"The blackboard root, current round and your name are in the environment as BB, ROUND, NAME.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: 'A path under the blackboard root to fetch (e.g. messages/round-1-a.md). Omit for the global snapshot.',
},
},
},
},
{
name: "post",
description:
"Atomically append a progress note to the blackboard messages/ directory. " +
"The file must be a bare .md name under messages/ (e.g. round-1-a.md). Max 32 KiB per post.",
inputSchema: {
type: "object",
properties: {
file: {
type: "string",
description: 'Bare file name under messages/, must end in .md (e.g. round-1-a.md).',
},
content: {
type: "string",
description: "The message body (max 32 KiB).",
},
},
required: ["file", "content"],
},
},
{
name: "done",
description:
"Atomically create the DONE marker with a final delivery summary. " +
"Only call this when the deliverable is truly complete (or confirmed unsolvable). Fails if DONE already exists.",
inputSchema: {
type: "object",
properties: {
summary: {
type: "string",
description: "Final delivery summary written into DONE (include key results / flag / submit response / artifact list).",
},
},
required: ["summary"],
},
},
],
}));
// 调用工具:根据 name 分发到对应实现,错误转为文本返回(不抛异常,保持 MCP 协议干净)。
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
let text;
switch (name) {
case "read":
text = readAction(args?.path);
break;
case "post":
text = postAction(args?.file, args?.content);
break;
case "done":
text = doneAction(args?.summary);
break;
default:
return {
content: [{ type: "text", text: `blackboard: unknown tool "${name}" (want read|post|done)` }],
isError: true,
};
}
return { content: [{ type: "text", text }] };
} catch (e) {
return {
content: [{ type: "text", text: `blackboard ${name}: ${e instanceof Error ? e.message : String(e)}` }],
isError: true,
};
}
});
// 启动 stdio transport,由 Claude Code 通过子进程方式拉起。
const transport = new StdioServerTransport();
await server.connect(transport);
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"blackboard": {
"command": "node",
"args": ["/mcp/blackboard-server.mjs"],
"env": {}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
# 角色:agent(主执行者)
你是 Claude Code 单 agent 任务执行者,独立完成 /blackboard/task.md 中的任务。
## 你的职责
- 理解任务、制定方案、完成核心产出。
- 所有产物写入你的工作区(你的 cwd,即 /blackboard/workspace/),按需命名。
- 需要提交 flag 的任务:拿到 flag 后立即按任务中的提交规则提交,并把提交响应与得分记录在产物中。
## 工作方式
- 每轮:用 blackboard read 读取 /blackboard/task.md 与工作区已有产物 → 推进任务 → 将进展与产物写入工作区。
- 环境变量 ROUND、NAME、BB 由 supervisor 提供;需要记录进展时用 blackboard post,创建完成标记时用 blackboard doneBB 指向黑板根目录)。
- 不要伪造命令结果、文件内容或提交响应;如实记录。
- 全部工作完成、交付物完整时,用 blackboard done 创建完成标记(summary 写最终交付总结,含关键结果、flag、提交响应、产物清单)。
## 注意
- 若本轮未完成,下一轮会用 --resume 恢复你的会话继续推进,跨轮保持上下文。
- 宁可多轮打磨,不要过早宣布完成;确认无法解出时同样用 blackboard done 标记,summary 写明「未解出」与已尝试内容,让调度方及时切换下一题。
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# Claude Code 协作 supervisor(单 agent 版)
#
# 在容器内运行一个 Claude Code headless 进程(claude -p)完成 /blackboard/task.md 中的任务。
# agent 的工作区为 /blackboard/workspace,产物与轮次日志保留在黑板上;
# 任务完成(agent 用 blackboard MCP 工具创建 DONE)后,supervisor 把结果
# 以结构化 JSONresult.json)落盘并打印一行摘要,供外部调度方直接解析。
#
# 与 pi-coop supervisor 的关键差异(Claude Code CLI 与 pi 不同):
# 1. 模型接入:Claude Code 用 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN /
# ANTHROPIC_MODEL 环境变量(不是 --provider/--model flag)。
# 第三方 Anthropic 兼容端点(如 DeepSeek)需 ANTHROPIC_API_KEY="" 触发
# 回退到 ANTHROPIC_AUTH_TOKEN,且 ANTHROPIC_BASE_URL 不带 /v1SDK 自己追加)。
# 2. 工具注册:Claude Code 用 MCP server--mcp-config),blackboard 工具
# 通过 blackboard-server.mjs 提供,工具命名空间为 mcp__blackboard__*。
# 3. 输出格式:claude -p --output-format json 输出单个 JSON,含 session_id
# 字段,直接用 jq 提取即可(无需像 pi 那样解析 NDJSON 首行)。
# 4. session 恢复:claude -p --resume <id>,跨轮保持上下文。
# 5. 权限:--dangerously-skip-permissions 跳过所有权限弹窗(headless 必需),
# 且必须以非 root 用户运行(node:22-alpine 自带 node 用户,uid 1000)。
#
# 环境变量(均由外部调用方传入,与 pi-coop 完全一致):
# MODEL 模型名,如 deepseek-v4-flash (必填)
# BASE_URL Anthropic 兼容 API base-url (必填)
# API_KEY API key(注入为 ANTHROPIC_AUTH_TOKEN (必填)
# PROTOCOL 协议(仅 anthropic 受 Claude Code 支持) (可选)
# TASK 任务描述 (必填;或挂载 $BB/task.md
# BLACKBOARD 黑板目录 (默认 /blackboard
# ROUND_MAX 最大轮次 (默认 10)
# TIMEOUT 单轮超时秒数,0=不超时 (默认 600)
# FAIL_MODE agent 失败时:stop=立即退出(默认)| continue=继续下一轮
set -u
BB="${BLACKBOARD:-/blackboard}"
PROMPTS="${PROMPTS:-/prompts}"
MCP_CONFIG="${MCP_CONFIG:-/mcp-servers.json}"
ROUND_MAX="${ROUND_MAX:-10}"
TIMEOUT="${TIMEOUT:-600}"
FAIL_MODE="${FAIL_MODE:-stop}"
log() { echo "[supervisor] $*"; }
# emit_result 把任务结果以结构化 JSON 写入 $BB/result.json 并打印一行摘要。
# status: solved | unsolved | error | timeout
emit_result() {
local status="$1" summary="$2" code="$3"
local flag="" artifacts_json="[]" summary_json='""' flag_json='""'
# flag 优先从 summary 提取,其次在 workspace 产物中全量检索
if [ -n "$summary" ]; then
flag=$(printf '%s' "$summary" | grep -oE 'flag\{[^}]+\}' | head -1)
fi
if [ -z "$flag" ] && [ -d "$BB/workspace" ]; then
flag=$(grep -rhoE 'flag\{[^}]+\}' "$BB/workspace" 2>/dev/null | head -1)
fi
# 产物清单:workspace 下的全部文件(最多 50 个),经 python3 转义为 JSON 数组
if [ -d "$BB/workspace" ]; then
artifacts_json=$(
cd "$BB/workspace" && find . -type f 2>/dev/null | sed 's|^\./||' | head -50 |
python3 -c 'import json,sys; print(json.dumps([l.rstrip("\n") for l in sys.stdin]))' 2>/dev/null
)
[ -z "$artifacts_json" ] && artifacts_json="[]"
fi
# summary 截断到 4000 字符并经 python3 转义,防止引号/换行破坏 JSON
summary_json=$(printf '%s' "$summary" | head -c 4000 | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null)
[ -z "$summary_json" ] && summary_json='""'
flag_json=$(printf '%s' "$flag" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null)
[ -z "$flag_json" ] && flag_json='""'
cat > "$BB/result.json" <<EOF
{
"status": "$status",
"exit_code": $code,
"summary": $summary_json,
"flag": $flag_json,
"artifacts": $artifacts_json
}
EOF
log "===== 协作结束 ====="
log "status=$status exit_code=$code"
[ -n "$flag" ] && log "flag=$flag"
log "结构化结果已写入:$BB/result.json"
}
# ---- 前置校验 ----
if [ -z "${MODEL:-}" ] || [ -z "${BASE_URL:-}" ] || [ -z "${API_KEY:-}" ]; then
echo "错误:必须提供 MODEL / BASE_URL / API_KEY 环境变量" >&2
exit 2
fi
if [ -z "${TASK:-}" ] && [ ! -f "$BB/task.md" ]; then
echo "错误:请通过 TASK 环境变量或挂载 $BB/task.md 提供任务" >&2
exit 2
fi
if [ -f "$BB/DONE" ]; then
log "黑板已有完成标记,如需重新开始请删除 $BB/DONE"
exit 0
fi
# ---- 初始化黑板 ----
mkdir -p "$BB/workspace" "$BB/logs" "$BB/sessions"
if [ -n "${TASK:-}" ]; then
printf '%s\n' "$TASK" > "$BB/task.md"
fi
# 协议注入:agent 的 cwd 是 $BB/workspaceAGENTS.md 注入链从 cwd 起,
# Claude Code 会自动加载 cwd 下的 CLAUDE.mdAGENTS.md 兼容),因此把协议
# 副本放进工作区(勿修改,它是每轮系统提示的一部分)
cp "$PROMPTS/AGENTS.md" "$BB/workspace/CLAUDE.md"
cp "$PROMPTS/AGENTS.md" "$BB/workspace/AGENTS.md"
log "黑板初始化完成:$BB"
log "任务:$(head -c 200 "$BB/task.md")"
# ---- 导出 Claude Code 模型配置环境变量 ----
# 关键:ANTHROPIC_API_KEY 必须设为空字符串(而非 unset),Claude Code 才会
# 回退到 ANTHROPIC_AUTH_TOKEN。BASE_URL 不带 /v1SDK 自动追加 /v1/messages)。
export ANTHROPIC_BASE_URL="$BASE_URL"
export ANTHROPIC_AUTH_TOKEN="$API_KEY"
export ANTHROPIC_API_KEY=""
export ANTHROPIC_MODEL="$MODEL"
# 把 opus/sonnet/haiku 三个别名都映射到目标模型,避免别名解析失败。
export ANTHROPIC_DEFAULT_OPUS_MODEL="$MODEL"
export ANTHROPIC_DEFAULT_SONNET_MODEL="$MODEL"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="$MODEL"
# 禁用非必要流量(遥测/登录检测),第三方端点必需。
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
# ---- 单轮运行:claude -p --output-format json,从 JSON 提取 session_id ----
run_agent() {
local round="$1"
local session_file="$BB/sessions/agent.session"
local task_prompt="轮次 $round 开始。先读取 /blackboard/task.md 中的任务,检查工作区已有产物与 DONE 状态,然后继续推进任务。全部工作完成、交付物完整时,用 blackboard 工具 done 创建完成标记(summary 写最终交付总结,含关键结果/flag/提交响应/产物清单)。"
local args=(-p "$task_prompt"
--output-format json
--mcp-config "$MCP_CONFIG"
--allowedTools "mcp__blackboard__*,Read,Write,Edit,Bash"
--append-system-prompt-file "$PROMPTS/agent.md"
--dangerously-skip-permissions)
if [ -s "$session_file" ]; then
args+=(--resume "$(cat "$session_file")")
fi
export ROUND="$round" NAME="agent" BB="$BB"
local logfile="$BB/logs/round-$round.log"
log "$round 轮开始运行 agent(日志:$logfile"
# Claude Code 用 --cwd / 进程 cwd 作工作区,先进入 $BB/workspace
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null; then
(cd "$BB/workspace" && timeout "$TIMEOUT" claude "${args[@]}") > "$logfile" 2>&1
else
(cd "$BB/workspace" && claude "${args[@]}") > "$logfile" 2>&1
fi
local rc=$?
# 从 --output-format json 的结果提取 session_id 供下一轮 --resume
# JSON 形如 {"type":"result","session_id":"<uuid>",...}
local sid
sid=$(python3 -c '
import json, sys
try:
d = json.loads(sys.stdin.read())
if d.get("type") == "result":
print(d.get("session_id", ""))
except Exception:
pass
' < "$logfile" 2>/dev/null)
if [ -n "$sid" ]; then
printf '%s' "$sid" > "$session_file"
fi
return $rc
}
# ---- 主循环:逐轮运行 agent,直到 DONE 或达到轮次上限 ----
for round in $(seq 1 "$ROUND_MAX"); do
[ -f "$BB/DONE" ] && break
log "===== 第 $round 轮开始 ====="
run_agent "$round"
rc=$?
if [ "$rc" -eq 0 ]; then
log "$round 轮完成"
else
log "$round 轮失败(exit=$rc),日志见 $BB/logs/round-$round.log"
if [ "$FAIL_MODE" = "stop" ]; then
# 兜底:agent 虽超时/失败,但黑板已有提交成功证据(workspace 内 *.md 含 correct:true
# → 视为完成正常退出,避免"flag 已提交却因未写 DONE 被强杀(exit 143"。
_done=0
for _f in "$BB"/workspace/*.md "$BB"/messages/*.md; do
[ -f "$_f" ] && grep -q '"correct":true' "$_f" && _done=1 && break
done
if [ "$_done" = "1" ]; then
log "检测到提交成功证据,自动标记完成"
{ echo "Task finished (auto-detected submit success)"; } > "$BB/DONE"
emit_result solved "$(cat "$BB/DONE")" 0
exit 0
fi
if [ "$rc" -eq 124 ]; then
emit_result timeout "" "$rc"
else
emit_result error "agent 退出码 $rc,日志见 logs/round-$round.log" "$rc"
fi
exit "$rc"
fi
fi
done
if [ -f "$BB/DONE" ]; then
log "检测到完成标记,任务结束"
emit_result solved "$(cat "$BB/DONE")" 0
exit 0
fi
log "达到最大轮次 $ROUND_MAX 仍未完成,请检查黑板产物与日志"
emit_result unsolved "" 1
exit 1