@andromarces/agent-loops 0.2.2 → 0.3.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/README.md +192 -114
- package/docs/orchestrator-instructions.md +25 -22
- package/package.json +2 -2
- package/src/agents/agy.mjs +2 -11
- package/src/agents/codex.mjs +3 -20
- package/src/agents/copilot.mjs +8 -16
- package/src/agents/opencode.mjs +2 -7
- package/src/agents/shared.mjs +29 -0
- package/src/cli.mjs +46 -25
- package/src/hook/antigravity-parent-guard.mjs +28 -0
- package/src/hook/copilot-parent-guard.mjs +5 -30
- package/src/hook/decision.mjs +59 -6
- package/src/hook/opencode-plugin.mjs +92 -0
- package/src/hook/parent-guard.mjs +8 -33
- package/src/install/commands.mjs +268 -0
- package/src/install/fsutil.mjs +159 -0
- package/src/install/harnesses.mjs +170 -0
- package/src/install/installer.mjs +688 -0
- package/src/install/manifest.mjs +222 -0
- package/src/install/settings.mjs +217 -0
- package/src/install/templates/antigravity/agent-loop-antigravity-parent-guard.mjs +14 -0
- package/src/install/templates/antigravity/hooks.json +16 -0
- package/src/install/templates/antigravity/skills/agent-loop/SKILL.md +24 -0
- package/src/install/templates/claude/skills/agent-loop/SKILL.md +33 -0
- package/src/install/templates/codex/skills/agent-loop/SKILL.md +30 -0
- package/src/install/templates/codex/skills/agent-loop/agents/openai.yaml +2 -0
- package/src/install/templates/copilot/hooks/parent-guard.json +15 -0
- package/src/install/templates/opencode/plugins/parent-guard.ts +11 -0
- package/src/lib/args.mjs +23 -0
- package/src/lib/hash.mjs +9 -0
- package/src/lib/log.mjs +18 -3
- package/src/lib/process-ancestry.mjs +104 -0
- package/src/lib/runstate.mjs +133 -38
- package/src/lib/snapshot.mjs +3 -7
- package/src/role.mjs +71 -87
- package/src/runtime.mjs +9 -9
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// CLI commands for `agent-loop install`, `agent-loop uninstall`, and
|
|
2
|
+
// `agent-loop harness-check` (#139). The installer is interactive when a TTY
|
|
3
|
+
// is present; `--harness` and `--yes` make it scriptable, and tests always pass
|
|
4
|
+
// `--harness`.
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { readArgValue } from "../lib/args.mjs";
|
|
8
|
+
import { logError, logInfo, logInfoFull, logWarn, setVerbose } from "../lib/log.mjs";
|
|
9
|
+
import { nearestHarness } from "../lib/process-ancestry.mjs";
|
|
10
|
+
import { HARNESS_META, HARNESS_ORDER, isHarness } from "./harnesses.mjs";
|
|
11
|
+
import { detectHarnesses, install, uninstall } from "./installer.mjs";
|
|
12
|
+
import { readManifest, resolveHome } from "./manifest.mjs";
|
|
13
|
+
|
|
14
|
+
const PACKAGE_ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
15
|
+
|
|
16
|
+
function parseHarnessList(value) {
|
|
17
|
+
const harnesses = [
|
|
18
|
+
...new Set(
|
|
19
|
+
value
|
|
20
|
+
.split(",")
|
|
21
|
+
.map((part) => part.trim())
|
|
22
|
+
.filter(Boolean),
|
|
23
|
+
),
|
|
24
|
+
];
|
|
25
|
+
for (const harness of harnesses) {
|
|
26
|
+
if (!isHarness(harness)) {
|
|
27
|
+
throw new Error(`Unknown harness: ${harness}. Choose from ${HARNESS_ORDER.join(", ")}.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return harnesses;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseFlags(argv) {
|
|
34
|
+
const options = { harnesses: null, yes: false, dryRun: false, help: false };
|
|
35
|
+
for (let i = 0; i < argv.length; i++) {
|
|
36
|
+
const arg = argv[i];
|
|
37
|
+
if (arg === "--harness") {
|
|
38
|
+
options.harnesses = parseHarnessList(readArgValue(argv, arg, ++i));
|
|
39
|
+
} else if (arg === "--yes" || arg === "-y") {
|
|
40
|
+
options.yes = true;
|
|
41
|
+
} else if (arg === "--dry-run") {
|
|
42
|
+
options.dryRun = true;
|
|
43
|
+
} else if (arg === "--verbose") {
|
|
44
|
+
options.verbose = true;
|
|
45
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
46
|
+
options.help = true;
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return options;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveSelection(token, discovered) {
|
|
55
|
+
if (/^\d+$/.test(token)) {
|
|
56
|
+
const index = Number(token) - 1;
|
|
57
|
+
const harness = discovered[index];
|
|
58
|
+
if (!harness) {
|
|
59
|
+
throw new Error(`No harness at position ${token}.`);
|
|
60
|
+
}
|
|
61
|
+
return harness;
|
|
62
|
+
}
|
|
63
|
+
return parseHarnessList(token)[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function selectHarnesses(discovered) {
|
|
67
|
+
const menu = HARNESS_ORDER.map((harness, index) => {
|
|
68
|
+
const marker = discovered.includes(harness) ? " [detected]" : "";
|
|
69
|
+
return `${index + 1}. ${HARNESS_META[harness].label} (${harness})${marker}`;
|
|
70
|
+
});
|
|
71
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
72
|
+
try {
|
|
73
|
+
const answer = (
|
|
74
|
+
await rl.question(
|
|
75
|
+
`\nSelect harnesses by number or name, comma-separated.\n${menu.join("\n")}\n` +
|
|
76
|
+
`Selection [${discovered.join(",") || "none"}]: `,
|
|
77
|
+
)
|
|
78
|
+
).trim();
|
|
79
|
+
if (answer === "") {
|
|
80
|
+
return discovered;
|
|
81
|
+
}
|
|
82
|
+
return [
|
|
83
|
+
...new Set(
|
|
84
|
+
answer
|
|
85
|
+
.split(",")
|
|
86
|
+
.map((part) => resolveSelection(part.trim(), discovered))
|
|
87
|
+
.filter(Boolean),
|
|
88
|
+
),
|
|
89
|
+
];
|
|
90
|
+
} finally {
|
|
91
|
+
rl.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function printReports(reports, dryRun) {
|
|
96
|
+
if (reports.length === 0) {
|
|
97
|
+
logInfo("Nothing to change.");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
for (const entry of reports) {
|
|
101
|
+
const prefix = dryRun ? "would " : "";
|
|
102
|
+
const detail = entry.detail ? ` (${entry.detail})` : "";
|
|
103
|
+
if (entry.action === "note") {
|
|
104
|
+
// Notes carry guidance past the 300-character log cap, so print them in
|
|
105
|
+
// full (#200).
|
|
106
|
+
logInfoFull(`${entry.harness}: ${entry.detail}`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
logInfo(`${entry.harness}: ${prefix}${entry.action} ${entry.path}${detail}`);
|
|
110
|
+
if (entry.snippet) {
|
|
111
|
+
console.log(entry.snippet);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function runInstallCommand(argv) {
|
|
117
|
+
let options;
|
|
118
|
+
try {
|
|
119
|
+
options = parseFlags(argv);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
logError(err.message);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (options.help) {
|
|
126
|
+
console.log(
|
|
127
|
+
[
|
|
128
|
+
"Usage: agent-loop install [--harness <list>] [--yes] [--dry-run]",
|
|
129
|
+
"",
|
|
130
|
+
`Harnesses: ${HARNESS_ORDER.join(", ")}`,
|
|
131
|
+
"Without --harness, detected CLIs are preselected in an interactive prompt.",
|
|
132
|
+
].join("\n"),
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
setVerbose(Boolean(options.verbose));
|
|
137
|
+
|
|
138
|
+
let harnesses = options.harnesses;
|
|
139
|
+
if (!harnesses) {
|
|
140
|
+
if (options.yes || !process.stdin.isTTY) {
|
|
141
|
+
logError("No --harness given and stdin is not interactive. Pass --harness <list>.");
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
harnesses = await selectHarnesses(await detectHarnesses());
|
|
146
|
+
}
|
|
147
|
+
if (!harnesses.length) {
|
|
148
|
+
logWarn("No harness selected; nothing to do.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let reports;
|
|
153
|
+
try {
|
|
154
|
+
reports = await install({
|
|
155
|
+
harnesses,
|
|
156
|
+
home: resolveHome(),
|
|
157
|
+
packageRoot: PACKAGE_ROOT,
|
|
158
|
+
dryRun: options.dryRun,
|
|
159
|
+
});
|
|
160
|
+
} catch (err) {
|
|
161
|
+
logError(err.path ? `${err.path}: ${err.message}` : err.message);
|
|
162
|
+
if (err.snippet) {
|
|
163
|
+
console.log(err.snippet);
|
|
164
|
+
}
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
printReports(reports, options.dryRun);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function runUninstallCommand(argv) {
|
|
172
|
+
let options;
|
|
173
|
+
try {
|
|
174
|
+
options = parseFlags(argv);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
logError(err.message);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (options.help) {
|
|
181
|
+
console.log(
|
|
182
|
+
[
|
|
183
|
+
"Usage: agent-loop uninstall [--harness <list>] [--yes] [--dry-run]",
|
|
184
|
+
"",
|
|
185
|
+
"Without --harness, the harnesses recorded in the install manifest are removed.",
|
|
186
|
+
].join("\n"),
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
setVerbose(Boolean(options.verbose));
|
|
191
|
+
|
|
192
|
+
let harnesses = options.harnesses;
|
|
193
|
+
if (!harnesses && !options.yes && process.stdin.isTTY) {
|
|
194
|
+
let manifest;
|
|
195
|
+
try {
|
|
196
|
+
manifest = await readManifest(resolveHome());
|
|
197
|
+
} catch (err) {
|
|
198
|
+
logError(err.message);
|
|
199
|
+
process.exitCode = 1;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const installed = HARNESS_ORDER.filter((harness) => manifest.harnesses[harness]);
|
|
203
|
+
if (installed.length === 0) {
|
|
204
|
+
logInfo("No harness is installed; nothing to remove.");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
harnesses = await selectHarnesses(installed);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let reports;
|
|
211
|
+
try {
|
|
212
|
+
reports = await uninstall({ harnesses, home: resolveHome(), dryRun: options.dryRun });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
logError(err.message);
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
printReports(reports, options.dryRun);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const HARNESS_ALIASES = new Map([["agy", "antigravity"]]);
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Exit code for a harness mismatch: the named harness is not the nearest
|
|
225
|
+
* ancestor, so another harness owns the shell. Exit 0 means the names match;
|
|
226
|
+
* every other non-zero code means the check could not run, found no harness
|
|
227
|
+
* ancestor, or could not read the process ancestry. The distinct code lets a
|
|
228
|
+
* caller separate a genuine harness refusal from a check that did not decide
|
|
229
|
+
* (#151).
|
|
230
|
+
*/
|
|
231
|
+
export const HARNESS_MISMATCH_EXIT = 3;
|
|
232
|
+
|
|
233
|
+
export async function runHarnessCheckCommand(argv, { lookup = nearestHarness } = {}) {
|
|
234
|
+
const requested = argv[0];
|
|
235
|
+
if (!requested) {
|
|
236
|
+
logError("harness-check requires a harness name.");
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const harness = HARNESS_ALIASES.get(requested) ?? requested;
|
|
241
|
+
if (!isHarness(harness)) {
|
|
242
|
+
logError(`Unknown harness: ${requested}`);
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let found;
|
|
248
|
+
try {
|
|
249
|
+
found = await lookup();
|
|
250
|
+
} catch (err) {
|
|
251
|
+
logError(`could not read the process table: ${err.message}`);
|
|
252
|
+
process.exitCode = 1;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (found === harness) {
|
|
256
|
+
process.exitCode = 0;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (found === null || found === undefined) {
|
|
260
|
+
logError(
|
|
261
|
+
`no known harness process was found above this shell, so not ${harness}. Not starting a run.`,
|
|
262
|
+
);
|
|
263
|
+
process.exitCode = 1;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
logError(`nearest harness ancestor is ${found}, not ${harness}. Refusing to start a run.`);
|
|
267
|
+
process.exitCode = HARNESS_MISMATCH_EXIT;
|
|
268
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Filesystem and comparison helpers for the user-scope installer (#139).
|
|
2
|
+
// Every write is atomic (temp file plus rename) and every hash is SHA-256 over
|
|
3
|
+
// the UTF-8 bytes, so the manifest can prove equality with what it last wrote.
|
|
4
|
+
import {
|
|
5
|
+
access,
|
|
6
|
+
chmod,
|
|
7
|
+
mkdir,
|
|
8
|
+
readFile,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
rmdir,
|
|
12
|
+
stat,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from "node:fs/promises";
|
|
15
|
+
import { dirname, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
export { sha256 } from "../lib/hash.mjs";
|
|
18
|
+
|
|
19
|
+
/** Reads a file as UTF-8; returns null when the path does not exist. */
|
|
20
|
+
export async function readTextOrNull(path) {
|
|
21
|
+
try {
|
|
22
|
+
return await readFile(path, "utf8");
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err.code === "ENOENT") {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function pathExists(path) {
|
|
32
|
+
try {
|
|
33
|
+
await access(path);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The permission bits of a file, or null when it does not exist. */
|
|
41
|
+
export async function fileMode(path) {
|
|
42
|
+
try {
|
|
43
|
+
return (await stat(path)).mode & 0o777;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.code === "ENOENT") {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Writes through a temp file in the same directory, then renames. Preserves the
|
|
54
|
+
* destination's permission bits unless `mode` is given, so a private settings
|
|
55
|
+
* file stays private after install, backup, and restore. The temp file is
|
|
56
|
+
* created with that mode, so it is never briefly world-readable.
|
|
57
|
+
*/
|
|
58
|
+
export async function writeTextAtomic(path, text, { mode } = {}) {
|
|
59
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
60
|
+
const targetMode = mode ?? (await fileMode(path));
|
|
61
|
+
await writeFile(temp, text, { encoding: "utf8", mode: targetMode ?? 0o666 });
|
|
62
|
+
if (targetMode !== null && process.platform !== "win32") {
|
|
63
|
+
await chmod(temp, targetMode);
|
|
64
|
+
}
|
|
65
|
+
await rename(temp, path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function removeFileQuiet(path) {
|
|
69
|
+
await rm(path, { force: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Removes an empty directory. A missing directory and a non-empty directory
|
|
74
|
+
* throw nothing, including the EEXIST that some platforms report for a
|
|
75
|
+
* non-empty directory. Every other error, such as EPERM, EACCES, or EBUSY,
|
|
76
|
+
* reaches the caller so it can report the failure and retry later.
|
|
77
|
+
* @returns {Promise<boolean>} true when this call removed the directory
|
|
78
|
+
*/
|
|
79
|
+
export async function removeDirQuiet(path) {
|
|
80
|
+
try {
|
|
81
|
+
await rmdir(path);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err.code === "ENOENT" || err.code === "ENOTEMPTY" || err.code === "EEXIST") {
|
|
85
|
+
// A non-empty or already-removed directory needs no action.
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Creates `dir` and every missing ancestor, recording each directory that did
|
|
94
|
+
* not exist before so uninstall can prune exactly what install created.
|
|
95
|
+
* @param {string} dir
|
|
96
|
+
* @param {Set<string>} created receives every newly created directory
|
|
97
|
+
*/
|
|
98
|
+
export async function ensureDir(dir, created) {
|
|
99
|
+
const missing = [];
|
|
100
|
+
let current = resolve(dir);
|
|
101
|
+
while (!(await pathExists(current))) {
|
|
102
|
+
missing.push(current);
|
|
103
|
+
const parent = dirname(current);
|
|
104
|
+
if (parent === current) {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
current = parent;
|
|
108
|
+
}
|
|
109
|
+
if (missing.length === 0) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await mkdir(dir, { recursive: true });
|
|
113
|
+
// Record shallow to deep so uninstall can remove deepest first.
|
|
114
|
+
for (const path of missing.reverse()) {
|
|
115
|
+
created.add(path);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Removes empty directories, deepest first. Missing and non-empty entries are
|
|
121
|
+
* ignored; any other error propagates to the caller.
|
|
122
|
+
* @returns {Promise<string[]>} the directories this call removed
|
|
123
|
+
*/
|
|
124
|
+
export async function pruneEmptyDirs(dirs) {
|
|
125
|
+
const ordered = [...new Set(dirs)].sort((a, b) => b.length - a.length);
|
|
126
|
+
const removed = [];
|
|
127
|
+
for (const dir of ordered) {
|
|
128
|
+
if (await removeDirQuiet(dir)) {
|
|
129
|
+
removed.push(dir);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return removed;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Structural equality for JSON-shaped values. Object key order is irrelevant. */
|
|
136
|
+
export function deepEqual(a, b) {
|
|
137
|
+
if (a === b) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
141
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return a.every((value, index) => deepEqual(value, b[index]));
|
|
145
|
+
}
|
|
146
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const keysA = Object.keys(a);
|
|
150
|
+
const keysB = Object.keys(b);
|
|
151
|
+
if (keysA.length !== keysB.length) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
return keysA.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function backupPathFor(path) {
|
|
158
|
+
return `${path}.agent-loops-backup`;
|
|
159
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Harness registry for the installer (#139). Each harness declares the files it
|
|
2
|
+
// creates and the one settings entry it owns, resolved against the installed
|
|
3
|
+
// package, so `src/install/templates/` stays the only source of every entry
|
|
4
|
+
// point and guard.
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
|
|
9
|
+
export const HARNESS_ORDER = ["claude", "codex", "opencode", "copilot", "antigravity"];
|
|
10
|
+
|
|
11
|
+
export const HARNESS_META = {
|
|
12
|
+
claude: { label: "Claude Code", commands: ["claude"] },
|
|
13
|
+
codex: { label: "Codex CLI", commands: ["codex"] },
|
|
14
|
+
opencode: { label: "OpenCode", commands: ["opencode"] },
|
|
15
|
+
copilot: { label: "GitHub Copilot CLI", commands: ["copilot"] },
|
|
16
|
+
antigravity: { label: "Antigravity CLI", commands: ["agy", "antigravity"] },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function isHarness(value) {
|
|
20
|
+
return Object.hasOwn(HARNESS_META, value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function forwardSlashes(path) {
|
|
24
|
+
return path.replaceAll("\\", "/");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function template(templateDir, ...segments) {
|
|
28
|
+
return readFile(join(templateDir, ...segments), "utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function claudeEntry(guardPath) {
|
|
32
|
+
return {
|
|
33
|
+
matcher: "Edit|Write|MultiEdit|NotebookEdit",
|
|
34
|
+
hooks: [{ type: "command", command: `node "${guardPath}"`, timeout: 10 }],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function codexEntry(guardPath) {
|
|
39
|
+
return {
|
|
40
|
+
matcher: "^apply_patch$",
|
|
41
|
+
hooks: [
|
|
42
|
+
{
|
|
43
|
+
type: "command",
|
|
44
|
+
command: `node "${guardPath}"`,
|
|
45
|
+
timeout: 10,
|
|
46
|
+
statusMessage: "Checking parent orchestration guard",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Builds the full target list for one harness.
|
|
54
|
+
* @param {string} harness
|
|
55
|
+
* @param {{ home: string, packageRoot: string, copilotHome?: string }} options
|
|
56
|
+
* @returns {Promise<{ files: Array<{path: string, content: string}>, settings: Array<{path: string, locator: object, entry: object}> }>}
|
|
57
|
+
*/
|
|
58
|
+
export async function buildTargets(harness, { home, packageRoot, copilotHome }) {
|
|
59
|
+
const templateDir = join(packageRoot, "src/install/templates");
|
|
60
|
+
const instructions = join(packageRoot, "docs/orchestrator-instructions.md");
|
|
61
|
+
const guardPath = join(packageRoot, "src/hook/parent-guard.mjs");
|
|
62
|
+
// The absolute invocation works from Git Bash and PowerShell alike, where the
|
|
63
|
+
// bare `agent-loop` command is absent or resolves only to a Windows `.cmd`
|
|
64
|
+
// shim. Bash does not resolve that shim, so a PATH lookup would report a
|
|
65
|
+
// missing command and the skill would misread it as a harness refusal (#151).
|
|
66
|
+
const cli = `node "${forwardSlashes(join(packageRoot, "src/cli.mjs"))}"`;
|
|
67
|
+
const files = [];
|
|
68
|
+
const settings = [];
|
|
69
|
+
|
|
70
|
+
const renderSkill = (text) =>
|
|
71
|
+
text
|
|
72
|
+
.replaceAll("{{AGENT_LOOP_INSTRUCTIONS}}", forwardSlashes(instructions))
|
|
73
|
+
.replaceAll("__AGENT_LOOP_CLI__", cli);
|
|
74
|
+
|
|
75
|
+
if (harness === "claude") {
|
|
76
|
+
const skill = await template(templateDir, "claude", "skills", "agent-loop", "SKILL.md");
|
|
77
|
+
files.push({
|
|
78
|
+
path: join(home, ".claude", "skills", "agent-loop", "SKILL.md"),
|
|
79
|
+
content: renderSkill(skill),
|
|
80
|
+
});
|
|
81
|
+
const entry = claudeEntry(guardPath);
|
|
82
|
+
settings.push({
|
|
83
|
+
path: join(home, ".claude", "settings.json"),
|
|
84
|
+
locator: {
|
|
85
|
+
kind: "array",
|
|
86
|
+
path: ["hooks", "PreToolUse"],
|
|
87
|
+
matcher: entry.matcher,
|
|
88
|
+
},
|
|
89
|
+
entry,
|
|
90
|
+
});
|
|
91
|
+
} else if (harness === "codex") {
|
|
92
|
+
const skill = await template(templateDir, "codex", "skills", "agent-loop", "SKILL.md");
|
|
93
|
+
files.push({
|
|
94
|
+
path: join(home, ".agents", "skills", "agent-loop", "SKILL.md"),
|
|
95
|
+
content: renderSkill(skill),
|
|
96
|
+
});
|
|
97
|
+
files.push({
|
|
98
|
+
path: join(home, ".agents", "skills", "agent-loop", "agents", "openai.yaml"),
|
|
99
|
+
content: await template(
|
|
100
|
+
templateDir,
|
|
101
|
+
"codex",
|
|
102
|
+
"skills",
|
|
103
|
+
"agent-loop",
|
|
104
|
+
"agents",
|
|
105
|
+
"openai.yaml",
|
|
106
|
+
),
|
|
107
|
+
});
|
|
108
|
+
const entry = codexEntry(guardPath);
|
|
109
|
+
settings.push({
|
|
110
|
+
path: join(home, ".codex", "hooks.json"),
|
|
111
|
+
locator: {
|
|
112
|
+
kind: "array",
|
|
113
|
+
path: ["hooks", "PreToolUse"],
|
|
114
|
+
matcher: entry.matcher,
|
|
115
|
+
},
|
|
116
|
+
entry,
|
|
117
|
+
});
|
|
118
|
+
} else if (harness === "opencode") {
|
|
119
|
+
const plugin = await template(templateDir, "opencode", "plugins", "parent-guard.ts");
|
|
120
|
+
files.push({
|
|
121
|
+
path: join(home, ".config", "opencode", "plugins", "parent-guard.ts"),
|
|
122
|
+
content: renderSkill(plugin)
|
|
123
|
+
.replaceAll(
|
|
124
|
+
"__AGENT_LOOP_PLUGIN_URL__",
|
|
125
|
+
pathToFileURL(join(packageRoot, "src/hook/opencode-plugin.mjs")).href,
|
|
126
|
+
)
|
|
127
|
+
.replaceAll(
|
|
128
|
+
"__AGENT_LOOP_GUARD_URL__",
|
|
129
|
+
pathToFileURL(join(packageRoot, "src/hook/decision.mjs")).href,
|
|
130
|
+
),
|
|
131
|
+
});
|
|
132
|
+
} else if (harness === "copilot") {
|
|
133
|
+
const hook = JSON.parse(await template(templateDir, "copilot", "hooks", "parent-guard.json"));
|
|
134
|
+
// Copilot consumes a flat permissionDecision object, so it runs its own
|
|
135
|
+
// guard script rather than the Claude/Codex hookSpecificOutput shape (#155).
|
|
136
|
+
hook.hooks.PreToolUse[0].args = [join(packageRoot, "src/hook/copilot-parent-guard.mjs")];
|
|
137
|
+
files.push({
|
|
138
|
+
path: join(copilotHome ?? join(home, ".copilot"), "hooks", "parent-guard.json"),
|
|
139
|
+
content: `${JSON.stringify(hook, null, 2)}\n`,
|
|
140
|
+
});
|
|
141
|
+
} else if (harness === "antigravity") {
|
|
142
|
+
const skill = await template(templateDir, "antigravity", "skills", "agent-loop", "SKILL.md");
|
|
143
|
+
files.push({
|
|
144
|
+
path: join(home, ".gemini", "antigravity-cli", "skills", "agent-loop", "SKILL.md"),
|
|
145
|
+
content: renderSkill(skill),
|
|
146
|
+
});
|
|
147
|
+
const shim = await template(
|
|
148
|
+
templateDir,
|
|
149
|
+
"antigravity",
|
|
150
|
+
"agent-loop-antigravity-parent-guard.mjs",
|
|
151
|
+
);
|
|
152
|
+
files.push({
|
|
153
|
+
path: join(home, ".gemini", "config", "agent-loop-antigravity-parent-guard.mjs"),
|
|
154
|
+
content: shim.replaceAll(
|
|
155
|
+
"__AGENT_LOOP_GUARD_URL__",
|
|
156
|
+
pathToFileURL(join(packageRoot, "src/hook/antigravity-parent-guard.mjs")).href,
|
|
157
|
+
),
|
|
158
|
+
});
|
|
159
|
+
const group = JSON.parse(await template(templateDir, "antigravity", "hooks.json"));
|
|
160
|
+
settings.push({
|
|
161
|
+
path: join(home, ".gemini", "config", "hooks.json"),
|
|
162
|
+
locator: { kind: "key", key: "agent-loop-parent-guard" },
|
|
163
|
+
entry: group["agent-loop-parent-guard"],
|
|
164
|
+
});
|
|
165
|
+
} else {
|
|
166
|
+
throw new Error(`Unsupported harness: ${harness}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { files, settings };
|
|
170
|
+
}
|