Skip to content

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

ColumnNotes
Name / CommandDisplay name on top, command line on a secondary line (monospaced, truncated)
ScheduleOne-shot, Cron: <expr>, or Every Nm/Nh/Ns
Statuspending, running, completed, failed, cancelled
Last RunRelative time since the last invocation
Next RunRelative time until the next scheduled run (only meaningful for cron/interval)
ProjectOptional project association (filterable from the header)
ActionsView session • History • Edit • Run now / Cancel • Delete

Actions

  • Run now (▶) - trigger the task immediately, regardless of schedule. The current next_run_at is 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:

  1. Command - a single shell command line. Pipes, redirects, env-var expansion, && / || chaining all work because the daemon runs it through sh -c on Unix or cmd /C on Windows.
  2. Working directory - absolute path on the daemon's host. The literal . resolves to the daemon's configured default working dir.
  3. 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 7th year field is allowed. * and ? mean "any"; steps (*/15), ranges (9-17), and lists (1,15) work; day-of-week is 1=Sun through 7=Sat or names (Mon-Fri); months accept 1-12 or names. Day-of-month and day-of-week are combined with AND. The shorthands @hourly, @daily, @weekly, @monthly, and @yearly are 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.
  4. 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_at is <= 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 cron crate, 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:

  1. The scheduler picks the (program, argv) pair for the executor. For shell that's ("/bin/sh", ["-c", command_line]) on Unix and ("cmd", ["/C", command_line]) on Windows.
  2. It creates a new PTY session in the task's working dir and links the session ID back to the task (task.session_id).
  3. 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.
  4. When the session exits, the scheduler:
    • records a TaskRun with the outcome and a captured output snapshot
    • sets task.last_exit_code to the captured code
    • sets status to completed (exit 0 or signal), failed (non-zero), cancelled (if you hit the cancel button), or treats it as timed_out when 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_at and resets the task to pending for the next run

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:

FieldMeaning
id / task_idRun identity and its parent task
attempt1-based attempt within a retry cycle (>1 means a retry)
statusrunning while in flight, then completed / failed / timed_out / cancelled
started_at / finished_atRun timing (drives the displayed duration)
exit_codeProcess exit code (null if it never produced one)
outputCollapsed, 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 running and 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 is ListTaskRunsTaskRunList.
  • Notifications still fire per terminal run. TaskCompleted fires on each completion that is not a pending retry, carrying the task name, exit code, and a failed flag. 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

MessagePayload
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

MessagePayload
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 as timed_out (and counts as a failure for retry purposes). null/0 means no limit.
  • Retries (max_retries, retry_delay_seconds) - after a run fails (or times out), the task re-fires up to max_retries more times, waiting retry_delay_seconds between attempts. Each retry increments the run's attempt. 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 one KEY=value per line.

Caveats

  • Shell injection is on you. The command line goes verbatim into sh -c / cmd /C. Don't paste untrusted input.

Hive - remote AI coding agents over WebSocket.