Bläddra i källkod

feat: ClankerSaddle — Spry web app orchestrating saddle daemons with projects, tasks, dispatch, terminal viewer

clanker 1 månad sedan
incheckning
4cf9855680

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+builddir/
+*.sqlite*
+web-config.json
+
+# Generated by meson; copied from builddir by spry for the Vala language server
+compile_commands.json

+ 15 - 0
.usmignore

@@ -0,0 +1,15 @@
+# Build trees at any depth (directory-only: the directory and all beneath it)
+builddir*/
+
+# Database artifacts by basename at any depth
+*.sqlite*
+db.sqlite
+
+# Static keys are never packaged (mount web-config.json at runtime instead)
+web-config.json
+
+# Local agent session state (directory-only)
+.kilo/
+
+# Generated build artifact (Vala language server)
+compile_commands.json

+ 63 - 0
MANIFEST.usm

@@ -0,0 +1,63 @@
+{
+  "name": "clanker-saddle",
+  "version": "0.1",
+  "summary": "ClankerSaddle — LLM task orchestration platform (Statum + Spry)",
+  "licences": [],
+  "flags": ["ninjaStyleProgress"],
+  "provides": {
+    "bin:clanker-saddle": "as-expected"
+  },
+  "depends": {
+    "runtime": [
+      "lib:libglib-2.0.so.0",
+      "lib:libgobject-2.0.so.0",
+      "lib:libgio-2.0.so.0",
+      "lib:libjson-glib-1.0.so.0",
+      "lib:libxml2.so.2",
+      "lib:libsodium.so.26",
+      "lib:libsoup-3.0.so.0",
+      "lib:libsqlite3.so.0",
+      "lib:libmicrohttpd.so.12",
+      "lib:libinvercargill-1.so",
+      "lib:libinvercargill-json.so",
+      "lib:libinvercargill-sql.so",
+      "lib:libinvercargill-sql-inversion.so",
+      "lib:libinversion-0.1.so",
+      "lib:libastralis-0.1.so",
+      "lib:libstatum-0.1.so",
+      "lib:libspry-0.2.so",
+      "lib:libc.so.6"
+    ],
+    "build": [
+      "bin:valac",
+      "bin:meson",
+      "bin:ninja",
+      "bin:python3",
+      "bin:statum-mkpstm",
+      "bin:statum-mkres",
+      "bin:statum-genkeys",
+      "pc:glib-2.0.pc",
+      "pc:gobject-2.0.pc",
+      "pc:gio-2.0.pc",
+      "pc:json-glib-1.0.pc",
+      "pc:libxml-2.0.pc",
+      "pc:libsoup-3.0.pc",
+      "pc:sqlite3.pc",
+      "pc:invercargill-1.pc",
+      "pc:invercargill-json.pc",
+      "pc:invercargill-sql.pc",
+      "pc:invercargill-sql-inversion.pc",
+      "pc:inversion-0.1.pc",
+      "pc:astralis-0.1.pc",
+      "pc:statum-0.1.pc",
+      "pc:spry-0.2.pc"
+    ],
+    "manage": [
+      "bin:bash"
+    ]
+  },
+  "execs": {
+    "build": "usm-scripts/build.sh",
+    "install": "usm-scripts/install.sh"
+  }
+}

+ 185 - 0
README.md

@@ -0,0 +1,185 @@
+# clanker-saddle
+
+ClankerSaddle is an LLM task orchestration platform: a Statum + Spry web
+application that queues work for humans and dispatches it to
+[`saddle`](../saddle) daemons — one-container agents that work on real git
+checkouts through bash sessions with full auditability. You design tasks
+during the day; the agent works while you sleep.
+
+## Architecture
+
+```
+Browser ←→ clanker-saddle (Spry app: SQLite queue, Statum UI, dispatcher)
+                          ↕ HTTP (poll-based, 5s ticks)
+              saddle daemons (Vala: bash session + LLM loop, port 7100)
+                          ↕ curl
+              any OpenAI-compatible chat-completions endpoint
+```
+
+The web app owns projects, tasks, dependencies and the saddle registry.
+Its dispatcher pairs ready tasks (dependencies done, priority first) with
+idle saddles, pushes each assignment to the daemon's `POST /start`
+(including the LLM endpoint config), then polls `GET /output` and
+`GET /status` every five seconds to mirror the agent's bash history into
+`session_commands` and to project its `saddle comment/subtask/done` events
+onto the task. Blocking questions flow back the same way: the daemon's
+question appears on the task page, and the human's answer is pushed to the
+daemon's `POST /answer` on the next tick.
+
+| Component | Lives | Role |
+|---|---|---|
+| Web app | `src/` | pages, actions, services, dispatcher, saddle API |
+| Saddle daemon | `../saddle` | bash PTY-free session, LLM loop, control server |
+| Mock LLM | `../saddle/test/mock_llm.py` | scripted endpoint for testing |
+
+Orchestration behaviour to know:
+
+| Setting | Meaning |
+|---|---|
+| `SADDLE_LLM_BASE_URL` | LLM endpoint sent with every assignment; unset pauses dispatch |
+| `SADDLE_LLM_API_KEY` / `SADDLE_LLM_MODEL` | Optional credentials/model for the above |
+| `SADDLE_PROJECTS_ROOT` | Checkouts are addressed as `root/<project-name>` inside the saddle container (default `/workspace`) |
+
+## Quick start (local)
+
+```bash
+export WS_PREFIX="$HOME/.local"
+export PKG_CONFIG_PATH="$WS_PREFIX/lib64/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
+export XDG_DATA_DIRS="$WS_PREFIX/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
+export LD_LIBRARY_PATH="$WS_PREFIX/lib64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+
+meson setup builddir && ninja -C builddir
+```
+
+Run the app, a saddle daemon and a mock LLM in three shells (the mock's
+port must be reachable from the daemon; project checkouts live under
+`SADDLE_PROJECTS_ROOT`):
+
+```bash
+export SPRY_SEED_ADMIN_USERNAME=admin SPRY_SEED_ADMIN_PASSWORD=change-me
+export SADDLE_LLM_BASE_URL=http://127.0.0.1:9999 SADDLE_LLM_MODEL=mock
+export SADDLE_PROJECTS_ROOT=/tmp/clanker-e2e/workspace
+./builddir/clanker-saddle 8080
+
+../saddle/builddir/src/saddle --port 7100
+
+python3 ../saddle/test/mock_llm.py --port 9999
+```
+
+Browse to `http://localhost:8080`, log in with the seeded admin account,
+register the saddle on `/saddles` with host `localhost:7100`, and create
+your first project and task. `spry dev` wraps the first shell (rebuild +
+restart on save); `web-config.json` (from `spry keys`) keeps sessions
+alive across restarts and is never packaged or baked into images.
+
+The database is `./clanker-saddle.sqlite`, or `SPRY_DB_PATH` when set.
+
+## Docker setup
+
+Two multi-stage Alpine images build everything from source against musl
+(see `../Dockerfile.saddle` and `../Dockerfile.clanker-saddle`):
+
+```bash
+podman build -f Dockerfile.clanker-saddle -t localhost/clanker-saddle:0.1 ..
+podman build -f Dockerfile.saddle -t localhost/saddle:0.1 ..
+
+podman network create clanker-net
+podman run -d --name clanker-web --userns=keep-id --network clanker-net \
+    -p 8080:8080 -v "$PWD/data:/var/lib/clanker-saddle:Z" \
+    -e SPRY_SEED_ADMIN_USERNAME=admin -e SPRY_SEED_ADMIN_PASSWORD=change-me \
+    -e SADDLE_LLM_BASE_URL=http://127.0.0.1:9999 \
+    -e SADDLE_PROJECTS_ROOT=/workspace \
+    localhost/clanker-saddle:0.1
+podman run -d --name clanker-saddle --userns=keep-id --network clanker-net \
+    -p 7100:7100 -v "$PWD/workspace:/workspace:Z" \
+    localhost/saddle:0.1
+```
+
+Register the saddle with its **network** address (`clanker-saddle:7100`)
+— the web app talks to the daemon container-to-container, while the LLM
+base URL is dialled by the daemon itself (a mock can run inside the
+saddle container via `podman exec -d clanker-saddle …`, reachable at
+`http://127.0.0.1:9999`). Put the SQLite store on a volume
+(`SPRY_DB_PATH` defaults to `/var/lib/clanker-saddle/clanker-saddle.sqlite`)
+and mount git checkouts under `/workspace`. On SELinux hosts the bind
+mounts need `:Z`, and rootless podman needs `--userns=keep-id` for the
+mounts to be writable. For durable sessions mount `web-config.json`
+read-only and point `ASTRALIS_CONFIG_PATH` at it; otherwise the app runs
+with ephemeral keys.
+
+## Creating your first project and task
+
+1. **Project** — `/projects` → name, git URL, default branch. The
+   checkout must exist at `$SADDLE_PROJECTS_ROOT/<name>` on the saddle's
+   filesystem (clone it into the workspace volume; the agent does not
+   clone). Link related projects as *co-developed* (agent may modify) or
+   *reference* (read-only) from the project page.
+2. **Task** — `/tasks` → project, title, description, priority (1 is
+   highest), optionally a design task (explore/plan/question only). Chain
+   work with dependencies: a task waits until everything it depends on is
+   done.
+3. **Saddle** — `/saddles` (admin) → name plus the daemon's host:port.
+   Within five seconds the dispatcher assigns the highest-priority ready
+   task to an idle saddle and the agent starts working.
+
+Tasks can also arrive from the agent itself: `saddle subtask` lines
+create child tasks, and push-style daemons may report through
+`POST /api/saddle/{id}/update` (`output`, `comment`, `block`, `subtask`,
+`done`, `status`) and collect answers from
+`GET /api/saddle/{id}/poll`.
+
+## Monitoring agent progress
+
+- **`/tasks`** — live queue: status chips (`queued → assigned → working →
+  blocked → done/failed`), dependency states and ready flags.
+- **`/tasks/{id}`** — the control room; it refreshes every three seconds
+  while a saddle works: comments thread (yours and the agent's), blocks,
+  subtasks, session history, and the agent's final summary.
+- **`/tasks/{id}/terminal`** — the full bash session log: every command
+  the agent ran with its combined output and exit code, plus its
+  commentary lines (`# …`) and saddle events.
+- **`/`** — dashboard: saddle cards, queue summary, recent activity.
+
+## Answering block questions
+
+When an agent needs a human decision it runs
+`saddle block "<question>"`; the task flips to **blocked** and the
+question appears on its page with an answer form. Type the answer, submit
+— the dispatcher delivers it to the daemon within five seconds, the agent
+resumes with the answer in its conversation, and the task returns to
+`working`. Answers are delivered exactly once per question.
+
+## Integration testing
+
+`test/integration_test.py` drives the whole loop against a running
+app + daemon pair: explore task, dependency gating, block + answer, and
+the audit trail (26 checks, Statum-style PASS/FAIL). Host run:
+
+```bash
+mkdir -p /tmp/clanker-e2e/workspace && cd /tmp/clanker-e2e/workspace \
+    && git init demo && cd demo && git commit --allow-empty -m one \
+    && git commit --allow-empty -m two
+
+WEB=http://localhost:8080 SADDLE=http://localhost:7100 \
+WORKSPACE=/tmp/clanker-e2e/workspace \
+DB_PATH=/tmp/clanker-e2e/web/clanker-saddle.sqlite \
+    python3 test/integration_test.py
+```
+
+For a container run point `WEB`/`SADDLE` at the published ports, pass
+`SADDLE_HOST=clanker-saddle:7100`, the container's SQLite file as
+`DB_PATH`, and start the in-container mock via `MOCK_CMD='podman exec -d
+clanker-saddle python3 /usr/libexec/saddle/mock_llm.py …'`.
+
+## Deployment (USM)
+
+`MANIFEST.usm` + `usm-scripts/` package the app as `bin:clanker-saddle`
+(`usm manifest package`), published to the Web-Stack repository:
+
+```bash
+usm manifest package
+cd ../web-stack-repo && usm repository add ../clanker-saddle-0.1.usmc
+```
+
+`web-config.json` is never packaged (`.usmignore`); keys are mounted at
+runtime, never baked into images.

+ 147 - 0
meson.build

@@ -0,0 +1,147 @@
+project('clanker-saddle', ['c', 'vala'],
+  version: '0.1',
+)
+
+glib_dep = dependency('glib-2.0')
+gobject_dep = dependency('gobject-2.0')
+gio_dep = dependency('gio-2.0')
+json_glib_dep = dependency('json-glib-1.0')
+soup_dep = dependency('libsoup-3.0')
+libxml_dep = dependency('libxml-2.0')
+sqlite_dep = dependency('sqlite3')
+invercargill_dep = dependency('invercargill-1')
+invercargill_json_dep = dependency('invercargill-json')
+invercargill_sql_dep = dependency('invercargill-sql')
+invercargill_sql_inversion_dep = dependency('invercargill-sql-inversion')
+inversion_dep = dependency('inversion-0.1')
+astralis_dep = dependency('astralis-0.1')
+statum_dep = dependency('statum-0.1')
+spry_dep = dependency('spry-0.2')
+
+# Statum build tools — statum-mkpstm compiles pages, statum-mkres embeds
+# resources. Resolved from PATH at setup time.
+statum_mkpstm = find_program('statum-mkpstm')
+statum_mkres = find_program('statum-mkres')
+
+# =============================================================================
+# Statum pages (statum-mkpstm): HTML → StatumPage subclass. Every page
+# composes the shared layout (main.html), which the tool discovers next to
+# the pages.
+# =============================================================================
+template_deps = files('src/pages/main.html')
+
+# spry:pages-begin
+home_page = custom_target('home-page',
+    input: 'src/pages/home.html',
+    output: 'HomePage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'HomePage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+login_page = custom_target('login-page',
+    input: 'src/pages/login.html',
+    output: 'LoginPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'LoginPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+projects_page = custom_target('projects-page',
+    input: 'src/pages/projects.html',
+    output: 'ProjectsPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'ProjectsPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+project_detail_page = custom_target('project-detail-page',
+    input: 'src/pages/project_detail.html',
+    output: 'ProjectDetailPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'ProjectDetailPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+tasks_page = custom_target('tasks-page',
+    input: 'src/pages/tasks.html',
+    output: 'TasksPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'TasksPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+task_detail_page = custom_target('task-detail-page',
+    input: 'src/pages/task_detail.html',
+    output: 'TaskDetailPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'TaskDetailPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+task_terminal_page = custom_target('task-terminal-page',
+    input: 'src/pages/task_terminal.html',
+    output: 'TaskTerminalPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'TaskTerminalPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+saddles_page = custom_target('saddles-page',
+    input: 'src/pages/saddles.html',
+    output: 'SaddlesPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'SaddlesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+# spry:pages-end
+
+# =============================================================================
+# Sources
+# =============================================================================
+app_sources = files(
+    # spry:sources-begin
+    'src/main.vala',
+    'src/Json.vala',
+    'src/Data/Entities.vala',
+    'src/Data/Migrations/M0001_Initial.vala',
+    'src/Services/ProjectService.vala',
+    'src/Services/TaskService.vala',
+    'src/Services/SaddleService.vala',
+    'src/Services/SaddlePollState.vala',
+    'src/Services/Dispatcher.vala',
+    'src/Api/SaddleApi.vala',
+    'src/actions/Forms.vala',
+    'src/actions/LoginAction.vala',
+    'src/actions/ProjectActions.vala',
+    'src/actions/TaskActions.vala',
+    'src/actions/SaddleActions.vala',
+    'src/entrypoints/HomeEntrypoint.vala',
+    'src/entrypoints/LoginEntrypoint.vala',
+    'src/entrypoints/ProjectsEntrypoint.vala',
+    'src/entrypoints/TasksEntrypoint.vala',
+    'src/entrypoints/TaskDetailEntrypoint.vala',
+    'src/entrypoints/TaskTerminalEntrypoint.vala',
+    'src/entrypoints/SaddlesEntrypoint.vala',
+    # spry:sources-end
+)
+
+app_generated = [
+    # spry:generated-begin
+    home_page,
+    login_page,
+    projects_page,
+    project_detail_page,
+    tasks_page,
+    task_detail_page,
+    task_terminal_page,
+    saddles_page,
+    # spry:generated-end
+]
+
+executable('clanker-saddle',
+    app_sources + app_generated,
+    dependencies: [
+        glib_dep,
+        gobject_dep,
+        gio_dep,
+        json_glib_dep,
+        soup_dep,
+        libxml_dep,
+        sqlite_dep,
+        invercargill_dep,
+        invercargill_json_dep,
+        invercargill_sql_dep,
+        invercargill_sql_inversion_dep,
+        inversion_dep,
+        astralis_dep,
+        statum_dep,
+        spry_dep,
+    ],
+    install: true
+)

+ 249 - 0
src/Api/SaddleApi.vala

@@ -0,0 +1,249 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Astralis;
+using Inversion;
+using Soup;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle.Api {
+
+    /**
+     * `POST /api/saddle/{id}/update` — the ingest channel for a saddle
+     * daemon. The body carries a type discriminator, the session id and a
+     * type-specific `data` object:
+     *
+     * ```json
+     * {"type": "output", "session_id": 12, "data": {
+     *     "command": "ls -la", "output": "…", "exit_code": 0,
+     *     "started_at": "…", "completed_at": "…", "duration_ms": 41}}
+     * ```
+     *
+     * Handled types: `output` (a single entry or an `entries` array; both
+     * `data` shapes store `session_commands` rows), `comment`, `block`
+     * (returns the new `block_id`), `subtask`, `done` (completes the task)
+     * and `status` (working|blocked).
+     */
+    public class SaddleUpdateEndpoint : Object, Endpoint {
+
+        protected TaskService tasks = inject<TaskService>();
+        protected InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            string id_text = "";
+            route_context.mapped_parameters.try_get("id", out id_text);
+            int64 saddle_id = int64.parse(id_text);
+
+            Json.Object body;
+            try {
+                body = yield read_json_body(http_context);
+            } catch (Error e) {
+                return json_error(400, e.message);
+            }
+
+            string type = body.has_member("type") ? body.get_string_member_with_default("type", "") : "";
+            int64 session_id = body.has_member("session_id") ? body.get_int_member("session_id") : 0;
+            var data = body.has_member("data") && body.get_member("data").get_node_type() == Json.NodeType.OBJECT
+                ? body.get_object_member("data") : new Json.Object();
+
+            try {
+                var response = yield handle_update(saddle_id, session_id, type, data);
+                return json_ok(response);
+            } catch (Error e) {
+                return json_error(400, e.message);
+            }
+        }
+
+        private async Json.Object handle_update(int64 saddle_id, int64 session_id,
+                string type, Json.Object data) throws Error {
+            var task_id = yield task_id_for_session(session_id);
+
+            switch (type) {
+                case "output":
+                    if (data.has_member("entries") && data.get_member("entries").get_node_type() == Json.NodeType.ARRAY) {
+                        foreach (var element in data.get_array_member("entries").get_elements()) {
+                            if (element.get_node_type() == Json.NodeType.OBJECT) {
+                                yield store_output(session_id, element.get_object());
+                            }
+                        }
+                    } else {
+                        yield store_output(session_id, data);
+                    }
+                    break;
+
+                case "comment":
+                    yield tasks.add_comment(task_id, "bot",
+                        get_string_or(data, "body", get_string_or(data, "text", "")));
+                    break;
+
+                case "block":
+                    int64 block_id = yield tasks.add_block(task_id,
+                        get_string_or(data, "question", ""));
+                    var response = new Json.Object();
+                    response.set_int_member("block_id", block_id);
+                    return response;
+
+                case "subtask":
+                    yield tasks.create_subtask(task_id,
+                        get_string_or(data, "title", "Untitled subtask"),
+                        get_string_or(data, "description", null));
+                    break;
+
+                case "done":
+                    yield tasks.complete_task(task_id,
+                        get_string_or(data, "summary", null),
+                        get_string_or(data, "working_branch", null), "done");
+                    break;
+
+                case "status":
+                    string state = get_string_or(data, "state", "");
+                    if (state == "working" || state == "blocked") {
+                        yield tasks.update_task(task_id, null, null, null, state);
+                    }
+                    break;
+
+                default:
+                    throw new Error(Quark.from_string("clanker-saddle"), 20,
+                        @"Unknown update type '$(type)'");
+            }
+
+            return new Json.Object();
+        }
+
+        private async void store_output(int64 session_id, Json.Object entry) throws Error {
+            var command = new SessionCommand() {
+                session_id = session_id,
+                command_text = get_string_or(entry, "command", ""),
+                output_text = get_string_or(entry, "output", null),
+                exit_code = entry.has_member("exit_code")
+                    && entry.get_member("exit_code").get_value_type() == typeof(int64)
+                    ? entry.get_int_member("exit_code") : 0,
+                started_at = parse_iso(entry, "started_at"),
+                completed_at = parse_iso(entry, "completed_at"),
+                duration_ms = entry.has_member("duration_ms")
+                    && entry.get_member("duration_ms").get_value_type() == typeof(int64)
+                    ? entry.get_int_member("duration_ms") : 0
+            };
+            yield db.insert_async<SessionCommand>(command);
+        }
+
+        private async int64 task_id_for_session(int64 session_id) throws Error {
+            var session = yield db.query<ClankerSaddle.Data.Session>()
+                .where(Invercargill.Expressions.expr("id == $0",
+                    Invercargill.Expressions.elem<int64?>(session_id)))
+                .first_async();
+            if (session == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 21,
+                    @"No session exists with id $(session_id)");
+            }
+            return ((!)session).task_id;
+        }
+
+        /**
+         * Reads and parses the request body as a JSON object; an empty body
+         * parses as an empty object.
+         */
+        private static async Json.Object read_json_body(HttpContext http_context) throws Error {
+            var bytes = yield http_context.request.request_body.read_all();
+            // to_raw_string on an empty body is not safe; guard the count.
+            string text = "";
+            if (bytes != null && (bytes.peek_count() ?? 0) > 0) {
+                text = bytes.to_raw_string();
+            }
+            if (text.strip().length == 0) {
+                text = "{}";
+            }
+
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(text);
+            } catch (Error e) {
+                throw new Error(Quark.from_string("clanker-saddle"), 22,
+                    @"Request body is not valid JSON: $(e.message)");
+            }
+            var root = parser.get_root();
+            if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                throw new Error(Quark.from_string("clanker-saddle"), 23,
+                    "Request body must be a JSON object");
+            }
+            return root.get_object();
+        }
+
+        internal static HttpResult json_ok(Json.Object body) {
+            body.set_boolean_member("ok", true);
+            var generator = new Json.Generator();
+            generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body));
+            var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data));
+            result.set_header("Content-Type", "application/json");
+            return result;
+        }
+
+        internal static HttpResult json_error(uint16 status_code, string message) {
+            var body = new Json.Object();
+            body.set_boolean_member("ok", false);
+            body.set_string_member("error", message);
+            var generator = new Json.Generator();
+            generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body));
+            var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data),
+                (StatusCode) status_code);
+            result.set_header("Content-Type", "application/json");
+            return result;
+        }
+
+        internal static string get_string_or(Json.Object obj, string name, string? fallback) {
+            if (obj.has_member(name) && obj.get_member(name).get_string() != null) {
+                return obj.get_string_member(name);
+            }
+            return fallback;
+        }
+
+        internal static DateTime? parse_iso(Json.Object obj, string name) {
+            if (!obj.has_member(name)) {
+                return null;
+            }
+            var value = obj.get_member(name);
+            if (value.get_node_type() != Json.NodeType.VALUE || value.get_string() == null) {
+                return null;
+            }
+            return new DateTime.from_iso8601(value.get_string(), new TimeZone.utc());
+        }
+    }
+
+    /**
+     * `GET /api/saddle/{id}/poll` — the outbound channel for a saddle
+     * daemon: answered blocks for the saddle's current task, each handed
+     * over exactly once.
+     *
+     * ```json
+     * {"answers": [{"block_id": 4, "answer": "Use the flange tool"}]}
+     * ```
+     */
+    public class SaddlePollEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            string id_text = "";
+            route_context.mapped_parameters.try_get("id", out id_text);
+            int64 saddle_id = int64.parse(id_text);
+
+            var answers = new Json.Array();
+            try {
+                foreach (var pending in yield saddles.take_pending_answers(saddle_id)) {
+                    var element = new Json.Object();
+                    element.set_int_member("block_id", pending.block_id);
+                    element.set_string_member("answer", pending.answer);
+                    answers.add_object_element(element);
+                }
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+
+            var body = new Json.Object();
+            body.set_array_member("answers", answers);
+            return SaddleUpdateEndpoint.json_ok(body);
+        }
+    }
+}

+ 226 - 0
src/Data/Entities.vala

@@ -0,0 +1,226 @@
+using InvercargillSql.Orm;
+
+namespace ClankerSaddle.Data {
+
+    /**
+     * A software project worked on by saddle agents, stored in the
+     * `projects` table.
+     */
+    public class Project : Object {
+
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string git_url { get; set; default = ""; }
+        public string default_branch { get; set; default = "main"; }
+        public string? summary { get; set; default = null; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<Project> cfg) {
+            cfg.table("projects")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<string>("name", o => o.name, (o, v) => o.name = v)
+                .column<string>("git_url", o => o.git_url, (o, v) => o.git_url = v)
+                .column<string>("default_branch", o => o.default_branch, (o, v) => o.default_branch = v)
+                .column<string>("summary", o => o.summary, (o, v) => o.summary = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /**
+     * A directed relationship between two projects ('co-developed' or
+     * 'reference'), stored in the `project_links` table.
+     */
+    public class ProjectLink : Object {
+
+        public int64 id { get; set; }
+        public int64 parent_project_id { get; set; }
+        public int64 linked_project_id { get; set; }
+        public string relationship { get; set; default = "reference"; }
+
+        public static void entity_mapping(EntityMapperBuilder<ProjectLink> cfg) {
+            cfg.table("project_links")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("parent_project_id", o => o.parent_project_id, (o, v) => o.parent_project_id = v)
+                .column<int64?>("linked_project_id", o => o.linked_project_id, (o, v) => o.linked_project_id = v)
+                .column<string>("relationship", o => o.relationship, (o, v) => o.relationship = v);
+        }
+    }
+
+    /**
+     * A unit of work for a saddle agent: queued, dispatched to a saddle,
+     * worked, possibly blocked on questions, and eventually done or failed.
+     * Stored in the `tasks` table; child tasks reference their parent via
+     * {@link parent_task_id}. Named `TaskEntity` (not `Task`) to stay
+     * clear of `GLib.Task` in files that import Gio transitively.
+     */
+    public class TaskEntity : Object {
+
+        public int64 id { get; set; }
+        public int64 parent_task_id { get; set; default = 0; }
+        public int64 project_id { get; set; }
+        public string title { get; set; default = ""; }
+        public string? description { get; set; default = null; }
+        public string status { get; set; default = "queued"; }
+        public int64 priority { get; set; default = 5; }
+        public bool is_design_task { get; set; default = false; }
+        public string? working_branch { get; set; default = null; }
+        public string? summary { get; set; default = null; }
+        public DateTime? created_at { get; set; default = null; }
+        public DateTime? started_at { get; set; default = null; }
+        public DateTime? completed_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<TaskEntity> cfg) {
+            cfg.table("tasks")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("parent_task_id", o => o.parent_task_id, (o, v) => o.parent_task_id = v)
+                .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v)
+                .column<string>("title", o => o.title, (o, v) => o.title = v)
+                .column<string>("description", o => o.description, (o, v) => o.description = v)
+                .column<string>("status", o => o.status, (o, v) => o.status = v)
+                .column<int64?>("priority", o => o.priority, (o, v) => o.priority = v)
+                .column<bool>("is_design_task", o => o.is_design_task, (o, v) => o.is_design_task = v)
+                .column<string>("working_branch", o => o.working_branch, (o, v) => o.working_branch = v)
+                .column<string>("summary", o => o.summary, (o, v) => o.summary = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v)
+                .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
+                .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v);
+        }
+    }
+
+    /** An edge saying one task must finish before another may start. */
+    public class TaskDependency : Object {
+
+        public int64 id { get; set; }
+        public int64 task_id { get; set; }
+        public int64 depends_on_task_id { get; set; }
+
+        public static void entity_mapping(EntityMapperBuilder<TaskDependency> cfg) {
+            cfg.table("task_dependencies")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("task_id", o => o.task_id, (o, v) => o.task_id = v)
+                .column<int64?>("depends_on_task_id", o => o.depends_on_task_id, (o, v) => o.depends_on_task_id = v);
+        }
+    }
+
+    /** A remark on a task, authored either by a human or by the agent ('bot'). */
+    public class TaskComment : Object {
+
+        public int64 id { get; set; }
+        public int64 task_id { get; set; }
+        public string author { get; set; default = "bot"; }
+        public string body { get; set; default = ""; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<TaskComment> cfg) {
+            cfg.table("task_comments")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("task_id", o => o.task_id, (o, v) => o.task_id = v)
+                .column<string>("author", o => o.author, (o, v) => o.author = v)
+                .column<string>("body", o => o.body, (o, v) => o.body = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /**
+     * A blocking question raised by an agent mid-task, answered by a human
+     * in the web UI; the answer is collected by the saddle on its next
+     * {@link ClankerSaddle.Api.SaddlePollEndpoint} poll.
+     */
+    public class TaskBlock : Object {
+
+        public int64 id { get; set; }
+        public int64 task_id { get; set; }
+        public string question { get; set; default = ""; }
+        public string? answer { get; set; default = null; }
+        public DateTime? answered_at { get; set; default = null; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<TaskBlock> cfg) {
+            cfg.table("task_blocks")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("task_id", o => o.task_id, (o, v) => o.task_id = v)
+                .column<string>("question", o => o.question, (o, v) => o.question = v)
+                .column<string>("answer", o => o.answer, (o, v) => o.answer = v)
+                .column<DateTime?>("answered_at", o => o.answered_at, (o, v) => o.answered_at = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /**
+     * A saddle container registered with this app: one daemon managing one
+     * bash session at a time, controlled over HTTP at {@link host}.
+     */
+    public class Saddle : Object {
+
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string? container_id { get; set; default = null; }
+        public string host { get; set; default = ""; }
+        public string status { get; set; default = "offline"; }
+        public int64 current_task_id { get; set; default = 0; }
+        public DateTime? allocated_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<Saddle> cfg) {
+            cfg.table("saddles")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<string>("name", o => o.name, (o, v) => o.name = v)
+                .column<string>("container_id", o => o.container_id, (o, v) => o.container_id = v)
+                .column<string>("host", o => o.host, (o, v) => o.host = v)
+                .column<string>("status", o => o.status, (o, v) => o.status = v)
+                .column<int64?>("current_task_id", o => o.current_task_id, (o, v) => o.current_task_id = v)
+                .column<DateTime?>("allocated_at", o => o.allocated_at, (o, v) => o.allocated_at = v);
+        }
+    }
+
+    /**
+     * One run of a task on a saddle: opened when the dispatcher assigns
+     * the task, closed with an outcome when the session ends.
+     */
+    public class Session : Object {
+
+        public int64 id { get; set; }
+        public int64 task_id { get; set; }
+        public int64 saddle_id { get; set; }
+        public DateTime? started_at { get; set; default = null; }
+        public DateTime? ended_at { get; set; default = null; }
+        public string? outcome { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<Session> cfg) {
+            cfg.table("sessions")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("task_id", o => o.task_id, (o, v) => o.task_id = v)
+                .column<int64?>("saddle_id", o => o.saddle_id, (o, v) => o.saddle_id = v)
+                .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
+                .column<DateTime?>("ended_at", o => o.ended_at, (o, v) => o.ended_at = v)
+                .column<string>("outcome", o => o.outcome, (o, v) => o.outcome = v);
+        }
+    }
+
+    /**
+     * A numbered bash command/output pair captured during a
+     * {@link Session}, stored as the saddle daemon reports it.
+     */
+    public class SessionCommand : Object {
+
+        public int64 id { get; set; }
+        public int64 session_id { get; set; }
+        public string command_text { get; set; default = ""; }
+        public string? output_text { get; set; default = null; }
+        public int64 exit_code { get; set; default = 0; }
+        public DateTime? started_at { get; set; default = null; }
+        public DateTime? completed_at { get; set; default = null; }
+        public int64 duration_ms { get; set; default = 0; }
+
+        public static void entity_mapping(EntityMapperBuilder<SessionCommand> cfg) {
+            cfg.table("session_commands")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("session_id", o => o.session_id, (o, v) => o.session_id = v)
+                .column<string>("command_text", o => o.command_text, (o, v) => o.command_text = v)
+                .column<string>("output_text", o => o.output_text, (o, v) => o.output_text = v)
+                .column<int64?>("exit_code", o => o.exit_code, (o, v) => o.exit_code = v)
+                .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
+                .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v)
+                .column<int64?>("duration_ms", o => o.duration_ms, (o, v) => o.duration_ms = v);
+        }
+    }
+}

+ 177 - 0
src/Data/Migrations/M0001_Initial.vala

@@ -0,0 +1,177 @@
+using InvercargillSql.Migrations;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Creates the full ClankerSaddle schema: projects and their links,
+     * tasks with dependencies/comments/blocks, saddle registrations, and
+     * the session + command audit trail.
+     */
+    public class M0001_Initial : Migration {
+
+        public override string name { get { return "Create ClankerSaddle tables"; } }
+        public override uint64 serial { get { return 1; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        public override void up(MigrationBuilder b) {
+            b.create_table("projects", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<string>("name")
+                    .not_null();
+                t.column<string>("git_url")
+                    .not_null();
+                t.column<string>("default_branch")
+                    .default_value<string>("main");
+                t.column<string>("summary");
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("project_links", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("parent_project_id")
+                    .not_null()
+                    .references("projects", "id");
+                t.column<int64?>("linked_project_id")
+                    .not_null()
+                    .references("projects", "id");
+                t.column<string>("relationship")
+                    .not_null();
+            });
+
+            b.create_table("tasks", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("parent_task_id")
+                    .references("tasks", "id");
+                t.column<int64?>("project_id")
+                    .not_null()
+                    .references("projects", "id")
+                    .indexed();
+                t.column<string>("title")
+                    .not_null();
+                t.column<string>("description");
+                t.column<string>("status")
+                    .default_value<string>("queued")
+                    .indexed();
+                t.column<int64?>("priority")
+                    .default_value<int64?>(5);
+                t.column<bool>("is_design_task")
+                    .default_value<bool>(false);
+                t.column<string>("working_branch");
+                t.column<string>("summary");
+                t.column<DateTime?>("created_at");
+                t.column<DateTime?>("started_at");
+                t.column<DateTime?>("completed_at");
+            });
+
+            b.create_table("task_dependencies", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .references("tasks", "id")
+                    .indexed();
+                t.column<int64?>("depends_on_task_id")
+                    .not_null()
+                    .references("tasks", "id");
+            });
+
+            b.create_table("task_comments", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .references("tasks", "id")
+                    .indexed();
+                t.column<string>("author")
+                    .not_null();
+                t.column<string>("body")
+                    .not_null();
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("task_blocks", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .references("tasks", "id")
+                    .indexed();
+                t.column<string>("question")
+                    .not_null();
+                t.column<string>("answer");
+                t.column<DateTime?>("answered_at");
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("saddles", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<string>("name")
+                    .not_null();
+                t.column<string>("container_id");
+                t.column<string>("host")
+                    .not_null();
+                t.column<string>("status")
+                    .default_value<string>("offline");
+                t.column<int64?>("current_task_id")
+                    .references("tasks", "id");
+                t.column<DateTime?>("allocated_at");
+            });
+
+            b.create_table("sessions", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .references("tasks", "id")
+                    .indexed();
+                t.column<int64?>("saddle_id")
+                    .not_null()
+                    .references("saddles", "id");
+                t.column<DateTime?>("started_at");
+                t.column<DateTime?>("ended_at");
+                t.column<string>("outcome");
+            });
+
+            b.create_table("session_commands", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("session_id")
+                    .not_null()
+                    .references("sessions", "id")
+                    .indexed();
+                t.column<string>("command_text")
+                    .not_null();
+                t.column<string>("output_text");
+                t.column<int64?>("exit_code");
+                t.column<DateTime?>("started_at");
+                t.column<DateTime?>("completed_at");
+                t.column<int64?>("duration_ms");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.drop_table("session_commands");
+            b.drop_table("sessions");
+            b.drop_table("saddles");
+            b.drop_table("task_blocks");
+            b.drop_table("task_comments");
+            b.drop_table("task_dependencies");
+            b.drop_table("tasks");
+            b.drop_table("project_links");
+            b.drop_table("projects");
+        }
+    }
+}

+ 39 - 0
src/Json.vala

@@ -0,0 +1,39 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillJson;
+using Statum;
+
+namespace ClankerSaddle {
+
+    /**
+     * Helpers for shaping Statum slot state. Because Statum's GObjectMapping
+     * cannot serialise array/list fields, lists are built as a JsonArray and set
+     * on a PropertyDictionary under a key, where stm-for-{x}-in can iterate them.
+     */
+    public class StateJson : Object {
+
+        /**
+         * Builds a JSON array element from a lot of GObject row models, mapping
+         * each row via GObjectMapping. Use the result as a slot public-data value:
+         * `dict["tasks"] = StateJson.list<TaskView>(rows);`
+         */
+        public static JsonElement list<T>(Lot<T> items) throws GLib.Error {
+            var arr = new JsonArray();
+            foreach (var item in items) {
+                var props = GObjectMapping.to_properties((Object) item);
+                arr.add(new JsonElement.from_properties(props));
+            }
+            return new JsonElement.from_elements(arr);
+        }
+
+        /** Same as {@link list} but accepts a Vala array. */
+        public static JsonElement list_array<T>(T[] items) throws GLib.Error {
+            var arr = new JsonArray();
+            foreach (var item in items) {
+                var props = GObjectMapping.to_properties((Object) item);
+                arr.add(new JsonElement.from_properties(props));
+            }
+            return new JsonElement.from_elements(arr);
+        }
+    }
+}

+ 136 - 0
src/Services/Dispatcher.vala

@@ -0,0 +1,136 @@
+using Invercargill.DataStructures;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * The task dispatcher: a five-second timer that pairs ready tasks with
+     * idle saddles and keeps busy saddles' output in sync.
+     *
+     * Each tick resolves fresh request-scoped services (so no database
+     * scope is held between ticks) and never overlaps with itself: a slow
+     * tick coalesces into the next one.
+     */
+    public class Dispatcher : Object {
+
+        private Container container = inject<Container>();
+        private bool ticking = false;
+        private bool warned_no_llm = false;
+
+        /**
+         * Consecutive communication failures per saddle id. Transient
+         * errors (a pooled connection dropped mid-reuse, a momentary
+         * SQLite lock) must not tear down a running session, so a saddle
+         * is only declared unreachable after three strikes in a row.
+         */
+        private Dictionary<string, string> strikes = new Dictionary<string, string>();
+
+        /** Starts the repeating timer. */
+        public void start() {
+            Timeout.add_seconds(5, () => {
+                run_tick.begin();
+                return true;
+            });
+            print("[ClankerSaddle] Dispatcher running (5s interval)\n");
+        }
+
+        private async void run_tick() {
+            if (ticking) {
+                return;
+            }
+            ticking = true;
+            try {
+                yield tick();
+            } catch (Error e) {
+                warning("[ClankerSaddle] Dispatch tick failed: %s", e.message);
+            }
+            ticking = false;
+        }
+
+        private int strike_count(int64 saddle_id) {
+            string stored = "";
+            return strikes.try_get(saddle_id.to_string(), out stored) ? int.parse(stored) : 0;
+        }
+
+        private void record_strike(int64 saddle_id, int count) {
+            strikes.set(saddle_id.to_string(), count.to_string());
+        }
+
+        /**
+         * One dispatch cycle: for every ready task (priority order) grab an
+         * idle saddle, open a session, push the assignment, then poll busy
+         * saddles for output and status changes. Assignment pauses while
+         * {@link LlmEndpoint} is unconfigured (polling continues).
+         */
+        public async void tick() throws Error {
+            var scope = container.create_scope();
+            var orm = scope.resolve<OrmSession>();
+            var tasks = scope.resolve<TaskService>();
+            var saddles = scope.resolve<SaddleService>();
+            var projects = scope.resolve<ProjectService>();
+
+            if (LlmEndpoint.from_environment() == null) {
+                if (!warned_no_llm) {
+                    warning("[ClankerSaddle] SADDLE_LLM_BASE_URL is not set; task assignment is paused");
+                    warned_no_llm = true;
+                }
+            } else {
+                warned_no_llm = false;
+                var ready = yield tasks.get_ready_tasks();
+                foreach (var task in ready) {
+                    var saddle = yield saddles.get_idle_saddle();
+                    if (saddle == null) {
+                        break;
+                    }
+
+                    var session = new Session() {
+                        task_id = task.id,
+                        saddle_id = ((!)saddle).id,
+                        started_at = new DateTime.now_local()
+                    };
+                    yield orm.insert_async<Session>(session);
+
+                    task.status = STATUS_ASSIGNED;
+                    task.started_at = new DateTime.now_local();
+                    yield orm.update_async<TaskEntity>(task);
+                    yield saddles.update_saddle_status(((!)saddle).id, "busy", task.id);
+
+                    try {
+                        yield saddles.seed_session_cursor(((!)saddle).id, session.id);
+                        yield saddles.push_task_to_saddle(((!)saddle).id, task, session.id, projects);
+                        record_strike(((!)saddle).id, 0);
+                        print(@"[ClankerSaddle] Dispatched task $(task.id) to saddle '$(((!)saddle).name)' (session $(session.id))\n");
+                    } catch (Error e) {
+                        warning(@"[ClankerSaddle] Pushing task $(task.id) to saddle $(((!)saddle).id) failed: $(e.message)");
+                        yield abandon_saddle(((!)saddle).id, saddles);
+                    }
+                }
+            }
+
+            foreach (var saddle in yield saddles.list_busy_saddles()) {
+                try {
+                    yield saddles.poll_saddle_updates(saddle.id, tasks);
+                    record_strike(saddle.id, 0);
+                } catch (Error e) {
+                    int count = strike_count(saddle.id) + 1;
+                    record_strike(saddle.id, count);
+                    warning(@"[ClankerSaddle] Polling saddle $(saddle.id) failed (attempt $(count)/3): $(e.message)");
+                    if (count >= 3) {
+                        yield abandon_saddle(saddle.id, saddles);
+                    }
+                }
+            }
+        }
+
+        /**
+         * Declares a saddle unreachable once its failures accumulated:
+         * the task it held returns to the queue and its session closes.
+         */
+        private async void abandon_saddle(int64 saddle_id, SaddleService saddles) throws Error {
+            record_strike(saddle_id, 0);
+            yield saddles.mark_saddle_unreachable(saddle_id);
+        }
+    }
+}

+ 133 - 0
src/Services/ProjectService.vala

@@ -0,0 +1,133 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /** Valid values of {@link ProjectLink.relationship}. */
+    public const string LINK_CO_DEVELOPED = "co-developed";
+    public const string LINK_REFERENCE = "reference";
+
+    /**
+     * Project storage: CRUD over `projects` plus the directed
+     * co-developed/reference link graph in `project_links`.
+     */
+    public class ProjectService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+
+        /** Inserts a project; `summary` may be empty. */
+        public async Project create_project(string name, string git_url,
+                string default_branch, string? summary) throws Error {
+            var project = new Project() {
+                name = name,
+                git_url = git_url,
+                default_branch = default_branch.length > 0 ? default_branch : "main",
+                summary = summary != null && summary.length > 0 ? summary : null,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<Project>(project);
+            return project;
+        }
+
+        /** Loads the project identified by `id`, or null. */
+        public async Project? get_project(int64 id) throws Error {
+            return yield db.query<Project>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /** Lists every project ordered by name. */
+        public async ImmutableLot<Project> list_projects() throws Error {
+            return yield db.query<Project>()
+                .order_by(expr("name"))
+                .materialise_async();
+        }
+
+        /**
+         * Saves the editable fields of the project identified by `id`;
+         * empty arguments keep their current values. Returns the saved
+         * project, or null when no such project exists.
+         */
+        public async Project? update_project(int64 id, string? name, string? git_url,
+                string? default_branch, string? summary) throws Error {
+            var project = yield get_project(id);
+            if (project == null) {
+                return null;
+            }
+            if (name != null && name.length > 0) {
+                project.name = name;
+            }
+            if (git_url != null && git_url.length > 0) {
+                project.git_url = git_url;
+            }
+            if (default_branch != null && default_branch.length > 0) {
+                project.default_branch = default_branch;
+            }
+            if (summary != null) {
+                project.summary = summary.length > 0 ? summary : null;
+            }
+            yield db.update_async<Project>(project);
+            return project;
+        }
+
+        /**
+         * Links `linked_id` to `parent_id` with `relationship`
+         * ('co-developed' or 'reference'); self-links and duplicates are
+         * rejected.
+         */
+        public async ProjectLink add_link(int64 parent_id, int64 linked_id,
+                string relationship) throws Error {
+            if (relationship != LINK_CO_DEVELOPED && relationship != LINK_REFERENCE) {
+                throw new Error(Quark.from_string("clanker-saddle"), 1,
+                    @"Relationship must be '$(LINK_CO_DEVELOPED)' or '$(LINK_REFERENCE)'");
+            }
+            if (parent_id == linked_id) {
+                throw new Error(Quark.from_string("clanker-saddle"), 2,
+                    "A project cannot be linked to itself");
+            }
+            var parent = yield get_project(parent_id);
+            var linked = yield get_project(linked_id);
+            if (parent == null || linked == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 3,
+                    "Both projects must exist");
+            }
+            var existing = yield db.query<ProjectLink>()
+                .where(expr("parent_project_id == $0 && linked_project_id == $1",
+                    elem<int64?>(parent_id), elem<int64?>(linked_id)))
+                .materialise_async();
+            if (existing.length > 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 4,
+                    "Those projects are already linked");
+            }
+
+            var link = new ProjectLink() {
+                parent_project_id = parent_id,
+                linked_project_id = linked_id,
+                relationship = relationship
+            };
+            yield db.insert_async<ProjectLink>(link);
+            return link;
+        }
+
+        /** Deletes the link identified by `id`. */
+        public async void remove_link(int64 id) throws Error {
+            var link = yield db.query<ProjectLink>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (link != null) {
+                yield db.delete_async<ProjectLink>((!)link);
+            }
+        }
+
+        /** Lists every link whose parent is `project_id`. */
+        public async ImmutableLot<ProjectLink> list_links(int64 project_id) throws Error {
+            return yield db.query<ProjectLink>()
+                .where(expr("parent_project_id == $0", elem<int64?>(project_id)))
+                .materialise_async();
+        }
+    }
+}

+ 61 - 0
src/Services/SaddlePollState.vala

@@ -0,0 +1,61 @@
+using Invercargill.DataStructures;
+using Inversion;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * Process-wide saddle poll bookkeeping: the last daemon-side output
+     * entry id stored per session (so repeated polls never duplicate
+     * `session_commands` rows) and the block answers already handed to a
+     * saddle (each answer is delivered exactly once; a restart re-delivers
+     * and the daemon deduplicates).
+     *
+     * A singleton by necessity: {@link SaddleService} is request-scoped,
+     * so this object is where the cross-request cursors live. Ids are
+     * stringified: the Invercargill collections cannot key on value types.
+     */
+    public class SaddlePollState : Object {
+
+        private Dictionary<string, string> last_stored_output_id = new Dictionary<string, string>();
+        private HashSet<string> delivered_answers = new HashSet<string>();
+        private Mutex state_lock = Mutex();
+
+        /** The last output entry id stored for `session_id` (0 when none). */
+        public int64 since_for(int64 session_id) {
+            state_lock.lock();
+            string stored = "";
+            bool found = last_stored_output_id.try_get(session_id.to_string(), out stored);
+            state_lock.unlock();
+            return found ? int64.parse(stored) : 0;
+        }
+
+        /** Records the new high-water output entry id for `session_id`. */
+        public void store_since(int64 session_id, int64 entry_id) {
+            state_lock.lock();
+            last_stored_output_id.set(session_id.to_string(), entry_id.to_string());
+            state_lock.unlock();
+        }
+
+        /**
+         * Marks `block_id`'s answer as delivered; returns false when it
+         * had already been handed over.
+         */
+        public bool mark_delivered(int64 block_id) {
+            state_lock.lock();
+            bool already = delivered_answers.has(block_id.to_string());
+            if (!already) {
+                delivered_answers.add(block_id.to_string());
+            }
+            state_lock.unlock();
+            return !already;
+        }
+
+        /** True when `block_id`'s answer was already handed over. */
+        public bool is_delivered(int64 block_id) {
+            state_lock.lock();
+            bool already = delivered_answers.has(block_id.to_string());
+            state_lock.unlock();
+            return already;
+        }
+    }
+}

+ 734 - 0
src/Services/SaddleService.vala

@@ -0,0 +1,734 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using Soup;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /** A block answer awaiting collection by one saddle's next poll. */
+    public class PendingAnswer : Object {
+        public int64 block_id { get; set; }
+        public string answer { get; set; default = ""; }
+    }
+
+    /**
+     * The OpenAI-compatible endpoint every saddle assignment is sent to,
+     * read from the environment: `SADDLE_LLM_BASE_URL` (required — dispatch
+     * pauses without it), `SADDLE_LLM_API_KEY` and `SADDLE_LLM_MODEL`.
+     */
+    public class LlmEndpoint : Object {
+
+        public string base_url { get; set; default = ""; }
+        public string api_key { get; set; default = ""; }
+        public string model { get; set; default = ""; }
+
+        /** The configured endpoint, or null when the base URL is unset. */
+        public static LlmEndpoint? from_environment() {
+            var base_url = (Environment.get_variable("SADDLE_LLM_BASE_URL") ?? "").strip();
+            if (base_url.length == 0) {
+                return null;
+            }
+            return new LlmEndpoint() {
+                base_url = base_url,
+                api_key = (Environment.get_variable("SADDLE_LLM_API_KEY") ?? "").strip(),
+                model = (Environment.get_variable("SADDLE_LLM_MODEL") ?? "").strip()
+            };
+        }
+    }
+
+    /**
+     * Saddle registry and control-plane client: registers containers,
+     * finds idle capacity for the dispatcher, pushes task assignments to a
+     * daemon's `/start` endpoint, and polls `/status` + `/output` to keep
+     * the local audit trail in sync. Block answers are handed out exactly
+     * once per saddle poll (delivered ids are remembered in memory; a
+     * restart re-delivers and the daemon deduplicates).
+     */
+    public class SaddleService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+        private Soup.Session http = new Soup.Session();
+        private SaddlePollState poll_state = inject<SaddlePollState>();
+
+        /** Registers a saddle container; `host` is host:port of its control server. */
+        public async Saddle register_saddle(string name, string host) throws Error {
+            var saddle = new Saddle() {
+                name = name,
+                host = host,
+                status = "idle"
+            };
+            yield db.insert_async<Saddle>(saddle);
+            return saddle;
+        }
+
+        /** Removes a saddle registration; tasks it was running are re-queued. */
+        public async void unregister_saddle(int64 id) throws Error {
+            var saddle = yield get_saddle(id);
+            if (saddle == null) {
+                return;
+            }
+            if (((!)saddle).current_task_id > 0) {
+                var task = yield db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(((!)saddle).current_task_id)))
+                    .first_async();
+                if (task != null && (((!)task).status == STATUS_ASSIGNED
+                        || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED)) {
+                    ((!)task).status = STATUS_QUEUED;
+                    yield db.update_async<TaskEntity>((!)task);
+                }
+            }
+            yield db.delete_async<Saddle>((!)saddle);
+        }
+
+        /** Loads the saddle identified by `id`, or null. */
+        public async Saddle? get_saddle(int64 id) throws Error {
+            return yield db.query<Saddle>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /** Lists every saddle ordered by name. */
+        public async ImmutableLot<Saddle> list_saddles() throws Error {
+            return yield db.query<Saddle>()
+                .order_by(expr("name"))
+                .materialise_async();
+        }
+
+        /**
+         * Lists saddles with status 'busy' (the dispatcher's poll
+         * candidates).
+         */
+        public async ImmutableLot<Saddle> list_busy_saddles() throws Error {
+            return yield db.query<Saddle>()
+                .where(expr("status == $0", elem("busy")))
+                .materialise_async();
+        }
+
+        /**
+         * Updates a saddle's status and current task. A null
+         * `current_task_id` clears the assignment (and `allocated_at`).
+         */
+        public async void update_saddle_status(int64 id, string status, int64? current_task_id) throws Error {
+            var saddle = yield get_saddle(id);
+            if (saddle == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 10,
+                    @"No saddle exists with id $(id)");
+            }
+            ((!)saddle).status = status;
+            ((!)saddle).current_task_id = current_task_id != null ? ((!)current_task_id) : 0;
+            ((!)saddle).allocated_at = current_task_id != null ? new DateTime.now_local() : null;
+            yield db.update_async<Saddle>((!)saddle);
+        }
+
+        /** The first saddle with status 'idle', or null when none is free. */
+        public async Saddle? get_idle_saddle() throws Error {
+            return yield db.query<Saddle>()
+                .where(expr("status == $0", elem("idle")))
+                .order_by(expr("id"))
+                .first_async();
+        }
+
+        /**
+         * Marks a saddle offline when its control server cannot be
+         * reached, releasing any task it was holding back to 'queued' and
+         * closing that task's open session (outcome 'stopped').
+         */
+        public async void mark_saddle_unreachable(int64 id) throws Error {
+            var saddle = yield get_saddle(id);
+            if (saddle == null) {
+                return;
+            }
+            if (((!)saddle).current_task_id > 0) {
+                int64 task_id = ((!)saddle).current_task_id;
+                var task = yield db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(task_id)))
+                    .first_async();
+                if (task != null && (((!)task).status == STATUS_ASSIGNED
+                        || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED)) {
+                    ((!)task).status = STATUS_QUEUED;
+                    yield db.update_async<TaskEntity>((!)task);
+                }
+
+                var sessions = yield db.query<ClankerSaddle.Data.Session>()
+                    .where(expr("task_id == $0", elem<int64?>(task_id)))
+                    .materialise_async();
+                foreach (var session in sessions) {
+                    if (session.ended_at != null) {
+                        continue;
+                    }
+                    session.ended_at = new DateTime.now_local();
+                    session.outcome = "stopped";
+                    yield db.update_async<ClankerSaddle.Data.Session>(session);
+                }
+            }
+            ((!)saddle).status = "offline";
+            ((!)saddle).current_task_id = 0;
+            ((!)saddle).allocated_at = null;
+            yield db.update_async<Saddle>((!)saddle);
+        }
+
+        /**
+         * Pushes a task assignment to the saddle daemon's `POST /start`
+         * endpoint in its wire shape — `{task, llm_config}` — with the
+         * project, co-developed/reference links and recent comments inside
+         * the task object. Project checkouts are addressed by the
+         * `SADDLE_PROJECTS_ROOT` convention (`root/name`), the LLM endpoint
+         * by the {@link LlmEndpoint} environment. Returns the parsed JSON
+         * response body.
+         */
+        public async Json.Object push_task_to_saddle(int64 saddle_id, TaskEntity task, int64 session_id,
+                ProjectService projects) throws Error {
+            var saddle = yield get_saddle(saddle_id);
+            if (saddle == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 11,
+                    @"No saddle exists with id $(saddle_id)");
+            }
+            var endpoint = LlmEndpoint.from_environment();
+            if (endpoint == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 24,
+                    "No LLM endpoint configured (set SADDLE_LLM_BASE_URL)");
+            }
+
+            var project = yield db.query<Project>()
+                .where(expr("id == $0", elem<int64?>(task.project_id)))
+                .first_async();
+            if (project == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 12,
+                    @"Task $(task.id) has no project $(task.project_id)");
+            }
+
+            var payload = new Json.Builder();
+            payload.begin_object();
+            payload.set_member_name("task");
+            payload.begin_object();
+            payload.set_member_name("id");
+            payload.add_int_value(task.id);
+            payload.set_member_name("title");
+            payload.add_string_value(task.title);
+            payload.set_member_name("description");
+            payload.add_string_value(task.description ?? "");
+            payload.set_member_name("is_design_task");
+            payload.add_boolean_value(task.is_design_task);
+            payload.set_member_name("project_name");
+            payload.add_string_value(((!)project).name);
+            payload.set_member_name("project_branch");
+            payload.add_string_value(((!)project).default_branch);
+
+            var co_developed = new Vector<Project>();
+            var references = new Vector<Project>();
+            foreach (var link in yield projects.list_links(task.project_id)) {
+                var linked = yield db.query<Project>()
+                    .where(expr("id == $0", elem<int64?>(link.linked_project_id)))
+                    .first_async();
+                if (linked == null) {
+                    continue;
+                }
+                if (link.relationship == LINK_CO_DEVELOPED) {
+                    co_developed.add((!)linked);
+                } else if (link.relationship == LINK_REFERENCE) {
+                    references.add((!)linked);
+                }
+            }
+
+            payload.set_member_name("co_developed");
+            payload.begin_array();
+            payload.add_value(project_node(((!)project)));
+            foreach (var linked in co_developed) {
+                payload.add_value(project_node(linked));
+            }
+            payload.end_array();
+            payload.set_member_name("reference");
+            payload.begin_array();
+            foreach (var linked in references) {
+                payload.add_value(project_node(linked));
+            }
+            payload.end_array();
+
+            payload.set_member_name("comments");
+            payload.begin_array();
+            var comments = yield db.query<TaskComment>()
+                .where(expr("task_id == $0", elem<int64?>(task.id)))
+                .order_by(expr("id"))
+                .materialise_async();
+            foreach (var comment in comments) {
+                payload.begin_object();
+                payload.set_member_name("author");
+                payload.add_string_value(comment.author);
+                payload.set_member_name("body");
+                payload.add_string_value(comment.body);
+                payload.end_object();
+            }
+            payload.end_array();
+            payload.end_object();
+
+            payload.set_member_name("llm_config");
+            payload.begin_object();
+            payload.set_member_name("base_url");
+            payload.add_string_value(((!)endpoint).base_url);
+            payload.set_member_name("api_key");
+            payload.add_string_value(((!)endpoint).api_key);
+            payload.set_member_name("model");
+            payload.add_string_value(((!)endpoint).model);
+            payload.end_object();
+            payload.end_object();
+
+            return yield request_saddle_json(((!)saddle).host, "POST", "/start",
+                (owned) payload);
+        }
+
+        /**
+         * Seeds the output cursor of a not-yet-pushed session from the
+         * daemon's current `latest_entry_id`. The daemon's entry log is
+         * global across its sessions, so this baseline — read before the
+         * `POST /start` push, when every logged entry belongs to earlier
+         * sessions — keeps prior sessions' entries out of the new
+         * session's audit trail and its domain events.
+         */
+        public async void seed_session_cursor(int64 saddle_id, int64 session_id) throws Error {
+            var saddle = yield get_saddle(saddle_id);
+            if (saddle == null) {
+                return;
+            }
+            var status = yield request_saddle_json(((!)saddle).host, "GET", "/status", null);
+            int64 baseline = status.has_member("latest_entry_id")
+                && status.get_member("latest_entry_id").get_value_type() == typeof(int64)
+                ? status.get_int_member("latest_entry_id") : 0;
+            poll_state.store_since(session_id, baseline);
+        }
+
+        /**
+         * Polls a busy saddle: stores new entries reported by the daemon's
+         * `GET /output` as `session_commands` rows of the open session and
+         * projects the agent's `saddle comment/subtask/done` events into
+         * their domain tables, then applies status changes from
+         * `GET /status` — done/failed complete or fail the task, blocked
+         * raises the daemon's question as an answerable block and delivers
+         * any human answers to the daemon's `POST /answer`. Returns true
+         * when the saddle reported the task finished; the status pass is
+         * skipped once the entries already finished the task.
+         */
+        public async bool poll_saddle_updates(int64 saddle_id, TaskService tasks) throws Error {
+            var saddle = yield get_saddle(saddle_id);
+            if (saddle == null || ((!)saddle).current_task_id == 0) {
+                return false;
+            }
+            int64 task_id = ((!)saddle).current_task_id;
+
+            var session = yield tasks.open_session_for_task(task_id);
+            if (session != null) {
+                var fresh = yield store_new_output(saddle_id, ((!)session).id);
+                if (yield apply_entry_events(task_id, fresh, tasks)) {
+                    return true;
+                }
+            }
+
+            var status = yield request_saddle_json(((!)saddle).host, "GET", "/status", null);
+            string state = status.has_member("state") ? status.get_string_member("state") : "";
+            switch (state) {
+                case "done":
+                    yield tasks.complete_task(task_id,
+                        status.has_member("summary") ? status.get_string_member("summary") : null,
+                        null, "done");
+                    return true;
+                case "failed":
+                    yield tasks.fail_task(task_id, null);
+                    return true;
+                case "blocked":
+                    string question = string_member(status, "block_question");
+                    if (question.length > 0) {
+                        yield ensure_block(tasks, task_id, question);
+                        yield deliver_answered_blocks((!)saddle, task_id, question);
+                    }
+                    yield tasks.update_task(task_id, null, null, null, STATUS_BLOCKED);
+                    break;
+                case "working":
+                    var task = yield tasks.update_task(task_id, null, null, null, STATUS_WORKING);
+                    if (task != null && ((!)task).started_at == null) {
+                        ((!)task).started_at = new DateTime.now_local();
+                        yield db.update_async<TaskEntity>((!)task);
+                    }
+                    break;
+                case "idle":
+                    // The daemon stopped without reporting done: release the
+                    // saddle and re-queue the task for another attempt.
+                    yield tasks.requeue_task(task_id);
+                    break;
+                default:
+                    break;
+            }
+            return false;
+        }
+
+        /**
+         * Probes a saddle's `GET /status` and refreshes its registered
+         * status: 'idle' or 'busy' from the daemon state, 'offline' when
+         * the daemon cannot be reached. The current task assignment is
+         * left untouched.
+         */
+        public async void refresh_saddle_status(int64 id) throws Error {
+            var saddle = yield get_saddle(id);
+            if (saddle == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 17,
+                    @"No saddle exists with id $(id)");
+            }
+            try {
+                var status = yield request_saddle_json(((!)saddle).host, "GET", "/status", null);
+                string state = status.has_member("state") ? status.get_string_member_with_default("state", "") : "";
+                ((!)saddle).status = state == "idle" ? "idle" : "busy";
+            } catch (Error e) {
+                ((!)saddle).status = "offline";
+            }
+            yield db.update_async<Saddle>((!)saddle);
+        }
+
+        /**
+         * Answered blocks of the saddle's current task not yet handed over
+         * the poll endpoint; marks them delivered.
+         */
+        public async ImmutableLot<PendingAnswer> take_pending_answers(int64 saddle_id) throws Error {
+            var saddle = yield get_saddle(saddle_id);
+            if (saddle == null || ((!)saddle).current_task_id == 0) {
+                return new Vector<PendingAnswer>().to_immutable_buffer();
+            }
+
+            var blocks = yield db.query<TaskBlock>()
+                .where(expr("task_id == $0", elem<int64?>(((!)saddle).current_task_id)))
+                .materialise_async();
+            var rows = new Vector<PendingAnswer>();
+            foreach (var block in blocks) {
+                if (block.answer == null) {
+                    continue;
+                }
+                if (!poll_state.mark_delivered(block.id)) {
+                    continue;
+                }
+                rows.add(new PendingAnswer() {
+                    block_id = block.id,
+                    answer = ((!)block).answer ?? ""
+                });
+            }
+            return rows.to_immutable_buffer();
+        }
+
+        /**
+         * Stores entries newly reported by the daemon's `GET /output`
+         * (a bare JSON array of `{id, command, output, exit_code, type,
+         * timestamp}` objects) as `session_commands` rows, advancing the
+         * session's cursor in {@link SaddlePollState}. Returns the entries
+         * stored, oldest first, for {@link apply_entry_events}.
+         */
+        private async Vector<Json.Object> store_new_output(int64 saddle_id, int64 session_id) throws Error {
+            var stored = new Vector<Json.Object>();
+            var saddle = yield get_saddle(saddle_id);
+            if (saddle == null) {
+                return stored;
+            }
+
+            int64 since = poll_state.since_for(session_id);
+            Json.Node? root = null;
+            int status_code = yield request_saddle(((!)saddle).host, "GET",
+                @"/output?since=$(since)", null, out root);
+            if (status_code >= 400 || root == null || root.get_node_type() != Json.NodeType.ARRAY) {
+                throw new Error(Quark.from_string("clanker-saddle"), 15,
+                    @"Saddle output request returned HTTP $(status_code)");
+            }
+
+            foreach (var element in root.get_array().get_elements()) {
+                if (element.get_node_type() != Json.NodeType.OBJECT) {
+                    continue;
+                }
+                var entry = element.get_object();
+                int64 entry_id = entry.has_member("id") ? entry.get_int_member("id") : 0;
+                if (entry_id <= since) {
+                    continue;
+                }
+                var command = new SessionCommand() {
+                    session_id = session_id,
+                    command_text = string_member(entry, "command"),
+                    output_text = entry.has_member("output") && entry.get_member("output").get_string() != null
+                        ? entry.get_string_member("output") : null,
+                    exit_code = entry.has_member("exit_code") && entry.get_member("exit_code").get_value_type() == typeof(int64)
+                        ? entry.get_int_member("exit_code") : 0,
+                    started_at = parse_iso(entry, "started_at") ?? parse_iso(entry, "timestamp"),
+                    completed_at = parse_iso(entry, "completed_at"),
+                    duration_ms = entry.has_member("duration_ms") && entry.get_member("duration_ms").get_value_type() == typeof(int64)
+                        ? entry.get_int_member("duration_ms") : 0
+                };
+                yield db.insert_async<SessionCommand>(command);
+                stored.add(entry);
+                since = entry_id;
+            }
+            poll_state.store_since(session_id, since);
+            return stored;
+        }
+
+        /**
+         * Projects the agent's conversation-level events onto the task:
+         * `saddle comment` lines become bot comments, `saddle subtask`
+         * lines create child tasks, and the `saddle done` entry completes
+         * the task with the summary the agent gave. Returns true once a
+         * done event finished the task.
+         */
+        private async bool apply_entry_events(int64 task_id, Vector<Json.Object> entries,
+                TaskService tasks) throws Error {
+            foreach (var entry in entries) {
+                string command = string_member(entry, "command");
+                switch (string_member(entry, "type")) {
+                    case "comment":
+                        string body = strip_quotes(strip_verb(command, "saddle comment"));
+                        if (body.length > 0) {
+                            yield tasks.add_comment(task_id, "bot", body);
+                        }
+                        break;
+                    case "subtask":
+                        string[] operands = split_operands(strip_verb(command, "saddle subtask"));
+                        if (operands.length >= 1 && operands[0].length > 0) {
+                            yield tasks.create_subtask(task_id, operands[0],
+                                operands.length >= 2 ? operands[1] : null);
+                        }
+                        break;
+                    case "done":
+                        yield tasks.complete_task(task_id,
+                            strip_quotes(strip_verb(command, "saddle done")), null, "done");
+                        return true;
+                    default:
+                        break;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * Raises the daemon's current blocking question as a `task_blocks`
+         * row exactly once: a not-yet-answered row, or an answered row
+         * whose answer has not been delivered to the daemon yet, means the
+         * question is already represented.
+         */
+        private async void ensure_block(TaskService tasks, int64 task_id, string question) throws Error {
+            foreach (var block in yield tasks.list_blocks(task_id)) {
+                if (block.question != question) {
+                    continue;
+                }
+                if (block.answer == null || !poll_state.is_delivered(block.id)) {
+                    return;
+                }
+            }
+            yield tasks.add_block(task_id, question);
+        }
+
+        /**
+         * Delivers human answers of the daemon's current question to its
+         * `POST /answer` endpoint. Delivery is marked in
+         * {@link SaddlePollState} once the daemon accepts it (200) or has
+         * nobody waiting (409 — the session moved on), so each answer is
+         * pushed exactly once.
+         */
+        private async void deliver_answered_blocks(Saddle saddle, int64 task_id, string question) throws Error {
+            foreach (var block in yield db.query<TaskBlock>()
+                    .where(expr("task_id == $0", elem<int64?>(task_id)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (block.answer == null || block.question != question
+                        || poll_state.is_delivered(block.id)) {
+                    continue;
+                }
+                var payload = new Json.Builder();
+                payload.begin_object();
+                payload.set_member_name("answer");
+                payload.add_string_value(((!)block).answer ?? "");
+                payload.end_object();
+                Json.Node? root = null;
+                int status_code = yield request_saddle(saddle.host, "POST", "/answer",
+                    (owned) payload, out root);
+                if (status_code != 200 && status_code != 409) {
+                    throw new Error(Quark.from_string("clanker-saddle"), 15,
+                        @"Saddle answer delivery returned HTTP $(status_code)");
+                }
+                poll_state.mark_delivered(block.id);
+            }
+        }
+
+        /**
+         * Performs an HTTP request against a saddle daemon and parses the
+         * response body as JSON; transport failures propagate to the
+         * caller (the dispatcher marks the saddle unreachable). Transport
+         * blips — typically a pooled keep-alive connection closed by the
+         * daemon at the same instant the request reused it — are retried
+         * once before giving up. Unlike {@link request_saddle_json} the
+         * HTTP status is returned rather than thrown, and array bodies
+         * parse too.
+         */
+        private async int request_saddle(string host, string method, string path,
+                owned Json.Builder? payload, out Json.Node? root) throws Error {
+            root = null;
+            GLib.Error? failure = null;
+            for (var attempt = 0; attempt < 2; attempt++) {
+                try {
+                    root = null;
+                    int status = yield request_saddle_once(host, method, path, payload, out root);
+                    return status;
+                } catch (Error e) {
+                    failure = e;
+                }
+                if (((!)failure).code != 13 && ((!)failure).code != 14) {
+                    break;
+                }
+                GLib.Timeout.add(250, request_saddle.callback);
+                yield;
+            }
+            throw (!)failure;
+        }
+
+        private async int request_saddle_once(string host, string method, string path,
+                Json.Builder? payload, out Json.Node? root) throws Error {
+            root = null;
+            string base_url = host.has_prefix("http") ? host : @"http://$(host)";
+            var message = new Soup.Message(method, @"$(base_url)$(path)");
+            if (payload != null) {
+                var generator = new Json.Generator();
+                generator.set_root(((!)payload).get_root());
+                message.set_request_body_from_bytes("application/json",
+                    new Bytes(generator.to_data(null).data));
+            }
+
+            InputStream input;
+            try {
+                input = yield http.send_async(message, Priority.DEFAULT, null);
+            } catch (Error e) {
+                throw new Error(Quark.from_string("clanker-saddle"), 13,
+                    @"Saddle request to $(base_url)$(path) failed: $(e.message)");
+            }
+
+            var output = new MemoryOutputStream.resizable();
+            try {
+                yield output.splice_async(input, OutputStreamSpliceFlags.CLOSE_SOURCE,
+                    Priority.DEFAULT, null);
+                yield output.close_async(Priority.DEFAULT, null);
+            } catch (Error e) {
+                throw new Error(Quark.from_string("clanker-saddle"), 14,
+                    @"Failed reading saddle response: $(e.message)");
+            }
+
+            uint8[] data = output.steal_data();
+            // steal_data's array length is -1; the buffer itself is
+            // NUL-terminated, so read it as a string directly.
+            string response_text = data != null && data[0] != '\0' ? (string) data : "{}";
+
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(response_text);
+            } catch (Error e) {
+                throw new Error(Quark.from_string("clanker-saddle"), 16,
+                    @"Saddle returned invalid JSON: $(e.message)");
+            }
+            root = parser.get_root();
+            return (int) message.status_code;
+        }
+
+        /**
+         * Performs an HTTP request against a saddle daemon and returns the
+         * parsed JSON object body (an empty object for non-object bodies);
+         * HTTP statuses of 400 and above throw.
+         */
+        private async Json.Object request_saddle_json(string host, string method, string path,
+                owned Json.Builder? payload) throws Error {
+            Json.Node? root = null;
+            int status_code = yield request_saddle(host, method, path, (owned) payload, out root);
+            if (status_code >= 400) {
+                throw new Error(Quark.from_string("clanker-saddle"), 15,
+                    @"Saddle request returned HTTP $(status_code)");
+            }
+            if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                return new Json.Object();
+            }
+            return root.get_object();
+        }
+
+        /**
+         * A `{"name", "path", "branch"}` project reference in the saddle
+         * daemon's wire shape: the checkout lives at
+         * `SADDLE_PROJECTS_ROOT/name` inside the saddle's container.
+         */
+        private static Json.Node project_node(Project project) {
+            var root_var = (Environment.get_variable("SADDLE_PROJECTS_ROOT") ?? "").strip();
+            string projects_root = root_var.length > 0 ? root_var : "/workspace";
+            var builder = new Json.Builder();
+            builder.begin_object();
+            builder.set_member_name("name");
+            builder.add_string_value(project.name);
+            builder.set_member_name("path");
+            builder.add_string_value(@"$(projects_root)/$(project.name)");
+            builder.set_member_name("branch");
+            builder.add_string_value(project.default_branch);
+            builder.end_object();
+            return builder.get_root();
+        }
+
+        /** `command` minus a leading `verb` and its separating whitespace. */
+        private static string strip_verb(string command, string verb) {
+            var trimmed = command.strip();
+            if (trimmed == verb) {
+                return "";
+            }
+            return trimmed.has_prefix(verb + " ") ? trimmed.substring(verb.length + 1).strip() : trimmed;
+        }
+
+        /** Splits operands on whitespace honouring double quotes. */
+        private static string[] split_operands(string args) {
+            var operands = new Vector<string>();
+            var current = new StringBuilder();
+            bool in_operand = false;
+            bool quoted = false;
+            for (var index = 0; index < args.length; index++) {
+                char character = args[index];
+                if (character == '"') {
+                    quoted = true;
+                    in_operand = true;
+                    continue;
+                }
+                if (!quoted && character.isspace()) {
+                    if (in_operand) {
+                        operands.add(current.str);
+                        current = new StringBuilder();
+                        in_operand = false;
+                        quoted = false;
+                    }
+                    continue;
+                }
+                current.append_c(character);
+                in_operand = true;
+            }
+            if (in_operand) {
+                operands.add(current.str);
+            }
+            return operands.to_array();
+        }
+
+        /** Strips one layer of matching double quotes, when present. */
+        private static string strip_quotes(string text) {
+            if (text.length >= 2 && text.has_prefix("\"") && text.has_suffix("\"")) {
+                return text.substring(1, text.length - 2);
+            }
+            return text;
+        }
+
+        private static string string_member(Json.Object obj, string name) {
+            return obj.has_member(name) && obj.get_member(name).get_string() != null
+                ? obj.get_string_member(name) : "";
+        }
+
+        private static DateTime? parse_iso(Json.Object obj, string name) {
+            if (!obj.has_member(name)) {
+                return null;
+            }
+            var value = obj.get_member(name);
+            if (value.get_node_type() != Json.NodeType.VALUE || value.get_string() == null) {
+                return null;
+            }
+            return new DateTime.from_iso8601(value.get_string(), new TimeZone.utc());
+        }
+    }
+}

+ 463 - 0
src/Services/TaskService.vala

@@ -0,0 +1,463 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /** Valid task statuses (the `tasks.status` wire values). */
+    public const string STATUS_QUEUED = "queued";
+    public const string STATUS_ASSIGNED = "assigned";
+    public const string STATUS_WORKING = "working";
+    public const string STATUS_BLOCKED = "blocked";
+    public const string STATUS_DONE = "done";
+    public const string STATUS_FAILED = "failed";
+
+    /**
+     * The full task aggregate loaded by {@link TaskService.get_task}: the
+     * task itself plus its comments, blocks, child tasks, dependencies and
+     * session history.
+     */
+    public class TaskDetail : Object {
+        public TaskEntity task { get; set; }
+        public ImmutableLot<TaskComment> comments { get; set; }
+        public ImmutableLot<TaskBlock> blocks { get; set; }
+        public ImmutableLot<TaskEntity> subtasks { get; set; }
+        public ImmutableLot<TaskDependency> dependencies { get; set; }
+        public ImmutableLot<Session> sessions { get; set; }
+    }
+
+    /**
+     * Task storage and lifecycle: creation, dependency-aware readiness,
+     * agent interaction primitives (comments, blocks, subtasks) and
+     * completion. Saddle bookkeeping rows are kept consistent on every
+     * terminal transition.
+     */
+    public class TaskService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+
+        /**
+         * Inserts a task (status 'queued'). `parent_task_id` and
+         * `description` may be null; priority defaults to 5 (1 is highest).
+         */
+        public async TaskEntity create_task(int64 project_id, string title, string? description,
+                int64 priority, bool is_design_task, int64? parent_task_id) throws Error {
+            var task = new TaskEntity() {
+                parent_task_id = parent_task_id != null ? ((!)parent_task_id) : 0,
+                project_id = project_id,
+                title = title,
+                description = description != null && description.length > 0 ? description : null,
+                priority = priority.clamp(1, 9),
+                is_design_task = is_design_task,
+                status = STATUS_QUEUED,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<TaskEntity>(task);
+            return task;
+        }
+
+        /**
+         * Loads the full aggregate for `id`: the task, its comments, blocks,
+         * child tasks, dependencies and sessions. Returns null when the task
+         * does not exist.
+         */
+        public async TaskDetail? get_task(int64 id) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (task == null) {
+                return null;
+            }
+            return new TaskDetail() {
+                task = (!)task,
+                comments = yield list_comments(id),
+                blocks = yield list_blocks(id),
+                subtasks = yield db.query<TaskEntity>()
+                    .where(expr("parent_task_id == $0", elem<int64?>(id)))
+                    .order_by(expr("id"))
+                    .materialise_async(),
+                dependencies = yield db.query<TaskDependency>()
+                    .where(expr("task_id == $0", elem<int64?>(id)))
+                    .materialise_async(),
+                sessions = yield db.query<Session>()
+                    .where(expr("task_id == $0", elem<int64?>(id)))
+                    .order_by(expr("id"))
+                    .materialise_async()
+            };
+        }
+
+        /**
+         * Lists tasks, optionally filtered by status and/or project, ordered
+         * by priority (1 first) then creation time.
+         */
+        public async ImmutableLot<TaskEntity> list_tasks(string? status, int64? project_id) throws Error {
+            var tasks = yield db.query<TaskEntity>().materialise_async();
+            var rows = new Vector<TaskEntity>();
+            foreach (var task in tasks) {
+                if (status != null && status.length > 0 && task.status != status) {
+                    continue;
+                }
+                if (project_id != null && task.project_id != project_id) {
+                    continue;
+                }
+                rows.add(task);
+            }
+            var sorted = rows.sort((a, b) => {
+                if (a.priority != b.priority) {
+                    return (int)(a.priority - b.priority);
+                }
+                int64 a_created = a.created_at != null ? ((!)a.created_at).to_unix() : 0;
+                int64 b_created = b.created_at != null ? ((!)b.created_at).to_unix() : 0;
+                return (int)(a_created - b_created);
+            });
+            return sorted.to_immutable_buffer();
+        }
+
+        /**
+         * Saves the editable fields of the task identified by `id`; null
+         * arguments keep their current values. Returns null when the task
+         * does not exist.
+         */        public async TaskEntity? update_task(int64 id, string? title, string? description,
+                int64? priority, string? status) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (task == null) {
+                return null;
+            }
+            if (title != null && title.length > 0) {
+                task.title = title;
+            }
+            if (description != null) {
+                task.description = description.length > 0 ? description : null;
+            }
+            if (priority != null) {
+                task.priority = ((!)priority).clamp(1, 9);
+            }
+            if (status != null && status.length > 0) {
+                task.status = status;
+                if (status == STATUS_DONE && task.completed_at == null) {
+                    task.completed_at = new DateTime.now_local();
+                }
+            }
+            yield db.update_async<TaskEntity>(task);
+            return task;
+        }
+
+        /** Appends a comment; `author` is 'bot' or 'user:<name>'. */
+        public async TaskComment add_comment(int64 task_id, string author, string body) throws Error {
+            var comment = new TaskComment() {
+                task_id = task_id,
+                author = author,
+                body = body,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<TaskComment>(comment);
+            return comment;
+        }
+
+        /**
+         * Raises a blocking question on a task and flips it to 'blocked'
+         * while it waits for a human answer. Returns the new block's id.
+         */
+        public async int64 add_block(int64 task_id, string question) throws Error {
+            var block = new TaskBlock() {
+                task_id = task_id,
+                question = question,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<TaskBlock>(block);
+
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task != null && (((!)task).status == STATUS_ASSIGNED || ((!)task).status == STATUS_WORKING)) {
+                ((!)task).status = STATUS_BLOCKED;
+                yield db.update_async<TaskEntity>((!)task);
+            }
+            return block.id;
+        }
+
+        /**
+         * Records a human's answer to a block and un-blocks the task (back
+         * to 'working') when it was waiting on it. The saddle collects the
+         * answer via the poll endpoint.
+         */
+        public async void answer_block(int64 block_id, string answer) throws Error {
+            var block = yield db.query<TaskBlock>()
+                .where(expr("id == $0", elem<int64?>(block_id)))
+                .first_async();
+            if (block == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 5,
+                    @"No block exists with id $(block_id)");
+            }
+
+            ((!)block).answer = answer;
+            ((!)block).answered_at = new DateTime.now_local();
+            yield db.update_async<TaskBlock>((!)block);
+
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(((!)block).task_id)))
+                .first_async();
+            if (task != null && ((!)task).status == STATUS_BLOCKED) {
+                ((!)task).status = STATUS_WORKING;
+                yield db.update_async<TaskEntity>((!)task);
+            }
+        }
+
+        /** Records that `task_id` must not start until `depends_on_task_id` is done. */
+        public async void add_dependency(int64 task_id, int64 depends_on_task_id) throws Error {
+            if (task_id == depends_on_task_id) {
+                throw new Error(Quark.from_string("clanker-saddle"), 6,
+                    "A task cannot depend on itself");
+            }
+            var existing = yield db.query<TaskDependency>()
+                .where(expr("task_id == $0 && depends_on_task_id == $1",
+                    elem<int64?>(task_id), elem<int64?>(depends_on_task_id)))
+                .materialise_async();
+            if (existing.length > 0) {
+                return;
+            }
+            var dependency = new TaskDependency() {
+                task_id = task_id,
+                depends_on_task_id = depends_on_task_id
+            };
+            yield db.insert_async<TaskDependency>(dependency);
+        }
+
+        /** Creates a child task under `parent_task_id` (status 'queued'). */
+        public async TaskEntity create_subtask(int64 parent_task_id, string title,
+                string? description) throws Error {
+            var parent = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(parent_task_id)))
+                .first_async();
+            if (parent == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 7,
+                    @"No task exists with id $(parent_task_id)");
+            }
+            return yield create_task(((!)parent).project_id, title, description,
+                ((!)parent).priority, false, parent_task_id);
+        }
+
+        /**
+         * Marks a task done: records `summary` and `working_branch`, stamps
+         * `completed_at`, closes any open session (outcome `outcome`) and
+         * frees the saddle that was running it.
+         */
+        public async void complete_task(int64 task_id, string? summary,
+                string? working_branch, string outcome) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 8,
+                    @"No task exists with id $(task_id)");
+            }
+
+            ((!)task).status = STATUS_DONE;
+            ((!)task).completed_at = new DateTime.now_local();
+            if (summary != null && summary.length > 0) {
+                ((!)task).summary = summary;
+            }
+            if (working_branch != null && working_branch.length > 0) {
+                ((!)task).working_branch = working_branch;
+            }
+            yield db.update_async<TaskEntity>((!)task);
+
+            yield close_open_sessions(task_id, outcome);
+            yield free_saddles_running(task_id);
+        }
+
+        /**
+         * Cancels a task: status 'failed' with a cancellation summary. Any
+         * open session is closed with outcome 'stopped' and its saddle
+         * freed.
+         */
+        public async void cancel_task(int64 task_id) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 9,
+                    @"No task exists with id $(task_id)");
+            }
+
+            ((!)task).status = STATUS_FAILED;
+            ((!)task).completed_at = new DateTime.now_local();
+            if (((!)task).summary == null || ((!)task).summary.length == 0) {
+                ((!)task).summary = "Cancelled by user";
+            }
+            yield db.update_async<TaskEntity>((!)task);
+
+            yield close_open_sessions(task_id, "stopped");
+            yield free_saddles_running(task_id);
+        }
+
+        /**
+         * Records a saddle-reported failure: status 'failed' with the
+         * daemon's message as summary, the open session closed with
+         * outcome 'failed' and its saddle freed.
+         */
+        public async void fail_task(int64 task_id, string? summary) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 25,
+                    @"No task exists with id $(task_id)");
+            }
+
+            ((!)task).status = STATUS_FAILED;
+            ((!)task).completed_at = new DateTime.now_local();
+            if (summary != null && summary.length > 0) {
+                ((!)task).summary = summary;
+            } else if (((!)task).summary == null || ((!)task).summary.length == 0) {
+                ((!)task).summary = "Saddle reported a failure";
+            }
+            yield db.update_async<TaskEntity>((!)task);
+
+            yield close_open_sessions(task_id, "failed");
+            yield free_saddles_running(task_id);
+        }
+
+        /**
+         * Returns a non-terminal task to the queue: closes its open
+         * sessions (outcome 'stopped') and frees the saddle that was
+         * running it.
+         */
+        public async void requeue_task(int64 task_id) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task != null && (((!)task).status == STATUS_ASSIGNED
+                    || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED)) {
+                ((!)task).status = STATUS_QUEUED;
+                yield db.update_async<TaskEntity>((!)task);
+            }
+            yield close_open_sessions(task_id, "stopped");
+            yield free_saddles_running(task_id);
+        }
+
+        /**
+         * Queued tasks whose dependencies are all done, ordered by priority
+         * (1 first) then creation time — the dispatcher's candidate list.
+         */
+        public async ImmutableLot<TaskEntity> get_ready_tasks() throws Error {
+            var queued = yield db.query<TaskEntity>()
+                .where(expr("status == $0", elem(STATUS_QUEUED)))
+                .materialise_async();
+            var all_tasks = yield db.query<TaskEntity>().materialise_async();
+            var dependencies = yield db.query<TaskDependency>().materialise_async();
+
+            var rows = new Vector<TaskEntity>();
+            foreach (var task in queued) {
+                bool ready = true;
+                foreach (var dep in dependencies) {
+                    if (dep.task_id != task.id) {
+                        continue;
+                    }
+                    bool dep_done = false;
+                    foreach (var other in all_tasks) {
+                        if (other.id == dep.depends_on_task_id && other.status == STATUS_DONE) {
+                            dep_done = true;
+                            break;
+                        }
+                    }
+                    if (!dep_done) {
+                        ready = false;
+                        break;
+                    }
+                }
+                if (ready) {
+                    rows.add(task);
+                }
+            }
+
+            var sorted = rows.sort((a, b) => {
+                if (a.priority != b.priority) {
+                    return (int)(a.priority - b.priority);
+                }
+                int64 a_created = a.created_at != null ? ((!)a.created_at).to_unix() : 0;
+                int64 b_created = b.created_at != null ? ((!)b.created_at).to_unix() : 0;
+                return (int)(a_created - b_created);
+            });
+            return sorted.to_immutable_buffer();
+        }
+
+        /** Comments on a task, oldest first. */
+        public async ImmutableLot<TaskComment> list_comments(int64 task_id) throws Error {
+            return yield db.query<TaskComment>()
+                .where(expr("task_id == $0", elem<int64?>(task_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /** Blocks on a task, oldest first. */
+        public async ImmutableLot<TaskBlock> list_blocks(int64 task_id) throws Error {
+            return yield db.query<TaskBlock>()
+                .where(expr("task_id == $0", elem<int64?>(task_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /** Every dependency edge in the graph (for queue annotations). */
+        public async ImmutableLot<TaskDependency> list_all_dependencies() throws Error {
+            return yield db.query<TaskDependency>().materialise_async();
+        }
+
+        /** All commands recorded against a session, oldest first. */
+        public async ImmutableLot<SessionCommand> list_commands(int64 session_id) throws Error {
+            return yield db.query<SessionCommand>()
+                .where(expr("session_id == $0", elem<int64?>(session_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * The session still open for `task_id` (no `ended_at`), or null.
+         * The null check runs in Vala: the expression layer cannot compare
+         * columns against null.
+         */
+        public async Session? open_session_for_task(int64 task_id) throws Error {
+            var sessions = yield db.query<Session>()
+                .where(expr("task_id == $0", elem<int64?>(task_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+            foreach (var session in sessions) {
+                if (session.ended_at == null) {
+                    return session;
+                }
+            }
+            return null;
+        }
+
+        private async void close_open_sessions(int64 task_id, string outcome) throws Error {
+            var sessions = yield db.query<Session>()
+                .where(expr("task_id == $0", elem<int64?>(task_id)))
+                .materialise_async();
+            foreach (var session in sessions) {
+                if (session.ended_at != null) {
+                    continue;
+                }
+                session.ended_at = new DateTime.now_local();
+                session.outcome = outcome;
+                yield db.update_async<Session>(session);
+            }
+        }
+
+        private async void free_saddles_running(int64 task_id) throws Error {
+            var saddles = yield db.query<Saddle>()
+                .where(expr("current_task_id == $0", elem<int64?>(task_id)))
+                .materialise_async();
+            foreach (var saddle in saddles) {
+                saddle.status = "idle";
+                saddle.current_task_id = 0;
+                saddle.allocated_at = null;
+                yield db.update_async<Saddle>(saddle);
+            }
+        }
+    }
+}

+ 37 - 0
src/actions/Forms.vala

@@ -0,0 +1,37 @@
+using Statum;
+
+namespace ClankerSaddle {
+
+    /**
+     * Form-reading helpers shared by the app's actions: a missing form or
+     * field is an empty value, and empty optional fields read as null
+     * (meaning "leave unchanged").
+     */
+    public class Forms : Object {
+
+        /** The trimmed value of form field `name`, or "" when absent. */
+        public static string field(StatumRequest request, string name) {
+            return request.form != null ? (request.form.get_field(name) ?? "").strip() : "";
+        }
+
+        /** The raw (untrimmed) value of form field `name`, or "". */
+        public static string field_raw(StatumRequest request, string name) {
+            return request.form != null ? (request.form.get_field(name) ?? "") : "";
+        }
+
+        /**
+         * The trimmed value of form field `name` as a nullable: null when
+         * the form or field is absent or empty.
+         */
+        public static string? optional(StatumRequest request, string name) {
+            var value = field(request, name);
+            return value.length > 0 ? value : null;
+        }
+
+        /** The integer value of form field `name`, or `fallback`. */
+        public static int64 integer(StatumRequest request, string name, int64 fallback) {
+            var value = field(request, name);
+            return value.length > 0 ? int64.parse(value) : fallback;
+        }
+    }
+}

+ 13 - 0
src/actions/LoginAction.vala

@@ -0,0 +1,13 @@
+using Statum;
+
+namespace ClankerSaddle {
+
+    /**
+     * The shipped {@link Spry.Actions.LoginAction} with this app's
+     * post-login destination: the dashboard.
+     */
+    public class LoginAction : Spry.Actions.LoginAction {
+
+        protected override string landing_uri { get { return "/"; } }
+    }
+}

+ 154 - 0
src/actions/ProjectActions.vala

@@ -0,0 +1,154 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed project id carried by the project actions. */
+    public class ProjectActionPrivate : Object {
+        public int64 project_id { get; set; }
+    }
+
+    /** Sealed link identity carried by {@link RemoveProjectLinkAction}. */
+    public class ProjectLinkActionPrivate : Object {
+        public int64 project_id { get; set; }
+        public int64 link_id { get; set; }
+    }
+
+    /**
+     * Creates a project from the projects form (name and git URL
+     * required); default_branch falls back to 'main'.
+     */
+    public class CreateProjectAction : StatumAction {
+
+        private ProjectService projects = inject<ProjectService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string name = Forms.field(request, "name");
+            string git_url = Forms.field(request, "git_url");
+            if (name.length == 0 || git_url.length == 0) {
+                return directives().notify("error", "Name and git URL are required").navigate("/projects");
+            }
+
+            try {
+                var project = yield projects.create_project(name, git_url,
+                    Forms.field(request, "default_branch"), Forms.field(request, "summary"));
+                return directives().notify("success", @"Project '$(project.name)' created")
+                    .navigate(@"/projects/$(project.id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not create the project: $(e.message)").navigate("/projects");
+            }
+        }
+    }
+
+    /**
+     * Saves the editable fields of a project; the id comes from the sealed
+     * {@link ProjectActionPrivate} blob (the hidden form field is a
+     * fallback). An empty summary clears the stored one.
+     */
+    public class UpdateProjectAction : TypedStatumAction<ProjectActionPrivate> {
+
+        private ProjectService projects = inject<ProjectService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
+            if (project_id <= 0) {
+                return directives().navigate("/projects");
+            }
+
+            try {
+                yield projects.update_project(project_id,
+                    Forms.optional(request, "name"), Forms.optional(request, "git_url"),
+                    Forms.optional(request, "default_branch"),
+                    request.form != null ? Forms.field(request, "summary") : null);
+                return directives().notify("success", "Project saved")
+                    .navigate(@"/projects/$(project_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the project: $(e.message)")
+                    .navigate(@"/projects/$(project_id)");
+            }
+        }
+    }
+
+    /**
+     * Links another project to this one; the parent id is sealed, the
+     * target and relationship come from the form selects.
+     */
+    public class AddProjectLinkAction : TypedStatumAction<ProjectActionPrivate> {
+
+        private ProjectService projects = inject<ProjectService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
+            int64 linked_id = Forms.integer(request, "linked_project_id", 0);
+            string relationship = Forms.field(request, "relationship");
+            if (project_id <= 0 || linked_id <= 0) {
+                return directives().notify("error", "Pick a project to link").navigate(@"/projects/$(project_id)");
+            }
+
+            try {
+                yield projects.add_link(project_id, linked_id, relationship);
+                return directives().notify("success", "Link added").navigate(@"/projects/$(project_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not add the link: $(e.message)")
+                    .navigate(@"/projects/$(project_id)");
+            }
+        }
+    }
+
+    /** Removes a project link; both ids come from the sealed private blob. */
+    public class RemoveProjectLinkAction : TypedStatumAction<ProjectLinkActionPrivate> {
+
+        private ProjectService projects = inject<ProjectService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ProjectLinkActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/projects");
+            }
+
+            try {
+                yield projects.remove_link(((!)ctx).link_id);
+                return directives().notify("success", "Link removed")
+                    .navigate(@"/projects/$(((!)ctx).project_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not remove the link: $(e.message)")
+                    .navigate(@"/projects/$(((!)ctx).project_id)");
+            }
+        }
+    }
+}

+ 110 - 0
src/actions/SaddleActions.vala

@@ -0,0 +1,110 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed saddle id carried by the saddle row actions. */
+    public class SaddleActionPrivate : Object {
+        public int64 saddle_id { get; set; }
+    }
+
+    /**
+     * Registers a saddle container (admin-only): name plus the host:port
+     * of its control server.
+     */
+    public class RegisterSaddleAction : StatumAction {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string name = Forms.field(request, "name");
+            string host = Forms.field(request, "host");
+            if (name.length == 0 || host.length == 0) {
+                return directives().notify("error", "Name and host are required").navigate("/saddles");
+            }
+
+            try {
+                var saddle = yield saddles.register_saddle(name, host);
+                return directives().notify("success", @"Saddle '$(saddle.name)' registered")
+                    .navigate("/saddles");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not register the saddle: $(e.message)").navigate("/saddles");
+            }
+        }
+    }
+
+    /**
+     * Removes a saddle registration (admin-only); a task it was still
+     * running is re-queued.
+     */
+    public class UnregisterSaddleAction : TypedStatumAction<SaddleActionPrivate> {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(SaddleActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/saddles");
+            }
+
+            try {
+                yield saddles.unregister_saddle(((!)ctx).saddle_id);
+                return directives().notify("success", "Saddle removed").navigate("/saddles");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not remove the saddle: $(e.message)").navigate("/saddles");
+            }
+        }
+    }
+
+    /**
+     * Probes a saddle's control server and refreshes its registered
+     * status (idle, busy or offline) — the manual counterpart of the
+     * dispatcher's polling.
+     */
+    public class RefreshSaddleStatusAction : TypedStatumAction<SaddleActionPrivate> {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(SaddleActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/saddles");
+            }
+
+            try {
+                yield saddles.refresh_saddle_status(((!)ctx).saddle_id);
+                return directives().notify("success", "Saddle status refreshed").navigate("/saddles");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not refresh the saddle: $(e.message)").navigate("/saddles");
+            }
+        }
+    }
+}

+ 329 - 0
src/actions/TaskActions.vala

@@ -0,0 +1,329 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed task id carried by the task row actions. */
+    public class TaskActionPrivate : Object {
+        public int64 task_id { get; set; }
+    }
+
+    /** Sealed block identity carried by {@link AnswerBlockAction}. */
+    public class BlockAnswerPrivate : Object {
+        public int64 task_id { get; set; }
+        public int64 block_id { get; set; }
+    }
+
+    /**
+     * Common base for actions sealed against a task id: materialises the
+     * typed private blob and exposes the resolved id (falling back to the
+     * hidden `task_id` form field).
+     */
+    public abstract class SealedTaskAction : TypedStatumAction<TaskActionPrivate> {
+
+        protected override Type private_type { get { return typeof(TaskActionPrivate); } }
+
+        /** The sealed task id, or 0 when neither blob nor form field carried one. */
+        protected int64 sealed_task_id() {
+            var ctx = request_private;
+            return ctx != null ? ((!)ctx).task_id : Forms.integer(request, "task_id", 0);
+        }
+    }
+
+    /**
+     * Creates a task from the queue form (project and title required).
+     * Priority clamps to 1–9; the design-task checkbox and the optional
+     * parent id support design tasks and manual subtasks.
+     */
+    public class CreateTaskAction : StatumAction {
+
+        private TaskService tasks = inject<TaskService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 project_id = Forms.integer(request, "project_id", 0);
+            string title = Forms.field(request, "title");
+            if (project_id <= 0 || title.length == 0) {
+                return directives().notify("error", "Project and title are required").navigate("/tasks");
+            }
+
+            int64? parent_id = null;
+            int64 parsed_parent = Forms.integer(request, "parent_task_id", 0);
+            if (parsed_parent > 0) {
+                parent_id = parsed_parent;
+            }
+
+            try {
+                var task = yield tasks.create_task(project_id, title,
+                    Forms.field_raw(request, "description"),
+                    Forms.integer(request, "priority", 5),
+                    Forms.field(request, "is_design_task") == "on", parent_id);
+                return directives().notify("success", @"Task '$(task.title)' queued")
+                    .navigate(@"/tasks/$(task.id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not create the task: $(e.message)").navigate("/tasks");
+            }
+        }
+    }
+
+    /**
+     * Saves the editable fields of a task; the id comes from the sealed
+     * {@link TaskActionPrivate} blob (the hidden form field is a
+     * fallback).
+     */
+    public class UpdateTaskAction : SealedTaskAction {
+
+        private TaskService tasks = inject<TaskService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            if (task_id <= 0) {
+                return directives().navigate("/tasks");
+            }
+
+            try {
+                int64? priority = null;
+                if (Forms.field(request, "priority").length > 0) {
+                    priority = Forms.integer(request, "priority", 5);
+                }
+                yield tasks.update_task(task_id,
+                    Forms.optional(request, "title"),
+                    request.form != null ? Forms.field(request, "description") : null,
+                    priority,
+                    Forms.optional(request, "status"));
+                return directives().notify("success", "Task saved").navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the task: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
+    /** Appends a comment to a task as the signed-in user. */
+    public class AddCommentAction : SealedTaskAction {
+
+        private TaskService tasks = inject<TaskService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            string body = Forms.field_raw(request, "body");
+            if (task_id <= 0 || body.length == 0) {
+                return directives().notify("error", "A comment body is required").navigate(@"/tasks/$(task_id)");
+            }
+
+            var me = SpryAuth.current(request);
+            try {
+                yield tasks.add_comment(task_id, @"user:$(me.username)", body);
+                return directives().notify("success", "Comment added").navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not add the comment: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
+    /**
+     * Records the human's answer to a block; the task and block ids come
+     * from the sealed {@link BlockAnswerPrivate} blob.
+     */
+    public class AnswerBlockAction : TypedStatumAction<BlockAnswerPrivate> {
+
+        private TaskService tasks = inject<TaskService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(BlockAnswerPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/tasks");
+            }
+            int64 task_id = ((!)ctx).task_id;
+            string answer = Forms.field_raw(request, "answer");
+            if (answer.length == 0) {
+                return directives().notify("error", "An answer is required").navigate(@"/tasks/$(task_id)");
+            }
+
+            try {
+                yield tasks.answer_block(((!)ctx).block_id, answer);
+                return directives().notify("success", "Answer recorded; the saddle will collect it")
+                    .navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not answer the block: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
+    /** Creates a child task under the sealed parent task id. */
+    public class CreateSubtaskAction : SealedTaskAction {
+
+        private TaskService tasks = inject<TaskService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            string title = Forms.field(request, "title");
+            if (task_id <= 0 || title.length == 0) {
+                return directives().notify("error", "A subtask title is required").navigate(@"/tasks/$(task_id)");
+            }
+
+            try {
+                yield tasks.create_subtask(task_id, title, Forms.field_raw(request, "description"));
+                return directives().notify("success", "Subtask created").navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not create the subtask: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
+    /** Adds a dependency edge from the sealed task to the form's target. */
+    public class AddDependencyAction : SealedTaskAction {
+
+        private TaskService tasks = inject<TaskService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            int64 depends_on = Forms.integer(request, "depends_on_task_id", 0);
+            if (task_id <= 0 || depends_on <= 0) {
+                return directives().notify("error", "Pick a task to depend on").navigate(@"/tasks/$(task_id)");
+            }
+
+            try {
+                yield tasks.add_dependency(task_id, depends_on);
+                return directives().notify("success", "Dependency added").navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not add the dependency: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
+    /**
+     * Completes a task (summary and working branch from the form); closes
+     * the open session and frees the saddle running it.
+     */
+    public class CompleteTaskAction : SealedTaskAction {
+
+        private TaskService tasks = inject<TaskService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            if (task_id <= 0) {
+                return directives().navigate("/tasks");
+            }
+
+            try {
+                yield tasks.complete_task(task_id, Forms.optional(request, "summary"),
+                    Forms.optional(request, "working_branch"), "done");
+                return directives().notify("success", "Task completed").navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not complete the task: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
+    /**
+     * Cancels a task (status 'failed'); the sealed id comes from the task
+     * page's cancel button.
+     */
+    public class CancelTaskAction : SealedTaskAction {
+
+        private TaskService tasks = inject<TaskService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            if (task_id <= 0) {
+                return directives().navigate("/tasks");
+            }
+
+            try {
+                yield tasks.cancel_task(task_id);
+                return directives().notify("success", "Task cancelled").navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not cancel the task: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
+    /**
+     * The task detail page's polling action: rebuilds the `task` slot from
+     * current data so the three-second client poll shows live comments,
+     * blocks, output-driven status changes and completions. Navigates
+     * nowhere; a failed reload leaves the client's state untouched.
+     */
+    public class RefreshTaskDataAction : SealedTaskAction {
+
+        private TaskService task_service = inject<TaskService>();
+        private ProjectService project_service = inject<ProjectService>();
+        private SaddleService saddle_service = inject<SaddleService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            if (task_id <= 0) {
+                return directives();
+            }
+
+            var state = yield TaskDetailState.build(state_service, task_service,
+                project_service, saddle_service, action_registry, task_id);
+            return directives().set(state);
+        }
+    }
+}

+ 160 - 0
src/entrypoints/HomeEntrypoint.vala

@@ -0,0 +1,160 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** A saddle card on the dashboard. */
+    public class DashboardSaddleView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string status { get; set; default = "offline"; }
+        public string host { get; set; default = ""; }
+        public int64 current_task_id { get; set; }
+        public string current_task_title { get; set; default = ""; }
+    }
+
+    /** One entry in the dashboard's recent activity feed. */
+    public class DashboardActivityView : Object {
+        public string kind { get; set; default = ""; }
+        public string when_display { get; set; default = ""; }
+        public int64 task_id { get; set; }
+        public string task_title { get; set; default = ""; }
+        public string text { get; set; default = ""; }
+    }
+
+    /** Public data of the dashboard's `dashboard` slot. */
+    public class DashboardPublic : Object {
+        public int64 queued_count { get; set; }
+        public int64 ready_count { get; set; }
+        public int64 active_count { get; set; }
+        public int64 blocked_count { get; set; }
+        public int64 done_count { get; set; }
+        public int64 failed_count { get; set; }
+        public int64 idle_saddle_count { get; set; }
+    }
+
+    /**
+     * Entrypoint for /: the dashboard — saddle status cards, a task-queue
+     * summary (including how many queued tasks are dispatch-ready) and a
+     * recent activity feed assembled from comments and task lifecycle
+     * events.
+     */
+    public class HomeEntrypoint : StatumEntrypoint {
+
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        private TaskService task_service = Inversion.inject<TaskService>();
+        private SaddleService saddle_service = Inversion.inject<SaddleService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var pub = new DashboardPublic();
+            foreach (var task in yield task_service.list_tasks(null, null)) {
+                switch (task.status) {
+                    case STATUS_QUEUED:
+                        pub.queued_count++;
+                        break;
+                    case STATUS_ASSIGNED:
+                    case STATUS_WORKING:
+                        pub.active_count++;
+                        break;
+                    case STATUS_BLOCKED:
+                        pub.blocked_count++;
+                        break;
+                    case STATUS_DONE:
+                        pub.done_count++;
+                        break;
+                    case STATUS_FAILED:
+                        pub.failed_count++;
+                        break;
+                    default:
+                        break;
+                }
+            }
+            pub.ready_count = (yield task_service.get_ready_tasks()).length;
+
+            var saddles = new Vector<DashboardSaddleView>();
+            foreach (var saddle in yield saddle_service.list_saddles()) {
+                if (saddle.status == "idle") {
+                    pub.idle_saddle_count++;
+                }
+                var view = new DashboardSaddleView() {
+                    id = saddle.id,
+                    name = saddle.name,
+                    status = saddle.status,
+                    host = saddle.host,
+                    current_task_id = saddle.current_task_id
+                };
+                if (saddle.current_task_id > 0) {
+                    var task = yield db.query<TaskEntity>()
+                        .where(expr("id == $0", elem<int64?>(saddle.current_task_id)))
+                        .first_async();
+                    view.current_task_title = task != null ? ((!)task).title : "";
+                }
+                saddles.add(view);
+            }
+
+            var activity = yield build_activity();
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["saddles"] = StateJson.list<DashboardSaddleView>(saddles.to_immutable_buffer());
+            props["activity"] = StateJson.list<DashboardActivityView>(activity.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "dashboard",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+
+        private async Vector<DashboardActivityView> build_activity() throws GLib.Error {
+            var activity = new Vector<DashboardActivityView>();
+            var titles = new Dictionary<string, string>();
+            foreach (var task in yield db.query<TaskEntity>().materialise_async()) {
+                titles.set(task.id.to_string(), task.title);
+            }
+
+            foreach (var comment in yield db.query<TaskComment>().order_by_desc(expr("id")).limit(10).materialise_async()) {
+                activity.add(new DashboardActivityView() {
+                    kind = "comment",
+                    when_display = format_time(comment.created_at),
+                    task_id = comment.task_id,
+                    task_title = titles.get_or_default(comment.task_id.to_string()) ?? "",
+                    text = @"$(comment.author): $(comment.body)"
+                });
+            }
+            foreach (var task in yield db.query<TaskEntity>().order_by_desc(expr("id")).limit(10).materialise_async()) {
+                activity.add(new DashboardActivityView() {
+                    kind = "created",
+                    when_display = format_time(task.created_at),
+                    task_id = task.id,
+                    task_title = task.title,
+                    text = "Task created"
+                });
+                if (task.completed_at != null) {
+                    activity.add(new DashboardActivityView() {
+                        kind = task.status,
+                        when_display = format_time(task.completed_at),
+                        task_id = task.id,
+                        task_title = task.title,
+                        text = task.status == STATUS_DONE ? "Task completed" : "Task failed"
+                    });
+                }
+            }
+
+            var sorted = activity.sort((a, b) => strcmp(b.when_display, a.when_display));
+            var trimmed = new Vector<DashboardActivityView>();
+            foreach (var item in sorted.take(15)) {
+                trimmed.add(item);
+            }
+            return trimmed;
+        }
+
+        private static string format_time(DateTime? time) {
+            return time != null ? ((!)time).format("%Y-%m-%d %H:%M") : "";
+        }
+    }
+}

+ 29 - 0
src/entrypoints/LoginEntrypoint.vala

@@ -0,0 +1,29 @@
+using Spry;
+using Spry.Authentication;
+using Statum;
+
+namespace ClankerSaddle {
+
+    /** Public data of the login page's `page` slot. */
+    public class LoginPagePublic : Object {
+        public Statum.Model.ActionDto login { get; set; }
+    }
+
+    /**
+     * Entrypoint for /login: hydrates the page slot with the
+     * {@link LoginAction} reference; authenticated visitors are sent
+     * straight to the dashboard.
+     */
+    public class LoginEntrypoint : StatumEntrypoint {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            if (!SpryAuth.current(request).is_anonymous) {
+                return directives().navigate("/");
+            }
+
+            var page = new LoginPagePublic();
+            page.login = action_registry.author<LoginAction>();
+            return directives().set_typed<LoginPagePublic>("page", Scope.PAGE, page);
+        }
+    }
+}

+ 210 - 0
src/entrypoints/ProjectsEntrypoint.vala

@@ -0,0 +1,210 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** A project row on the projects list, with task counts and a link to its page. */
+    public class ProjectRowView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string git_url { get; set; default = ""; }
+        public string default_branch { get; set; default = ""; }
+        public string summary { get; set; default = ""; }
+        public int64 open_tasks { get; set; }
+        public int64 done_tasks { get; set; }
+    }
+
+    /** Public data of the `projects` slot. */
+    public class ProjectsPublic : Object {
+        public Statum.Model.ActionDto create { get; set; }
+    }
+
+    /**
+     * Entrypoint for /projects: lists every project with open/done task
+     * counts and authors the create-project action (login required).
+     */
+    public class ProjectsEntrypoint : StatumEntrypoint {
+
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private TaskService task_service = Inversion.inject<TaskService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var rows = new Vector<ProjectRowView>();
+            var tasks = yield task_service.list_tasks(null, null);
+            foreach (var project in yield project_service.list_projects()) {
+                var view = new ProjectRowView() {
+                    id = project.id,
+                    name = project.name,
+                    git_url = project.git_url,
+                    default_branch = project.default_branch,
+                    summary = project.summary ?? ""
+                };
+                foreach (var task in tasks) {
+                    if (task.project_id != project.id) {
+                        continue;
+                    }
+                    if (task.status == STATUS_DONE || task.status == STATUS_FAILED) {
+                        view.done_tasks++;
+                    } else {
+                        view.open_tasks++;
+                    }
+                }
+                rows.add(view);
+            }
+
+            var pub = new ProjectsPublic();
+            pub.create = action_registry.author<CreateProjectAction>();
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["projects"] = StateJson.list<ProjectRowView>(rows.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "projects",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+
+    /** A linked project on the project detail page, with a remove action. */
+    public class ProjectLinkView : Object {
+        public int64 link_id { get; set; }
+        public int64 project_id { get; set; }
+        public string name { get; set; default = ""; }
+        public string git_url { get; set; default = ""; }
+        public string relationship { get; set; default = ""; }
+        public bool co_developed { get; set; }
+        public Statum.Model.ActionDto? remove { get; set; }
+    }
+
+    /** A task row on the project detail page. */
+    public class ProjectTaskRowView : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = ""; }
+        public int64 priority { get; set; }
+        public bool is_design_task { get; set; }
+    }
+
+    /** Public data of the `project` slot. */
+    public class ProjectDetailPublic : Object {
+        public bool exists { get; set; }
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string git_url { get; set; default = ""; }
+        public string default_branch { get; set; default = ""; }
+        public string summary { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? update { get; set; }
+        public Statum.Model.ActionDto? add_link { get; set; }
+    }
+
+    /**
+     * Entrypoint for /projects/{project_id}: the project's editable card,
+     * its co-developed/reference links (with remove actions), the other
+     * projects available for linking, and its task list.
+     */
+    public class ProjectDetailEntrypoint : StatumEntrypoint {
+
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private TaskService task_service = Inversion.inject<TaskService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 project_id = int64.parse(request.route("project_id") ?? "0");
+            var project = yield project_service.get_project(project_id);
+            if (project == null) {
+                return directives().set_typed<ProjectDetailPublic>("project", Scope.PAGE,
+                    new ProjectDetailPublic() { exists = false });
+            }
+
+            var sealed = new ProjectActionPrivate() { project_id = project_id };
+            var pub = new ProjectDetailPublic() {
+                exists = true,
+                id = ((!)project).id,
+                name = ((!)project).name,
+                git_url = ((!)project).git_url,
+                default_branch = ((!)project).default_branch,
+                summary = ((!)project).summary ?? "",
+                created_display = ((!)project).created_at != null
+                    ? ((!)((!)project).created_at).format("%Y-%m-%d %H:%M") : "",
+                update = action_registry.author_private<UpdateProjectAction, ProjectActionPrivate>(sealed),
+                add_link = action_registry.author_private<AddProjectLinkAction, ProjectActionPrivate>(sealed)
+            };
+
+            var links = new Vector<ProjectLinkView>();
+            foreach (var link in yield project_service.list_links(project_id)) {
+                var linked = yield project_service.get_project(link.linked_project_id);
+                if (linked == null) {
+                    continue;
+                }
+                var private_blob = new ProjectLinkActionPrivate() {
+                    project_id = project_id,
+                    link_id = link.id
+                };
+                links.add(new ProjectLinkView() {
+                    link_id = link.id,
+                    project_id = link.linked_project_id,
+                    name = ((!)linked).name,
+                    git_url = ((!)linked).git_url,
+                    relationship = link.relationship,
+                    co_developed = link.relationship == LINK_CO_DEVELOPED,
+                    remove = action_registry.author_private<RemoveProjectLinkAction, ProjectLinkActionPrivate>(private_blob)
+                });
+            }
+
+            var linkable = new Vector<ProjectRowView>();
+            foreach (var other in yield project_service.list_projects()) {
+                if (other.id != project_id) {
+                    linkable.add(new ProjectRowView() {
+                        id = other.id,
+                        name = other.name,
+                        git_url = other.git_url,
+                        default_branch = other.default_branch,
+                        summary = other.summary ?? ""
+                    });
+                }
+            }
+
+            var task_rows = new Vector<ProjectTaskRowView>();
+            foreach (var task in yield task_service.list_tasks(null, project_id)) {
+                task_rows.add(new ProjectTaskRowView() {
+                    id = task.id,
+                    title = task.title,
+                    status = task.status,
+                    priority = task.priority,
+                    is_design_task = task.is_design_task
+                });
+            }
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
+            props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
+            props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "project",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+}

+ 85 - 0
src/entrypoints/SaddlesEntrypoint.vala

@@ -0,0 +1,85 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** A saddle card on the management page, with its current task. */
+    public class SaddleRowView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string host { get; set; default = ""; }
+        public string status { get; set; default = ""; }
+        public int64 current_task_id { get; set; }
+        public string current_task_title { get; set; default = ""; }
+        public string allocated_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? unregister { get; set; }
+        public Statum.Model.ActionDto? refresh { get; set; }
+    }
+
+    /** Public data of the `saddles` slot. */
+    public class SaddlesPublic : Object {
+        public Statum.Model.ActionDto register { get; set; }
+    }
+
+    /**
+     * Entrypoint for /saddles: the saddle inventory. Admin-only (the
+     * authoritative guard); each card carries its current task and the
+     * unregister/refresh actions.
+     */
+    public class SaddlesEntrypoint : StatumEntrypoint {
+
+        private SaddleService saddle_service = Inversion.inject<SaddleService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var rows = new Vector<SaddleRowView>();
+            foreach (var saddle in yield saddle_service.list_saddles()) {
+                var view = new SaddleRowView() {
+                    id = saddle.id,
+                    name = saddle.name,
+                    host = saddle.host,
+                    status = saddle.status,
+                    current_task_id = saddle.current_task_id,
+                    allocated_display = saddle.allocated_at != null
+                        ? ((!)saddle.allocated_at).format("%Y-%m-%d %H:%M") : ""
+                };
+                if (saddle.current_task_id > 0) {
+                    var task = yield db.query<TaskEntity>()
+                        .where(expr("id == $0", elem<int64?>(saddle.current_task_id)))
+                        .first_async();
+                    view.current_task_title = task != null ? ((!)task).title : "";
+                }
+                var sealed = new SaddleActionPrivate() { saddle_id = saddle.id };
+                view.unregister = action_registry.author_private<UnregisterSaddleAction, SaddleActionPrivate>(sealed);
+                view.refresh = action_registry.author_private<RefreshSaddleStatusAction, SaddleActionPrivate>(sealed);
+                rows.add(view);
+            }
+
+            var pub = new SaddlesPublic();
+            pub.register = action_registry.author<RegisterSaddleAction>();
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["saddles"] = StateJson.list<SaddleRowView>(rows.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "saddles",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+}

+ 258 - 0
src/entrypoints/TaskDetailEntrypoint.vala

@@ -0,0 +1,258 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** A comment row in the task detail thread. */
+    public class CommentView : Object {
+        public string author { get; set; default = ""; }
+        public string body { get; set; default = ""; }
+        public string when_display { get; set; default = ""; }
+        public bool from_bot { get; set; }
+    }
+
+    /** A block (agent question) with its answer state and answer action. */
+    public class BlockView : Object {
+        public int64 id { get; set; }
+        public string question { get; set; default = ""; }
+        public string answer { get; set; default = ""; }
+        public bool answered { get; set; }
+        public string created_display { get; set; default = ""; }
+        public string answered_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? answer_action { get; set; }
+    }
+
+    /** A subtask row in the task detail tree. */
+    public class SubtaskView : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = ""; }
+    }
+
+    /** A dependency row (an edge to another task). */
+    public class DependencyView : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = ""; }
+    }
+
+    /** A session row in the task's history, linking to the terminal view. */
+    public class SessionView : Object {
+        public int64 id { get; set; }
+        public string saddle_name { get; set; default = ""; }
+        public string started_display { get; set; default = ""; }
+        public string ended_display { get; set; default = ""; }
+        public string outcome { get; set; default = ""; }
+        public bool open { get; set; }
+    }
+
+    /** Public data of the `task` slot. */
+    public class TaskDetailPublic : Object {
+        public bool exists { get; set; }
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string description { get; set; default = ""; }
+        public string status { get; set; default = ""; }
+        public int64 priority { get; set; default = 5; }
+        public bool is_design_task { get; set; }
+        public string working_branch { get; set; default = ""; }
+        public string summary { get; set; default = ""; }
+        public int64 project_id { get; set; }
+        public string project_name { get; set; default = ""; }
+        public int64 parent_task_id { get; set; }
+        public string created_display { get; set; default = ""; }
+        public string started_display { get; set; default = ""; }
+        public string completed_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? update { get; set; }
+        public Statum.Model.ActionDto? comment { get; set; }
+        public Statum.Model.ActionDto? subtask { get; set; }
+        public Statum.Model.ActionDto? dependency { get; set; }
+        public Statum.Model.ActionDto? complete { get; set; }
+        public Statum.Model.ActionDto? cancel { get; set; }
+        public Statum.Model.ActionDto? refresh { get; set; }
+    }
+
+    /**
+     * Builds the `task` slot state shared by the task detail entrypoint and
+     * the {@link RefreshTaskDataAction} polling action, so both hydrate
+     * identical state.
+     */
+    public class TaskDetailState : Object {
+
+        /**
+         * Loads task `task_id` and shapes its PAGE slot (full view rows
+         * plus every authored action reference). The state's `exists` flag
+         * is false when the task is missing.
+         */
+        public static async Slot build(StateService state_service, TaskService tasks,
+                ProjectService projects, SaddleService saddles, ActionRegistry actions,
+                int64 task_id) throws GLib.Error {
+            var detail = yield tasks.get_task(task_id);
+            if (detail == null) {
+                return slot_for(state_service, new TaskDetailPublic() { exists = false },
+                    new PropertyDictionary());
+            }
+
+            var task = ((!)detail).task;
+            var project = yield projects.get_project(task.project_id);
+
+            var pub = new TaskDetailPublic() {
+                exists = true,
+                id = task.id,
+                title = task.title,
+                description = task.description ?? "",
+                status = task.status,
+                priority = task.priority,
+                is_design_task = task.is_design_task,
+                working_branch = task.working_branch ?? "",
+                summary = task.summary ?? "",
+                project_id = task.project_id,
+                project_name = project != null ? ((!)project).name : "",
+                parent_task_id = task.parent_task_id,
+                created_display = format_time(task.created_at),
+                started_display = format_time(task.started_at),
+                completed_display = format_time(task.completed_at)
+            };
+
+            var sealed = new TaskActionPrivate() { task_id = task.id };
+            pub.update = actions.author_private<UpdateTaskAction, TaskActionPrivate>(sealed);
+            pub.comment = actions.author_private<AddCommentAction, TaskActionPrivate>(sealed);
+            pub.subtask = actions.author_private<CreateSubtaskAction, TaskActionPrivate>(sealed);
+            pub.dependency = actions.author_private<AddDependencyAction, TaskActionPrivate>(sealed);
+            pub.complete = actions.author_private<CompleteTaskAction, TaskActionPrivate>(sealed);
+            pub.cancel = actions.author_private<CancelTaskAction, TaskActionPrivate>(sealed);
+            pub.refresh = actions.author_private<RefreshTaskDataAction, TaskActionPrivate>(sealed, "GET");
+
+            var comments = new Vector<CommentView>();
+            foreach (var comment in ((!)detail).comments) {
+                comments.add(new CommentView() {
+                    author = comment.author,
+                    body = comment.body,
+                    when_display = format_time(comment.created_at),
+                    from_bot = comment.author == "bot"
+                });
+            }
+
+            var blocks = new Vector<BlockView>();
+            foreach (var block in ((!)detail).blocks) {
+                var block_private = new BlockAnswerPrivate() {
+                    task_id = task.id,
+                    block_id = block.id
+                };
+                blocks.add(new BlockView() {
+                    id = block.id,
+                    question = block.question,
+                    answer = block.answer ?? "",
+                    answered = block.answer != null,
+                    created_display = format_time(block.created_at),
+                    answered_display = format_time(block.answered_at),
+                    answer_action = actions.author_private<AnswerBlockAction, BlockAnswerPrivate>(block_private)
+                });
+            }
+
+            var subtasks = new Vector<SubtaskView>();
+            foreach (var subtask in ((!)detail).subtasks) {
+                subtasks.add(new SubtaskView() {
+                    id = subtask.id,
+                    title = subtask.title,
+                    status = subtask.status
+                });
+            }
+
+            var dependencies = new Vector<DependencyView>();
+            var candidates = new Vector<DependencyView>();
+            foreach (var dep in ((!)detail).dependencies) {
+                var other = yield tasks.get_task(dep.depends_on_task_id);
+                dependencies.add(task_brief(other, dep.depends_on_task_id));
+            }
+            foreach (var row in yield tasks.list_tasks(null, task.project_id)) {
+                if (row.id != task.id) {
+                    candidates.add(new DependencyView() {
+                        id = row.id,
+                        title = row.title,
+                        status = row.status
+                    });
+                }
+            }
+
+            var sessions = new Vector<SessionView>();
+            foreach (var session in ((!)detail).sessions) {
+                var saddle = yield saddles.get_saddle(session.saddle_id);
+                sessions.add(new SessionView() {
+                    id = session.id,
+                    saddle_name = saddle != null ? ((!)saddle).name : @"saddle #$(session.saddle_id)",
+                    started_display = format_time(session.started_at),
+                    ended_display = format_time(session.ended_at),
+                    outcome = session.outcome ?? "",
+                    open = session.ended_at == null
+                });
+            }
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["comments"] = StateJson.list<CommentView>(comments.to_immutable_buffer());
+            props["blocks"] = StateJson.list<BlockView>(blocks.to_immutable_buffer());
+            props["subtasks"] = StateJson.list<SubtaskView>(subtasks.to_immutable_buffer());
+            props["dependencies"] = StateJson.list<DependencyView>(dependencies.to_immutable_buffer());
+            props["candidates"] = StateJson.list<DependencyView>(candidates.to_immutable_buffer());
+            props["sessions"] = StateJson.list<SessionView>(sessions.to_immutable_buffer());
+            return slot_for(state_service, pub, props);
+        }
+
+        private static DependencyView task_brief(TaskDetail? detail, int64 id) {
+            if (detail == null) {
+                return new DependencyView() { id = id, title = @"#$(id)", status = "missing" };
+            }
+            return new DependencyView() {
+                id = ((!)detail).task.id,
+                title = ((!)detail).task.title,
+                status = ((!)detail).task.status
+            };
+        }
+
+        private static Slot slot_for(StateService state_service, TaskDetailPublic pub,
+                PropertyDictionary props) {
+            return state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "task",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+        }
+
+        private static string format_time(DateTime? time) {
+            return time != null ? ((!)time).format("%Y-%m-%d %H:%M") : "";
+        }
+    }
+
+    /**
+     * Entrypoint for /tasks/{task_id}: the task detail page — comments
+     * thread, blocks (pending ones carry the answer form), subtask tree,
+     * dependencies, session history links and the lifecycle actions. The
+     * page polls the {@link RefreshTaskDataAction} reference every three
+     * seconds to stay current while a saddle works.
+     */
+    public class TaskDetailEntrypoint : StatumEntrypoint {
+
+        private TaskService task_service = Inversion.inject<TaskService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private SaddleService saddle_service = Inversion.inject<SaddleService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = int64.parse(request.route("task_id") ?? "0");
+            var state = yield TaskDetailState.build(state_service, task_service,
+                project_service, saddle_service, action_registry, task_id);
+            return directives().set(state);
+        }
+    }
+}

+ 133 - 0
src/entrypoints/TaskTerminalEntrypoint.vala

@@ -0,0 +1,133 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** A command/output pair in the terminal log. */
+    public class CommandView : Object {
+        public int64 id { get; set; }
+        public string command_text { get; set; default = ""; }
+        public string output_text { get; set; default = ""; }
+        public string exit_code_display { get; set; default = ""; }
+        public string when_display { get; set; default = ""; }
+        public string duration_display { get; set; default = ""; }
+    }
+
+    /** A session selector row above the terminal log. */
+    public class TerminalSessionView : Object {
+        public int64 id { get; set; }
+        public string label { get; set; default = ""; }
+        public bool selected { get; set; }
+    }
+
+    /** Public data of the `terminal` slot. */
+    public class TerminalPublic : Object {
+        public bool exists { get; set; }
+        public int64 task_id { get; set; }
+        public string task_title { get; set; default = ""; }
+        public int64 session_id { get; set; }
+        public int64 command_count { get; set; }
+    }
+
+    /**
+     * Entrypoint for /tasks/{task_id}/terminal: the read-only bash session
+     * log. The `session_id` query parameter selects which of the task's
+     * sessions to show (the latest when absent); commands render as a
+     * scrollable command + output transcript.
+     */
+    public class TaskTerminalEntrypoint : StatumEntrypoint {
+
+        private TaskService task_service = Inversion.inject<TaskService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = int64.parse(request.route("task_id") ?? "0");
+            var detail = yield task_service.get_task(task_id);
+            if (detail == null) {
+                return directives().set_typed<TerminalPublic>("terminal", Scope.PAGE,
+                    new TerminalPublic() { exists = false });
+            }
+
+            var sessions = ((!)detail).sessions;
+            if (sessions.length == 0) {
+                var pub = new TerminalPublic() {
+                    exists = true,
+                    task_id = task_id,
+                    task_title = ((!)detail).task.title
+                };
+                return directives().set_typed<TerminalPublic>("terminal", Scope.PAGE, pub);
+            }
+
+            int64 selected = 0;
+            string? requested = null;
+            request.query.try_get_any("session_id", out requested);
+            if (requested != null && requested.length > 0) {
+                selected = int64.parse(requested);
+            }
+            bool found = false;
+            foreach (var session in sessions) {
+                if (session.id == selected) {
+                    found = true;
+                }
+            }
+            if (!found) {
+                selected = sessions.first().id;
+            }
+
+            var pub_with_commands = new TerminalPublic() {
+                exists = true,
+                task_id = task_id,
+                task_title = ((!)detail).task.title,
+                session_id = selected
+            };
+
+            var session_rows = new Vector<TerminalSessionView>();
+            foreach (var session in sessions) {
+                session_rows.add(new TerminalSessionView() {
+                    id = session.id,
+                    label = @"#$(session.id) — $(format_time(session.started_at)) ($(session.outcome ?? "open"))",
+                    selected = session.id == selected
+                });
+            }
+
+            var commands = new Vector<CommandView>();
+            foreach (var command in yield task_service.list_commands(selected)) {
+                pub_with_commands.command_count++;
+                commands.add(new CommandView() {
+                    id = command.id,
+                    command_text = command.command_text,
+                    output_text = command.output_text ?? "",
+                    exit_code_display = command.exit_code != 0 ? @"exit $(command.exit_code)" : "",
+                    when_display = format_time(command.started_at),
+                    duration_display = command.duration_ms > 0 ? @"$(command.duration_ms)ms" : ""
+                });
+            }
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub_with_commands);
+            props["sessions"] = StateJson.list<TerminalSessionView>(session_rows.to_immutable_buffer());
+            props["commands"] = StateJson.list<CommandView>(commands.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "terminal",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+
+        private static string format_time(DateTime? time) {
+            return time != null ? ((!)time).format("%Y-%m-%d %H:%M") : "";
+        }
+    }
+}

+ 143 - 0
src/entrypoints/TasksEntrypoint.vala

@@ -0,0 +1,143 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** A task row on the task queue page, with a dependency summary. */
+    public class TaskRowView : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = ""; }
+        public int64 priority { get; set; }
+        public bool is_design_task { get; set; }
+        public int64 parent_task_id { get; set; }
+        public string project_name { get; set; default = ""; }
+        public string dependency_summary { get; set; default = ""; }
+        public bool has_dependencies { get; set; }
+        public bool ready { get; set; }
+    }
+
+    /** A project option in the queue page's filter and create forms. */
+    public class TaskProjectOptionView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+    }
+
+    /** Public data of the `tasks` slot. */
+    public class TasksPublic : Object {
+        public string filter_status { get; set; default = ""; }
+        public int64 filter_project_id { get; set; }
+        public Statum.Model.ActionDto create { get; set; }
+    }
+
+    /**
+     * Entrypoint for /tasks: the filterable, sortable queue. Filters come
+     * from the `status` and `project_id` query parameters; rows carry a
+     * dependency summary (each dependency's id and status) and whether the
+     * task is dispatch-ready.
+     */
+    public class TasksEntrypoint : StatumEntrypoint {
+
+        private TaskService task_service = Inversion.inject<TaskService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string? status = null;
+            request.query.try_get_any("status", out status);
+            if (status == null || status.length == 0 || status == "all") {
+                status = null;
+            }
+            int64? project_id = null;
+            string? project_text = null;
+            request.query.try_get_any("project_id", out project_text);
+            if (project_text != null && project_text.length > 0) {
+                int64 parsed = int64.parse(project_text);
+                if (parsed > 0) {
+                    project_id = parsed;
+                }
+            }
+
+            var projects = yield project_service.list_projects();
+            var project_names = new Dictionary<string, string>();
+            foreach (var project in projects) {
+                project_names.set(project.id.to_string(), project.name);
+            }
+
+            var tasks_all = yield task_service.list_tasks(null, null);
+            var statuses = new Dictionary<string, string>();
+            foreach (var task in tasks_all) {
+                statuses.set(task.id.to_string(), task.status);
+            }
+            var all_dependencies = yield task_service.list_all_dependencies();
+            var ready = yield task_service.get_ready_tasks();
+
+            var rows = new Vector<TaskRowView>();
+            foreach (var task in yield task_service.list_tasks(status, project_id)) {
+                bool is_ready = false;
+                foreach (var ready_task in ready) {
+                    if (ready_task.id == task.id) {
+                        is_ready = true;
+                        break;
+                    }
+                }
+                var view = new TaskRowView() {
+                    id = task.id,
+                    title = task.title,
+                    status = task.status,
+                    priority = task.priority,
+                    is_design_task = task.is_design_task,
+                    parent_task_id = task.parent_task_id,
+                    ready = is_ready,
+                    project_name = project_names.get_or_default(task.project_id.to_string()) ?? ""
+                };
+                var parts = new StringBuilder();
+                foreach (var dep in all_dependencies) {
+                    if (dep.task_id != task.id) {
+                        continue;
+                    }
+                    if (parts.len > 0) {
+                        parts.append(", ");
+                    }
+                    parts.printf("#%lld (%s)", dep.depends_on_task_id,
+                        statuses.get_or_default(dep.depends_on_task_id.to_string()) ?? "?");
+                }
+                view.has_dependencies = parts.len > 0;
+                view.dependency_summary = parts.str;
+                rows.add(view);
+            }
+
+            var options = new Vector<TaskProjectOptionView>();
+            foreach (var project in projects) {
+                options.add(new TaskProjectOptionView() { id = project.id, name = project.name });
+            }
+
+            var pub = new TasksPublic() {
+                filter_status = status ?? "",
+                filter_project_id = project_id != null ? ((!)project_id) : 0,
+                create = action_registry.author<CreateTaskAction>()
+            };
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["tasks"] = StateJson.list<TaskRowView>(rows.to_immutable_buffer());
+            props["projects"] = StateJson.list<TaskProjectOptionView>(options.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "tasks",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+}

+ 135 - 0
src/main.vala

@@ -0,0 +1,135 @@
+using Astralis;
+using Inversion;
+using InvercargillSqlInversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle;
+using ClankerSaddle.Api;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+int main(string[] args) {
+    int port = args.length > 1 ? int.parse(args[1]) : 8080;
+    print("ClankerSaddle - LLM task orchestration platform\n");
+    print(@"Website starting on port $(port)\n");
+
+    try {
+        var application = new WebApplication(port);
+        application.use_compression();
+        application.add_module<StatumModule>();
+        application.add_module<SpryModule>();
+
+        var db_path = Environment.get_variable("SPRY_DB_PATH") ?? "";
+        if (db_path.strip().length == 0) {
+            db_path = "./clanker-saddle.sqlite";
+        }
+        var db = application.container.configure_with<DatabaseConfigurator>();
+        db.register_connection(@"sqlite://$(db_path)");
+        db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
+        db.add_entity<Project>(Project.entity_mapping);
+        db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
+        db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
+        db.add_entity<TaskDependency>(TaskDependency.entity_mapping);
+        db.add_entity<TaskComment>(TaskComment.entity_mapping);
+        db.add_entity<TaskBlock>(TaskBlock.entity_mapping);
+        db.add_entity<Saddle>(Saddle.entity_mapping);
+        db.add_entity<Session>(Session.entity_mapping);
+        db.add_entity<SessionCommand>(SessionCommand.entity_mapping);
+        db.migrate_on_startup();
+
+        application.add_scoped<ProjectService>();
+        application.add_scoped<TaskService>();
+        application.add_scoped<SaddleService>();
+        application.add_singleton<SaddlePollState>();
+        application.add_singleton<Dispatcher>();
+
+        var statum = application.configure_with<StatumConfigurator>();
+
+        // spry:pages-begin
+        statum.add_page<HomePage, HomeEntrypoint>();
+        statum.add_page<LoginPage, LoginEntrypoint>();
+        statum.add_page<ProjectsPage, ProjectsEntrypoint>();
+        statum.add_page<ProjectDetailPage, ProjectDetailEntrypoint>();
+        statum.add_page<TasksPage, TasksEntrypoint>();
+        statum.add_page<TaskDetailPage, TaskDetailEntrypoint>();
+        statum.add_page<TaskTerminalPage, TaskTerminalEntrypoint>();
+        statum.add_page<SaddlesPage, SaddlesEntrypoint>();
+        // spry:pages-end
+
+        // spry:actions-begin
+        statum.action<LoginAction>();
+        statum.action<Spry.Actions.LogoutAction>();
+        statum.action<CreateProjectAction>();
+        statum.action<UpdateProjectAction>();
+        statum.action<AddProjectLinkAction>();
+        statum.action<RemoveProjectLinkAction>();
+        statum.action<CreateTaskAction>();
+        statum.action<UpdateTaskAction>();
+        statum.action<AddCommentAction>();
+        statum.action<AnswerBlockAction>();
+        statum.action<CreateSubtaskAction>();
+        statum.action<AddDependencyAction>();
+        statum.action<CompleteTaskAction>();
+        statum.action<CancelTaskAction>();
+        statum.action<RefreshTaskDataAction>();
+        statum.action<RegisterSaddleAction>();
+        statum.action<UnregisterSaddleAction>();
+        statum.action<RefreshSaddleStatusAction>();
+        // spry:actions-end
+
+        // spry:resources-begin
+        // spry:resources-end
+
+        // spry:auth-begin
+        // spry:auth-end
+
+        application.add_endpoint<SaddleUpdateEndpoint>(
+            new EndpointRoute("/api/saddle/{id}/update", Method.POST));
+        application.add_endpoint<SaddlePollEndpoint>(
+            new EndpointRoute("/api/saddle/{id}/poll", Method.GET));
+
+        GLib.Idle.add(() => {
+            seed_admin.begin(application.container);
+            start_dispatcher.begin(application.container);
+            return false;
+        });
+
+        application.run();
+        return 0;
+    } catch (Error e) {
+        printerr("Error: %s\n", e.message);
+        return 1;
+    }
+}
+
+/**
+ * Seeds the admin account when BOTH SPRY_SEED_ADMIN_USERNAME and
+ * SPRY_SEED_ADMIN_PASSWORD are set; skipped silently otherwise. The
+ * password is never logged.
+ */
+private async void seed_admin(Container container) {
+    var username = Environment.get_variable("SPRY_SEED_ADMIN_USERNAME") ?? "";
+    var password = Environment.get_variable("SPRY_SEED_ADMIN_PASSWORD") ?? "";
+    if (username.length == 0 || password.length == 0) {
+        return;
+    }
+
+    try {
+        var scope = container.create_scope();
+        yield SpryAuth.ensure_admin(scope.resolve<UserService>(),
+            username, @"$(username)@example.com", password);
+        print("[ClankerSaddle] Seeded admin account: %s\n", username);
+    } catch (Error e) {
+        printerr("[ClankerSaddle] Admin seeding failed: %s\n", e.message);
+    }
+}
+
+private async void start_dispatcher(Container container) {
+    try {
+        var scope = container.create_scope();
+        scope.resolve<Dispatcher>().start();
+    } catch (Error e) {
+        printerr("[ClankerSaddle] Dispatcher startup failed: %s\n", e.message);
+    }
+}

+ 60 - 0
src/pages/home.html

@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Dashboard</h2>
+        <p stm-if="dashboard == null">Loading…</p>
+
+        <div stm-if="dashboard != null">
+            <div class="cards">
+                <section class="card">
+                    <h3>Queue</h3>
+                    <p><strong stm-text="dashboard.ready_count">0</strong> ready to dispatch</p>
+                    <p class="muted"><span stm-text="dashboard.queued_count">0</span> queued ·
+                        <span stm-text="dashboard.active_count">0</span> active ·
+                        <span stm-text="dashboard.blocked_count">0</span> blocked</p>
+                    <p class="muted"><span stm-text="dashboard.done_count">0</span> done ·
+                        <span stm-text="dashboard.failed_count">0</span> failed</p>
+                </section>
+                <section class="card">
+                    <h3>Saddles</h3>
+                    <p><strong stm-text="dashboard.idle_saddle_count">0</strong> idle</p>
+                    <p><a href="/saddles">Manage saddles</a> · <a href="/tasks">Task queue</a></p>
+                </section>
+            </div>
+
+            <h3>Saddles</h3>
+            <div class="cards">
+                <section class="card" stm-for-saddle-in="dashboard.saddles">
+                    <h3 stm-text="saddle.name">name</h3>
+                    <p><span stm-attribute.class="'badge badge-' + saddle.status" stm-text="saddle.status">status</span>
+                        <span class="muted" stm-text="saddle.host">host</span></p>
+                    <p stm-if="saddle.current_task_id > 0">Working on
+                        <a stm-attribute.href="'/tasks/' + saddle.current_task_id" stm-text="saddle.current_task_title">task</a></p>
+                    <p stm-if="saddle.current_task_id == 0" class="muted">No current task</p>
+                </section>
+                <section class="card" stm-if="dashboard.saddles.length == 0">
+                    <p class="muted">No saddles registered.</p>
+                </section>
+            </div>
+
+            <h3>Recent activity</h3>
+            <table>
+                <thead><tr><th>When</th><th>Task</th><th>Activity</th></tr></thead>
+                <tbody>
+                    <tr stm-for-item-in="dashboard.activity">
+                        <td stm-text="item.when_display">when</td>
+                        <td><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a></td>
+                        <td stm-text="item.text">activity</td>
+                    </tr>
+                    <tr stm-if="dashboard.activity.length == 0"><td colspan="3" class="muted">Nothing yet.</td></tr>
+                </tbody>
+            </table>
+        </div>
+    </section>
+</body>
+</html>

+ 35 - 0
src/pages/login.html

@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/login</pstm-uri>
+    <pstm-template>main</pstm-template>
+    <script>
+        // Surface error notifications inline instead of via alert().
+        if (window.statum) {
+            statum.onNotifyHandler = function (kind, message) {
+                var area = document.getElementById('login-error');
+                if (kind === 'error' && area) {
+                    area.hidden = false;
+                    area.textContent = message;
+                    return;
+                }
+                window.alert(message);
+            };
+        }
+    </script>
+</head>
+<body>
+    <section>
+        <h2>Log in</h2>
+        <p stm-if="auth != null">Already signed in as <strong stm-text="auth.display_name">user</strong>.</p>
+        <form stm-if="auth == null" stm-action="page.login" style="max-width: 24rem">
+            <div class="form-row"><label for="login-username">Username</label>
+                <input id="login-username" name="username" required autocomplete="username" autofocus></div>
+            <div class="form-row"><label for="login-password">Password</label>
+                <input id="login-password" type="password" name="password" required autocomplete="current-password"></div>
+            <button type="submit">Log in</button>
+        </form>
+        <p class="error" id="login-error" hidden></p>
+    </section>
+</body>
+</html>

+ 78 - 0
src/pages/main.html

@@ -0,0 +1,78 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <pstm-name>main</pstm-name>
+    <pstm-title>ClankerSaddle</pstm-title>
+    <script pstm-res-src="statum.js"></script>
+    <style>
+        body { font-family: system-ui, sans-serif; margin: 0 auto; max-width: 60rem; padding: 0 1rem; }
+        header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; border-bottom: 1px solid #8884; padding: 1rem 0; }
+        header h1 { font-size: 1.2rem; margin: 0; }
+        header nav { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.75rem; }
+        main { padding: 1rem 0 3rem; }
+        footer { border-top: 1px solid #8884; color: #888; font-size: 0.85rem; padding: 1rem 0; }
+        input, button, select, textarea { font: inherit; }
+        input[type="text"], input[type="password"], input[type="email"], input[type="date"], input[type="number"], select { padding: 0.3rem 0.4rem; }
+        .form-row { display: flex; align-items: center; gap: 0.6rem; margin: 0.6rem 0; }
+        .form-row label { flex: none; width: 16ch; margin: 0; }
+        .form-row input, .form-row select { flex: 1 1 12ch; max-width: 32ch; }
+        form.inline { display: inline-flex; align-items: center; gap: 0.4rem; margin: 0.2rem 0; }
+        table { border-collapse: collapse; width: 100%; }
+        th, td { border: 1px solid #8884; padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }
+        .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: 1rem; margin: 1rem 0; }
+        .card { border: 1px solid #8886; border-radius: 0.5rem; padding: 0.8rem 1rem; }
+        .card h3 { margin: 0 0 0.5rem; }
+        .badge { border-radius: 1em; display: inline-block; font-size: 0.75rem; padding: 0.1rem 0.6rem; }
+        .badge-queued { background: #8883; }
+        .badge-assigned { background: #58a6ff33; color: #1f5fa9; }
+        .badge-working { background: #d2992233; color: #7d5a00; }
+        .badge-blocked { background: #d2992233; color: #7d5a00; }
+        .badge-done { background: #2ea04333; color: #1a6b2c; }
+        .badge-failed { background: #f8514933; color: #a32721; }
+        .badge-idle { background: #2ea04333; color: #1a6b2c; }
+        .badge-busy { background: #d2992233; color: #7d5a00; }
+        .badge-offline { background: #8883; }
+        .badge-co-developed { background: #58a6ff33; color: #1f5fa9; }
+        .badge-reference { background: #8883; }
+        .muted { color: #666; }
+        .comment { border-left: 3px solid #8886; margin: 0.5rem 0; padding: 0.2rem 0 0.2rem 0.7rem; }
+        .comment.bot { border-left-color: #58a6ff88; }
+        .terminal { background: #0d1117; border-radius: 0.5rem; color: #c9d1d9; font-family: ui-monospace, monospace; font-size: 0.85rem; max-height: 32rem; overflow-y: auto; padding: 0.8rem 1rem; }
+        .terminal .command { color: #79c0ff; }
+        .terminal .meta { color: #8b949e; }
+        .terminal pre { margin: 0.1rem 0 0.8rem; white-space: pre-wrap; word-break: break-word; }
+        details { border: 1px solid #8884; border-radius: 0.5rem; margin: 0.6rem 0; padding: 0.5rem 0.8rem; }
+        summary { cursor: pointer; }
+        .error { color: #b00020; }
+        [hidden] { display: none; }
+    </style>
+</head>
+<body>
+    <header>
+        <h1>ClankerSaddle</h1>
+        <nav>
+            <a href="/">Dashboard</a>
+            <!-- spry:nav-begin -->
+            <a href="/projects">Projects</a>
+            <a href="/tasks">Tasks</a>
+            <a href="/saddles">Saddles</a>
+            <!-- spry:nav-end -->
+            <span stm-if="auth != null"><strong stm-text="auth.username">user</strong>
+                <button type="button" class="inline" stm-action="auth.logout">Log out</button>
+                <!-- spry:nav-auth-begin -->
+                <!-- spry:nav-auth-end -->
+            </span>
+            <a stm-if="auth == null" href="/login">Log in</a>
+        </nav>
+    </header>
+    <main>
+        <div stm-preloader>Loading…</div>
+        <div stm-content hidden>
+            <pstm-content></pstm-content>
+        </div>
+    </main>
+    <footer><p>ClankerSaddle — the agent works while you sleep.</p></footer>
+</body>
+</html>

+ 82 - 0
src/pages/project_detail.html

@@ -0,0 +1,82 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/projects/{project_id}</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2 stm-if="project != null && project.exists" stm-text="project.name">Project</h2>
+        <p stm-if="project != null && !project.exists" class="muted">Project not found.</p>
+
+        <div stm-if="project != null && project.exists">
+            <p><a href="/projects">All projects</a></p>
+            <div class="cards">
+                <section class="card">
+                    <h3>Details</h3>
+                    <p><strong stm-text="project.git_url">git_url</strong> · branch
+                        <em stm-text="project.default_branch">main</em></p>
+                    <p class="muted" stm-if="project.summary.length > 0" stm-text="project.summary">summary</p>
+                    <p class="muted">Created <span stm-text="project.created_display">when</span></p>
+                </section>
+
+                <section class="card">
+                    <h3>Edit</h3>
+                    <form stm-action="project.update">
+                        <input type="hidden" name="project_id" stm-attribute.value="project.id">
+                        <div class="form-row"><label>Name</label>
+                            <input type="text" name="name" stm-attribute.value="project.name"></div>
+                        <div class="form-row"><label>Git URL</label>
+                            <input type="text" name="git_url" stm-attribute.value="project.git_url"></div>
+                        <div class="form-row"><label>Branch</label>
+                            <input type="text" name="default_branch" stm-attribute.value="project.default_branch"></div>
+                        <div class="form-row"><label>Summary</label>
+                            <input type="text" name="summary" stm-attribute.value="project.summary"></div>
+                        <button type="submit">Save</button>
+                    </form>
+                </section>
+            </div>
+
+            <h3>Linked projects</h3>
+            <table>
+                <thead><tr><th>Project</th><th>Git URL</th><th>Relationship</th><th></th></tr></thead>
+                <tbody>
+                    <tr stm-for-link-in="project.links">
+                        <td><a stm-attribute.href="'/projects/' + link.project_id" stm-text="link.name">name</a></td>
+                        <td stm-text="link.git_url">git_url</td>
+                        <td><span stm-attribute.class="'badge badge-' + link.relationship" stm-text="link.relationship">relationship</span></td>
+                        <td><button type="button" stm-action="link.remove" stm-confirm="Remove this link?">Remove</button></td>
+                    </tr>
+                    <tr stm-if="project.links.length == 0"><td colspan="4" class="muted">No linked projects.</td></tr>
+                </tbody>
+            </table>
+            <form class="inline" stm-action="project.add_link">
+                <label for="link-target">Link project</label>
+                <select id="link-target" name="linked_project_id" required>
+                    <option stm-for-option-in="project.linkable" stm-attribute.value="option.id" stm-text="option.name">project</option>
+                </select>
+                <select name="relationship">
+                    <option value="co-developed">co-developed</option>
+                    <option value="reference">reference</option>
+                </select>
+                <button type="submit">Add link</button>
+            </form>
+
+            <h3>Tasks in this project</h3>
+            <table>
+                <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
+                <tbody>
+                    <tr stm-for-task-in="project.tasks">
+                        <td stm-text="task.id">id</td>
+                        <td><a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
+                            <span class="muted" stm-if="task.is_design_task"> · design</span></td>
+                        <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.status">status</span></td>
+                        <td stm-text="task.priority">priority</td>
+                    </tr>
+                    <tr stm-if="project.tasks.length == 0"><td colspan="4" class="muted">No tasks yet — create one from the <a href="/tasks">queue</a>.</td></tr>
+                </tbody>
+            </table>
+        </div>
+    </section>
+</body>
+</html>

+ 45 - 0
src/pages/projects.html

@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/projects</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Projects</h2>
+        <p stm-if="projects == null">Loading…</p>
+
+        <div stm-if="projects != null">
+            <details>
+                <summary><strong>New project</strong></summary>
+                <form stm-action="projects.create">
+                    <div class="form-row"><label>Name</label>
+                        <input type="text" name="name" required placeholder="web-stack"></div>
+                    <div class="form-row"><label>Git URL</label>
+                        <input type="text" name="git_url" required placeholder="https://example.com/repo.git"></div>
+                    <div class="form-row"><label>Default branch</label>
+                        <input type="text" name="default_branch" placeholder="main"></div>
+                    <div class="form-row"><label>Summary</label>
+                        <input type="text" name="summary" placeholder="What this project is for"></div>
+                    <button type="submit">Create project</button>
+                </form>
+            </details>
+
+            <table>
+                <thead><tr><th>Name</th><th>Git URL</th><th>Branch</th><th>Tasks</th></tr></thead>
+                <tbody>
+                    <tr stm-for-project-in="projects.projects">
+                        <td><a stm-attribute.href="'/projects/' + project.id" stm-text="project.name">name</a>
+                            <div class="muted" stm-if="project.summary.length > 0" stm-text="project.summary">summary</div></td>
+                        <td stm-text="project.git_url">git_url</td>
+                        <td stm-text="project.default_branch">branch</td>
+                        <td><span stm-text="project.open_tasks">0</span> open ·
+                            <span stm-text="project.done_tasks">0</span> closed</td>
+                    </tr>
+                    <tr stm-if="projects.projects.length == 0"><td colspan="4" class="muted">No projects yet.</td></tr>
+                </tbody>
+            </table>
+        </div>
+    </section>
+</body>
+</html>

+ 45 - 0
src/pages/saddles.html

@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/saddles</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Saddles</h2>
+        <p stm-if="saddles == null">Loading…</p>
+
+        <div stm-if="saddles != null">
+            <details>
+                <summary><strong>Register saddle</strong></summary>
+                <form stm-action="saddles.register">
+                    <div class="form-row"><label>Name</label>
+                        <input type="text" name="name" required placeholder="saddle-1"></div>
+                    <div class="form-row"><label>Host</label>
+                        <input type="text" name="host" required placeholder="localhost:7100"></div>
+                    <button type="submit">Register</button>
+                </form>
+            </details>
+
+            <div class="cards">
+                <section class="card" stm-for-saddle-in="saddles.saddles">
+                    <h3 stm-text="saddle.name">name</h3>
+                    <p><span stm-attribute.class="'badge badge-' + saddle.status" stm-text="saddle.status">status</span>
+                        <span class="muted" stm-text="saddle.host">host</span></p>
+                    <p stm-if="saddle.current_task_id > 0">Current task:
+                        <a stm-attribute.href="'/tasks/' + saddle.current_task_id" stm-text="saddle.current_task_title">task</a>
+                        <span class="muted" stm-if="saddle.allocated_display.length > 0" stm-text="'since ' + saddle.allocated_display">since</span></p>
+                    <p stm-if="saddle.current_task_id == 0" class="muted">No current task</p>
+                    <form class="inline">
+                        <button type="button" stm-action="saddle.refresh">Refresh status</button>
+                        <button type="button" stm-action="saddle.unregister" stm-confirm="Remove this saddle? A running task is re-queued.">Remove</button>
+                    </form>
+                </section>
+                <section class="card" stm-if="saddles.saddles.length == 0">
+                    <p class="muted">No saddles registered. The dispatcher pairs queued tasks with idle saddles automatically.</p>
+                </section>
+            </div>
+        </div>
+    </section>
+</body>
+</html>

+ 176 - 0
src/pages/task_detail.html

@@ -0,0 +1,176 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/tasks/{task_id}</pstm-uri>
+    <pstm-template>main</pstm-template>
+    <script>
+        // Live refresh: invoke the page's sealed refresh action every three
+        // seconds so comments, blocks and saddle progress stream in without
+        // a page reload. Stops once the page's slot disappears.
+        if (window.statum) {
+            window.setInterval(function () {
+                var task = statum.read('task');
+                if (task && task.refresh) {
+                    statum.act(task.refresh).catch(function () { });
+                }
+            }, 3000);
+        }
+    </script>
+</head>
+<body>
+    <section>
+        <h2 stm-if="task != null && task.exists">#<span stm-text="task.id">id</span> —
+            <span stm-text="task.title">title</span></h2>
+        <p stm-if="task != null && !task.exists" class="muted">Task not found.</p>
+
+        <div stm-if="task != null && task.exists">
+            <p><a href="/tasks">All tasks</a> · project
+                <a stm-attribute.href="'/projects/' + task.project_id" stm-text="task.project_name">project</a>
+                <span stm-if="task.parent_task_id > 0"> · subtask of
+                    <a stm-attribute.href="'/tasks/' + task.parent_task_id" stm-text="'#' + task.parent_task_id">#parent</a></span></p>
+
+            <div class="cards">
+                <section class="card">
+                    <h3>Status</h3>
+                    <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.status">status</span>
+                        · priority <strong stm-text="task.priority">5</strong>
+                        <span stm-if="task.is_design_task"> · <strong>design task</strong></span></p>
+                    <p class="muted">Created <span stm-text="task.created_display">when</span></p>
+                    <p class="muted" stm-if="task.started_display.length > 0">Started <span stm-text="task.started_display">when</span></p>
+                    <p class="muted" stm-if="task.completed_display.length > 0">Completed <span stm-text="task.completed_display">when</span></p>
+                    <p stm-if="task.working_branch.length > 0">Branch: <strong stm-text="task.working_branch">branch</strong></p>
+                    <form class="inline">
+                        <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                        <button type="button" stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
+                    </form>
+                </section>
+
+                <section class="card">
+                    <h3>Description</h3>
+                    <p stm-text="task.description">description</p>
+                    <p class="muted" stm-if="task.description.length == 0">No description.</p>
+                    <p stm-if="task.summary.length > 0"><strong>Summary:</strong> <span stm-text="task.summary">summary</span></p>
+                </section>
+
+                <section class="card">
+                    <h3>Edit</h3>
+                    <form stm-action="task.update">
+                        <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                        <div class="form-row"><label>Title</label>
+                            <input type="text" name="title" stm-attribute.value="task.title"></div>
+                        <div class="form-row"><label>Description</label>
+                            <input type="text" name="description" stm-attribute.value="task.description"></div>
+                        <div class="form-row"><label>Priority (1–9)</label>
+                            <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
+                        <div class="form-row"><label>Status</label>
+                            <select name="status" stm-attribute.value="task.status">
+                                <option value="queued">queued</option>
+                                <option value="assigned">assigned</option>
+                                <option value="working">working</option>
+                                <option value="blocked">blocked</option>
+                                <option value="done">done</option>
+                                <option value="failed">failed</option>
+                            </select></div>
+                        <button type="submit">Save</button>
+                    </form>
+                </section>
+            </div>
+
+            <h3>Blocks <span class="muted" stm-if="task.blocks.length > 0">(<span stm-text="task.blocks.length">0</span>)</span></h3>
+            <div stm-for-block-in="task.blocks" class="card">
+                <p><strong stm-text="block.question">question</strong>
+                    <span class="muted" stm-text="block.created_display">when</span></p>
+                <div stm-if="block.answered">
+                    <p>Answer: <strong stm-text="block.answer">answer</strong>
+                        <span class="muted" stm-text="block.answered_display">when</span></p>
+                </div>
+                <form stm-if="!block.answered" class="inline" stm-action="block.answer_action">
+                    <input type="text" name="answer" placeholder="Your answer for the agent" required style="min-width: 24rem">
+                    <button type="submit">Answer</button>
+                </form>
+            </div>
+            <p stm-if="task.blocks.length == 0" class="muted">No blocks — the agent has no open questions.</p>
+
+            <h3>Comments</h3>
+            <div stm-for-comment-in="task.comments">
+                <div stm-attribute.class="comment + (comment.from_bot ? ' bot' : '')">
+                    <strong stm-text="comment.author">author</strong>
+                    <span class="muted" stm-text="comment.when_display">when</span>
+                    <div stm-text="comment.body">body</div>
+                </div>
+            </div>
+            <p stm-if="task.comments.length == 0" class="muted">No comments yet.</p>
+            <form stm-action="task.comment" class="inline">
+                <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                <input type="text" name="body" placeholder="Add a comment for the agent" required style="min-width: 24rem">
+                <button type="submit">Comment</button>
+            </form>
+
+            <h3>Subtasks</h3>
+            <table>
+                <thead><tr><th>#</th><th>Title</th><th>Status</th></tr></thead>
+                <tbody>
+                    <tr stm-for-subtask-in="task.subtasks">
+                        <td stm-text="subtask.id">id</td>
+                        <td><a stm-attribute.href="'/tasks/' + subtask.id" stm-text="subtask.title">title</a></td>
+                        <td><span stm-attribute.class="'badge badge-' + subtask.status" stm-text="subtask.status">status</span></td>
+                    </tr>
+                    <tr stm-if="task.subtasks.length == 0"><td colspan="3" class="muted">No subtasks.</td></tr>
+                </tbody>
+            </table>
+            <details>
+                <summary><strong>New subtask</strong></summary>
+                <form stm-action="task.subtask">
+                    <div class="form-row"><label>Title</label>
+                        <input type="text" name="title" required></div>
+                    <div class="form-row"><label>Description</label>
+                        <input type="text" name="description"></div>
+                    <button type="submit">Create subtask</button>
+                </form>
+            </details>
+
+            <h3>Dependencies</h3>
+            <p stm-if="task.dependencies.length > 0">Depends on:
+                <span stm-for-dep-in="task.dependencies">
+                    <a stm-attribute.href="'/tasks/' + dep.id" stm-text="'#' + dep.id + ' ' + dep.title">dep</a>
+                    <span stm-attribute.class="'badge badge-' + dep.status" stm-text="dep.status">status</span>
+                </span>
+            </p>
+            <p stm-if="task.dependencies.length == 0" class="muted">No dependencies.</p>
+            <form class="inline" stm-action="task.dependency">
+                <label for="dep-target">Depends on</label>
+                <select id="dep-target" name="depends_on_task_id" required>
+                    <option stm-for-candidate-in="task.candidates" stm-attribute.value="candidate.id"
+                             stm-text="'#' + candidate.id + ' ' + candidate.title">candidate</option>
+                </select>
+                <button type="submit">Add dependency</button>
+            </form>
+
+            <h3>Sessions</h3>
+            <table>
+                <thead><tr><th>#</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th><th></th></tr></thead>
+                <tbody>
+                    <tr stm-for-session-in="task.sessions">
+                        <td stm-text="session.id">id</td>
+                        <td stm-text="session.saddle_name">saddle</td>
+                        <td stm-text="session.started_display">started</td>
+                        <td stm-text="session.ended_display">ended</td>
+                        <td stm-if="session.open"><span class="badge badge-working">open</span></td>
+                        <td stm-if="!session.open" stm-text="session.outcome">outcome</td>
+                        <td><a stm-attribute.href="'/tasks/' + task.id + '/terminal?session_id=' + session.id">Terminal</a></td>
+                    </tr>
+                    <tr stm-if="task.sessions.length == 0"><td colspan="6" class="muted">No sessions yet.</td></tr>
+                </tbody>
+            </table>
+
+            <h3>Complete</h3>
+            <form stm-action="task.complete" class="inline">
+                <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                <input type="text" name="summary" placeholder="Completion summary" style="min-width: 20rem">
+                <input type="text" name="working_branch" placeholder="Branch (optional)">
+                <button type="submit">Mark done</button>
+            </form>
+        </div>
+    </section>
+</body>
+</html>

+ 42 - 0
src/pages/task_terminal.html

@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/tasks/{task_id}/terminal</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Terminal — <span stm-text="terminal.task_title">task</span></h2>
+        <p stm-if="terminal != null && !terminal.exists" class="muted">Task not found.</p>
+
+        <div stm-if="terminal != null && terminal.exists">
+            <p><a stm-attribute.href="'/tasks/' + terminal.task_id" stm-text="'Back to task #' + terminal.task_id">task</a>
+                · session <strong stm-text="terminal.session_id">id</strong>
+                · <strong stm-text="terminal.command_count">0</strong> commands</p>
+
+            <p stm-if="terminal.sessions.length > 1">Sessions:
+                <span stm-for-session-in="terminal.sessions">
+                    <a stm-if="session.selected"
+                       stm-attribute.href="'/tasks/' + terminal.task_id + '/terminal?session_id=' + session.id"
+                       stm-text="session.label">session</a>
+                    <a stm-if="!session.selected" class="muted"
+                       stm-attribute.href="'/tasks/' + terminal.task_id + '/terminal?session_id=' + session.id"
+                       stm-text="session.label">session</a>
+                </span>
+            </p>
+            <p stm-if="terminal.sessions.length == 0" class="muted">No sessions recorded for this task yet.</p>
+
+            <div class="terminal" stm-if="terminal.sessions.length > 0">
+                <div stm-for-command-in="terminal.commands">
+                    <div class="meta"><span stm-text="command.when_display">when</span>
+                        <span stm-if="command.duration_display.length > 0" stm-text="command.duration_display">duration</span>
+                        <span stm-if="command.exit_code_display.length > 0" stm-text="command.exit_code_display">exit</span></div>
+                    <pre class="command" stm-text="command.command_text">$ command</pre>
+                    <pre stm-if="command.output_text.length > 0" stm-text="command.output_text">output</pre>
+                </div>
+                <p stm-if="terminal.commands.length == 0" class="meta">No commands captured yet.</p>
+            </div>
+        </div>
+    </section>
+</body>
+</html>

+ 76 - 0
src/pages/tasks.html

@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/tasks</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Task queue</h2>
+        <p stm-if="tasks == null">Loading…</p>
+
+        <div stm-if="tasks != null">
+            <form method="get" action="/tasks" class="inline">
+                <label for="filter-status">Status</label>
+                <select id="filter-status" name="status">
+                    <option value="all" stm-attribute.selected="tasks.filter_status == ''">all</option>
+                    <option value="queued" stm-attribute.selected="tasks.filter_status == 'queued'">queued</option>
+                    <option value="assigned" stm-attribute.selected="tasks.filter_status == 'assigned'">assigned</option>
+                    <option value="working" stm-attribute.selected="tasks.filter_status == 'working'">working</option>
+                    <option value="blocked" stm-attribute.selected="tasks.filter_status == 'blocked'">blocked</option>
+                    <option value="done" stm-attribute.selected="tasks.filter_status == 'done'">done</option>
+                    <option value="failed" stm-attribute.selected="tasks.filter_status == 'failed'">failed</option>
+                </select>
+                <label for="filter-project">Project</label>
+                <select id="filter-project" name="project_id">
+                    <option value="0" stm-attribute.selected="tasks.filter_project_id == 0">all</option>
+                    <option stm-for-project-in="tasks.projects"
+                             stm-attribute.value="project.id"
+                             stm-attribute.selected="tasks.filter_project_id == project.id"
+                             stm-text="project.name">project</option>
+                </select>
+                <button type="submit">Filter</button>
+            </form>
+
+            <table>
+                <thead><tr><th>#</th><th>Title</th><th>Project</th><th>Status</th><th>Prio</th><th>Depends on</th></tr></thead>
+                <tbody>
+                    <tr stm-for-task-in="tasks.tasks">
+                        <td stm-text="task.id">id</td>
+                        <td><a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
+                            <span class="muted" stm-if="task.is_design_task"> · design</span>
+                            <span class="muted" stm-if="task.parent_task_id > 0"> · subtask of
+                                <a stm-attribute.href="'/tasks/' + task.parent_task_id" stm-text="'#' + task.parent_task_id">#parent</a></span>
+                            <span class="muted" stm-if="task.ready"> · ready</span></td>
+                        <td stm-text="task.project_name">project</td>
+                        <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.status">status</span></td>
+                        <td stm-text="task.priority">priority</td>
+                        <td stm-if="task.has_dependencies" stm-text="task.dependency_summary">deps</td>
+                        <td stm-if="!task.has_dependencies" class="muted">—</td>
+                    </tr>
+                    <tr stm-if="tasks.tasks.length == 0"><td colspan="6" class="muted">No tasks match.</td></tr>
+                </tbody>
+            </table>
+
+            <details>
+                <summary><strong>New task</strong></summary>
+                <form stm-action="tasks.create">
+                    <div class="form-row"><label>Project</label>
+                        <select name="project_id" required>
+                            <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id" stm-text="project.name">project</option>
+                        </select></div>
+                    <div class="form-row"><label>Title</label>
+                        <input type="text" name="title" required placeholder="Implement the flange module"></div>
+                    <div class="form-row"><label>Description</label>
+                        <input type="text" name="description" placeholder="What the agent should achieve"></div>
+                    <div class="form-row"><label>Priority (1–9)</label>
+                        <input type="number" name="priority" min="1" max="9" value="5"></div>
+                    <div class="form-row"><label>Design task</label>
+                        <input type="checkbox" name="is_design_task"></div>
+                    <button type="submit">Create task</button>
+                </form>
+            </details>
+        </div>
+    </section>
+</body>
+</html>

+ 310 - 0
test/integration_test.py

@@ -0,0 +1,310 @@
+#!/usr/bin/env python3
+"""End-to-end integration test: ClankerSaddle web app + saddle daemon.
+
+Drives the web app the way statum.js does (entrypoint state loads plus
+action POSTs carrying the auth slot and sealed private blobs) while a
+real saddle daemon works on a real git checkout under WORKSPACE/demo,
+powered by saddle/test/mock_llm.py with a deterministic three-task
+script: an explore task (bash + comment + done), a dependency-gated
+follow-up, and a blocking question answered through the web UI.
+
+Prerequisites (see README.md "Integration testing"):
+- the web app running with SPRY_SEED_ADMIN_USERNAME=admin,
+  SPRY_SEED_ADMIN_PASSWORD=adminpass123, SADDLE_LLM_BASE_URL pointing at
+  the mock (below), SADDLE_PROJECTS_ROOT=$WORKSPACE
+- a saddle daemon reachable at SADDLE
+- a git repo with at least two commits at $WORKSPACE/demo
+
+Environment:
+  WEB            web app base URL      (default http://localhost:8080)
+  SADDLE         daemon control URL    (default http://localhost:7100)
+  SADDLE_HOST    host:port to register (default localhost:7100)
+  SADDLE_NAME    registration name     (default saddle-e2e-1)
+  WORKSPACE      demo repo parent dir  (default /tmp/clanker-e2e/workspace)
+  DB_PATH        web app SQLite file   (for audit assertions)
+  MOCK           mock_llm.py path      (default <repo>/../saddle/test/mock_llm.py)
+  MOCK_PORT      local mock port       (default 9999)
+  MOCK_CMD       run instead of the local mock (e.g. a podman exec line
+                 that starts the mock inside the saddle container); the
+                 script skips spawning the mock when set
+
+Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
+"""
+import json
+import os
+import subprocess
+import sqlite3
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+WEB = os.environ.get("WEB", "http://localhost:8080")
+SADDLE = os.environ.get("SADDLE", "http://localhost:7100")
+SADDLE_HOST = os.environ.get("SADDLE_HOST", "localhost:7100")
+SADDLE_NAME = os.environ.get("SADDLE_NAME", "saddle-e2e-1")
+WORKSPACE = os.environ.get("WORKSPACE", "/tmp/clanker-e2e/workspace")
+DB_PATH = os.environ.get("DB_PATH", os.path.join(WORKSPACE, "..", "web", "clanker-saddle.sqlite"))
+MOCK = os.environ.get("MOCK", os.path.join(os.path.dirname(REPO), "saddle", "test", "mock_llm.py"))
+MOCK_PORT = os.environ.get("MOCK_PORT", "9999")
+MOCK_CMD = os.environ.get("MOCK_CMD", "")
+DEMO = os.path.join(WORKSPACE, "demo")
+
+passes = 0
+failures = 0
+
+
+def check(condition, label):
+    global passes, failures
+    if condition:
+        passes += 1
+        print("PASS %s" % label)
+    else:
+        failures += 1
+        print("FAIL %s" % label)
+
+
+def http(method, url, data=None, headers=None, timeout=30):
+    body = None
+    if data is not None:
+        body = "&".join("%s=%s" % (k, urllib.parse.quote(str(v))) for k, v in data.items()).encode()
+    request = urllib.request.Request(WEB + url if url.startswith("/") else url,
+                                     data=body, method=method)
+    if data is not None:
+        request.add_header("Content-Type", "application/x-www-form-urlencoded")
+    for key, value in (headers or {}).items():
+        request.add_header(key, value)
+    try:
+        with urllib.request.urlopen(request, timeout=timeout) as response:
+            return response.status, response.read().decode()
+    except urllib.error.HTTPError as error:
+        return error.code, error.read().decode()
+
+
+def directives(uri, auth=None):
+    headers = {}
+    if auth:
+        headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
+    status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
+                        headers=headers)
+    if status != 200:
+        raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
+    return json.loads(body)
+
+
+def slot_public(auth, uri, type_name):
+    for directive in directives(uri, auth):
+        if directive["type"] == "set":
+            content = json.loads(directive["snapshot_frame"]["content"])
+            if content["slot"]["type"] == type_name:
+                return content["public"]
+    raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
+
+
+def act(auth, action, fields):
+    headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
+    if action.get("private"):
+        headers["X-Statum-Private"] = action["private"]
+    status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
+    if status != 200:
+        raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
+    return json.loads(body)
+
+
+def login(username, password):
+    frames = directives("/login")
+    content = json.loads(frames[0]["snapshot_frame"]["content"])
+    action = content["public"]["login"]
+    status, body = http(action.get("method", "POST"), action["uri"],
+                        {"username": username, "password": password})
+    if status != 200:
+        raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
+    for directive in json.loads(body):
+        if directive["type"] == "set":
+            frame = json.loads(directive["snapshot_frame"]["content"])
+            if frame["slot"]["type"] == "auth":
+                return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
+    raise SystemExit("login response carried no auth slot: " + body[:300])
+
+
+def saddle_status():
+    try:
+        status, body = http("GET", SADDLE + "/status", timeout=8)
+        return json.loads(body) if status == 200 else None
+    except (urllib.error.URLError, OSError):
+        return None
+
+
+def wait_for(predicate, timeout=60, what="condition", interval=1.0):
+    deadline = time.monotonic() + timeout
+    last = None
+    while time.monotonic() < deadline:
+        last = predicate()
+        if last is not False and last is not None:
+            return last
+        time.sleep(interval)
+    print("  (timeout waiting for %s; last: %s)" % (what, last))
+    return None
+
+
+def task_state(auth, task_id):
+    return slot_public(auth, "/tasks/%d" % task_id, "task")
+
+
+def wait_task_status(auth, task_id, status, timeout=90):
+    return wait_for(lambda: task_state(auth, task_id).get("status") == status or False,
+                    timeout=timeout, what="task %d -> %s" % (task_id, status))
+
+
+def start_mock():
+    responses = [
+        "# Inspecting the checkout\nls -la %s" % DEMO,
+        "# Checking history\ngit -C %s log --oneline" % DEMO,
+        'saddle comment "Inspected the checkout"\nsaddle done "Explored the demo repository"',
+        "# Starting the follow-up\necho task-two-running",
+        'saddle done "Follow-up audit complete"',
+        '# I need to ask\nsaddle block "Which branch should I target?"',
+    ]
+    argv = [sys.executable, MOCK, "--port", MOCK_PORT]
+    for response in responses:
+        argv += ["-r", response]
+    argv += ["--on-answer", 'saddle done "Branch confirmed: main"', "--delay", "2"]
+    return subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
+
+
+def main():
+    mock = None
+    if not MOCK_CMD:
+        mock = start_mock()
+        time.sleep(0.5)
+    else:
+        subprocess.Popen(["bash", "-c", MOCK_CMD])
+        time.sleep(1.0)
+
+    try:
+        auth = login("admin", "adminpass123")
+        check(True, "login yields an auth slot")
+
+        state = slot_public(auth, "/projects", "projects")
+        act(auth, state["create"], {
+            "name": "demo",
+            "git_url": "https://example.com/demo.git",
+            "default_branch": "main",
+            "summary": "Integration test repository",
+        })
+        check(any(p["name"] == "demo" for p in slot_public(auth, "/projects", "projects")["projects"]),
+              "project demo created")
+
+        state = slot_public(auth, "/tasks", "tasks")
+        act(auth, state["create"], {
+            "project_id": "1",
+            "title": "Explore the demo repository",
+            "description": "List the checkout and inspect its git history.",
+            "priority": "3",
+        })
+        act(auth, state["create"], {
+            "project_id": "1",
+            "title": "Follow-up audit",
+            "description": "Runs after the explore task completes.",
+            "priority": "4",
+        })
+        act(auth, state["create"], {
+            "project_id": "1",
+            "title": "Ask a blocking question",
+            "description": "Asks which branch to target, then finishes.",
+            "priority": "5",
+        })
+        titles = [t["title"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]]
+        check(titles == ["Explore the demo repository", "Follow-up audit", "Ask a blocking question"],
+              "three tasks queued in priority order")
+
+        detail = task_state(auth, 2)
+        act(auth, detail["dependency"], {"depends_on_task_id": "1"})
+        check(wait_for(lambda: any(int(d.get("id", 0)) == 1
+                                   for d in task_state(auth, 2).get("dependencies", [])) or False,
+                       timeout=5, what="dependency edge of task 2"),
+              "task 2 depends on task 1")
+
+        state = slot_public(auth, "/saddles", "saddles")
+        act(auth, state["register"], {"name": SADDLE_NAME, "host": SADDLE_HOST})
+        check(any(s["name"] == SADDLE_NAME and s["host"] == SADDLE_HOST
+                  for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
+              "saddle registered at %s" % SADDLE_HOST)
+
+        print("== explore task ==")
+        left_queue = wait_for(
+            lambda: (lambda s: s if s != "queued" else False)(task_state(auth, 1).get("status")) or False,
+            timeout=15, what="task 1 to leave the queue")
+        check(left_queue is not None, "dispatcher picks task 1 up within 15s (status %s)" % left_queue)
+        check(wait_for(lambda: (saddle_status() or {}).get("state") in ("working", "blocked", "done") or False,
+                       timeout=15, what="saddle active") is not None,
+              "saddle daemon receives the task")
+        check(wait_task_status(auth, 1, "working", timeout=30) is not None,
+              "task 1 flips to working from daemon status")
+        check(wait_task_status(auth, 1, "done", timeout=90) is not None, "task 1 completes")
+
+        detail = task_state(auth, 1)
+        check("Explored the demo repository" in (detail.get("summary") or ""),
+              "task 1 carries the agent's summary")
+        check(any(c["author"] == "bot" and "Inspected the checkout" in c["body"]
+                  for c in detail.get("comments", [])),
+              "agent comment projected into task_comments")
+        check(detail.get("sessions") and detail["sessions"][0].get("outcome") == "done",
+              "session closed with outcome done")
+        check(directives("/tasks/1/terminal", auth) is not None, "terminal page state loads")
+
+        print("== dependency gating ==")
+        check(wait_task_status(auth, 2, "done", timeout=90) is not None,
+              "task 2 (dependent) dispatches and completes after task 1")
+
+        print("== block and answer ==")
+        check(wait_task_status(auth, 3, "blocked", timeout=90) is not None, "task 3 reaches blocked")
+        detail = task_state(auth, 3)
+        open_blocks = [b for b in detail.get("blocks", []) if not b.get("answered")]
+        check(len(open_blocks) == 1 and "Which branch" in open_blocks[0]["question"],
+              "block question surfaced in the web UI")
+        act(auth, open_blocks[0]["answer_action"], {"answer": "main"})
+        check(any(b.get("answer") == "main" for b in task_state(auth, 3).get("blocks", [])),
+              "human answer recorded via the web action")
+        check(wait_task_status(auth, 3, "done", timeout=90) is not None,
+              "answer delivered to daemon; task 3 completes")
+        detail = task_state(auth, 3)
+        check("Branch confirmed: main" in (detail.get("summary") or ""),
+              "task 3 summary reflects the post-answer completion")
+
+        print("== audit trail ==")
+        db = sqlite3.connect(DB_PATH)
+        rows = db.execute(
+            "select command_text, output_text, exit_code from session_commands order by id").fetchall()
+        commands = {row[0]: row for row in rows}
+        check(any("ls -la" in c and "hello.py" in (r[1] or "") for c, r in commands.items()),
+              "ls output captured in session_commands")
+        check(any("git" in c and "Initial commit" in (r[1] or "") and r[2] == 0
+                  for c, r in commands.items()),
+              "git log output captured with exit code 0")
+        check(len([c for c in commands if "echo" in c]) >= 1, "echo output captured")
+        per_session = db.execute(
+            "select session_id, count(*) from session_commands group by session_id order by session_id"
+        ).fetchall()
+        check(all(count > 0 for _, count in per_session) and len(per_session) == 3,
+              "each session carries its own commands (%s)" % per_session)
+        db.close()
+
+        print("== quiesce ==")
+        check(wait_for(
+            lambda: all(int(s["current_task_id"] or 0) == 0 and s["status"] == "idle"
+                        for s in slot_public(auth, "/saddles", "saddles")["saddles"]) or False,
+            timeout=30, what="saddle idle with no current task") is not None,
+            "saddle freed and idle once every task finished")
+    finally:
+        if mock is not None:
+            mock.terminate()
+
+    print("%d passed, %d failed" % (passes, failures))
+    return 1 if failures else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 10 - 0
usm-scripts/build.sh

@@ -0,0 +1,10 @@
+#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+
+cd ${build_dir}
+
+meson setup ${src_dir} --prefix=${PREFIX} --libdir=${LIBDIR} --bindir=${BINDIR} --includedir=${INCLUDEDIR}
+ninja

+ 10 - 0
usm-scripts/install.sh

@@ -0,0 +1,10 @@
+#!/bin/bash
+set -e
+
+src_dir=$(pwd)
+build_dir=$1
+install_dir=$2
+
+cd ${build_dir}
+
+meson install --destdir ${install_dir}