@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.
Files changed (36) hide show
  1. package/README.md +192 -114
  2. package/docs/orchestrator-instructions.md +25 -22
  3. package/package.json +2 -2
  4. package/src/agents/agy.mjs +2 -11
  5. package/src/agents/codex.mjs +3 -20
  6. package/src/agents/copilot.mjs +8 -16
  7. package/src/agents/opencode.mjs +2 -7
  8. package/src/agents/shared.mjs +29 -0
  9. package/src/cli.mjs +46 -25
  10. package/src/hook/antigravity-parent-guard.mjs +28 -0
  11. package/src/hook/copilot-parent-guard.mjs +5 -30
  12. package/src/hook/decision.mjs +59 -6
  13. package/src/hook/opencode-plugin.mjs +92 -0
  14. package/src/hook/parent-guard.mjs +8 -33
  15. package/src/install/commands.mjs +268 -0
  16. package/src/install/fsutil.mjs +159 -0
  17. package/src/install/harnesses.mjs +170 -0
  18. package/src/install/installer.mjs +688 -0
  19. package/src/install/manifest.mjs +222 -0
  20. package/src/install/settings.mjs +217 -0
  21. package/src/install/templates/antigravity/agent-loop-antigravity-parent-guard.mjs +14 -0
  22. package/src/install/templates/antigravity/hooks.json +16 -0
  23. package/src/install/templates/antigravity/skills/agent-loop/SKILL.md +24 -0
  24. package/src/install/templates/claude/skills/agent-loop/SKILL.md +33 -0
  25. package/src/install/templates/codex/skills/agent-loop/SKILL.md +30 -0
  26. package/src/install/templates/codex/skills/agent-loop/agents/openai.yaml +2 -0
  27. package/src/install/templates/copilot/hooks/parent-guard.json +15 -0
  28. package/src/install/templates/opencode/plugins/parent-guard.ts +11 -0
  29. package/src/lib/args.mjs +23 -0
  30. package/src/lib/hash.mjs +9 -0
  31. package/src/lib/log.mjs +18 -3
  32. package/src/lib/process-ancestry.mjs +104 -0
  33. package/src/lib/runstate.mjs +133 -38
  34. package/src/lib/snapshot.mjs +3 -7
  35. package/src/role.mjs +71 -87
  36. package/src/runtime.mjs +9 -9
@@ -0,0 +1,222 @@
1
+ // The install manifest (#139). One record per touched target, at
2
+ // `<home>/.agent-loops/install.json`, so `agent-loop uninstall` can restore the
3
+ // pre-install bytes and remove only entries it inserted. The baseline recorded
4
+ // by the first install is immutable: later installs carry it forward untouched
5
+ // and only uninstall consumes it.
6
+ import { createHash } from "node:crypto";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import {
10
+ ensureDir,
11
+ readTextOrNull,
12
+ removeDirQuiet,
13
+ removeFileQuiet,
14
+ writeTextAtomic,
15
+ } from "./fsutil.mjs";
16
+
17
+ export const MANIFEST_VERSION = 1;
18
+
19
+ /**
20
+ * The install home. `AGENT_LOOP_HOME` overrides it so tests never touch the
21
+ * developer's real home directory.
22
+ */
23
+ export function resolveHome() {
24
+ const override = process.env.AGENT_LOOP_HOME;
25
+ return override ? resolve(override) : homedir();
26
+ }
27
+
28
+ export function installRoot(home = resolveHome()) {
29
+ return join(home, ".agent-loops");
30
+ }
31
+
32
+ export function manifestPath(home = resolveHome()) {
33
+ return join(installRoot(home), "install.json");
34
+ }
35
+
36
+ /**
37
+ * The lock file that serializes `install` and `uninstall` for one install home.
38
+ * It lives under the OS temp root, not under the home, so `removeManifest` can
39
+ * delete `<home>/.agent-loops` while the lock is held. The key is a hash of the
40
+ * canonical home: the resolved path, lowercased on Windows, where paths compare
41
+ * case-insensitively, so equivalent spellings share one lock. The temp root is
42
+ * `tmpdir()`: two processes with different `TMPDIR`/`TEMP` values compute
43
+ * different lock paths and do not contend, so callers that must serialize
44
+ * across a sandbox must share one temp root.
45
+ */
46
+ export function manifestLockFile(home = resolveHome()) {
47
+ const resolved = resolve(home);
48
+ const canonical = process.platform === "win32" ? resolved.toLowerCase() : resolved;
49
+ const key = createHash("sha256").update(canonical).digest("hex").slice(0, 12);
50
+ return join(tmpdir(), "agent-loops", `install-${key}.lock`);
51
+ }
52
+
53
+ export function emptyManifest() {
54
+ return { version: MANIFEST_VERSION, harnesses: {} };
55
+ }
56
+
57
+ /** True for a JSON object, which excludes null and an array. */
58
+ function isJsonObject(value) {
59
+ return value !== null && typeof value === "object" && !Array.isArray(value);
60
+ }
61
+
62
+ /** True for a settings locator, so a record's `locator` cannot crash the merge. */
63
+ function isLocator(value) {
64
+ if (!isJsonObject(value)) {
65
+ return false;
66
+ }
67
+ if (value.kind === "key") {
68
+ return typeof value.key === "string";
69
+ }
70
+ return (
71
+ value.kind === "array" &&
72
+ Array.isArray(value.path) &&
73
+ value.path.length > 0 &&
74
+ value.path.every((key) => typeof key === "string")
75
+ );
76
+ }
77
+
78
+ /** True for a value a record holds as a hash or a backup path. */
79
+ function isStringOrNull(value) {
80
+ return value === null || typeof value === "string";
81
+ }
82
+
83
+ /** The error for a record whose named field cannot be acted on. */
84
+ function unexpectedHarnessRecord(harness, detail, path) {
85
+ return new Error(
86
+ `Install manifest has an unexpected record for harness "${harness}" (${detail}): ${path}`,
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Rejects a `files` entry install or uninstall cannot act on. Uninstall reads
92
+ * `shaAfter`, `existedBefore`, and `backupPath` before any write, so a wrong
93
+ * type there would leave a partial uninstall.
94
+ */
95
+ function assertFileRecord(harness, entry, where, path) {
96
+ if (!isJsonObject(entry)) {
97
+ throw unexpectedHarnessRecord(harness, where, path);
98
+ }
99
+ if (typeof entry.path !== "string") {
100
+ throw unexpectedHarnessRecord(harness, `${where}.path`, path);
101
+ }
102
+ if (typeof entry.shaAfter !== "string") {
103
+ throw unexpectedHarnessRecord(harness, `${where}.shaAfter`, path);
104
+ }
105
+ if (!isStringOrNull(entry.shaBefore)) {
106
+ throw unexpectedHarnessRecord(harness, `${where}.shaBefore`, path);
107
+ }
108
+ if (typeof entry.existedBefore !== "boolean") {
109
+ throw unexpectedHarnessRecord(harness, `${where}.existedBefore`, path);
110
+ }
111
+ if (!isStringOrNull(entry.backupPath)) {
112
+ throw unexpectedHarnessRecord(harness, `${where}.backupPath`, path);
113
+ }
114
+ }
115
+
116
+ /** Rejects a `settings` entry, which carries the file fields plus its merge metadata. */
117
+ function assertSettingsRecord(harness, entry, where, path) {
118
+ assertFileRecord(harness, entry, where, path);
119
+ if (!isLocator(entry.locator)) {
120
+ throw unexpectedHarnessRecord(harness, `${where}.locator`, path);
121
+ }
122
+ if (!Object.hasOwn(entry, "entry")) {
123
+ throw unexpectedHarnessRecord(harness, `${where}.entry`, path);
124
+ }
125
+ if (typeof entry.userEdited !== "boolean") {
126
+ throw unexpectedHarnessRecord(harness, `${where}.userEdited`, path);
127
+ }
128
+ if (!Number.isInteger(entry.createdFrom)) {
129
+ throw unexpectedHarnessRecord(harness, `${where}.createdFrom`, path);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Rejects a harness record that install or uninstall cannot act on, naming the
135
+ * first bad field. `files` and `settings` may be absent, but when present every
136
+ * entry must carry the fields uninstall reads; `dirs` may be absent, but when
137
+ * present it must be an array of strings. Every field is checked before any
138
+ * caller writes, so a corrupt field cannot leave a partial install or uninstall.
139
+ */
140
+ function assertHarnessRecord(harness, record, path) {
141
+ if (!isJsonObject(record)) {
142
+ throw unexpectedHarnessRecord(harness, "record", path);
143
+ }
144
+ if (record.files !== undefined) {
145
+ if (!Array.isArray(record.files)) {
146
+ throw unexpectedHarnessRecord(harness, "files", path);
147
+ }
148
+ record.files.forEach((entry, index) =>
149
+ assertFileRecord(harness, entry, `files[${index}]`, path),
150
+ );
151
+ }
152
+ if (record.settings !== undefined) {
153
+ if (!Array.isArray(record.settings)) {
154
+ throw unexpectedHarnessRecord(harness, "settings", path);
155
+ }
156
+ record.settings.forEach((entry, index) =>
157
+ assertSettingsRecord(harness, entry, `settings[${index}]`, path),
158
+ );
159
+ }
160
+ if (record.dirs !== undefined) {
161
+ if (!Array.isArray(record.dirs)) {
162
+ throw unexpectedHarnessRecord(harness, "dirs", path);
163
+ }
164
+ record.dirs.forEach((dir, index) => {
165
+ if (typeof dir !== "string") {
166
+ throw unexpectedHarnessRecord(harness, `dirs[${index}]`, path);
167
+ }
168
+ });
169
+ }
170
+ }
171
+
172
+ export async function readManifest(home = resolveHome()) {
173
+ const path = manifestPath(home);
174
+ const text = await readTextOrNull(path);
175
+ if (text === null) {
176
+ return emptyManifest();
177
+ }
178
+ let value;
179
+ try {
180
+ value = JSON.parse(text);
181
+ } catch {
182
+ throw new Error(`Install manifest is not valid JSON: ${path}`);
183
+ }
184
+ if (!isJsonObject(value)) {
185
+ throw new Error(`Install manifest has an unexpected shape: ${path}`);
186
+ }
187
+ if (value.version !== MANIFEST_VERSION) {
188
+ throw new Error(
189
+ `Install manifest has an unsupported version (${JSON.stringify(value.version)}): ${path}`,
190
+ );
191
+ }
192
+ if (!isJsonObject(value.harnesses)) {
193
+ throw new Error(`Install manifest has an unexpected shape: ${path}`);
194
+ }
195
+ for (const [harness, record] of Object.entries(value.harnesses)) {
196
+ assertHarnessRecord(harness, record, path);
197
+ }
198
+ return { version: MANIFEST_VERSION, harnesses: value.harnesses };
199
+ }
200
+
201
+ export async function writeManifest(home, manifest) {
202
+ const path = manifestPath(home);
203
+ await ensureDir(dirname(path), new Set());
204
+ await writeTextAtomic(path, `${JSON.stringify(manifest, null, 2)}\n`);
205
+ }
206
+
207
+ /** Deletes the manifest and its now-empty directory once no harness is recorded. */
208
+ export async function removeManifest(home) {
209
+ const root = installRoot(home);
210
+ await removeFileQuiet(manifestPath(home));
211
+ try {
212
+ await removeDirQuiet(root);
213
+ } catch (err) {
214
+ // The manifest is already gone, so no later uninstall can retry this
215
+ // directory. Name it and ask for a manual cleanup instead of hiding it.
216
+ throw new Error(
217
+ `Install directory ${root} could not be removed (${err.code}). ` +
218
+ "The manifest was already deleted; remove the directory manually.",
219
+ { cause: err },
220
+ );
221
+ }
222
+ }
@@ -0,0 +1,217 @@
1
+ // Settings-file merge for the installer (#139). A settings target records where
2
+ // its one entry lives with a locator:
3
+ // - `{ kind: "array", path: ["hooks", "PreToolUse"], matcher, matcherKey }`
4
+ // inserts one element into the array at `path` (for example a Claude or
5
+ // Codex hook entry).
6
+ // - `{ kind: "key", key }` sets one top-level key (the Antigravity named hook
7
+ // group in `~/.gemini/config/hooks.json`).
8
+ // The visible entry is the only ownership key: find and remove compare the
9
+ // recorded entry by deep equality, so a package move or an upgrade that changes
10
+ // a path still finds the old record.
11
+ import { deepEqual } from "./fsutil.mjs";
12
+
13
+ export function parseSettings(text, path) {
14
+ let value;
15
+ try {
16
+ value = JSON.parse(text);
17
+ } catch (err) {
18
+ throw new Error(`Settings file does not parse: ${path} (${err.message})`);
19
+ }
20
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
21
+ throw new Error(`Settings file is not a JSON object: ${path}`);
22
+ }
23
+ return value;
24
+ }
25
+
26
+ export function serializeSettings(value, originalText) {
27
+ const newline =
28
+ originalText === null || originalText === undefined || originalText.endsWith("\n") ? "\n" : "";
29
+ return `${JSON.stringify(value, null, 2)}${newline}`;
30
+ }
31
+
32
+ function existingArray(settings, locator) {
33
+ let node = settings;
34
+ for (const key of locator.path) {
35
+ if (node === null || typeof node !== "object" || !Object.hasOwn(node, key)) {
36
+ return null;
37
+ }
38
+ node = node[key];
39
+ }
40
+ return Array.isArray(node) ? node : null;
41
+ }
42
+
43
+ /** Returns the array a target writes into, creating missing objects if asked. */
44
+ export function arrayAt(settings, locator, { create = false } = {}) {
45
+ const existing = existingArray(settings, locator);
46
+ if (existing || !create) {
47
+ return existing;
48
+ }
49
+ let node = settings;
50
+ for (const key of locator.path) {
51
+ if (node[key] === null || typeof node[key] !== "object") {
52
+ node[key] = key === locator.path[locator.path.length - 1] ? [] : {};
53
+ }
54
+ node = node[key];
55
+ }
56
+ return node;
57
+ }
58
+
59
+ /**
60
+ * Checks the shape along an array locator path before a merge. A wrong-typed
61
+ * container (`"hooks": []`, `"PreToolUse": {}`) is invalid settings, so the
62
+ * caller refuses with the manual snippet instead of writing a dropped or
63
+ * crashing entry.
64
+ * @returns {{ ok: true } | { ok: false, reason: string }}
65
+ */
66
+ export function validateLocator(settings, locator) {
67
+ if (locator.kind === "key") {
68
+ return { ok: true };
69
+ }
70
+ let node = settings;
71
+ for (let index = 0; index < locator.path.length; index++) {
72
+ const key = locator.path[index];
73
+ if (node === null || typeof node !== "object" || Array.isArray(node)) {
74
+ const where = locator.path.slice(0, index).join(".") || "(root)";
75
+ return { ok: false, reason: `expected an object at ${where}` };
76
+ }
77
+ if (!Object.hasOwn(node, key)) {
78
+ return { ok: true };
79
+ }
80
+ const value = node[key];
81
+ const last = index === locator.path.length - 1;
82
+ if (last) {
83
+ if (!Array.isArray(value)) {
84
+ return { ok: false, reason: `expected an array at ${locator.path.join(".")}` };
85
+ }
86
+ } else if (value === null || typeof value !== "object" || Array.isArray(value)) {
87
+ return {
88
+ ok: false,
89
+ reason: `expected an object at ${locator.path.slice(0, index + 1).join(".")}`,
90
+ };
91
+ }
92
+ node = value;
93
+ }
94
+ return { ok: true };
95
+ }
96
+
97
+ export function findEntryIndex(settings, locator, entry) {
98
+ if (locator.kind === "key") {
99
+ return Object.hasOwn(settings, locator.key) && deepEqual(settings[locator.key], entry) ? 0 : -1;
100
+ }
101
+ const array = existingArray(settings, locator);
102
+ return array ? array.findIndex((candidate) => deepEqual(candidate, entry)) : -1;
103
+ }
104
+
105
+ /**
106
+ * Inserts the entry. Returns `{ status: "inserted" | "conflict" | "duplicate" }`.
107
+ * An array locator appends a new entry even when another entry shares its
108
+ * matcher, because harnesses such as Claude Code allow several entries per
109
+ * matcher and a same-matcher user entry is never replaced. A key locator owns
110
+ * exactly one key, so a key occupied by different content is a conflict.
111
+ */
112
+ export function insertEntry(settings, locator, entry) {
113
+ if (locator.kind === "key") {
114
+ if (Object.hasOwn(settings, locator.key)) {
115
+ return deepEqual(settings[locator.key], entry)
116
+ ? { status: "duplicate" }
117
+ : { status: "conflict" };
118
+ }
119
+ settings[locator.key] = entry;
120
+ return { status: "inserted" };
121
+ }
122
+ const array = arrayAt(settings, locator, { create: true });
123
+ if (findEntryIndex(settings, locator, entry) !== -1) {
124
+ return { status: "duplicate" };
125
+ }
126
+ array.push(entry);
127
+ return { status: "inserted" };
128
+ }
129
+
130
+ /** Replaces `current` with `next` in place. Returns true when `current` was found. */
131
+ export function replaceEntry(settings, locator, current, next) {
132
+ const index = findEntryIndex(settings, locator, current);
133
+ if (index === -1) {
134
+ return false;
135
+ }
136
+ if (locator.kind === "key") {
137
+ settings[locator.key] = next;
138
+ } else {
139
+ existingArray(settings, locator)[index] = next;
140
+ }
141
+ return true;
142
+ }
143
+
144
+ /** Removes the recorded entry by deep equality. Returns true when it was found. */
145
+ export function removeEntry(settings, locator, entry) {
146
+ const index = findEntryIndex(settings, locator, entry);
147
+ if (index === -1) {
148
+ return false;
149
+ }
150
+ if (locator.kind === "key") {
151
+ delete settings[locator.key];
152
+ } else {
153
+ existingArray(settings, locator).splice(index, 1);
154
+ }
155
+ return true;
156
+ }
157
+
158
+ /**
159
+ * Deletes empty containers along an array locator path, deepest key first, after
160
+ * the entry is removed. `createdFrom` is the index of the first path key that
161
+ * install created; a container the user already had is kept even when it is
162
+ * empty. A container that holds anything is kept.
163
+ */
164
+ export function pruneEmptyLocator(settings, locator, createdFrom = 0) {
165
+ if (locator.kind === "key") {
166
+ return;
167
+ }
168
+ const chain = [];
169
+ let node = settings;
170
+ for (const key of locator.path) {
171
+ if (node === null || typeof node !== "object" || !Object.hasOwn(node, key)) {
172
+ return;
173
+ }
174
+ chain.push({ parent: node, key });
175
+ node = node[key];
176
+ }
177
+ for (let i = chain.length - 1; i >= createdFrom; i--) {
178
+ const { parent, key } = chain[i];
179
+ const value = parent[key];
180
+ const empty = Array.isArray(value)
181
+ ? value.length === 0
182
+ : value !== null && typeof value === "object" && Object.keys(value).length === 0;
183
+ if (!empty) {
184
+ return;
185
+ }
186
+ delete parent[key];
187
+ }
188
+ }
189
+
190
+ /**
191
+ * The index of the first key along an array locator path that is absent, or the
192
+ * path length when every key exists. Install records it so uninstall prunes only
193
+ * the containers install created.
194
+ */
195
+ export function missingLocatorIndex(settings, locator) {
196
+ if (locator.kind === "key") {
197
+ return Object.hasOwn(settings, locator.key) ? 1 : 0;
198
+ }
199
+ let node = settings;
200
+ for (let index = 0; index < locator.path.length; index++) {
201
+ const key = locator.path[index];
202
+ if (node === null || typeof node !== "object" || !Object.hasOwn(node, key)) {
203
+ return index;
204
+ }
205
+ node = node[key];
206
+ }
207
+ return locator.path.length;
208
+ }
209
+
210
+ /** The fragment a maintainer can paste by hand when the installer refuses to merge. */
211
+ export function manualSnippet(path, locator, entry) {
212
+ const where =
213
+ locator.kind === "key"
214
+ ? `under the top-level key "${locator.key}"`
215
+ : `under ${locator.path.join(".")}`;
216
+ return [`Add to ${path} ${where}:`, JSON.stringify(entry, null, 2)].join("\n");
217
+ }
@@ -0,0 +1,14 @@
1
+ // Agent-loop Antigravity parent-guard hook shim (#88). Install copies this file
2
+ // beside `hooks.json` in the global `~/.gemini/config/` folder, and the named
3
+ // `agent-loop-parent-guard` `PreToolUse` group runs it by relative path.
4
+ // Antigravity runs the hook command without a shell and resolves it against
5
+ // that folder, so a quoted or spaced absolute script path fails. The shim
6
+ // loads the guard from the installed package, whose path may contain spaces,
7
+ // through a file URL. Install renders the import below to that absolute URL.
8
+ try {
9
+ await import("__AGENT_LOOP_GUARD_URL__");
10
+ } catch {
11
+ // A stale or unreachable guard URL denies nothing. A thrown import exits
12
+ // non-zero, and Antigravity blocks the tool call on a non-zero exit, so the
13
+ // failure is swallowed to keep the permission flow intact for every session.
14
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "agent-loop-parent-guard": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "write_to_file|replace_file_content|multi_replace_file_content|sed_file|notebook_edit|invoke_subagent|send_message",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node ./agent-loop-antigravity-parent-guard.mjs",
10
+ "timeout": 10
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: agent-loop
3
+ description: Run a delegated agent-loop role orchestration through the agent-loop CLI. Invoke only with /agent-loop.
4
+ ---
5
+
6
+ Read `{{AGENT_LOOP_INSTRUCTIONS}}` and follow it for this request. Run every
7
+ `agent-loop` command in the instructions as `__AGENT_LOOP_CLI__` instead, so the
8
+ run does not depend on the `agent-loop` command resolving on PATH.
9
+
10
+ Before the init dispatch call, run `__AGENT_LOOP_CLI__ harness-check antigravity`.
11
+ It exits 0 only when the nearest harness process above this shell is Antigravity
12
+ CLI. If it exits 3, stop and report that another harness owns the session; do
13
+ not start a run. If it exits with any other non-zero code, stop and report that
14
+ the check could not run or found no harness ancestor, which is distinct from a
15
+ harness refusal. Do not use `ANTIGRAVITY_CONVERSATION_ID` to make this
16
+ decision, because a nested harness inherits it.
17
+
18
+ Use the invocation text as the task and role settings. Before the init dispatch
19
+ call, verify that the active shell has `ANTIGRAVITY_CONVERSATION_ID`. Do not
20
+ print its value. In PowerShell, pass `$env:ANTIGRAVITY_CONVERSATION_ID` as
21
+ `--parent-session`. Set `$worktree = (Get-Location).Path` and pass
22
+ `--cwd $worktree`. Do not embed `$PWD` in POSIX-style quotes. In POSIX shells,
23
+ pass `--cwd "$PWD"` and `$ANTIGRAVITY_CONVERSATION_ID`. If the variable is
24
+ absent, report the missing session channel and do not start the run.
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: agent-loop
3
+ description: Run a delegated agent-loop role orchestration through the agent-loop CLI. Invoke only with /agent-loop.
4
+ disable-model-invocation: true
5
+ argument-hint: <task and role settings>
6
+ # OpenCode also discovers ~/.claude/skills, lists this skill to the model, and
7
+ # the model auto-invokes it for an /agent-loop request. Hide it from OpenCode's
8
+ # model list so the installed OpenCode plugin command owns /agent-loop and
9
+ # supplies the session id.
10
+ metadata:
11
+ opencode/autoinvoke: false
12
+ ---
13
+
14
+ @{{AGENT_LOOP_INSTRUCTIONS}}
15
+
16
+ Follow the attached instructions for this invocation. If the file is not
17
+ attached, read `{{AGENT_LOOP_INSTRUCTIONS}}` before acting. Run every
18
+ `agent-loop` command in the instructions as `__AGENT_LOOP_CLI__` instead, so the
19
+ run does not depend on the `agent-loop` command resolving on PATH. The task and
20
+ role settings from the invocation are:
21
+
22
+ $ARGUMENTS
23
+
24
+ Before the init dispatch call, run `__AGENT_LOOP_CLI__ harness-check claude`. It
25
+ exits 0 only when the nearest harness process above this shell is Claude Code.
26
+ If it exits 3, stop and report that another harness owns the session; do not
27
+ start a run. If it exits with any other non-zero code, stop and report that the
28
+ check could not run or found no harness ancestor, which is distinct from a
29
+ harness refusal. Do not use `${CLAUDE_SESSION_ID}` to make this decision,
30
+ because a foreign harness leaves the literal in place and a nested harness
31
+ inherits the value.
32
+
33
+ On the init dispatch call, pass ${CLAUDE_SESSION_ID} as `--parent-session`.
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: agent-loop
3
+ description: Run a delegated agent-loop role orchestration when the user explicitly invokes $agent-loop.
4
+ # OpenCode also discovers ~/.agents/skills, lists this skill to the model, and
5
+ # the model auto-invokes it for an /agent-loop request. Hide it from OpenCode's
6
+ # model list so the installed OpenCode plugin command owns /agent-loop and
7
+ # supplies the session id.
8
+ metadata:
9
+ opencode/autoinvoke: false
10
+ ---
11
+
12
+ Read `{{AGENT_LOOP_INSTRUCTIONS}}` and follow it for this request. Run every
13
+ `agent-loop` command in the instructions as `__AGENT_LOOP_CLI__` instead, so the
14
+ run does not depend on the `agent-loop` command resolving on PATH.
15
+
16
+ Before the init dispatch call, run `__AGENT_LOOP_CLI__ harness-check codex`. It
17
+ exits 0 only when the nearest harness process above this shell is Codex CLI. If
18
+ it exits 3, stop and report that another harness owns the session; do not start a
19
+ run. If it exits with any other non-zero code, stop and report that the check
20
+ could not run or found no harness ancestor, which is distinct from a harness
21
+ refusal. Do not use `CODEX_THREAD_ID` to make this decision, because a nested
22
+ harness inherits it.
23
+
24
+ Use the invocation text as the task and role settings. Before the init dispatch
25
+ call, verify that the active shell has `CODEX_THREAD_ID`. Do not print its value.
26
+ In PowerShell, pass `$env:CODEX_THREAD_ID` as `--parent-session`. Set
27
+ `$worktree = (Get-Location).Path` and pass `--cwd $worktree`. Do not embed
28
+ `$PWD` in POSIX-style quotes. In POSIX shells, pass `--cwd "$PWD"`. In POSIX
29
+ shells, pass `$CODEX_THREAD_ID`. If it is absent, report the missing session
30
+ channel and do not start the run.
@@ -0,0 +1,2 @@
1
+ policy:
2
+ allow_implicit_invocation: false
@@ -0,0 +1,15 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "PreToolUse": [
5
+ {
6
+ "type": "command",
7
+ "matcher": "Edit|Write",
8
+ "exec": "node",
9
+ "args": [],
10
+ "cwd": ".",
11
+ "timeoutSec": 10
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -0,0 +1,11 @@
1
+ // Agent-loop OpenCode parent-guard plugin (#75), installed at user scope by
2
+ // `agent-loop install`. The factory and its rationale live in
3
+ // `src/hook/opencode-plugin.mjs`; this template only wires that factory to the
4
+ // shipped decision logic and the installed orchestrator instructions.
5
+ import { createParentGuardPlugin } from "__AGENT_LOOP_PLUGIN_URL__";
6
+ import { decideParentGuard } from "__AGENT_LOOP_GUARD_URL__";
7
+
8
+ export default createParentGuardPlugin({
9
+ decideParentGuard,
10
+ instructionsPath: "{{AGENT_LOOP_INSTRUCTIONS}}",
11
+ });
package/src/lib/args.mjs CHANGED
@@ -26,6 +26,29 @@ export function readNonNegativeInt(flag, value) {
26
26
  return val;
27
27
  }
28
28
 
29
+ // Defaults shared by the headless CLI, the role subcommand, and the loop runtime.
30
+ export const DEFAULT_MAX_STEPS = 20;
31
+ export const DEFAULT_TIMEOUT = 3600;
32
+
33
+ // Role flags: `--<role>[-model|-effort]` to option key. The headless CLI reads
34
+ // all three roles, the role subcommand reads the child roles.
35
+ export const CHILD_ROLE_KINDS = ["worker", "reviewer"];
36
+ export const ROLE_KINDS = ["orchestrator", ...CHILD_ROLE_KINDS];
37
+
38
+ export function roleFlags(roles) {
39
+ return Object.fromEntries(
40
+ roles.flatMap((role) => [
41
+ [`--${role}`, role],
42
+ [`--${role}-model`, `${role}Model`],
43
+ [`--${role}-effort`, `${role}Effort`],
44
+ ]),
45
+ );
46
+ }
47
+
48
+ export const ROLE_FLAG_BY_OPTION = Object.fromEntries(
49
+ Object.entries(roleFlags(ROLE_KINDS)).map(([flag, option]) => [option, flag]),
50
+ );
51
+
29
52
  export function assertOpenCodeOptions(role, kind, model, effort) {
30
53
  if (kind && normalizeAgent(kind) !== "opencode") {
31
54
  return;
@@ -0,0 +1,9 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * SHA-256 hex digest. Accepts a string (hashed as UTF-8) or a Buffer, so text
5
+ * and file bytes hash through one helper.
6
+ */
7
+ export function sha256(data) {
8
+ return createHash("sha256").update(data).digest("hex");
9
+ }
package/src/lib/log.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  // Lifecycle logging at operational boundaries (AGENTS.md logging guideline).
2
2
  // Every line is tagged with its level: "[agent-loop] <level>: <message>".
3
3
  // info and debug go to stdout, warn and error to stderr; debug is shown only when
4
- // the --verbose gate is on. Warn and error lines are length-bounded so untrusted
5
- // content (for example a model echo in a validation error) cannot flood a line.
4
+ // the --verbose gate is on. Every line is length-bounded so untrusted content
5
+ // (for example a model echo in a validation error) cannot flood a line; logInfoFull
6
+ // prints a trusted, user-facing note in full instead.
6
7
  const MAX_LENGTH = 300;
7
8
 
8
9
  let verbose = false;
@@ -31,8 +32,22 @@ export function logDebug(message) {
31
32
  }
32
33
  }
33
34
 
35
+ function writeInfo(message) {
36
+ (stderrOnly ? console.error : console.log)(`[agent-loop] info: ${message}`);
37
+ }
38
+
34
39
  export function logInfo(message) {
35
- (stderrOnly ? console.error : console.log)(`[agent-loop] info: ${truncate(message)}`);
40
+ writeInfo(truncate(message));
41
+ }
42
+
43
+ /**
44
+ * Prints an info line in full, without the 300-character bound. A post-install
45
+ * note is trusted guidance the user must read to its last sentence, so the
46
+ * bound that keeps untrusted content from flooding an ordinary line does not
47
+ * apply.
48
+ */
49
+ export function logInfoFull(message) {
50
+ writeInfo(message);
36
51
  }
37
52
 
38
53
  export function logWarn(message) {