@andromarces/agent-loops 0.2.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.
@@ -0,0 +1,152 @@
1
+ import { parseJsonLines } from "../lib/json.mjs";
2
+ import { exec } from "../lib/exec.mjs";
3
+ import { logInfo } from "../lib/log.mjs";
4
+
5
+ // The built-in plan agent can launch explore and general subagents through the `subagent`
6
+ // action. They inherit the session model, so a read-only turn spends the role model budget
7
+ // invisibly. Deny the action for the read-only turn only; see the README.
8
+ // A global permissions allow can resolve after the plan agent's `edit` deny and cancel it, so
9
+ // deny `edit` here too. Shell stays available for read-only commands such as `git diff`.
10
+ const READONLY_CONFIG =
11
+ '{"permissions":[{"action":"subagent","resource":"*","effect":"deny"},{"action":"edit","resource":"*","effect":"deny"}]}';
12
+
13
+ export async function runOpenCode(state, prompt, options = {}) {
14
+ const { cwd, readOnly, timeout, signal, role } = options;
15
+ const args = ["run", "--standalone", "--format", "json"];
16
+ const execOptions = { cwd, input: prompt, timeout, signal, role };
17
+
18
+ if (state.sessionId) {
19
+ args.push("--session", state.sessionId);
20
+ }
21
+
22
+ if (readOnly) {
23
+ args.push("--agent", "plan");
24
+ execOptions.env = { OPENCODE_CONFIG_CONTENT: READONLY_CONFIG };
25
+ }
26
+
27
+ // Argument validation rejects an effort without a model; this guards a direct adapter call.
28
+ if (state.effort && !state.model) {
29
+ throw new Error(`opencode effort requires ${role ? `--${role}-model` : "an explicit model"}.`);
30
+ }
31
+
32
+ // state holds the requested model and effort, so null means the caller named nothing.
33
+ if (state.model) {
34
+ const resolved = state.effort ? `${state.model}#${state.effort}` : state.model;
35
+ logInfo(`opencode effective model: ${resolved}`);
36
+ args.push("--model", resolved);
37
+ } else {
38
+ // No --model: OpenCode selects its own default, which the JSON stream does not name.
39
+ logInfo(
40
+ "opencode model: OpenCode selects its CLI default; the OpenCode session metadata records the model that ran.",
41
+ );
42
+ }
43
+
44
+ let stdout;
45
+ try {
46
+ ({ stdout } = await exec("opencode", args, execOptions));
47
+ } catch (err) {
48
+ // A non-zero exit can still carry completed-step usage. Expose it, then rethrow.
49
+ setUsage(state, parseJsonLines(err?.stdout ?? ""));
50
+ throw err;
51
+ }
52
+
53
+ const events = parseJsonLines(stdout);
54
+
55
+ const sessionId = events.map((event) => event.sessionID).find(Boolean);
56
+
57
+ if (!sessionId) {
58
+ throw new Error("opencode did not return a session ID.");
59
+ }
60
+
61
+ if (state.sessionId && state.sessionId !== sessionId) {
62
+ throw new Error(
63
+ [
64
+ `opencode did not resume the expected session.`,
65
+ `Expected: ${state.sessionId}`,
66
+ `Received: ${sessionId}`,
67
+ ].join("\n"),
68
+ );
69
+ }
70
+
71
+ state.sessionId = sessionId;
72
+
73
+ // Record usage before the error check so a turn that failed after completing steps still reports
74
+ // what it spent, matching the Claude adapter and the runtime's error invocation event.
75
+ setUsage(state, events);
76
+
77
+ // Defense-in-depth: if the CLI ever exits 0 with an error event, surface its detail instead of
78
+ // falling through to the missing-text error. The session is recorded first, as it is on any turn.
79
+ const errorEvent = events.find((event) => event.type === "error");
80
+
81
+ if (errorEvent) {
82
+ throw new Error(`opencode returned an error event: ${describeError(errorEvent.error)}`);
83
+ }
84
+
85
+ const text = events
86
+ .filter((event) => event.type === "text" && typeof event.part?.text === "string")
87
+ .map((event) => event.part.text)
88
+ .join("");
89
+
90
+ if (!text.trim()) {
91
+ throw new Error("opencode did not return response text.");
92
+ }
93
+
94
+ return text.trim();
95
+ }
96
+
97
+ /**
98
+ * Sets `state.usage` from the `step_finish` parts of the stream, or removes it when the stream
99
+ * carries none. Each completed step emits one `step_finish` part with `tokens` and `cost`, so
100
+ * both fields sum across steps. No event names the model, so `models` is omitted.
101
+ */
102
+ function setUsage(state, events) {
103
+ const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } };
104
+ let cost = 0;
105
+ let hasTokens = false;
106
+ let hasCost = false;
107
+
108
+ for (const event of events) {
109
+ if (event.type !== "step_finish") {
110
+ continue;
111
+ }
112
+
113
+ const part = event.part ?? {};
114
+
115
+ if (part.tokens && typeof part.tokens === "object") {
116
+ hasTokens = true;
117
+ tokens.input += part.tokens.input ?? 0;
118
+ tokens.output += part.tokens.output ?? 0;
119
+ tokens.reasoning += part.tokens.reasoning ?? 0;
120
+ tokens.cache.read += part.tokens.cache?.read ?? 0;
121
+ tokens.cache.write += part.tokens.cache?.write ?? 0;
122
+ }
123
+
124
+ if (typeof part.cost === "number") {
125
+ hasCost = true;
126
+ cost += part.cost;
127
+ }
128
+ }
129
+
130
+ const usage = {};
131
+ if (hasTokens) usage.mainLoop = tokens;
132
+ if (hasCost) usage.totalCostUsd = cost;
133
+
134
+ if (Object.keys(usage).length > 0) {
135
+ state.usage = usage;
136
+ } else {
137
+ delete state.usage;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Formats an OpenCode error event payload for the thrown message.
143
+ * The payload carries `{ type, message, status }`; any field can be absent.
144
+ */
145
+ function describeError(error) {
146
+ if (!error || typeof error !== "object") {
147
+ return "unknown error";
148
+ }
149
+
150
+ const detail = [error.type, error.message].filter(Boolean).join(": ") || "unknown error";
151
+ return error.status == null ? detail : `${detail} (status ${error.status})`;
152
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { writeFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { defaultAgents, normalizeAgent, supportedAgents } from "./agents/index.mjs";
6
+ import {
7
+ assertOpenCodeOptions,
8
+ readArgValue,
9
+ readNonNegativeInt,
10
+ readPositiveInt,
11
+ } from "./lib/args.mjs";
12
+ import { isEntryPoint } from "./lib/entrypoint.mjs";
13
+ import { setVerbose } from "./lib/log.mjs";
14
+ import { assertGitWorkTree } from "./lib/snapshot.mjs";
15
+ import { runLoop } from "./runtime.mjs";
16
+ import { main as runRoleMain } from "./role.mjs";
17
+
18
+ const ROLES = ["orchestrator", "worker", "reviewer"];
19
+
20
+ export function parseArgs(argv) {
21
+ const options = {
22
+ cwd: process.cwd(),
23
+ task: null,
24
+ maxSteps: 20,
25
+ timeout: 3600,
26
+ transcript: null,
27
+ verbose: false,
28
+ };
29
+ for (const role of ROLES) {
30
+ options[role] = null;
31
+ options[`${role}Model`] = null;
32
+ options[`${role}Effort`] = null;
33
+ }
34
+
35
+ const readValue = (flag, index) => readArgValue(argv, flag, index);
36
+
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const arg = argv[i];
39
+
40
+ let matched = false;
41
+ for (const role of ROLES) {
42
+ if (arg === `--${role}`) {
43
+ options[role] = readValue(arg, ++i);
44
+ matched = true;
45
+ } else if (arg === `--${role}-model`) {
46
+ options[`${role}Model`] = readValue(arg, ++i);
47
+ matched = true;
48
+ } else if (arg === `--${role}-effort`) {
49
+ options[`${role}Effort`] = readValue(arg, ++i);
50
+ matched = true;
51
+ }
52
+ if (matched) break;
53
+ }
54
+ if (matched) continue;
55
+
56
+ switch (arg) {
57
+ case "--cwd":
58
+ options.cwd = resolve(readValue(arg, ++i));
59
+ break;
60
+
61
+ case "--task":
62
+ options.task = readValue(arg, ++i);
63
+ break;
64
+
65
+ case "--max-steps":
66
+ options.maxSteps = readPositiveInt("--max-steps", readValue("--max-steps", ++i));
67
+ break;
68
+
69
+ case "--timeout": {
70
+ const seconds = readNonNegativeInt("--timeout", readValue("--timeout", ++i));
71
+ options.timeout = seconds === 0 ? null : seconds;
72
+ break;
73
+ }
74
+
75
+ case "--transcript":
76
+ options.transcript = resolve(readValue(arg, ++i));
77
+ break;
78
+
79
+ case "--verbose":
80
+ options.verbose = true;
81
+ break;
82
+
83
+ case "--help":
84
+ case "-h":
85
+ printHelp();
86
+ process.exit(0);
87
+ break;
88
+
89
+ default:
90
+ throw new Error(`Unknown argument: ${arg}`);
91
+ }
92
+ }
93
+
94
+ for (const role of ROLES) {
95
+ if (!options[role]) {
96
+ throw new Error(`Missing required --${role}.`);
97
+ }
98
+ if (!supportedAgents.has(options[role])) {
99
+ throw new Error(`Unsupported ${role}: ${options[role]}`);
100
+ }
101
+ }
102
+
103
+ for (const role of ROLES) {
104
+ assertOpenCodeOptions(role, options[role], options[`${role}Model`], options[`${role}Effort`]);
105
+ }
106
+ if (options.task === null || options.task === undefined || String(options.task).trim() === "") {
107
+ throw new Error(
108
+ 'Missing required --task. Provide the task, for example --task "Implement the change."',
109
+ );
110
+ }
111
+
112
+ return options;
113
+ }
114
+
115
+ function printHelp() {
116
+ console.log(
117
+ `
118
+ Usage:
119
+
120
+ agent-loop \\
121
+ --orchestrator codex \\
122
+ --worker claude \\
123
+ --reviewer agy \\
124
+ --task "Implement the change."
125
+
126
+ agent-loop role dispatch --role worker --prompt-file prompt.txt
127
+
128
+ The orchestrator selects actions (run_worker, run_reviewer, finish, abort).
129
+ The runtime enforces step limits and mutation boundaries.
130
+
131
+ Subcommands:
132
+
133
+ agent-loop role Run a single worker or reviewer turn, or finish/abort
134
+ a run, from a lifecycle state file (see below). One JSON
135
+ object on stdout; logs on stderr.
136
+
137
+ Role operations:
138
+
139
+ dispatch (default) Run one --role turn for the run state at --cwd.
140
+ finish End the run; the five-key summary arrives as JSON on stdin.
141
+ abort End the run with --reason.
142
+
143
+ Role flags:
144
+
145
+ --role worker|reviewer Role to dispatch. Required for dispatch.
146
+ --cwd <directory> Target work tree. Defaults to the current directory.
147
+ --task / --mode / --parent-session / --worker* / --reviewer* / --max-steps / --timeout
148
+ First (init) call only. Later calls read these from the
149
+ state file and reject any attempt to change them.
150
+ --prompt-file <path> Prompt source. Default is stdin.
151
+ --transcript <file> Append invocation and result events (JSON lines).
152
+ --resume-interrupted Explicitly continue after an uncertain previous turn.
153
+
154
+ Options:
155
+
156
+ --orchestrator <agent> Agent that directs the loop. Required.
157
+ --worker <agent> Agent that implements changes. Required.
158
+ --reviewer <agent> Agent that reviews the repository (read-only). Required.
159
+ --orchestrator-model <model> Model passed to the orchestrator CLI. Optional.
160
+ --orchestrator-effort <level> Thinking effort passed to the orchestrator CLI. Optional.
161
+ --worker-model <model> Model passed to the worker CLI. Optional.
162
+ --worker-effort <level> Thinking effort passed to the worker CLI. Optional.
163
+ --reviewer-model <model> Model passed to the reviewer CLI. Optional.
164
+ --reviewer-effort <level> Thinking effort passed to the reviewer CLI. Optional.
165
+
166
+ Model and effort flags record what the caller requested. With opencode and no
167
+ --<role>-model, the adapter passes no --model and OpenCode selects its own
168
+ default, which varies by machine. An explicit --<role>-model passes through,
169
+ and --<role>-effort applies to it as <model>#<effort>. An effort without a
170
+ model is rejected.
171
+
172
+ --cwd <directory> Working directory for the agents. Must be inside a Git work tree. Defaults to current directory.
173
+ --task <text> Task description. Required.
174
+ --max-steps <count> Maximum child steps. Defaults to 20.
175
+ --timeout <seconds> Timeout per agent invocation. Defaults to 3600. 0 disables the bound.
176
+ --transcript <file> Record execution transcript to a JSON file.
177
+ --verbose Enable debug-level lifecycle logging, including snapshot activity.
178
+ -h, --help Show help.
179
+
180
+ Environment:
181
+
182
+ The loop spawns each agent CLI directly, without a shell. Agents inherit the environment of the process that launched the loop. Start the loop from a shell where direnv or a similar tool already exported the required variables.
183
+
184
+ Agents:
185
+
186
+ claude
187
+ codex
188
+ agy
189
+ antigravity
190
+ opencode
191
+ copilot
192
+ `.trim(),
193
+ );
194
+ }
195
+
196
+ function formatSummary(summary) {
197
+ return [
198
+ `Changed: ${summary.changed}`,
199
+ `Verified: ${summary.verified}`,
200
+ `Deferred: ${summary.deferred}`,
201
+ `Not done: ${summary.notDone}`,
202
+ `Open: ${summary.open}`,
203
+ ].join("\n");
204
+ }
205
+
206
+ export async function main(argv = process.argv.slice(2), agents = defaultAgents) {
207
+ if (argv[0] === "role") {
208
+ await runRoleMain(argv.slice(1), { agents });
209
+ return;
210
+ }
211
+
212
+ let options;
213
+ try {
214
+ options = parseArgs(argv);
215
+ } catch (err) {
216
+ console.error(`\n${err.message}`);
217
+ process.exitCode = 1;
218
+ return;
219
+ }
220
+
221
+ setVerbose(options.verbose);
222
+
223
+ const events = [];
224
+ const roles = {};
225
+ for (const role of ROLES) {
226
+ roles[role] = {
227
+ kind: normalizeAgent(options[role]),
228
+ model: options[`${role}Model`],
229
+ effort: options[`${role}Effort`],
230
+ sessionId: null,
231
+ };
232
+ }
233
+ const transcriptData = {
234
+ task: options.task,
235
+ cwd: options.cwd,
236
+ options: {
237
+ maxSteps: options.maxSteps,
238
+ timeout: options.timeout,
239
+ },
240
+ roles,
241
+ events,
242
+ exitCode: 1,
243
+ error: null,
244
+ };
245
+
246
+ const writeTranscript = async () => {
247
+ if (!options.transcript) return;
248
+ try {
249
+ await writeFile(options.transcript, JSON.stringify(transcriptData, null, 2), "utf8");
250
+ } catch (err) {
251
+ console.error(`Warning: Failed to write transcript to ${options.transcript}: ${err.message}`);
252
+ }
253
+ };
254
+
255
+ const finish = async ({ exitCode, error }) => {
256
+ transcriptData.exitCode = exitCode;
257
+ transcriptData.error = error ? (error.message ?? String(error)) : null;
258
+ await writeTranscript();
259
+ if (error) {
260
+ console.error(`\n${error.message ?? error}`);
261
+ }
262
+ process.exitCode = exitCode;
263
+ };
264
+
265
+ const controller = new AbortController();
266
+ const onSigInt = () => {
267
+ controller.abort();
268
+ };
269
+ process.once("SIGINT", onSigInt);
270
+
271
+ try {
272
+ try {
273
+ await assertGitWorkTree(options.cwd);
274
+ } catch (err) {
275
+ await finish({ exitCode: 1, error: err });
276
+ return;
277
+ }
278
+
279
+ const onEvent = (event) => {
280
+ if (options.transcript) {
281
+ events.push({
282
+ ...event,
283
+ at: new Date().toISOString(),
284
+ });
285
+ }
286
+
287
+ if (event.type === "action") {
288
+ console.log("\n===== ORCHESTRATOR =====\n");
289
+ console.log(JSON.stringify(event.action, null, 2));
290
+ } else if (event.type === "result") {
291
+ const banner =
292
+ event.role === "worker" ? `WORKER ${event.stepsUsed}` : `REVIEWER ${event.stepsUsed}`;
293
+ console.log(`\n===== ${banner} =====\n`);
294
+ if (event.result.status === "ok") {
295
+ console.log(event.result.response);
296
+ } else {
297
+ console.log(`Error: ${event.result.error}`);
298
+ }
299
+ }
300
+ };
301
+
302
+ try {
303
+ const result = await runLoop({
304
+ task: options.task,
305
+ cwd: options.cwd,
306
+ maxSteps: options.maxSteps,
307
+ timeout: options.timeout,
308
+ signal: controller.signal,
309
+ roles: transcriptData.roles,
310
+ agents,
311
+ onEvent,
312
+ });
313
+
314
+ if (result.exitCode === 0) {
315
+ console.log("\n===== SUMMARY =====\n");
316
+ console.log(formatSummary(result.summary));
317
+ await finish({ exitCode: 0, error: null });
318
+ } else {
319
+ await finish({ exitCode: result.exitCode, error: new Error(result.reason) });
320
+ }
321
+ } catch (err) {
322
+ if (err?.isCanceled) {
323
+ await finish({ exitCode: 130, error: new Error("Interrupted by SIGINT") });
324
+ } else {
325
+ await finish({ exitCode: 1, error: err });
326
+ }
327
+ }
328
+ } finally {
329
+ process.removeListener("SIGINT", onSigInt);
330
+ }
331
+ }
332
+
333
+ if (isEntryPoint(import.meta.filename)) {
334
+ main().catch((error) => {
335
+ console.error(`\n${error.stack ?? error.message ?? error}`);
336
+ process.exitCode = 1;
337
+ });
338
+ }
@@ -0,0 +1,74 @@
1
+ const SUMMARY_KEYS = ["changed", "verified", "deferred", "notDone", "open"];
2
+
3
+ export function validateAction(value) {
4
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
5
+ return { ok: false, error: "Action must be an object." };
6
+ }
7
+
8
+ const { action } = value;
9
+
10
+ // The case labels are the single source of truth for the supported action set:
11
+ // any action without a branch falls to `default` and is rejected, so the
12
+ // repair turn in decide() runs instead of a TypeError on an undefined result.
13
+ switch (action) {
14
+ case "run_worker":
15
+ case "run_reviewer": {
16
+ if (typeof value.prompt !== "string" || value.prompt.trim() === "") {
17
+ return { ok: false, error: `${action} requires a non-empty string prompt.` };
18
+ }
19
+ return {
20
+ ok: true,
21
+ value: {
22
+ action,
23
+ prompt: value.prompt.trim(),
24
+ },
25
+ };
26
+ }
27
+
28
+ case "finish": {
29
+ if (
30
+ value.summary === null ||
31
+ typeof value.summary !== "object" ||
32
+ Array.isArray(value.summary)
33
+ ) {
34
+ return { ok: false, error: "finish requires a summary object." };
35
+ }
36
+
37
+ const summary = {};
38
+ for (const key of SUMMARY_KEYS) {
39
+ const fieldVal = value.summary[key];
40
+ if (typeof fieldVal !== "string" || fieldVal.trim() === "") {
41
+ return {
42
+ ok: false,
43
+ error: `finish summary requires a non-empty string for ${key}.`,
44
+ };
45
+ }
46
+ summary[key] = fieldVal.trim();
47
+ }
48
+
49
+ return {
50
+ ok: true,
51
+ value: {
52
+ action: "finish",
53
+ summary,
54
+ },
55
+ };
56
+ }
57
+
58
+ case "abort": {
59
+ if (typeof value.reason !== "string" || value.reason.trim() === "") {
60
+ return { ok: false, error: "abort requires a non-empty string reason." };
61
+ }
62
+ return {
63
+ ok: true,
64
+ value: {
65
+ action: "abort",
66
+ reason: value.reason.trim(),
67
+ },
68
+ };
69
+ }
70
+
71
+ default:
72
+ return { ok: false, error: `Unsupported action: ${action}` };
73
+ }
74
+ }
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { randomUUID } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { execa } from "execa";
7
+ import { isEntryPoint } from "../lib/entrypoint.mjs";
8
+
9
+ // Resolve the shipped instructions relative to this file, so the launcher works
10
+ // from any directory, not only from a clone of this repository. The file lives
11
+ // outside the session workspace, so the invocation grants Copilot access to its
12
+ // directory with --add-dir.
13
+ const INSTRUCTIONS_DIR = fileURLToPath(new URL("../../docs", import.meta.url));
14
+ const INSTRUCTIONS_PATH = join(INSTRUCTIONS_DIR, "orchestrator-instructions.md");
15
+
16
+ const INSTRUCTIONS = (sessionId, task) =>
17
+ [
18
+ `Read \`${INSTRUCTIONS_PATH}\` and follow it for this request.`,
19
+ "The task and role settings are:",
20
+ task,
21
+ `This Copilot CLI session id is \`${sessionId}\`. Pass it as \`--parent-session\` on the init dispatch call.`,
22
+ ].join("\n\n");
23
+
24
+ export function buildCopilotInvocation(task, sessionId) {
25
+ const normalizedTask = String(task ?? "").trim();
26
+ if (!normalizedTask) {
27
+ throw new Error("A task and role settings prompt is required.");
28
+ }
29
+ if (typeof sessionId !== "string" || sessionId.trim() === "") {
30
+ throw new Error("A Copilot CLI session id is required.");
31
+ }
32
+ return {
33
+ command: "copilot",
34
+ args: [
35
+ "--session-id",
36
+ sessionId,
37
+ "--add-dir",
38
+ INSTRUCTIONS_DIR,
39
+ "--interactive",
40
+ INSTRUCTIONS(sessionId, normalizedTask),
41
+ ],
42
+ };
43
+ }
44
+
45
+ export async function main(argv = process.argv.slice(2)) {
46
+ const task = argv.join(" ").trim();
47
+ const invocation = buildCopilotInvocation(task, randomUUID());
48
+ await execa(invocation.command, invocation.args, { stdio: "inherit" });
49
+ }
50
+
51
+ if (isEntryPoint(import.meta.filename)) {
52
+ main().catch((error) => {
53
+ const message = String(error?.shortMessage ?? error?.message ?? error).split("\n", 1)[0];
54
+ console.error(`agent-loop-copilot: ${message}`);
55
+ process.exitCode = 1;
56
+ });
57
+ }
@@ -0,0 +1,35 @@
1
+ // GitHub Copilot CLI PreToolUse hook for the parent-edit guard. Copilot's
2
+ // PascalCase event payload uses `session_id`, and its command-hook decision is
3
+ // a flat permissionDecision object rather than Claude's hookSpecificOutput.
4
+ // Fail-open by design: malformed input and lookup failures deny nothing.
5
+ import { decideParentGuard } from "./decision.mjs";
6
+
7
+ async function readHookInput() {
8
+ const chunks = [];
9
+ for await (const chunk of process.stdin) {
10
+ chunks.push(chunk);
11
+ }
12
+ try {
13
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ const input = await readHookInput();
20
+ const sessionId = typeof input?.session_id === "string" ? input.session_id : null;
21
+ if (sessionId) {
22
+ try {
23
+ const verdict = await decideParentGuard(sessionId);
24
+ if (verdict.decision === "deny") {
25
+ console.log(
26
+ JSON.stringify({
27
+ permissionDecision: "deny",
28
+ permissionDecisionReason: verdict.reason,
29
+ }),
30
+ );
31
+ }
32
+ } catch {
33
+ // A failed lookup denies nothing: the guard never blocks on its own error.
34
+ }
35
+ }
@@ -0,0 +1,27 @@
1
+ // Decision logic for the #57 parent guard. The Claude Code PreToolUse hook
2
+ // denies file-edit tools only when the hook session id matches `parentSession`
3
+ // in the state file registered for that session; every other session, every
4
+ // terminal lifecycle, and every absent or corrupt record allows the call.
5
+ // Fail-open by design: the guard is optional defense for the prompt-only
6
+ // parent rule, so unknown records never block a tool call.
7
+ import { TERMINAL_LIFECYCLES, readStateForSession } from "../lib/runstate.mjs";
8
+
9
+ export const GUARD_DENY_REASON =
10
+ "agent-loop orchestrator mode: this parent session has an active run. " +
11
+ "The parent never edits files. Delegate edits to the worker through " +
12
+ "'agent-loop role dispatch'; run 'agent-loop role finish' or 'abort' to " +
13
+ "release the guard.";
14
+
15
+ /**
16
+ * Decides whether one tool call from one session is denied.
17
+ * @param {string} sessionId session id from the hook input
18
+ * @param {{ lookup?: typeof readStateForSession }} deps
19
+ * @returns {Promise<{ decision: "deny", reason: string } | { decision: "allow" }>}
20
+ */
21
+ export async function decideParentGuard(sessionId, { lookup = readStateForSession } = {}) {
22
+ const state = await lookup(sessionId);
23
+ if (!state || state.parentSession !== sessionId || TERMINAL_LIFECYCLES.has(state.lifecycle)) {
24
+ return { decision: "allow" };
25
+ }
26
+ return { decision: "deny", reason: GUARD_DENY_REASON };
27
+ }