Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9cf6fb0
feat(coding-agent): add persistent session heartbeat
sethkarten Jun 15, 2026
c40e97b
Add RLM heartbeat skill
sethkarten Jun 15, 2026
a3ecfcc
Fix RLM heartbeat controller propagation
sethkarten Jun 15, 2026
74a19ec
Make user heartbeats user-controlled
sethkarten Jun 15, 2026
0d360c0
Merge remote-tracking branch 'origin/main' into feature/rlm-heartbeats
sethkarten Jun 16, 2026
05b4edd
Validate heartbeat get session
sethkarten Jun 16, 2026
f5da55d
fix(coding-agent): rebind heartbeat jobs on daemon restart
sethkarten Jun 17, 2026
b713f6e
fix(coding-agent): clean up stale heartbeat jobs
sethkarten Jun 17, 2026
50397c5
fix(coding-agent): skip cancelled cron jobs during run
sethkarten Jun 17, 2026
a9c515f
fix(coding-agent): correct cron job persistence error
sethkarten Jun 17, 2026
bd1398d
fix(coding-agent): dedupe daemon session creation
sethkarten Jun 17, 2026
dc9b395
fix(coding-agent): start cron after daemon restore
sethkarten Jun 17, 2026
3484cfc
fix(coding-agent): list paused cron jobs by default
sethkarten Jun 17, 2026
1f4ac2a
fix(coding-agent): preserve concurrent cron job writes
sethkarten Jun 17, 2026
31c797a
merge main into feature/rlm-heartbeats
sethkarten Jun 17, 2026
1a2b579
merge main into feature/rlm-heartbeats
sethkarten Jun 17, 2026
1137f14
fix(coding-agent): coalesce heartbeat follow-ups
sethkarten Jun 17, 2026
dc764ad
Merge remote-tracking branch 'origin/main' into pr-170-comment-fixes
sethkarten Jun 17, 2026
7ddb1a8
fix(coding-agent): coalesce queued heartbeat runs
sethkarten Jun 17, 2026
1351c90
Merge remote-tracking branch 'origin/main' into pr-170-comment-fixes
sethkarten Jun 17, 2026
308964e
fix(coding-agent): handle duplicate queued heartbeats
sethkarten Jun 17, 2026
b4c99f5
fix(coding-agent): preserve queued heartbeat delivery
sethkarten Jun 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- Added opt-in Prime Agent trace sharing with `/traces` and background uploads of persisted session JSONL files.
- Added a first draft of daemon-backed cron jobs for scheduling prompts against long-running sessions without using `/goal`.

## [0.1.3] - 2026-06-12

Expand Down
41 changes: 41 additions & 0 deletions packages/coding-agent/skills/rlm-heartbeat/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
name: rlm-heartbeat
description: Manage internal RLM heartbeats from IPython. Use to schedule recurring self-checks for the current agent session without touching the user's /heartbeat.
---

# RLM Heartbeat

RLM heartbeats are internal recurring prompts for the current agent session.
They are separate from the user's visible `/heartbeat`: this skill cannot read,
replace, pause, resume, or clear that user-level heartbeat.

Call directly from IPython:

```python
await rlm_heartbeat.create("check test progress", interval="5m", label="tests")
await rlm_heartbeat.list()
await rlm_heartbeat.update("job-id", status="pause")
await rlm_heartbeat.delete("job-id")
```

## API

- `await rlm_heartbeat.list(include_inactive=False)` — list this session's
internal RLM heartbeats. By default this includes active and paused entries.
- `await rlm_heartbeat.create(instruction, interval=None, label=None)` — create
a recurring heartbeat for this session. The default interval is every 5
minutes. Multiple RLM heartbeats may run at once; use labels to distinguish
them.
- `await rlm_heartbeat.update(id, instruction=None, interval=None, label=None,
status=None)` — update one RLM heartbeat by id. `status` may be `"pause"` or
`"resume"`.
- `await rlm_heartbeat.delete(id)` — cancel one RLM heartbeat by id.

## Rules

- Use this only for agent-internal recurring checks and long-running task
coordination.
- Do not use this skill to satisfy a user's request to configure `/heartbeat`;
that is a separate user-level surface.
- Keep heartbeat instructions specific and actionable so each recurring turn
knows exactly what to inspect or continue.
17 changes: 17 additions & 0 deletions packages/coding-agent/skills/rlm-heartbeat/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Kernel-side package for the bundled RLM heartbeat skill. It only talks to the
# host through rlm.host_request; prime-agent-runtime is always installed in the
# kernel venv before skills, so it is intentionally not declared as a
# dependency (it is not published on PyPI).
[project]
name = "rlm-heartbeat"
version = "0.1.0"
description = "Prime Agent RLM heartbeat skill: internal recurring session checks over the host bridge"
requires-python = ">=3.10"
dependencies = []

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/rlm_heartbeat"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Prime Agent RLM heartbeat skill: internal recurring session checks.

All heartbeat state lives in the TypeScript host; these functions are thin
typed wrappers over the generic host bridge (`rlm.host_request`). They only
work inside the Prime Agent IPython kernel.
"""

from __future__ import annotations

from typing import Any, Literal

from rlm import host_request

StatusUpdate = Literal["pause", "resume"]


async def list(include_inactive: bool = False) -> dict[str, Any]:
"""List internal RLM heartbeats for the current agent session."""
if not isinstance(include_inactive, bool):
raise TypeError(f"include_inactive must be bool, got {type(include_inactive).__name__}")
return await host_request("rlm_heartbeat.list", {"include_inactive": include_inactive})


async def create(instruction: str, interval: str | None = None, label: str | None = None) -> dict[str, Any]:
"""Create an internal recurring heartbeat for the current agent session."""
if not isinstance(instruction, str):
raise TypeError(f"instruction must be str, got {type(instruction).__name__}")
payload: dict[str, Any] = {"instruction": instruction}
if interval is not None:
if not isinstance(interval, str):
raise TypeError(f"interval must be str or None, got {type(interval).__name__}")
payload["interval"] = interval
if label is not None:
if not isinstance(label, str):
raise TypeError(f"label must be str or None, got {type(label).__name__}")
payload["label"] = label
return await host_request("rlm_heartbeat.create", payload)


async def update(
id: str,
instruction: str | None = None,
interval: str | None = None,
label: str | None = None,
status: StatusUpdate | None = None,
) -> dict[str, Any]:
"""Update one internal RLM heartbeat for the current agent session."""
if not isinstance(id, str):
raise TypeError(f"id must be str, got {type(id).__name__}")
payload: dict[str, Any] = {"id": id}
if instruction is not None:
if not isinstance(instruction, str):
raise TypeError(f"instruction must be str or None, got {type(instruction).__name__}")
payload["instruction"] = instruction
if interval is not None:
if not isinstance(interval, str):
raise TypeError(f"interval must be str or None, got {type(interval).__name__}")
payload["interval"] = interval
if label is not None:
if not isinstance(label, str):
raise TypeError(f"label must be str or None, got {type(label).__name__}")
payload["label"] = label
if status is not None:
if status not in {"pause", "resume"}:
raise ValueError('status must be "pause", "resume", or None')
payload["status"] = status
return await host_request("rlm_heartbeat.update", payload)


async def delete(id: str) -> dict[str, Any]:
"""Cancel one internal RLM heartbeat for the current agent session."""
if not isinstance(id, str):
raise TypeError(f"id must be str, got {type(id).__name__}")
return await host_request("rlm_heartbeat.delete", {"id": id})
110 changes: 110 additions & 0 deletions packages/coding-agent/src/cli/daemon-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { spawn } from "child_process";
import { APP_NAME, expandTildePath } from "../config.js";
import type { AgentSessionEvent } from "../core/agent-session.js";
import type { AgentSessionRuntimeConfig } from "../core/agent-session-config.js";
import { type AgentCronJob, formatAgentCronJob } from "../core/cron-jobs.js";
import { DaemonClient, type DaemonClientMessageListener } from "../modes/daemon/daemon-client.js";
import type { DaemonOutbound, DaemonResponse } from "../modes/daemon/daemon-protocol.js";
import type { SessionSummary } from "../modes/daemon/daemon-session-list.js";
Expand Down Expand Up @@ -40,6 +41,7 @@ const DAEMON_CLIENT_COMMANDS = new Set([
"messages",
"stats",
"commands",
"cron",
"shutdown",
]);

Expand Down Expand Up @@ -98,6 +100,12 @@ function parseDaemonClientCommand(args: string[]): ParsedDaemonClientCommand {
continue;
}

if (arg === "--" && command === "cron") {
positionals.push(arg);
passthrough = true;
continue;
}

if (arg === "--") {
passthrough = true;
continue;
Expand Down Expand Up @@ -227,6 +235,9 @@ async function runDaemonClientCommand(parsed: ParsedDaemonClientCommand): Promis
true,
);
return;
case "cron":
await runCron(client, parsed.positionals, parsed.json);
return;
case "shutdown":
await printResponseData(client, { type: "shutdown" }, parsed.json);
return;
Expand Down Expand Up @@ -803,6 +814,79 @@ async function runMessageCommand(
await printResponseData(client, { type, activeSessionId, message }, json);
}

async function runCron(client: DaemonClient, args: string[], json: boolean): Promise<void> {
const subcommand = args[0] ?? "list";
if (subcommand === "list") {
const includeInactive = args.includes("--all") || args.includes("-a");
const activeSessionId = args.find((arg) => !arg.startsWith("-") && arg !== "list");
const response = await client.request({ type: "cron_list", activeSessionId, includeInactive });
const data = requireSuccess(response);
if (json) {
printJson(data);
return;
}
const jobs = getCronJobs(data);
if (!jobs) {
printJson(data);
return;
}
if (jobs.length === 0) {
console.log("No cron jobs.");
return;
}
for (const job of jobs) {
console.log(formatAgentCronJob(job));
}
return;
}

if (subcommand === "add" || subcommand === "schedule") {
const separator = args.indexOf("--");
if (separator < 0) {
throw new Error("Usage: daemon cron add <session> <schedule> -- <message>");
}
const activeSessionId = args[1];
if (!activeSessionId) {
throw new Error("Usage: daemon cron add <session> <schedule> -- <message>");
}
const schedule = args.slice(2, separator).join(" ").trim();
const message = args
.slice(separator + 1)
.join(" ")
.trim();
if (!schedule || !message) {
throw new Error("Usage: daemon cron add <session> <schedule> -- <message>");
}
const response = await client.request({ type: "cron_add", activeSessionId, schedule, prompt: message });
const data = requireSuccess(response);
if (json) {
printJson(data);
return;
}
const job = getCronJob(data);
console.log(job ? `Scheduled ${job.id} next=${job.nextRunAt ?? "-"}` : "Scheduled cron job.");
return;
}

if (subcommand === "cancel" || subcommand === "delete" || subcommand === "remove") {
const jobId = args[1];
if (!jobId) {
throw new Error("Usage: daemon cron cancel <job-id>");
}
const response = await client.request({ type: "cron_cancel", jobId });
const data = requireSuccess(response);
if (json) {
printJson(data);
return;
}
const job = getCronJob(data);
console.log(job ? `Cancelled ${job.id}` : "Cancelled cron job.");
return;
}

throw new Error(`Unknown cron command: ${subcommand}`);
}

async function printResponseData(
client: DaemonClient,
command: Parameters<DaemonClient["request"]>[0],
Expand Down Expand Up @@ -1340,6 +1424,26 @@ function isLiveSessionSummary(value: unknown): value is SessionSummary & { activ
return isSessionSummary(value) && typeof value.activeSessionId === "string";
}

function getCronJobs(value: unknown): AgentCronJob[] | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const jobs = (value as { jobs?: unknown }).jobs;
return Array.isArray(jobs) ? (jobs as AgentCronJob[]) : undefined;
}

function getCronJob(value: unknown): { id: string; nextRunAt?: string } | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const job = (value as { job?: unknown }).job;
if (!job || typeof job !== "object" || typeof (job as { id?: unknown }).id !== "string") {
return undefined;
}
const candidate = job as { id: string; nextRunAt?: unknown };
return { id: candidate.id, ...(typeof candidate.nextRunAt === "string" ? { nextRunAt: candidate.nextRunAt } : {}) };
}

function printDaemonHelp(): void {
console.log(`${chalk.bold("Usage:")}
${APP_NAME} daemon [options] [session name]
Expand All @@ -1364,6 +1468,10 @@ ${chalk.bold("Commands:")}
messages <session> Print messages as JSON
stats <session> Print session stats as JSON
commands <session> Print available commands as JSON
cron list [-a|--all] [session] List scheduled cron jobs
cron add <session> <schedule> -- <message>
Schedule a prompt for a session
cron cancel <job-id> Cancel a scheduled cron job
shutdown Stop the daemon

${chalk.bold("Options:")}
Expand All @@ -1387,6 +1495,8 @@ ${chalk.bold("Examples:")}
${APP_NAME} daemon --socket /tmp/prime-agent.sock list
${APP_NAME} daemon --socket /tmp/prime-agent.sock list -a
${APP_NAME} daemon --socket /tmp/prime-agent.sock create scratch
${APP_NAME} daemon --socket /tmp/prime-agent.sock cron add <session> "*/30 * * * *" -- "Check progress"
${APP_NAME} daemon --socket /tmp/prime-agent.sock cron list
${APP_NAME} daemon --socket /tmp/prime-agent.sock prompt <session> "Say hello"
${APP_NAME} daemon --socket /tmp/prime-agent.sock attach <session>
${APP_NAME} daemon --socket /tmp/prime-agent.sock shutdown
Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,11 @@ export function getSettingsPath(): string {
return join(getAgentDir(), "settings.json");
}

/** Get path to cron jobs store */
export function getCronJobsPath(agentDir: string = getAgentDir()): string {
return join(agentDir, "cron-jobs.json");
}

/** Get path to tools directory */
export function getToolsDir(): string {
return join(getAgentDir(), "tools");
Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/src/core/agent-session-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Model } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.js";
import { installAgentTraceUpload } from "./agent-traces.js";
import { AuthStorage } from "./auth-storage.js";
import type { AgentRlmHeartbeatController } from "./cron-jobs.js";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js";
import { ModelRegistry } from "./model-registry.js";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js";
Expand Down Expand Up @@ -56,6 +57,7 @@ export interface AgentSessionCreationOptions {
rlmSessionDir?: string;
rlmParentNodeId?: string;
subagentRuntimeHost?: SubagentRuntimeHost;
rlmHeartbeatController?: AgentRlmHeartbeatController;
prewarmIpythonKernel?: boolean;
}

Expand Down Expand Up @@ -219,6 +221,7 @@ export async function createAgentSessionFromServices(
rlmSessionDir: options.rlmSessionDir,
rlmParentNodeId: options.rlmParentNodeId,
subagentRuntimeHost: options.subagentRuntimeHost,
rlmHeartbeatController: options.rlmHeartbeatController,
sessionStartEvent: options.sessionStartEvent,
prewarmIpythonKernel: options.prewarmIpythonKernel,
});
Expand Down
Loading