From 9e7e9ee68a719e4a63d74652113ad5490fb22d54 Mon Sep 17 00:00:00 2001 From: Yasha Date: Thu, 18 Jun 2026 20:07:31 +0200 Subject: [PATCH] feat: add session-bound /loop and agent-managed loop/heartbeat tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new session-bound /loop feature (alongside the untouched, durable /heartbeat) that wakes the agent visibly in the live conversation, plus two memory-style tools so the agent can manage both itself. /loop (packages/cli/src/cli/loop.ts): - Fixed mode (/loop 5m ) and self-paced mode (/loop ), the latter scheduling its own next wake via schedule_wakeup/end_loop tools layered onto the live tool set only while a dynamic loop runs. - A 1s ticker in interactive-mode re-injects the prompt as a visible turn between turns; Esc/`/loop stop`/`/clear` end it. Footer ⟳ chip and render lines mark the lifecycle. Agent-facing management tools (modeled on the memory tool): - `heartbeat` (core, in createCodingTools): list/create/update/delete/ view_log over the per-project heartbeat store + user crontab. - `loop` (CLI, registered at interactive startup): status/start/stop, bridged to the live session so an agent-started loop behaves exactly like a /loop the user typed (shared start/stop/status logic). Tests: unit + e2e for /loop (drives the real REPL + 1s ticker) and the heartbeat tool (fake crontab IO). 760 tests pass; typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/cli/loop.ts | 439 ++++++++++++++++++ .../src/modes/interactive/interactive-mode.ts | 230 ++++++++- packages/cli/src/modes/interactive/render.ts | 130 +++++- packages/cli/tests/loop.e2e.test.ts | 271 +++++++++++ packages/cli/tests/loop.test.ts | 284 +++++++++++ packages/core/src/index.ts | 5 + packages/core/src/tools/heartbeat-tool.ts | 273 +++++++++++ packages/core/src/tools/index.ts | 14 + packages/core/tests/heartbeat-tool.test.ts | 165 +++++++ 9 files changed, 1807 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/cli/loop.ts create mode 100644 packages/cli/tests/loop.e2e.test.ts create mode 100644 packages/cli/tests/loop.test.ts create mode 100644 packages/core/src/tools/heartbeat-tool.ts create mode 100644 packages/core/tests/heartbeat-tool.test.ts diff --git a/packages/cli/src/cli/loop.ts b/packages/cli/src/cli/loop.ts new file mode 100644 index 0000000..de8d2b1 --- /dev/null +++ b/packages/cli/src/cli/loop.ts @@ -0,0 +1,439 @@ +import type { AgentTool } from '@earendil-works/pi-agent-core'; +import { Type } from '@sinclair/typebox'; + +/** + * Session-bound `/loop`. Unlike `/heartbeat` (durable, headless, crontab-backed), + * a loop lives entirely inside one interactive session: a timer re-injects the + * loop's prompt as a visible turn into the live conversation, and the loop dies + * when the session ends. Two modes: + * + * fixed — `/loop 5m ` fires every N on a clock the user set. + * dynamic — `/loop ` each tick the model schedules its own next + * wake via the `schedule_wakeup` tool (or stops via `end_loop`). + * + * This module is pure state + parsing + tool definitions; all terminal I/O and + * the actual agent run live in interactive-mode, which drives the controller. + */ + +/** Floor for any interval/delay — guards against a runaway tight loop. */ +export const MIN_INTERVAL_MS = 5_000; +/** Ceiling for a fixed interval — a session-bound loop longer than this is a + * heartbeat in disguise; steer the user there instead. */ +export const MAX_INTERVAL_MS = 24 * 60 * 60_000; + +export type LoopMode = 'fixed' | 'dynamic'; + +export interface LoopSpec { + mode: LoopMode; + prompt: string; + /** Fixed mode only: ms between ticks. */ + intervalMs?: number; +} + +export interface LoopState { + mode: LoopMode; + prompt: string; + intervalMs?: number; + /** Completed ticks. */ + iterations: number; + status: 'waiting' | 'running'; + /** Epoch ms of the next due tick (meaningful while `waiting`). */ + nextFireAt: number; + startedAt: number; + /** Dynamic mode: the reason attached to the most recent scheduled wake. */ + lastReason?: string; +} + +export type ParseLoopResult = + | { kind: 'spec'; spec: LoopSpec } + | { kind: 'command'; command: 'stop' | 'status' } + | { kind: 'help' } + | { kind: 'error'; message: string }; + +const DURATION_RE = /^(\d+)(s|m|h|d)$/i; +const UNIT_MS: Record = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }; + +/** Parse a single duration token (e.g. `5m`, `30s`, `2h`) to ms, or null. */ +export function parseDuration(token: string): number | null { + const m = DURATION_RE.exec(token); + if (!m) return null; + const value = Number(m[1]); + if (!Number.isFinite(value) || value <= 0) return null; + return value * UNIT_MS[m[2].toLowerCase()]; +} + +/** + * Parse the raw argument string after `/loop`. Shapes: + * "" → help + * "stop"|"status" → control command + * "5m " → fixed spec + * "" → dynamic spec + */ +export function parseLoopArgs(raw: string): ParseLoopResult { + const trimmed = raw.trim(); + if (!trimmed) return { kind: 'help' }; + + const lower = trimmed.toLowerCase(); + if (lower === 'stop' || lower === 'off' || lower === 'cancel') { + return { kind: 'command', command: 'stop' }; + } + if (lower === 'status' || lower === 'list') { + return { kind: 'command', command: 'status' }; + } + + const spaceIdx = trimmed.search(/\s/); + const firstToken = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); + const rest = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim(); + + const intervalMs = parseDuration(firstToken); + if (intervalMs !== null) { + // Leading token is an interval → fixed mode, the remainder is the prompt. + if (!rest) { + return { + kind: 'error', + message: `Provide a prompt to run every ${firstToken}, e.g. /loop ${firstToken} check the build.`, + }; + } + if (intervalMs < MIN_INTERVAL_MS) { + return { kind: 'error', message: `Interval too short — minimum is ${formatLoopDelay(MIN_INTERVAL_MS)}.` }; + } + if (intervalMs > MAX_INTERVAL_MS) { + return { + kind: 'error', + message: `Interval too long for a session loop (max ${formatLoopDelay(MAX_INTERVAL_MS)}). For longer schedules use /heartbeat.`, + }; + } + return { kind: 'spec', spec: { mode: 'fixed', prompt: rest, intervalMs } }; + } + + // No leading interval → self-paced; the whole string is the prompt. + return { kind: 'spec', spec: { mode: 'dynamic', prompt: trimmed } }; +} + +export type LoopTickOutcome = + | { kind: 'scheduled'; delayMs: number; reason?: string; iterations: number } + | { kind: 'finished'; reason?: string; iterations: number; implicit: boolean } + | { kind: 'stopped' }; + +type Directive = + | { type: 'next'; delayMs: number; reason?: string } + | { type: 'done'; reason?: string }; + +/** + * Pure state machine for the single active loop in a session. No timers, no I/O: + * interactive-mode polls `due()` once a second, runs the tick through the normal + * prompt path, then calls `endTick()` to advance the schedule. + */ +export class LoopController { + private state: LoopState | null = null; + /** Set by the dynamic-mode tools during a tick; consumed by endTick(). */ + private directive: Directive | null = null; + + get active(): boolean { + return this.state !== null; + } + + /** Read-only view of the current loop, or null when idle. */ + get snapshot(): Readonly | null { + return this.state; + } + + /** Begin a loop. The first tick is due immediately (next poll). */ + start(spec: LoopSpec, now: number): void { + this.state = { + mode: spec.mode, + prompt: spec.prompt, + intervalMs: spec.intervalMs, + iterations: 0, + status: 'waiting', + nextFireAt: now, + startedAt: now, + }; + this.directive = null; + } + + /** Stop and clear the loop. Returns the final state (for a summary) or null. */ + stop(): LoopState | null { + const s = this.state; + this.state = null; + this.directive = null; + return s; + } + + /** True when a tick is due and we are idle/waiting. */ + due(now: number): boolean { + return this.state !== null && this.state.status === 'waiting' && now >= this.state.nextFireAt; + } + + /** Mark a tick as in-flight. Returns the 1-based tick number for framing. */ + beginTick(): number { + if (!this.state) throw new Error('no active loop'); + this.state.status = 'running'; + this.directive = null; + return this.state.iterations + 1; + } + + /** Dynamic mode: the model requests its next wake (via `schedule_wakeup`). */ + requestNextWake(delaySeconds: number, reason?: string): void { + const delayMs = Math.min( + MAX_INTERVAL_MS, + Math.max(MIN_INTERVAL_MS, Math.round(delaySeconds * 1_000)), + ); + this.directive = { type: 'next', delayMs, reason }; + } + + /** Dynamic mode: the model declares the task complete (via `end_loop`). */ + requestDone(reason?: string): void { + this.directive = { type: 'done', reason }; + } + + /** + * Advance after a tick's agent run completes. Fixed mode always reschedules; + * dynamic mode follows the directive the tools set, and stops if the model + * neither scheduled a wake nor declared completion (no silent runaway). + */ + endTick(now: number): LoopTickOutcome { + if (!this.state) return { kind: 'stopped' }; + this.state.iterations += 1; + const iterations = this.state.iterations; + + if (this.state.mode === 'fixed') { + const interval = this.state.intervalMs ?? MIN_INTERVAL_MS; + this.state.status = 'waiting'; + this.state.nextFireAt = now + interval; + return { kind: 'scheduled', delayMs: interval, iterations }; + } + + const directive = this.directive; + this.directive = null; + if (directive?.type === 'next') { + this.state.status = 'waiting'; + this.state.nextFireAt = now + directive.delayMs; + this.state.lastReason = directive.reason; + return { kind: 'scheduled', delayMs: directive.delayMs, reason: directive.reason, iterations }; + } + + const reason = directive?.type === 'done' ? directive.reason : undefined; + const implicit = !directive; + this.state = null; + return { kind: 'finished', reason, iterations, implicit }; + } +} + +/** + * Build the prompt injected on each tick. The system-reminder makes clear this + * is an automated wake-up (not freshly typed by the user) and, for dynamic + * loops, tells the model how to schedule its own next iteration. + */ +export function buildLoopTickPrompt(opts: { + mode: LoopMode; + prompt: string; + tickNumber: number; +}): string { + const reminder = + `This is an automated /loop wake-up — iteration #${opts.tickNumber}. ` + + `A timer in the user's live session triggered it; the user did not just type this. ` + + `The standing loop instruction is reproduced below — continue from the conversation so far.` + + (opts.mode === 'dynamic' + ? ` When you finish this iteration, do exactly one of: call \`schedule_wakeup\` ` + + `(with the delay until you should next check, and a short reason) if the task is not ` + + `yet done; or call \`end_loop\` (with a short summary) if it is complete. ` + + `If you call neither, the loop stops.` + : ` The loop will wake you again automatically on its fixed schedule.`) + + `\n\n${opts.prompt}`; + return reminder; +} + +/** Human-friendly delay: `45s`, `8m`, `1h30m`, `2h`. */ +export function formatLoopDelay(ms: number): string { + const totalSec = Math.round(ms / 1_000); + if (totalSec < 60) return `${totalSec}s`; + const totalMin = Math.round(totalSec / 60); + if (totalMin < 60) return `${totalMin}m`; + const hours = Math.floor(totalMin / 60); + const mins = totalMin % 60; + return mins === 0 ? `${hours}h` : `${hours}h${mins}m`; +} + +const scheduleWakeupSchema = Type.Object({ + delaySeconds: Type.Number({ + description: + 'Seconds until the next loop iteration should run. Choose it from how soon the ' + + 'situation could meaningfully change (short while a build runs; longer when a PR is quiet).', + }), + reason: Type.Optional( + Type.String({ + description: 'Short, user-visible explanation of why you chose this delay.', + }), + ), +}); + +const endLoopSchema = Type.Object({ + reason: Type.Optional( + Type.String({ description: 'Short, user-visible summary of why the loop is complete.' }), + ), +}); + +export interface ScheduleWakeupDetails { + scheduled: boolean; + delaySeconds?: number; + reason?: string; +} +export interface EndLoopDetails { + ended: boolean; + reason?: string; +} + +/** Build the `schedule_wakeup` tool bound to a controller. */ +function createScheduleWakeupTool( + controller: LoopController, +): AgentTool { + return { + name: 'schedule_wakeup', + label: 'schedule_wakeup', + description: + 'Schedule the next iteration of the current self-paced /loop. Call this once, at the ' + + 'end of your turn, when the looped task is not yet complete. The same loop prompt runs ' + + 'again after the delay.', + parameters: scheduleWakeupSchema, + async execute(_toolCallId, params) { + if (!controller.active) { + return { + content: [{ type: 'text', text: 'No active self-paced loop; nothing scheduled.' }], + details: { scheduled: false }, + }; + } + controller.requestNextWake(params.delaySeconds, params.reason); + const delayMs = Math.max(MIN_INTERVAL_MS, Math.round(params.delaySeconds * 1_000)); + return { + content: [{ type: 'text', text: `Next wake-up scheduled in ${formatLoopDelay(delayMs)}.` }], + details: { scheduled: true, delaySeconds: params.delaySeconds, reason: params.reason }, + }; + }, + }; +} + +/** Build the `end_loop` tool bound to a controller. */ +function createEndLoopTool( + controller: LoopController, +): AgentTool { + return { + name: 'end_loop', + label: 'end_loop', + description: + 'End the current self-paced /loop because the task is complete. Call this instead of ' + + 'schedule_wakeup when there is no further work to do.', + parameters: endLoopSchema, + async execute(_toolCallId, params) { + if (!controller.active) { + return { + content: [{ type: 'text', text: 'No active self-paced loop; nothing to end.' }], + details: { ended: false }, + }; + } + controller.requestDone(params.reason); + return { + content: [{ type: 'text', text: 'Loop will end after this iteration.' }], + details: { ended: true, reason: params.reason }, + }; + }, + }; +} + +/** + * The two tools a self-paced loop exposes. They are pushed onto the live agent's + * tool list when a dynamic loop starts and removed when it stops, so they never + * clutter the default tool set or a fixed-interval loop. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createLoopTools(controller: LoopController): AgentTool[] { + return [createScheduleWakeupTool(controller), createEndLoopTool(controller)]; +} + +/** + * Bridge the always-on `loop` management tool to the live session. The + * interactive REPL implements this (sharing its logic with the `/loop` slash + * command) so a loop started by the agent behaves exactly like one a user typed. + */ +export interface LoopToolHost { + /** Start a loop from a validated spec. Returns a model-facing message. */ + start(spec: LoopSpec): { ok: boolean; message: string }; + /** Stop the active loop. Returns a model-facing message. */ + stop(): { ok: boolean; message: string }; + /** Plain-text status of the active loop (or a "no loop" note). */ + status(): string; +} + +const loopToolSchema = Type.Object({ + command: Type.Union( + [Type.Literal('status'), Type.Literal('start'), Type.Literal('stop')], + { + description: + 'status: describe the active loop. start: begin a loop (provide a prompt; add an ' + + 'interval for fixed cadence, or omit it to self-pace). stop: end the active loop.', + }, + ), + interval: Type.Optional( + Type.String({ + description: + 'Fixed cadence like "5m", "30s", "2h" (units s/m/h/d). Omit for a self-paced loop where ' + + 'you call schedule_wakeup each iteration to pick the next delay.', + }), + ), + prompt: Type.Optional( + Type.String({ description: 'The instruction to run each iteration. Required for start.' }), + ), +}); + +export interface LoopToolDetails { + command: string; + ok: boolean; +} + +/** + * The always-on `loop` management tool. Lets the agent start/stop/inspect the + * session loop itself — the same affordance the `/loop` command gives the user. + */ +export function createLoopManagementTool( + host: LoopToolHost, +): AgentTool { + return { + name: 'loop', + label: 'loop', + description: + 'Manage the session loop — a prompt that re-runs on a timer inside this live session and ' + + 'ends when the session does (use `heartbeat` instead for a durable cron schedule). ' + + 'Commands: status, start, stop. Starting one runs its first iteration shortly after your ' + + 'current turn ends.', + parameters: loopToolSchema, + async execute(_toolCallId, params) { + const { command } = params; + if (command === 'status') { + return { content: [{ type: 'text', text: host.status() }], details: { command, ok: true } }; + } + if (command === 'stop') { + const r = host.stop(); + return { content: [{ type: 'text', text: r.message }], details: { command, ok: r.ok } }; + } + // start + const prompt = params.prompt?.trim(); + if (!prompt) { + return { + content: [{ type: 'text', text: 'Error: start requires a prompt.' }], + details: { command, ok: false }, + }; + } + const raw = (params.interval ? params.interval.trim() + ' ' : '') + prompt; + const parsed = parseLoopArgs(raw); + if (parsed.kind !== 'spec') { + const message = + parsed.kind === 'error' + ? parsed.message + : 'Error: could not interpret the loop arguments.'; + return { content: [{ type: 'text', text: message }], details: { command, ok: false } }; + } + const r = host.start(parsed.spec); + return { content: [{ type: 'text', text: r.message }], details: { command, ok: r.ok } }; + }, + }; +} diff --git a/packages/cli/src/modes/interactive/interactive-mode.ts b/packages/cli/src/modes/interactive/interactive-mode.ts index 806d82b..5840e66 100644 --- a/packages/cli/src/modes/interactive/interactive-mode.ts +++ b/packages/cli/src/modes/interactive/interactive-mode.ts @@ -36,6 +36,17 @@ import { runSetGoalConfigCommand } from '../../cli/goal-config-prompt.js'; import { runConnectCommand, runDisconnectCommand } from '../../cli/connect-prompt.js'; import { runConnectGithubCommand } from '../../cli/github-prompt.js'; import { runHeartbeatCommand } from '../../cli/heartbeat-prompt.js'; +import { + LoopController, + buildLoopTickPrompt, + createLoopManagementTool, + createLoopTools, + formatLoopDelay, + parseLoopArgs, + type LoopSpec, + type LoopState, + type LoopToolHost, +} from '../../cli/loop.js'; import { clipboardInstallHint, formatImageSize, @@ -96,6 +107,8 @@ interface CommandContext { ensureBundledSkills: () => EnsureResult; invokeSkill: (skill: Skill, args: string, echoLabel: string) => Promise; writeAbove: (text: string) => void; + /** Handle a `/loop` invocation (start / stop / status / help). */ + loop: (args: string) => void; } export const SLASH_COMMANDS: SlashCommand[] = [ @@ -296,6 +309,16 @@ export const SLASH_COMMANDS: SlashCommand[] = [ return true; }, }, + { + name: '/loop', + description: 'Loop a prompt in this session (e.g. /loop 5m run tests, or /loop watch CI)', + pause: false, + acceptsArgs: true, + action: async (ctx, args) => { + ctx.loop(args); + return true; + }, + }, { name: '/connect', description: 'Connect this machine to a context engine to push conversations', @@ -746,6 +769,45 @@ export async function runInteractiveMode( // the entry is committed to the scrollback as a normal user message. let pendingSteers: { message: AgentMessage; rawText: string }[] = []; + // ── Session loop (/loop) ──────────────────────────────────────────── + // A loop re-injects its prompt as a visible turn on a timer, in this very + // session. The controller is pure state; the 1s ticker below (defined once + // the textarea + runPrompt exist) drives the actual ticks. Dynamic loops + // expose `schedule_wakeup`/`end_loop`, layered onto the live tool list only + // while such a loop runs so they never clutter the default set. + const loop = new LoopController(); + const loopTools = createLoopTools(loop); + const addLoopTools = (): void => { + const present = new Set(session.agent.state.tools.map((t) => t.name)); + const toAdd = loopTools.filter((t) => !present.has(t.name)); + // Assign through the setter (the getter may hand back a copy) so the new + // tools actually land on the live agent. + if (toAdd.length > 0) session.agent.state.tools = [...session.agent.state.tools, ...toAdd]; + }; + const removeLoopTools = (): void => { + const names = new Set(loopTools.map((t) => t.name)); + session.agent.state.tools = session.agent.state.tools.filter((t) => !names.has(t.name)); + }; + // Stop the loop and announce it (skips the announcement when no textarea is + // live, e.g. during teardown). Safe to call when no loop is active; returns + // the final state (for a tick count) or null. + const stopLoop = (announce = true): LoopState | null => { + if (!loop.active) return null; + const final = loop.stop(); + removeLoopTools(); + if (announce && textarea && textarea.isActive()) { + textarea.writeAbove('\n' + render.loopStopped({ iterations: final?.iterations ?? 0 }) + '\n'); + } + return final; + }; + // Footer chip label: the next-wake countdown, or "running" mid-tick. + const loopFooterLabel = (): string => { + const s = loop.snapshot; + if (!s) return ''; + if (s.status === 'running') return 'running'; + return `next ${formatLoopDelay(Math.max(0, s.nextFireAt - Date.now()))}`; + }; + // ── Permission mode (cycled with shift+tab) ─────────────────────── // Three coding-agent modes, mirroring Claude Code: // plan — read-only; the agent drafts a plan via the exit_plan @@ -908,6 +970,7 @@ export async function runInteractiveMode( backgroundJobs: runningJobs, backgroundJobsFocused: footerFocused, attachedImages: pendingImages.length, + loop: loop.active ? { label: loopFooterLabel() } : undefined, }); }, onShiftTab: () => { @@ -981,7 +1044,12 @@ export async function runInteractiveMode( // (their message references are preserved, so future commits still match). onSteerDequeue: () => { const popped = pendingSteers.pop(); - if (!popped) return null; + if (!popped) { + // Nothing queued: Esc on an empty prompt while a loop waits idle stops + // the loop (a live tick is interrupted via the normal 'interrupt' path). + if (loop.active && !agentBusy) stopLoop(); + return null; + } try { session.agent.clearSteeringQueue(); for (const s of pendingSteers) session.agent.steer(s.message); @@ -1132,6 +1200,8 @@ export async function runInteractiveMode( textarea.on('exit', () => { stopSpinner(); + clearInterval(loopTimer); + stopLoop(false); textarea.close(); resolve(); }); @@ -1291,10 +1361,14 @@ export async function runInteractiveMode( text: string, echo?: string, images?: ImageContent[], + // Pre-rendered echo block written verbatim instead of the `❯` user-message + // caret — used by loop ticks to show their own `⟳ loop tick #N` header. + echoOverride?: string, ): Promise => { // Leading blank separates the echo from the previous block. const imgNote = images && images.length > 0 ? chalk.dim(` 🖼 ${images.length}`) : ''; - textarea.writeAbove('\n' + render.userMessage(echo ?? text) + imgNote + '\n'); + const echoBlock = echoOverride ?? render.userMessage(echo ?? text); + textarea.writeAbove('\n' + echoBlock + imgNote + '\n'); agentBusy = true; startSpinner(); @@ -1333,6 +1407,154 @@ export async function runInteractiveMode( } }; + // ── Loop ticker ───────────────────────────────────────────────────── + // Once a second, if a tick is due and the agent is idle, inject the loop's + // prompt as a visible turn. It runs between turns (never mid-response), then + // advances the schedule — fixed loops reschedule by their interval; dynamic + // loops follow the wake the model scheduled, or end if it scheduled none. + let loopTickInFlight = false; + const runLoopTick = async (): Promise => { + loopTickInFlight = true; + try { + const tickNumber = loop.beginTick(); + const snap = loop.snapshot; + if (!snap) return; + const header = render.loopTickHeader({ tickNumber, prompt: snap.prompt }); + const payload = buildLoopTickPrompt({ + mode: snap.mode, + prompt: snap.prompt, + tickNumber, + }); + await runPrompt(payload, undefined, undefined, header); + const outcome = loop.endTick(Date.now()); + if (outcome.kind === 'scheduled') { + textarea.writeAbove( + '\n' + + render.loopScheduled({ + delayLabel: formatLoopDelay(outcome.delayMs), + reason: outcome.reason, + }) + + '\n', + ); + } else if (outcome.kind === 'finished') { + removeLoopTools(); + textarea.writeAbove( + '\n' + + render.loopFinished({ + iterations: outcome.iterations, + reason: outcome.reason, + implicit: outcome.implicit, + }) + + '\n', + ); + } + } finally { + loopTickInFlight = false; + } + }; + const loopTimer = setInterval(() => { + if (loopTickInFlight || agentBusy) return; + // Don't fire while a modal viewer/approval owns the screen (textarea paused). + if (!textarea.isActive()) return; + if (loop.due(Date.now())) void runLoopTick(); + }, 1000); + // Don't let the ticker keep the process alive on its own. + loopTimer.unref(); + + // Parse and dispatch a `/loop` invocation. Starting a loop only sets state; + // the ticker above runs the first tick on its next pass (within ~1s). + // Shared start/status logic, used by BOTH the `/loop` slash command and the + // always-on `loop` agent tool — so a loop the agent starts behaves exactly + // like one the user typed (same render lines, same dynamic-tool wiring). + const startLoop = (spec: LoopSpec): { ok: boolean; message: string } => { + if (loop.active) { + return { ok: false, message: 'A loop is already running — stop it first.' }; + } + if (spec.mode === 'dynamic') addLoopTools(); + loop.start(spec, Date.now()); + textarea.writeAbove( + render.loopStarted({ + mode: spec.mode, + intervalLabel: spec.intervalMs != null ? formatLoopDelay(spec.intervalMs) : undefined, + prompt: spec.prompt, + }) + '\n', + ); + textarea.redraw(); + const cadence = + spec.mode === 'fixed' ? `every ${formatLoopDelay(spec.intervalMs ?? 0)}` : 'self-paced'; + return { + ok: true, + message: `Started a ${cadence} loop: ${spec.prompt}. The first iteration runs shortly after this turn.`, + }; + }; + + const loopStatusText = (): string => { + const s = loop.snapshot; + if (!s) return 'No loop is running.'; + const cadence = + s.mode === 'fixed' ? `fixed (every ${formatLoopDelay(s.intervalMs ?? 0)})` : 'self-paced'; + const when = + s.status === 'running' + ? 'running now' + : `next in ${formatLoopDelay(Math.max(0, s.nextFireAt - Date.now()))}`; + return `Loop: ${cadence}, ${s.iterations} ticks so far, ${when}.\nPrompt: ${s.prompt}`; + }; + + // Register the always-on `loop` management tool, bridged to this session. + const loopHost: LoopToolHost = { + start: startLoop, + stop: () => { + if (!loop.active) return { ok: false, message: 'No loop is running.' }; + const final = stopLoop(); // announces in-session + return { ok: true, message: `Stopped the loop after ${final?.iterations ?? 0} ticks.` }; + }, + status: loopStatusText, + }; + session.agent.state.tools = [...session.agent.state.tools, createLoopManagementTool(loopHost)]; + + const loopCommand = (args: string): void => { + const parsed = parseLoopArgs(args); + switch (parsed.kind) { + case 'help': + textarea.writeAbove(render.loopHelp() + '\n'); + return; + case 'error': + textarea.writeAbove('\n' + render.loopError(parsed.message) + '\n'); + return; + case 'command': { + if (!loop.active) { + textarea.writeAbove('\n' + render.loopError('No loop is running.') + '\n'); + return; + } + if (parsed.command === 'stop') { + stopLoop(); + return; + } + const s = loop.snapshot; + if (s) { + textarea.writeAbove( + render.loopStatus({ + mode: s.mode, + prompt: s.prompt, + iterations: s.iterations, + running: s.status === 'running', + nextInLabel: + s.status === 'waiting' + ? formatLoopDelay(Math.max(0, s.nextFireAt - Date.now())) + : undefined, + }) + '\n', + ); + } + return; + } + case 'spec': { + const r = startLoop(parsed.spec); + if (!r.ok) textarea.writeAbove('\n' + render.loopError(r.message) + '\n'); + return; + } + } + }; + const invokeSkill = async (skill: Skill, args: string, echoLabel: string): Promise => { let expanded: string; try { @@ -1364,6 +1586,9 @@ export async function runInteractiveMode( } catch { // no active run — nothing to abort } + // A loop is session-scoped; clearing the session ends it. Quietly — + // the screen is wiped just below. + stopLoop(false); session.agent.reset(); pendingTools.clear(); pendingImages = []; @@ -1384,6 +1609,7 @@ export async function runInteractiveMode( ensureBundledSkills, invokeSkill, writeAbove: (text) => textarea.writeAbove(text), + loop: (args: string) => loopCommand(args), }; // Run a command action. Commands with pause:false stay live on the textarea diff --git a/packages/cli/src/modes/interactive/render.ts b/packages/cli/src/modes/interactive/render.ts index 0969427..1cf4dd2 100644 --- a/packages/cli/src/modes/interactive/render.ts +++ b/packages/cli/src/modes/interactive/render.ts @@ -921,6 +921,124 @@ export function modeSwitchLine(mode: Mode): string { return ' ' + style.bg(` ${style.label} `) + ' ' + c.faint(modeHint(mode)); } +// ── Loop (session-bound /loop) ─────────────────────────────────────── +// +// A loop re-injects its prompt as a visible turn on a timer. These lines mark +// the lifecycle in the scrollback so the user feels the agent "waking up". + +const LOOP_ICON = '⟳'; + +function clip(text: string, max: number): string { + const oneLine = text.replace(/\s+/g, ' ').trim(); + return oneLine.length > max ? oneLine.slice(0, max - 1) + '…' : oneLine; +} + +function tickWord(n: number): string { + return `${n} tick${n === 1 ? '' : 's'}`; +} + +export function loopStarted(opts: { + mode: 'fixed' | 'dynamic'; + intervalLabel?: string; + prompt: string; +}): string { + const cadence = opts.mode === 'fixed' ? `every ${opts.intervalLabel}` : 'self-paced'; + return ( + '\n ' + + c.magenta(`${LOOP_ICON} loop started`) + + c.faint(` · ${cadence} · `) + + c.dim(clip(opts.prompt, 60)) + + '\n ' + + c.faint(' esc while idle, or /loop stop, to end') + + '\n' + ); +} + +/** Echoed in place of the user caret when a tick is injected. */ +export function loopTickHeader(opts: { tickNumber: number; prompt: string }): string { + return ( + c.magenta(`${LOOP_ICON} loop tick #${opts.tickNumber}`) + + c.faint(' · ') + + c.dim(clip(opts.prompt, 60)) + ); +} + +export function loopScheduled(opts: { delayLabel: string; reason?: string }): string { + return ( + ' ' + + c.magenta(`${LOOP_ICON} next wake in ${opts.delayLabel}`) + + (opts.reason ? c.faint(` — ${clip(opts.reason, 70)}`) : '') + + '\n' + ); +} + +export function loopFinished(opts: { + iterations: number; + reason?: string; + implicit?: boolean; +}): string { + const base = + c.magenta(`${LOOP_ICON} loop ended`) + c.faint(` · ${tickWord(opts.iterations)}`); + if (opts.reason) return ' ' + base + c.faint(` — ${clip(opts.reason, 70)}`) + '\n'; + if (opts.implicit) return ' ' + base + c.faint(' — no next wake scheduled') + '\n'; + return ' ' + base + '\n'; +} + +export function loopStopped(opts: { iterations: number }): string { + return ( + ' ' + c.magenta(`${LOOP_ICON} loop stopped`) + c.faint(` · ${tickWord(opts.iterations)}`) + '\n' + ); +} + +export function loopStatus(opts: { + mode: 'fixed' | 'dynamic'; + prompt: string; + iterations: number; + nextInLabel?: string; + running: boolean; +}): string { + const cadence = opts.mode === 'fixed' ? 'fixed' : 'self-paced'; + const when = opts.running + ? c.amber('running now') + : opts.nextInLabel + ? c.dim(`next in ${opts.nextInLabel}`) + : c.dim('idle'); + return ( + '\n ' + + c.magenta(`${LOOP_ICON} loop`) + + c.faint(` · ${cadence} · ${tickWord(opts.iterations)} so far · `) + + when + + '\n ' + + c.faint(' ') + + c.dim(clip(opts.prompt, 70)) + + '\n' + ); +} + +export function loopHelp(): string { + return ( + '\n ' + + c.bright('/loop') + + c.faint(' — run a prompt on a timer inside this session\n') + + ' ' + + c.dim('/loop 5m ') + + c.faint(' fixed: re-run every 5m (s/m/h/d)\n') + + ' ' + + c.dim('/loop ') + + c.faint(' self-paced: the agent schedules its own next wake\n') + + ' ' + + c.dim('/loop status') + + c.faint(' show the active loop\n') + + ' ' + + c.dim('/loop stop') + + c.faint(' stop it (or press esc while idle)\n') + ); +} + +export function loopError(message: string): string { + return ' ' + c.amber(message) + '\n'; +} + // ── Status bar ─────────────────────────────────────────────────────── // // NORMAL ~/path ⎇ main ✓ ↑5.9K ↓2.3K $0.041 provider/model ctx ▓▓░░░░ 31% @@ -940,6 +1058,8 @@ export interface StatusBarOptions { backgroundJobsFocused?: boolean; /** Number of Ctrl+V-pasted images queued for the next prompt; shows a 🖼 chip. */ attachedImages?: number; + /** Active session loop; shows a ⟳ chip with the next-wake countdown. */ + loop?: { label: string }; } const CTX_BAR_CELLS = 6; @@ -998,6 +1118,11 @@ export function inputFooter(opts: StatusBarOptions): string { const imgRendered = imgN > 0 ? ' ' + c.magenta(imgLabel) : ''; const imgW = imgN > 0 ? 1 + imgLabel.length + 1 : 0; // ' ' + label (+1: 🖼 is wide) + // Loop chip (⟳ next-wake), right after the images chip while a loop is active. + const loopLabel = opts.loop ? `${LOOP_ICON} ${opts.loop.label}` : ''; + const loopRendered = opts.loop ? ' ' + c.magenta(loopLabel) : ''; + const loopW = opts.loop ? 1 + loopLabel.length : 0; + // Right: tokens · cost · provider/model · ctx meter. const segs: { rendered: string; width: number }[] = []; if (opts.inputTokens != null && opts.outputTokens != null) { @@ -1036,7 +1161,7 @@ export function inputFooter(opts: StatusBarOptions): string { // Fixed chrome: pill + space + git + jobs + images + min 1-space gap before // the right side. Drop right-side segments from the left inward until it fits. - const chromeW = pillW + 1 + gitW + jobsW + imgW; + const chromeW = pillW + 1 + gitW + jobsW + imgW + loopW; let right = joinSegs(); while (chromeW + 1 + right.width > w && segs.length > 1) { segs.shift(); @@ -1049,7 +1174,7 @@ export function inputFooter(opts: StatusBarOptions): string { if (cwdStr.length > leftBudget) { cwdStr = leftBudget <= 1 ? cwdStr.slice(0, leftBudget) : '…' + cwdStr.slice(-(leftBudget - 1)); } - const leftW = pillW + 1 + cwdStr.length + gitW + jobsW + imgW; + const leftW = pillW + 1 + cwdStr.length + gitW + jobsW + imgW + loopW; const gap = Math.max(1, w - leftW - right.width); const infoLine = @@ -1059,6 +1184,7 @@ export function inputFooter(opts: StatusBarOptions): string { gitRendered + jobsRendered + imgRendered + + loopRendered + ' '.repeat(gap) + right.rendered; return separator() + '\n' + infoLine; diff --git a/packages/cli/tests/loop.e2e.test.ts b/packages/cli/tests/loop.e2e.test.ts new file mode 100644 index 0000000..4b82e71 --- /dev/null +++ b/packages/cli/tests/loop.e2e.test.ts @@ -0,0 +1,271 @@ +import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { AgentSession } from '@harnext/core'; +import { createAgentSession } from '@harnext/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { runInteractiveMode } from '../src/modes/interactive/interactive-mode.js'; + +/** + * End-to-end /loop: drives the *whole* interactive REPL — a real AgentSession + * (gated fake stream), the real sticky textarea over a fake TTY, and the real + * 1s loop ticker — and asserts the core UX: a `/loop` started in-session wakes + * the agent *visibly in the same conversation*, then can be stopped with Esc. + */ +type FakeStdin = EventEmitter & { + isTTY?: boolean; + isRaw?: boolean; + setRawMode: (v: boolean) => void; + resume: () => void; + unref: () => void; +}; + +function makeFakeStdin(): FakeStdin { + const stdin = new EventEmitter() as FakeStdin; + stdin.isTTY = true; + stdin.isRaw = false; + stdin.setRawMode = () => {}; + stdin.resume = () => {}; + stdin.unref = () => {}; + return stdin; +} + +function type(stdin: FakeStdin, text: string) { + for (const ch of text) stdin.emit('keypress', ch, { name: ch, sequence: ch }); +} +function press(stdin: FakeStdin, name: string, opts: { ctrl?: boolean } = {}) { + stdin.emit('keypress', undefined, { name, ...opts }); +} +function stripAnsi(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); +} + +const flush = async (times = 4) => { + for (let i = 0; i < times; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +const MODEL_ID = 'claude-sonnet-4-6'; + +function makeAssistant(text: string): Record { + return { + role: 'assistant', + content: [{ type: 'text', text }], + api: 'anthropic-messages', + provider: 'anthropic', + model: MODEL_ID, + usage: { + input: 10, + output: 5, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 15, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: 'stop', + timestamp: 1, + }; +} + +describe('/loop (e2e through the real REPL + 1s ticker)', () => { + let originalStdin: NodeJS.ReadStream; + let stdin: FakeStdin; + let writes: string[]; + let writeSpy: ReturnType; + let originalColumns: number | undefined; + let originalRows: number | undefined; + let harnextHome: string; + const originalHarnextHome = process.env.HARNEXT_HOME; + + let turnsStarted: number; + let releasers: Array<() => void>; + let session: AgentSession; + + function installFakeStream() { + turnsStarted = 0; + releasers = []; + let turn = 0; + const fakeStreamFn = () => { + const final = makeAssistant(`reply ${++turn}`); + let release!: () => void; + const gate = new Promise((res) => { + release = res; + }); + releasers.push(release); + turnsStarted++; + return { + async *[Symbol.asyncIterator]() { + await gate; + yield { type: 'done', reason: 'stop', message: final }; + }, + result: async () => final, + }; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + session.agent.streamFn = fakeStreamFn as any; + } + + // Real-time wait so the 1s loop ticker can actually fire. + async function waitForTurnRealtime(n: number, timeoutMs = 3000) { + const start = Date.now(); + while (turnsStarted < n && Date.now() - start < timeoutMs) { + await new Promise((r) => setTimeout(r, 25)); + } + if (turnsStarted < n) throw new Error(`turn ${n} never started (got ${turnsStarted})`); + } + function releaseTurn(n: number) { + releasers[n - 1](); + } + + beforeEach(async () => { + harnextHome = mkdtempSync(join(tmpdir(), 'harnext-loop-e2e-')); + process.env.HARNEXT_HOME = harnextHome; + + originalStdin = process.stdin; + stdin = makeFakeStdin(); + Object.defineProperty(process, 'stdin', { value: stdin, configurable: true }); + + writes = []; + writeSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation((chunk: string | Uint8Array): boolean => { + writes.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return true; + }); + originalColumns = process.stdout.columns; + originalRows = process.stdout.rows; + Object.defineProperty(process.stdout, 'columns', { value: 80, configurable: true }); + Object.defineProperty(process.stdout, 'rows', { value: 24, configurable: true }); + + const created = await createAgentSession({ + provider: 'anthropic', + modelId: MODEL_ID, + mcpDisabled: true, + quiet: true, + compaction: false, + skills: [], + }); + session = created.session; + installFakeStream(); + }); + + afterEach(async () => { + writeSpy.mockRestore(); + Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true }); + Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true }); + Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true }); + await session.dispose(); + if (originalHarnextHome === undefined) delete process.env.HARNEXT_HOME; + else process.env.HARNEXT_HOME = originalHarnextHome; + rmSync(harnextHome, { recursive: true, force: true }); + }); + + it('wakes the agent in-session on a fixed loop, then stops on Esc', async () => { + const done = runInteractiveMode(session, { provider: 'anthropic', model: MODEL_ID }); + + // Start a fixed loop. The first tick is due immediately; the ticker fires it + // on its next pass (~1s) as a visible turn — no user keystroke involved. + const beforeStart = writes.length; + type(stdin, '/loop 5s run the check'); + press(stdin, 'return'); + await flush(); + const startFrame = stripAnsi(writes.slice(beforeStart).join('')); + expect(startFrame).toContain('loop started'); + expect(startFrame).toContain('every 5s'); // fixed cadence + + // The ticker fires turn 1 on its own. + const beforeTick = writes.length; + await waitForTurnRealtime(1); + const tickFrame = stripAnsi(writes.slice(beforeTick).join('')); + expect(tickFrame).toContain('loop tick #1'); // the wake-up header, in this session + + // Let the tick complete; fixed mode reschedules and says when. + releaseTurn(1); + await flush(8); + const afterTick = stripAnsi(writes.slice(beforeTick).join('')); + expect(afterTick).toContain('next wake in'); + + // The looped prompt landed in the *same* conversation as a real user turn. + const userTexts = session.messages + .filter((m) => m.role === 'user') + .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))); + expect(userTexts.some((t) => t.includes('run the check'))).toBe(true); + + // Esc on the empty prompt stops the loop. + const beforeStop = writes.length; + press(stdin, 'escape'); + await flush(); + const stopFrame = stripAnsi(writes.slice(beforeStop).join('')); + expect(stopFrame).toContain('loop stopped'); + + press(stdin, 'd', { ctrl: true }); + await done; + }); + + it('registers the always-on `loop` tool, and the agent can start a loop with it', async () => { + const done = runInteractiveMode(session, { provider: 'anthropic', model: MODEL_ID }); + await flush(); + + const loopTool = session.agent.state.tools.find((t) => t.name === 'loop'); + expect(loopTool).toBeDefined(); + + // Drive the tool the way the agent would: start a fixed loop. + const beforeStart = writes.length; + const res = await loopTool!.execute('id', { + command: 'start', + interval: '5s', + prompt: 'reply with only the word TICK', + }); + expect((res.details as { ok: boolean }).ok).toBe(true); + const startFrame = stripAnsi(writes.slice(beforeStart).join('')); + expect(startFrame).toContain('loop started'); // the user sees it in-session + + // The agent-initiated loop wakes the agent on the next idle tick. + await waitForTurnRealtime(1); + releaseTurn(1); + await flush(8); + + // status via the tool reflects the live loop. + const status = await loopTool!.execute('id', { command: 'status' }); + expect(status.content[0].text.toLowerCase()).toContain('loop'); + + // stop via the tool. + const stop = await loopTool!.execute('id', { command: 'stop' }); + expect((stop.details as { ok: boolean }).ok).toBe(true); + + press(stdin, 'd', { ctrl: true }); + await done; + }); + + it('exposes schedule_wakeup/end_loop only while a self-paced loop runs', async () => { + const done = runInteractiveMode(session, { provider: 'anthropic', model: MODEL_ID }); + + const toolNames = () => session.agent.state.tools.map((t) => t.name); + expect(toolNames()).not.toContain('schedule_wakeup'); + + // Self-paced loop (no interval) → the scheduling tools appear. + type(stdin, '/loop keep watching CI'); + press(stdin, 'return'); + await flush(); + expect(toolNames()).toContain('schedule_wakeup'); + expect(toolNames()).toContain('end_loop'); + + // Drive its first (gated) tick. With the fake stream the model never calls + // schedule_wakeup, so the dynamic loop finishes implicitly after one tick — + // and the scheduling tools are torn back off the live tool list. + const beforeTick = writes.length; + await waitForTurnRealtime(1); + releaseTurn(1); + await flush(8); + const afterTick = stripAnsi(writes.slice(beforeTick).join('')); + expect(afterTick).toContain('loop ended'); + expect(toolNames()).not.toContain('schedule_wakeup'); + expect(toolNames()).not.toContain('end_loop'); + + press(stdin, 'd', { ctrl: true }); + await done; + }); +}); diff --git a/packages/cli/tests/loop.test.ts b/packages/cli/tests/loop.test.ts new file mode 100644 index 0000000..1331aa6 --- /dev/null +++ b/packages/cli/tests/loop.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from 'vitest'; + +import { + LoopController, + MAX_INTERVAL_MS, + MIN_INTERVAL_MS, + buildLoopTickPrompt, + createLoopManagementTool, + createLoopTools, + formatLoopDelay, + parseDuration, + parseLoopArgs, + type LoopSpec, + type LoopToolHost, +} from '../src/cli/loop.js'; + +describe('parseDuration', () => { + it('parses each unit', () => { + expect(parseDuration('30s')).toBe(30_000); + expect(parseDuration('5m')).toBe(300_000); + expect(parseDuration('2h')).toBe(7_200_000); + expect(parseDuration('1d')).toBe(86_400_000); + }); + + it('is case-insensitive', () => { + expect(parseDuration('5M')).toBe(300_000); + }); + + it('rejects junk and zero', () => { + expect(parseDuration('abc')).toBeNull(); + expect(parseDuration('5')).toBeNull(); + expect(parseDuration('5x')).toBeNull(); + expect(parseDuration('0m')).toBeNull(); + }); +}); + +describe('parseLoopArgs', () => { + it('treats empty input as help', () => { + expect(parseLoopArgs('')).toEqual({ kind: 'help' }); + expect(parseLoopArgs(' ')).toEqual({ kind: 'help' }); + }); + + it('recognizes control commands', () => { + expect(parseLoopArgs('stop')).toEqual({ kind: 'command', command: 'stop' }); + expect(parseLoopArgs('STATUS')).toEqual({ kind: 'command', command: 'status' }); + }); + + it('parses a fixed-interval spec', () => { + expect(parseLoopArgs('5m check the build')).toEqual({ + kind: 'spec', + spec: { mode: 'fixed', prompt: 'check the build', intervalMs: 300_000 }, + }); + }); + + it('parses a self-paced spec when no leading interval', () => { + expect(parseLoopArgs('watch CI until it is green')).toEqual({ + kind: 'spec', + spec: { mode: 'dynamic', prompt: 'watch CI until it is green' }, + }); + }); + + it('errors when an interval has no prompt', () => { + const r = parseLoopArgs('5m'); + expect(r.kind).toBe('error'); + }); + + it('errors when the interval is below the floor', () => { + const r = parseLoopArgs('1s do thing'); + expect(r.kind).toBe('error'); + }); + + it('errors when the interval is above the ceiling', () => { + const r = parseLoopArgs('2d do thing'); + expect(r.kind).toBe('error'); + }); +}); + +describe('LoopController — fixed mode', () => { + it('fires immediately then reschedules by the interval', () => { + const c = new LoopController(); + c.start({ mode: 'fixed', prompt: 'p', intervalMs: 60_000 }, 1_000); + expect(c.active).toBe(true); + expect(c.due(1_000)).toBe(true); + + expect(c.beginTick()).toBe(1); + expect(c.due(1_000)).toBe(false); // running, not due + + const out = c.endTick(2_000); + expect(out).toEqual({ kind: 'scheduled', delayMs: 60_000, iterations: 1 }); + expect(c.due(2_000)).toBe(false); + expect(c.due(62_000)).toBe(true); // next.fireAt = 2_000 + 60_000 + + c.beginTick(); + const out2 = c.endTick(62_000); + expect(out2).toMatchObject({ kind: 'scheduled', iterations: 2 }); + }); + + it('ignores dynamic directives and keeps the fixed schedule', () => { + const c = new LoopController(); + c.start({ mode: 'fixed', prompt: 'p', intervalMs: 30_000 }, 0); + c.beginTick(); + c.requestDone('done'); // a stray tool call must not stop a fixed loop + const out = c.endTick(0); + expect(out.kind).toBe('scheduled'); + expect(c.active).toBe(true); + }); +}); + +describe('LoopController — dynamic mode', () => { + it('reschedules from schedule_wakeup with a floored delay', () => { + const c = new LoopController(); + c.start({ mode: 'dynamic', prompt: 'p' }, 0); + c.beginTick(); + c.requestNextWake(480, 'CI still running'); + const out = c.endTick(1_000); + expect(out).toMatchObject({ kind: 'scheduled', delayMs: 480_000, reason: 'CI still running', iterations: 1 }); + expect(c.snapshot?.lastReason).toBe('CI still running'); + expect(c.due(1_000 + 480_000)).toBe(true); + }); + + it('floors and caps the requested delay', () => { + const c = new LoopController(); + c.start({ mode: 'dynamic', prompt: 'p' }, 0); + c.beginTick(); + c.requestNextWake(1); // below floor + let out = c.endTick(0); + expect(out).toMatchObject({ kind: 'scheduled', delayMs: MIN_INTERVAL_MS }); + + c.beginTick(); + c.requestNextWake(10 * 24 * 3600); // above ceiling + out = c.endTick(0); + expect(out).toMatchObject({ kind: 'scheduled', delayMs: MAX_INTERVAL_MS }); + }); + + it('finishes on end_loop', () => { + const c = new LoopController(); + c.start({ mode: 'dynamic', prompt: 'p' }, 0); + c.beginTick(); + c.requestDone('all green'); + const out = c.endTick(0); + expect(out).toEqual({ kind: 'finished', reason: 'all green', iterations: 1, implicit: false }); + expect(c.active).toBe(false); + }); + + it('finishes implicitly when the model schedules nothing', () => { + const c = new LoopController(); + c.start({ mode: 'dynamic', prompt: 'p' }, 0); + c.beginTick(); + const out = c.endTick(0); + expect(out).toEqual({ kind: 'finished', reason: undefined, iterations: 1, implicit: true }); + expect(c.active).toBe(false); + }); + + it('clears a stale directive at the start of the next tick', () => { + const c = new LoopController(); + c.start({ mode: 'dynamic', prompt: 'p' }, 0); + c.beginTick(); + c.requestNextWake(60); + c.endTick(0); + // Next tick: model calls nothing → should finish implicitly, not reuse the old wake. + c.beginTick(); + const out = c.endTick(0); + expect(out).toMatchObject({ kind: 'finished', implicit: true }); + }); +}); + +describe('stop()', () => { + it('clears the loop and returns the final state', () => { + const c = new LoopController(); + c.start({ mode: 'fixed', prompt: 'p', intervalMs: 60_000 }, 0); + const s = c.stop(); + expect(s?.prompt).toBe('p'); + expect(c.active).toBe(false); + expect(c.stop()).toBeNull(); + }); +}); + +describe('buildLoopTickPrompt', () => { + it('includes the iteration number and the original prompt', () => { + const out = buildLoopTickPrompt({ mode: 'fixed', prompt: 'do X', tickNumber: 3 }); + expect(out).toContain('iteration #3'); + expect(out).toContain('do X'); + expect(out).toContain('fixed schedule'); + }); + + it('instructs dynamic loops to call the scheduling tools', () => { + const out = buildLoopTickPrompt({ mode: 'dynamic', prompt: 'watch', tickNumber: 1 }); + expect(out).toContain('schedule_wakeup'); + expect(out).toContain('end_loop'); + }); +}); + +describe('formatLoopDelay', () => { + it('formats seconds, minutes, and hours', () => { + expect(formatLoopDelay(45_000)).toBe('45s'); + expect(formatLoopDelay(8 * 60_000)).toBe('8m'); + expect(formatLoopDelay(2 * 3_600_000)).toBe('2h'); + expect(formatLoopDelay(90 * 60_000)).toBe('1h30m'); + }); +}); + +describe('createLoopTools', () => { + it('routes schedule_wakeup and end_loop into the controller', async () => { + const c = new LoopController(); + c.start({ mode: 'dynamic', prompt: 'p' }, 0); + const [schedule, end] = createLoopTools(c); + expect(schedule.name).toBe('schedule_wakeup'); + expect(end.name).toBe('end_loop'); + + c.beginTick(); + await schedule.execute('id', { delaySeconds: 120, reason: 'pending' }); + const out = c.endTick(0); + expect(out).toMatchObject({ kind: 'scheduled', delayMs: 120_000, reason: 'pending' }); + }); + + it('is inert when called with no active loop', async () => { + const c = new LoopController(); + const [schedule] = createLoopTools(c); + const res = await schedule.execute('id', { delaySeconds: 60 }); + expect((res.details as { scheduled: boolean }).scheduled).toBe(false); + }); +}); + +describe('createLoopManagementTool', () => { + function fakeHost() { + const starts: LoopSpec[] = []; + let stopped = 0; + const host: LoopToolHost = { + start: (spec) => { + starts.push(spec); + return { ok: true, message: 'started' }; + }, + stop: () => { + stopped++; + return { ok: true, message: 'stopped' }; + }, + status: () => 'status text', + }; + return { host, starts, stopped: () => stopped }; + } + + it('routes status/stop to the host', async () => { + const { host } = fakeHost(); + const tool = createLoopManagementTool(host); + expect(tool.name).toBe('loop'); + const st = await tool.execute('id', { command: 'status' }); + expect(st.content[0].text).toBe('status text'); + const sp = await tool.execute('id', { command: 'stop' }); + expect(sp.content[0].text).toBe('stopped'); + }); + + it('parses a fixed-interval start into a spec', async () => { + const { host, starts } = fakeHost(); + const tool = createLoopManagementTool(host); + const res = await tool.execute('id', { + command: 'start', + interval: '5m', + prompt: 'check the build', + }); + expect(res.details.ok).toBe(true); + expect(starts[0]).toEqual({ mode: 'fixed', prompt: 'check the build', intervalMs: 300_000 }); + }); + + it('parses a self-paced start (no interval) into a dynamic spec', async () => { + const { host, starts } = fakeHost(); + const tool = createLoopManagementTool(host); + await tool.execute('id', { command: 'start', prompt: 'watch CI' }); + expect(starts[0]).toEqual({ mode: 'dynamic', prompt: 'watch CI' }); + }); + + it('errors on start without a prompt, and on a bad interval', async () => { + const { host, starts } = fakeHost(); + const tool = createLoopManagementTool(host); + const noPrompt = await tool.execute('id', { command: 'start' }); + expect(noPrompt.details.ok).toBe(false); + const badInterval = await tool.execute('id', { + command: 'start', + interval: '1s', + prompt: 'x', + }); + expect(badInterval.details.ok).toBe(false); + expect(starts.length).toBe(0); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 93832cd..c86b4b5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -622,6 +622,11 @@ export { createMemoryTool, type MemoryToolDetails, type MemoryToolInput, + heartbeatTool, + createHeartbeatTool, + type HeartbeatToolDetails, + type HeartbeatToolInput, + type CreateHeartbeatToolOptions, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, diff --git a/packages/core/src/tools/heartbeat-tool.ts b/packages/core/src/tools/heartbeat-tool.ts new file mode 100644 index 0000000..e767ad2 --- /dev/null +++ b/packages/core/src/tools/heartbeat-tool.ts @@ -0,0 +1,273 @@ +/** + * The `heartbeat` tool: the agent's affordance for managing this project's + * durable, cron-driven heartbeats — the same ones the `/heartbeat` CLI command + * configures. A small set of commands (`list`, `create`, `update`, `delete`, + * `view_log`) over the per-project heartbeat store and the user's crontab. + * + * Unlike `loop` (session-bound, dies with the session), a heartbeat survives the + * CLI closing: each tick spawns a fresh headless agent run from cron. Creating or + * updating one therefore writes a line into the user's crontab. + */ + +import { existsSync, readFileSync } from 'node:fs'; + +import type { AgentTool } from '@earendil-works/pi-agent-core'; +import { Type, type Static } from '@sinclair/typebox'; + +import { + HEARTBEAT_INTERVAL_PRESETS, + buildCronLine, + buildCronSchedule, + deleteHeartbeatConfig, + findCronLine, + getHeartbeatPaths, + getHeartbeatTag, + installCronLine, + listHeartbeats, + loadHeartbeatConfig, + removeCronLine, + saveHeartbeatConfig, + validateHeartbeatName, + type CrontabIO, + type HeartbeatConfig, + type HeartbeatIntervalMinutes, +} from '../heartbeat.js'; + +const heartbeatSchema = Type.Object({ + command: Type.Union( + [ + Type.Literal('list'), + Type.Literal('create'), + Type.Literal('update'), + Type.Literal('delete'), + Type.Literal('view_log'), + ], + { + description: + 'list: show this project’s heartbeats. create: add a new one (installs a cron entry). ' + + 'update: change an existing one’s interval and/or prompt. delete: remove it and its cron entry. ' + + 'view_log: show recent tick records.', + }, + ), + name: Type.Optional( + Type.String({ + description: + 'Heartbeat name (lowercase a-z, 0-9, hyphens; max 32). Required for create/update/delete/view_log.', + }), + ), + interval_minutes: Type.Optional( + Type.Number({ + description: + 'Minutes between ticks. Must be one of: 1, 3, 5, 15, 30, 60, 120, 360, 720, 1440. ' + + 'Required for create; optional for update.', + }), + ), + prompt: Type.Optional( + Type.String({ + description: 'The instruction the agent runs each tick. Required for create; optional for update.', + }), + ), + limit: Type.Optional( + Type.Number({ description: 'For view_log: how many of the most recent ticks to show (default 10).' }), + ), +}); + +export type HeartbeatToolInput = Static; + +export interface HeartbeatToolDetails { + command: string; + name?: string; + ok: boolean; +} + +export interface CreateHeartbeatToolOptions { + /** Absolute path to the harnext CLI entrypoint baked into cron lines. Defaults to process.argv[1]. */ + cliPath?: string; + /** Absolute path to the node binary baked into cron lines. Defaults to process.execPath. */ + nodePath?: string; + /** Injected crontab reader/writer (tests pass a fake). Defaults to the real `crontab` shell I/O. */ + crontabIO?: CrontabIO; +} + +function ok(text: string, command: string, name?: string) { + return { content: [{ type: 'text' as const, text }], details: { command, name, ok: true } }; +} +function fail(text: string, command: string, name?: string) { + return { content: [{ type: 'text' as const, text }], details: { command, name, ok: false } }; +} + +function formatInterval(minutes: number): string { + if (minutes < 60) return `${minutes}m`; + if (minutes === 60) return 'hourly'; + if (minutes < 1440) return `every ${minutes / 60}h`; + return 'daily'; +} + +/** + * Install (or replace) the cron entry + config for a heartbeat. Mirrors the + * CLI's install flow: PATH and SSH_AUTH_SOCK are propagated so a tick can find + * user binaries and authenticate git-over-SSH without a TTY. + */ +function install( + cwd: string, + draft: { name: string; intervalMinutes: HeartbeatIntervalMinutes; prompt: string }, + opts: Required> & { crontabIO?: CrontabIO }, +): string { + const schedule = buildCronSchedule(draft.intervalMinutes); + const tag = getHeartbeatTag(cwd, draft.name); + const cronLine = buildCronLine({ + schedule, + cliPath: opts.cliPath, + cwd, + name: draft.name, + tag, + nodePath: opts.nodePath, + path: process.env.PATH, + sshAuthSock: process.env.SSH_AUTH_SOCK, + }); + const cfg: HeartbeatConfig = { + name: draft.name, + intervalMinutes: draft.intervalMinutes, + prompt: draft.prompt, + cwd, + updatedAt: Date.now(), + }; + saveHeartbeatConfig(cfg); + installCronLine(cronLine, tag, opts.crontabIO); + return cronLine; +} + +export function createHeartbeatTool( + cwd: string, + options: CreateHeartbeatToolOptions = {}, +): AgentTool { + const cliPath = options.cliPath ?? process.argv[1] ?? ''; + const nodePath = options.nodePath ?? process.execPath; + const crontabIO = options.crontabIO; + + return { + name: 'heartbeat', + label: 'heartbeat', + description: + 'Manage this project’s durable, cron-driven heartbeats — periodic headless agent runs that ' + + 'keep going after the CLI is closed. Commands: list, create, update, delete, view_log. ' + + 'For an in-session loop that you can watch live and that ends with the session, use `loop` instead.', + parameters: heartbeatSchema, + async execute(_toolCallId, params) { + const { command } = params; + try { + switch (command) { + case 'list': { + const all = listHeartbeats(cwd); + if (all.length === 0) return ok('No heartbeats configured for this project.', command); + const lines = all.map((c) => { + const installed = findCronLine(getHeartbeatTag(c.cwd, c.name), crontabIO) + ? 'cron installed' + : 'cron MISSING'; + return `- ${c.name} · ${formatInterval(c.intervalMinutes)} · ${installed}\n ${c.prompt}`; + }); + return ok(`Heartbeats (${all.length}):\n${lines.join('\n')}`, command); + } + + case 'create': { + const name = params.name?.trim(); + if (!name) return fail('Error: create requires a name.', command); + const nameErr = validateHeartbeatName(name); + if (nameErr) return fail(`Error: ${nameErr}`, command, name); + if (loadHeartbeatConfig(cwd, name)) + return fail(`Error: heartbeat "${name}" already exists — use update.`, command, name); + if (params.interval_minutes == null) + return fail('Error: create requires interval_minutes.', command, name); + if (!HEARTBEAT_INTERVAL_PRESETS.includes(params.interval_minutes as HeartbeatIntervalMinutes)) + return fail( + `Error: interval_minutes must be one of ${HEARTBEAT_INTERVAL_PRESETS.join(', ')}.`, + command, + name, + ); + const prompt = params.prompt?.trim(); + if (!prompt) return fail('Error: create requires a prompt.', command, name); + const cronLine = install( + cwd, + { name, intervalMinutes: params.interval_minutes as HeartbeatIntervalMinutes, prompt }, + { cliPath, nodePath, crontabIO }, + ); + return ok( + `Created heartbeat "${name}" (${formatInterval(params.interval_minutes)}).\n` + + `Installed cron: ${cronLine}`, + command, + name, + ); + } + + case 'update': { + const name = params.name?.trim(); + if (!name) return fail('Error: update requires a name.', command); + const existing = loadHeartbeatConfig(cwd, name); + if (!existing) return fail(`Error: heartbeat "${name}" not found.`, command, name); + const intervalMinutes = + params.interval_minutes == null + ? existing.intervalMinutes + : (params.interval_minutes as HeartbeatIntervalMinutes); + if (!HEARTBEAT_INTERVAL_PRESETS.includes(intervalMinutes)) + return fail( + `Error: interval_minutes must be one of ${HEARTBEAT_INTERVAL_PRESETS.join(', ')}.`, + command, + name, + ); + const prompt = params.prompt?.trim() || existing.prompt; + const cronLine = install(cwd, { name, intervalMinutes, prompt }, { cliPath, nodePath, crontabIO }); + return ok( + `Updated heartbeat "${name}" (${formatInterval(intervalMinutes)}).\nCron: ${cronLine}`, + command, + name, + ); + } + + case 'delete': { + const name = params.name?.trim(); + if (!name) return fail('Error: delete requires a name.', command); + if (!loadHeartbeatConfig(cwd, name)) + return fail(`Error: heartbeat "${name}" not found.`, command, name); + removeCronLine(getHeartbeatTag(cwd, name), crontabIO); + deleteHeartbeatConfig(cwd, name); + return ok(`Deleted heartbeat "${name}" and removed its cron entry.`, command, name); + } + + case 'view_log': { + const name = params.name?.trim(); + if (!name) return fail('Error: view_log requires a name.', command); + const { log } = getHeartbeatPaths(cwd, name); + if (!existsSync(log)) return ok(`No ticks logged yet for "${name}".`, command, name); + const limit = Math.max(1, Math.min(params.limit ?? 10, 100)); + const lines = readFileSync(log, 'utf-8').trim().split('\n').filter(Boolean).slice(-limit); + const rendered = lines.map((raw) => { + try { + const r = JSON.parse(raw) as { + ts: string; + exit: number; + durationMs: number; + output?: string; + error?: string; + }; + const status = r.exit === 0 ? 'ok' : r.exit === 2 ? 'tool-err' : 'fail'; + const summary = (r.error ?? r.output ?? '').split('\n')[0]?.slice(0, 100) ?? ''; + return `${r.ts} ${status} ${r.durationMs}ms ${summary}`; + } catch { + return raw; + } + }); + return ok(`Last ${rendered.length} ticks for "${name}":\n${rendered.join('\n')}`, command, name); + } + + default: + return fail(`Error: unknown command "${command as string}".`, command); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return fail(`Error (${command}): ${msg}`, command, params.name); + } + }, + }; +} + +export const heartbeatTool = createHeartbeatTool(process.cwd()); diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index aad0134..8935fe0 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -59,6 +59,13 @@ export { exitPlanTool, createExitPlanTool, } from './exit-plan.js'; +export { + type HeartbeatToolDetails, + type HeartbeatToolInput, + type CreateHeartbeatToolOptions, + heartbeatTool, + createHeartbeatTool, +} from './heartbeat-tool.js'; export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, @@ -75,6 +82,7 @@ import { createBashOutputTool } from './bash-output.js'; import { createKillShellTool } from './kill-shell.js'; import { editTool, createEditTool } from './edit.js'; import { exitPlanTool, createExitPlanTool } from './exit-plan.js'; +import { heartbeatTool, createHeartbeatTool, type CreateHeartbeatToolOptions } from './heartbeat-tool.js'; import { memoryTool, createMemoryTool } from './memory.js'; import { readTool, createReadTool } from './read.js'; import { todoTool, createTodoTool } from './todo.js'; @@ -91,6 +99,7 @@ export const codingTools: Tool[] = [ todoTool, exitPlanTool, memoryTool, + heartbeatTool, ]; export const allTools = { @@ -101,6 +110,7 @@ export const allTools = { todo: todoTool, exit_plan: exitPlanTool, memory: memoryTool, + heartbeat: heartbeatTool, }; export type ToolName = keyof typeof allTools; @@ -112,6 +122,8 @@ export interface CreateCodingToolsOptions { executor?: CommandExecutor; /** Command working directory when it differs from the file-tool `cwd`. */ execCwd?: string; + /** Overrides for the `heartbeat` tool (cli/node paths, crontab I/O). Defaults derive from `process`. */ + heartbeat?: CreateHeartbeatToolOptions; } export function createCodingTools(cwd: string, options: CreateCodingToolsOptions = {}): Tool[] { @@ -124,6 +136,7 @@ export function createCodingTools(cwd: string, options: CreateCodingToolsOptions createTodoTool(), createExitPlanTool(), createMemoryTool(cwd), + createHeartbeatTool(cwd, options.heartbeat), ]; // The background-shell companions only exist when a manager is wired in // (i.e. background execution is enabled for this session). @@ -142,5 +155,6 @@ export function createAllTools(cwd: string): Record { todo: createTodoTool(), exit_plan: createExitPlanTool(), memory: createMemoryTool(cwd), + heartbeat: createHeartbeatTool(cwd), }; } diff --git a/packages/core/tests/heartbeat-tool.test.ts b/packages/core/tests/heartbeat-tool.test.ts new file mode 100644 index 0000000..044b31b --- /dev/null +++ b/packages/core/tests/heartbeat-tool.test.ts @@ -0,0 +1,165 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + appendHeartbeatTick, + createHeartbeatTool, + loadHeartbeatConfig, + type CrontabIO, +} from '../src/index.js'; + +/** In-memory crontab so the tool never touches the real user crontab. */ +function fakeCrontab(): CrontabIO & { dump: () => string } { + let store = ''; + return { + read: () => store, + write: (c: string) => { + store = c; + }, + dump: () => store, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeTool(cwd: string, crontabIO: CrontabIO): any { + return createHeartbeatTool(cwd, { + cliPath: '/opt/harnext/index.js', + nodePath: '/usr/bin/node', + crontabIO, + }); +} + +describe('heartbeat tool', () => { + let home: string; + const cwd = '/proj/example'; + const originalHome = process.env.HARNEXT_HOME; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'harnext-hb-tool-')); + process.env.HARNEXT_HOME = home; + }); + afterEach(() => { + if (originalHome === undefined) delete process.env.HARNEXT_HOME; + else process.env.HARNEXT_HOME = originalHome; + rmSync(home, { recursive: true, force: true }); + }); + + it('creates a heartbeat: saves config and installs a tagged cron line', async () => { + const cron = fakeCrontab(); + const tool = makeTool(cwd, cron); + const res = await tool.execute('id', { + command: 'create', + name: 'ci', + interval_minutes: 5, + prompt: 'check CI and report failures', + }); + expect(res.details.ok).toBe(true); + const cfg = loadHeartbeatConfig(cwd, 'ci'); + expect(cfg?.prompt).toBe('check CI and report failures'); + expect(cfg?.intervalMinutes).toBe(5); + // The cron line is present, references the heartbeat, and carries its tag. + expect(cron.dump()).toContain('--heartbeat ci'); + expect(cron.dump()).toContain('harnext:heartbeat:'); + expect(cron.dump()).toContain('*/5 * * * *'); + }); + + it('rejects a duplicate name, a bad interval, and a missing prompt', async () => { + const cron = fakeCrontab(); + const tool = makeTool(cwd, cron); + await tool.execute('id', { command: 'create', name: 'ci', interval_minutes: 5, prompt: 'x' }); + + const dup = await tool.execute('id', { + command: 'create', + name: 'ci', + interval_minutes: 5, + prompt: 'y', + }); + expect(dup.details.ok).toBe(false); + expect(dup.content[0].text).toContain('already exists'); + + const badInterval = await tool.execute('id', { + command: 'create', + name: 'two', + interval_minutes: 7, + prompt: 'y', + }); + expect(badInterval.details.ok).toBe(false); + expect(badInterval.content[0].text).toContain('must be one of'); + + const noPrompt = await tool.execute('id', { + command: 'create', + name: 'three', + interval_minutes: 5, + }); + expect(noPrompt.details.ok).toBe(false); + }); + + it('lists heartbeats with their cron status', async () => { + const cron = fakeCrontab(); + const tool = makeTool(cwd, cron); + await tool.execute('id', { command: 'create', name: 'ci', interval_minutes: 15, prompt: 'p' }); + + const list = await tool.execute('id', { command: 'list' }); + expect(list.details.ok).toBe(true); + expect(list.content[0].text).toContain('ci'); + expect(list.content[0].text).toContain('cron installed'); + }); + + it('updates interval and prompt, reinstalling the cron line', async () => { + const cron = fakeCrontab(); + const tool = makeTool(cwd, cron); + await tool.execute('id', { command: 'create', name: 'ci', interval_minutes: 5, prompt: 'old' }); + + const upd = await tool.execute('id', { + command: 'update', + name: 'ci', + interval_minutes: 30, + prompt: 'new prompt', + }); + expect(upd.details.ok).toBe(true); + const cfg = loadHeartbeatConfig(cwd, 'ci'); + expect(cfg?.intervalMinutes).toBe(30); + expect(cfg?.prompt).toBe('new prompt'); + expect(cron.dump()).toContain('*/30 * * * *'); + // Exactly one cron line for this heartbeat (no stale duplicate). + expect(cron.dump().split('\n').filter((l) => l.includes('--heartbeat ci')).length).toBe(1); + }); + + it('view_log reports empty, then surfaces tick records', async () => { + const cron = fakeCrontab(); + const tool = makeTool(cwd, cron); + await tool.execute('id', { command: 'create', name: 'ci', interval_minutes: 5, prompt: 'p' }); + + const empty = await tool.execute('id', { command: 'view_log', name: 'ci' }); + expect(empty.content[0].text).toContain('No ticks logged yet'); + + appendHeartbeatTick(cwd, 'ci', { + ts: '2026-06-18T10:00:00.000Z', + exit: 0, + durationMs: 1234, + prompt: 'p', + output: 'all green', + }); + const log = await tool.execute('id', { command: 'view_log', name: 'ci' }); + expect(log.content[0].text).toContain('all green'); + expect(log.content[0].text).toContain('ok'); + }); + + it('deletes a heartbeat and removes its cron line', async () => { + const cron = fakeCrontab(); + const tool = makeTool(cwd, cron); + await tool.execute('id', { command: 'create', name: 'ci', interval_minutes: 5, prompt: 'p' }); + expect(cron.dump()).toContain('--heartbeat ci'); + + const del = await tool.execute('id', { command: 'delete', name: 'ci' }); + expect(del.details.ok).toBe(true); + expect(loadHeartbeatConfig(cwd, 'ci')).toBeNull(); + expect(cron.dump()).not.toContain('--heartbeat ci'); + + const missing = await tool.execute('id', { command: 'delete', name: 'ci' }); + expect(missing.details.ok).toBe(false); + }); +});