import type { AgentMessage } from "@mariozechner/companion-agent-core"; import type { ExtensionAPI, ExtensionContext, RegisteredCommand } from "@mariozechner/companion-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.COMPANION_GRIND_FORCE_DAEMON === "1") { return true; } if (process.env.COMPANION_GRIND_FORCE_DAEMON === "0") { return false; } return ( process.argv.includes("daemon") || process.argv.includes("gateway") || Boolean(process.env.COMPANION_GATEWAY_BIND) || Boolean(process.env.COMPANION_GATEWAY_PORT) || Boolean(process.env.COMPANION_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(companion: ExtensionAPI, ctx: ExtensionContext, state: GrindRunState): GrindRunState { companion.appendEntry(GRIND_STATE_ENTRY_TYPE, state); if (ctx.hasUI) { ctx.ui.setStatus("companion-grind", state.status === "active" ? "GRIND" : state.status.toUpperCase()); } return state; } function clearUiStatus(ctx: ExtensionContext): void { if (ctx.hasUI) { ctx.ui.setStatus("companion-grind", ""); } } function maybeExpireRun(companion: 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(companion, 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( companion: 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 `companion daemon`."); return null; } const nextState = createRunState(input); persistState(companion, ctx, nextState); note(ctx, "Grind mode activated."); return nextState; } export default function grind(companion: 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(companion, ctx, state); state = expired; if (!state || state.status !== "active") { return; } if (state.pendingRepair) { companion.sendUserMessage(buildRepairPrompt(state), { deliverAs: "followUp", }); } else { companion.sendUserMessage(buildContinuationPrompt(state), { deliverAs: "followUp", }); } }, config?.pollIntervalMs ?? 30_000); heartbeat.unref?.(); }; const registerCommand = (name: string, command: Omit) => { companion.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