Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .infer/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ tools:
- .infer/keybindings.yaml
- .infer/prompts.yaml
- .infer/channels.yaml
- .infer/heartbeat.yaml
- .infer/computer_use.yaml
- .git/
- '*.env'
Expand Down
6 changes: 6 additions & 0 deletions .infer/heartbeat.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
enabled: false
interval: 1h
initial_delay: 1m
model: ""
prompt: Heartbeat tick — check for any pending tasks, todos, or background work and act on them.
19 changes: 19 additions & 0 deletions .infer/prompts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,25 @@ agent:
- Works over SSH without X11 forwarding
- Precise output (structured data, not visual interpretation)
- Lower resource usage (critical for remote systems)
system_prompt_heartbeat: |-
You are an autonomous agent that has just been woken up by a periodic heartbeat tick.

PURPOSE: Self-driven progress checks. The user did not just send a message — you were woken up on a schedule to inspect persistent state and take any action that has become possible or overdue since the last tick.

WHAT TO CHECK (in order):
1. Pending todos in your conversation history (TodoWrite items not yet completed).
2. Background tasks you previously started (long-running shells, scheduled jobs, A2A tasks).
3. External signals you have explicit instructions to monitor (issues, PRs, queues — only if user-configured).

DECISION RULE:
- If nothing actionable is pending, respond briefly with "no action needed" and stop. Do NOT invent work.
- If exactly one thing is pending, take the next concrete step using your tools.
- If multiple things are pending, pick the highest-priority single item and do that — leave the rest for the next tick.

CONSTRAINTS:
- You run autonomously without human approval. Be conservative: prefer read-only inspection over irreversible changes unless the action was already authorised.
- Never spam channels or open noisy artifacts (PRs, issues) on a heartbeat unless the user has set up explicit instructions for that behaviour.
- Each tick is a fresh session — you have no memory of previous ticks beyond what is persisted (todos, scheduled jobs, conversation history).
custom_instructions: ""
system_reminders:
enabled: true
Expand Down
62 changes: 62 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,68 @@ images that don't ship `/usr/share/zoneinfo`.
...)` calls in `cmd/root.go` — without those, viper unmarshals an empty
config and the defaults function's values are ignored.

## Heartbeat (Periodic Wake-Up)

The **Heartbeat** wakes the agent on a fixed interval to check for
pending work. It is a peer of the scheduler — both run inside the
`infer channels-manager` daemon, both spawn `infer agent`
subprocesses, but heartbeat is a single global tick (vs. many
user-defined cron jobs) and logs output (vs. routing to a channel).
Disabled by default.

- Config struct: `config.HeartbeatConfig` in `config/heartbeat.go`
- Config file: `~/.infer/heartbeat.yaml` (separate file, mirrors
channels.yaml; `yaml:"-"` on `Config.Heartbeat`).
- System prompt: `cfg.Prompts.Agent.SystemPromptHeartbeat` in
`prompts.yaml` — separate from `system_prompt`/`system_prompt_plan`.
- Service: `internal/services/heartbeat/heartbeat.go` (`Service`
with `Start(ctx)` / `Stop(ctx)`, ticker-driven, no cron).
- Daemon wiring: `cmd/channels.go` `startHeartbeat()` next to
`startScheduler()`.
- Init wiring: `cmd/init.go` `createHeartbeatConfigFile()`.
- Env vars: `INFER_HEARTBEAT_*` applied via
`applyHeartbeatEnvOverrides` in `cmd/config.go`.

### Heartbeat architecture

```text
┌─ infer channels-manager (daemon) ─────────────────────────┐
│ ChannelManagerService (channels — optional) │
│ SchedulerService (cron jobs — optional) │
│ HeartbeatService │
│ ├─ time.Ticker(interval) │
│ └─ on tick: spawn `infer agent --heartbeat │
│ --session-id <uuid> <prompt>` │
│ log stdout │
└────────────────────────────────────────────────────────────┘
```

Key properties:

- **Off by default.** `Heartbeat.Enabled = false` in
`DefaultHeartbeatConfig()`.
- **Daemon gate is relaxed.** `infer channels-manager` boots if
*any* of channels / scheduler / heartbeat is enabled. Heartbeat
alone is a valid run mode.
- **Fresh session per fire.** UUID-format session ID (not channel
prefixed); the Schedule tool's `resolveRouting` will refuse to
operate from a heartbeat run, which is intentional — heartbeat
should not directly create scheduled jobs without explicit
channel context.
- **Overlap guard.** `atomic.Int32` flag suppresses concurrent
ticks when the agent run takes longer than `interval`. Logs a
warning when skipped.
- **System prompt selection.** `infer agent --heartbeat` (cmd flag
added in `cmd/agent.go`) swaps `cfg.Prompts.Agent.SystemPrompt`
for `cfg.Prompts.Agent.SystemPromptHeartbeat` *before* the
service container is built. The agent service stays oblivious to
the new mode.
- **Output.** Agent stdout is logged via the standard logger. No
channel routing — if the user wants a channel notification, the
agent itself uses its tools to send one.

See `docs/heartbeat.md` for the user-facing guide.

## Plan Mode

Plan mode (`AgentModePlan` in `internal/domain/state.go`) is a read-only
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ An agentic command-line assistant that writes code, understands project context,
- [Tool Approval System](#tool-approval-system)
- [Shortcuts](#shortcuts)
- [Channels (Remote Messaging)](#channels-remote-messaging)
- [Heartbeat (Periodic Wake-Up)](#heartbeat-periodic-wake-up)
- [Global Flags](#global-flags)
- [Examples](#examples)
- [Development](#development)
Expand Down Expand Up @@ -73,6 +74,8 @@ An agentic command-line assistant that writes code, understands project context,
- **Remote Messaging Channels**: Control the agent from Telegram, WhatsApp, and other platforms via a pluggable channel system - [Learn more →](docs/channels.md)
- **Scheduled Tasks**: Ask the agent (over Telegram, etc.) to run a prompt on a cron schedule and deliver the result back through the same channel -
recurring ("send me a quote every morning") or one-off ("remind me at 6pm today") - [Learn more →](docs/scheduling.md)
- **Heartbeat (Periodic Wake-Up)**: Wake the agent on a fixed interval to check for pending todos and background work,
with a separate configurable system prompt - off by default - [Learn more →](docs/heartbeat.md)

## Installation

Expand Down Expand Up @@ -1040,6 +1043,54 @@ database so this works on any base image.
For the full guide, including the cron syntax primer and end-to-end Telegram
walkthroughs, see [Scheduling Documentation](docs/scheduling.md).

## Heartbeat (Periodic Wake-Up)

Heartbeat wakes the agent on a fixed interval — without any user input —
so it can check for pending todos, background tasks, or anything else
your system prompt tells it to monitor. It runs alongside the scheduler
inside the `infer channels-manager` daemon and is **disabled by default**.

Unlike the [Schedule](docs/scheduling.md) tool (which the LLM uses to
create user-driven cron jobs that deliver to a channel), heartbeat is a
single global tick the operator configures once. Output goes to logs;
the agent itself decides whether to send a Telegram message, open a PR,
or just no-op.

Enable in `.infer/heartbeat.yaml` (seeded by `infer init`):

```yaml
---
enabled: true
interval: 1h # Go duration: 30s, 5m, 1h, 24h
initial_delay: 1m # delay before first tick
model: "" # optional override; empty = agent.model
prompt: "Heartbeat tick — check for any pending tasks, todos, or background work and act on them."
```

The **system prompt** for heartbeat runs lives in `.infer/prompts.yaml`
under `agent.system_prompt_heartbeat` so you can tune the agent's
wake-up behaviour separately from chat-mode behaviour.

Then start the daemon:

```bash
infer channels-manager
```

Heartbeat alone is a valid run mode — you don't need any channel
enabled to use it. The daemon hosts whichever of channels / scheduler /
heartbeat are turned on.

Or via env vars:

```bash
export INFER_HEARTBEAT_ENABLED=true
export INFER_HEARTBEAT_INTERVAL=30m
```

For the full guide, including configuration reference and common
patterns (TODO sweeps, CI watchdogs), see [Heartbeat Documentation](docs/heartbeat.md).

## Global Flags

- `-v, --verbose`: Enable verbose output
Expand Down
10 changes: 8 additions & 2 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ Examples:
noSave, _ := cmd.Flags().GetBool("no-save")
sessionID, _ := cmd.Flags().GetString("session-id")
requireApproval, _ := cmd.Flags().GetBool("require-approval")
return RunAgentCommand(Cfg, model, args[0], files, noSave, sessionID, requireApproval)
heartbeat, _ := cmd.Flags().GetBool("heartbeat")
return RunAgentCommand(Cfg, model, args[0], files, noSave, sessionID, requireApproval, heartbeat)
},
}

Expand Down Expand Up @@ -91,7 +92,7 @@ type AgentSession struct {
approvalCh chan domain.ApprovalResponse
}

func RunAgentCommand(cfg *config.Config, modelFlag, taskDescription string, files []string, noSave bool, sessionID string, requireApproval bool) (err error) {
func RunAgentCommand(cfg *config.Config, modelFlag, taskDescription string, files []string, noSave bool, sessionID string, requireApproval, heartbeat bool) (err error) {
defer func() {
if r := recover(); r != nil {
outputAgentError(fmt.Sprintf("agent panic: %v", r))
Expand All @@ -103,6 +104,10 @@ func RunAgentCommand(cfg *config.Config, modelFlag, taskDescription string, file
}
}()

if heartbeat && cfg.Prompts.Agent.SystemPromptHeartbeat != "" {
cfg.Prompts.Agent.SystemPrompt = cfg.Prompts.Agent.SystemPromptHeartbeat
}

svc := container.NewServiceContainer(cfg)
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Expand Down Expand Up @@ -1116,5 +1121,6 @@ func init() {
agentCmd.Flags().Bool("no-save", false, "Disable saving conversation to database")
agentCmd.Flags().String("session-id", "", "Resume an existing agent session by conversation ID")
agentCmd.Flags().Bool("require-approval", false, "Enable IPC-based tool approval via stdin/stdout (used by channel manager)")
agentCmd.Flags().Bool("heartbeat", false, "Run with the heartbeat system prompt (used by the heartbeat service)")
rootCmd.AddCommand(agentCmd)
}
93 changes: 82 additions & 11 deletions cmd/channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
logger "github.com/inference-gateway/cli/internal/logger"
services "github.com/inference-gateway/cli/internal/services"
channels "github.com/inference-gateway/cli/internal/services/channels"
heartbeat "github.com/inference-gateway/cli/internal/services/heartbeat"
scheduler "github.com/inference-gateway/cli/internal/services/scheduler"
cobra "github.com/spf13/cobra"
)
Expand Down Expand Up @@ -46,33 +47,40 @@ Examples:
},
}

// RunChannelsCommand starts the channel listener daemon
// RunChannelsCommand starts the channel listener daemon. The daemon
// hosts up to three subsystems — channels, scheduler, and heartbeat —
// and starts whichever are enabled. At least one must be enabled or
// the daemon refuses to boot (otherwise it would just sleep forever).
func RunChannelsCommand(cfg *config.Config) error {
if !cfg.Channels.Enabled {
return fmt.Errorf("channels are not enabled. Set enabled: true in .infer/channels.yaml or INFER_CHANNELS_ENABLED=true")
if !cfg.Channels.Enabled && !cfg.Tools.Schedule.Enabled && !cfg.Heartbeat.Enabled {
return fmt.Errorf("nothing to run: enable at least one of channels, scheduler, or heartbeat in .infer/")
}

cm := services.NewChannelManagerService(cfg.Channels)

if err := registerChannels(cm, cfg); err != nil {
return err
if cfg.Channels.Enabled {
if err := registerChannels(cm, cfg); err != nil {
return err
}
}

logger.Info("Starting channels-manager",
"version", version,
"commit", commit,
"build_date", date,
)
logger.Info("Starting channel listener...")

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

if err := cm.Start(ctx); err != nil {
return fmt.Errorf("failed to start channels: %w", err)
if cfg.Channels.Enabled {
logger.Info("Starting channel listener...")
if err := cm.Start(ctx); err != nil {
return fmt.Errorf("failed to start channels: %w", err)
}
}

sched, err := startScheduler(ctx, cm, cfg)
Expand All @@ -81,12 +89,31 @@ func RunChannelsCommand(cfg *config.Config) error {
return fmt.Errorf("failed to start scheduler: %w", err)
}

logger.Info("Listening for messages. Press Ctrl+C to stop.")
hb, err := startHeartbeat(ctx, cfg)
if err != nil {
if sched != nil {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second)
_ = sched.Stop(stopCtx)
stopCancel()
}
_ = cm.Stop()
return fmt.Errorf("failed to start heartbeat: %w", err)
}

logger.Info("Daemon ready. Press Ctrl+C to stop.")

<-sigChan
logger.Info("Shutting down channels...")
logger.Info("Shutting down...")
cancel()

if hb != nil {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := hb.Stop(stopCtx); err != nil {
logger.Error("Failed to stop heartbeat", "error", err)
}
stopCancel()
}

if sched != nil {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := sched.Stop(stopCtx); err != nil {
Expand All @@ -99,7 +126,7 @@ func RunChannelsCommand(cfg *config.Config) error {
return fmt.Errorf("failed to stop channels: %w", err)
}

logger.Info("Channels stopped.")
logger.Info("Daemon stopped.")
return nil
}

Expand Down Expand Up @@ -134,6 +161,50 @@ func startScheduler(ctx context.Context, cm *services.ChannelManagerService, cfg
return svc, nil
}

// startHeartbeat initialises the heartbeat service when enabled.
// Returns nil service when disabled. Parses interval/initial_delay as
// time.Duration strings and surfaces parse errors so the daemon fails
// fast on bad config.
func startHeartbeat(ctx context.Context, cfg *config.Config) (*heartbeat.Service, error) {
if !cfg.Heartbeat.Enabled {
return nil, nil
}

interval, err := time.ParseDuration(cfg.Heartbeat.Interval)
if err != nil {
return nil, fmt.Errorf("parse heartbeat.interval %q: %w", cfg.Heartbeat.Interval, err)
}

var initialDelay time.Duration
if cfg.Heartbeat.InitialDelay != "" {
initialDelay, err = time.ParseDuration(cfg.Heartbeat.InitialDelay)
if err != nil {
return nil, fmt.Errorf("parse heartbeat.initial_delay %q: %w", cfg.Heartbeat.InitialDelay, err)
}
}

prompt := cfg.Heartbeat.Prompt
if prompt == "" {
prompt = config.DefaultHeartbeatConfig().Prompt
}

svc, err := heartbeat.NewService(heartbeat.Options{
Config: heartbeat.Config{
Interval: interval,
InitialDelay: initialDelay,
Model: cfg.Heartbeat.Model,
Prompt: prompt,
},
})
if err != nil {
return nil, err
}
if err := svc.Start(ctx); err != nil {
return nil, err
}
return svc, nil
}

// registerChannels registers enabled channel implementations with the manager
func registerChannels(cm *services.ChannelManagerService, cfg *config.Config) error {
registered := 0
Expand Down
Loading