Persistent Task Sessions¶
Tasks default to runtime: oneshot, which runs claude -p <prompt> as a fresh subprocess for each cron firing. Setting runtime: persistent reuses a warm claude session living in a leo-supervised tmux session.
Why¶
- Skip claude startup cost on every firing.
- Carry conversational context across firings without juggling
--resumeids. tmux attachto watch a scheduled task run live.
Quickstart — dedicated session per task¶
tasks:
morning:
runtime: persistent
schedule: "0 7 * * *"
prompt_file: prompts/morning.md
workspace: ~/work/morning
channels: [plugin:slack@official]
That config implicitly creates a dedicated session named leo-session-morning. Leo:
- Starts a long-running
claudeinside the tmux session atleo serviceboot. - Resumes (
--resume <id>) on crash using the persisted session id. - Writes a leo-managed Stop hook into
~/work/morning/.claude/settings.local.json. - On each cron firing, pastes the prompt into the live session and waits for the hook to report completion.
Sharing a session across multiple tasks¶
Declare a session explicitly under sessions: and reference it from tasks:
sessions:
daily:
workspace: ~/work/daily
model: sonnet
channels: [plugin:slack@official, plugin:telegram@official]
tasks:
standup:
runtime: persistent
session: daily
schedule: "0 7 * * *"
prompt_file: prompts/standup.md
channels: [plugin:slack@official] # must be subset of session.channels
summary:
runtime: persistent
session: daily
schedule: "0 18 * * *"
prompt_file: prompts/summary.md
channels: [plugin:telegram@official]
Tasks share the same claude process and channel plugins. Each firing's prompt is queued per-session (FIFO), so they execute in arrival order.
Sharing with a supervised process¶
processes:
bot:
workspace: ~/work/bot
channels: [plugin:telegram@official]
tasks:
midday-poke:
runtime: persistent
session: process:bot
schedule: "0 12 * * *"
prompt_file: prompts/midday.md
channels: [plugin:telegram@official]
The same tmux session hosts both your interactive process and the scheduled prompt. Sentinel correlation in the injected prompt distinguishes leo's turns from human ones.
Channel delivery¶
Persistent tasks reuse the channel plugins loaded by the session at boot (via LEO_CHANNELS). The injected prompt ends with a footer instructing the model to deliver its reply via the task's channel list. No claude -p is invoked for delivery.
If a task has notify_on_fail: true and fails (timeout, queue full, etc.), leo enqueues a follow-up failure-notice prompt into the same session — again, no claude -p.
Configuration reference¶
sessions: (top-level map)¶
| Field | Type | Notes |
|---|---|---|
workspace | path | Required. Where the session's .claude/ lives. |
model | string | sonnet / opus / haiku. |
agent | string | Optional agent template. |
permission_mode | string | One of the standard claude permission modes. |
allowed_tools | list | Restrict tool surface. |
disallowed_tools | list | Block specific tools. |
append_system_prompt | string | Appended to the system prompt. |
add_dirs | list | Extra --add-dir paths. |
channels | list | Channel plugins loaded for the session. |
env | map[str]str | Extra env vars passed to claude. |
idle_timeout | duration string | Reserved for future lazy-session support. |
New tasks.<name> fields¶
| Field | Type | Notes |
|---|---|---|
runtime | enum | oneshot (default) or persistent. |
session | string | <name> references sessions:; process:<name> references processes:. Optional — omit for an implicit dedicated session. |
lazy | bool | Parsed but not yet honored; always-on for now. |
queue_max | int | Max queued firings per session (default 5; overflow rejected with "queue full"). |
Task channels: must be a subset of the resolved session's channels:. Validation enforces this at config load.
Operator commands¶
leo session list # configured sessions with workspace/model/channels
leo session status <name> # stored session id + tmux liveness
leo session attach <name> # tmux attach (interactive)
leo session logs <name> # capture last 200 lines of pane scrollback
leo session reset <name> # kill tmux + clear stored id (use when context fills)
leo session drain <name> # placeholder; not yet implemented
How it works¶
leo serviceboots a goroutine persessions:entry (and per implicit dedicated session) that runsclaudeinsideleo-session-<name>tmux, with restart-on-crash and--resumeof the persisted session id.- Each session's workspace gets a leo-managed Stop hook merged into
.claude/settings.local.json. The hook runsleo internal task-reportwhen a turn ends. leo run <task>for aruntime: persistenttask POSTs to the daemon (/task/enqueue) with a prompt wrapped in:- a sentinel marker:
<!-- leo:invocation=<uuid> --> - a delivery footer naming the task's
channels:
- a sentinel marker:
- The daemon's per-session pump goroutine pastes the prompt via
tmux paste-bufferthensend-keys Enter. - When the turn ends, the Stop hook fires
leo internal task-report, which reads the transcript JSONL, finds the marker, extracts the assistant reply, and POSTs to/task/report. - The pump correlates the report to the in-flight invocation, signals the result channel, and the
leo runsubprocess returns. History is recorded; the session id is persisted for next-boot resume.
Known limitations (v1)¶
lazysessions are parsed but always-on;idle_timeoutis reserved.leo session drainis a stub.- Context-fill recovery is manual via
leo session reset <name>(no auto-compaction). - No remote-daemon (
client.hosts) dispatch forleo sessionsubcommands yet. - End-to-end integration tests are not yet in place; the unit tests cover the daemon router, runner branch, hook installer, tmux primitives, config validation, and the hidden CLI exhaustively.