@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,40 @@
1
+ // Claude Code PreToolUse hook (#57), registered in .claude/settings.json for
2
+ // Edit|Write|MultiEdit|NotebookEdit. Reads the hook JSON from stdin and prints
3
+ // a deny decision on stdout only while the hook session is the parent of a
4
+ // non-terminal run. Every other outcome exits 0 with no output — unparseable
5
+ // input, a malformed session id, or an unexpected lookup error all leave the
6
+ // normal permission flow intact; the hook never leaks an error to the session.
7
+ import { decideParentGuard } from "./decision.mjs";
8
+
9
+ async function readHookInput() {
10
+ const chunks = [];
11
+ for await (const chunk of process.stdin) {
12
+ chunks.push(chunk);
13
+ }
14
+ try {
15
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ const input = await readHookInput();
22
+ const sessionId = typeof input?.session_id === "string" ? input.session_id : null;
23
+ if (sessionId) {
24
+ try {
25
+ const verdict = await decideParentGuard(sessionId);
26
+ if (verdict.decision === "deny") {
27
+ console.log(
28
+ JSON.stringify({
29
+ hookSpecificOutput: {
30
+ hookEventName: "PreToolUse",
31
+ permissionDecision: "deny",
32
+ permissionDecisionReason: verdict.reason,
33
+ },
34
+ }),
35
+ );
36
+ }
37
+ } catch {
38
+ // A failed lookup denies nothing: the guard never blocks on its own error.
39
+ }
40
+ }
@@ -0,0 +1,43 @@
1
+ // Shared CLI argument readers and agent option validation, used by both the
2
+ // headless loop (src/cli.mjs) and the role subcommand (src/role.mjs).
3
+ import { normalizeAgent } from "../agents/index.mjs";
4
+
5
+ export function readArgValue(argv, flag, index) {
6
+ const value = argv[index];
7
+ if (!value || value.startsWith("-")) {
8
+ throw new Error(`Missing value for ${flag}.`);
9
+ }
10
+ return value;
11
+ }
12
+
13
+ export function readPositiveInt(flag, value) {
14
+ const val = Number(value);
15
+ if (!Number.isInteger(val) || val < 1) {
16
+ throw new Error(`${flag} must be a positive integer.`);
17
+ }
18
+ return val;
19
+ }
20
+
21
+ export function readNonNegativeInt(flag, value) {
22
+ const val = Number(value);
23
+ if (!Number.isInteger(val) || val < 0) {
24
+ throw new Error(`${flag} must be a non-negative integer.`);
25
+ }
26
+ return val;
27
+ }
28
+
29
+ export function assertOpenCodeOptions(role, kind, model, effort) {
30
+ if (kind && normalizeAgent(kind) !== "opencode") {
31
+ return;
32
+ }
33
+
34
+ if (effort && !model) {
35
+ throw new Error(`--${role}-effort requires --${role}-model for opencode.`);
36
+ }
37
+
38
+ if (effort && model?.includes("#")) {
39
+ throw new Error(
40
+ `--${role}-model "${model}" already contains a variant and cannot be combined with --${role}-effort.`,
41
+ );
42
+ }
43
+ }
@@ -0,0 +1,17 @@
1
+ import { realpathSync } from "node:fs";
2
+
3
+ /**
4
+ * Reports whether the module at modulePath is the process entry point.
5
+ *
6
+ * Compares real paths, so a bin shim that reaches the file through a symlinked
7
+ * package directory (pnpm, npm on POSIX) still matches. A plain path compare
8
+ * fails there, and the CLI exits without running.
9
+ */
10
+ export function isEntryPoint(modulePath) {
11
+ if (!process.argv[1]) return false;
12
+ try {
13
+ return realpathSync(process.argv[1]) === realpathSync(modulePath);
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
@@ -0,0 +1,100 @@
1
+ import { execa } from "execa";
2
+ import { logDebug, logInfo } from "./log.mjs";
3
+
4
+ export class ExecError extends Error {
5
+ constructor(
6
+ message,
7
+ { command, exitCode, stdout, stderr, timedOut, isCanceled, isTerminated } = {},
8
+ ) {
9
+ super(message);
10
+ this.name = "ExecError";
11
+ this.command = command;
12
+ this.exitCode = exitCode;
13
+ this.stdout = stdout ?? "";
14
+ this.stderr = stderr ?? "";
15
+ this.timedOut = Boolean(timedOut);
16
+ this.isCanceled = Boolean(isCanceled);
17
+ this.isTerminated = Boolean(isTerminated);
18
+ }
19
+ }
20
+
21
+ export async function exec(command, args = [], options = {}) {
22
+ const { cwd, input, timeout, signal, role, env } = options;
23
+
24
+ const label = role ? `${role}: ${command}` : command;
25
+ const startedAt = Date.now();
26
+ logInfo(`${label} started`);
27
+
28
+ const execaOptions = {
29
+ cwd,
30
+ reject: false,
31
+ input,
32
+ stdin: input === undefined ? "ignore" : undefined,
33
+ killDescendants: true,
34
+ };
35
+
36
+ // execa merges env with process.env; the child still inherits the launcher environment.
37
+ if (env) {
38
+ execaOptions.env = env;
39
+ }
40
+
41
+ if (typeof timeout === "number" && timeout > 0) {
42
+ execaOptions.timeout = timeout * 1000;
43
+ }
44
+
45
+ if (signal) {
46
+ execaOptions.cancelSignal = signal;
47
+ }
48
+
49
+ const result = await execa(command, args, execaOptions);
50
+
51
+ const timedOut = Boolean(result.timedOut);
52
+ const isCanceled = Boolean(result.isCanceled);
53
+ const isTerminated = Boolean(result.isTerminated);
54
+
55
+ if (result.exitCode !== 0 || timedOut || isCanceled || isTerminated) {
56
+ let cause;
57
+ if (timedOut) {
58
+ cause = `${command} timed out after ${timeout} seconds.`;
59
+ } else if (isCanceled) {
60
+ cause = `${command} was canceled.`;
61
+ } else if (isTerminated) {
62
+ // POSIX-only: execa cannot detect signal termination on Windows.
63
+ const description = result.signalDescription ?? result.signal ?? "a signal";
64
+ cause = `${command} was killed by ${description}.`;
65
+ } else if (result.exitCode === undefined) {
66
+ // POSIX-only: execa leaves exitCode undefined when the subprocess
67
+ // could not be spawned (Windows reports exit code 1 instead).
68
+ cause = `${command} failed to start.`;
69
+ } else {
70
+ cause = `${command} exited with code ${result.exitCode}.`;
71
+ }
72
+
73
+ const message = [cause, result.stderr?.trim(), result.stdout?.trim()]
74
+ .filter(Boolean)
75
+ .join("\n\n");
76
+
77
+ // The caller owns the failure level (it knows whether the runtime recovers); this
78
+ // debug line terminates the invocation trace when the caller does not log one.
79
+ logDebug(`${label} failed in ${Date.now() - startedAt}ms: ${cause}`);
80
+
81
+ throw new ExecError(message, {
82
+ command,
83
+ exitCode: result.exitCode,
84
+ stdout: result.stdout,
85
+ stderr: result.stderr,
86
+ timedOut,
87
+ isCanceled,
88
+ isTerminated,
89
+ });
90
+ }
91
+
92
+ const durationMs = Date.now() - startedAt;
93
+ logInfo(`${label} finished in ${durationMs}ms (exit 0)`);
94
+
95
+ return {
96
+ stdout: result.stdout ?? "",
97
+ stderr: result.stderr ?? "",
98
+ durationMs,
99
+ };
100
+ }
@@ -0,0 +1,53 @@
1
+ export function parseJson(text, description) {
2
+ try {
3
+ return JSON.parse(text);
4
+ } catch {
5
+ throw new Error(`Invalid JSON from ${description}:\n${text.slice(0, 2000)}`);
6
+ }
7
+ }
8
+
9
+ export function parseJsonLines(text) {
10
+ const events = [];
11
+
12
+ for (const line of text.split(/\r?\n/)) {
13
+ const trimmed = line.trim();
14
+
15
+ if (!trimmed) {
16
+ continue;
17
+ }
18
+
19
+ try {
20
+ events.push(JSON.parse(trimmed));
21
+ } catch {
22
+ // Ignore non-JSON diagnostic lines.
23
+ }
24
+ }
25
+
26
+ return events;
27
+ }
28
+
29
+ export function extractJsonObject(text) {
30
+ let trimmed = text.trim();
31
+
32
+ if (trimmed.startsWith("```")) {
33
+ const lines = trimmed.split(/\r?\n/);
34
+ lines.shift();
35
+ if (lines.length > 0 && lines[lines.length - 1].trim().startsWith("```")) {
36
+ lines.pop();
37
+ }
38
+ trimmed = lines.join("\n").trim();
39
+ }
40
+
41
+ let value;
42
+ try {
43
+ value = JSON.parse(trimmed);
44
+ } catch {
45
+ return { ok: false, error: "Response is not valid JSON." };
46
+ }
47
+
48
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
49
+ return { ok: false, error: "Response is not a JSON object." };
50
+ }
51
+
52
+ return { ok: true, value };
53
+ }
@@ -0,0 +1,44 @@
1
+ // Lifecycle logging at operational boundaries (AGENTS.md logging guideline).
2
+ // Every line is tagged with its level: "[agent-loop] <level>: <message>".
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.
6
+ const MAX_LENGTH = 300;
7
+
8
+ let verbose = false;
9
+ let stderrOnly = false;
10
+
11
+ export function setVerbose(value) {
12
+ verbose = Boolean(value);
13
+ }
14
+
15
+ /**
16
+ * Routes info and debug lines to stderr as well. The role subcommand must keep
17
+ * stdout reserved for its single JSON envelope, so it turns this on for its
18
+ * whole lifetime.
19
+ */
20
+ export function setLogsToStderr(value) {
21
+ stderrOnly = Boolean(value);
22
+ }
23
+
24
+ function truncate(message) {
25
+ return message.length > MAX_LENGTH ? `${message.slice(0, MAX_LENGTH)}...` : message;
26
+ }
27
+
28
+ export function logDebug(message) {
29
+ if (verbose) {
30
+ (stderrOnly ? console.error : console.log)(`[agent-loop] debug: ${truncate(message)}`);
31
+ }
32
+ }
33
+
34
+ export function logInfo(message) {
35
+ (stderrOnly ? console.error : console.log)(`[agent-loop] info: ${truncate(message)}`);
36
+ }
37
+
38
+ export function logWarn(message) {
39
+ console.error(`[agent-loop] warn: ${truncate(message)}`);
40
+ }
41
+
42
+ export function logError(message) {
43
+ console.error(`[agent-loop] error: ${truncate(message)}`);
44
+ }
@@ -0,0 +1,89 @@
1
+ // Parses the closing block that reportBlock (src/prompts/report.mjs) requires
2
+ // from every child turn, plus the reviewer Verdict line. Both parse only the
3
+ // final block, which starts at the last `Conclusion:` line, so labels or
4
+ // verdicts in earlier prose can never produce a verdict.
5
+
6
+ const REPORT_LABELS = [
7
+ ["conclusion", "Conclusion"],
8
+ ["why", "Why"],
9
+ ["blockers", "Blockers"],
10
+ ];
11
+
12
+ /**
13
+ * The closing block: the lines from the last `Conclusion:` line to the end of
14
+ * the response, or null when the response has no `Conclusion:` line at all.
15
+ * @param {string} response
16
+ * @returns {string[] | null}
17
+ */
18
+ function closingBlock(response) {
19
+ const lines = response.split(/\r?\n/);
20
+ let start = -1;
21
+ for (let i = 0; i < lines.length; i++) {
22
+ if (/^Conclusion:\s*/i.test(lines[i])) {
23
+ start = i;
24
+ }
25
+ }
26
+ return start === -1 ? null : lines.slice(start);
27
+ }
28
+
29
+ /**
30
+ * Extracts the closing block as `{ conclusion, why, blockers }`. Each label is
31
+ * matched case-insensitively at line start inside the closing block only; the
32
+ * last occurrence in that range wins. Returns null when the block or any of
33
+ * the three labels is missing.
34
+ * @param {string} response
35
+ * @returns {{ conclusion: string, why: string, blockers: string } | null}
36
+ */
37
+ export function parseReportBlock(response) {
38
+ const block = closingBlock(response);
39
+ if (!block) {
40
+ return null;
41
+ }
42
+ const report = {};
43
+ for (const [key, label] of REPORT_LABELS) {
44
+ const match = lastLabeledLine(block, label);
45
+ if (!match) {
46
+ return null;
47
+ }
48
+ report[key] = match;
49
+ }
50
+ return report;
51
+ }
52
+
53
+ /**
54
+ * Extracts the reviewer verdict from a `Verdict:` line inside the closing
55
+ * block. The verdict word alone, the word closed by an optional sentence
56
+ * period, or the word followed by a punctuation separator, whitespace, and a
57
+ * clause, maps to that word. A clause that names either verdict as a whole word
58
+ * (for example `accept, reject`) maps to `unknown`, because it does not state
59
+ * one verdict. A verdict outside the block, or any other value (including a
60
+ * missing or malformed line), also maps to `unknown`; process success never
61
+ * implies acceptance.
62
+ * @param {string} response
63
+ * @returns {"accept" | "reject" | "unknown"}
64
+ */
65
+ export function parseVerdict(response) {
66
+ const block = closingBlock(response);
67
+ const line = block ? lastLabeledLine(block, "Verdict") : null;
68
+ const value = line ? line.replace(/\.$/, "") : null;
69
+ const match = value ? value.match(/^(accept|reject)(?:\s*[—–:,.-]\s+(.*))?$/i) : null;
70
+ if (!match) {
71
+ return "unknown";
72
+ }
73
+ if (match[2] && /\b(accept|reject)\b/i.test(match[2])) {
74
+ return "unknown";
75
+ }
76
+ return match[1].toLowerCase();
77
+ }
78
+
79
+ function lastLabeledLine(lines, label) {
80
+ const pattern = new RegExp(`^${label}:\\s*(.*)$`, "i");
81
+ let last = null;
82
+ for (const line of lines) {
83
+ const match = line.match(pattern);
84
+ if (match) {
85
+ last = match[1].trim();
86
+ }
87
+ }
88
+ return last;
89
+ }
@@ -0,0 +1,207 @@
1
+ // Lifecycle state file shared by the role subcommand (#55) and the parent
2
+ // guard hook (#57). The state file lives at a fixed path derived from the
3
+ // resolved work tree cwd, never passed as a flag; tests override the runs
4
+ // root with AGENT_LOOP_RUNS_ROOT.
5
+ import { createHash } from "node:crypto";
6
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { logWarn } from "./log.mjs";
10
+
11
+ export const TERMINAL_LIFECYCLES = new Set(["halted", "finished", "aborted"]);
12
+
13
+ /**
14
+ * Resolve the state paths for one run. `cwd` derives the per-work-tree state
15
+ * directory; `parentSession` derives the session index entry that the #57 hook
16
+ * reads back. Both are optional so a caller with only one of them still
17
+ * resolves the half it needs.
18
+ * @param {{ cwd?: string, parentSession?: string }} args
19
+ * @returns {{ root: string, stateDir?: string, stateFile?: string, lockFile?: string, sessionIndexFile?: string }}
20
+ */
21
+ export function statePaths({ cwd, parentSession } = {}) {
22
+ const root = stateRoot();
23
+ const paths = { root };
24
+
25
+ if (cwd !== undefined) {
26
+ const stateDir = join(root, cwdHash(cwd));
27
+ paths.stateDir = stateDir;
28
+ paths.stateFile = join(stateDir, "state.json");
29
+ paths.lockFile = join(stateDir, "state.lock");
30
+ }
31
+
32
+ if (parentSession !== undefined) {
33
+ assertSessionId(parentSession);
34
+ paths.sessionIndexFile = join(root, "sessions", parentSession);
35
+ }
36
+
37
+ return paths;
38
+ }
39
+
40
+ function stateRoot() {
41
+ const override = process.env.AGENT_LOOP_RUNS_ROOT;
42
+ return override ? resolve(override) : join(tmpdir(), "agent-loops", "runs");
43
+ }
44
+
45
+ function cwdHash(cwd) {
46
+ return createHash("sha256").update(canonicalCwd(cwd)).digest("hex").slice(0, 12);
47
+ }
48
+
49
+ function canonicalCwd(cwd) {
50
+ const resolved = resolve(cwd);
51
+ // Windows drive letters compare case-insensitively in the filesystem but
52
+ // not in the hash; normalize the letter so `c:\repo` and `C:\repo` share one
53
+ // state directory.
54
+ return resolved.replace(/^[A-Za-z]:/, (drive) => drive.toLowerCase());
55
+ }
56
+
57
+ function assertSessionId(sessionId) {
58
+ if (!sessionId || /[\\/\0]/.test(sessionId) || sessionId === "." || sessionId === "..") {
59
+ throw new Error(`Invalid session id: ${JSON.stringify(sessionId ?? null)}`);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Reads the state file that the init call registered for a parent session id.
65
+ * Returns null when the session has no index entry or the file is unreadable
66
+ * as JSON; never throws on absence, so the #57 hook can treat it as unguarded.
67
+ */
68
+ export async function readStateForSession(parentSession) {
69
+ const indexFile = statePaths({ parentSession }).sessionIndexFile;
70
+ let stateFile;
71
+ try {
72
+ stateFile = (await readFile(indexFile, "utf8")).trim();
73
+ } catch {
74
+ return null;
75
+ }
76
+ try {
77
+ return JSON.parse(await readFile(stateFile, "utf8"));
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Exclusive access around one state-file operation. Creates `state.lock` with
85
+ * O_EXCL, treats an existing lock with a live owner pid as busy and a dead one
86
+ * as stale (removed with a warning, then retried). Returns a release function.
87
+ */
88
+ export async function withStateLock(lockFile, fn) {
89
+ await mkdir(dirname(lockFile), { recursive: true });
90
+ await acquireLock(lockFile);
91
+ try {
92
+ return await fn();
93
+ } finally {
94
+ await rm(lockFile, { force: true });
95
+ }
96
+ }
97
+
98
+ async function acquireLock(lockFile, retry = true) {
99
+ try {
100
+ const handle = await open(lockFile, "wx");
101
+ try {
102
+ await handle.writeFile(
103
+ JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }),
104
+ "utf8",
105
+ );
106
+ } finally {
107
+ await handle.close();
108
+ }
109
+ // The O_EXCL create above is the lock; the content write closes the
110
+ // reader-visible window. Contenders finding an unparseable lock fail
111
+ // closed below and never remove it while it is fresh.
112
+ return;
113
+ } catch (err) {
114
+ if (err.code !== "EEXIST") {
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ const owner = await readLockOwner(lockFile);
120
+ if (owner && pidAlive(owner.pid)) {
121
+ throw new Error(
122
+ `State is locked by a live process (pid ${owner.pid}, started ${owner.startedAt ?? "unknown"}).`,
123
+ );
124
+ }
125
+
126
+ if (!owner && (await lockAgeMs(lockFile)) < STALE_LOCK_GRACE_MS) {
127
+ // Unparseable and fresh: the creator may still be between create and
128
+ // content write, so it is never treated as stale here.
129
+ throw new Error("State is locked (the lock file is not readable yet; retry shortly).");
130
+ }
131
+
132
+ if (!retry) {
133
+ throw new Error("State lock could not be acquired after stale removal.");
134
+ }
135
+
136
+ logWarn(`removing stale state lock (dead pid ${owner?.pid ?? "unknown"})`);
137
+ await rm(lockFile, { force: true });
138
+ return acquireLock(lockFile, false);
139
+ }
140
+
141
+ export function pidAlive(pid) {
142
+ try {
143
+ process.kill(pid, 0);
144
+ return true;
145
+ } catch (err) {
146
+ return err.code === "EPERM";
147
+ }
148
+ }
149
+
150
+ async function readLockOwner(lockFile) {
151
+ try {
152
+ const value = JSON.parse(await readFile(lockFile, "utf8"));
153
+ if (value === null || typeof value !== "object" || !Number.isInteger(value.pid)) {
154
+ return null;
155
+ }
156
+ return value;
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ // An unparseable lock younger than this is assumed to be a contender still
163
+ // between create and content write; only an older one is stale.
164
+ export const STALE_LOCK_GRACE_MS = 60_000;
165
+
166
+ async function lockAgeMs(lockFile) {
167
+ try {
168
+ const stats = await stat(lockFile);
169
+ return Date.now() - stats.mtimeMs;
170
+ } catch {
171
+ return 0;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Reads and parses the state file. Returns null when absent; a corrupt file
177
+ * throws so the caller exits instead of guessing the lifecycle.
178
+ */
179
+ export async function readState(stateFile) {
180
+ let text;
181
+ try {
182
+ text = await readFile(stateFile, "utf8");
183
+ } catch (err) {
184
+ if (err.code === "ENOENT") {
185
+ return null;
186
+ }
187
+ throw err;
188
+ }
189
+ const value = JSON.parse(text);
190
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
191
+ throw new Error(`State file is not a JSON object: ${stateFile}`);
192
+ }
193
+ return value;
194
+ }
195
+
196
+ export async function writeState(stateFile, state) {
197
+ const temp = `${stateFile}.${process.pid}.tmp`;
198
+ await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
199
+ // Node rename replaces an existing destination on Windows and POSIX.
200
+ await rename(temp, stateFile);
201
+ }
202
+
203
+ export async function appendSessionIndex(sessionIndexFile, stateFile) {
204
+ // The index entry is overwritten by the next init call from the same session.
205
+ await mkdir(dirname(sessionIndexFile), { recursive: true });
206
+ return writeFile(sessionIndexFile, `${stateFile}\n`, "utf8");
207
+ }