@lnilluv/pi-ralph-loop 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lnilluv/pi-ralph-loop",
3
- "version": "1.1.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 { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, 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 {
@@ -844,8 +844,8 @@ function exportRalphLogs(taskDir: string, destDir: string): { iterations: number
844
844
  for (const entry of readdirSync(transcriptsDir)) {
845
845
  const srcPath = join(transcriptsDir, entry);
846
846
  try {
847
- const stat = statSync(srcPath);
848
- if (stat.isFile()) {
847
+ const stat = lstatSync(srcPath);
848
+ if (stat.isFile() && !stat.isSymbolicLink()) {
849
849
  copyFileSync(srcPath, join(destTranscripts, entry));
850
850
  transcripts++;
851
851
  }
@@ -1707,6 +1707,30 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
1707
1707
  });
1708
1708
  if (!result || result.kind === "not-found") return;
1709
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
+
1710
1734
  createCancelSignal(result.taskDir);
1711
1735
  ctx.ui.notify("Cancel requested. The active iteration will be terminated immediately.", "warning");
1712
1736
  },
@@ -1737,6 +1761,13 @@ export default function (pi: ExtensionAPI, services: RegisterRalphCommandService
1737
1761
  ralphPath = join(taskDir, "RALPH.md");
1738
1762
  }
1739
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
+
1740
1771
  if (existsSync(ralphPath)) {
1741
1772
  ctx.ui.notify(`RALPH.md already exists at ${displayPath(ctx.cwd, ralphPath)}. Not overwriting.`, "error");
1742
1773
  return;
package/src/runner-rpc.ts CHANGED
@@ -34,6 +34,9 @@ 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. */
37
40
  signal?: AbortSignal;
38
41
  };
39
42
 
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import test from "node:test";
@@ -592,6 +592,18 @@ test("/ralph-cancel writes the cancel flag from persisted active loop state afte
592
592
  guardrails: { blockCommands: [], protectedFiles: [] },
593
593
  stopRequested: false,
594
594
  };
595
+ writeStatusFile(taskDir, {
596
+ loopToken: persistedState.loopToken,
597
+ ralphPath: join(taskDir, "RALPH.md"),
598
+ taskDir,
599
+ cwd,
600
+ status: "running",
601
+ currentIteration: 3,
602
+ maxIterations: 5,
603
+ timeout: 300,
604
+ startedAt: new Date().toISOString(),
605
+ guardrails: { blockCommands: [], protectedFiles: [] },
606
+ });
595
607
  const notifications: Array<{ message: string; level: string }> = [];
596
608
  const harness = createHarness();
597
609
  const handler = harness.handler("ralph-cancel");
@@ -624,6 +636,113 @@ test("/ralph-cancel writes the cancel flag from persisted active loop state afte
624
636
  assert.equal(notifications.some(({ message }) => message.includes("No active ralph loop")), false);
625
637
  });
626
638
 
639
+ test("/ralph-cancel refuses when the loop already finished", async (t) => {
640
+ const cwd = createTempDir();
641
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
642
+
643
+ const taskDir = join(cwd, "finished-loop-task");
644
+ mkdirSync(taskDir, { recursive: true });
645
+ writeStatusFile(taskDir, {
646
+ loopToken: "finished-loop-token",
647
+ ralphPath: join(taskDir, "RALPH.md"),
648
+ taskDir,
649
+ cwd,
650
+ status: "complete",
651
+ currentIteration: 3,
652
+ maxIterations: 5,
653
+ timeout: 300,
654
+ startedAt: new Date().toISOString(),
655
+ completedAt: new Date().toISOString(),
656
+ guardrails: { blockCommands: [], protectedFiles: [] },
657
+ });
658
+ const notifications: Array<{ message: string; level: string }> = [];
659
+ const harness = createHarness();
660
+ const handler = harness.handler("ralph-cancel");
661
+ let ctx: any;
662
+ ctx = {
663
+ cwd,
664
+ hasUI: true,
665
+ ui: {
666
+ notify: (message: string, level: string) => notifications.push({ message, level }),
667
+ select: async () => undefined,
668
+ input: async () => undefined,
669
+ editor: async () => undefined,
670
+ setStatus: () => undefined,
671
+ },
672
+ sessionManager: createSessionManager([
673
+ {
674
+ type: "custom",
675
+ customType: "ralph-loop-state",
676
+ data: {
677
+ active: true,
678
+ loopToken: "finished-loop-token",
679
+ cwd,
680
+ taskDir,
681
+ iteration: 3,
682
+ maxIterations: 5,
683
+ noProgressStreak: 0,
684
+ iterationSummaries: [],
685
+ guardrails: { blockCommands: [], protectedFiles: [] },
686
+ stopRequested: false,
687
+ },
688
+ },
689
+ ], "session-a"),
690
+ getRuntimeCtx: () => ctx,
691
+ };
692
+
693
+ await handler("", ctx);
694
+
695
+ assert.equal(existsSync(join(taskDir, ".ralph-runner", "cancel.flag")), false);
696
+ assert.ok(notifications.some(({ message, level }) => level === "warning" && message.includes("The loop already ended with status: complete.")));
697
+ });
698
+
699
+ test("/ralph-cancel refuses when no status.json exists", async (t) => {
700
+ const cwd = createTempDir();
701
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
702
+
703
+ const taskDir = join(cwd, "missing-status-task");
704
+ mkdirSync(taskDir, { recursive: true });
705
+ const notifications: Array<{ message: string; level: string }> = [];
706
+ const harness = createHarness();
707
+ const handler = harness.handler("ralph-cancel");
708
+ let ctx: any;
709
+ ctx = {
710
+ cwd,
711
+ hasUI: true,
712
+ ui: {
713
+ notify: (message: string, level: string) => notifications.push({ message, level }),
714
+ select: async () => undefined,
715
+ input: async () => undefined,
716
+ editor: async () => undefined,
717
+ setStatus: () => undefined,
718
+ },
719
+ sessionManager: createSessionManager([
720
+ {
721
+ type: "custom",
722
+ customType: "ralph-loop-state",
723
+ data: {
724
+ active: true,
725
+ loopToken: "missing-status-loop-token",
726
+ cwd,
727
+ taskDir,
728
+ iteration: 3,
729
+ maxIterations: 5,
730
+ noProgressStreak: 0,
731
+ iterationSummaries: [],
732
+ guardrails: { blockCommands: [], protectedFiles: [] },
733
+ stopRequested: false,
734
+ },
735
+ },
736
+ ], "session-a"),
737
+ getRuntimeCtx: () => ctx,
738
+ };
739
+
740
+ await handler("", ctx);
741
+
742
+ assert.equal(existsSync(join(taskDir, ".ralph-runner", "cancel.flag")), false);
743
+ assert.ok(notifications.some(({ message, level }) => level === "warning" && message.includes("No run data exists.")));
744
+ });
745
+
627
746
  test("/ralph-scaffold creates a parseable scaffold from a task name", async (t) => {
628
747
  const cwd = createTempDir();
629
748
  t.after(() => rmSync(cwd, { recursive: true, force: true }));
@@ -680,6 +799,35 @@ test("/ralph-scaffold accepts path-style arguments", async (t) => {
680
799
  assert.equal(existsSync(join(cwd, "feature", "new-task", "RALPH.md")), true);
681
800
  });
682
801
 
802
+ test("/ralph-scaffold rejects paths outside the current working directory", async (t) => {
803
+ const cwd = createTempDir();
804
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
805
+
806
+ const escapedTaskDir = join(cwd, "..", "escape");
807
+ t.after(() => rmSync(escapedTaskDir, { recursive: true, force: true }));
808
+
809
+ const notifications: Array<{ message: string; level: string }> = [];
810
+ const harness = createHarness();
811
+ const handler = harness.handler("ralph-scaffold");
812
+ const ctx = {
813
+ cwd,
814
+ hasUI: true,
815
+ ui: {
816
+ notify: (message: string, level: string) => notifications.push({ message, level }),
817
+ select: async () => undefined,
818
+ input: async () => undefined,
819
+ editor: async () => undefined,
820
+ setStatus: () => undefined,
821
+ },
822
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
823
+ };
824
+
825
+ await handler("../escape", ctx);
826
+
827
+ assert.equal(existsSync(join(escapedTaskDir, "RALPH.md")), false);
828
+ assert.deepEqual(notifications, [{ message: "Task path must be within the current working directory.", level: "error" }]);
829
+ });
830
+
683
831
  test("/ralph-scaffold refuses to overwrite an existing RALPH.md", async (t) => {
684
832
  const cwd = createTempDir();
685
833
  t.after(() => rmSync(cwd, { recursive: true, force: true }));
@@ -846,6 +994,42 @@ test("/ralph-logs excludes runtime control files", async (t) => {
846
994
  assert.equal(existsSync(join(exportedDir, "active-loops")), false);
847
995
  });
848
996
 
997
+ test("/ralph-logs skips symlinked transcript entries", async (t) => {
998
+ const cwd = createTempDir();
999
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
1000
+
1001
+ const taskDir = join(cwd, "my-task");
1002
+ mkdirSync(join(taskDir, ".ralph-runner", "transcripts"), { recursive: true });
1003
+ writeFileSync(join(taskDir, "RALPH.md"), "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n", "utf8");
1004
+ writeFileSync(join(taskDir, ".ralph-runner", "status.json"), JSON.stringify({ status: "running" }), "utf8");
1005
+ writeFileSync(join(taskDir, ".ralph-runner", "iterations.jsonl"), "{\"iteration\":1}\n", "utf8");
1006
+ writeFileSync(join(taskDir, ".ralph-runner", "events.jsonl"), "{\"event\":1}\n", "utf8");
1007
+ writeFileSync(join(taskDir, "secret.txt"), "top secret", "utf8");
1008
+ writeFileSync(join(taskDir, ".ralph-runner", "transcripts", "good.txt"), "good", "utf8");
1009
+ symlinkSync(join(taskDir, "secret.txt"), join(taskDir, ".ralph-runner", "transcripts", "leak.txt"));
1010
+
1011
+ const harness = createHarness();
1012
+ const handler = harness.handler("ralph-logs");
1013
+ const ctx = {
1014
+ cwd,
1015
+ hasUI: true,
1016
+ ui: {
1017
+ notify: () => undefined,
1018
+ select: async () => undefined,
1019
+ input: async () => undefined,
1020
+ editor: async () => undefined,
1021
+ setStatus: () => undefined,
1022
+ },
1023
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
1024
+ };
1025
+
1026
+ await handler("my-task --dest exported", ctx);
1027
+
1028
+ const exportedDir = join(cwd, "exported");
1029
+ assert.equal(existsSync(join(exportedDir, "transcripts", "good.txt")), true);
1030
+ assert.equal(existsSync(join(exportedDir, "transcripts", "leak.txt")), false);
1031
+ });
1032
+
849
1033
  test("/ralph reverse engineer this app with an injected llm-strengthened draft still shows review before start", async (t) => {
850
1034
  const cwd = createTempDir();
851
1035
  t.after(() => rmSync(cwd, { recursive: true, force: true }));