specrune-opencode 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/agents/specrune-check.md +39 -0
- package/agents/specrune-implement.md +38 -0
- package/agents/specrune-research.md +43 -0
- package/bin/specrune-opencode.mjs +13 -0
- package/commands/finish-work.md +66 -0
- package/commands/next.md +33 -0
- package/commands/start.md +52 -0
- package/package.json +40 -4
- package/src/install.mjs +214 -0
- package/src/plugin.ts +835 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
// SpecRune 的 OpenCode v2 宿主插件(由 OpenCode 以 Bun 加载,无编译步骤)。
|
|
2
|
+
//
|
|
3
|
+
// 落盘在 `.opencode/plugins/specrune.ts`,OpenCode 启动时自动加载该目录下的所有插件,
|
|
4
|
+
// 不需要任何配置挂载。
|
|
5
|
+
//
|
|
6
|
+
// 模块形状与钩子名是 OpenCode v2.0.15 的实测契约,不是文档抄写:
|
|
7
|
+
// - 模块必须 `export default { id, setup }`,Effect schema 会校验这个形状。
|
|
8
|
+
// - `setup(ctx)` 收到的是服务注册表(ctx.session / ctx.tool / ctx.shell /
|
|
9
|
+
// ctx.permission / ctx.aisdk / ctx.rpc …),钩子用 `ctx.<域>.hook(名字, 处理器)`
|
|
10
|
+
// 命令式注册。注册时名字不做校验,名字打错就静默不触发。
|
|
11
|
+
// - `session/prompt` :每条用户发言触发,载荷含 sessionID / prompt.text。
|
|
12
|
+
// - `session/context` :每轮组装 LLM 上下文时触发,载荷含 sessionID / agent /
|
|
13
|
+
// system({type:"text",text} 块数组)/ messages。往 system **追加**文本块即完成
|
|
14
|
+
// 稳定段注入:前面的块保持逐字节不变,provider 的前缀缓存不受影响
|
|
15
|
+
// (req:injection-chain#injection-pipeline-hard-constraints)。易变段与 workflow-state 面包屑改走 messages——易变内容
|
|
16
|
+
// 禁入 system,而 messages 的改动只影响当次模型调用、不进持久历史。
|
|
17
|
+
// messages 的 Message 形状为 { id, role, content:[{type,text}], metadata },
|
|
18
|
+
// 字段是 `content` **不是** `parts`;push 无运行时校验,写错字段不报错但内容
|
|
19
|
+
// 不会到达模型(静默失效)。见 req:injection-runtime#opencode-rpc-injection。
|
|
20
|
+
// - `tool/execute.before`:工具调用前触发,载荷含 tool / input,只读审计用。
|
|
21
|
+
//
|
|
22
|
+
// 注入与审计逻辑与具体 OpenCode 版本解耦:钩子名改了只改本文件顶部的注册表,
|
|
23
|
+
// 协议层(分帧、超时、微缓存、降级)不动。
|
|
24
|
+
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
// 二进制定位 + 熔断
|
|
31
|
+
|
|
32
|
+
// npm 全局安装的 Windows bin 只有 specrune.cmd,没有 .exe;spawn 不能直接执行
|
|
33
|
+
// .cmd(req:backend/npm-distribution#launcher-contract)。win32 下按
|
|
34
|
+
// SPECRUNE_BIN → specrune.exe → specrune.cmd 解析,.cmd 经 cmd.exe 拉起。
|
|
35
|
+
type BinarySpec = { command: string; viaCmd: boolean };
|
|
36
|
+
|
|
37
|
+
// findOnPath 在 PATH 里找一个存在的文件;名字自带扩展名,不做 PATHEXT 展开。
|
|
38
|
+
// 扫 PATH 而不是 `where`:省一次进程,且宿主启动路径上任何额外进程都要付
|
|
39
|
+
// 控制台闪窗与超时的代价。
|
|
40
|
+
function findOnPath(name: string): string | undefined {
|
|
41
|
+
const raw = process.env["PATH"] ?? process.env["Path"] ?? "";
|
|
42
|
+
for (const dir of raw.split(path.delimiter)) {
|
|
43
|
+
if (!dir) continue;
|
|
44
|
+
const candidate = path.join(dir, name);
|
|
45
|
+
try {
|
|
46
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
47
|
+
} catch {
|
|
48
|
+
// 单个坏目录项不该让整条 PATH 解析失败。
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// npm 平台包映射表是 npm/cli/lib/launcher.js 的 TARGETS 的镜像,那边是唯一出处。
|
|
55
|
+
// 插件落盘在宿主配置目录、包里不带 npm/cli 模板,运行时只能抄一份;漂移由
|
|
56
|
+
// npm/stage.test.mjs 的平台包名断言与安装器同表兜住。
|
|
57
|
+
const NPM_PLATFORM_PACKAGES: Record<string, string> = {
|
|
58
|
+
"linux-x64": "specrune-linux-x64",
|
|
59
|
+
"linux-arm64": "specrune-linux-arm64",
|
|
60
|
+
"darwin-x64": "specrune-darwin-x64",
|
|
61
|
+
"darwin-arm64": "specrune-darwin-arm64",
|
|
62
|
+
"win32-x64": "specrune-windows-x64",
|
|
63
|
+
"win32-arm64": "specrune-windows-arm64",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// 安装器在落盘前把带引号的占位符替换成本机平台二进制的绝对路径。未被替换时
|
|
67
|
+
// (仍以 __ 开头)说明是从源码直接加载、没经过安装器,继续走下一档。
|
|
68
|
+
const BUNDLED_SPECRUNE = "__SPECRUNE_BUNDLED_BIN__";
|
|
69
|
+
|
|
70
|
+
// .cmd / .bat 在 Windows 上不能直接 spawn,必须经 cmd.exe 拉起。安装器通常注入
|
|
71
|
+
// 原生平台二进制,但 SPECRUNE_BIN 也可能被覆盖成 npm 的 shim,所以按扩展名判定,
|
|
72
|
+
// 不能假设某个来源一定是原生可执行文件。
|
|
73
|
+
function isCmdShim(command: string): boolean {
|
|
74
|
+
return /\.(?:cmd|bat)$/i.test(command);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// findPlatformPackageBinary 沿自身目录向上找 node_modules 里的平台包。
|
|
78
|
+
// 用纯 fs 查找而不是 createRequire:插件可能被 Bun 以任意路径加载,纯 fs
|
|
79
|
+
// 的向上查找对 npm / pnpm / Bun 的 node_modules 布局都成立。
|
|
80
|
+
//
|
|
81
|
+
// 每层查两处:npm 提升后的 <dir>/node_modules/<pkg>,以及 npm 因版本冲突
|
|
82
|
+
// 塞进 <dir>/node_modules/specrune/node_modules/<pkg> 的嵌套副本。
|
|
83
|
+
function findPlatformPackageBinary(): string | undefined {
|
|
84
|
+
const pkg = NPM_PLATFORM_PACKAGES[`${process.platform}-${process.arch}`];
|
|
85
|
+
if (!pkg) return undefined;
|
|
86
|
+
const binName = process.platform === "win32" ? "specrune.exe" : "specrune";
|
|
87
|
+
for (let dir = path.dirname(fileURLToPath(import.meta.url)); ; dir = path.dirname(dir)) {
|
|
88
|
+
for (const base of [dir, path.join(dir, "node_modules", "specrune")]) {
|
|
89
|
+
const bin = path.join(base, "node_modules", pkg, "bin", binName);
|
|
90
|
+
try {
|
|
91
|
+
if (existsSync(bin) && statSync(bin).isFile()) return bin;
|
|
92
|
+
} catch {
|
|
93
|
+
// 单个坏目录项不该让整条查找失败。
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const parent = path.dirname(dir);
|
|
97
|
+
if (parent === dir) return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 定位 specrune 可执行文件。顺序:SPECRUNE_BIN(本地开发期覆盖,与 pi 扩展同名,
|
|
102
|
+
// 见 req:cli-contract#env-contract-table)→ 安装器注入的随包平台二进制 →
|
|
103
|
+
// npm 平台包向上查找 → PATH。
|
|
104
|
+
//
|
|
105
|
+
// viaCmd 只由扩展名决定:注入档与 npm 档通常是原生平台二进制,但 SPECRUNE_BIN
|
|
106
|
+
// 或安装时的覆盖可能指向 .cmd shim,漏判会让 Windows 上的 spawn 直接 EINVAL。
|
|
107
|
+
// win32 的 PATH 回退才需要依次找 specrune.exe、specrune.cmd,都没有时回退
|
|
108
|
+
// specrune.exe 让 spawn 报 ENOENT(保留旧失败形态)。
|
|
109
|
+
function locateSpecruneBinary(): BinarySpec {
|
|
110
|
+
const fromEnv = (process.env["SPECRUNE_BIN"] || "").trim();
|
|
111
|
+
if (fromEnv) return { command: fromEnv, viaCmd: isCmdShim(fromEnv) };
|
|
112
|
+
// 注入路径可能因用户后来删掉依赖而失效;存在性检查让这种情况自然落到下一档,
|
|
113
|
+
// 而不是每轮 spawn 一个不存在的文件。
|
|
114
|
+
if (BUNDLED_SPECRUNE && !BUNDLED_SPECRUNE.startsWith("__") && existsSync(BUNDLED_SPECRUNE)) {
|
|
115
|
+
return { command: BUNDLED_SPECRUNE, viaCmd: isCmdShim(BUNDLED_SPECRUNE) };
|
|
116
|
+
}
|
|
117
|
+
const viaNpm = findPlatformPackageBinary();
|
|
118
|
+
if (viaNpm) return { command: viaNpm, viaCmd: false };
|
|
119
|
+
if (process.platform !== "win32") return { command: "specrune", viaCmd: false };
|
|
120
|
+
const exe = findOnPath("specrune.exe");
|
|
121
|
+
if (exe) return { command: exe, viaCmd: false };
|
|
122
|
+
const cmd = findOnPath("specrune.cmd");
|
|
123
|
+
if (cmd) return { command: cmd, viaCmd: true };
|
|
124
|
+
return { command: "specrune.exe", viaCmd: false };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// spawnSpecrune 按定位结果起进程:.cmd 必须经 cmd.exe /d /s /c 执行,
|
|
128
|
+
// windowsVerbatimArguments 保证路径里的空格不被 Node 的二次转义拆坏。
|
|
129
|
+
function spawnSpecrune(
|
|
130
|
+
spec: BinarySpec,
|
|
131
|
+
args: string[],
|
|
132
|
+
options: Parameters<typeof spawn>[2],
|
|
133
|
+
): ReturnType<typeof spawn> {
|
|
134
|
+
if (!spec.viaCmd) return spawn(spec.command, args, options);
|
|
135
|
+
const line = [`"${spec.command}"`, ...args].join(" ");
|
|
136
|
+
return spawn(process.env["ComSpec"] || "cmd.exe", ["/d", "/s", "/c", `"${line}"`], {
|
|
137
|
+
...options,
|
|
138
|
+
windowsVerbatimArguments: true,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// killSpecrune 终止一次调用的进程树。.cmd 分支的 child.pid 是 cmd.exe,
|
|
143
|
+
// 只 kill 它会把真正的 specrune 子进程留下;taskkill /pid <pid> /T /F 才是整棵树。
|
|
144
|
+
function killSpecrune(child: ReturnType<typeof spawn>, viaCmd: boolean): void {
|
|
145
|
+
if (!viaCmd) {
|
|
146
|
+
try {
|
|
147
|
+
if (child.exitCode === null) child.kill();
|
|
148
|
+
} catch {
|
|
149
|
+
/* 已经死了 */
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const pid = child.pid;
|
|
154
|
+
if (pid === undefined) return;
|
|
155
|
+
const fallback = (): void => {
|
|
156
|
+
try {
|
|
157
|
+
if (child.exitCode === null) child.kill();
|
|
158
|
+
} catch {
|
|
159
|
+
/* 已经死了 */
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
164
|
+
windowsHide: true,
|
|
165
|
+
stdio: "ignore",
|
|
166
|
+
});
|
|
167
|
+
killer.on("error", fallback);
|
|
168
|
+
} catch {
|
|
169
|
+
fallback();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 拉起失败几乎总是环境性的(二进制不在 PATH、目录不可写、权限不足),重试不会
|
|
174
|
+
// 变好,而每次重试都在 Agent 的关键路径上。熔断:连续 3 次失败后冷却 30 秒,期间
|
|
175
|
+
// 直接走降级(微缓存 / 跳过注入)。
|
|
176
|
+
const MAX_SPAWN_FAILURES = 3;
|
|
177
|
+
const RETRY_COOLDOWN_MS = 30_000;
|
|
178
|
+
|
|
179
|
+
// 逐轮上下文走 stdio 帧管道,不走套接字(req:injection-chain#injection-pipeline-hard-constraints)。
|
|
180
|
+
//
|
|
181
|
+
// 不做套接字直连:stdio 管道在 Node 与 Bun 上行为一致,而套接字直连只有 Bun 可行,
|
|
182
|
+
// 两条注入链会因此分叉(req:architecture#injection-bridge-process-shape)。
|
|
183
|
+
//
|
|
184
|
+
// 父子同生共死:不设 detached,子进程绑宿主生命周期,宿主退出即随之退出——detached
|
|
185
|
+
// 全局进程倍增那类事故在结构上不可能发生。
|
|
186
|
+
|
|
187
|
+
const HEADER_END = "\r\n\r\n";
|
|
188
|
+
|
|
189
|
+
function encodeFrame(obj: unknown): Buffer {
|
|
190
|
+
const payload = Buffer.from(JSON.stringify(obj), "utf8");
|
|
191
|
+
return Buffer.concat([Buffer.from(`Content-Length: ${payload.length}${HEADER_END}`, "utf8"), payload]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// decodeFrame 从缓冲里解出**一帧**。
|
|
195
|
+
//
|
|
196
|
+
// 返回 undefined 表示半截帧(等更多字节),null 表示头坏了(整条流已废)。
|
|
197
|
+
// per-call 下只读一帧,所以解出即返回,不做「扫完整个缓冲里所有帧」。
|
|
198
|
+
function decodeFrame(buf: Buffer): Record<string, unknown> | undefined | null {
|
|
199
|
+
const headerEnd = buf.indexOf(HEADER_END);
|
|
200
|
+
if (headerEnd < 0) return undefined;
|
|
201
|
+
const m = /Content-Length:\s*(\d+)/i.exec(buf.subarray(0, headerEnd).toString("utf8"));
|
|
202
|
+
if (!m) return null;
|
|
203
|
+
const length = parseInt(m[1], 10);
|
|
204
|
+
const start = headerEnd + HEADER_END.length;
|
|
205
|
+
if (buf.length < start + length) return undefined;
|
|
206
|
+
try {
|
|
207
|
+
return JSON.parse(buf.subarray(start, start + length).toString("utf8")) as Record<string, unknown>;
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// CallFailure 是一次调用没拿到结果的原因。
|
|
214
|
+
//
|
|
215
|
+
// "error" 与其它几种性质不同:它意味着桥**应答了**(最常见是「没有活跃任务」),
|
|
216
|
+
// 属于正常跳过而不是故障——所以消息要单独带出来。
|
|
217
|
+
type CallFailure = "dead" | "timeout" | "error";
|
|
218
|
+
|
|
219
|
+
// CallResult 承载一次调用的结局:结果 / 子进程已死 / 超时 / 对端报错。
|
|
220
|
+
// message 只在 reason==="error" 时有值。
|
|
221
|
+
type CallResult<T> =
|
|
222
|
+
| { ok: true; value: T }
|
|
223
|
+
| { ok: false; reason: CallFailure; message?: string };
|
|
224
|
+
|
|
225
|
+
// BRIDGE_FALLBACK_MS 是「等不到响应就降级」的兜底,**不是**决定成败的预算。
|
|
226
|
+
//
|
|
227
|
+
// 预算档位表只在 Go 侧(`cli/hookserve.go` 按方法查表,req:architecture#bridge-budget-tiers):两个宿主共用
|
|
228
|
+
// 同一座桥,预算只能有一个来源。TS 侧另写常数会让兜底抢在档位之前把请求掐掉,
|
|
229
|
+
// 等于把预算又搬回 TS 侧。
|
|
230
|
+
//
|
|
231
|
+
// 它必须大于桥的最高档(800ms),只负责「桥已经没了 / 卡住了」这两种情况。
|
|
232
|
+
const BRIDGE_FALLBACK_MS = 1_500;
|
|
233
|
+
|
|
234
|
+
// HookBridge 是一次性的注入桥:**每次调用 spawn 一个 `specrune hook-serve` 子进程,
|
|
235
|
+
// 发一帧、读一帧、随即终止**(req:architecture#injection-bridge-process-shape)。
|
|
236
|
+
//
|
|
237
|
+
// 不做常驻:常驻唯一的收益是省掉重复的 `BuildSessionContext`,而缓存 TTL 1s、每轮只调
|
|
238
|
+
// 一次,那个节省从不发生(req:architecture#bridge-no-cache)。per-call 后新进程没有状态。
|
|
239
|
+
//
|
|
240
|
+
// **一次调用只发一帧、只读一帧,所以响应错配在结构上不可能发生**——child 引用、请求表、
|
|
241
|
+
// 按 id 配对、存活探测、跨调用的缓冲累积全都不需要,它们都是为常驻服务的。
|
|
242
|
+
class HookBridge {
|
|
243
|
+
private failures = 0;
|
|
244
|
+
private cooldownUntil = 0;
|
|
245
|
+
// 微缓存:key = repoRoot|agent → 最近一次成功的稳定段。桥不可用或超时时注入降级用它,
|
|
246
|
+
// Agent 的推理循环不被阻塞(req:architecture#daemon-process-retired / req:architecture#bridge-degradation-contract)。
|
|
247
|
+
//
|
|
248
|
+
// 它**不是加速器**(加速器已被 req:architecture#bridge-no-cache 删除),是失败降级路径,
|
|
249
|
+
// 与「进程内会话上下文缓存」是两回事。
|
|
250
|
+
private cache = new Map<string, string>();
|
|
251
|
+
|
|
252
|
+
call<T>(method: string, params: unknown, root: string): Promise<CallResult<T>> {
|
|
253
|
+
if (Date.now() < this.cooldownUntil) {
|
|
254
|
+
return Promise.resolve({ ok: false, reason: "dead" } as CallResult<T>);
|
|
255
|
+
}
|
|
256
|
+
return this.exchange<T>(method, params, root);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// exchange 完成一次「spawn → 发一帧 → 读一帧 → 终止」。
|
|
260
|
+
private exchange<T>(method: string, params: unknown, root: string): Promise<CallResult<T>> {
|
|
261
|
+
return new Promise<CallResult<T>>((resolve) => {
|
|
262
|
+
let child: ReturnType<typeof spawn>;
|
|
263
|
+
let viaCmd = false;
|
|
264
|
+
try {
|
|
265
|
+
const spec = locateSpecruneBinary();
|
|
266
|
+
viaCmd = spec.viaCmd;
|
|
267
|
+
// cwd + SPECRUNE_REPO:桥要知道服务哪个仓库,而它不继承宿主的启动目录
|
|
268
|
+
// (OpenCode 的 location.directory 可能是子目录)。
|
|
269
|
+
// detached: false —— 父子同生共死,不做后台进程(req:architecture#injection-bridge-process-shape)。
|
|
270
|
+
child = spawnSpecrune(spec, ["hook-serve"], {
|
|
271
|
+
cwd: root,
|
|
272
|
+
windowsHide: true,
|
|
273
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
274
|
+
detached: false,
|
|
275
|
+
env: { ...process.env, SPECRUNE_REPO: root },
|
|
276
|
+
});
|
|
277
|
+
} catch {
|
|
278
|
+
this.noteFailure();
|
|
279
|
+
resolve({ ok: false, reason: "dead" } as CallResult<T>);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// buf 是**这一次调用**的局部累积,不跨调用:进程随调用而生灭。
|
|
284
|
+
let buf = Buffer.alloc(0);
|
|
285
|
+
let settled = false;
|
|
286
|
+
const finish = (result: CallResult<T>): void => {
|
|
287
|
+
if (settled) return;
|
|
288
|
+
settled = true;
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
// 读完即毁:关 stdin 让 Go 侧读到 EOF 正常退出,再终止兜底。不留活进程是
|
|
291
|
+
// per-call 的硬要求(req:architecture#injection-bridge-process-shape)。
|
|
292
|
+
try {
|
|
293
|
+
child.stdin?.end();
|
|
294
|
+
} catch {
|
|
295
|
+
/* 已关闭 */
|
|
296
|
+
}
|
|
297
|
+
killSpecrune(child, viaCmd);
|
|
298
|
+
resolve(result);
|
|
299
|
+
};
|
|
300
|
+
// 超时**必须**计入熔断(req:architecture#bridge-degradation-contract):这段预算防的是「桥还在但不回话」——卡死、
|
|
301
|
+
// 死锁、git 探测挂起。只计 spawn 失败的话,这类卡死每轮都重试、永不冷却,
|
|
302
|
+
// 症状恰好是熔断当初要消灭的「注入时灵时不灵且零报错」。
|
|
303
|
+
const timer = setTimeout(() => {
|
|
304
|
+
this.noteFailure();
|
|
305
|
+
finish({ ok: false, reason: "timeout" } as CallResult<T>);
|
|
306
|
+
}, BRIDGE_FALLBACK_MS);
|
|
307
|
+
|
|
308
|
+
// spawn 失败是异步的:二进制不在 PATH 时若没有 'error' 监听者会变成
|
|
309
|
+
// 未捕获异常。收到即判本轮失败(下一轮是另一个全新进程)。
|
|
310
|
+
child.on("error", () => {
|
|
311
|
+
this.noteFailure();
|
|
312
|
+
finish({ ok: false, reason: "dead" } as CallResult<T>);
|
|
313
|
+
});
|
|
314
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
315
|
+
buf = Buffer.concat([buf, chunk]);
|
|
316
|
+
const frame = decodeFrame(buf);
|
|
317
|
+
if (frame === undefined) return; // 半截帧,等下一块
|
|
318
|
+
if (frame === null) {
|
|
319
|
+
// 头解不出来:整条流已废,丢弃它。
|
|
320
|
+
finish({ ok: false, reason: "dead" } as CallResult<T>);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// 桥的 error 是**裸字符串**(不是 JSON-RPC 的那个对象):hook-serve
|
|
324
|
+
// 刻意不走守护进程那套带 jsonrpc 字段与令牌的自研协议(req:architecture#injection-bridge-process-shape 无鉴权面)。
|
|
325
|
+
// 两侧形状不同是设计,不是 bug——所以也绝不能复用 ipc/jsonrpc.go。
|
|
326
|
+
const err = frame["error"];
|
|
327
|
+
if (typeof err === "string" && err.length > 0) {
|
|
328
|
+
finish({ ok: false, reason: "error", message: err } as CallResult<T>);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// 整帧交给调用方:响应字段按方法互斥(text / stable+volatile / block),
|
|
332
|
+
// 与 ipc.SessionContextResult、WorkflowStateResult 的 json tag 一一对应。
|
|
333
|
+
this.noteSuccess();
|
|
334
|
+
finish({ ok: true, value: frame as unknown as T });
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// 桥没给出任何帧就收流了(超预算退出、崩溃、半截帧)——立刻判本轮失败,
|
|
338
|
+
// 不让宿主干等 BRIDGE_FALLBACK_MS 走完。
|
|
339
|
+
//
|
|
340
|
+
// 为什么监听 stdout 的 'close' 而不是子进程的 'exit':'exit' 可能早于
|
|
341
|
+
// stdout 排空触发(进程已经死了,管道里的字节还没被父进程读走),那一刻
|
|
342
|
+
// 判失败会把**已经写完、正在路上的**应答丢掉。'close' 保证排在所有
|
|
343
|
+
// 'data' 之后,于是顺序是确定的:先 data(拿到应答或拿到 error),
|
|
344
|
+
// 后 close(什么都没拿到)。settled 标志让两者只有一次生效。
|
|
345
|
+
child.stdout?.on("close", () => finish({ ok: false, reason: "dead" } as CallResult<T>));
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
// 刻意不带 jsonrpc / token 字段:hook-serve 协议不含这两样。
|
|
349
|
+
// id 恒为 1 —— 它只是协议要求的字段,不用于配对(每进程只有一个请求)。
|
|
350
|
+
child.stdin?.write(encodeFrame({ id: 1, method, params }));
|
|
351
|
+
} catch {
|
|
352
|
+
this.noteFailure();
|
|
353
|
+
finish({ ok: false, reason: "dead" } as CallResult<T>);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// noteFailure 累计失败并决定是否进入冷却。
|
|
359
|
+
//
|
|
360
|
+
// 熔断在 per-call 下**比常驻版更要紧**:常驻版一次会话只 spawn 一次,
|
|
361
|
+
// per-call 每轮都 spawn——二进制缺失会让每轮都失败,熔断是唯一的止血手段。
|
|
362
|
+
//
|
|
363
|
+
// 冷却要**显式告警**而不静默失败:降级还在跑(微缓存 / 跳过),用户看到的
|
|
364
|
+
// 现象只是「规范好像没生效」,没人知道原因(req:architecture#daemon-lifecycle-retired / req:architecture#bridge-degradation-contract)。
|
|
365
|
+
//
|
|
366
|
+
// 告警措辞必须覆盖两类失败:spawn 失败(桥起不来)与超时(桥起来了但不回话)。
|
|
367
|
+
// 只写「无法启动」的话,超时熔断会打出一条与真实故障相反的日志——排障第一步
|
|
368
|
+
// 就是错的,这正是本任务要消灭的那类「说反话的降级」。
|
|
369
|
+
private noteFailure(): void {
|
|
370
|
+
this.failures += 1;
|
|
371
|
+
if (this.failures < MAX_SPAWN_FAILURES) return;
|
|
372
|
+
this.cooldownUntil = Date.now() + RETRY_COOLDOWN_MS;
|
|
373
|
+
this.failures = 0;
|
|
374
|
+
const binary = locateSpecruneBinary();
|
|
375
|
+
process.stderr.write(
|
|
376
|
+
`[specrune] 警告:注入桥已连续 ${MAX_SPAWN_FAILURES} 次没能应答(启动失败或超时未回),接下来 ` +
|
|
377
|
+
`${Math.round(RETRY_COOLDOWN_MS / 1000)} 秒内不再重试,规范注入降级为「跳过/微缓存」。\n` +
|
|
378
|
+
` 排查:① 确认 \`${binary.command}\` 在 PATH 上(可用 SPECRUNE_BIN 指定绝对路径);\n` +
|
|
379
|
+
` ② 手动执行 \`${binary.command} hook-serve\` 看前台报错;\n` +
|
|
380
|
+
` ③ 若能启动但经常超时,多半是仓库过大或 git 探测挂起,先复现本条告警再调预算。\n`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// noteSuccess 清零连续失败计数。
|
|
385
|
+
//
|
|
386
|
+
// 为什么必须有:超时是**瞬时**故障(机器某刻繁忙、git 探测偶尔挂起),而
|
|
387
|
+
// 熔断要判的是「连续失败」。若成功不重置,插件进程活得越久、偶发超时攒得越多,
|
|
388
|
+
// 总有一天凑满三次就把一个基本健康的桥打进 30 秒冷却——而告警里写的却是
|
|
389
|
+
// 「连续 3 次」,与事实相反。成功后清零让「连续」名副其实,也让真正卡死的桥
|
|
390
|
+
// (每轮都超时,没有成功可清)照样在三次后冷却。
|
|
391
|
+
private noteSuccess(): void {
|
|
392
|
+
this.failures = 0;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
cacheKey(repoRoot: string, agent?: string): string {
|
|
396
|
+
return `${repoRoot}|${agent ?? ""}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
cacheGet(key: string): string | undefined {
|
|
400
|
+
return this.cache.get(key);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// cacheSet 是有界的(16 条):宿主进程可能长时间不退出,不封顶就是无界增长。
|
|
404
|
+
cacheSet(key: string, block: string): void {
|
|
405
|
+
if (this.cache.size > 16) {
|
|
406
|
+
const first = this.cache.keys().next().value;
|
|
407
|
+
if (first !== undefined) this.cache.delete(first);
|
|
408
|
+
}
|
|
409
|
+
this.cache.set(key, block);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const client = new HookBridge();
|
|
414
|
+
|
|
415
|
+
// 插件状态(每个 setup 调用一份;OpenCode 可能按目录多次实例化插件)
|
|
416
|
+
|
|
417
|
+
// 逐轮注入的预算档位表只存在于 Go 侧 `cli/hookserve.go`(按方法查表):两个宿主共用
|
|
418
|
+
// 同一座桥,预算只能有一个来源(req:architecture#bridge-budget-tiers)。扩展只保留
|
|
419
|
+
// 「等不到响应就降级」的兜底(BRIDGE_FALLBACK_MS),那不是决定成败的预算值。
|
|
420
|
+
|
|
421
|
+
// SPECRUNE_PLUGIN_DEBUG 打开后,注入的关键节点各打一行到 stderr。插件跑在宿主进程里,
|
|
422
|
+
// 出问题时唯一的观测口就是宿主日志。
|
|
423
|
+
const DEBUG = process.env["SPECRUNE_PLUGIN_DEBUG"] === "1";
|
|
424
|
+
|
|
425
|
+
function debug(msg: string): void {
|
|
426
|
+
if (DEBUG) process.stderr.write(`[specrune] ${msg}\n`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let bridgeDown = false; // 一轮会话里「注入桥不在」只告警一次,不刷屏
|
|
430
|
+
|
|
431
|
+
// lastPrompt 暂存本会话最近一条用户 prompt。
|
|
432
|
+
//
|
|
433
|
+
// `workflow.state` 需要 prompt 判定跳过关键字(`prompt_injection.skip_keyword`),而
|
|
434
|
+
// `context` 钩子不携带 prompt——同一会话里只有 `prompt` 钩子拿得到。取不到时按空串传,
|
|
435
|
+
// 跳过关键字判定失效(面包屑照发,不是故障,故不告警)。上限 64 条只为防长会话无界
|
|
436
|
+
// 增长;逐轮覆盖同一 sessionID,实际占用是「活跃会话数」量级。
|
|
437
|
+
const lastPrompt = new Map<string, string>();
|
|
438
|
+
const LAST_PROMPT_LIMIT = 64;
|
|
439
|
+
|
|
440
|
+
function stashPrompt(sessionID: string, text: string | undefined): void {
|
|
441
|
+
if (!text) return;
|
|
442
|
+
if (lastPrompt.size >= LAST_PROMPT_LIMIT) {
|
|
443
|
+
const oldest = lastPrompt.keys().next();
|
|
444
|
+
if (!oldest.done) lastPrompt.delete(oldest.value);
|
|
445
|
+
}
|
|
446
|
+
lastPrompt.set(sessionID, text);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// INJECTION_SENTINEL 标记「以下内容由 specrune 注入」。
|
|
450
|
+
//
|
|
451
|
+
// 值必须与 Go 侧 `context.go` 的 InjectionSentinel 和 pi 扩展三处逐字一致
|
|
452
|
+
// (req:injection-chain#subagent-dispatch 的跨宿主约定)。
|
|
453
|
+
//
|
|
454
|
+
// 刻意不加在 `<workflow-state>` 面包屑上:那是 workflow 块自己的格式,哨兵会让两处的
|
|
455
|
+
// 存在性判定互相干扰(同 workflow.go)。
|
|
456
|
+
const INJECTION_SENTINEL = "<!-- specrune-hook-injected -->";
|
|
457
|
+
|
|
458
|
+
// SUBAGENT_SKILL_INSTRUCTION 是子代理会话的兜底技能加载指令
|
|
459
|
+
// (req:subagents#subagent-skill-loading)。OpenCode 没有派发钩子,子代理由宿主
|
|
460
|
+
// Task 工具创建;这里在子代理的 system 段补一条,旧模板或用户自定义模板也能生效。
|
|
461
|
+
// 主会话不会看到它(parentID 判定为子代理才注入)。
|
|
462
|
+
const SUBAGENT_SKILL_INSTRUCTION =
|
|
463
|
+
"Before acting, load your SpecRune role skill: `specrune-before-dev` for the implement role, `specrune-review` for the check role. Also load any skills named in your dispatch prompt. Use your host's skill tool when it has one; otherwise read `.agents/skills/<name>/SKILL.md` (project) or `~/.agents/skills/<name>/SKILL.md` (global) for each. If any is unavailable, report `<name> skill unavailable` and stop. Research may ignore the role-skill sentence.";
|
|
464
|
+
|
|
465
|
+
// 易变段**不做**逐轮去重,且这是刻意的——不要照抄 pi 侧的 `lastTurn`。
|
|
466
|
+
//
|
|
467
|
+
// 两个宿主的持久化语义相反,所以同一段代码在一侧是优化、在另一侧是信息丢失:
|
|
468
|
+
//
|
|
469
|
+
// pi `before_agent_start` 返回 `{ message }`,是 append-only 消息,**进 transcript
|
|
470
|
+
// 历史**。内容逐字节未变时不再重复投递是对的——历史里已经有一份。
|
|
471
|
+
//
|
|
472
|
+
// opencode `session/context` 的 `ev.messages` **只影响当次模型调用、不进持久历史**。
|
|
473
|
+
// 于是「未变就不投递」等于**这一轮的上下文里根本没有易变段与面包屑**——
|
|
474
|
+
// 模型会突然失去 git 状态、当前任务和 workflow 引导。这不是省 token,是丢信息。
|
|
475
|
+
//
|
|
476
|
+
// 判据(改动这段之前先答):**这份内容下一轮还在不在?** 在 → 可以去重;不在 → 去重就是丢信息。
|
|
477
|
+
|
|
478
|
+
// subagentCache 暂存「会话 → 是否子代理」的判定结果(req:injection-runtime#workflow-state-decision)。
|
|
479
|
+
//
|
|
480
|
+
// 判据是宿主持久化的 parentID:子会话创建时写死、此后不变,所以**确定性的**结论(含
|
|
481
|
+
// 「确认主会话」,parentID 为 NULL)可以按 sessionID 缓存,每会话只付一次 SDK 往返。
|
|
482
|
+
// `session.get` 的异常**不缓存**(不是确定性结论,下一轮重试);失败方向遵循「宁漏关
|
|
483
|
+
// 不误关」——判不出子代理就按主会话处理。
|
|
484
|
+
const subagentCache = new Map<string, boolean>();
|
|
485
|
+
const SUBAGENT_CACHE_LIMIT = 64;
|
|
486
|
+
|
|
487
|
+
function cacheSubagentFlag(sessionID: string, isSub: boolean): void {
|
|
488
|
+
if (subagentCache.size >= SUBAGENT_CACHE_LIMIT) {
|
|
489
|
+
const oldest = subagentCache.keys().next();
|
|
490
|
+
if (!oldest.done) subagentCache.delete(oldest.value);
|
|
491
|
+
}
|
|
492
|
+
subagentCache.set(sessionID, isSub);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// HostSessionApi 是子代理判定用到的最小宿主 SDK 面(ctx 就是 OpenCode server client,
|
|
496
|
+
// `session.get` 返回 Session.Info;这里只声明消费到的字段)。
|
|
497
|
+
interface HostSessionApi {
|
|
498
|
+
get?: (args: { sessionID: string }) => Promise<{ parentID?: string } | undefined>;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// resolveSubagent 判定会话是否子代理(宿主派生的独立 session)。
|
|
502
|
+
//
|
|
503
|
+
// 判据用 parentID 而不是 agent 名单:名单会漂移(内置改名、用户自定义),只配做辅助
|
|
504
|
+
// 信号;parentID 是宿主持久化的权威事实。宿主没有 `session.get`(老版本)时按主会话处理。
|
|
505
|
+
async function resolveSubagent(sessionApi: HostSessionApi | undefined, sessionID: string): Promise<boolean> {
|
|
506
|
+
const cached = subagentCache.get(sessionID);
|
|
507
|
+
if (cached !== undefined) return cached;
|
|
508
|
+
if (!sessionApi?.get) return false;
|
|
509
|
+
try {
|
|
510
|
+
const info = await sessionApi.get({ sessionID });
|
|
511
|
+
const isSub = typeof info?.parentID === "string" && info.parentID.length > 0;
|
|
512
|
+
cacheSubagentFlag(sessionID, isSub);
|
|
513
|
+
return isSub;
|
|
514
|
+
} catch (e) {
|
|
515
|
+
debug(`session.get 失败,按主会话处理:${(e as Error).message}`);
|
|
516
|
+
return false; // 异常不缓存,下轮重试
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
interface PromptEvent {
|
|
521
|
+
sessionID: string;
|
|
522
|
+
messageID?: string;
|
|
523
|
+
prompt?: { text?: string };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
interface ContextEvent {
|
|
527
|
+
sessionID: string;
|
|
528
|
+
agent?: string;
|
|
529
|
+
// system 是 {type:"text",text} 块数组;追加块 = 注入,前缀保持逐字节稳定。
|
|
530
|
+
system: Array<{ type: string; text: string }>;
|
|
531
|
+
// messages 是本次模型调用的消息数组(形状见文件头)。易变段与面包屑走这里。
|
|
532
|
+
messages?: Array<{ id?: string; role?: string; content?: Array<{ type: string; text: string }>; metadata?: unknown }>;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// session.context 与 workflow.state 的响应形状(对齐 Go 侧 ipc 包)。
|
|
536
|
+
interface SessionCtxResult {
|
|
537
|
+
// mode="split" 时两段分列;其余 mode 只有 text。
|
|
538
|
+
text?: string;
|
|
539
|
+
stable?: string;
|
|
540
|
+
volatile?: string;
|
|
541
|
+
status?: string;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
interface WorkflowStateResult {
|
|
545
|
+
block?: string;
|
|
546
|
+
skipped?: boolean;
|
|
547
|
+
status?: string;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
interface ToolEvent {
|
|
551
|
+
tool: string;
|
|
552
|
+
sessionID?: string;
|
|
553
|
+
input?: unknown;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// resolveRepoRoot 从起点目录向上找 `.specrune`。
|
|
557
|
+
//
|
|
558
|
+
// OpenCode 的项目目录不保证是仓库根(从子目录启动、monorepo),直接把 cwd
|
|
559
|
+
// 当 repoRoot 会让注入桥回答「这不是一个 SpecRune 项目」——注入静默消失,
|
|
560
|
+
// 而症状看起来像桥坏了。向上走到根为止,找不到就原样返回(交给桥去明确报错,
|
|
561
|
+
// 那条错误比这里猜一个仓库强)。
|
|
562
|
+
// SPECRUNE_REPO 是显式覆盖(与 CLI 同名,见 req:cli-contract#env-contract-table),优先于向上查找。
|
|
563
|
+
function resolveRepoRoot(baseDir: string): string {
|
|
564
|
+
const start = process.env["SPECRUNE_REPO"] || baseDir;
|
|
565
|
+
let dir = path.resolve(start);
|
|
566
|
+
for (;;) {
|
|
567
|
+
if (existsSync(path.join(dir, ".specrune"))) return dir;
|
|
568
|
+
const parent = path.dirname(dir);
|
|
569
|
+
if (parent === dir) return path.resolve(start);
|
|
570
|
+
dir = parent;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// projectDisabled 判定项目是否应完全跳过自动注入。
|
|
575
|
+
//
|
|
576
|
+
// 判据与 Go 侧 specrune.ProjectDisabled 一致:没有 `.specrune/`(非项目)或
|
|
577
|
+
// 根目录存在 `.specrune-disabled`(显式禁用)。标记只影响自动注入与宿主插件;
|
|
578
|
+
// 显式 CLI 命令(task / context / session)由用户主动调用,不受影响。
|
|
579
|
+
function projectDisabled(root: string): boolean {
|
|
580
|
+
try {
|
|
581
|
+
if (!statSync(path.join(root, ".specrune")).isDirectory()) return true;
|
|
582
|
+
} catch {
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
return existsSync(path.join(root, ".specrune-disabled"));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// warnOnceBridgeDown 一轮会话里只告警一次,不刷屏。
|
|
589
|
+
//
|
|
590
|
+
// 降级必须留日志(req:architecture#daemon-lifecycle-retired / req:architecture#bridge-degradation-contract):
|
|
591
|
+
// 用户看到的现象只是「规范好像没生效」,没人知道原因就跟没修一样。
|
|
592
|
+
function warnOnceBridgeDown(): void {
|
|
593
|
+
if (bridgeDown) return;
|
|
594
|
+
bridgeDown = true;
|
|
595
|
+
process.stderr.write("[specrune] 注入桥不可用,本轮注入降级(不影响 Agent 运行)\n");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// injectContext 在每轮上下文组装时注入会话上下文。
|
|
599
|
+
//
|
|
600
|
+
// 与 Pi 侧同构:稳定段进 system,易变段与 workflow-state 面包屑进 messages
|
|
601
|
+
// (req:injection-chain#injection-pipeline-hard-constraints:二分在两个宿主上必须一致,否则「前缀稳定」
|
|
602
|
+
// 这条承诺只对 Pi 成立)。
|
|
603
|
+
//
|
|
604
|
+
// 每轮都调是因为任务状态在会话中途可能切换(task start / finish),注入内容必须跟着走。
|
|
605
|
+
// 桥超时或不在时回退微缓存,再不行就跳过——本轮注入失败绝不允许阻断 Agent 的推理循环。
|
|
606
|
+
//
|
|
607
|
+
// sessionApi 是宿主 SDK 面(setup 的 ctx.session),供子代理判定用(req:injection-runtime#workflow-state-decision):
|
|
608
|
+
// 判定成立时 workflow.state 带 `role: "subagent"`,Go 侧对无指针的子代理渲染 delegated。TS 只传
|
|
609
|
+
// 事实(req:injection-chain#injection-pipeline-hard-constraints),delegated 的语义在 Go/模板侧。
|
|
610
|
+
async function injectContext(
|
|
611
|
+
ev: ContextEvent,
|
|
612
|
+
repoRoot: string,
|
|
613
|
+
sessionApi?: HostSessionApi,
|
|
614
|
+
): Promise<void> {
|
|
615
|
+
// 会话中途才放上禁用标记时也要停下;setup 时已禁用的项目根本不会注册到这里。
|
|
616
|
+
if (projectDisabled(repoRoot)) {
|
|
617
|
+
debug("项目已禁用(缺少 .specrune/ 或存在 .specrune-disabled),跳过注入");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
const params = { repoRoot, contextKey: `opencode_${ev.sessionID}`, platform: "opencode" };
|
|
622
|
+
// 子代理判定提前:parentID 是权威事实,稳定段和 workflow.state 都要用。
|
|
623
|
+
// ev.agent 只作 debug 观测,不参与判定(名单会漂移)。
|
|
624
|
+
const isSub = await resolveSubagent(sessionApi, ev.sessionID);
|
|
625
|
+
if (isSub) {
|
|
626
|
+
debug(`子代理判定成立(parentID 非空)session=${ev.sessionID} agent=${ev.agent ?? "-"}`);
|
|
627
|
+
}
|
|
628
|
+
// 一次 session.context(mode=split) 拿两段,不分两次调:BuildSessionContext 每次都要跑
|
|
629
|
+
// git(status / for-each-ref / log),两次调用等于把 git 成本翻倍,而 per-call 之后没有
|
|
630
|
+
// 进程内缓存可省(req:architecture#bridge-no-cache)。
|
|
631
|
+
const split = await client.call<SessionCtxResult>("session.context", { ...params, mode: "split" }, repoRoot);
|
|
632
|
+
|
|
633
|
+
// ① 稳定段 → system。内容是身份/包/路径,逐轮相同即逐字节稳定,进 system
|
|
634
|
+
// 不破前缀缓存(req:injection-chain#injection-pipeline-hard-constraints)。桥缺席时回退微缓存。
|
|
635
|
+
if (Array.isArray(ev.system)) {
|
|
636
|
+
const key = client.cacheKey(repoRoot);
|
|
637
|
+
const stable = split.ok ? split.value?.stable : undefined;
|
|
638
|
+
if (stable) {
|
|
639
|
+
client.cacheSet(key, stable);
|
|
640
|
+
ev.system.push({ type: "text", text: `${INJECTION_SENTINEL}\n${stable}` });
|
|
641
|
+
debug(`注入 STABLE ${Buffer.byteLength(stable, "utf8")} 字节 session=${ev.sessionID}`);
|
|
642
|
+
} else if (!split.ok && split.reason !== "error") {
|
|
643
|
+
const cached = client.cacheGet(key);
|
|
644
|
+
if (cached) {
|
|
645
|
+
ev.system.push({ type: "text", text: `${INJECTION_SENTINEL}\n${cached}` });
|
|
646
|
+
debug(`注入 STABLE 微缓存 ${Buffer.byteLength(cached, "utf8")} 字节(split ${split.reason})`);
|
|
647
|
+
} else {
|
|
648
|
+
debug(`跳过 STABLE:${split.reason} 且无微缓存`);
|
|
649
|
+
}
|
|
650
|
+
} else if (split.ok) {
|
|
651
|
+
debug("跳过 STABLE:注入桥返回空段");
|
|
652
|
+
} else {
|
|
653
|
+
// dead / timeout / error 三种都要说话:降级可以,静默不行(req:architecture#daemon-lifecycle-retired)。
|
|
654
|
+
// 「没打这行日志」与「注入了但内容为空」在外部看来是同一件事。
|
|
655
|
+
debug(`跳过 STABLE:${split.reason}${split.reason === "error" ? `(${split.message ?? ""})` : ""}`);
|
|
656
|
+
}
|
|
657
|
+
if (isSub) {
|
|
658
|
+
ev.system.push({ type: "text", text: `${INJECTION_SENTINEL}\n${SUBAGENT_SKILL_INSTRUCTION}` });
|
|
659
|
+
debug(`注入子代理技能兜底 ${Buffer.byteLength(SUBAGENT_SKILL_INSTRUCTION, "utf8")} 字节`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ② 易变段 + workflow-state 面包屑 → messages。易变内容禁入 system
|
|
664
|
+
// (req:injection-chain#injection-pipeline-hard-constraints);messages 的改动只影响当次模型调用,不进持久历史。
|
|
665
|
+
// 无活跃任务时 workflow.state 仍会回 no_task 引导块(req:injection-runtime#opencode-rpc-injection)。
|
|
666
|
+
if (Array.isArray(ev.messages)) {
|
|
667
|
+
// 子代理判定已在稳定段前完成;这里只消费 isSub 传 workflow.state 的 role。
|
|
668
|
+
const wf = await client.call<WorkflowStateResult>("workflow.state", {
|
|
669
|
+
...params,
|
|
670
|
+
// role 只在判定成立时出现:缺省(主会话 / 判定失败降级)= 逐字节现行为。
|
|
671
|
+
...(isSub ? { role: "subagent" } : {}),
|
|
672
|
+
prompt: lastPrompt.get(ev.sessionID) ?? "",
|
|
673
|
+
}, repoRoot);
|
|
674
|
+
const parts: string[] = [];
|
|
675
|
+
if (split.ok && split.value?.volatile) parts.push(split.value.volatile);
|
|
676
|
+
// 面包屑取一次并就地判空:skipped 的空块不是故障,是「本轮按配置不注入」。
|
|
677
|
+
// 用三元取值而不是 Boolean(...) 中间别名:别名会切断控制流窄化,迫使后面用 `!`
|
|
678
|
+
// 断言 `wf.value`(tsc TS2339)。
|
|
679
|
+
const crumb =
|
|
680
|
+
wf.ok && wf.value?.block && !wf.value.skipped ? wf.value.block : undefined;
|
|
681
|
+
if (crumb) parts.push(crumb);
|
|
682
|
+
const hasBreadcrumb = crumb !== undefined;
|
|
683
|
+
if (parts.length > 0) {
|
|
684
|
+
const text = parts.join("\n\n");
|
|
685
|
+
// 每轮**无条件**投递,不做逐字节去重:ev.messages 不进持久历史,未变而不投递 =
|
|
686
|
+
// 这一轮的上下文里没有易变段与面包屑。判据见 INJECTION_SENTINEL 上方的注释。
|
|
687
|
+
ev.messages.push({
|
|
688
|
+
id: `specrune_${ev.sessionID}_${ev.messages.length}`,
|
|
689
|
+
role: "user",
|
|
690
|
+
content: [{ type: "text", text: `${INJECTION_SENTINEL}\n${text}` }],
|
|
691
|
+
metadata: { specrune: true },
|
|
692
|
+
});
|
|
693
|
+
debug(`注入易变段 ${Buffer.byteLength(text, "utf8")} 字节(面包屑 ${hasBreadcrumb ? "有" : "无"})`);
|
|
694
|
+
} else {
|
|
695
|
+
debug(`跳过易变段:volatile=${split.ok ? "空段" : split.reason} 面包屑=${hasBreadcrumb ? "有" : "无"}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (!split.ok && split.reason !== "error") warnOnceBridgeDown();
|
|
699
|
+
} catch (e) {
|
|
700
|
+
process.stderr.write(`[specrune] 注入失败(已跳过本轮):${(e as Error).message}\n`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// parseTicketCommands 从 shell 命令串里识别会话敏感的 specrune 命令。
|
|
705
|
+
//
|
|
706
|
+
// 这是 shell 票据的写端。存在理由:**没有任何被调研的平台会把会话 id 导出到 shell
|
|
707
|
+
// 子进程**——OpenCode 也是。于是 `specrune task start <ref>` 在 OpenCode 的 bash 里
|
|
708
|
+
// 跑时无从得知自己属于哪个会话,任务指针写不到 `opencode_<sessionID>` 名下。钩子恰好
|
|
709
|
+
// 在 shell 命令执行**前**触发,这里是唯一能桥接的时机。
|
|
710
|
+
//
|
|
711
|
+
// 只认显式 `specrune task start/current/finish` 与 `specrune session record`;解析不出
|
|
712
|
+
// 任务引用的 start 不写——票据侧 taskRefsMatch 对空引用本来也判不中。
|
|
713
|
+
//
|
|
714
|
+
// `session record` 在名单里:记录头的 session / platform / task 全靠会话身份,票据是
|
|
715
|
+
// OpenCode 下唯一的通道(Pi 走注入变量因而正常)。
|
|
716
|
+
//
|
|
717
|
+
// 路径前缀不是可选项:Windows 上二进制叫 specrune.exe,且 agent 几乎总是用相对路径调它
|
|
718
|
+
// (bin\specrune.exe)。只认裸 `specrune` 会让 Windows 上的票据桥失效,症状是
|
|
719
|
+
// 「OpenCode 绑不上 session」,与 worktree 缺陷表象相同。
|
|
720
|
+
function parseTicketCommands(command: string): Array<{ name: string; task_ref?: string }> {
|
|
721
|
+
const out: Array<{ name: string; task_ref?: string }> = [];
|
|
722
|
+
const re =
|
|
723
|
+
/\b(?:[^\s;&|]*[\\/])?specrune(?:\.exe)?\s+(?:task\s+(start|current|finish)|session\s+(record))\b(?:\s+([^\s;&|]+))?/g;
|
|
724
|
+
let m: RegExpExecArray | null;
|
|
725
|
+
while ((m = re.exec(command)) !== null) {
|
|
726
|
+
const name = m[1] ?? m[2];
|
|
727
|
+
if (!name) continue;
|
|
728
|
+
if (name === "start") {
|
|
729
|
+
const ref = m[3];
|
|
730
|
+
if (!ref || ref.startsWith("-")) continue;
|
|
731
|
+
out.push({ name, task_ref: ref });
|
|
732
|
+
} else {
|
|
733
|
+
out.push({ name });
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return out;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// writeShellTicket 在 shell 工具执行前写一张 30 秒票据。
|
|
740
|
+
//
|
|
741
|
+
// 字段与 TTL 必须与 Go 侧 lookupShellTicketContextKey 的读取逻辑逐项一致
|
|
742
|
+
// (.specrune/.runtime/shell-tickets/*.json,TTL 对齐 ShellTicketTTLSeconds)。任何一步
|
|
743
|
+
// 失败都静默退化为「无身份」——那是 task start 会显式报错的状态,不是崩溃状态。
|
|
744
|
+
function writeShellTicket(ev: ToolEvent, repoRoot: string): void {
|
|
745
|
+
try {
|
|
746
|
+
if (ev.tool !== "shell" || !ev.sessionID) return;
|
|
747
|
+
const input = ev.input as { command?: string; workdir?: string } | undefined;
|
|
748
|
+
if (!input?.command) return;
|
|
749
|
+
const subs = parseTicketCommands(input.command);
|
|
750
|
+
if (subs.length === 0) return; // 没有会话敏感的命令就不需要桥接
|
|
751
|
+
// 只看 `.specrune/` 是否存在:票据服务于显式 CLI 命令(task / session),
|
|
752
|
+
// 项目被禁用时这些命令仍然可用,因此不能因为 `.specrune-disabled` 就
|
|
753
|
+
// 掐断它们的会话身份。也不能因为没有 `.specrune/` 就在每个项目里凭空
|
|
754
|
+
// 制造一个(禁用项目本身已有 `.specrune/`,不受影响)。
|
|
755
|
+
if (!existsSync(path.join(repoRoot, ".specrune"))) return;
|
|
756
|
+
const dir = path.join(repoRoot, ".specrune", ".runtime", "shell-tickets");
|
|
757
|
+
mkdirSync(dir, { recursive: true });
|
|
758
|
+
const now = Date.now() / 1000;
|
|
759
|
+
const ticket = {
|
|
760
|
+
context_key: `opencode_${ev.sessionID}`,
|
|
761
|
+
platform: "opencode",
|
|
762
|
+
cwd: input.workdir || repoRoot,
|
|
763
|
+
created_at_epoch: now,
|
|
764
|
+
expires_at_epoch: now + 30,
|
|
765
|
+
subcommands: subs,
|
|
766
|
+
};
|
|
767
|
+
const file = path.join(dir, `opencode_${ev.sessionID.replace(/[^\w-]/g, "_")}.json`);
|
|
768
|
+
writeFileSync(file, JSON.stringify(ticket), "utf8");
|
|
769
|
+
debug(`写 shell 票据:${subs.map((s) => s.name).join(" / ")}`);
|
|
770
|
+
} catch {
|
|
771
|
+
/* 票据是尽力而为:失败退化为无身份,命令侧会显式报错,不会静默错绑 */
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// auditToolCall 只读审计:打到 stderr,由 OpenCode 收进日志。不拦截、不改参。
|
|
776
|
+
function auditToolCall(ev: ToolEvent): void {
|
|
777
|
+
try {
|
|
778
|
+
let input = "";
|
|
779
|
+
try {
|
|
780
|
+
input = JSON.stringify(ev.input ?? "").slice(0, 200);
|
|
781
|
+
} catch {
|
|
782
|
+
input = String(ev.input).slice(0, 200);
|
|
783
|
+
}
|
|
784
|
+
process.stderr.write(`[specrune-audit] ${ev.tool} ${input}\n`);
|
|
785
|
+
} catch {
|
|
786
|
+
/* 审计是旁路,任何失败都不许影响工具调用 */
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// 插件入口
|
|
791
|
+
|
|
792
|
+
// 钩子注册在 setup 闭包里而不是模块顶层:OpenCode 可能以不同项目目录多次实例化同一份
|
|
793
|
+
// 插件,repoRoot 必须跟各自的 location.directory 走,共享的只有连接与微缓存。
|
|
794
|
+
const setup = async (ctx: {
|
|
795
|
+
location?: { directory?: string };
|
|
796
|
+
session: {
|
|
797
|
+
hook: (name: string, handler: (ev: any) => Promise<void>) => void;
|
|
798
|
+
// get 是宿主 SDK 面:`session.get({ sessionID })` 返回 Session.Info(本插件只消费
|
|
799
|
+
// parentID,req:injection-runtime#workflow-state-decision 子代理判定)。可选:宿主缺它时按主会话处理。
|
|
800
|
+
get?: (args: { sessionID: string }) => Promise<{ parentID?: string } | undefined>;
|
|
801
|
+
};
|
|
802
|
+
tool: { hook: (name: string, handler: (ev: any) => Promise<void>) => void };
|
|
803
|
+
}) => {
|
|
804
|
+
const baseDir = ctx.location?.directory || process.cwd();
|
|
805
|
+
// 项目级禁用:没有 .specrune/ 或根目录存在 .specrune-disabled 时完全不注册
|
|
806
|
+
// SpecRune hook。必须在任何 hook 注册之前返回——否则「不激活」只是不注入,
|
|
807
|
+
// 宿主里仍留着 prompt/工具审计钩子。
|
|
808
|
+
if (projectDisabled(resolveRepoRoot(baseDir))) return;
|
|
809
|
+
const repoRoot = () => resolveRepoRoot(baseDir);
|
|
810
|
+
|
|
811
|
+
// 只剩 prompt 暂存:注入桥无常驻实例、协议只有一种形状,没有握手要协商的余地。
|
|
812
|
+
ctx.session.hook("prompt", async (ev: PromptEvent) => {
|
|
813
|
+
stashPrompt(ev.sessionID, ev.prompt?.text);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
ctx.session.hook("context", async (ev: ContextEvent) => {
|
|
817
|
+
await injectContext(ev, repoRoot(), ctx.session);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
ctx.tool.hook("execute.before", async (ev: ToolEvent) => {
|
|
821
|
+
const root = repoRoot();
|
|
822
|
+
// 会话中途放上标记时 hook 无法注销,但审计(stderr)必须停下。票据仍要写:
|
|
823
|
+
// task / session 这类显式 CLI 命令不因禁用而失效,OpenCode 的会话身份
|
|
824
|
+
// 只靠这张票据传递。
|
|
825
|
+
if (!projectDisabled(root)) auditToolCall(ev);
|
|
826
|
+
writeShellTicket(ev, root);
|
|
827
|
+
});
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
export default {
|
|
831
|
+
id: "specrune",
|
|
832
|
+
// 字段名必须是 setup:2.0.15 的加载 schema 只认 id + setup/effect 之一,
|
|
833
|
+
// 名字写错(比如 dev 分支的 server)整份插件会被静默拒载。
|
|
834
|
+
setup,
|
|
835
|
+
};
|