222 lines
5.7 KiB
JavaScript
Executable File
222 lines
5.7 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
/**
|
||
* pi-coding-agent bridge (stdio JSON).
|
||
*
|
||
* Input (stdin):
|
||
* {
|
||
* "workspace": "/abs/path",
|
||
* "model": "claude-sonnet-4-5",
|
||
* "prompt": "...",
|
||
* "targets": ["docs/RD-UN.md"]
|
||
* }
|
||
*
|
||
* Output (stdout, last line JSON): 同 cursor-bridge / opencode-bridge 协议
|
||
*
|
||
* 设计:与 cursor-bridge / opencode-bridge 完全相同的协议层。
|
||
*
|
||
* 调用的 pi 命令:
|
||
* pi --mode rpc --no-session --provider <p> --model <m>
|
||
* --append-system-prompt INSTRUCTIONS.md
|
||
* --tools read,write,edit --approve
|
||
*
|
||
* 工作目录:Go 端 cmd.Dir 设(pi 没有 --cwd flag;node 桥 spawn 时再覆盖一次)
|
||
*
|
||
* 完成信号:
|
||
* - agent_settled → 成功("最稳妥" 标志,自动重试/队列全走完才算)
|
||
* - agent_error → 失败
|
||
* - turn_end → 单轮结束,不一定等于整体任务完成
|
||
*/
|
||
import { spawn } from "node:child_process";
|
||
import { readFileSync, existsSync } from "node:fs";
|
||
import { resolve, relative, isAbsolute } from "node:path";
|
||
|
||
async function readStdin() {
|
||
const chunks = [];
|
||
for await (const chunk of process.stdin) {
|
||
chunks.push(chunk);
|
||
}
|
||
return Buffer.concat(chunks).toString("utf8");
|
||
}
|
||
|
||
function fail(reason, extra = {}, stderr = "") {
|
||
process.stdout.write(
|
||
JSON.stringify({
|
||
ok: false,
|
||
patches: [],
|
||
fallback_reason: reason,
|
||
stderr: stderr ? stderr.slice(0, 400) : undefined,
|
||
...extra,
|
||
}) + "\n",
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
function safeRel(workspace, target) {
|
||
const abs = isAbsolute(target) ? target : resolve(workspace, target);
|
||
const rel = relative(workspace, abs);
|
||
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
||
return null;
|
||
}
|
||
return rel.replace(/\\/g, "/");
|
||
}
|
||
|
||
async function main() {
|
||
let req;
|
||
try {
|
||
const raw = await readStdin();
|
||
req = JSON.parse(raw);
|
||
} catch (e) {
|
||
fail("invalid_json: " + (e && e.message ? e.message : String(e)));
|
||
return;
|
||
}
|
||
|
||
const workspace = req.workspace;
|
||
const model = req.model || process.env.PI_AGENT_MODEL || "claude-sonnet-4-5";
|
||
const provider = req.provider || process.env.PI_AGENT_PROVIDER || "anthropic";
|
||
const prompt = req.prompt || "";
|
||
const targets = Array.isArray(req.targets) ? req.targets : [];
|
||
|
||
if (!workspace || !existsSync(workspace)) {
|
||
fail("workspace_missing");
|
||
return;
|
||
}
|
||
if (!prompt.trim()) {
|
||
fail("prompt_empty");
|
||
return;
|
||
}
|
||
|
||
const allowed = new Set();
|
||
for (const t of targets) {
|
||
const rel = safeRel(workspace, t);
|
||
if (rel) allowed.add(rel);
|
||
}
|
||
if (allowed.size === 0) {
|
||
fail("no_targets");
|
||
return;
|
||
}
|
||
|
||
// 1. spawn pi --mode rpc
|
||
const piBin = process.env.PI_AGENT_BIN || "pi";
|
||
const args = [
|
||
"--mode", "rpc",
|
||
"--no-session",
|
||
"--provider", provider,
|
||
"--model", model,
|
||
];
|
||
|
||
const sysPromptFile = process.env.PI_AGENT_SYSTEM_PROMPT_FILE || "INSTRUCTIONS.md";
|
||
const absSysPrompt = resolve(workspace, sysPromptFile);
|
||
if (existsSync(absSysPrompt)) {
|
||
args.push("--append-system-prompt", sysPromptFile);
|
||
}
|
||
|
||
args.push(
|
||
"--tools", "read,write,edit",
|
||
"--approve",
|
||
);
|
||
|
||
const env = { ...process.env };
|
||
env.PI_AGENT_MODEL = model;
|
||
env.PI_AGENT_PROVIDER = provider;
|
||
|
||
let agentId = "";
|
||
let runId = "";
|
||
let lastErr = "";
|
||
let succeeded = false;
|
||
|
||
try {
|
||
const child = spawn(piBin, args, {
|
||
cwd: workspace,
|
||
env,
|
||
stdio: ["pipe", "pipe", "pipe"],
|
||
});
|
||
|
||
// 2. 写 JSONL prompt 命令到 stdin
|
||
const cmdId = "req-" + Date.now();
|
||
child.stdin.write(
|
||
JSON.stringify({
|
||
id: cmdId,
|
||
type: "prompt",
|
||
message: prompt,
|
||
}) + "\n",
|
||
);
|
||
|
||
// 3. 按行解析 ndjson 事件流
|
||
let buf = "";
|
||
child.stdout.on("data", (chunk) => {
|
||
buf += chunk.toString("utf8");
|
||
let nl;
|
||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||
const line = buf.slice(0, nl).trim();
|
||
buf = buf.slice(nl + 1);
|
||
if (!line) continue;
|
||
let ev;
|
||
try { ev = JSON.parse(line); } catch { continue; }
|
||
if (!ev || typeof ev !== "object") continue;
|
||
const t = ev.type || "";
|
||
if (t === "session" && ev.id) {
|
||
agentId = ev.id;
|
||
}
|
||
if (t === "agent_settled") {
|
||
succeeded = true;
|
||
// agent_settled 表示任务真正结束;关闭 stdin 让 pi 退出
|
||
child.stdin.end();
|
||
}
|
||
if (t === "agent_error") {
|
||
lastErr = ev.error?.message || ev.message || "agent_error";
|
||
}
|
||
}
|
||
});
|
||
|
||
let stderrBuf = "";
|
||
child.stderr.on("data", (chunk) => {
|
||
stderrBuf += chunk.toString("utf8");
|
||
lastErr = (stderrBuf + " " + lastErr).slice(-400);
|
||
});
|
||
|
||
await new Promise((resolveDone) => {
|
||
child.on("close", () => resolveDone());
|
||
child.on("error", (err) => {
|
||
lastErr = err && err.message ? err.message : String(err);
|
||
resolveDone();
|
||
});
|
||
});
|
||
} catch (e) {
|
||
fail("startup_or_run: " + (e && e.message ? e.message : String(e)));
|
||
return;
|
||
}
|
||
|
||
if (!succeeded) {
|
||
fail(lastErr || "agent_not_settled", { agent_id: agentId, run_id: runId }, lastErr);
|
||
return;
|
||
}
|
||
|
||
// 4. 读 workspace 内目标文件
|
||
const patches = [];
|
||
for (const rel of allowed) {
|
||
const abs = resolve(workspace, rel);
|
||
if (!existsSync(abs)) continue;
|
||
const content = readFileSync(abs, "utf8");
|
||
patches.push({ rel_path: rel, content });
|
||
}
|
||
|
||
if (patches.length === 0) {
|
||
fail("empty_patches", { agent_id: agentId, run_id: runId });
|
||
return;
|
||
}
|
||
|
||
process.stdout.write(
|
||
JSON.stringify({
|
||
ok: true,
|
||
agent_id: agentId,
|
||
run_id: runId,
|
||
patches,
|
||
fallback_reason: "",
|
||
}) + "\n",
|
||
);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
fail("unhandled: " + (e && e.message ? e.message : String(e)));
|
||
});
|