Appearance
Tasks
The Tasks view in hive-app lets you schedule shell commands to run inside a Hive session. A task is a (name, command, working directory, schedule) tuple persisted by the daemon. When the schedule fires, the daemon spawns a PTY session, runs the command through the platform shell, and records the exit code on the task.
Tasks are executor-agnostic - claude -p "...", ./backup.sh, and pnpm test are all just shell command lines. For interactive Claude back-and-forth use a Session; Tasks are for unattended runs that exit 0 (success) or non-zero (failure).
The Tasks view
Route: /tasks, sidebar icon: clock.
Table columns
| Column | Notes |
|---|---|
| Name / Command | Display name on top, command line on a secondary line (monospaced, truncated) |
| Schedule | One-shot, Cron: <expr>, or Every Nm/Nh/Ns |
| Status | pending, running, completed, failed, cancelled |
| Last Run | Relative time since the last invocation |
| Next Run | Relative time until the next scheduled run (only meaningful for cron/interval) |
| Project | Optional project association (filterable from the header) |
| Actions | View session • History • Edit • Run now / Cancel • Delete |
Actions
- Run now (▶) - trigger the task immediately, regardless of schedule. The current
next_run_atis preserved. - Cancel (✕, only while running) - kills the underlying session; task status becomes
cancelled. - View session (↗, when a session exists) - jumps to the session detail view so you can scrub through the captured output.
- History (clock-with-arrow) - expands an inline panel of the task's past runs (status, duration, attempt, exit code, and a per-run output snapshot).
- Edit - modify name, command, working directory, project, schedule, env vars, timeout, or retries. Selecting a project auto-fills the working directory from the project (you can still override it), the same way the new-session dialog does.
- Delete - removes the task. A running session is not affected; it keeps running until it exits on its own.
Keyboard
n- open the New Task dialog (when the daemon is connected).
Defining a task
A task has four required pieces:
- Command - a single shell command line. Pipes, redirects, env-var expansion,
&&/||chaining all work because the daemon runs it throughsh -con Unix orcmd /Con Windows. - Working directory - absolute path on the daemon's host. The literal
.resolves to the daemon's configured default working dir. - Schedule - one of:
- One-shot - runs immediately, exactly once.
- Cron - a 6-field cron expression evaluated in UTC:
second minute hour day-of-month month day-of-week(seconds are mandatory - a bare 5-field expression is rejected). An optional 7thyearfield is allowed.*and?mean "any"; steps (*/15), ranges (9-17), and lists (1,15) work; day-of-week is1=Sun through7=Sat or names (Mon-Fri); months accept1-12or names. Day-of-month and day-of-week are combined with AND. The shorthands@hourly,@daily,@weekly,@monthly, and@yearlyare also accepted. Examples:0 0 * * * *(every hour),0 */15 * * * *(every 15 minutes),0 0 9 * * Mon-Fri(09:00 UTC on weekdays). The create/edit dialog shows a live preview of the next run times so you can confirm the expression before saving. - Interval - fires every N seconds, starting N seconds from creation.
- Name (optional) - used in the list view and notifications. Falls back to the task's UUID prefix if omitted.
It can also carry optional environment variables, a timeout, and retry settings - see Timeout, retries, and env vars. The create/edit dialog also exposes Notify on success and Notify on failure toggles (notify_on_success / notify_on_failure, both default off): they decide whether a completed run raises a user-facing notification. This is also what the Android Background Notifications poller reports when the app was closed at the time the task finished. A task can also be associated with a project. Beyond filtering and grouping, the project determines the task's execution context the same way it does for an interactive session: when a project is selected the working directory is pre-filled from the project, and at run time the run-as OS user is resolved from the project's run_as_users mapping for this node (falling back to the node's default_session_user). A blank working directory also falls back to the project's directory.
Scheduling engine
Tasks are scheduled entirely inside hived - there is no integration with the OS scheduler (cron/crond, systemd timers, or Windows Task Scheduler). The daemon owns a single in-process scheduler (crates/hive-daemon/src/scheduler.rs):
- One Tokio loop ticks every 100 seconds and runs any task whose
next_run_atis<=now. - One-shot tasks run immediately on creation rather than waiting for the next tick, so a one-shot you just created starts right away.
- Cron expressions are parsed and advanced by the Rust
croncrate, always in UTC. Interval schedules just add N seconds (the first run lands one interval after creation). - Because scheduling lives in the daemon process, tasks are local to each node and not replicated across cluster peers. A task only ever runs on the daemon it was created on.
This means hived must be running for a task to fire. A task whose scheduled time passes while the daemon is down does not backfill - it runs at the first tick after the daemon is back up (one catch-up run, not one per missed slot).
Execution model
When a task fires:
- The scheduler picks the (program, argv) pair for the executor. For
shellthat's("/bin/sh", ["-c", command_line])on Unix and("cmd", ["/C", command_line])on Windows. - It creates a new PTY session in the task's working dir and links the session ID back to the task (
task.session_id). - The session runs to completion. Its output is captured in the normal session machinery, so the View session link works while it's running and after it finishes.
- When the session exits, the scheduler:
- records a
TaskRunwith the outcome and a captured output snapshot - sets
task.last_exit_codeto the captured code - sets status to
completed(exit 0 or signal),failed(non-zero),cancelled(if you hit the cancel button), or treats it astimed_outwhen the wall-clock timeout fired - if the run failed and retries remain, re-fires it after the retry delay (see Timeout, retries, and env vars)
- for cron/interval schedules, recomputes
next_run_atand resets the task topendingfor the next run
- records a
The scheduler tick interval is 100 seconds. One-shot tasks run immediately on creation, but a recurring (cron/interval) task whose next_run_at falls between two ticks runs at the next tick, not exactly on time. Use Run now if you need precise immediate execution.
Tasks survive daemon restarts. Any task that was running when the daemon exited comes back as pending so the next tick re-evaluates it.
Runs history & output
The parent Task still carries a summary of its latest run (last_run_at, last_exit_code, status, and the current session_id). On top of that, the daemon records a bounded per-run history so you can see past runs, not just the most recent state.
Each run is a TaskRun row with its own outcome and a captured output snapshot:
| Field | Meaning |
|---|---|
id / task_id | Run identity and its parent task |
attempt | 1-based attempt within a retry cycle (>1 means a retry) |
status | running while in flight, then completed / failed / timed_out / cancelled |
started_at / finished_at | Run timing (drives the displayed duration) |
exit_code | Process exit code (null if it never produced one) |
output | Collapsed, capped output snapshot (tail kept; 64 KB max) |
How it behaves:
- History is retained per task, most recent first. The daemon keeps the newest 50 runs per task (
TASK_RUNS_KEEP); older runs are pruned on each new insert. In the Tasks view, the history icon on a row expands an inline panel listing those runs with status, time-ago, duration, attempt, and exit code. Each run with captured output has an Output toggle, and a link to its session when that session still exists. - Output is captured at completion. When a run's session exits (or is killed by the timeout), the daemon collapses the PTY output through the same headless VT used for history replay (so spinners/status redraws don't stack) and stores the tail. This survives the underlying session being torn down - unlike View session, which only works while that session is still live.
- Live updates. Runs start as
runningand finish to a terminal status; both transitions are broadcast (TaskRunUpdated) so an open history panel updates without a manual refresh. Listing a task's runs isListTaskRuns→TaskRunList. - Notifications still fire per terminal run.
TaskCompletedfires on each completion that is not a pending retry, carrying the task name, exit code, and afailedflag. Whether that completion raises a user-facing alert is controlled per task by the Notify on success / Notify on failure flags (below). These same alerts are what the Android Background Notifications poller surfaces when the app was closed at completion time.
Wire protocol
Tasks are exchanged over the same WebSocket as everything else (see protocol.md for the framing). The task-specific messages are:
Task
jsonc
{
"id": "uuid",
"project_id": "uuid | null",
"name": "string | null",
"executor": { "kind": "shell", "command_line": "pnpm test" },
"working_dir": "/abs/path",
"schedule": { "type": "one_shot" }
| { "type": "cron", "expression": "0 0 3 * * *" }
| { "type": "interval", "seconds": 3600 },
"status": "pending | running | completed | failed | cancelled",
"created_at": "RFC3339",
"last_run_at": "RFC3339 | null",
"next_run_at": "RFC3339 | null",
"session_id": "uuid | null",
"last_exit_code": "i32 | null",
"env": { "KEY": "value" }, // extra env vars injected into the run
"timeout_seconds": "u64 | null", // wall-clock kill; null = no limit
"max_retries": 0, // re-fires after a failed run
"retry_delay_seconds": 0, // wait between a failure and its retry
"notify_on_success": false, // alert on a successful completion
"notify_on_failure": false // alert on a failed / timed-out completion
}A TaskRun (per-run history record):
jsonc
{
"id": "uuid",
"task_id": "uuid",
"session_id": "uuid | null",
"attempt": 1,
"status": "running | completed | failed | timed_out | cancelled",
"started_at": "RFC3339",
"finished_at": "RFC3339 | null",
"exit_code": "i32 | null",
"output": "string | null" // collapsed, capped (64 KB tail)
}The executor field is discriminated by kind. Today there is one variant:
{"kind": "shell", "command_line": "<line>"}- runs through the platform shell. Future variants (e.g. a dedicated webhook executor) can be added without breaking existing tasks because the wire format and the SQL schema both treat the executor as a single tagged JSON blob.
Client → Daemon
| Message | Payload |
|---|---|
CreateTask | { name?, executor, working_dir, schedule, project_id?, env, timeout_seconds?, max_retries, retry_delay_seconds, notify_on_success, notify_on_failure } |
ListTasks | - |
GetTask | { task_id } |
ListTaskRuns | { task_id } - fetch a task's run history |
UpdateTask | { task_id, name?, executor?, working_dir?, schedule?, project_id??, env?, timeout_seconds?, max_retries?, retry_delay_seconds?, notify_on_success?, notify_on_failure? } (only provided fields are changed; timeout_seconds and project_id are nested options so they can be cleared - inner null detaches the project) |
DeleteTask | { task_id } |
CancelTask | { task_id } |
TriggerTask | { task_id } (run now) |
Daemon → Client
| Message | Payload |
|---|---|
TaskList | { tasks: Task[] } - response to ListTasks and GetTask |
TaskCreated | { task } |
TaskUpdated | { task } - broadcast to every connected client on create, status change (run start, session exit, cancel), and manual edit |
TaskDeleted | { task_id } - broadcast to every connected client when a task is deleted |
TaskRunList | { task_id, runs: TaskRun[] } - response to ListTaskRuns, most recent first |
TaskRunUpdated | { run } - fired when a run starts or finishes |
Persistence
Tasks live in the SQLite tasks table (extended through schema v26), with per-run history in task_runs:
sql
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
name TEXT,
executor TEXT NOT NULL, -- JSON of TaskExecutor (tagged by `kind`)
working_dir TEXT NOT NULL,
schedule TEXT NOT NULL, -- JSON of TaskSchedule
status TEXT NOT NULL,
created_at TEXT NOT NULL,
last_run_at TEXT,
next_run_at TEXT,
session_id TEXT,
last_exit_code INTEGER,
env TEXT, -- JSON object of env vars (v23)
timeout_seconds INTEGER, -- v23
max_retries INTEGER NOT NULL DEFAULT 0, -- v23
retry_delay_seconds INTEGER NOT NULL DEFAULT 0, -- v23
notify_on_success INTEGER NOT NULL DEFAULT 0, -- v26
notify_on_failure INTEGER NOT NULL DEFAULT 0 -- v26
);
CREATE TABLE task_runs ( -- v23
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
session_id TEXT,
attempt INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
exit_code INTEGER,
output TEXT
);Run history is pruned to the newest 50 rows per task on each insert. Deleting a task cascades to its runs.
Tasks are local to each daemon. They are not replicated across cluster peers - unlike sessions and projects, every daemon runs its own task list. If you need a task to run on a specific node, schedule it on that node.
Timeout, retries, and env vars
- Timeout (
timeout_seconds) - a wall-clock limit per run. A run that exceeds it is killed and recorded astimed_out(and counts as a failure for retry purposes).null/0means no limit. - Retries (
max_retries,retry_delay_seconds) - after a run fails (or times out), the task re-fires up tomax_retriesmore times, waitingretry_delay_secondsbetween attempts. Each retry increments the run'sattempt. Retry bookkeeping is in-memory and resets on success, on exhaustion, or on daemon restart. A cancelled run never retries. - Env vars (
env) - a key/value map injected into the run's environment on top of the daemon's own (task values win on conflict). In the UI, enter oneKEY=valueper line.
Caveats
- Shell injection is on you. The command line goes verbatim into
sh -c/cmd /C. Don't paste untrusted input.