grind mode baby

This commit is contained in:
Harivansh Rathi 2026-03-08 23:38:13 -07:00
parent aa70afbd7e
commit ff6e39dd10
17 changed files with 1232 additions and 8 deletions

View file

@ -8,8 +8,8 @@
process.title = "pi";
import { setBedrockProviderModule } from "@mariozechner/pi-ai";
import { bedrockProviderModule } from "@mariozechner/pi-ai/bedrock-provider";
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
import { bedrockProviderModule } from "../../ai/src/bedrock-provider.js";
import { main } from "./main.js";
setGlobalDispatcher(new EnvHttpProxyAgent());

View file

@ -995,12 +995,15 @@ export interface RegisteredCommand {
// Extension API
// ============================================================================
type ExtensionHandlerResult<R> = [R] extends [undefined]
? Promise<void> | void
: Promise<R | undefined> | R | undefined;
/** Handler function type for events */
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements
export type ExtensionHandler<E, R = undefined> = (
event: E,
ctx: ExtensionContext,
) => Promise<R | void> | R | void;
) => ExtensionHandlerResult<R>;
/**
* ExtensionAPI passed to extension factory functions.

View file

@ -9,10 +9,10 @@ import { join } from "node:path";
import { URL } from "node:url";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AgentSession, AgentSessionEvent } from "../agent-session.js";
import type { Settings } from "../settings-manager.js";
import { extractMessageText, getLastAssistantText } from "./helpers.js";
import {
type GatewayEvent,
type GatewayQueuedMessage,
HttpError,
type ManagedGatewaySession,
} from "./internal-types.js";
@ -29,7 +29,6 @@ import type {
HistoryPart,
ModelInfo,
} from "./types.js";
import type { Settings } from "../settings-manager.js";
import {
createVercelStreamListener,
errorVercelStream,

View file

@ -836,10 +836,10 @@ export class ToolExecutionComponent extends Container {
.join("\n");
if (remaining > 0) {
text +=
theme.fg(
`${theme.fg(
"muted",
`\n... (${remaining} more lines, ${totalLines} total,`,
) + ` ${keyHint("expandTools", "to expand")})`;
)} ${keyHint("expandTools", "to expand")})`;
}
}

View file

@ -3,7 +3,7 @@ import type { AgentSessionEvent } from "../src/core/agent-session.js";
import {
createVercelStreamListener,
extractUserText,
} from "../src/core/vercel-ai-stream.js";
} from "../src/core/gateway/vercel-ai-stream.js";
describe("extractUserText", () => {
it("extracts text from useChat v5+ format with parts", () => {

View file

@ -0,0 +1,45 @@
# pi-grind
Explicit grind mode for Pi.
Features:
- Auto-activates only when the user uses explicit grind cues in a prompt
- Persists run state in session custom entries
- Continues work on a heartbeat while running in `pi daemon`
- Pauses automatically when the user sends a normal prompt
Example prompts:
- `Keep going on this until 5pm`
- `Don't stop until the refactor is done`
Commands:
- `/grind start --until "5pm" Ship the refactor`
- `/grind status`
- `/grind pause`
- `/grind resume`
- `/grind stop`
Settings:
```json
{
"pi-grind": {
"enabled": true,
"pollIntervalMs": 30000,
"cueMode": "explicit-only",
"requireDaemon": true,
"userIntervention": "pause",
"cuePatterns": [
"don't stop",
"keep going",
"keep running",
"run until",
"until done",
"stay on this until"
]
}
}
```

View file

@ -0,0 +1,35 @@
{
"name": "pi-grind",
"version": "0.1.0",
"description": "Explicit grind mode for pi with durable follow-up continuation in daemon mode",
"type": "module",
"keywords": [
"pi-package"
],
"license": "MIT",
"author": "OpenAI",
"main": "./src/index.ts",
"files": [
"src",
"test",
"package.json",
"README.md"
],
"pi": {
"extensions": [
"./src/index.ts"
]
},
"peerDependencies": {
"@mariozechner/pi-agent-core": "*",
"@mariozechner/pi-coding-agent": "*"
},
"devDependencies": {
"@types/node": "^24.3.0",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},
"scripts": {
"test": "vitest --run"
}
}

View file

@ -0,0 +1,64 @@
import { getAgentDir, SettingsManager } from "@mariozechner/pi-coding-agent";
import { DEFAULT_POLL_INTERVAL_MS, GRIND_SETTINGS_KEY, type GrindConfig } from "./types.js";
const DEFAULT_CUE_PATTERNS = [
"don't stop",
"keep going",
"keep running",
"run until",
"until done",
"stay on this until",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
}
function asStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const strings = value.filter((item): item is string => typeof item === "string");
return strings.length > 0 ? strings : undefined;
}
export function loadConfig(cwd: string): GrindConfig {
const settingsManager = SettingsManager.create(cwd, getAgentDir());
const globalSettings = settingsManager.getGlobalSettings() as Record<string, unknown>;
const projectSettings = settingsManager.getProjectSettings() as Record<string, unknown>;
const globalConfig = isRecord(globalSettings[GRIND_SETTINGS_KEY]) ? globalSettings[GRIND_SETTINGS_KEY] : {};
const projectConfig = isRecord(projectSettings[GRIND_SETTINGS_KEY]) ? projectSettings[GRIND_SETTINGS_KEY] : {};
const cuePatterns =
asStringArray(projectConfig.cuePatterns) ?? asStringArray(globalConfig.cuePatterns) ?? DEFAULT_CUE_PATTERNS;
const pollIntervalMsRaw =
typeof projectConfig.pollIntervalMs === "number"
? projectConfig.pollIntervalMs
: typeof globalConfig.pollIntervalMs === "number"
? globalConfig.pollIntervalMs
: DEFAULT_POLL_INTERVAL_MS;
return {
enabled:
typeof projectConfig.enabled === "boolean"
? projectConfig.enabled
: typeof globalConfig.enabled === "boolean"
? globalConfig.enabled
: true,
pollIntervalMs:
Number.isFinite(pollIntervalMsRaw) && pollIntervalMsRaw > 0
? Math.max(1_000, Math.floor(pollIntervalMsRaw))
: DEFAULT_POLL_INTERVAL_MS,
cueMode: "explicit-only",
requireDaemon:
typeof projectConfig.requireDaemon === "boolean"
? projectConfig.requireDaemon
: typeof globalConfig.requireDaemon === "boolean"
? globalConfig.requireDaemon
: true,
userIntervention: "pause",
cuePatterns,
};
}

View file

@ -0,0 +1,480 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionAPI, ExtensionContext, RegisteredCommand } from "@mariozechner/pi-coding-agent";
import { loadConfig } from "./config.js";
import { parseAutoActivation, parseGrindStatus, parseStopCondition } from "./parser.js";
import { buildContinuationPrompt, buildRepairPrompt, buildSystemPromptAddon } from "./prompts.js";
import { createRunState, getLatestRunState, withLoopStatus, withStatus } from "./state.js";
import {
DEFAULT_COMPLETION_CRITERION,
GRIND_STATE_ENTRY_TYPE,
type GrindConfig,
type GrindRunState,
MAX_PARSE_FAILURES,
} from "./types.js";
function isDaemonRuntime(): boolean {
if (process.env.PI_GRIND_FORCE_DAEMON === "1") {
return true;
}
if (process.env.PI_GRIND_FORCE_DAEMON === "0") {
return false;
}
return (
process.argv.includes("daemon") ||
process.argv.includes("gateway") ||
Boolean(process.env.PI_GATEWAY_BIND) ||
Boolean(process.env.PI_GATEWAY_PORT) ||
Boolean(process.env.PI_GATEWAY_TOKEN)
);
}
function getAssistantText(message: AgentMessage): string {
if (message.role !== "assistant") {
return "";
}
const parts: string[] = [];
for (const part of message.content) {
if (part.type === "text") {
parts.push(part.text);
}
}
return parts.join("\n");
}
function isGrindCommand(text: string): boolean {
return text.trim().toLowerCase().startsWith("/grind");
}
function note(ctx: ExtensionContext, message: string): void {
if (ctx.hasUI) {
ctx.ui.notify(message, "info");
}
}
function readState(ctx: ExtensionContext): GrindRunState | null {
return getLatestRunState(ctx.sessionManager.getEntries());
}
function persistState(pi: ExtensionAPI, ctx: ExtensionContext, state: GrindRunState): GrindRunState {
pi.appendEntry(GRIND_STATE_ENTRY_TYPE, state);
if (ctx.hasUI) {
ctx.ui.setStatus("pi-grind", state.status === "active" ? "GRIND" : state.status.toUpperCase());
}
return state;
}
function clearUiStatus(ctx: ExtensionContext): void {
if (ctx.hasUI) {
ctx.ui.setStatus("pi-grind", "");
}
}
function maybeExpireRun(pi: ExtensionAPI, ctx: ExtensionContext, state: GrindRunState): GrindRunState | null {
if (!state.deadlineAt) {
return state;
}
if (Date.parse(state.deadlineAt) > Date.now()) {
return state;
}
const expired = withStatus(state, "expired", {
lastCheckpoint: "Grind run expired at the configured deadline.",
lastNextAction: null,
pendingRepair: false,
});
persistState(pi, ctx, expired);
note(ctx, "Grind mode stopped: deadline reached.");
return expired;
}
function tokenizeArgs(input: string): string[] {
const tokens: string[] = [];
let current = "";
let quote: '"' | "'" | null = null;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if ((char === '"' || char === "'") && !quote) {
quote = char;
continue;
}
if (quote && char === quote) {
quote = null;
continue;
}
if (!quote && /\s/.test(char)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
current += char;
}
if (current) {
tokens.push(current);
}
return tokens;
}
function parseStartCommandArgs(args: string): {
goal: string | null;
until: string | null;
criterion: string | null;
} {
const tokens = tokenizeArgs(args);
const goalParts: string[] = [];
let until: string | null = null;
let criterion: string | null = null;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token === "--until") {
until = tokens[index + 1] ?? null;
index += 1;
continue;
}
if (token.startsWith("--until=")) {
until = token.slice("--until=".length) || null;
continue;
}
if (token === "--criterion") {
criterion = tokens[index + 1] ?? null;
index += 1;
continue;
}
if (token.startsWith("--criterion=")) {
criterion = token.slice("--criterion=".length) || null;
continue;
}
goalParts.push(token);
}
return {
goal: goalParts.length > 0 ? goalParts.join(" ") : null,
until,
criterion,
};
}
function startRun(
pi: ExtensionAPI,
ctx: ExtensionContext,
config: GrindConfig,
input: {
activation: "explicit" | "command";
goal: string;
sourcePrompt: string;
deadlineAt: string | null;
completionCriterion: string | null;
},
): GrindRunState | null {
if (!config.enabled) {
note(ctx, "Grind mode is disabled in settings.");
return null;
}
if (config.requireDaemon && !isDaemonRuntime()) {
note(ctx, "Durable grind mode requires `pi daemon`.");
return null;
}
const nextState = createRunState(input);
persistState(pi, ctx, nextState);
note(ctx, "Grind mode activated.");
return nextState;
}
export default function grind(pi: ExtensionAPI) {
let config: GrindConfig | null = null;
let state: GrindRunState | null = null;
let heartbeat: NodeJS.Timeout | null = null;
const getConfig = (cwd: string): GrindConfig => {
config = loadConfig(cwd);
return config;
};
const stopHeartbeat = () => {
if (heartbeat) {
clearInterval(heartbeat);
heartbeat = null;
}
};
const ensureHeartbeat = (ctx: ExtensionContext) => {
stopHeartbeat();
heartbeat = setInterval(() => {
if (!config || !state || state.status !== "active") {
return;
}
if (config.requireDaemon && !isDaemonRuntime()) {
return;
}
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
return;
}
const expired = maybeExpireRun(pi, ctx, state);
state = expired;
if (!state || state.status !== "active") {
return;
}
if (state.pendingRepair) {
pi.sendUserMessage(buildRepairPrompt(state), {
deliverAs: "followUp",
});
} else {
pi.sendUserMessage(buildContinuationPrompt(state), {
deliverAs: "followUp",
});
}
}, config?.pollIntervalMs ?? 30_000);
heartbeat.unref?.();
};
const registerCommand = (name: string, command: Omit<RegisteredCommand, "name">) => {
pi.registerCommand(name, command);
};
registerCommand("grind", {
description: "Manage grind mode: /grind [start|status|pause|resume|stop]",
getArgumentCompletions: (prefix: string) =>
["start", "status", "pause", "resume", "stop"]
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
.map((value) => ({ value, label: value })),
handler: async (args, ctx) => {
const currentConfig = getConfig(ctx.cwd);
const trimmed = args?.trim() ?? "";
const [command] = tokenizeArgs(trimmed);
const subcommand = command?.toLowerCase() ?? "status";
const restArgs = trimmed.slice(command?.length ?? 0).trim();
if (subcommand === "start") {
const parsed = parseStartCommandArgs(restArgs);
if (!parsed.goal) {
note(ctx, "Usage: /grind start [--until <time>] [--criterion <text>] <goal>");
return;
}
const parsedUntil = parsed.until
? parseStopCondition(`until ${parsed.until}`)
: { deadlineAt: null, completionCriterion: null };
const nextState = startRun(pi, ctx, currentConfig, {
activation: "command",
goal: parsed.goal,
sourcePrompt: parsed.goal,
deadlineAt: parsedUntil.deadlineAt,
completionCriterion: parsed.criterion ?? parsedUntil.completionCriterion ?? DEFAULT_COMPLETION_CRITERION,
});
state = nextState;
if (state) {
pi.sendUserMessage(parsed.goal, { deliverAs: "followUp" });
}
return;
}
if (subcommand === "status") {
const currentState = state ?? readState(ctx);
if (!currentState) {
note(ctx, "Grind mode is not active in this session.");
return;
}
note(
ctx,
[
`status=${currentState.status}`,
`goal=${currentState.goal}`,
currentState.deadlineAt ? `deadline=${currentState.deadlineAt}` : null,
currentState.completionCriterion ? `criterion=${currentState.completionCriterion}` : null,
currentState.lastCheckpoint ? `checkpoint=${currentState.lastCheckpoint}` : null,
]
.filter(Boolean)
.join("\n"),
);
return;
}
if (subcommand === "pause") {
if (!state) {
state = readState(ctx);
}
if (!state) {
note(ctx, "No grind run to pause.");
return;
}
state = persistState(pi, ctx, withStatus(state, "paused", { pendingRepair: false }));
note(ctx, "Grind mode paused.");
return;
}
if (subcommand === "resume") {
if (!state) {
state = readState(ctx);
}
if (!state) {
note(ctx, "No grind run to resume.");
return;
}
if (currentConfig.requireDaemon && !isDaemonRuntime()) {
note(ctx, "Durable grind mode requires `pi daemon`.");
return;
}
state = persistState(pi, ctx, withStatus(state, "active"));
note(ctx, "Grind mode resumed.");
pi.sendUserMessage(buildContinuationPrompt(state), {
deliverAs: "followUp",
});
return;
}
if (subcommand === "stop") {
if (!state) {
state = readState(ctx);
}
if (!state) {
note(ctx, "No grind run to stop.");
return;
}
state = persistState(
pi,
ctx,
withStatus(state, "stopped", {
pendingRepair: false,
lastNextAction: null,
}),
);
note(ctx, "Grind mode stopped.");
clearUiStatus(ctx);
return;
}
note(ctx, `Unknown grind command: ${subcommand}`);
},
});
pi.on("session_start", async (_event, ctx) => {
config = loadConfig(ctx.cwd);
state = readState(ctx);
if (state && ctx.hasUI) {
ctx.ui.setStatus("pi-grind", state.status === "active" ? "GRIND" : state.status.toUpperCase());
}
if (config.enabled) {
ensureHeartbeat(ctx);
}
});
pi.on("session_shutdown", async () => {
stopHeartbeat();
});
pi.on("input", async (event, ctx) => {
const currentConfig = getConfig(ctx.cwd);
if (!currentConfig.enabled || event.source === "extension") {
return { action: "continue" } as const;
}
const currentState = state ?? readState(ctx);
if (currentState && currentState.status === "active" && !isGrindCommand(event.text)) {
const activation = parseAutoActivation(event.text, currentConfig.cuePatterns);
if (!activation) {
if (currentConfig.userIntervention === "pause") {
state = persistState(pi, ctx, withStatus(currentState, "paused", { pendingRepair: false }));
note(ctx, "Grind mode paused for manual input.");
}
return { action: "continue" } as const;
}
}
const activation = parseAutoActivation(event.text, currentConfig.cuePatterns);
if (!activation) {
return { action: "continue" } as const;
}
state = startRun(pi, ctx, currentConfig, {
activation: "explicit",
goal: event.text,
sourcePrompt: event.text,
deadlineAt: activation.stopCondition.deadlineAt,
completionCriterion: activation.stopCondition.completionCriterion,
});
return { action: "continue" } as const;
});
pi.on("before_agent_start", async (event, ctx) => {
state = state ?? readState(ctx);
if (!state || state.status !== "active") {
return;
}
const expired = maybeExpireRun(pi, ctx, state);
state = expired;
if (!state || state.status !== "active") {
return;
}
return {
systemPrompt: `${event.systemPrompt}\n\n${buildSystemPromptAddon(state)}`,
};
});
pi.on("turn_end", async (event, ctx) => {
state = state ?? readState(ctx);
if (!state || state.status !== "active") {
return;
}
const expired = maybeExpireRun(pi, ctx, state);
state = expired;
if (!state || state.status !== "active") {
return;
}
const text = getAssistantText(event.message);
const parsed = parseGrindStatus(text);
if (!parsed) {
if (state.consecutiveParseFailures + 1 >= MAX_PARSE_FAILURES) {
state = persistState(
pi,
ctx,
withStatus(state, "blocked", {
pendingRepair: false,
consecutiveParseFailures: state.consecutiveParseFailures + 1,
lastCheckpoint: "Blocked: assistant failed to return a valid <grind_status> trailer.",
lastNextAction: null,
}),
);
note(ctx, "Grind mode blocked: invalid grind status trailer.");
return;
}
state = persistState(pi, ctx, {
...state,
pendingRepair: true,
consecutiveParseFailures: state.consecutiveParseFailures + 1,
updatedAt: new Date().toISOString(),
});
return;
}
state = persistState(pi, ctx, withLoopStatus(state, parsed));
if (state.status !== "active") {
note(ctx, `Grind mode ${state.status}.`);
if (state.status !== "paused") {
clearUiStatus(ctx);
}
}
});
}

View file

@ -0,0 +1,101 @@
import { parseDeadline } from "./time.js";
import {
DEFAULT_COMPLETION_CRITERION,
type GrindStatusPayload,
type ParsedAutoActivation,
type ParsedStopCondition,
} from "./types.js";
const GRIND_STATUS_PATTERN = /<grind_status>\s*(\{[\s\S]*?\})\s*<\/grind_status>/i;
function normalizeText(value: string): string {
return value.replace(/\s+/g, " ").trim().toLowerCase();
}
function extractUntilClause(text: string): string | null {
const match = text.match(/\buntil\b([\s\S]+)$/i);
if (!match) {
return null;
}
const clause = match[1]?.trim();
return clause ? clause : null;
}
export function detectCue(text: string, cuePatterns: readonly string[]): string | null {
const normalized = normalizeText(text);
for (const pattern of cuePatterns) {
if (normalized.includes(normalizeText(pattern))) {
return pattern;
}
}
return null;
}
export function parseStopCondition(text: string, now: Date = new Date()): ParsedStopCondition {
const clause = extractUntilClause(text);
if (!clause) {
return {
deadlineAt: null,
completionCriterion: DEFAULT_COMPLETION_CRITERION,
};
}
const deadline = parseDeadline(clause, now);
if (deadline) {
return {
deadlineAt: deadline.toISOString(),
completionCriterion: null,
};
}
return {
deadlineAt: null,
completionCriterion: clause,
};
}
export function parseAutoActivation(
text: string,
cuePatterns: readonly string[],
now: Date = new Date(),
): ParsedAutoActivation | null {
const matchedCue = detectCue(text, cuePatterns);
if (!matchedCue) {
return null;
}
return {
matchedCue,
stopCondition: parseStopCondition(text, now),
};
}
export function parseGrindStatus(text: string): GrindStatusPayload | null {
const match = text.match(GRIND_STATUS_PATTERN);
if (!match?.[1]) {
return null;
}
try {
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
const state = parsed.state;
const summary = parsed.summary;
const nextAction = parsed.nextAction;
if (
(state !== "continue" && state !== "done" && state !== "blocked") ||
typeof summary !== "string" ||
summary.trim().length === 0
) {
return null;
}
return {
state,
summary: summary.trim(),
nextAction: typeof nextAction === "string" ? nextAction.trim() : undefined,
};
} catch {
return null;
}
}

View file

@ -0,0 +1,60 @@
import type { GrindRunState } from "./types.js";
function describeStopCondition(state: GrindRunState): string {
const parts: string[] = [];
if (state.deadlineAt) {
parts.push(`Hard deadline: ${state.deadlineAt}.`);
}
if (state.completionCriterion) {
parts.push(`Completion criterion: ${state.completionCriterion}.`);
}
return parts.join(" ");
}
export function buildSystemPromptAddon(state: GrindRunState): string {
const lines = ["Grind mode is active for this session.", `Primary goal: ${state.goal}`];
const stopCondition = describeStopCondition(state);
if (stopCondition) {
lines.push(stopCondition);
}
lines.push("Keep working until the task is done, blocked, or the deadline passes.");
lines.push(
'Every response must end with <grind_status>{"state":"continue|done|blocked","summary":"...","nextAction":"..."}</grind_status>.',
);
return lines.join("\n");
}
export function buildContinuationPrompt(state: GrindRunState): string {
const parts = ["Continue the active grind run.", `Goal: ${state.goal}`];
const stopCondition = describeStopCondition(state);
if (stopCondition) {
parts.push(stopCondition);
}
if (state.lastCheckpoint) {
parts.push(`Last checkpoint: ${state.lastCheckpoint}`);
}
if (state.lastNextAction) {
parts.push(`Planned next action: ${state.lastNextAction}`);
}
parts.push(
'End with <grind_status>{"state":"continue|done|blocked","summary":"...","nextAction":"..."}</grind_status>.',
);
return parts.join("\n\n");
}
export function buildRepairPrompt(state: GrindRunState): string {
return [
"Your previous grind-mode response did not include a valid <grind_status> trailer.",
"Do not restart the task from scratch.",
state.lastCheckpoint ? `Latest known checkpoint: ${state.lastCheckpoint}` : "",
'Reply with a corrected trailer only, or a very short update plus <grind_status>{"state":"continue|done|blocked","summary":"...","nextAction":"..."}</grind_status>.',
]
.filter(Boolean)
.join("\n\n");
}

View file

@ -0,0 +1,132 @@
import { randomUUID } from "node:crypto";
import type { SessionEntry } from "@mariozechner/pi-coding-agent";
import {
GRIND_STATE_ENTRY_TYPE,
type GrindActivation,
type GrindRunState,
type GrindRunStatus,
type GrindStatusPayload,
} from "./types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
}
function isGrindRunStatus(value: unknown): value is GrindRunStatus {
return (
value === "active" ||
value === "paused" ||
value === "done" ||
value === "blocked" ||
value === "expired" ||
value === "stopped"
);
}
function isGrindActivation(value: unknown): value is GrindActivation {
return value === "explicit" || value === "command";
}
function isNullableString(value: unknown): value is string | null {
return typeof value === "string" || value === null;
}
export function createRunState(input: {
activation: GrindActivation;
goal: string;
sourcePrompt: string;
deadlineAt: string | null;
completionCriterion: string | null;
}): GrindRunState {
const now = new Date().toISOString();
return {
version: 1,
runId: randomUUID(),
activation: input.activation,
status: "active",
goal: input.goal,
sourcePrompt: input.sourcePrompt,
deadlineAt: input.deadlineAt,
completionCriterion: input.completionCriterion,
lastCheckpoint: null,
lastNextAction: null,
pendingRepair: false,
consecutiveParseFailures: 0,
consecutiveControllerFailures: 0,
updatedAt: now,
};
}
export function isValidRunState(value: unknown): value is GrindRunState {
if (!isRecord(value)) {
return false;
}
return (
value.version === 1 &&
typeof value.runId === "string" &&
isGrindActivation(value.activation) &&
isGrindRunStatus(value.status) &&
typeof value.goal === "string" &&
typeof value.sourcePrompt === "string" &&
isNullableString(value.deadlineAt) &&
isNullableString(value.completionCriterion) &&
isNullableString(value.lastCheckpoint) &&
isNullableString(value.lastNextAction) &&
typeof value.pendingRepair === "boolean" &&
typeof value.consecutiveParseFailures === "number" &&
typeof value.consecutiveControllerFailures === "number" &&
typeof value.updatedAt === "string"
);
}
export function getLatestRunState(entries: readonly SessionEntry[]): GrindRunState | null {
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.type !== "custom" || entry.customType !== GRIND_STATE_ENTRY_TYPE) {
continue;
}
if (isValidRunState(entry.data)) {
return entry.data;
}
}
return null;
}
export function withStatus(
state: GrindRunState,
status: GrindRunStatus,
overrides: Partial<GrindRunState> = {},
): GrindRunState {
return {
...state,
...overrides,
status,
updatedAt: new Date().toISOString(),
};
}
export function withLoopStatus(state: GrindRunState, payload: GrindStatusPayload): GrindRunState {
return {
...state,
status: payload.state === "continue" ? "active" : payload.state === "done" ? "done" : "blocked",
lastCheckpoint: payload.summary,
lastNextAction: payload.nextAction ?? null,
pendingRepair: false,
consecutiveParseFailures: 0,
consecutiveControllerFailures: 0,
updatedAt: new Date().toISOString(),
};
}
export function withControllerFailure(state: GrindRunState, note: string): GrindRunState {
return {
...state,
status: "blocked",
lastCheckpoint: note,
lastNextAction: null,
pendingRepair: false,
consecutiveControllerFailures: state.consecutiveControllerFailures + 1,
updatedAt: new Date().toISOString(),
};
}

View file

@ -0,0 +1,103 @@
function setTimeParts(base: Date, hours: number, minutes: number, seconds = 0): Date {
const next = new Date(base);
next.setHours(hours, minutes, seconds, 0);
return next;
}
function parseClockValue(input: string): { hours: number; minutes: number } | null {
const normalized = input
.trim()
.toLowerCase()
.replace(/^at\s+/, "")
.replace(/^by\s+/, "");
const match = normalized.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i);
if (!match) {
return null;
}
let hours = Number(match[1]);
const minutes = match[2] ? Number(match[2]) : 0;
const meridiem = match[3]?.toLowerCase();
if (minutes < 0 || minutes > 59) {
return null;
}
if (meridiem) {
if (hours < 1 || hours > 12) {
return null;
}
if (meridiem === "pm" && hours !== 12) {
hours += 12;
}
if (meridiem === "am" && hours === 12) {
hours = 0;
}
} else if (hours > 23) {
return null;
}
return { hours, minutes };
}
function stripTrailingContinuation(text: string): string {
return text
.trim()
.replace(/\s+(?:and|then)\b[\s\S]*$/i, "")
.trim();
}
export function parseDeadline(raw: string, now: Date = new Date()): Date | null {
const candidate = raw.trim();
if (!candidate) {
return null;
}
const normalized = candidate
.toLowerCase()
.replace(/^until\s+/, "")
.replace(/^by\s+/, "")
.trim();
if (!normalized) {
return null;
}
const tomorrowMatch = normalized.match(/^tomorrow\s+(.+)$/);
if (tomorrowMatch) {
const time = parseClockValue(stripTrailingContinuation(tomorrowMatch[1]));
if (!time) {
return null;
}
const base = new Date(now);
base.setDate(base.getDate() + 1);
return setTimeParts(base, time.hours, time.minutes);
}
const todayMatch = normalized.match(/^today\s+(.+)$/);
if (todayMatch) {
const time = parseClockValue(stripTrailingContinuation(todayMatch[1]));
if (!time) {
return null;
}
return setTimeParts(now, time.hours, time.minutes);
}
const time = parseClockValue(stripTrailingContinuation(normalized));
if (!time) {
const direct = new Date(candidate);
if (!Number.isNaN(direct.getTime())) {
return direct;
}
return null;
}
const sameDay = setTimeParts(now, time.hours, time.minutes);
if (sameDay.getTime() > now.getTime()) {
return sameDay;
}
const nextDay = new Date(now);
nextDay.setDate(nextDay.getDate() + 1);
return setTimeParts(nextDay, time.hours, time.minutes);
}

View file

@ -0,0 +1,53 @@
export const GRIND_SETTINGS_KEY = "pi-grind";
export const GRIND_STATE_ENTRY_TYPE = "pi-grind/state";
export const DEFAULT_COMPLETION_CRITERION = "finish the requested task";
export const DEFAULT_POLL_INTERVAL_MS = 30_000;
export const MAX_PARSE_FAILURES = 2;
export type GrindActivation = "explicit" | "command";
export type GrindCueMode = "explicit-only";
export type GrindInterventionMode = "pause";
export type GrindRunStatus = "active" | "paused" | "done" | "blocked" | "expired" | "stopped";
export type GrindLoopState = "continue" | "done" | "blocked";
export interface GrindConfig {
enabled: boolean;
pollIntervalMs: number;
cueMode: GrindCueMode;
requireDaemon: boolean;
userIntervention: GrindInterventionMode;
cuePatterns: string[];
}
export interface ParsedStopCondition {
deadlineAt: string | null;
completionCriterion: string | null;
}
export interface ParsedAutoActivation {
matchedCue: string | null;
stopCondition: ParsedStopCondition;
}
export interface GrindStatusPayload {
state: GrindLoopState;
summary: string;
nextAction?: string;
}
export interface GrindRunState {
version: 1;
runId: string;
activation: GrindActivation;
status: GrindRunStatus;
goal: string;
sourcePrompt: string;
deadlineAt: string | null;
completionCriterion: string | null;
lastCheckpoint: string | null;
lastNextAction: string | null;
pendingRepair: boolean;
consecutiveParseFailures: number;
consecutiveControllerFailures: number;
updatedAt: string;
}

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { detectCue, parseAutoActivation, parseGrindStatus, parseStopCondition } from "../src/parser.js";
describe("pi-grind parser", () => {
const now = new Date(2026, 2, 9, 9, 0, 0);
const cues = ["don't stop", "keep going", "run until"];
it("detects explicit grind cues", () => {
expect(detectCue("Please keep going on this", cues)).toBe("keep going");
expect(detectCue("Normal prompt", cues)).toBeNull();
});
it("parses time-based stop conditions", () => {
const result = parseStopCondition("keep going until 5pm", now);
expect(result.deadlineAt).toBe(new Date(2026, 2, 9, 17, 0, 0).toISOString());
expect(result.completionCriterion).toBeNull();
});
it("parses criterion-based stop conditions when no time is found", () => {
const result = parseStopCondition("don't stop until the migration is finished", now);
expect(result.deadlineAt).toBeNull();
expect(result.completionCriterion).toBe("the migration is finished");
});
it("parses full auto activation payloads", () => {
const result = parseAutoActivation("run until tomorrow 5:30pm and finish the report", cues, now);
expect(result).not.toBeNull();
expect(result?.matchedCue).toBe("run until");
expect(result?.stopCondition.deadlineAt).toBe(new Date(2026, 2, 10, 17, 30, 0).toISOString());
});
it("parses grind status trailers", () => {
const payload = parseGrindStatus(
'Work done.\n<grind_status>{"state":"continue","summary":"half done","nextAction":"finish tests"}</grind_status>',
);
expect(payload).toEqual({
state: "continue",
summary: "half done",
nextAction: "finish tests",
});
});
});

View file

@ -0,0 +1,72 @@
import type { SessionEntry } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { createRunState, getLatestRunState, withLoopStatus, withStatus } from "../src/state.js";
import { GRIND_STATE_ENTRY_TYPE } from "../src/types.js";
describe("pi-grind state", () => {
it("creates active run state", () => {
const state = createRunState({
activation: "explicit",
goal: "Ship the refactor",
sourcePrompt: "Don't stop until this is done",
deadlineAt: null,
completionCriterion: "this is done",
});
expect(state.status).toBe("active");
expect(state.goal).toBe("Ship the refactor");
expect(state.completionCriterion).toBe("this is done");
});
it("hydrates latest persisted state from session entries", () => {
const older = createRunState({
activation: "command",
goal: "older",
sourcePrompt: "older",
deadlineAt: null,
completionCriterion: null,
});
const newer = withStatus(older, "paused");
const entries = [
{
type: "custom",
id: "a",
parentId: null,
timestamp: new Date().toISOString(),
customType: GRIND_STATE_ENTRY_TYPE,
data: older,
},
{
type: "custom",
id: "b",
parentId: "a",
timestamp: new Date().toISOString(),
customType: GRIND_STATE_ENTRY_TYPE,
data: newer,
},
] satisfies SessionEntry[];
expect(getLatestRunState(entries)?.status).toBe("paused");
});
it("applies loop results", () => {
const state = createRunState({
activation: "explicit",
goal: "Ship it",
sourcePrompt: "Ship it",
deadlineAt: null,
completionCriterion: null,
});
const next = withLoopStatus(state, {
state: "done",
summary: "finished",
nextAction: "none",
});
expect(next.status).toBe("done");
expect(next.lastCheckpoint).toBe("finished");
expect(next.lastNextAction).toBe("none");
});
});