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
1136 lines
36 KiB
TypeScript
1136 lines
36 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import type {
|
|
RalpiConfig,
|
|
PRDProgress,
|
|
ProgressState,
|
|
ToolUsage,
|
|
} from "./types";
|
|
import { DEFAULT_CONFIG } from "./types";
|
|
import { parseTaskFile } from "./parser";
|
|
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
import {
|
|
createAgentSession,
|
|
DefaultResourceLoader,
|
|
getAgentDir,
|
|
SessionManager,
|
|
SettingsManager,
|
|
type ModelRuntime,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
|
|
// ─── Directory Helpers ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Ensure a directory exists, creating it recursively if needed
|
|
*/
|
|
export function ensureDir(dirPath: string): void {
|
|
if (!fs.existsSync(dirPath)) {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write file content, creating parent directories if needed
|
|
*/
|
|
export function writeFileSafe(filePath: string, content: string): void {
|
|
ensureDir(path.dirname(filePath));
|
|
fs.writeFileSync(filePath, content, "utf-8");
|
|
}
|
|
|
|
// ─── Loop-Active State ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* State persisted to disk when a ralpi execution loop is active.
|
|
* Used to re-instantiate widgets after a session reload, and to resume
|
|
* the loop non-interactively when a reload interrupts in-progress tasks.
|
|
*/
|
|
export interface LoopActiveState {
|
|
taskFile: string;
|
|
mode: "parallel" | "sequential";
|
|
startedAt: string;
|
|
taskIds: string[];
|
|
prdKey: string;
|
|
/** Loop option snapshot at loop start, so a reload can resume without
|
|
* re-prompting the user. */
|
|
autoCommit?: boolean;
|
|
autoReview?: boolean;
|
|
saveReviews?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Path (relative to projectDir) where the loop-active marker is stored.
|
|
*/
|
|
const LOOP_ACTIVE_FILE = ".ralpi/loop-active.json";
|
|
|
|
/**
|
|
* Write the loop-active marker, indicating an execution loop is running.
|
|
*/
|
|
export function writeLoopActive(
|
|
projectDir: string,
|
|
state: LoopActiveState,
|
|
): void {
|
|
writeFileSafe(
|
|
path.join(projectDir, LOOP_ACTIVE_FILE),
|
|
JSON.stringify(state, null, 2),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Read the loop-active marker, if present.
|
|
*/
|
|
export function readLoopActive(projectDir: string): LoopActiveState | null {
|
|
const filePath = path.join(projectDir, LOOP_ACTIVE_FILE);
|
|
try {
|
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
return JSON.parse(raw) as LoopActiveState;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete the loop-active marker.
|
|
*/
|
|
export function deleteLoopActive(projectDir: string): void {
|
|
const filePath = path.join(projectDir, LOOP_ACTIVE_FILE);
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
} catch {
|
|
// Ignore if already gone
|
|
}
|
|
}
|
|
|
|
// ─── Git Hygiene ────────────────────────────────────────────────────────────
|
|
|
|
const ralpiIgnoreMemo = new Set<string>();
|
|
|
|
/**
|
|
* Ensure `.ralpi/` is excluded from the project's `.gitignore` so ralpi's own
|
|
* run-state, worktrees, and reviews never show up as tracked/untracked files
|
|
* in the user's repo.
|
|
*
|
|
* Memoized per project dir; only acts inside a git work tree (`.git` may be a
|
|
* directory or, in linked worktrees, a file). Creates or appends `.ralpi/` to
|
|
* `.gitignore`, best-effort: any failure returns `false` (never throws).
|
|
*
|
|
* @returns true when the ignore entry was newly added, false otherwise.
|
|
*/
|
|
export function ensureRalpiIgnored(projectDir: string): boolean {
|
|
if (ralpiIgnoreMemo.has(projectDir)) return false;
|
|
ralpiIgnoreMemo.add(projectDir);
|
|
try {
|
|
// Only act inside a git work tree (works for worktrees too: .git is a file).
|
|
fs.statSync(path.join(projectDir, ".git"));
|
|
const ignorePath = path.join(projectDir, ".gitignore");
|
|
const marker = ".ralpi/";
|
|
let content: string;
|
|
try {
|
|
content = fs.readFileSync(ignorePath, "utf8");
|
|
} catch {
|
|
fs.writeFileSync(ignorePath, `${marker}\n`, "utf8");
|
|
return true;
|
|
}
|
|
if (content.split(/\r?\n/).some((l) => l.trim() === marker)) return false;
|
|
const prefix = content.endsWith("\n") ? "" : "\n";
|
|
fs.appendFileSync(
|
|
ignorePath,
|
|
`${prefix}# ralpi run-state, worktrees, and reviews\n${marker}\n`,
|
|
"utf8",
|
|
);
|
|
return true;
|
|
} catch {
|
|
return false; // not a git work tree, or a best-effort write failed
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Discover the project directory by walking up to find `.ralpi/`.
|
|
*/
|
|
export function findRalpiDir(startDir: string): string | null {
|
|
let current = path.resolve(startDir);
|
|
const root = path.parse(current).root;
|
|
while (current !== root) {
|
|
if (fs.existsSync(path.join(current, ".ralpi"))) {
|
|
return current;
|
|
}
|
|
current = path.dirname(current);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ─── Async Agent Session ────────────────────────────────────────────────────
|
|
|
|
// ─── Progress Discovery ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Find the nearest .ralpi/progress.json by walking up from the given directory.
|
|
* For a specific sourcePath, finds the matching PRD entry.
|
|
*/
|
|
export function findProgressFile(
|
|
startDir: string,
|
|
sourcePath?: string,
|
|
): { path: string; state: ProgressState; prdKey?: string } | null {
|
|
let current = path.resolve(startDir);
|
|
const root = path.parse(current).root;
|
|
|
|
while (current !== root) {
|
|
const candidate = path.join(current, ".ralpi", "progress.json");
|
|
if (fs.existsSync(candidate)) {
|
|
try {
|
|
const raw = fs.readFileSync(candidate, "utf-8");
|
|
const state = JSON.parse(raw) as ProgressState;
|
|
|
|
// If looking for a specific source path, find matching PRD
|
|
if (sourcePath && state.prds) {
|
|
const resolvedSource = path.resolve(sourcePath);
|
|
for (const [key, prd] of Object.entries(state.prds)) {
|
|
if (path.resolve(prd.sourcePath) === resolvedSource) {
|
|
return { path: candidate, state, prdKey: key };
|
|
}
|
|
}
|
|
// No matching PRD found, continue walking up
|
|
current = path.dirname(current);
|
|
continue;
|
|
}
|
|
|
|
return { path: candidate, state };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
current = path.dirname(current);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* List all PRDs from a ProgressState, sorted by lastUpdatedAt descending
|
|
* (most recent first). Used by resume to offer a selection when multiple
|
|
* loops have progress simultaneously.
|
|
*/
|
|
export function listPRDsSorted(
|
|
state: ProgressState,
|
|
): Array<{ key: string; prd: PRDProgress }> {
|
|
const entries: Array<{ key: string; prd: PRDProgress }> = [];
|
|
|
|
if (state.prds) {
|
|
for (const [key, prd] of Object.entries(state.prds)) {
|
|
entries.push({ key, prd });
|
|
}
|
|
} else {
|
|
// Legacy flat mode — single PRD
|
|
entries.push({
|
|
key: "legacy",
|
|
prd: {
|
|
sourcePath: state.sourcePath,
|
|
tasks: state.tasks,
|
|
startedAt: state.startedAt,
|
|
lastUpdatedAt: state.lastUpdatedAt,
|
|
paused: state.paused,
|
|
},
|
|
});
|
|
}
|
|
|
|
entries.sort((a, b) => {
|
|
return (
|
|
new Date(b.prd.lastUpdatedAt).getTime() -
|
|
new Date(a.prd.lastUpdatedAt).getTime()
|
|
);
|
|
});
|
|
|
|
return entries;
|
|
}
|
|
|
|
export interface PRDResumeSummary {
|
|
total: number;
|
|
completed: number;
|
|
failed: number;
|
|
}
|
|
|
|
/**
|
|
* Count tasks for the resume-selection display.
|
|
*
|
|
* The progress tracker only records tasks that were TOUCHED (started,
|
|
* completed, or failed) — never-started tasks are absent from `prd.tasks`,
|
|
* so a naive Object.keys(prd.tasks).length under-reports the real total.
|
|
* The true total comes from parsing the PRD source file. Completed counts
|
|
* both progress-marked completions and PRD checkbox completions (a task
|
|
* checked off in the file is done even if the loop was interrupted before
|
|
* markCompleted), deduped by task id. Falls back to touched-task counts
|
|
* when the source file is missing or unparseable.
|
|
*/
|
|
export function countPRDResumeStats(
|
|
prd: PRDProgress,
|
|
sourcePath: string,
|
|
): PRDResumeSummary {
|
|
const touched = Object.entries(prd.tasks);
|
|
const failed = touched.filter(([, t]) => t.status === "failed").length;
|
|
const completedIds = new Set(
|
|
touched.filter(([, t]) => t.status === "completed").map(([id]) => id),
|
|
);
|
|
|
|
let total: number;
|
|
try {
|
|
const project = parseTaskFile(sourcePath);
|
|
total = project.tasks.length;
|
|
for (const task of project.tasks) {
|
|
if (task.status === "completed") completedIds.add(task.id);
|
|
}
|
|
} catch {
|
|
// PRD file missing/unparseable — fall back to touched-task counts
|
|
total = touched.length;
|
|
}
|
|
|
|
return { total, completed: completedIds.size, failed };
|
|
}
|
|
|
|
// ─── Model Resolution ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve a "<provider>/<model>" spec string via the model registry.
|
|
* Returns undefined if spec is empty, malformed, or not found.
|
|
*/
|
|
export function resolveModelSpec(
|
|
modelRegistry:
|
|
| { find(provider: string, modelId: string): unknown }
|
|
| undefined,
|
|
spec: string,
|
|
onWarning?: (msg: string) => void,
|
|
): unknown | undefined {
|
|
if (!spec) return undefined;
|
|
const slashIdx = spec.indexOf("/");
|
|
if (slashIdx === -1) {
|
|
onWarning?.(
|
|
`ralpi config: skipping model "${spec}" — expected <provider>/<model> format`,
|
|
);
|
|
return undefined;
|
|
}
|
|
const provider = spec.slice(0, slashIdx);
|
|
const modelId = spec.slice(slashIdx + 1);
|
|
return modelRegistry?.find(provider, modelId);
|
|
}
|
|
|
|
// ─── Config ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Try to use the `yaml` package (real dependency in package.json).
|
|
* Falls back to a flat key:value parser when unavailable. */
|
|
const parseSimpleYaml: (content: string) => Record<string, any> = (() => {
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
const { parse } = require("yaml");
|
|
return (content: string) => parse(content) ?? {};
|
|
} catch {
|
|
return (content: string) => {
|
|
const result: Record<string, any> = {};
|
|
for (const line of content.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
const match = trimmed.match(/^([^:]+):\s*(.*)$/);
|
|
if (match) {
|
|
const value = match[2].trim();
|
|
if (value === "true") result[match[1].trim()] = true;
|
|
else if (value === "false") result[match[1].trim()] = false;
|
|
else if (/^\d+$/.test(value))
|
|
result[match[1].trim()] = parseInt(value, 10);
|
|
else if (/^\d+\.\d+$/.test(value))
|
|
result[match[1].trim()] = parseFloat(value);
|
|
else result[match[1].trim()] = value;
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
}
|
|
})();
|
|
|
|
/**
|
|
* Deep merge configuration objects
|
|
*/
|
|
function mergeConfig(
|
|
defaults: RalpiConfig,
|
|
overrides: Record<string, any>,
|
|
): RalpiConfig {
|
|
const result = { ...defaults };
|
|
|
|
for (const [key, value] of Object.entries(overrides)) {
|
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
(result as any)[key] = { ...(defaults as any)[key], ...value };
|
|
} else {
|
|
(result as any)[key] = value;
|
|
}
|
|
}
|
|
|
|
return result as RalpiConfig;
|
|
}
|
|
|
|
/** Path to the global ralpi config under the user's Pi home directory. */
|
|
const GLOBAL_CONFIG_PATH = path.join(
|
|
process.env.HOME || "/tmp",
|
|
".pi",
|
|
"ralpi",
|
|
"config.yaml",
|
|
);
|
|
|
|
/**
|
|
* Load and merge config from global and project sources.
|
|
*
|
|
* Precedence (highest wins):
|
|
* 1. Project-level: `<projectDir>/.ralpi/config.yaml`
|
|
* 2. Global: `~/.pi/ralpi/config.yaml`
|
|
* 3. `DEFAULT_CONFIG` in `src/types.ts`
|
|
*/
|
|
export function loadConfig(projectDir: string): RalpiConfig {
|
|
// Start with defaults
|
|
const merged: RalpiConfig = { ...DEFAULT_CONFIG };
|
|
|
|
// Layer 1: global config (~/.pi/ralpi/config.yaml)
|
|
tryLoadConfigFile(GLOBAL_CONFIG_PATH, merged);
|
|
|
|
// Layer 2: project config (.ralpi/config.yaml) — overrides global
|
|
tryLoadConfigFile(path.join(projectDir, ".ralpi", "config.yaml"), merged);
|
|
|
|
return merged;
|
|
|
|
/** Attempt to load a single config file and merge into `acc` in place. */
|
|
function tryLoadConfigFile(filePath: string, acc: RalpiConfig): void {
|
|
if (!fs.existsSync(filePath)) return;
|
|
try {
|
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
const parsed = parseSimpleYaml(content);
|
|
Object.assign(acc, mergeConfig(acc, parsed));
|
|
// Track which execution keys were explicitly set in this YAML so the
|
|
// loop-startup prompts can be skipped for fields the user already set.
|
|
const exec = parsed?.execution;
|
|
if (exec && typeof exec === "object" && !Array.isArray(exec)) {
|
|
acc.execution.explicitKeys ??= new Set<string>();
|
|
for (const key of Object.keys(exec)) {
|
|
acc.execution.explicitKeys.add(key);
|
|
}
|
|
}
|
|
} catch {
|
|
// Malformed config — skip silently
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Task Resolution ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve a task argument to a file path.
|
|
* Strips leading `@` (from autocomplete) before resolution.
|
|
*/
|
|
export function resolveTaskArg(arg: string, cwd: string): string {
|
|
// Strip leading @ from autocomplete
|
|
const cleanArg = arg.startsWith("@") ? arg.slice(1) : arg;
|
|
|
|
const candidates = [
|
|
path.resolve(cwd, cleanArg),
|
|
path.resolve(cwd, cleanArg + ".md"),
|
|
path.resolve(cwd, cleanArg + ".yaml"),
|
|
path.resolve(cwd, cleanArg + ".yml"),
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (fs.existsSync(candidate)) return candidate;
|
|
}
|
|
|
|
// Try looking for README.md in the arg directory
|
|
try {
|
|
if (fs.statSync(path.resolve(cwd, cleanArg)).isDirectory()) {
|
|
const readme = path.resolve(cwd, cleanArg, "README.md");
|
|
if (fs.existsSync(readme)) return readme;
|
|
}
|
|
} catch {
|
|
// Directory doesn't exist, fall through to error
|
|
}
|
|
|
|
throw new Error(
|
|
`Task file not found: ${cleanArg}\nSearched: ${candidates.join("\n ")}`,
|
|
);
|
|
}
|
|
|
|
// ─── Formatting ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Format duration in milliseconds to human-readable string
|
|
*/
|
|
export function formatDuration(ms: number): string {
|
|
const seconds = Math.floor(ms / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes % 60}m`;
|
|
}
|
|
if (minutes > 0) {
|
|
return `${minutes}m ${seconds % 60}s`;
|
|
}
|
|
return `${seconds}s`;
|
|
}
|
|
|
|
/**
|
|
* Format progress status for display. Accepts a single PRDProgress entry.
|
|
*/
|
|
export function formatProgressStatus(state: PRDProgress): string {
|
|
const lines: string[] = [];
|
|
const tasks = state.tasks;
|
|
const total = Object.keys(tasks).length;
|
|
const completed = Object.values(tasks).filter(
|
|
(t) => t.status === "completed",
|
|
).length;
|
|
const failed = Object.values(tasks).filter(
|
|
(t) => t.status === "failed",
|
|
).length;
|
|
const inProgress = Object.values(tasks).filter(
|
|
(t) => t.status === "in_progress",
|
|
).length;
|
|
|
|
lines.push("## Progress");
|
|
lines.push("");
|
|
lines.push(
|
|
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
|
|
);
|
|
lines.push("");
|
|
|
|
for (const [id, info] of Object.entries(tasks)) {
|
|
const statusIcon =
|
|
info.status === "completed"
|
|
? "[x]"
|
|
: info.status === "in_progress"
|
|
? "[~]"
|
|
: info.status === "failed"
|
|
? "[!]"
|
|
: "[ ]";
|
|
|
|
const duration = info.durationMs
|
|
? ` (${formatDuration(info.durationMs)})`
|
|
: "";
|
|
|
|
lines.push(`- ${statusIcon} ${id}${duration}`);
|
|
|
|
if (info.error) {
|
|
lines.push(` Error: ${info.error}`);
|
|
}
|
|
}
|
|
|
|
lines.push("");
|
|
lines.push(`Started: ${state.startedAt}`);
|
|
lines.push(`Updated: ${state.lastUpdatedAt}`);
|
|
lines.push(`Paused: ${state.paused ? "yes" : "no"}`);
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Format progress status for all PRDs in a ProgressState.
|
|
*/
|
|
export function formatAllPRDsStatus(state: ProgressState): string {
|
|
const prds = state.prds;
|
|
if (!prds || Object.keys(prds).length <= 1) {
|
|
// Single PRD — use simple format
|
|
const prd = prds
|
|
? Object.values(prds)[0]
|
|
: (state as unknown as PRDProgress);
|
|
return formatProgressStatus(prd);
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
lines.push("## Progress (all PRDs)");
|
|
lines.push("");
|
|
|
|
for (const [key, prd] of Object.entries(prds)) {
|
|
const tasks = prd.tasks;
|
|
const total = Object.keys(tasks).length;
|
|
const completed = Object.values(tasks).filter(
|
|
(t) => t.status === "completed",
|
|
).length;
|
|
const failed = Object.values(tasks).filter(
|
|
(t) => t.status === "failed",
|
|
).length;
|
|
const inProgress = Object.values(tasks).filter(
|
|
(t) => t.status === "in_progress",
|
|
).length;
|
|
|
|
lines.push(`### ${key}`);
|
|
lines.push(`Source: ${path.relative(process.cwd(), prd.sourcePath)}`);
|
|
lines.push(
|
|
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
|
|
);
|
|
lines.push("");
|
|
|
|
for (const [id, info] of Object.entries(tasks)) {
|
|
const statusIcon =
|
|
info.status === "completed"
|
|
? "[x]"
|
|
: info.status === "in_progress"
|
|
? "[~]"
|
|
: info.status === "failed"
|
|
? "[!]"
|
|
: "[ ]";
|
|
|
|
const duration = info.durationMs
|
|
? ` (${formatDuration(info.durationMs)})`
|
|
: "";
|
|
|
|
lines.push(`- ${statusIcon} ${id}${duration}`);
|
|
|
|
if (info.error) {
|
|
lines.push(` Error: ${info.error}`);
|
|
}
|
|
}
|
|
|
|
lines.push("");
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// ─── Async Agent Session ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Run a task prompt through an in-process Pi agent session (async, non-blocking).
|
|
*
|
|
* Unlike the old spawnPi() which used spawnSync and froze the TUI,
|
|
* this uses createAgentSession from the Pi SDK, keeping the event loop
|
|
* responsive and allowing progress updates during task execution.
|
|
*/
|
|
export async function runAgentSession(
|
|
taskPrompt: string,
|
|
cwd: string,
|
|
timeoutMs: number,
|
|
onEvent?: (event: AgentSessionEvent) => void,
|
|
signal?: AbortSignal,
|
|
model?: unknown,
|
|
thinkingLevel?: unknown,
|
|
/** When true, skip loading the skills catalog for this session. Used by
|
|
* focused follow-up sessions (commit/review) that don't need skills —
|
|
* keeps the context lean and avoids dragging in unrelated overhead. */
|
|
noSkills = false,
|
|
/** Parent session's model runtime. Must be passed so extension-registered
|
|
* providers (e.g., neuralwatt with its streamSimple wrapper for 429
|
|
* rate-limit normalization) are available. When omitted, the SDK creates
|
|
* a fresh runtime from models.json only — extension providers are lost. */
|
|
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<{
|
|
success: boolean;
|
|
text: string;
|
|
error?: string;
|
|
toolUsage: ToolUsage;
|
|
stopReason?: string;
|
|
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 = {
|
|
read: 0,
|
|
write: 0,
|
|
edit: 0,
|
|
bash: 0,
|
|
other: 0,
|
|
};
|
|
// Wire timeout via abort signal (only when set; 0 means inherit Pi's defaults)
|
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
|
if (timeoutMs > 0) {
|
|
timeoutHandle = setTimeout(() => {
|
|
if (sessionRef?.session) sessionRef.session.agent.abort();
|
|
}, timeoutMs);
|
|
}
|
|
|
|
const sessionRef: {
|
|
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 {
|
|
// Loop sessions load the full normal pi context: extensions (so all
|
|
// extension-provided tools register), skills, and project context
|
|
// (AGENTS.md / CLAUDE.md)
|
|
const loader = new DefaultResourceLoader({
|
|
cwd,
|
|
agentDir: getAgentDir(),
|
|
noSkills,
|
|
noPromptTemplates: true,
|
|
noThemes: true,
|
|
noExtensions: false,
|
|
noContextFiles: false,
|
|
});
|
|
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({
|
|
cwd,
|
|
sessionManager,
|
|
resourceLoader: loader,
|
|
settingsManager: SettingsManager.create(cwd, getAgentDir()),
|
|
modelRuntime,
|
|
// No `tools` allowlist: matches a normal pi session's tool set.
|
|
model: model as any,
|
|
thinkingLevel: thinkingLevel as any,
|
|
});
|
|
sessionCreated = true;
|
|
sessionRef.session = result.session;
|
|
|
|
sessionFile = result.session.sessionFile;
|
|
if (sessionFile) onSessionFile?.(sessionFile);
|
|
|
|
// Wire external abort signal
|
|
const abortHandler = () => result.session.agent.abort();
|
|
signal?.addEventListener("abort", abortHandler, { once: true });
|
|
|
|
let finalText = "";
|
|
let errorMessage: 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) => {
|
|
lastEventTime = Date.now();
|
|
onEvent?.(event);
|
|
|
|
if (event.type === "message_end") {
|
|
const message = event.message as {
|
|
role?: string;
|
|
content?: unknown;
|
|
stopReason?: string;
|
|
errorMessage?: string;
|
|
};
|
|
if (message.role !== "assistant") return;
|
|
if (message.stopReason) stopReason = message.stopReason;
|
|
// 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);
|
|
if (text) finalText = text;
|
|
}
|
|
|
|
if (event.type === "tool_execution_start") {
|
|
const name = event.toolName;
|
|
if (name in toolUsage) {
|
|
(toolUsage as unknown as Record<string, number>)[name]++;
|
|
} else {
|
|
toolUsage.other++;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (signal?.aborted) throw new Error("Aborted before prompt");
|
|
|
|
await result.session.prompt(taskPrompt);
|
|
await result.session.agent.waitForIdle();
|
|
|
|
unsubscribe();
|
|
result.session.dispose();
|
|
signal?.removeEventListener("abort", abortHandler);
|
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
|
|
if (errorMessage && !finalText) {
|
|
return {
|
|
success: false,
|
|
text: "",
|
|
error: errorMessage,
|
|
toolUsage,
|
|
stopReason,
|
|
events: [], // streamed to file
|
|
sessionFile,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
text: finalText.trim(),
|
|
toolUsage,
|
|
stopReason,
|
|
events: [],
|
|
sessionFile,
|
|
};
|
|
} catch (error) {
|
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
return {
|
|
success: false,
|
|
text: "",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
toolUsage,
|
|
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 {
|
|
sessionRef.session?.dispose();
|
|
if (inactivityInterval) clearInterval(inactivityInterval);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract assistant text from message content (text blocks only).
|
|
*/
|
|
function extractAssistantText(content: unknown): string {
|
|
if (typeof content === "string") return content;
|
|
if (!Array.isArray(content)) return "";
|
|
return content
|
|
.filter(
|
|
(c): c is { type: string; text?: string } =>
|
|
!!c &&
|
|
typeof c === "object" &&
|
|
(c as { type?: string }).type === "text",
|
|
)
|
|
.map((c) => (c as { text?: string }).text ?? "")
|
|
.join("");
|
|
}
|
|
|
|
// ─── Git Commit Capture ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check if there are any uncommitted changes in the git repository.
|
|
* Includes untracked files — a new file created by a task agent is work
|
|
* that still needs committing.
|
|
*/
|
|
export function hasUncommittedChanges(projectDir: string): boolean {
|
|
const { execSync } = require("node:child_process");
|
|
try {
|
|
const output = execSync("git status --porcelain", {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
return output.length > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for uncommitted changes to TRACKED files only, ignoring untracked
|
|
* (`??`) entries.
|
|
*
|
|
* Untracked files never block a merge, so a worktree whose task work is
|
|
* fully committed is "done" even when it carries stray untracked files
|
|
* (scratch files, build artifacts, files created but deliberately left out
|
|
* of the commit). Resume-finalize uses this to decide whether a task's
|
|
* committed branch should be merged into main: counting `??` entries there
|
|
* would strand committed code in `.ralpi/worktrees/` forever.
|
|
*/
|
|
export function hasTrackedUncommittedChanges(projectDir: string): boolean {
|
|
const { execSync } = require("node:child_process");
|
|
try {
|
|
const output = execSync("git status --porcelain", {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
return output
|
|
.split("\n")
|
|
.some((line: string) => line.length > 0 && !line.startsWith("??"));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current git status in porcelain format.
|
|
* Includes untracked files, which `git diff` alone would miss.
|
|
*/
|
|
export function getGitStatusPorcelain(projectDir: string): string {
|
|
const { execSync } = require("node:child_process");
|
|
try {
|
|
return execSync("git status --porcelain", {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current git diff for tracked uncommitted changes.
|
|
*/
|
|
export function getGitDiff(projectDir: string): string {
|
|
const { execSync } = require("node:child_process");
|
|
try {
|
|
return execSync("git diff", {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Capture recent git commits made during task execution
|
|
* Returns commit messages and a summary string
|
|
*/
|
|
export function captureGitCommits(projectDir: string): {
|
|
commitMessages: string[];
|
|
commitSummary: string;
|
|
} {
|
|
const { execSync } = require("node:child_process");
|
|
|
|
try {
|
|
// Check if this is a git repo
|
|
execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" });
|
|
} catch {
|
|
return { commitMessages: [], commitSummary: "" };
|
|
}
|
|
|
|
const commitMessages: string[] = [];
|
|
let commitSummary = "";
|
|
|
|
try {
|
|
// Get recent commits (last 5) with short hash and subject
|
|
const output = execSync("git log --oneline -5 --no-decorate", {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
|
|
if (output) {
|
|
const lines = output.split("\n").filter((l: string) => l.trim());
|
|
for (const line of lines) {
|
|
// Format: "abc1234 Commit message"
|
|
const parts = line.split(" ", 2);
|
|
if (parts.length >= 2) {
|
|
commitMessages.push(parts[1]);
|
|
}
|
|
}
|
|
|
|
// Build summary from commit subjects
|
|
commitSummary = commitMessages.slice(0, 3).join("; ");
|
|
if (commitMessages.length > 3) {
|
|
commitSummary += ` (+${commitMessages.length - 3} more)`;
|
|
}
|
|
}
|
|
} catch {
|
|
// Git command failed, return empty
|
|
}
|
|
|
|
return { commitMessages, commitSummary };
|
|
}
|
|
|
|
/**
|
|
* Get the diff of the latest commit (HEAD).
|
|
* Returns the short hash, subject, and full diff (stat + patch).
|
|
* Used by the auto-review agent to review a commit against the task.
|
|
*/
|
|
export function getLatestCommitDiff(
|
|
projectDir: string,
|
|
): { hash: string; subject: string; diff: string } | null {
|
|
const { execSync } = require("node:child_process");
|
|
|
|
try {
|
|
execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" });
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const hash = execSync("git rev-parse --short HEAD", {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
|
|
const subject = execSync("git log -1 --format=%s", {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
|
|
// Full diff of the latest commit: stat overview + patch.
|
|
// maxBuffer set high — the prompt builder truncates to MAX_DIFF_BYTES.
|
|
const diff = execSync("git show HEAD --stat --patch", {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
}).trim();
|
|
|
|
return { hash, subject, diff };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Capture the current HEAD commit SHA. Returns the full 40-char SHA, or
|
|
* undefined when not a git repo / git unavailable. Used to snapshot the
|
|
* worktree HEAD before a task runs so the review can diff the complete task
|
|
* output (baseRef..HEAD) — including any commits the task agent makes.
|
|
*/
|
|
export function captureGitHead(projectDir: string): string | undefined {
|
|
const { execSync } = require("node:child_process");
|
|
try {
|
|
const sha = execSync("git rev-parse HEAD", {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
}).trim();
|
|
// Guard against injection — only accept hex SHAs.
|
|
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the diff from `baseRef` to HEAD — the complete set of committed changes
|
|
* made since the base reference. Used by the review-gated loop so the reviewer
|
|
* sees the full task diff (all commits, not just the latest) across execution
|
|
* attempts and re-execution fixes. `baseRef` must be a validated hex SHA from
|
|
* captureGitHead().
|
|
*
|
|
* Returns a tri-state so the review loop can tell a FAILED range computation
|
|
* (invalid/stale base ref, git error) apart from a GENUINELY EMPTY range — a
|
|
* broken base must never be silently treated as a clean, verified task.
|
|
*/
|
|
export type CommitRangeDiffResult =
|
|
| { kind: "ok"; hash: string; subject: string; diff: string }
|
|
| { kind: "no-changes" }
|
|
| { kind: "error"; error: string };
|
|
|
|
/**
|
|
* Whether the `baseRef..HEAD` range can be computed — i.e. the base ref is a
|
|
* resolvable commit in this repo (mirrors @piex-dev/review's canCompareToBase).
|
|
* Only validated hex SHAs are passed to the shell.
|
|
*/
|
|
export function canComputeRange(projectDir: string, baseRef: string): boolean {
|
|
const { execSync } = require("node:child_process");
|
|
if (!/^[0-9a-f]{7,40}$/i.test(baseRef)) return false;
|
|
try {
|
|
// git cat-file -e truly verifies the object EXISTS (rev-parse --verify
|
|
// accepts any 40-hex SHA even if it was never created), so a stale/broken
|
|
// base ref is caught here rather than silently treated as no-changes.
|
|
execSync(`git cat-file -e ${baseRef}`, {
|
|
cwd: projectDir,
|
|
stdio: "pipe",
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function getCommitRangeDiff(
|
|
projectDir: string,
|
|
baseRef: string,
|
|
): CommitRangeDiffResult {
|
|
const { execSync } = require("node:child_process");
|
|
|
|
// Only pass validated hex SHAs to the shell.
|
|
if (!/^[0-9a-f]{7,40}$/i.test(baseRef)) {
|
|
return { kind: "error", error: "invalid or stale base ref" };
|
|
}
|
|
|
|
try {
|
|
execSync("git rev-parse --git-dir", {
|
|
cwd: projectDir,
|
|
stdio: "pipe",
|
|
});
|
|
} catch {
|
|
return { kind: "error", error: "not a git repository" };
|
|
}
|
|
|
|
// Verify the base ref resolves before diffing — a stale/unfetched ref is a
|
|
// computation failure, not a clean "no changes" signal. git cat-file -e
|
|
// checks the object genuinely exists (rev-parse --verify would accept any
|
|
// 40-hex SHA even if it was never created).
|
|
try {
|
|
execSync(`git cat-file -e ${baseRef}`, {
|
|
cwd: projectDir,
|
|
stdio: "pipe",
|
|
});
|
|
} catch {
|
|
return { kind: "error", error: `base ref ${baseRef} cannot be resolved` };
|
|
}
|
|
|
|
try {
|
|
const hash = execSync("git rev-parse --short HEAD", {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
|
|
const subject = execSync("git log -1 --format=%s", {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
|
|
// Diff from baseRef to HEAD — shows all committed changes made since
|
|
// the snapshot. Includes stat overview + full patch.
|
|
//
|
|
// maxBuffer is set high (10 MB) so larger tasks don't cause execSync to
|
|
// throw. The review prompt builder filters noise and inlines only under
|
|
// MAX_DIFF_BYTES, so the full diff in memory is fine.
|
|
const diff = execSync(`git diff ${baseRef} HEAD --stat --patch`, {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
}).trim();
|
|
|
|
if (!diff) return { kind: "no-changes" }; // genuinely no changes since baseRef
|
|
return { kind: "ok", hash, subject, diff };
|
|
} catch (error) {
|
|
return {
|
|
kind: "error",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|