@tachikomagundam/abathur 0.2.0 → 0.2.2
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/README.md +65 -22
- package/dist/commands/opencode.js +26 -4
- package/dist/test/opencode.test.js +61 -15
- package/package.json +7 -1
- package/plugin/abathur-command.md +9 -8
- package/plugin/abathur.ts +30 -19
package/README.md
CHANGED
|
@@ -139,8 +139,10 @@ abathur run --genome toy-smoke --dry-run # plan + requires[] probes, zero sp
|
|
|
139
139
|
### Inside opencode
|
|
140
140
|
|
|
141
141
|
Abathur ships an official opencode plugin adapter. The harness remains the
|
|
142
|
-
orchestrator, but once
|
|
143
|
-
directly:
|
|
142
|
+
orchestrator, but once set up an opencode session can also drive the CLI
|
|
143
|
+
directly. Two install routes:
|
|
144
|
+
|
|
145
|
+
**Route A — one command (tool + `/abathur` slash command).**
|
|
144
146
|
|
|
145
147
|
```bash
|
|
146
148
|
npm i -g @tachikomagundam/abathur
|
|
@@ -148,18 +150,39 @@ abathur opencode install # copies plugin assets into ~/.config/opencode/
|
|
|
148
150
|
# restart opencode
|
|
149
151
|
```
|
|
150
152
|
|
|
151
|
-
|
|
153
|
+
**Route B — config only (tool, no slash command).** Put the package name in
|
|
154
|
+
your opencode config and restart:
|
|
155
|
+
|
|
156
|
+
```jsonc
|
|
157
|
+
{ "plugin": ["@tachikomagundam/abathur"] }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
opencode then downloads the package from npm at startup into its own cache
|
|
161
|
+
(`~/.cache/opencode/packages/…`) and loads the package's `./server` export —
|
|
162
|
+
no file copying, no uninstall step (delete the line). Two honest limits: the
|
|
163
|
+
`abathur` **CLI** still must exist on `PATH` (or `ABATHUR_BIN`) because the
|
|
164
|
+
tool spawns it, so keep the `npm i -g` install; and slash commands are plain
|
|
165
|
+
markdown files that plugins cannot register upstream, so `/abathur` is Route A
|
|
166
|
+
only.
|
|
167
|
+
|
|
168
|
+
Either route gives the session:
|
|
152
169
|
|
|
153
170
|
- the agent tool **`abathur`** — spawns the `abathur` CLI argv-only (never a
|
|
154
|
-
shell), restricted to
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
171
|
+
shell), restricted to eight commands (`genome`, `run`, `status`, `bundle`,
|
|
172
|
+
`graft`, `self-eval`, `kernel`, `--help`), with a 120 s timeout and a 64 KB
|
|
173
|
+
output cap. The binary resolves from `PATH` unless `ABATHUR_BIN` overrides
|
|
174
|
+
it. Honest privilege note: the tool carries bash-equivalent privilege —
|
|
175
|
+
`run` and `genome` legitimately spawn mutator/engine binaries by design —
|
|
176
|
+
so the allowlist limits typos and UX, not capability. `promote` and
|
|
177
|
+
`tombstone` are deliberately NOT reachable through the tool: they are
|
|
178
|
+
human gates and must be run in a terminal.
|
|
179
|
+
- the user command **`/abathur <args…>`** (Route A only) — a slash command
|
|
180
|
+
that tells the agent to translate its arguments into a tool call and report
|
|
181
|
+
the exit code.
|
|
182
|
+
|
|
183
|
+
Route A ships its own manager commands: `abathur opencode status` shows each
|
|
184
|
+
target's path, installed/packaged sha256, and state (up-to-date / outdated /
|
|
185
|
+
foreign / absent).
|
|
163
186
|
`abathur opencode uninstall` removes only files carrying the abathur marker; a
|
|
164
187
|
foreign file at a target path makes both commands refuse with exit 2 and name
|
|
165
188
|
the path — nothing is ever overwritten or deleted behind your back (there is
|
|
@@ -404,8 +427,10 @@ abathur run --genome toy-smoke --dry-run # 计划 + requires[] 探针,零
|
|
|
404
427
|
|
|
405
428
|
### 在 opencode 里调用
|
|
406
429
|
|
|
407
|
-
Abathur 自带官方 opencode
|
|
408
|
-
会话也可以直接驱动 CLI
|
|
430
|
+
Abathur 自带官方 opencode 插件适配器。工装仍是编排者,但配置之后,opencode
|
|
431
|
+
会话也可以直接驱动 CLI。安装有两条路线:
|
|
432
|
+
|
|
433
|
+
**路线 A——一条命令(工具 + `/abathur` 斜杠命令)。**
|
|
409
434
|
|
|
410
435
|
```bash
|
|
411
436
|
npm i -g @tachikomagundam/abathur
|
|
@@ -413,16 +438,34 @@ abathur opencode install # 把插件资产复制进 ~/.config/opencode/
|
|
|
413
438
|
# 重启 opencode
|
|
414
439
|
```
|
|
415
440
|
|
|
416
|
-
|
|
441
|
+
**路线 B——纯配置(只有工具,没有斜杠命令)。** 把包名写进 opencode 配置再
|
|
442
|
+
重启:
|
|
417
443
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
444
|
+
```jsonc
|
|
445
|
+
{ "plugin": ["@tachikomagundam/abathur"] }
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
opencode 会在启动时自行从 npm 把包下载到它自己的缓存
|
|
449
|
+
(`~/.cache/opencode/packages/…`)并加载该包的 `./server` 导出——不复制文件,
|
|
450
|
+
也没有 uninstall 步骤(删掉那行即可)。两点诚实的限制:工具 spawn 的是
|
|
451
|
+
`abathur` **CLI**,所以 CLI 仍必须在 `PATH`(或 `ABATHUR_BIN`)上可达,
|
|
452
|
+
`npm i -g` 不能省;而斜杠命令在上游只是 markdown 文件、插件无法注册,
|
|
453
|
+
所以 `/abathur` 只有路线 A 提供。
|
|
424
454
|
|
|
425
|
-
|
|
455
|
+
任一路线都会让会话获得:
|
|
456
|
+
|
|
457
|
+
- 智能体工具 **`abathur`**——以纯 argv 方式 spawn `abathur` CLI(绝不经过
|
|
458
|
+
shell),顶层命令限定为八个(`genome`、`run`、`status`、`bundle`、`graft`、
|
|
459
|
+
`self-eval`、`kernel`、`--help`),120 秒超时、64 KB 输出封顶。二进制从
|
|
460
|
+
`PATH` 解析,除非用 `ABATHUR_BIN` 覆盖。诚实的权限说明:该工具具备与
|
|
461
|
+
bash 等同的权限——`run` 与 `genome` 按设计就会 spawn 变异器/引擎二进制——
|
|
462
|
+
所以允许清单限制的是笔误与体验,而非能力。`promote` 与 `tombstone` 刻意
|
|
463
|
+
不可经由工具触达:它们是人类闸门,必须在终端里运行。
|
|
464
|
+
- 用户命令 **`/abathur <参数…>`**(仅路线 A)——斜杠命令,指示智能体把参数
|
|
465
|
+
翻译成工具调用并回报退出码。
|
|
466
|
+
|
|
467
|
+
路线 A 自带管理命令:`abathur opencode status` 逐目标打印路径、已安装/随包
|
|
468
|
+
sha256 与状态
|
|
426
469
|
(up-to-date / outdated / foreign / absent)。
|
|
427
470
|
`abathur opencode uninstall` 只删除带有 abathur 标记的文件;若目标路径上躺着
|
|
428
471
|
别人的文件,两条命令都会 exit 2 并点名路径——绝不会背着你覆盖或删除任何东西
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
// {plugins,commands}/. No network, no shell, no opencode process contact.
|
|
6
6
|
// Identity rule: a target is ours only if its FIRST LINE carries our marker;
|
|
7
7
|
// a foreign file at a target path is refused (exit 2), never overwritten,
|
|
8
|
-
// never deleted — there is no --force anywhere.
|
|
8
|
+
// never deleted — there is no --force anywhere. Destination rule (0.2.1):
|
|
9
|
+
// a path that exists must be a plain regular file — directories and symlinks
|
|
10
|
+
// are refused via lstat, never followed, entered, or destroyed.
|
|
9
11
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
13
|
import os from "node:os";
|
|
12
14
|
import path from "node:path";
|
|
13
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -79,9 +81,24 @@ function refuseForeign(target) {
|
|
|
79
81
|
return cannotAnswer(`opencode: refusing to touch ${target.destination} — the file exists but is not ours ` +
|
|
80
82
|
`(its first line lacks the marker '${target.marker}')`, "resolve it manually: move the foreign file aside (or adopt the marker), then re-run 'abathur opencode install'");
|
|
81
83
|
}
|
|
84
|
+
/** Destination shape guard shared by install/uninstall (lstat: links are inspected, not followed). */
|
|
85
|
+
function requirePlainDestination(target) {
|
|
86
|
+
let shape;
|
|
87
|
+
try {
|
|
88
|
+
shape = lstatSync(target.destination);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return; // absent (or a not-yet-existing parent): the write path creates it fresh
|
|
92
|
+
}
|
|
93
|
+
if (!shape.isFile() || shape.isSymbolicLink()) {
|
|
94
|
+
cannotAnswer(`opencode: refusing to touch ${target.destination} — the path exists but is not a regular file ` +
|
|
95
|
+
`(directories and symlinks are never followed, entered, or destroyed)`, "resolve it manually: move the entry aside (or replace the symlink with a real file), then re-run 'abathur opencode install'");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
82
98
|
/** Atomicity rule shared by install/uninstall: validate EVERY target before writing to ANY. */
|
|
83
99
|
function requireNoForeign(targets) {
|
|
84
100
|
for (const target of targets) {
|
|
101
|
+
requirePlainDestination(target);
|
|
85
102
|
const current = readTarget(target);
|
|
86
103
|
if (current !== null && !carriesMarker(current, target.marker))
|
|
87
104
|
refuseForeign(target);
|
|
@@ -89,10 +106,15 @@ function requireNoForeign(targets) {
|
|
|
89
106
|
}
|
|
90
107
|
function opencodeInstall(env) {
|
|
91
108
|
const targets = pluginTargets(env);
|
|
109
|
+
// Read BOTH packaged assets up front: a missing second asset must fail before anything is written.
|
|
110
|
+
const prepared = targets.map((target) => ({ target, packaged: readPackagedAsset(target) }));
|
|
92
111
|
requireNoForeign(targets);
|
|
93
|
-
for (const target of
|
|
94
|
-
const packaged = readPackagedAsset(target);
|
|
112
|
+
for (const { target, packaged } of prepared) {
|
|
95
113
|
const { state } = classify(target, packaged);
|
|
114
|
+
// TOCTOU: identity is re-checked right before the write — a foreign file planted
|
|
115
|
+
// since validation is refused, never overwritten.
|
|
116
|
+
if (state === "foreign")
|
|
117
|
+
refuseForeign(target);
|
|
96
118
|
if (state === "up-to-date") {
|
|
97
119
|
writeStdout(`${target.destination}: up to date`);
|
|
98
120
|
continue;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { test } from "node:test";
|
|
@@ -98,6 +98,38 @@ test("install: foreign target without our marker ⇒ exit 2 naming the path, NOT
|
|
|
98
98
|
// Atomicity: the refusal must happen before ANY write, so the command target stays absent.
|
|
99
99
|
await assert.rejects(readFile(commandMd), (error) => error.code === "ENOENT");
|
|
100
100
|
});
|
|
101
|
+
test("install: a DIRECTORY at a target path is refused (exit 2, named) — never entered or destroyed", async (t) => {
|
|
102
|
+
const { home, env } = await makeEnv(t);
|
|
103
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
104
|
+
await mkdir(pluginTs, { recursive: true });
|
|
105
|
+
const inner = path.join(pluginTs, "someone-elses-plugin.ts");
|
|
106
|
+
await writeFile(inner, "// lives inside, must survive\n", "utf8");
|
|
107
|
+
const run = abathur(env, "opencode", "install");
|
|
108
|
+
assert.equal(run.status, 2, `${run.stdout}${run.stderr}`);
|
|
109
|
+
const combined = `${run.stdout}${run.stderr}`;
|
|
110
|
+
assert.ok(combined.includes(pluginTs), `refusal must name the path: ${combined}`);
|
|
111
|
+
assert.ok((await readFile(inner, "utf8")).includes("must survive"), "directory contents untouched");
|
|
112
|
+
// Atomicity still holds: validation precedes every write, so the sibling stays absent.
|
|
113
|
+
await assert.rejects(readFile(commandMd), (error) => error.code === "ENOENT");
|
|
114
|
+
});
|
|
115
|
+
test("install: a SYMLINK at a target path is refused (exit 2) — inspected by lstat, never followed", async (t) => {
|
|
116
|
+
const { home, env } = await makeEnv(t);
|
|
117
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
118
|
+
// The marker check alone would pass here: readFileSync follows the link, and the
|
|
119
|
+
// old write would then destroy the external file. lstat must refuse the link itself.
|
|
120
|
+
const real = path.join(home, "elsewhere", "real-file.ts");
|
|
121
|
+
await mkdir(path.dirname(real), { recursive: true });
|
|
122
|
+
const original = `${PLUGIN_MARKER} 0.0.1\n// real file outside the opencode tree\n`;
|
|
123
|
+
await writeFile(real, original, "utf8");
|
|
124
|
+
await mkdir(path.dirname(pluginTs), { recursive: true });
|
|
125
|
+
await symlink(real, pluginTs);
|
|
126
|
+
const run = abathur(env, "opencode", "install");
|
|
127
|
+
assert.equal(run.status, 2, `${run.stdout}${run.stderr}`);
|
|
128
|
+
assert.ok(`${run.stdout}${run.stderr}`.includes(pluginTs), `refusal must name the path: ${run.stdout}${run.stderr}`);
|
|
129
|
+
assert.equal(await readFile(real, "utf8"), original, "file behind the link must be untouched");
|
|
130
|
+
assert.equal((await lstat(pluginTs)).isSymbolicLink(), true, "the symlink itself must survive too");
|
|
131
|
+
await assert.rejects(readFile(commandMd), (error) => error.code === "ENOENT");
|
|
132
|
+
});
|
|
101
133
|
test("install: our marker'd older file is overwritten with packaged bytes", async (t) => {
|
|
102
134
|
const { home, env } = await makeEnv(t);
|
|
103
135
|
const { pluginTs } = targetPaths(home);
|
|
@@ -197,20 +229,14 @@ test("plugin/abathur.ts: V1 shape — marker, default {id, server}, tool allowli
|
|
|
197
229
|
assert.ok(bytes.includes('id: "abathur"'), "file plugins must export id (resolvePluginId)");
|
|
198
230
|
assert.ok(/server:\s*async\s*\(\)/.test(bytes), "server must be an async function (readV1Plugin)");
|
|
199
231
|
assert.ok(bytes.includes("tool.schema"), "args must be built via tool.schema (no local zod dep)");
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
"self-eval",
|
|
209
|
-
"kernel",
|
|
210
|
-
"--help",
|
|
211
|
-
]) {
|
|
212
|
-
assert.ok(bytes.includes(`"${command}"`), `allowlist must contain ${command}`);
|
|
213
|
-
}
|
|
232
|
+
// Exact-array pin (0.2.1): parse the ALLOWED_COMMANDS literal from source — membership
|
|
233
|
+
// checks alone would let promote/tombstone creep back in via order or extras.
|
|
234
|
+
const literal = bytes.match(/const ALLOWED_COMMANDS[^=]*=\s*\[([^\]]*)\]/);
|
|
235
|
+
assert.ok(literal !== null, "ALLOWED_COMMANDS array literal must exist");
|
|
236
|
+
const allowed = [...(literal[1] ?? "").matchAll(/"([^"]+)"/g)].map((found) => found[1] ?? "");
|
|
237
|
+
assert.deepEqual(allowed, ["genome", "run", "status", "bundle", "graft", "self-eval", "kernel", "--help"]);
|
|
238
|
+
assert.ok(!allowed.includes("promote"), "promote is terminal-only — must not be tool-reachable");
|
|
239
|
+
assert.ok(!allowed.includes("tombstone"), "tombstone is terminal-only — must not be tool-reachable");
|
|
214
240
|
assert.ok(bytes.includes("execFile"), "must spawn via execFile argv-only");
|
|
215
241
|
assert.ok(!/shell\s*:\s*true/.test(bytes), "shell:true is banned");
|
|
216
242
|
assert.ok(bytes.includes("ABATHUR_BIN"), "bin overridable via ABATHUR_BIN");
|
|
@@ -229,6 +255,26 @@ test("shipped plugin assets carry zero machine literals (D7 discipline extended
|
|
|
229
255
|
}
|
|
230
256
|
}
|
|
231
257
|
});
|
|
258
|
+
test("npm name route: exports['./server'] ships the marker'd plugin + runtime dep declared", async () => {
|
|
259
|
+
const pkg = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8"));
|
|
260
|
+
// opencode picks the server entry via exports["./server"] (extractExportValue
|
|
261
|
+
// accepts a plain string; plugin/shared.ts resolvePackageEntrypoint).
|
|
262
|
+
const serverEntry = pkg.exports?.["./server"];
|
|
263
|
+
assert.equal(typeof serverEntry, "string", "./server export must be a plain string path");
|
|
264
|
+
const server = serverEntry;
|
|
265
|
+
const target = path.resolve(repoRoot, server);
|
|
266
|
+
const head = (await readFile(target, "utf8")).split("\n")[0] ?? "";
|
|
267
|
+
assert.ok(head.startsWith(PLUGIN_MARKER), `server entry must start with the plugin marker: ${head}`);
|
|
268
|
+
// The tarball must actually contain that file or the name route installs nothing to import.
|
|
269
|
+
assert.ok((pkg.files ?? []).some((entry) => server.startsWith(`./${entry}/`)), "server entry must live under a published files[] directory");
|
|
270
|
+
// Arborist installs the package's dependencies next to it in opencode's cache —
|
|
271
|
+
// that sibling copy is how the shipped import of @opencode-ai/plugin resolves (OMO mechanism).
|
|
272
|
+
const dep = pkg.dependencies?.["@opencode-ai/plugin"];
|
|
273
|
+
assert.equal(typeof dep, "string", "@opencode-ai/plugin must be a runtime dependency");
|
|
274
|
+
assert.ok((dep ?? "").length > 0, "dependency range must not be empty");
|
|
275
|
+
// Route B must serve the same bytes the route A installer copies: both resolve to plugin/abathur.ts.
|
|
276
|
+
assert.equal(serverEntry, "./plugin/abathur.ts", "server entry and installer asset must be one file");
|
|
277
|
+
});
|
|
232
278
|
test("CLI help: opencode is a registered top-level command", async (t) => {
|
|
233
279
|
const { env } = await makeEnv(t);
|
|
234
280
|
const help = abathur(env, "--help");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tachikomagundam/abathur",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Evolution harness: observe failures, mutate, re-bench, select — human-gated promotion, offline lineage bundles.",
|
|
5
5
|
"author": "TachikomaGundam",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
"abathur": "dist/cli.js"
|
|
21
21
|
},
|
|
22
22
|
"main": "dist/cli.js",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./dist/cli.js",
|
|
25
|
+
"./server": "./plugin/abathur.ts",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
23
28
|
"files": [
|
|
24
29
|
"dist",
|
|
25
30
|
"config",
|
|
@@ -33,6 +38,7 @@
|
|
|
33
38
|
"prepack": "npm run build"
|
|
34
39
|
},
|
|
35
40
|
"dependencies": {
|
|
41
|
+
"@opencode-ai/plugin": "^1.17.4",
|
|
36
42
|
"zod": "^4.0.0"
|
|
37
43
|
},
|
|
38
44
|
"devDependencies": {
|
|
@@ -4,11 +4,12 @@ Drive the abathur evolution harness through the `abathur` tool on this machine.
|
|
|
4
4
|
User request: $ARGUMENTS
|
|
5
5
|
|
|
6
6
|
Interpret the request as one `abathur` CLI invocation: the first word is the
|
|
7
|
-
top-level command (`genome`, `run`, `status`, `
|
|
8
|
-
`
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
human gates
|
|
7
|
+
top-level command (`genome`, `run`, `status`, `bundle`, `graft`, `self-eval`,
|
|
8
|
+
`kernel`, or `--help`) and the rest are argv tokens. Call the `abathur` tool
|
|
9
|
+
with `command` set to the first word and `extra` set to the remaining tokens,
|
|
10
|
+
then report the CLI exit code (0 ok / 1 blocked decision / 2 cannot-answer)
|
|
11
|
+
and the relevant lines of its output. If no request was given, call the tool
|
|
12
|
+
with `command: "--help"` and summarize the command list. `promote` and
|
|
13
|
+
`tombstone` cannot be called through the tool at all — the tool refuses them.
|
|
14
|
+
They are human gates that belong to a terminal: if the user asks for one,
|
|
15
|
+
tell them to run `abathur promote …` / `abathur tombstone …` there.
|
package/plugin/abathur.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// abathur-opencode-plugin v0.2.
|
|
1
|
+
// abathur-opencode-plugin v0.2.2
|
|
2
2
|
// Official opencode plugin adapter for the abathur evolution harness.
|
|
3
3
|
// Registers ONE agent tool, `abathur`, that shells out to the abathur CLI —
|
|
4
4
|
// argv-only (node:child_process execFile, never a shell), top-level commands
|
|
@@ -8,10 +8,15 @@
|
|
|
8
8
|
// V1 plugin format (opencode >= 1.14): default export { id, server }, where
|
|
9
9
|
// server(input, options) resolves to Hooks; Hooks.tool is a
|
|
10
10
|
// { [name]: ToolDefinition } record (see @opencode-ai/plugin).
|
|
11
|
-
// This file is shipped as-is (package.json "files") and
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// This file is shipped as-is (package.json "files") and reaches a session by
|
|
12
|
+
// two routes, neither compiling it through abathur's tsc:
|
|
13
|
+
// (A) copied into ~/.config/opencode/plugins/ by `abathur opencode install`
|
|
14
|
+
// (adds the /abathur command too); "@opencode-ai/plugin" then resolves in
|
|
15
|
+
// opencode's config-directory node_modules.
|
|
16
|
+
// (B) served as the package's "./server" export when the npm package name is
|
|
17
|
+
// listed in opencode.jsonc "plugin" — opencode's arborist install places
|
|
18
|
+
// @opencode-ai/plugin (runtime dependency) next to the package in its
|
|
19
|
+
// cache, so the same import resolves there too.
|
|
15
20
|
|
|
16
21
|
import { execFile } from "node:child_process";
|
|
17
22
|
import { tool } from "@opencode-ai/plugin";
|
|
@@ -21,13 +26,12 @@ const TIMEOUT_MS = 120_000;
|
|
|
21
26
|
/** Per-stream output cap fed back into the session; keeps one tool call from flooding context. */
|
|
22
27
|
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
23
28
|
|
|
24
|
-
/** The
|
|
29
|
+
/** The seven tool-reachable top-level commands plus --help. Anything else is refused locally.
|
|
30
|
+
* promote/tombstone are human gates and deliberately terminal-only (0.2.1): not reachable here. */
|
|
25
31
|
const ALLOWED_COMMANDS: readonly string[] = [
|
|
26
32
|
"genome",
|
|
27
33
|
"run",
|
|
28
34
|
"status",
|
|
29
|
-
"promote",
|
|
30
|
-
"tombstone",
|
|
31
35
|
"bundle",
|
|
32
36
|
"graft",
|
|
33
37
|
"self-eval",
|
|
@@ -89,14 +93,18 @@ function runCli(bin: string, argv: readonly string[]): Promise<CliResult> {
|
|
|
89
93
|
}
|
|
90
94
|
if (typeof failure.code !== "number") {
|
|
91
95
|
const timedOut = failure.killed === true || (failure.signal !== undefined && failure.signal !== null);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
if (timedOut) {
|
|
97
|
+
let note = `killed after ${String(TIMEOUT_MS / 1000)}s timeout (signal: ${String(failure.signal ?? "SIGTERM")})`;
|
|
98
|
+
if (argv[0] === "run") {
|
|
99
|
+
// The timeout kills the CLI only; mutator/bench children are detached by design.
|
|
100
|
+
note +=
|
|
101
|
+
" — abathur run spawns detached children (mutator/bench sessions) " +
|
|
102
|
+
"that may still be running — the next 'abathur run' reaps them";
|
|
103
|
+
}
|
|
104
|
+
resolve({ status: 124, stdout: out, stderr: errOut, note });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
resolve({ status: -1, stdout: out, stderr: errOut, note: `spawn failed: ${failure.message}` });
|
|
100
108
|
return;
|
|
101
109
|
}
|
|
102
110
|
// Ordinary non-zero exit: the CLI's own verdict (blocked / cannot-answer).
|
|
@@ -114,11 +122,14 @@ export default {
|
|
|
114
122
|
description:
|
|
115
123
|
"Run the abathur evolution-harness CLI on this machine. " +
|
|
116
124
|
"Pass the top-level command word in `command` (one of: genome, run, status, " +
|
|
117
|
-
"
|
|
125
|
+
"bundle, graft, self-eval, kernel, --help) and every " +
|
|
118
126
|
"remaining argv token in `extra`. The call is spawned argv-only (no shell) " +
|
|
119
127
|
"with a 120s timeout; the result text always ends with the CLI exit code " +
|
|
120
|
-
"(0 ok, 1 blocked decision, 2 cannot-answer).
|
|
121
|
-
"
|
|
128
|
+
"(0 ok, 1 blocked decision, 2 cannot-answer). Honest privilege note: this " +
|
|
129
|
+
"tool carries bash-equivalent privilege — `run` and `genome` legitimately " +
|
|
130
|
+
"spawn mutator/engine binaries by design — so the allowlist limits typos " +
|
|
131
|
+
"and UX, not capability. `promote` and `tombstone` are deliberately NOT " +
|
|
132
|
+
"reachable here: they are human gates, run in a terminal.",
|
|
122
133
|
args: {
|
|
123
134
|
command: tool.schema
|
|
124
135
|
.string()
|