Compare commits

..

7 Commits

Author SHA1 Message Date
c1a65f68ac port: emit publish workflow into omp repos (independent npm releases)
All checks were successful
port-to-omp / verify (push) Successful in 5s
port-to-omp / publish (push) Successful in 4s
2026-08-12 19:37:05 -04:00
1aec74216a add scripts/release-tag.sh (bump package.json, tag, push; triggers port + npm publish CI)
Some checks failed
port-to-omp / verify (push) Failing after 2s
port-to-omp / publish (push) Has been skipped
2026-08-12 19:31:37 -04:00
807704aabe add npm publish workflow (v* tags): publishes pi + omp packages via NPM_TOKEN
All checks were successful
port-to-omp / verify (push) Successful in 7s
port-to-omp / publish (push) Successful in 5s
2026-08-12 19:27:09 -04:00
3cc7c0469b fix: resume/restart logic fixed
All checks were successful
port-to-omp / verify (push) Successful in 9s
port-to-omp / publish (push) Successful in 5s
2026-08-12 15:24:34 -04:00
c870efa15a port fix
All checks were successful
port-to-omp / verify (push) Successful in 7s
port-to-omp / publish (push) Successful in 6s
2026-08-12 14:13:14 -04:00
100262b94f feat: hang detection and resumable task sessions
Some checks failed
port-to-omp / port (push) Failing after 4s
- runAgentSession gains an inactivity watchdog (execution.inactivityTimeoutMs,
  default 0 = off): when no session event arrives within the window, the
  session is aborted (agent abort + bash subprocess kill) with a clear
  "inactivity timeout" error
- agent sessions persist to .ralpi/sessions/*.jsonl; resume reopens the
  JSONL via SessionManager.open so an interrupted task continues with its
  prior conversation instead of restarting from scratch
- progress.json tracks sessionFile per task (persisted at session creation,
  so kill/reload mid-run is resumable); the first attempt after resume
  reuses it, failover retries stay fresh; corrupt/missing files fall back
  to a fresh session with a warning
- bump version to 0.6.0
2026-08-12 12:55:44 -04:00
c2525a6411 fix: skip agent spawn on zero-conflict merges; carry prior review context across passes
- resolveConflictsSession: guard against reattemptMerge returning
  clean=false with zero unmerged paths (branch already merged, dirty
  index, stale ref). Previously fell through to spawning a full agent
  session with no conflicts to resolve. Now tries completeMerge or
  aborts without spawning.

- Review-gated loop: accumulate rejected reviews in a priorReviews
  array and inject them into buildReviewPrompt via a new
  ReviewPromptOptions.priorReviews field. The reviewer now sees prior
  findings and can verify they were addressed instead of re-reviewing
  from scratch each pass. Added renderPriorReviews() helper and wired
  it into both buildReviewPrompt and buildReviewPromptUncommitted.
2026-08-12 12:07:48 -04:00
12 changed files with 781 additions and 399 deletions

View File

@@ -1,7 +1,17 @@
name: port-to-omp name: port-to-omp
# Regenerate the omp port of ralpi from this repo and push it to # Keep the omp port of ralpi in lockstep with this repo.
# Mike/omp-ralpi. #
# Two jobs:
# - verify: port to a scratch dir, then typecheck + test the ported tree on
# EVERY push and PR. Source drift — a base change that breaks a port op —
# fails here, on the branch that introduced it, before it reaches master.
# (The ported tree contains the full test suite, so this also gives the
# base repo its CI test coverage.)
# - publish: regenerate the omp port into Mike/omp-ralpi and push it, but
# only on master, and only after verify passed — a broken port can never
# ship. The port commit lands in the omp repo, never here, so this
# workflow cannot re-trigger itself.
# #
# Prerequisites on git.freno.me: # Prerequisites on git.freno.me:
# - an access token with write:repository scope, stored as the repo secret # - an access token with write:repository scope, stored as the repo secret
@@ -10,16 +20,32 @@ name: port-to-omp
# - the omp repo must exist (Mike/omp-ralpi) # - the omp repo must exist (Mike/omp-ralpi)
# #
# Manual run: Actions tab → Run workflow (workflow_dispatch), or push. # Manual run: Actions tab → Run workflow (workflow_dispatch), or push.
# Safe by construction: the port commit lands in the omp repo, never here, so
# this workflow cannot re-trigger itself.
on: on:
push: push:
branches: [master] pull_request:
workflow_dispatch:
jobs: jobs:
port: verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install bun
uses: oven-sh/setup-bun@v2
- name: Port to scratch dir (drift check)
run: |
set -euo pipefail
bun port-to-omp.mjs --out "$RUNNER_TEMP/omp-port-check"
cd "$RUNNER_TEMP/omp-port-check"
bun run typecheck
bun test
publish:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout

View File

@@ -0,0 +1,51 @@
name: publish
# Publish this package and its omp port to the npm registry on version tags.
#
# Prerequisites on npmjs.com / git.freno.me:
# - an npm automation token (publish scope) stored as the repo secret
# NPM_TOKEN (consumed via actions/setup-node registry auth)
# - the scoped package names claimed on npm (@mikefreno/<name> and
# @mikefreno/omp-<name>)
#
# Both packages ship TypeScript sources — nothing is built — so lifecycle
# scripts (prepublishOnly tsc) are skipped: the port workflow already
# typechecks the generated port against the pinned @oh-my-pi SDK, and the pi
# package typechecks against the global pi SDK that CI does not install.
#
# Manual publish: Actions tab → Run workflow (workflow_dispatch).
on:
push:
tags: ['v*']
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install bun
uses: oven-sh/setup-bun@v2
- name: Setup node (npm registry auth)
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Publish pi package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public --ignore-scripts
- name: Port to omp
run: bun port-to-omp.mjs --out "$RUNNER_TEMP/omp-port"
- name: Publish omp package
working-directory: ${{ runner.temp }}/omp-port
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public --ignore-scripts

View File

@@ -36,7 +36,7 @@ is unavailable).
- `index.ts` — extension entry, command registration (`ralpi`, `ralpi-run`, - `index.ts` — extension entry, command registration (`ralpi`, `ralpi-run`,
`ralpi-plan`, `ralpi-resume`, `ralpi-reset`), execution-mode + loop-option `ralpi-plan`, `ralpi-resume`, `ralpi-reset`), execution-mode + loop-option
prompts, reload auto-resume via `session_start`, progress message renderer prompts, progress message renderer
- `src/` — all logic modules: - `src/` — all logic modules:
- `parser.ts` — task file parsing (Fio/README numbered, phased, checkbox, - `parser.ts` — task file parsing (Fio/README numbered, phased, checkbox,
YAML formats), dependency + parallel-group + timeout parsing, YAML formats), dependency + parallel-group + timeout parsing,
@@ -69,18 +69,16 @@ is unavailable).
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory): All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
- `.ralpi/progress.json` — execution progress, supports multiple PRDs - `.ralpi/progress.json` — execution progress, supports multiple PRDs
- `.ralpi/loop-active.json` — marker written while a loop runs; drives - `.ralpi/loop-active.json` — marker written while a loop runs; snapshots the
auto-resume after a session reload mode + loop options so `/ralpi-resume` can continue non-interactively
- `.ralpi/reflections/` — per-task reflection JSON files - `.ralpi/reflections/` — per-task reflection JSON files
- `.ralpi/reviews/<prdKey>/` — full review output JSON (only when - `.ralpi/reviews/<prdKey>/` — full review output JSON (only when
`saveReviews` is on) `saveReviews` is on)
- `.ralpi/sessions/` — agent session JSONL files per task (persisted so an
interrupted task can be resumed from its prior conversation)
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging) - `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
- `.ralpi/config.yaml` — project-level config (optional) - `.ralpi/config.yaml` — project-level config (optional)
There is no `.ralpi/sessions/` directory anymore — full task output is shown
inline via expandable `ralpi-progress` chat messages, and review output is
persisted under `.ralpi/reviews/`.
## Task ID convention ## Task ID convention
Task IDs are zero-padded strings (`"01"`, `"02"`, etc.) with an optional Task IDs are zero-padded strings (`"01"`, `"02"`, etc.) with an optional
@@ -93,8 +91,10 @@ use raw numeric IDs.
- `/ralpi` — no args → show plan for `README.md`; first token looks like a - `/ralpi` — no args → show plan for `README.md`; first token looks like a
path (`@path`, `./path`, `.md`, `.yaml`, etc.) → run; anything else → path (`@path`, `./path`, `.md`, `.yaml`, etc.) → run; anything else →
error suggesting the dash commands error suggesting the dash commands
- `/ralpi-run [task-file]` — run tasks (auto-resumes when progress already - `/ralpi-run [task-file]` — run tasks (always prompts for execution mode +
exists for the file; otherwise prompts for execution mode + loop options) loop options; a cancelled loop for the file is continued from its progress
with the newly chosen settings — it never silently resumes with the old
loop's settings, only `/ralpi-resume` does that)
- `/ralpi-plan [prompt]` — loads the bundled `prompts/task-manager.md` - `/ralpi-plan [prompt]` — loads the bundled `prompts/task-manager.md`
template and sends it as a user message. Pi's `sendUserMessage()` sends template and sends it as a user message. Pi's `sendUserMessage()` sends
with `expandPromptTemplates: false`, so the extension does its own with `expandPromptTemplates: false`, so the extension does its own
@@ -124,6 +124,9 @@ Key config fields in `execution`:
- `autoCommit` / `autoReview` / `saveReviews` — loop options (selectable at - `autoCommit` / `autoReview` / `saveReviews` — loop options (selectable at
loop startup via `selectLoopOptions`; review is asked FIRST, commit is loop startup via `selectLoopOptions`; review is asked FIRST, commit is
mandated when review is on) mandated when review is on)
- `inactivityTimeoutMs` — hang detection: if no agent session event arrives
within this window (e.g. a bash subprocess that never returns), the task is
aborted (agent abort + bash subprocess kill). `0` = disabled (default)
- `models` — slot-aware round-robin model list for parallel mode, with - `models` — slot-aware round-robin model list for parallel mode, with
failover to the next model per task (only after exhausting same-model failover to the next model per task (only after exhausting same-model
retries, see `maxSameModelAttempts`) retries, see `maxSameModelAttempts`)

283
index.ts
View File

@@ -306,9 +306,10 @@ async function executePlanBatches(
); );
} }
// Write loop-active marker so a session reload can detect an interrupted // Write the loop-active marker so an interrupted loop can be resumed
// loop and resume it (in-process agent sessions die on reload — the marker // non-interactively via /ralpi-resume: it snapshots the execution mode and
// + progress.json in_progress tasks are the signal to re-run them). // loop options (autoCommit/autoReview/saveReviews) that /ralpi-resume
// would otherwise re-prompt for.
if (projectDir) { if (projectDir) {
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id)); const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
writeLoopActive(projectDir, { writeLoopActive(projectDir, {
@@ -764,237 +765,6 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
}, },
); );
// ─── Reload detection: resume interrupted loops when session reloads ──
//
// ralpi runs task agent sessions in-process (createAgentSession), so they
// do NOT survive a /reload. When the new session starts, this handler
// reads the persisted loop-active marker + progress.json: if any task is
// still `in_progress`, the loop was interrupted mid-task and we resume it
// (resetting those tasks to pending so the DAG re-schedules them), using
// the mode + loop options snapshotted in loop-active.json so the resume is
// non-interactive.
pi.on("session_start", async (event, ctx) => {
if (event.reason !== "reload") return;
// Find the ralpi project directory
const projectDir = findRalpiDir(ctx.cwd);
if (!projectDir) return;
// Check if a task execution loop was active before the reload
const loopState = readLoopActive(projectDir);
if (!loopState) return;
// The auto-resume path has no CLI flag, so the gitignore guard is
// always on: keep `.ralpi/` out of the user's repo on reload too.
ensureRalpiIgnored(projectDir);
// Load progress state
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
/** Re-read progress from disk. */
const readTasks = (): Record<string, { status: string }> | null => {
try {
const raw = fs.readFileSync(progressPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, any>;
return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null;
} catch {
return null;
}
};
// ralpi agent sessions run in-process (createAgentSession), so they do
// NOT survive a session reload. Any task left `in_progress` is therefore
// stalled — its agent died with the previous session. Detect that state
// and actively resume the loop instead of passively polling (which would
// spin forever waiting for a dead task to complete).
const initialTasks = readTasks();
if (initialTasks) {
const inProgressIds = Object.entries(initialTasks).flatMap(([id, t]) =>
t.status === "in_progress" ? [id] : [],
);
// Build the sendProgress wrapper so resumed task messages render the
// same expandable tool-call tree as an interactive run. Defined before
// the finalize path below so it can report self-healed merges.
const sendProgress: SendChatMessage = (
content: string,
meta?: {
toolCalls?: Array<{ name: string; label: string }>;
reviewText?: string;
reviewPath?: string;
reviewResult?: ReviewResult;
},
) => {
pi.sendMessage({
customType: "ralpi-progress",
content,
display: true,
details: {
phase: "progress",
toolCalls: meta?.toolCalls,
reviewText: meta?.reviewText,
reviewPath: meta?.reviewPath,
reviewResult: meta?.reviewResult,
},
});
};
if (inProgressIds.length === 0) {
// Nothing was mid-flight — the loop either finished cleanly between
// the reload landing and this handler running, or was stopped
// between tasks. Either way, committed worktree branches from an
// interrupted loop may still be unmerged (e.g. a prior resume
// attempt reset tasks to pending before it was itself interrupted).
// Finalize those first so committed code lands in the workspace,
// persist the state to progress.json, update the PRD file, THEN
// clean up the stale marker.
try {
const config = loadConfig(projectDir);
// Clear any half-done merge left by an interrupted
// conflict-resolution session (it would block every merge below).
abortMerge(projectDir);
const allIds = Object.entries(initialTasks).flatMap(([id, t]) =>
t.status !== "failed" && t.status !== "pending" ? [id] : [],
);
const fin = finalizeCommittedWorktrees(
projectDir,
config.paths.stateDir,
loopState.prdKey,
allIds,
);
// Persist finalized tasks to progress.json + PRD file so the
// state is correct for subsequent /ralpi resume calls.
const stateDir = config.paths.stateDir;
const progressPath = path.join(projectDir, stateDir, "progress.json");
// Batch-update progress.json and PRD file for all finalized tasks
if (fin.finalized.length > 0) {
const progressRaw = fs.existsSync(progressPath)
? JSON.parse(fs.readFileSync(progressPath, "utf-8"))
: null;
for (const id of fin.finalized) {
sendProgress?.(
`${id} — finalized on resume (committed branch merged into main)`,
);
if (progressRaw) {
const tasks =
progressRaw.prds?.[loopState.prdKey]?.tasks ??
progressRaw.tasks;
if (tasks && tasks[id]) {
tasks[id].status = "completed";
tasks[id].completedAt = new Date().toISOString();
}
}
try {
const prdPath = loopState.taskFile;
if (fs.existsSync(prdPath)) {
updateTaskInFile(prdPath, id, "completed");
}
} catch {
// Best-effort
}
}
if (progressRaw) {
fs.writeFileSync(
progressPath,
JSON.stringify(progressRaw, null, 2),
"utf-8",
);
}
}
// ── Handle conflicted tasks ──
// Same logic as resumeLoop: reset to pending so the DAG can
// re-schedule them, keep the worktree for in-place re-run.
const conflictIds = Object.keys(fin.conflicts);
if (conflictIds.length > 0) {
const detail = conflictIds
.map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`)
.join("; ");
// Batch-reset all conflicted tasks to pending, then write once
const progressRaw = fs.existsSync(progressPath)
? JSON.parse(fs.readFileSync(progressPath, "utf-8"))
: null;
for (const id of conflictIds) {
if (progressRaw) {
const tasks =
progressRaw.prds?.[loopState.prdKey]?.tasks ??
progressRaw.tasks;
if (tasks && tasks[id]) {
tasks[id].status = "pending";
delete tasks[id].startedAt;
delete tasks[id].error;
}
}
try {
const prdPath = loopState.taskFile;
if (fs.existsSync(prdPath)) {
updateTaskInFile(prdPath, id, "pending");
}
} catch {
// Best-effort
}
}
if (progressRaw) {
fs.writeFileSync(
progressPath,
JSON.stringify(progressRaw, null, 2),
"utf-8",
);
}
ctx.ui.notify(
`Reset ${conflictIds.length} conflicted task(s) to pending for re-execution (${detail})`,
"info",
);
}
} catch {
// Best-effort — the marker is removed either way; the worktrees
// stay on disk for a manual /ralpi-resume.
}
ctx.ui.notify(
"ralpi loop has no in-progress task to resume — marking complete.",
"info",
);
deleteLoopActive(projectDir);
return;
}
const taskCount = loopState.taskIds.length;
ctx.ui.notify(
`ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` +
`Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`,
"info",
);
// Load config from the project directory so model + thinking level
// resolve the same way the interactive command handler does.
const config = loadConfig(projectDir);
try {
await resumeLoop(
ctx,
loopState.taskFile,
projectDir,
loopState.prdKey,
sendProgress,
config.model ?? ctx.model,
pi.getThinkingLevel(),
{
mode: loopState.mode,
autoCommit: loopState.autoCommit ?? config.execution.autoCommit,
autoReview: loopState.autoReview ?? config.execution.autoReview,
saveReviews: loopState.saveReviews ?? config.execution.saveReviews,
skipFinalStatus: false,
},
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`ralpi auto-resume failed: ${msg}`, "error");
// Leave loop-active.json in place so the user can retry via
// /ralpi resume after addressing the underlying error.
}
return;
}
});
pi.registerCommand("ralpi", { pi.registerCommand("ralpi", {
description: description:
"Execute tasks from a task file using DAG-based dependency resolution", "Execute tasks from a task file using DAG-based dependency resolution",
@@ -1118,38 +888,15 @@ async function handleRun(
const noGitignore = stripNoGitignore(args); const noGitignore = stripNoGitignore(args);
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd); const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
// If targeting a specific task file and there's existing progress for it, // A cancelled loop for this file is continued below AFTER re-prompting for
// auto-resume instead of starting fresh // settings — /ralpi-run never silently resumes with the old loop's
// settings (only /ralpi-resume does that). isResume below keeps the
// interrupted task's worktree so it continues rather than restarts.
const existingProgress = findProgressFile(ctx.cwd, taskFile); const existingProgress = findProgressFile(ctx.cwd, taskFile);
if (existingProgress) {
return handleResume(
ctx,
args.slice(0, 1),
sendChatMessage,
parentModel,
parentThinkingLevel,
);
}
// No existing progress for this task — check for any progress at all // Resolve the project root from any existing progress so running from a
// subdirectory still targets the loop's project.
const found = findProgressFile(ctx.cwd); const found = findProgressFile(ctx.cwd);
if (found && !args[0]) {
// Offer to resume instead of starting fresh
const shouldResume = await ctx.ui.select(
"Found existing ralpi progress. Resume?",
["Yes, resume", "No, start fresh"],
);
if (shouldResume?.startsWith("Yes")) {
return handleResume(
ctx,
[],
sendChatMessage,
parentModel,
parentThinkingLevel,
);
}
}
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd; const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
ensureIgnoredNote(projectDir, ctx, noGitignore); ensureIgnoredNote(projectDir, ctx, noGitignore);
@@ -1196,6 +943,7 @@ async function handleRun(
mode, mode,
sendChatMessage, sendChatMessage,
projectDir, projectDir,
!!existingProgress, // preserve in-progress worktrees from a cancelled loop
); );
const state = progress.getState(); const state = progress.getState();
@@ -1217,11 +965,10 @@ async function handleRun(
/** /**
* Resume core: given a resolved task file, project dir, and PRD key, * Resume core: given a resolved task file, project dir, and PRD key,
* build the remaining plan and execute it. Used by both the explicit * build the remaining plan and execute it.
* `/ralpi resume` command and the auto-resume on session reload.
* *
* `mode` and loop options (`autoCommit`/`autoReview`/`saveReviews`) may be * `mode` and loop options (`autoCommit`/`autoReview`/`saveReviews`) may be
* passed to skip interactive prompts — this is how a reload resumes * passed to skip interactive prompts — this is how /ralpi-resume resumes
* non-interactively using the snapshot stored in loop-active.json. * non-interactively using the snapshot stored in loop-active.json.
* When omitted, the user is prompted as usual. * When omitted, the user is prompted as usual.
*/ */
@@ -1477,8 +1224,8 @@ async function handleResume(
// Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews) // Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews)
// persisted when the loop started, so an interrupted loop resumes // persisted when the loop started, so an interrupted loop resumes
// non-interactively — matching the auto-resume-on-reload path. Only fall // non-interactively. Only fall back to interactive prompts when no
// back to interactive prompts when no snapshot is present. // snapshot is present.
const snapshot = readLoopActive(projectDir); const snapshot = readLoopActive(projectDir);
const loopOpts = (() => { const loopOpts = (() => {
if ( if (

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mikefreno/ralpi", "name": "@mikefreno/ralpi",
"version": "0.5.0", "version": "0.6.0",
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking", "description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
"keywords": [ "keywords": [
"pi-package", "pi-package",

View File

@@ -25,7 +25,14 @@ import {
rmSync, rmSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { join, relative, resolve, isAbsolute, dirname, basename } from "node:path"; import {
join,
relative,
resolve,
isAbsolute,
dirname,
basename,
} from "node:path";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { homedir } from "node:os"; import { homedir } from "node:os";
@@ -35,6 +42,7 @@ const OMP = join(HOME, ".omp", "agent", "extensions");
const SKIP = new Set([ const SKIP = new Set([
"port-to-omp.mjs", "port-to-omp.mjs",
"release-tag.sh",
".gitea", ".gitea",
".github", ".github",
"node_modules", "node_modules",
@@ -50,11 +58,62 @@ const OMP_SDK = "17.2.12";
// ─── helpers ──────────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────────
/** Appended to op failures: the base repo is the source of truth, so a failed
* assertion means the source drifted and the op needs updating — never a
* reason to weaken the assertion. */
const DRIFT_HINT =
"\n\nBase repo drifted since this op was written — update the op (from/to) in" +
" port-to-omp.mjs. The verify CI job runs this script on every push, so this" +
" should surface on the branch that introduced the drift.";
/** Point at the first divergence between an expected op target and the actual
* file text, with a little surrounding context. */
function driftHint(src, from) {
const fromLines = from.split("\n");
const srcLines = src.split("\n");
const needle = fromLines[0].slice(0, 60);
// indexOf failed, so no full match exists. Anchor on the occurrence of the
// op's opening line that shares the LONGEST consecutive run with the
// expected text — a bare ` try {` can match unrelated blocks.
let anchor = -1;
let bestRun = -1;
for (let i = 0; i < srcLines.length; i++) {
if (!srcLines[i].includes(needle)) continue;
let run = 0;
while (run < fromLines.length && fromLines[run] === srcLines[i + run]) run++;
if (run > bestRun) {
bestRun = run;
anchor = i;
}
}
if (anchor === -1) {
return `\n\ncould not locate the op's opening line anywhere in the file:\n ${fromLines[0]}`;
}
const ctx = 2;
const i = Math.min(bestRun, fromLines.length - 1);
const before = srcLines
.slice(Math.max(0, anchor + i - ctx), anchor + i)
.map((l) => ` ${l}`);
const after = srcLines
.slice(anchor + i + 1, anchor + i + 1 + ctx)
.map((l) => ` ${l}`);
return (
`\n\nop matches at file line ${anchor + 1} for ${bestRun} line(s), then diverges at expected line ${i + 1}:\n` +
` expected: ${fromLines[i]}\n` +
` actual : ${srcLines[anchor + i] ?? "<end of file>"}\n` +
(before.length ? `\nactual context before:\n${before.join("\n")}\n` : "") +
(after.length ? `\nactual context after:\n${after.join("\n")}` : "")
);
}
function assertEdit(src, from, to, file, label = "") { function assertEdit(src, from, to, file, label = "") {
const idx = src.indexOf(from); const idx = src.indexOf(from);
if (idx === -1) { if (idx === -1) {
throw new Error( throw new Error(
`[${file}] target not found${label ? ` (${label})` : ""}:\n${from.slice(0, 300)}`, `[${file}] target not found${label ? ` (${label})` : ""}${driftHint(
src,
from,
)}\n` + `\nexpected target text:\n${from}${DRIFT_HINT}`,
); );
} }
return src.slice(0, idx) + to + src.slice(idx + from.length); return src.slice(0, idx) + to + src.slice(idx + from.length);
@@ -63,7 +122,12 @@ function assertEdit(src, from, to, file, label = "") {
function replaceAll(src, from, to, file, label) { function replaceAll(src, from, to, file, label) {
const parts = src.split(from); const parts = src.split(from);
if (parts.length === 1) { if (parts.length === 1) {
throw new Error(`[${file}] target not found${label ? ` (${label})` : ""}:\n${from.slice(0, 300)}`); throw new Error(
`[${file}] target not found${label ? ` (${label})` : ""}${driftHint(
src,
from,
)}\n` + `\nexpected target text:\n${from}${DRIFT_HINT}`,
);
} }
return parts.join(to); return parts.join(to);
} }
@@ -71,7 +135,9 @@ function replaceAll(src, from, to, file, label) {
function reEdit(src, re, to, file, label) { function reEdit(src, re, to, file, label) {
const out = src.replace(re, to); const out = src.replace(re, to);
if (out === src) { if (out === src) {
throw new Error(`[${file}] regex matched nothing (${label}): ${re}`); throw new Error(
`[${file}] regex matched nothing (${label}): ${re}${DRIFT_HINT}`,
);
} }
return out; return out;
} }
@@ -83,7 +149,9 @@ function reAll(src, re, to, file, label) {
return typeof to === "function" ? to(...args) : to; return typeof to === "function" ? to(...args) : to;
}); });
if (count === 0) { if (count === 0) {
throw new Error(`[${file}] regex matched nothing (${label}): ${re}`); throw new Error(
`[${file}] regex matched nothing (${label}): ${re}${DRIFT_HINT}`,
);
} }
return out; return out;
} }
@@ -92,9 +160,13 @@ function reAll(src, re, to, file, label) {
function applyOps(src, ops, file) { function applyOps(src, ops, file) {
for (const op of ops) { for (const op of ops) {
if ("from" in op) { if ("from" in op) {
src = op.all ? replaceAll(src, op.from, op.to, file, op.label) : assertEdit(src, op.from, op.to, file, op.label); src = op.all
? replaceAll(src, op.from, op.to, file, op.label)
: assertEdit(src, op.from, op.to, file, op.label);
} else { } else {
src = op.all ? reAll(src, op.re, op.to, file, op.label) : reEdit(src, op.re, op.to, file, op.label); src = op.all
? reAll(src, op.re, op.to, file, op.label)
: reEdit(src, op.re, op.to, file, op.label);
} }
} }
return src; return src;
@@ -110,7 +182,8 @@ function mirrorTree(srcDir, dstDir) {
// drop stale files in dst that no longer exist in src; keep .git* intact // drop stale files in dst that no longer exist in src; keep .git* intact
for (const rel of walk(dstDir)) { for (const rel of walk(dstDir)) {
if (rel.startsWith(".git")) continue; if (rel.startsWith(".git")) continue;
if (!existsSync(join(srcDir, rel))) rmSync(join(dstDir, rel), { force: true, recursive: true }); if (!existsSync(join(srcDir, rel)))
rmSync(join(dstDir, rel), { force: true, recursive: true });
} }
} }
@@ -137,15 +210,17 @@ function assertDstOutsideSrc(srcDir, dstDir) {
const rel = relative(real(srcDir), real(dstDir)); const rel = relative(real(srcDir), real(dstDir));
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) { if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
throw new Error( throw new Error(
`refusing to port into a subdirectory of the source: ${dstDir} is inside ${srcDir}` `refusing to port into a subdirectory of the source: ${dstDir} is inside ${srcDir}`,
); );
} }
}function walk(dir) { }
function walk(dir) {
const out = []; const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) { for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (SKIP.has(entry.name)) continue; if (SKIP.has(entry.name)) continue;
const p = join(dir, entry.name); const p = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(p).map((r) => join(entry.name, r))); if (entry.isDirectory())
out.push(...walk(p).map((r) => join(entry.name, r)));
else out.push(entry.name); else out.push(entry.name);
} }
return out; return out;
@@ -166,7 +241,8 @@ function rewriteSpecifiers(src) {
// ─── package.json transforms ──────────────────────────────────────────────── // ─── package.json transforms ────────────────────────────────────────────────
function pkgName(piName) { function pkgName(piName) {
if (piName.startsWith("@mikefreno/")) return piName.replace(/^@mikefreno\//, "@mikefreno/omp-"); if (piName.startsWith("@mikefreno/"))
return piName.replace(/^@mikefreno\//, "@mikefreno/omp-");
return `@mikefreno/omp-${piName}`; return `@mikefreno/omp-${piName}`;
} }
@@ -177,27 +253,45 @@ function reorder(obj, keys) {
return out; return out;
} }
const PKG_RULES = { const PKG_RULES = {
order: ["name", "version", "description", "keywords", "author", "license", "homepage", "repository", "bugs", "files", "scripts", "engines", "omp", "dependencies", "publishConfig", "devDependencies"], order: [
transform(p) { "name",
p.name = pkgName(p.name); "version",
p.keywords = ["omp", "omp-extension", ...p.keywords.slice(2)]; "description",
delete p.scripts.prepublishOnly; "keywords",
p.engines.bun = ">=1.3.14"; "author",
p.omp = p.pi; "license",
delete p.pi; "homepage",
delete p.omp.prompts; "repository",
delete p.peerDependencies; "bugs",
p.devDependencies = { "files",
"@oh-my-pi/pi-coding-agent": OMP_SDK, "scripts",
"@oh-my-pi/pi-tui": OMP_SDK, "engines",
...p.devDependencies, "omp",
}; "dependencies",
}, "publishConfig",
}; "devDependencies",
],
transform(p) {
p.name = pkgName(p.name);
p.keywords = ["omp", "omp-extension", ...p.keywords.slice(2)];
delete p.scripts.prepublishOnly;
p.engines.bun = ">=1.3.14";
p.omp = p.pi;
delete p.pi;
delete p.omp.prompts;
delete p.peerDependencies;
p.devDependencies = {
"@oh-my-pi/pi-coding-agent": OMP_SDK,
"@oh-my-pi/pi-tui": OMP_SDK,
...p.devDependencies,
};
},
};
function transformPkg() { function transformPkg() {
const raw = JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8")); const raw = JSON.parse(
readFileSync(join(import.meta.dir, "package.json"), "utf8"),
);
if (!raw.pi) throw new Error('expected "pi" manifest key in package.json'); if (!raw.pi) throw new Error('expected "pi" manifest key in package.json');
const rule = PKG_RULES; const rule = PKG_RULES;
rule.transform(raw); rule.transform(raw);
@@ -217,76 +311,75 @@ omp install @mikefreno/omp-ralpi
This is the omp port of [Mike/ralpi](https://git.freno.me/Mike/ralpi), regenerated automatically from the source repo. See the source repo for full documentation. This is the omp port of [Mike/ralpi](https://git.freno.me/Mike/ralpi), regenerated automatically from the source repo. See the source repo for full documentation.
`; `;
const FILE_RULES = {
"src/utils.ts": [
{
from:
'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport {\n createAgentSession,\n DefaultResourceLoader,\n getAgentDir,\n SessionManager,\n SettingsManager,\n type ModelRuntime,\n} from "@oh-my-pi/pi-coding-agent";',
to:
'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport type { ModelRegistry } from "@oh-my-pi/pi-coding-agent";\nimport {\n AgentRegistry,\n createAgentSession,\n getAgentDir,\n SessionManager,\n Settings,\n} from "@oh-my-pi/pi-coding-agent";',
label: "import block",
},
{ from: "/** Path to the global ralpi config under the user's Pi home directory. */", to: "/** Path to the global ralpi config under the user's omp home directory. */" },
{ from: ' process.env.HOME || "/tmp",\n ".pi",\n "ralpi",', to: ' process.env.HOME || "/tmp",\n ".omp",\n "ralpi",', label: "GLOBAL_CONFIG_PATH" },
{ from: "~/.pi/ralpi/config.yaml", to: "~/.omp/ralpi/config.yaml", all: true, label: "config path mentions" },
{
from:
" /** Parent session's model runtime. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh runtime from models.json only — extension providers are lost. */\n modelRuntime?: ModelRuntime,",
to:
" /** Parent session's model registry. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh registry from models.json only — extension providers are lost. */\n modelRegistry?: ModelRegistry,",
label: "modelRuntime signature",
},
{
from:
" try {\n // Loop sessions load the full normal pi context: extensions (so all\n // extension-provided tools register), skills, and project context\n // (AGENTS.md / CLAUDE.md)\n const loader = new DefaultResourceLoader({\n cwd,\n agentDir: getAgentDir(),\n noSkills,\n noPromptTemplates: true,\n noThemes: true,\n noExtensions: false,\n noContextFiles: false,\n });\n await loader.reload();\n\n const result = await createAgentSession({\n cwd,\n sessionManager: SessionManager.inMemory(),\n resourceLoader: loader,\n settingsManager: SettingsManager.create(cwd, getAgentDir()),\n modelRuntime,\n // No `tools` allowlist: matches a normal pi session's tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n });",
to:
" try {\n // Loop sessions load the full normal omp context: extensions (so all\n // extension-provided tools register) and project context (AGENTS.md).\n const result = await createAgentSession({\n cwd,\n sessionManager: SessionManager.inMemory(cwd),\n settingsManager: Settings.init({ cwd, agentDir: getAgentDir() }),\n // Loop sessions intentionally load extensions (no disableExtensionDiscovery),\n // plus skills and project context via default discovery.\n skills: noSkills ? [] : undefined,\n promptTemplates: [],\n // No `tools` allowlist: matches a normal omp session's tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n modelRegistry,\n agentRegistry: new AgentRegistry(),\n });",
label: "runAgentSession body",
},
],
"index.ts": [
{
from:
'import type {\n\tExtensionAPI,\n\tExtensionContext,\n} from "@oh-my-pi/pi-coding-agent";',
to:
'import type {\n\tExtensionAPI,\n\tExtensionContext,\n\tSessionStartEvent,\n} from "@oh-my-pi/pi-coding-agent";',
label: "SessionStartEvent import",
},
{
from: '\tpi.on("session_start", async (event, ctx) => {\n\t\tif (event.reason !== "reload") return;',
to:
'\tpi.on("session_start", async (event: SessionStartEvent, ctx) => {\n\t\t// omp\'s SessionStartEvent has no reason/reload field; the in_progress-task\n\t\t// check below already scopes recovery to genuinely interrupted loops (a\n\t\t// completed loop has no in_progress tasks), so recovery runs on any start\n\t\t// where a stalled loop marker exists.',
label: "session_start handler",
},
],
"src/executor.ts": [
{
from:
'import type {\n\tExtensionContext,\n\tModelRuntime,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";',
to:
'import type {\n\tExtensionContext,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";',
label: "ModelRuntime import removal",
},
{ from: "(ctx.modelRegistry as any).runtime as ModelRuntime,", to: "ctx.modelRegistry,", all: true, label: "modelRegistry pass-through" },
{ from: "\t// Pi's built-in retry (via SettingsManager) handles transient HTTP errors\n\t// with exponential backoff WITHIN a single prompt. Ralpi adds two layers on", to: "\t// The agent's built-in retry handles transient HTTP errors with exponential\n\t// backoff WITHIN a single prompt. Ralpi adds two layers on", label: "retry comment" },
{
from:
'\t\tcase "find":\n\t\t\treturn sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);\n\t\tcase "ls":\n\t\t\treturn sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));',
to: '\t\tcase "glob":\n\t\t\treturn sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);',
label: "tool labeler find/ls",
},
],
"src/task-manager-prompt.ts": [
{
from:
'import * as fs from "node:fs";\nimport * as path from "node:path";\nimport { stripFrontmatter } from "@oh-my-pi/pi-coding-agent";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");',
to:
'import * as fs from "node:fs";\nimport * as path from "node:path";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");\n\n/**\n * Strip leading YAML frontmatter (--- delimited) from template content.\n * Local port of the helper omp does not export from the package root.\n */\nfunction stripFrontmatter(content: string): string {\n const m = /^---\\r?\\n[\\s\\S]*?\\r?\\n---\\r?\\n/.exec(content);\n return m ? content.slice(m[0].length) : content;\n}',
label: "vendor stripFrontmatter",
},
],
}; // publish workflow emitted into the port repo so the omp package can be
// released independently (tag push or manual dispatch on the omp repo).
const PORT_PUBLISH_WORKFLOW = "name: publish\n\n# Publish this omp port package to the npm registry on version tags.\n#\n# Prerequisites:\n# - npm automation token (publish scope) stored as the repo secret NPM_TOKEN\n# - package name claimed on npm (@mikefreno/omp-<name>)\n#\n# Manual publish: Actions tab → Run workflow (workflow_dispatch).\n\non:\n push:\n tags: ['v*']\n workflow_dispatch:\n\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n\n - name: Setup node (npm registry auth)\n uses: actions/setup-node@v4\n with:\n node-version: '22'\n registry-url: 'https://registry.npmjs.org'\n\n - name: Publish\n env:\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n run: npm publish --access public --ignore-scripts\n";
const FILE_RULES = {
"src/utils.ts": [
{
from: 'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport {\n createAgentSession,\n DefaultResourceLoader,\n getAgentDir,\n SessionManager,\n SettingsManager,\n type ModelRuntime,\n} from "@oh-my-pi/pi-coding-agent";',
to: 'import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";\nimport type { ModelRegistry } from "@oh-my-pi/pi-coding-agent";\nimport {\n AgentRegistry,\n createAgentSession,\n getAgentDir,\n SessionManager,\n Settings,\n} from "@oh-my-pi/pi-coding-agent";',
label: "import block",
},
{
from: "/** Path to the global ralpi config under the user's Pi home directory. */",
to: "/** Path to the global ralpi config under the user's omp home directory. */",
},
{
from: ' process.env.HOME || "/tmp",\n ".pi",\n "ralpi",',
to: ' process.env.HOME || "/tmp",\n ".omp",\n "ralpi",',
label: "GLOBAL_CONFIG_PATH",
},
{
from: "~/.pi/ralpi/config.yaml",
to: "~/.omp/ralpi/config.yaml",
all: true,
label: "config path mentions",
},
{
from: " /** Parent session's model runtime. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh runtime from models.json only — extension providers are lost. */\n modelRuntime?: ModelRuntime,",
to: " /** Parent session's model registry. Must be passed so extension-registered\n * providers (e.g., neuralwatt with its streamSimple wrapper for 429\n * rate-limit normalization) are available. When omitted, the SDK creates\n * a fresh registry from models.json only — extension providers are lost. */\n modelRegistry?: ModelRegistry,",
label: "modelRuntime signature",
},
{
from: ' try {\n // Loop sessions load the full normal pi context: extensions (so all\n // extension-provided tools register), skills, and project context\n // (AGENTS.md / CLAUDE.md)\n const loader = new DefaultResourceLoader({\n cwd,\n agentDir: getAgentDir(),\n noSkills,\n noPromptTemplates: true,\n noThemes: true,\n noExtensions: false,\n noContextFiles: false,\n });\n await loader.reload();\n\n // Persist sessions under the ralpi project\'s `.ralpi/sessions/` so they\n // survive worktree removal and are findable from the main repo on resume.\n // Worktrees live inside `<project>/.ralpi/worktrees/...`, so walking up\n // from the agent\'s cwd always finds the main project\'s `.ralpi` first.\n const ralpiDir = findRalpiDir(cwd);\n const sessionDir = ralpiDir\n ? path.join(ralpiDir, ".ralpi", "sessions")\n : path.join(cwd, ".ralpi", "sessions");\n\n let sessionManager: SessionManager;\n if (resumeSessionFile && fs.existsSync(resumeSessionFile)) {\n sessionManager = SessionManager.open(resumeSessionFile, sessionDir, cwd);\n } else {\n if (resumeSessionFile) {\n console.warn(\n `[ralpi] resume session file not found (${resumeSessionFile}) — starting a fresh session`,\n );\n }\n sessionManager = SessionManager.create(cwd, sessionDir);\n }\n\n const result = await createAgentSession({\n cwd,\n sessionManager,\n resourceLoader: loader,\n settingsManager: SettingsManager.create(cwd, getAgentDir()),\n modelRuntime,\n // No `tools` allowlist: matches a normal pi session\'s tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n });',
to: ' try {\n // Loop sessions load the full normal omp context: extensions (so all\n // extension-provided tools register) and project context (AGENTS.md).\n // Persist sessions under the ralpi project\'s `.ralpi/sessions/` so they\n // survive worktree removal and are findable from the main repo on resume.\n // Worktrees live inside `<project>/.ralpi/worktrees/...`, so walking up\n // from the agent\'s cwd always finds the main project\'s `.ralpi` first.\n const ralpiDir = findRalpiDir(cwd);\n const sessionDir = ralpiDir\n ? path.join(ralpiDir, ".ralpi", "sessions")\n : path.join(cwd, ".ralpi", "sessions");\n\n let sessionManager: SessionManager;\n if (resumeSessionFile && fs.existsSync(resumeSessionFile)) {\n sessionManager = await SessionManager.open(resumeSessionFile, sessionDir, undefined, {\n initialCwd: cwd,\n });\n } else {\n if (resumeSessionFile) {\n console.warn(\n `[ralpi] resume session file not found (${resumeSessionFile}) — starting a fresh session`,\n );\n }\n sessionManager = SessionManager.create(cwd, sessionDir);\n }\n\n const result = await createAgentSession({\n cwd,\n sessionManager,\n settingsManager: Settings.init({ cwd, agentDir: getAgentDir() }),\n // Loop sessions intentionally load extensions (no disableExtensionDiscovery),\n // plus skills and project context via default discovery.\n skills: noSkills ? [] : undefined,\n promptTemplates: [],\n // No `tools` allowlist: matches a normal omp session\'s tool set.\n model: model as any,\n thinkingLevel: thinkingLevel as any,\n modelRegistry,\n agentRegistry: new AgentRegistry(),\n });',
label: "runAgentSession body",
},
],
"src/executor.ts": [
{
from: 'import type {\n\tExtensionContext,\n\tModelRuntime,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";',
to: 'import type {\n\tExtensionContext,\n\tAgentSessionEvent,\n} from "@oh-my-pi/pi-coding-agent";',
label: "ModelRuntime import removal",
},
{
from: "(ctx.modelRegistry as any).runtime as ModelRuntime,",
to: "ctx.modelRegistry,",
all: true,
label: "modelRegistry pass-through",
},
{
from: "\t// Pi's built-in retry (via SettingsManager) handles transient HTTP errors\n\t// with exponential backoff WITHIN a single prompt. Ralpi adds two layers on",
to: "\t// The agent's built-in retry handles transient HTTP errors with exponential\n\t// backoff WITHIN a single prompt. Ralpi adds two layers on",
label: "retry comment",
},
{
from: '\t\tcase "find":\n\t\t\treturn sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);\n\t\tcase "ls":\n\t\t\treturn sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));',
to: '\t\tcase "glob":\n\t\t\treturn sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);',
label: "tool labeler find/ls",
},
],
"src/task-manager-prompt.ts": [
{
from: 'import * as fs from "node:fs";\nimport * as path from "node:path";\nimport { stripFrontmatter } from "@oh-my-pi/pi-coding-agent";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");',
to: 'import * as fs from "node:fs";\nimport * as path from "node:path";\n\nconst TEMPLATE_REL = path.join("prompts", "task-manager.md");\n\n/**\n * Strip leading YAML frontmatter (--- delimited) from template content.\n * Local port of the helper omp does not export from the package root.\n */\nfunction stripFrontmatter(content: string): string {\n const m = /^---\\r?\\n[\\s\\S]*?\\r?\\n---\\r?\\n/.exec(content);\n return m ? content.slice(m[0].length) : content;\n}',
label: "vendor stripFrontmatter",
},
],
};
// ─── main ─────────────────────────────────────────────────────────────────── // ─── main ───────────────────────────────────────────────────────────────────
@@ -322,6 +415,10 @@ function portExtension() {
writeFileSync(p, text); writeFileSync(p, text);
} }
} }
const wfDir = join(dstDir, ".gitea", "workflows");
mkdirSync(wfDir, { recursive: true });
writeFileSync(join(wfDir, "publish.yml"), PORT_PUBLISH_WORKFLOW);
console.log("== bun install (regenerates bun.lock + node_modules)"); console.log("== bun install (regenerates bun.lock + node_modules)");
execSync("bun install", { cwd: dstDir, stdio: "inherit" }); execSync("bun install", { cwd: dstDir, stdio: "inherit" });
console.log("== done"); console.log("== done");

196
scripts/release-tag.sh Executable file
View File

@@ -0,0 +1,196 @@
#!/bin/bash
# release-tag.sh — version bump, commit, tag, and push for this pi extension.
#
# Mirrors the release flow from PodTui's scripts/release-tag.sh, adapted for
# the pi extension repos: the version lives in package.json, and the omp port
# derives its version from it during regeneration.
#
# Usage:
# scripts/release-tag.sh interactive release
# scripts/release-tag.sh --dry-run plan the bump/tag/pushes without doing
#
# After this script runs:
# - pushing master triggers the port-to-omp workflow, which regenerates and
# pushes the omp port (picking up the new version)
# - pushing the v* tag triggers the publish workflow, which npm-publishes
# this package and its omp port together
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
-h | --help)
echo "usage: $0 [--dry-run]"
exit 0
;;
esac
done
if [ ! -d .git ] && [ ! -f .git ]; then
echo -e "${RED}✗ not a git repository: $PROJECT_ROOT${NC}"
exit 1
fi
if ! git diff-index --quiet HEAD --; then
echo -e "${RED}✗ working tree is dirty — commit or stash before releasing${NC}"
git status --short
exit 1
fi
NAME=$(python3 -c "import json;print(json.load(open('package.json'))['name'])")
OMP_NAME=$(python3 -c "import json,re;n=json.load(open('package.json'))['name'];print(re.sub(r'^@mikefreno/(.+)$', r'@mikefreno/omp-\1', n))")
CURRENT_VERSION=$(python3 -c "import json;print(json.load(open('package.json'))['version'])")
echo -e "${CYAN}Package:${NC} ${GREEN}${NAME}${NC}"
echo -e "${CYAN}Current version:${NC} ${GREEN}v${CURRENT_VERSION}${NC}"
echo ""
IFS='.' read -r MAJOR MINOR PATCH <<<"$CURRENT_VERSION"
MAJOR=$(echo "$MAJOR" | sed 's/[^0-9].*//')
MINOR=$(echo "$MINOR" | sed 's/[^0-9].*//')
PATCH=$(echo "$PATCH" | sed 's/[^0-9].*//')
echo -e "${CYAN}Select version bump type:${NC}"
echo " 1) Major (breaking changes) ${MAJOR}.${MINOR}.${PATCH}$((MAJOR + 1)).0.0"
echo " 2) Minor (new features) ${MAJOR}.${MINOR}.${PATCH}${MAJOR}.$((MINOR + 1)).0"
echo " 3) Patch (bug fixes) ${MAJOR}.${MINOR}.${PATCH}${MAJOR}.${MINOR}.$((PATCH + 1))"
echo " 4) Custom version"
echo " 5) Cancel"
echo ""
read -p "Enter choice (1-5): " -n 1 -r CHOICE
echo ""
echo ""
case $CHOICE in
1) NEW_VERSION="$((MAJOR + 1)).0.0" ;;
2) NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" ;;
3) NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
4)
read -p "Enter new version (e.g. 0.6.1): " -r NEW_VERSION
echo ""
;;
5)
echo -e "${YELLOW}Cancelled.${NC}"
exit 0
;;
*)
echo -e "${RED}✗ invalid choice${NC}"
exit 1
;;
esac
if ! echo "$NEW_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo -e "${RED}✗ version must be MAJOR.MINOR.PATCH (got: ${NEW_VERSION})${NC}"
exit 1
fi
if git rev-parse -q --verify "refs/tags/v${NEW_VERSION}" >/dev/null; then
echo -e "${RED}✗ tag v${NEW_VERSION} already exists${NC}"
exit 1
fi
echo -e "${CYAN}New version:${NC} ${GREEN}v${NEW_VERSION}${NC}"
echo ""
echo -e "${YELLOW}This will:${NC}"
echo " 1. Set package.json → \"version\": \"${NEW_VERSION}\""
echo " 2. Commit the bump"
echo " 3. Create annotated tag v${NEW_VERSION}"
echo " 4. Push master and the tag to every remote"
REMOTES=$(git remote)
for r in $REMOTES; do
echo "$r"
done
echo ""
echo -e "${YELLOW}Note: master push triggers the port-to-omp workflow; the v* tag${NC}"
echo -e "push triggers the npm publish workflow (this package + omp port).${NC}"
echo ""
read -p "Proceed? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Aborted.${NC}"
exit 0
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo -e "${BLUE}── dry run: no changes made ──${NC}"
exit 0
fi
# ── Apply the bump ───────────────────────────────────────────────────────────
echo ""
echo -e "${CYAN}[1/4]${NC} Updating package.json..."
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"${NEW_VERSION}\"/" package.json
rm -f package.json.bak
echo -e "${GREEN}✓ package.json updated${NC}"
if git diff --quiet -- package.json; then
echo -e "${RED}✗ package.json did not change${NC}"
exit 1
fi
echo -e "${CYAN}[2/4]${NC} Committing..."
git add package.json
git commit -m "chore: bump version to v${NEW_VERSION}"
echo -e "${GREEN}✓ committed${NC}"
echo -e "${CYAN}[3/4]${NC} Tagging..."
git tag -a "v${NEW_VERSION}" -m "${NAME} v${NEW_VERSION}"
echo -e "${GREEN}✓ tagged v${NEW_VERSION}${NC}"
echo ""
echo -e "${CYAN}[4/4]${NC} Pushing..."
FAILED=""
for r in $REMOTES; do
echo -e "${BLUE}${r}${NC} master..."
if git push "$r" master >/dev/null 2>&1; then
echo -e " ${GREEN}✓ master pushed${NC}"
else
echo -e " ${RED}✗ master push failed${NC}"
FAILED="$FAILED $r"
continue
fi
echo -e "${BLUE}${r}${NC} v${NEW_VERSION}..."
if git push "$r" "v${NEW_VERSION}" >/dev/null 2>&1; then
echo -e " ${GREEN}✓ tag pushed${NC}"
else
echo -e " ${RED}✗ tag push failed${NC}"
FAILED="$FAILED $r"
fi
done
echo ""
if [ -n "$FAILED" ]; then
echo -e "${RED}✗ push failed for remote(s):${FAILED}${NC}"
echo -e "${YELLOW}Re-run: git push <remote> master && git push <remote> v${NEW_VERSION}${NC}"
exit 1
fi
echo -e "${GREEN}═══════════════════════════════════════${NC}"
echo -e "${GREEN}${NAME} v${NEW_VERSION} released${NC}"
echo -e "${GREEN}═══════════════════════════════════════${NC}"
echo ""
echo -e "${CYAN}Version:${NC} ${CURRENT_VERSION}${GREEN}${NEW_VERSION}${NC}"
echo -e "${CYAN}Tag:${NC} v${NEW_VERSION}"
echo ""
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
echo " 1. Gitea Actions port-to-omp regenerates and pushes the omp port"
echo " (which now carries version ${NEW_VERSION})"
echo " 2. Gitea Actions publish npm-publishes ${NAME} and"
echo " ${OMP_NAME} to the npm registry"
echo ""
echo -e "${CYAN}Local port (optional):${NC} run 'bun port-to-omp.mjs' in this repo, or"
echo "pull in ~/.omp once CI has pushed."

View File

@@ -245,6 +245,13 @@ export async function runTask(
/** Review feedback from a rejected review — injected when re-executing /** Review feedback from a rejected review — injected when re-executing
* a task in review-gated mode so the agent knows what to fix. */ * a task in review-gated mode so the agent knows what to fix. */
reviewFeedback?: ReviewResult, reviewFeedback?: ReviewResult,
/** Session JSONL from a prior interrupted run of this task. When set and
* readable, the agent session reopens it and continues from the prior
* conversation instead of starting fresh. */
resumeSessionFile?: string,
/** Called with the session file path as soon as the agent session is
* created, so the caller can persist it for a later resume. */
onSessionFile?: (sessionFile: string) => void,
): Promise<{ ): Promise<{
success: boolean; success: boolean;
reflection?: Reflection; reflection?: Reflection;
@@ -254,6 +261,12 @@ export async function runTask(
outputPreview?: string; outputPreview?: string;
commitMessages?: string[]; commitMessages?: string[];
commitSummary?: string; commitSummary?: string;
/** Path to the JSONL session file backing this run (for resume). */
sessionFile?: string;
/** True when a resume was requested but the session could not be opened
* from the file — the caller should clear the stored session file so
* retries start fresh. */
resumeFailed?: boolean;
}> { }> {
const startMs = Date.now(); const startMs = Date.now();
@@ -385,6 +398,9 @@ export async function runTask(
config.thinkingLevel, config.thinkingLevel,
false, // noSkills — task sessions need skills false, // noSkills — task sessions need skills
(ctx.modelRegistry as any).runtime as ModelRuntime, (ctx.modelRegistry as any).runtime as ModelRuntime,
config.execution.inactivityTimeoutMs,
resumeSessionFile,
onSessionFile,
); );
const durationMs = Date.now() - startMs; const durationMs = Date.now() - startMs;
@@ -409,6 +425,8 @@ export async function runTask(
success: false, success: false,
error: output.error, error: output.error,
durationMs, durationMs,
sessionFile: output.sessionFile,
resumeFailed: output.resumeFailed,
}; };
} }
@@ -439,6 +457,7 @@ export async function runTask(
outputPreview, outputPreview,
commitMessages, commitMessages,
commitSummary, commitSummary,
sessionFile: output.sessionFile,
}; };
} }
@@ -825,6 +844,12 @@ async function executeTask(
: null; : null;
const worktreeDir = wt?.dir ?? projectDir; const worktreeDir = wt?.dir ?? projectDir;
// Session file from a prior interrupted run of this task. The first
// attempt reopens it so the agent continues with its prior conversation
// (tool calls, findings) instead of restarting from scratch; failover
// retries start fresh.
const resumeSessionFile = progress.getSessionFile(task.id);
while (modelAttempt < maxModelAttempts) { while (modelAttempt < maxModelAttempts) {
// Model advancement happens in the cycling branch below (not here) so a // Model advancement happens in the cycling branch below (not here) so a
// same-model retry `continue` doesn't re-advance and accidentally swap // same-model retry `continue` doesn't re-advance and accidentally swap
@@ -885,6 +910,11 @@ async function executeTask(
currentModel, currentModel,
batchRender, batchRender,
priorReview, priorReview,
modelAttempt === 0 && sameModelAttempt === 0
? resumeSessionFile
: undefined,
(sessionFile) =>
progress.setSessionFile(task.id, sessionFile),
); );
if (result.success) { if (result.success) {
@@ -936,6 +966,10 @@ async function executeTask(
// A FAILED range computation (broken/stale base ref, git error) is // A FAILED range computation (broken/stale base ref, git error) is
// logged as a distinct warning and is never treated as a clean, // logged as a distinct warning and is never treated as a clean,
// verified task — only a GENUINE "no changes" skips review. // verified task — only a GENUINE "no changes" skips review.
// Accumulated rejected reviews from earlier passes — injected
// into the next review prompt so the reviewer sees prior
// findings and can verify they were addressed.
const priorReviews: ReviewResult[] = [];
while (true) { while (true) {
if (!baseRef) { if (!baseRef) {
sendChatMessage?.( sendChatMessage?.(
@@ -973,16 +1007,17 @@ async function executeTask(
reviewInfo.hash, reviewInfo.hash,
reviewInfo.subject, reviewInfo.subject,
reviewInfo.diff, reviewInfo.diff,
{ {
projectContext: config.prompts.projectContext, projectContext: config.prompts.projectContext,
focus: config.prompts.reviewFocus, focus: config.prompts.reviewFocus,
diffOptions: { priorReviews,
extraPatterns: compileIgnorePatterns( diffOptions: {
config.review.extraIgnorePatterns, extraPatterns: compileIgnorePatterns(
), config.review.extraIgnorePatterns,
ignorePaths: config.review.ignorePaths, ),
}, ignorePaths: config.review.ignorePaths,
}, },
},
); );
const reviewModel = resolveFollowUpModel( const reviewModel = resolveFollowUpModel(
@@ -1004,6 +1039,7 @@ async function executeTask(
`review-${task.id}`, `review-${task.id}`,
config.execution.reviewTimeoutMs, config.execution.reviewTimeoutMs,
reviewModels, reviewModels,
config.execution.inactivityTimeoutMs,
); );
if (!reviewResult.success) { if (!reviewResult.success) {
@@ -1097,6 +1133,9 @@ async function executeTask(
break; // changes already committed — merge proceeds break; // changes already committed — merge proceeds
} }
// Accumulate the rejected review so the next review pass
// sees prior findings and can verify they were addressed.
if (review) priorReviews.push(review);
attempt++; attempt++;
reviewRetries++; reviewRetries++;
sendChatMessage?.( sendChatMessage?.(
@@ -1293,6 +1332,7 @@ async function executeTask(
finalCommitSummary, finalCommitSummary,
finalReview, finalReview,
reviewRetries, reviewRetries,
result.sessionFile,
); );
// Auto-update the PRD source file checkbox // Auto-update the PRD source file checkbox
try { try {
@@ -1306,6 +1346,12 @@ async function executeTask(
// Agent session failed (provider error). // Agent session failed (provider error).
// Pi's built-in in-call retry already exhausted for this attempt. // Pi's built-in in-call retry already exhausted for this attempt.
// A resumed session that couldn't be opened (corrupt/missing
// JSONL) must not be retried — forget it so later attempts (and
// future resumes) start fresh.
if (result.resumeFailed) {
progress.setSessionFile(task.id, undefined);
}
// Reattempt on the SAME model a few more times before cycling — a // Reattempt on the SAME model a few more times before cycling — a
// transient outage can outlast pi's per-prompt backoff window. // transient outage can outlast pi's per-prompt backoff window.
sameModelAttempt++; sameModelAttempt++;
@@ -1438,6 +1484,8 @@ async function runFollowUpSession(
widgetKeySuffix: string, widgetKeySuffix: string,
timeoutMs: number, timeoutMs: number,
models: unknown[], models: unknown[],
/** Inactivity timeout for the session (see runAgentSession). */
inactivityTimeoutMs = 0,
): Promise<{ ): Promise<{
result: Awaited<ReturnType<typeof runAgentSession>>; result: Awaited<ReturnType<typeof runAgentSession>>;
toolCalls: ToolCallEntry[]; toolCalls: ToolCallEntry[];
@@ -1536,6 +1584,7 @@ async function runFollowUpSession(
config.thinkingLevel, config.thinkingLevel,
false, // noSkills=false — follow-up sessions load skills too false, // noSkills=false — follow-up sessions load skills too
(ctx.modelRegistry as any).runtime as ModelRuntime, (ctx.modelRegistry as any).runtime as ModelRuntime,
inactivityTimeoutMs,
); );
if (result.success) break; if (result.success) break;
@@ -1682,6 +1731,7 @@ async function runCommitSession(
`commit-${task.id}`, `commit-${task.id}`,
config.execution.commitTimeoutMs, config.execution.commitTimeoutMs,
commitModels, commitModels,
config.execution.inactivityTimeoutMs,
); );
if (commitResult.success) { if (commitResult.success) {
@@ -1779,6 +1829,52 @@ async function resolveConflictsSession(
return; return;
} }
// Merge failed but produced no unmerged paths — no real conflicts to
// resolve. This can happen when the branch tip was already merged by the
// first attempt (mergeWorktree) before it aborted, or when the merge
// fails for a non-conflict reason (dirty index, stale ref). Don't spawn
// an agent session for zero conflicts — abort or complete and finish.
if (attempt.conflicts.length === 0) {
// Try to complete whatever merge state exists; if there's nothing to
// commit, abort to leave the working tree clean.
if (completeMerge(projectDir)) {
sendChatMessage?.(
`✓ conflicts auto-resolved for ${task.id} · ${task.title}`,
);
removeWorktree(projectDir, worktree);
progress.markCompleted(
task.id,
0,
undefined,
undefined,
undefined,
[],
"",
undefined,
0,
);
try {
updateTaskInFile(project.sourcePath, task.id, "completed");
} catch {
// Best-effort
}
return;
}
abortMerge(projectDir);
sendChatMessage?.(
`~ conflict resolution for ${task.id} · ${task.title} — merge produced no conflicts but could not be completed`,
);
progress.markFailed(
task.id,
`Merge of ${branch} produced no conflicts but could not be completed`,
);
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
} catch {
// Best-effort
}
return;
}
// Conflicts exist — spawn a resolution agent session. // Conflicts exist — spawn a resolution agent session.
const prompt = buildConflictResolutionPrompt( const prompt = buildConflictResolutionPrompt(
task, task,
@@ -1808,6 +1904,7 @@ async function resolveConflictsSession(
`resolve-${task.id}`, `resolve-${task.id}`,
config.execution.commitTimeoutMs, config.execution.commitTimeoutMs,
models, models,
config.execution.inactivityTimeoutMs,
); );
if (!result.success) { if (!result.success) {

View File

@@ -185,11 +185,13 @@ export class ProgressTracker {
} }
/** Mark a task as in progress */ /** Mark a task as in progress */
markInProgress(taskId: string): void { markInProgress(taskId: string, sessionFile?: string): void {
const prd = this.getPRD(); const prd = this.getPRD();
this.ensureTask(prd, taskId); this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "in_progress"; prd.tasks[taskId].status = "in_progress";
prd.tasks[taskId].startedAt = new Date().toISOString(); prd.tasks[taskId].startedAt = new Date().toISOString();
if (sessionFile !== undefined)
prd.tasks[taskId].sessionFile = sessionFile;
this.save(); this.save();
} }
@@ -204,6 +206,7 @@ export class ProgressTracker {
commitSummary?: string, commitSummary?: string,
review?: ReviewResult, review?: ReviewResult,
reviewRetries?: number, reviewRetries?: number,
sessionFile?: string,
): void { ): void {
const prd = this.getPRD(); const prd = this.getPRD();
this.ensureTask(prd, taskId); this.ensureTask(prd, taskId);
@@ -218,6 +221,7 @@ export class ProgressTracker {
if (review) prd.tasks[taskId].review = review; if (review) prd.tasks[taskId].review = review;
if (reviewRetries !== undefined) if (reviewRetries !== undefined)
prd.tasks[taskId].reviewRetries = reviewRetries; prd.tasks[taskId].reviewRetries = reviewRetries;
if (sessionFile !== undefined) prd.tasks[taskId].sessionFile = sessionFile;
this.save(); this.save();
} }
@@ -236,6 +240,22 @@ export class ProgressTracker {
return prd.tasks[taskId]?.status ?? "pending"; return prd.tasks[taskId]?.status ?? "pending";
} }
/** Get the persisted session file path for a task (for resume), if any. */
getSessionFile(taskId: string): string | undefined {
const prd = this.getPRD();
return prd.tasks[taskId]?.sessionFile;
}
/** Persist the session file path for a task without changing its status.
* Called as soon as an agent session is created so an interrupted run
* can be resumed from the JSONL history. Pass undefined to clear. */
setSessionFile(taskId: string, sessionFile: string | undefined): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].sessionFile = sessionFile;
this.save();
}
/** Get IDs of all completed tasks */ /** Get IDs of all completed tasks */
getCompletedTaskIds(): string[] { getCompletedTaskIds(): string[] {
const prd = this.getPRD(); const prd = this.getPRD();

View File

@@ -29,6 +29,12 @@ export interface ReviewPromptOptions {
focus?: string; focus?: string;
/** Noise-filter overrides (config.review.*). */ /** Noise-filter overrides (config.review.*). */
diffOptions?: DiffOptions; diffOptions?: DiffOptions;
/** Prior review results from earlier passes in a review-gated loop.
* Each rejected review is injected into the next review prompt so the
* reviewer can verify prior findings were addressed and catch new
* regressions introduced by the fix attempt — instead of re-reviewing
* from scratch. */
priorReviews?: ReviewResult[];
} }
// ─── Task Prompt ───────────────────────────────────────────────────────────── // ─── Task Prompt ─────────────────────────────────────────────────────────────
@@ -258,8 +264,12 @@ export function buildReviewPrompt(
parts.push(renderDiffSection(summary, filtered, "### Diff")); parts.push(renderDiffSection(summary, filtered, "### Diff"));
parts.push(""); parts.push("");
// ── Custom Review Focus ── // ── Prior Review History (review-gated re-review) ──
parts.push(renderPriorReviews(opts.priorReviews ?? []));
if (opts.priorReviews && opts.priorReviews.length > 0) parts.push("");
// ── Custom Review Focus ──
if (opts.focus) { if (opts.focus) {
parts.push("## Custom Review Focus"); parts.push("## Custom Review Focus");
parts.push(opts.focus); parts.push(opts.focus);
@@ -360,7 +370,11 @@ export function buildReviewPromptUncommitted(
parts.push( parts.push(
renderDiffSection(summary, filtered, "### Current Tracked Diff (git diff)"), renderDiffSection(summary, filtered, "### Current Tracked Diff (git diff)"),
); );
parts.push("");
// ── Prior Review History (review-gated re-review) ──
parts.push(renderPriorReviews(opts.priorReviews ?? []));
if (opts.priorReviews && opts.priorReviews.length > 0) parts.push("");
// ── Custom Review Focus ── // ── Custom Review Focus ──
@@ -469,6 +483,45 @@ function renderDiffSection(
return lines.join("\n"); return lines.join("\n");
} }
/**
* Render a "Prior Review History" section from earlier rejected reviews.
* Returns an empty string when there are no prior reviews so callers omit
* the section entirely.
*
* Each prior review's verdict, summary, and findings are listed so the
* reviewer can verify the developer addressed them and watch for new
* regressions — instead of re-reviewing from scratch on each pass.
*/
function renderPriorReviews(priorReviews: ReviewResult[]): string {
if (priorReviews.length === 0) return "";
const lines: string[] = [];
lines.push("## Prior Review History");
lines.push(
"Previous review pass(es) rejected this task. Verify each finding was",
"addressed in the current diff and watch for new regressions:",
);
lines.push("");
for (let i = 0; i < priorReviews.length; i++) {
const r = priorReviews[i];
if (!r) continue;
lines.push(`### Review ${i + 1}${r.verdict.toUpperCase()}`);
lines.push(`Summary: ${r.summary}`);
if (r.findings.length > 0) {
lines.push("Findings:");
for (const f of r.findings) {
const loc = f.file
? f.line
? ` (${f.file}:${f.line})`
: ` (${f.file})`
: "";
lines.push(`- [${f.severity}]${loc} ${f.message}`);
}
}
lines.push("");
}
return lines.join("\n");
}
function reviewInstructions(): string[] { function reviewInstructions(): string[] {
return [ return [
"- **Correctness**: Does the implementation fulfill the task requirements?", "- **Correctness**: Does the implementation fulfill the task requirements?",

View File

@@ -161,6 +161,10 @@ export interface TaskProgressInfo {
commitSummary?: string; commitSummary?: string;
/** Number of review-fix re-execution attempts made (review-gated mode) */ /** Number of review-fix re-execution attempts made (review-gated mode) */
reviewRetries?: number; reviewRetries?: number;
/** Path to the JSONL session file backing this task's agent session,
* persisted so a resume can reopen it and continue where the
* interrupted session left off. */
sessionFile?: string;
} }
export interface ProgressState { export interface ProgressState {
@@ -205,6 +209,11 @@ export interface RalpiConfig {
execution: { execution: {
/** Task execution timeout in milliseconds */ /** Task execution timeout in milliseconds */
timeoutMs: number; timeoutMs: number;
/** Inactivity timeout in milliseconds — if no agent session event
* arrives within this window, the task is considered hung (e.g. a
* bash subprocess that never returns) and the session is aborted.
* 0 = disabled. */
inactivityTimeoutMs: number;
/** Maximum parallel tasks (0 = unlimited) */ /** Maximum parallel tasks (0 = unlimited) */
maxParallel: number; maxParallel: number;
/** Round-robin model list for parallel tasks (empty = inherit parent model) */ /** Round-robin model list for parallel tasks (empty = inherit parent model) */
@@ -301,6 +310,7 @@ export const DEFAULT_CONFIG: RalpiConfig = {
}, },
execution: { execution: {
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout) timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
inactivityTimeoutMs: 0, // 0 = disabled (no inactivity hang detection)
maxParallel: 3, maxParallel: 3,
models: [], models: [],
autoCommit: true, autoCommit: true,

View File

@@ -611,6 +611,18 @@ export async function runAgentSession(
* rate-limit normalization) are available. When omitted, the SDK creates * rate-limit normalization) are available. When omitted, the SDK creates
* a fresh runtime from models.json only — extension providers are lost. */ * a fresh runtime from models.json only — extension providers are lost. */
modelRuntime?: ModelRuntime, modelRuntime?: ModelRuntime,
/** Inactivity timeout in milliseconds — if no agent session event arrives
* within this window, the task is considered hung (e.g. a bash subprocess
* that never returns) and the session is aborted. 0 = disabled. */
inactivityTimeoutMs = 0,
/** Existing session JSONL file to resume. The session reopens the file and
* appends to it, so the agent sees the full prior conversation and can
* continue rather than redo prior tool calls. When the file is missing,
* a fresh session is created instead (with a warning). */
resumeSessionFile?: string,
/** Called with the session file path as soon as the session is created, so
* callers can persist it for resume before the session completes. */
onSessionFile?: (sessionFile: string) => void,
): Promise<{ ): Promise<{
success: boolean; success: boolean;
text: string; text: string;
@@ -618,6 +630,13 @@ export async function runAgentSession(
toolUsage: ToolUsage; toolUsage: ToolUsage;
stopReason?: string; stopReason?: string;
events: AgentSessionEvent[]; events: AgentSessionEvent[];
/** Path to the JSONL session file backing this session (set once the
* session is created; enables resume). */
sessionFile?: string;
/** True when a resume was requested but the session could not be created
* from the file (corrupt/unreadable JSONL). Callers should clear the
* stored session file so retries start fresh. */
resumeFailed?: boolean;
}> { }> {
const toolUsage: ToolUsage = { const toolUsage: ToolUsage = {
read: 0, read: 0,
@@ -638,6 +657,16 @@ export async function runAgentSession(
session?: Awaited<ReturnType<typeof createAgentSession>>["session"]; session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
} = {}; } = {};
let sessionFile: string | undefined;
let sessionCreated = false;
// Inactivity watchdog: aborts the session when no events arrive within
// inactivityTimeoutMs. The SDK emits an event for every tool start/end/
// update and message start/end, so silence means the agent is stuck
// (typically a hung bash subprocess producing no output).
let inactivityInterval: NodeJS.Timeout | null = null;
let inactivityAborted = false;
let lastEventTime = 0;
try { try {
// Loop sessions load the full normal pi context: extensions (so all // Loop sessions load the full normal pi context: extensions (so all
// extension-provided tools register), skills, and project context // extension-provided tools register), skills, and project context
@@ -653,9 +682,30 @@ export async function runAgentSession(
}); });
await loader.reload(); await loader.reload();
// Persist sessions under the ralpi project's `.ralpi/sessions/` so they
// survive worktree removal and are findable from the main repo on resume.
// Worktrees live inside `<project>/.ralpi/worktrees/...`, so walking up
// from the agent's cwd always finds the main project's `.ralpi` first.
const ralpiDir = findRalpiDir(cwd);
const sessionDir = ralpiDir
? path.join(ralpiDir, ".ralpi", "sessions")
: path.join(cwd, ".ralpi", "sessions");
let sessionManager: SessionManager;
if (resumeSessionFile && fs.existsSync(resumeSessionFile)) {
sessionManager = SessionManager.open(resumeSessionFile, sessionDir, cwd);
} else {
if (resumeSessionFile) {
console.warn(
`[ralpi] resume session file not found (${resumeSessionFile}) — starting a fresh session`,
);
}
sessionManager = SessionManager.create(cwd, sessionDir);
}
const result = await createAgentSession({ const result = await createAgentSession({
cwd, cwd,
sessionManager: SessionManager.inMemory(), sessionManager,
resourceLoader: loader, resourceLoader: loader,
settingsManager: SettingsManager.create(cwd, getAgentDir()), settingsManager: SettingsManager.create(cwd, getAgentDir()),
modelRuntime, modelRuntime,
@@ -663,8 +713,12 @@ export async function runAgentSession(
model: model as any, model: model as any,
thinkingLevel: thinkingLevel as any, thinkingLevel: thinkingLevel as any,
}); });
sessionCreated = true;
sessionRef.session = result.session; sessionRef.session = result.session;
sessionFile = result.session.sessionFile;
if (sessionFile) onSessionFile?.(sessionFile);
// Wire external abort signal // Wire external abort signal
const abortHandler = () => result.session.agent.abort(); const abortHandler = () => result.session.agent.abort();
signal?.addEventListener("abort", abortHandler, { once: true }); signal?.addEventListener("abort", abortHandler, { once: true });
@@ -672,8 +726,26 @@ export async function runAgentSession(
let finalText = ""; let finalText = "";
let errorMessage: string | undefined; let errorMessage: string | undefined;
let stopReason: string | undefined; let stopReason: string | undefined;
lastEventTime = Date.now();
// Inactivity watchdog: check the silence window on an interval and abort
// (plus kill any hung bash subprocess) when it is exceeded.
if (inactivityTimeoutMs > 0) {
const intervalMs = Math.min(inactivityTimeoutMs, 5000);
inactivityInterval = setInterval(() => {
if (!sessionRef.session) return;
if (Date.now() - lastEventTime <= inactivityTimeoutMs) return;
inactivityAborted = true;
sessionRef.session.agent.abort();
sessionRef.session.abortBash();
errorMessage = `Task aborted: inactivity timeout (no events for ${Math.round(inactivityTimeoutMs / 1000)}s)`;
if (inactivityInterval) clearInterval(inactivityInterval);
inactivityInterval = null;
}, intervalMs);
}
const unsubscribe = result.session.subscribe((event) => { const unsubscribe = result.session.subscribe((event) => {
lastEventTime = Date.now();
onEvent?.(event); onEvent?.(event);
if (event.type === "message_end") { if (event.type === "message_end") {
@@ -685,7 +757,10 @@ export async function runAgentSession(
}; };
if (message.role !== "assistant") return; if (message.role !== "assistant") return;
if (message.stopReason) stopReason = message.stopReason; if (message.stopReason) stopReason = message.stopReason;
if (message.errorMessage) errorMessage = message.errorMessage; // Keep the inactivity-timeout message: the abort's own errorMessage
// would otherwise clobber the (more useful) hang explanation.
if (message.errorMessage && !inactivityAborted)
errorMessage = message.errorMessage;
const text = extractAssistantText(message.content); const text = extractAssistantText(message.content);
if (text) finalText = text; if (text) finalText = text;
} }
@@ -718,6 +793,7 @@ export async function runAgentSession(
toolUsage, toolUsage,
stopReason, stopReason,
events: [], // streamed to file events: [], // streamed to file
sessionFile,
}; };
} }
@@ -727,6 +803,7 @@ export async function runAgentSession(
toolUsage, toolUsage,
stopReason, stopReason,
events: [], events: [],
sessionFile,
}; };
} catch (error) { } catch (error) {
if (timeoutHandle) clearTimeout(timeoutHandle); if (timeoutHandle) clearTimeout(timeoutHandle);
@@ -736,9 +813,14 @@ export async function runAgentSession(
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
toolUsage, toolUsage,
events: [], events: [],
sessionFile,
// A requested resume that failed to open (corrupt/unreadable file)
// should not be retried — callers clear the stored file and go fresh.
resumeFailed: resumeSessionFile !== undefined && !sessionCreated,
}; };
} finally { } finally {
sessionRef.session?.dispose(); sessionRef.session?.dispose();
if (inactivityInterval) clearInterval(inactivityInterval);
} }
} }