@lnilluv/pi-ralph-loop 1.0.0 → 1.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.
package/README.md CHANGED
@@ -57,6 +57,9 @@ Stop with <promise>DONE</promise> only when the gate passes.
57
57
  | `/ralph [path-or-task]` | Run an existing task folder or `RALPH.md`, or draft a new loop from a task description. |
58
58
  | `/ralph-draft [path-or-task]` | Draft or edit a loop without starting it. |
59
59
  | `/ralph-stop [path-or-task]` | Request a graceful stop after the current iteration. |
60
+ | `/ralph-cancel [path-or-task]` | Cancel the active iteration immediately. |
61
+ | `/ralph-scaffold <name-or-path>` | Create a non-interactive RALPH.md starter template. |
62
+ | `/ralph-logs [--path <task>] [--dest <dir>]` | Export run logs to an external directory. |
60
63
 
61
64
  ## Config reference
62
65
  | Field | Purpose |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lnilluv/pi-ralph-loop",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Pi-native ralph loop — autonomous coding iterations with mid-turn supervision",
5
5
  "type": "module",
6
6
  "pi": {
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join, relative, resolve } from "node:path";
4
4
  import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, AgentEndEvent as PiAgentEndEvent, ToolResultEvent as PiToolResultEvent } from "@mariozechner/pi-coding-agent";
5
5
  import {
@@ -30,6 +30,8 @@ import { runRalphLoop } from "./runner.ts";
30
30
  import {
31
31
  checkStopSignal,
32
32
  createStopSignal,
33
+ createCancelSignal,
34
+ checkCancelSignal,
33
35
  listActiveLoopRegistryEntries,
34
36
  readActiveLoopRegistry,
35
37
  readIterationRecords,
@@ -119,6 +121,106 @@ type StopTarget = {
119
121
  source: StopTargetSource;
120
122
  };
121
123
 
124
+ type ResolveRalphTargetResult =
125
+ | { kind: "resolved"; taskDir: string }
126
+ | { kind: "not-found" };
127
+
128
+ function resolveRalphTarget(
129
+ ctx: Pick<CommandContext, "cwd" | "sessionManager" | "ui">,
130
+ options: {
131
+ commandName: string;
132
+ explicitPath?: string;
133
+ checkCrossProcess?: boolean;
134
+ allowCompletedRuns?: boolean;
135
+ },
136
+ ): ResolveRalphTargetResult | undefined {
137
+ const { commandName, explicitPath, checkCrossProcess = false, allowCompletedRuns = false } = options;
138
+ const now = new Date().toISOString();
139
+ const activeRegistryEntries = () => listActiveLoopRegistryEntries(ctx.cwd);
140
+ const { target: sessionTarget } = resolveSessionStopTarget(ctx, now);
141
+ const resolvedExplicitPath = explicitPath?.trim();
142
+
143
+ if (resolvedExplicitPath) {
144
+ const inspection = inspectExistingTarget(resolvedExplicitPath, ctx.cwd, true);
145
+ if (inspection.kind === "run") {
146
+ const taskDir = dirname(inspection.ralphPath);
147
+ if (checkCrossProcess) {
148
+ const registryTarget = activeRegistryEntries().find((entry) => entry.taskDir === taskDir);
149
+ if (registryTarget) {
150
+ return { kind: "resolved", taskDir: registryTarget.taskDir };
151
+ }
152
+
153
+ const statusFile = readStatusFile(taskDir);
154
+ if (
155
+ statusFile &&
156
+ (statusFile.status === "running" || statusFile.status === "initializing") &&
157
+ typeof statusFile.cwd === "string" &&
158
+ statusFile.cwd.length > 0
159
+ ) {
160
+ const statusRegistryTarget = listActiveLoopRegistryEntries(statusFile.cwd).find(
161
+ (entry) => entry.taskDir === taskDir && entry.loopToken === statusFile.loopToken,
162
+ );
163
+ if (statusRegistryTarget) {
164
+ return { kind: "resolved", taskDir: statusRegistryTarget.taskDir };
165
+ }
166
+ }
167
+ }
168
+
169
+ return { kind: "resolved", taskDir };
170
+ }
171
+
172
+ if (allowCompletedRuns) {
173
+ const taskDir = resolve(ctx.cwd, resolvedExplicitPath);
174
+ if (existsSync(join(taskDir, ".ralph-runner"))) {
175
+ return { kind: "resolved", taskDir };
176
+ }
177
+ ctx.ui.notify(`No ralph run data found at ${displayPath(ctx.cwd, taskDir)}.`, "error");
178
+ return { kind: "not-found" };
179
+ }
180
+
181
+ if (inspection.kind === "invalid-markdown") {
182
+ ctx.ui.notify(`Only task folders or RALPH.md can be stopped directly. ${displayPath(ctx.cwd, inspection.path)} is not stoppable.`, "error");
183
+ return undefined;
184
+ }
185
+ if (inspection.kind === "invalid-target") {
186
+ ctx.ui.notify(`Only task folders or RALPH.md can be stopped directly. ${displayPath(ctx.cwd, inspection.path)} is a file, not a task folder.`, "error");
187
+ return undefined;
188
+ }
189
+ if (inspection.kind === "dir-without-ralph" || inspection.kind === "missing-path") {
190
+ ctx.ui.notify(`No active ralph loop found at ${displayPath(ctx.cwd, inspection.dirPath)}.`, "warning");
191
+ return { kind: "not-found" };
192
+ }
193
+
194
+ ctx.ui.notify(`${commandName} expects a task folder or RALPH.md path.`, "error");
195
+ return undefined;
196
+ }
197
+
198
+ if (sessionTarget) {
199
+ return { kind: "resolved", taskDir: sessionTarget.taskDir };
200
+ }
201
+
202
+ const activeEntries = activeRegistryEntries();
203
+ if (activeEntries.length === 0) {
204
+ ctx.ui.notify(
205
+ allowCompletedRuns
206
+ ? `No ralph run data found. Specify a task path with ${commandName} <path>.`
207
+ : "No active ralph loops found.",
208
+ "warning",
209
+ );
210
+ return { kind: "not-found" };
211
+ }
212
+
213
+ if (activeEntries.length > 1) {
214
+ ctx.ui.notify(
215
+ `Multiple active ralph loops found. Use ${commandName} <task folder or RALPH.md> for an explicit target path.`,
216
+ "error",
217
+ );
218
+ return undefined;
219
+ }
220
+
221
+ return { kind: "resolved", taskDir: activeEntries[0].taskDir };
222
+ }
223
+
122
224
  type ToolEvent = {
123
225
  toolName?: string;
124
226
  toolCallId?: string;
@@ -717,6 +819,81 @@ function displayPath(cwd: string, filePath: string): string {
717
819
  return rel && !rel.startsWith("..") ? `./${rel}` : filePath;
718
820
  }
719
821
 
822
+ function exportRalphLogs(taskDir: string, destDir: string): { iterations: number; events: number; transcripts: number } {
823
+ const runnerDir = join(taskDir, ".ralph-runner");
824
+ if (!existsSync(runnerDir)) {
825
+ throw new Error(`No .ralph-runner directory found at ${taskDir}`);
826
+ }
827
+
828
+ mkdirSync(destDir, { recursive: true });
829
+
830
+ const filesToCopy = ["status.json", "iterations.jsonl", "events.jsonl"];
831
+ for (const file of filesToCopy) {
832
+ const src = join(runnerDir, file);
833
+ if (existsSync(src)) {
834
+ copyFileSync(src, join(destDir, file));
835
+ }
836
+ }
837
+
838
+ // Copy transcripts directory
839
+ const transcriptsDir = join(runnerDir, "transcripts");
840
+ let transcripts = 0;
841
+ if (existsSync(transcriptsDir)) {
842
+ const destTranscripts = join(destDir, "transcripts");
843
+ mkdirSync(destTranscripts, { recursive: true });
844
+ for (const entry of readdirSync(transcriptsDir)) {
845
+ const srcPath = join(transcriptsDir, entry);
846
+ try {
847
+ const stat = lstatSync(srcPath);
848
+ if (stat.isFile() && !stat.isSymbolicLink()) {
849
+ copyFileSync(srcPath, join(destTranscripts, entry));
850
+ transcripts++;
851
+ }
852
+ } catch {
853
+ // skip unreadable entries
854
+ }
855
+ }
856
+ }
857
+
858
+ // Count iterations and events
859
+ let iterations = 0;
860
+ let events = 0;
861
+ const iterPath = join(destDir, "iterations.jsonl");
862
+ if (existsSync(iterPath)) {
863
+ iterations = readFileSync(iterPath, "utf8").split("\n").filter((l) => l.trim()).length;
864
+ }
865
+ const evPath = join(destDir, "events.jsonl");
866
+ if (existsSync(evPath)) {
867
+ events = readFileSync(evPath, "utf8").split("\n").filter((l) => l.trim()).length;
868
+ }
869
+
870
+ return { iterations, events, transcripts };
871
+ }
872
+
873
+ export function parseLogExportArgs(raw: string): { path?: string; dest?: string; error?: string } {
874
+ const parts = raw.trim().split(/\s+/);
875
+ let path: string | undefined;
876
+ let dest: string | undefined;
877
+ let i = 0;
878
+ while (i < parts.length) {
879
+ if (parts[i] === "--dest" || parts[i] === "-d") {
880
+ if (i + 1 >= parts.length) return { error: "--dest requires a directory path" };
881
+ dest = parts[i + 1];
882
+ i += 2;
883
+ } else if (parts[i] === "--path" || parts[i] === "-p") {
884
+ if (i + 1 >= parts.length) return { error: "--path requires a task path" };
885
+ path = parts[i + 1];
886
+ i += 2;
887
+ } else if (!path && parts[i]) {
888
+ path = parts[i];
889
+ i++;
890
+ } else {
891
+ i++;
892
+ }
893
+ }
894
+ return { path, dest };
895
+ }
896
+
720
897
  async function promptForTask(ctx: Pick<CommandContext, "hasUI" | "ui">, title: string, placeholder: string): Promise<string | undefined> {
721
898
  if (!ctx.hasUI) return undefined;
722
899
  const value = await ctx.ui.input(title, placeholder);
@@ -955,6 +1132,32 @@ function applyStopTarget(
955
1132
  let loopState: LoopState = defaultLoopState();
956
1133
  const RALPH_EXTENSION_REGISTERED = Symbol.for("pi-ralph-loop.registered");
957
1134
 
1135
+ function scaffoldRalphTemplate(): string {
1136
+ return `---
1137
+ max_iterations: 10
1138
+ timeout: 120
1139
+ commands: []
1140
+ ---
1141
+ # {{ task.name }}
1142
+
1143
+ Describe the task here.
1144
+
1145
+ ## Evidence
1146
+ Use {{ commands.* }} outputs as evidence.
1147
+
1148
+ ## Completion
1149
+ Stop with <promise>DONE</promise> when finished.
1150
+ `;
1151
+ }
1152
+
1153
+ function slugifyTaskName(text: string): string {
1154
+ return text
1155
+ .toLowerCase()
1156
+ .trim()
1157
+ .replace(/[^a-z0-9]+/g, "-")
1158
+ .replace(/^-+|-+$/g, "");
1159
+ }
1160
+
958
1161
  export default function (pi: ExtensionAPI, services: RegisterRalphCommandServices = {}) {
959
1162
  const registeredPi = pi as ExtensionAPI & Record<symbol, boolean | undefined>;
960
1163
  if (registeredPi[RALPH_EXTENSION_REGISTERED]) return;
@@ -1032,6 +1235,7 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
1032
1235
 
1033
1236
  async function startRalphLoop(ralphPath: string, ctx: CommandContext, runLoopFn: typeof runRalphLoop = runRalphLoop, runtimeArgs: RuntimeArgs = {}) {
1034
1237
  let name: string;
1238
+ let currentStopOnError = true;
1035
1239
  try {
1036
1240
  const raw = readFileSync(ralphPath, "utf8");
1037
1241
  const draftError = validateDraftContent(raw);
@@ -1042,6 +1246,7 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
1042
1246
  const parsed = parseRalphMarkdown(raw);
1043
1247
  const { frontmatter } = parsed;
1044
1248
  if (!validateFrontmatter(frontmatter, ctx)) return;
1249
+ currentStopOnError = frontmatter.stopOnError;
1045
1250
  const runtimeValidationError = validateRuntimeArgs(frontmatter, parsed.body, frontmatter.commands, runtimeArgs);
1046
1251
  if (runtimeValidationError) {
1047
1252
  ctx.ui.notify(runtimeValidationError, "error");
@@ -1078,6 +1283,7 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
1078
1283
  timeout: loopState.timeout,
1079
1284
  maxIterations: loopState.maxIterations,
1080
1285
  guardrails: loopState.guardrails,
1286
+ stopOnError: currentStopOnError,
1081
1287
  runtimeArgs,
1082
1288
  modelPattern: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
1083
1289
  thinkingLevel: ctx.model?.reasoning ? "high" : undefined,
@@ -1480,4 +1686,133 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
1480
1686
  applyStopTarget(pi, ctx, materializeRegistryStopTarget(activeEntries[0]), now);
1481
1687
  },
1482
1688
  });
1689
+
1690
+ pi.registerCommand("ralph-cancel", {
1691
+ description: "Cancel the active ralph iteration immediately",
1692
+ handler: async (args: string, ctx: CommandContext) => {
1693
+ const parsed = parseCommandArgs(args ?? "");
1694
+ if (parsed.error) {
1695
+ ctx.ui.notify(parsed.error, "error");
1696
+ return;
1697
+ }
1698
+ if (parsed.mode === "task") {
1699
+ ctx.ui.notify("/ralph-cancel expects a task folder or RALPH.md path, not task text.", "error");
1700
+ return;
1701
+ }
1702
+
1703
+ const result = resolveRalphTarget(ctx, {
1704
+ commandName: "/ralph-cancel",
1705
+ explicitPath: parsed.value || undefined,
1706
+ checkCrossProcess: true,
1707
+ });
1708
+ if (!result || result.kind === "not-found") return;
1709
+
1710
+ const statusPath = join(result.taskDir, ".ralph-runner", "status.json");
1711
+ if (!existsSync(statusPath)) {
1712
+ ctx.ui.notify(`No active loop found at ${displayPath(ctx.cwd, result.taskDir)}. No run data exists.`, "warning");
1713
+ return;
1714
+ }
1715
+
1716
+ const statusFile = readStatusFile(result.taskDir);
1717
+ const finishedStatuses = new Set([
1718
+ "complete",
1719
+ "max-iterations",
1720
+ "no-progress-exhaustion",
1721
+ "stopped",
1722
+ "timeout",
1723
+ "error",
1724
+ "cancelled",
1725
+ ]);
1726
+ if (statusFile?.status && finishedStatuses.has(statusFile.status)) {
1727
+ ctx.ui.notify(
1728
+ `No active loop found at ${displayPath(ctx.cwd, result.taskDir)}. The loop already ended with status: ${statusFile.status}.`,
1729
+ "warning",
1730
+ );
1731
+ return;
1732
+ }
1733
+
1734
+ createCancelSignal(result.taskDir);
1735
+ ctx.ui.notify("Cancel requested. The active iteration will be terminated immediately.", "warning");
1736
+ },
1737
+ });
1738
+
1739
+ pi.registerCommand("ralph-scaffold", {
1740
+ description: "Create a non-interactive RALPH.md starter template",
1741
+ handler: async (args: string, ctx: CommandContext) => {
1742
+ const name = (args ?? "").trim();
1743
+ if (!name) {
1744
+ ctx.ui.notify("/ralph-scaffold expects a task name or path.", "error");
1745
+ return;
1746
+ }
1747
+
1748
+ let taskDir: string;
1749
+ let ralphPath: string;
1750
+
1751
+ if (name.includes("/") || name.startsWith("./")) {
1752
+ ralphPath = resolve(ctx.cwd, name.endsWith("/RALPH.md") ? name : join(name, "RALPH.md"));
1753
+ taskDir = dirname(ralphPath);
1754
+ } else {
1755
+ const slug = slugifyTaskName(name);
1756
+ if (!slug) {
1757
+ ctx.ui.notify(`Cannot slugify "${name}" into a valid directory name.`, "error");
1758
+ return;
1759
+ }
1760
+ taskDir = join(ctx.cwd, slug);
1761
+ ralphPath = join(taskDir, "RALPH.md");
1762
+ }
1763
+
1764
+ const resolvedTaskDir = resolve(taskDir);
1765
+ const resolvedCwd = resolve(ctx.cwd);
1766
+ if (!resolvedTaskDir.startsWith(resolvedCwd + "/") && resolvedTaskDir !== resolvedCwd) {
1767
+ ctx.ui.notify("Task path must be within the current working directory.", "error");
1768
+ return;
1769
+ }
1770
+
1771
+ if (existsSync(ralphPath)) {
1772
+ ctx.ui.notify(`RALPH.md already exists at ${displayPath(ctx.cwd, ralphPath)}. Not overwriting.`, "error");
1773
+ return;
1774
+ }
1775
+
1776
+ if (existsSync(taskDir) && readdirSync(taskDir).length > 0) {
1777
+ ctx.ui.notify(`Directory ${displayPath(ctx.cwd, taskDir)} already exists and is not empty. Not overwriting.`, "error");
1778
+ return;
1779
+ }
1780
+
1781
+ mkdirSync(taskDir, { recursive: true });
1782
+ writeFileSync(ralphPath, scaffoldRalphTemplate(), "utf8");
1783
+ ctx.ui.notify(`Scaffolded ${displayPath(ctx.cwd, ralphPath)}`, "info");
1784
+ },
1785
+ });
1786
+
1787
+ pi.registerCommand("ralph-logs", {
1788
+ description: "Export run logs from a ralph task to an external directory",
1789
+ handler: async (args: string, ctx: CommandContext) => {
1790
+ const parsed = parseLogExportArgs(args ?? "");
1791
+ if (parsed.error) {
1792
+ ctx.ui.notify(parsed.error, "error");
1793
+ return;
1794
+ }
1795
+
1796
+ const resolvedTarget = resolveRalphTarget(ctx, {
1797
+ commandName: "/ralph-logs",
1798
+ explicitPath: parsed.path,
1799
+ allowCompletedRuns: true,
1800
+ });
1801
+ if (!resolvedTarget || resolvedTarget.kind === "not-found") return;
1802
+ const taskDir = resolvedTarget.taskDir;
1803
+
1804
+ // Resolve dest directory
1805
+ const destDir = parsed.dest
1806
+ ? resolve(ctx.cwd, parsed.dest)
1807
+ : join(ctx.cwd, `ralph-logs-${new Date().toISOString().replace(/[:.]/g, "-")}`);
1808
+
1809
+ try {
1810
+ const result = exportRalphLogs(taskDir, destDir);
1811
+ ctx.ui.notify(`Exported ${result.iterations} iteration records, ${result.events} events, ${result.transcripts} transcripts to ${displayPath(ctx.cwd, destDir)}`, "info");
1812
+ } catch (err) {
1813
+ const message = err instanceof Error ? err.message : String(err);
1814
+ ctx.ui.notify(`Log export failed: ${message}`, "error");
1815
+ }
1816
+ },
1817
+ });
1483
1818
  }
package/src/ralph.ts CHANGED
@@ -17,6 +17,7 @@ export type Frontmatter = {
17
17
  timeout: number;
18
18
  completionPromise?: string;
19
19
  requiredOutputs?: string[];
20
+ stopOnError: boolean;
20
21
  guardrails: { blockCommands: string[]; protectedFiles: string[] };
21
22
  invalidCommandEntries?: number[];
22
23
  invalidArgEntries?: number[];
@@ -483,7 +484,7 @@ function escapeHtmlCommentMarkers(text: string): string {
483
484
  }
484
485
 
485
486
  export function defaultFrontmatter(): Frontmatter {
486
- return { commands: [], maxIterations: 50, interIterationDelay: 0, timeout: 300, requiredOutputs: [], guardrails: { blockCommands: [], protectedFiles: [] } };
487
+ return { commands: [], maxIterations: 50, interIterationDelay: 0, timeout: 300, requiredOutputs: [], stopOnError: true, guardrails: { blockCommands: [], protectedFiles: [] } };
487
488
  }
488
489
 
489
490
  export function parseRalphMarkdown(raw: string): ParsedRalph {
@@ -514,6 +515,7 @@ export function parseRalphMarkdown(raw: string): ParsedRalph {
514
515
  completionPromise:
515
516
  typeof yaml.completion_promise === "string" && yaml.completion_promise.trim() ? yaml.completion_promise : undefined,
516
517
  requiredOutputs: toStringArray(yaml.required_outputs),
518
+ stopOnError: yaml.stop_on_error === false ? false : true,
517
519
  guardrails: {
518
520
  blockCommands: toStringArray(guardrails.block_commands),
519
521
  protectedFiles: toStringArray(guardrails.protected_files),
@@ -558,6 +560,9 @@ export function validateFrontmatter(fm: Frontmatter): string | null {
558
560
  }
559
561
  seenArgNames.add(arg);
560
562
  }
563
+ if (typeof fm.stopOnError !== "boolean") {
564
+ return "Invalid stop_on_error: must be true or false";
565
+ }
561
566
  for (const output of fm.requiredOutputs ?? []) {
562
567
  const requiredOutputError = validateRequiredOutputEntry(output);
563
568
  if (requiredOutputError) {
@@ -1137,6 +1142,7 @@ function buildDraftFrontmatter(mode: DraftMode, commands: CommandDef[]): Frontma
1137
1142
  interIterationDelay: 0,
1138
1143
  timeout: 300,
1139
1144
  requiredOutputs: [],
1145
+ stopOnError: true,
1140
1146
  guardrails,
1141
1147
  };
1142
1148
  }
@@ -1177,6 +1183,7 @@ function renderDraftPlan(task: string, mode: DraftMode, target: DraftTarget, fro
1177
1183
  `max_iterations: ${frontmatter.maxIterations}`,
1178
1184
  `inter_iteration_delay: ${frontmatter.interIterationDelay}`,
1179
1185
  `timeout: ${frontmatter.timeout}`,
1186
+ ...(frontmatter.stopOnError === false ? ["stop_on_error: false"] : []),
1180
1187
  ...(requiredOutputs.length > 0
1181
1188
  ? ["required_outputs:", ...requiredOutputs.map((output) => ` - ${yamlQuote(output)}`)]
1182
1189
  : []),
package/src/runner-rpc.ts CHANGED
@@ -34,6 +34,10 @@ export type RpcSubprocessConfig = {
34
34
  thinkingLevel?: string;
35
35
  /** Callback for observing events as they stream */
36
36
  onEvent?: (event: RpcEvent) => void;
37
+ /** AbortSignal for cooperative cancellation. On abort, the direct child process is SIGKILLed.
38
+ * Grandchild processes may survive — the caller is responsible for process group cleanup
39
+ * if full-tree termination is required. */
40
+ signal?: AbortSignal;
37
41
  };
38
42
 
39
43
  export type RpcTelemetry = {
@@ -55,6 +59,7 @@ export type RpcSubprocessResult = {
55
59
  lastAssistantText: string;
56
60
  agentEndMessages: unknown[];
57
61
  timedOut: boolean;
62
+ cancelled?: boolean;
58
63
  error?: string;
59
64
  telemetry: RpcTelemetry;
60
65
  };
@@ -126,6 +131,7 @@ export async function runRpcIteration(config: RpcSubprocessConfig): Promise<RpcS
126
131
  provider: explicitProvider,
127
132
  modelId: explicitModelId,
128
133
  onEvent,
134
+ signal,
129
135
  } = config;
130
136
 
131
137
  // Parse modelPattern ("provider/modelId" or "provider/modelId:thinking") into provider and modelId
@@ -201,7 +207,35 @@ export async function runRpcIteration(config: RpcSubprocessConfig): Promise<RpcS
201
207
  telemetry.lastEventType = eventType;
202
208
  };
203
209
 
204
- const timeout = setTimeout(() => {
210
+ let timeout: ReturnType<typeof setTimeout> | undefined;
211
+
212
+ const onAbort = () => {
213
+ if (settled) return;
214
+ settled = true;
215
+ telemetry.error = "cancelled";
216
+ try {
217
+ childProcess.kill("SIGKILL");
218
+ } catch {
219
+ // process may already be dead
220
+ }
221
+ clearTimeout(timeout);
222
+ resolve(buildResult({
223
+ success: false,
224
+ lastAssistantText,
225
+ agentEndMessages,
226
+ timedOut: false,
227
+ cancelled: true,
228
+ error: "cancelled",
229
+ }));
230
+ };
231
+
232
+ if (signal?.aborted) {
233
+ onAbort();
234
+ return;
235
+ }
236
+ signal?.addEventListener("abort", onAbort, { once: true });
237
+
238
+ timeout = setTimeout(() => {
205
239
  if (settled) return;
206
240
  settled = true;
207
241
  telemetry.timedOutAt = nowIso();
@@ -230,6 +264,7 @@ export async function runRpcIteration(config: RpcSubprocessConfig): Promise<RpcS
230
264
  const cleanup = () => {
231
265
  clearTimeout(timeout);
232
266
  endStdin();
267
+ signal?.removeEventListener("abort", onAbort);
233
268
  };
234
269
 
235
270
  const settle = (result: RpcSubprocessResult) => {
@@ -33,7 +33,7 @@ export type Guardrails = {
33
33
 
34
34
  export type IterationRecord = {
35
35
  iteration: number;
36
- status: "running" | "complete" | "timeout" | "error";
36
+ status: "running" | "complete" | "timeout" | "error" | "cancelled";
37
37
  startedAt: string;
38
38
  completedAt?: string;
39
39
  durationMs?: number;
@@ -146,7 +146,7 @@ export type IterationCompletedEvent = {
146
146
  timestamp: string;
147
147
  iteration: number;
148
148
  loopToken: string;
149
- status: "complete" | "timeout" | "error";
149
+ status: "complete" | "timeout" | "error" | "cancelled";
150
150
  progress: ProgressState;
151
151
  changedFiles: string[];
152
152
  noProgressStreak: number;
@@ -230,6 +230,7 @@ const STATUS_FILE = "status.json";
230
230
  const ITERATIONS_FILE = "iterations.jsonl";
231
231
  const EVENTS_FILE = "events.jsonl";
232
232
  const STOP_FLAG_FILE = "stop.flag";
233
+ const CANCEL_FLAG_FILE = "cancel.flag";
233
234
  const ACTIVE_LOOP_REGISTRY_DIR = "active-loops";
234
235
  const ACTIVE_LOOP_REGISTRY_LEGACY_FILE = "active-loops.json";
235
236
  const ACTIVE_LOOP_REGISTRY_FILE_EXTENSION = ".json";
@@ -328,7 +329,7 @@ function isCompletionGate(value: unknown): value is { ready: boolean; reasons: s
328
329
  }
329
330
 
330
331
  function isIterationCompletedStatus(value: unknown): value is IterationRecord["status"] {
331
- return value === "complete" || value === "timeout" || value === "error";
332
+ return value === "complete" || value === "timeout" || value === "error" || value === "cancelled";
332
333
  }
333
334
 
334
335
  function isRunnerEvent(value: unknown): value is RunnerEvent {
@@ -610,6 +611,22 @@ export function clearStopSignal(taskDir: string): void {
610
611
  }
611
612
  }
612
613
 
614
+ export function createCancelSignal(taskDir: string): void {
615
+ const dir = ensureRunnerDir(taskDir);
616
+ writeFileSync(join(dir, CANCEL_FLAG_FILE), "", "utf8");
617
+ }
618
+
619
+ export function checkCancelSignal(taskDir: string): boolean {
620
+ return existsSync(join(runnerDir(taskDir), CANCEL_FLAG_FILE));
621
+ }
622
+
623
+ export function clearCancelSignal(taskDir: string): void {
624
+ const filePath = join(runnerDir(taskDir), CANCEL_FLAG_FILE);
625
+ if (existsSync(filePath)) {
626
+ rmSync(filePath, { force: true });
627
+ }
628
+ }
629
+
613
630
  export function clearRunnerDir(taskDir: string): void {
614
631
  const dir = runnerDir(taskDir);
615
632
  if (existsSync(dir)) {