Преглед изворни кода

feat: SSE (terminal stream + pulse), task plans with stm-markdown, typed blocks (question/RFC/review), progress updates, terminal highlight-to-comment, toast notifications, auth tokens, workspace pause/resume, UI overhaul

clanker пре 1 месец
родитељ
комит
bd6d54da86

+ 16 - 1
meson.build

@@ -81,6 +81,17 @@ saddles_page = custom_target('saddles-page',
 )
 # spry:pages-end
 
+# =============================================================================
+# Embedded resources (statum-mkres): the stylesheet precompressed at build
+# time and served from /_statum/resource/app.css.
+# =============================================================================
+app_css = custom_target('app-css',
+    input: 'src/pages/app.css',
+    output: 'AppCss.vala',
+    command: [statum_mkres, '-o', '@OUTPUT@', '-n', 'app.css', '--class-name', 'AppCss',
+              '--ns', 'ClankerSaddle', '-c', 'text/css', '@INPUT@'],
+)
+
 # =============================================================================
 # Sources
 # =============================================================================
@@ -90,16 +101,20 @@ app_sources = files(
     'src/Json.vala',
     'src/Data/Entities.vala',
     'src/Data/Migrations/M0001_Initial.vala',
+    'src/Data/Migrations/M0002_Part2.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/SaddleService.vala',
     'src/Services/SaddlePollState.vala',
+    'src/Services/StatePulse.vala',
     'src/Services/Dispatcher.vala',
     'src/Api/SaddleApi.vala',
+    'src/Api/TerminalSse.vala',
     'src/actions/Forms.vala',
     'src/actions/LoginAction.vala',
     'src/actions/ProjectActions.vala',
     'src/actions/TaskActions.vala',
+    'src/actions/DashboardActions.vala',
     'src/actions/SaddleActions.vala',
     'src/entrypoints/HomeEntrypoint.vala',
     'src/entrypoints/LoginEntrypoint.vala',
@@ -125,7 +140,7 @@ app_generated = [
 ]
 
 executable('clanker-saddle',
-    app_sources + app_generated,
+    app_sources + app_generated + [app_css],
     dependencies: [
         glib_dep,
         gobject_dep,

+ 121 - 15
src/Api/SaddleApi.vala

@@ -10,23 +10,28 @@ 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:
+     * daemon, authorised by its `Authorization: Bearer <token>` header
+     * (see {@link SaddleService.validate_token}). 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,
+     *     "command": "ls -la", "output": "…", "exit_code": 0, "type": "command",
      *     "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).
+     * (question/rfc/review with optional `options` and markdown `body`;
+     * returns the new `block_id`), `subtask`, `plan`, `progress`,
+     * `review`, `done` (completes the task) and `status`
+     * (working|blocked). Conversation-level types also append a typed
+     * entry to the session's terminal log.
      */
     public class SaddleUpdateEndpoint : Object, Endpoint {
 
         protected TaskService tasks = inject<TaskService>();
+        protected SaddleService saddles = inject<SaddleService>();
         protected InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
 
         public virtual async HttpResult handle_request(HttpContext http_context,
@@ -35,6 +40,10 @@ namespace ClankerSaddle.Api {
             route_context.mapped_parameters.try_get("id", out id_text);
             int64 saddle_id = int64.parse(id_text);
 
+            if (!(yield saddles.validate_token(saddle_id, bearer_token(http_context)))) {
+                return json_error(401, "Missing or invalid saddle token");
+            }
+
             Json.Object body;
             try {
                 body = yield read_json_body(http_context);
@@ -73,16 +82,22 @@ namespace ClankerSaddle.Api {
                     break;
 
                 case "comment":
-                    yield tasks.add_comment(task_id, "bot",
-                        get_string_or(data, "body", get_string_or(data, "text", "")));
+                    string body_text = get_string_or(data, "body", get_string_or(data, "text", ""));
+                    yield tasks.add_comment(task_id, "bot", body_text);
+                    yield tasks.log_session_entry(task_id, "comment", "saddle comment", body_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;
+                    var block = yield tasks.add_block(task_id,
+                        get_string_or(data, "question", ""),
+                        get_string_or(data, "type", "question"),
+                        string_options(data),
+                        get_string_or(data, "body", null));
+                    yield tasks.log_session_entry(task_id, "block",
+                        @"block: $(block.question)", block.body);
+                    var block_response = new Json.Object();
+                    block_response.set_int_member("block_id", block.id);
+                    return block_response;
 
                 case "subtask":
                     yield tasks.create_subtask(task_id,
@@ -90,6 +105,26 @@ namespace ClankerSaddle.Api {
                         get_string_or(data, "description", null));
                     break;
 
+                case "plan":
+                    yield tasks.update_plan(task_id, get_string_or(data, "plan",
+                        get_string_or(data, "markdown", "")));
+                    yield tasks.log_session_entry(task_id, "plan", "saddle plan",
+                        get_string_or(data, "plan", get_string_or(data, "markdown", "")));
+                    break;
+
+                case "progress":
+                    string summary = get_string_or(data, "summary",
+                        get_string_or(data, "text", ""));
+                    yield tasks.add_progress(task_id, summary);
+                    yield tasks.log_session_entry(task_id, "progress", "saddle progress-update", summary);
+                    break;
+
+                case "review":
+                    string review_summary = get_string_or(data, "summary", "");
+                    yield tasks.request_review(task_id, review_summary);
+                    yield tasks.log_session_entry(task_id, "review", "saddle review", review_summary);
+                    break;
+
                 case "done":
                     yield tasks.complete_task(task_id,
                         get_string_or(data, "summary", null),
@@ -119,6 +154,7 @@ namespace ClankerSaddle.Api {
                 exit_code = entry.has_member("exit_code")
                     && entry.get_member("exit_code").get_value_type() == typeof(int64)
                     ? entry.get_int_member("exit_code") : 0,
+                entry_type = get_string_or(entry, "type", "command"),
                 started_at = parse_iso(entry, "started_at"),
                 completed_at = parse_iso(entry, "completed_at"),
                 duration_ms = entry.has_member("duration_ms")
@@ -128,6 +164,46 @@ namespace ClankerSaddle.Api {
             yield db.insert_async<SessionCommand>(command);
         }
 
+        /** The `options` member as a string array (native or JSON-encoded). */
+        private static string[] string_options(Json.Object data) {
+            if (!data.has_member("options")) {
+                return new string[0];
+            }
+            var member = data.get_member("options");
+            if (member.get_node_type() == Json.NodeType.ARRAY) {
+                var values = new Vector<string>();
+                foreach (var element in member.get_array().get_elements()) {
+                    if (element.get_node_type() == Json.NodeType.VALUE
+                            && element.get_string() != null) {
+                        values.add(element.get_string());
+                    }
+                }
+                return values.to_array();
+            }
+            string raw = get_string_or(data, "options", "");
+            if (raw.length == 0) {
+                return new string[0];
+            }
+            try {
+                var parser = new Json.Parser();
+                parser.load_from_data(raw);
+                var root = parser.get_root();
+                if (root != null && root.get_node_type() == Json.NodeType.ARRAY) {
+                    var values = new Vector<string>();
+                    foreach (var element in root.get_array().get_elements()) {
+                        if (element.get_node_type() == Json.NodeType.VALUE
+                                && element.get_string() != null) {
+                            values.add(element.get_string());
+                        }
+                    }
+                    return values.to_array();
+                }
+            } catch (Error e) {
+                warning("[ClankerSaddle] Ignoring malformed block options: %s", e.message);
+            }
+            return new string[0];
+        }
+
         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",
@@ -212,11 +288,14 @@ namespace ClankerSaddle.Api {
 
     /**
      * `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.
+     * daemon, authorised by its `Authorization: Bearer <token>` header:
+     * answered blocks and undelivered human comments for the saddle's
+     * current task, each handed over exactly once.
      *
      * ```json
-     * {"answers": [{"block_id": 4, "answer": "Use the flange tool"}]}
+     * {"answers": [{"block_id": 4, "answer": "Use the flange tool"}],
+     *  "comments": [{"comment_id": 9, "author": "user:admin",
+     *                "body": "…", "quote": "…"}]}
      * ```
      */
     public class SaddlePollEndpoint : Object, Endpoint {
@@ -229,7 +308,12 @@ namespace ClankerSaddle.Api {
             route_context.mapped_parameters.try_get("id", out id_text);
             int64 saddle_id = int64.parse(id_text);
 
+            if (!(yield saddles.validate_token(saddle_id, bearer_token(http_context)))) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
+            }
+
             var answers = new Json.Array();
+            var comments = new Json.Array();
             try {
                 foreach (var pending in yield saddles.take_pending_answers(saddle_id)) {
                     var element = new Json.Object();
@@ -237,13 +321,35 @@ namespace ClankerSaddle.Api {
                     element.set_string_member("answer", pending.answer);
                     answers.add_object_element(element);
                 }
+                foreach (var pending in yield saddles.take_pending_comments(saddle_id)) {
+                    var element = new Json.Object();
+                    element.set_int_member("comment_id", pending.comment_id);
+                    element.set_string_member("author", pending.author);
+                    element.set_string_member("body", pending.body);
+                    element.set_string_member("quote", pending.quote);
+                    comments.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);
+            body.set_array_member("comments", comments);
             return SaddleUpdateEndpoint.json_ok(body);
         }
     }
+
+    /**
+     * The request's bearer token: the `Authorization` header minus its
+     * `Bearer ` prefix, or "" when the header is missing or shaped
+     * differently.
+     */
+    private static string bearer_token(HttpContext http_context) {
+        string? header = http_context.request.get_header("Authorization");
+        if (header == null || !((!)header).has_prefix("Bearer ")) {
+            return "";
+        }
+        return ((!)header).substring("Bearer ".length).strip();
+    }
 }

+ 90 - 0
src/Api/TerminalSse.vala

@@ -0,0 +1,90 @@
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using Astralis;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Api {
+
+    /**
+     * `GET /terminal-stream/{session_id}` — a Server-Sent Events stream of
+     * one session's terminal log. Every existing `session_commands` row is
+     * pushed as an initial event; the connection then stays open, polling
+     * the database each second and streaming entries newer than the id
+     * cursor until the client disconnects.
+     *
+     * Each event carries `id: <entry id>` (so EventSource reconnects
+     * resume from the last seen entry) and a `data` payload of
+     * `{"id", "command", "output", "type"}` — the entry types the terminal
+     * page renders with per-type styling (command, comment, block,
+     * user-comment, progress, review, plan).
+     */
+    public class TerminalSseEndpoint : SseEndpoint {
+
+        private OrmSession db = inject<OrmSession>();
+
+        /** Reconnect hint for browsers (EventSource retries sooner). */
+        public override uint retry_interval { get { return 3000; } }
+
+        public override async void new_connection(HttpContext http_context,
+                RouteContext route_context, SseStream stream) {
+            string id_text = "";
+            route_context.mapped_parameters.try_get("session_id", out id_text);
+            int64 session_id = int64.parse(id_text);
+
+            int64 cursor = 0;
+            try {
+                while (!stream.is_closed) {
+                    bool pushed = false;
+                    foreach (var entry in yield db.query<SessionCommand>()
+                            .where(expr("session_id == $0 && id > $1",
+                                elem<int64?>(session_id), elem<int64?>(cursor)))
+                            .order_by(expr("id"))
+                            .materialise_async()) {
+                        yield stream.send_event(new SseEvent.with_id(
+                            entry.id.to_string(), payload(entry)));
+                        cursor = entry.id;
+                        pushed = true;
+                    }
+                    if (!pushed) {
+                        // Heartbeat: detects disconnected clients even when
+                        // the session is quiet.
+                        yield stream.send_event(new SseEvent("ping"));
+                    }
+                    yield nap_seconds(1);
+                }
+            } catch (Error e) {
+                // A closed stream ends the loop; anything else is logged.
+                if (!stream.is_closed) {
+                    warning(@"[ClankerSaddle] Terminal stream for session $(session_id) ended: $(e.message)");
+                }
+            }
+        }
+
+        /** The entry as a one-line JSON object payload. */
+        private static string payload(SessionCommand entry) {
+            var builder = new Json.Builder();
+            builder.begin_object();
+            builder.set_member_name("id");
+            builder.add_int_value(entry.id);
+            builder.set_member_name("command");
+            builder.add_string_value(entry.command_text);
+            builder.set_member_name("output");
+            builder.add_string_value(entry.output_text ?? "");
+            builder.set_member_name("type");
+            builder.add_string_value(entry.entry_type);
+            builder.end_object();
+            var generator = new Json.Generator();
+            generator.set_root(builder.get_root());
+            return generator.to_data(null);
+        }
+
+        private static async void nap_seconds(uint seconds) {
+            GLib.Timeout.add_seconds(seconds, () => {
+                nap_seconds.callback();
+                return false;
+            });
+            yield;
+        }
+    }
+}

+ 64 - 4
src/Data/Entities.vala

@@ -65,6 +65,9 @@ namespace ClankerSaddle.Data {
         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 string? plan { get; set; default = null; }
+        public string? task_branch { get; set; default = null; }
+        public string? branch_from { 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; }
@@ -81,12 +84,35 @@ namespace ClankerSaddle.Data {
                 .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<string>("plan", o => o.plan, (o, v) => o.plan = v)
+                .column<string>("task_branch", o => o.task_branch, (o, v) => o.task_branch = v)
+                .column<string>("branch_from", o => o.branch_from, (o, v) => o.branch_from = 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);
         }
     }
 
+    /**
+     * One LLM-reported progress line on a task (`saddle progress-update`),
+     * stored in the `task_progress` table.
+     */
+    public class TaskProgress : Object {
+
+        public int64 id { get; set; }
+        public int64 task_id { get; set; }
+        public string summary { get; set; default = ""; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<TaskProgress> cfg) {
+            cfg.table("task_progress")
+                .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>("summary", o => o.summary, (o, v) => o.summary = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
     /** An edge saying one task must finish before another may start. */
     public class TaskDependency : Object {
 
@@ -109,6 +135,7 @@ namespace ClankerSaddle.Data {
         public int64 task_id { get; set; }
         public string author { get; set; default = "bot"; }
         public string body { get; set; default = ""; }
+        public string? quote { get; set; default = null; }
         public DateTime? created_at { get; set; default = null; }
 
         public static void entity_mapping(EntityMapperBuilder<TaskComment> cfg) {
@@ -117,13 +144,16 @@ namespace ClankerSaddle.Data {
                 .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<string>("quote", o => o.quote, (o, v) => o.quote = 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
+     * A blocking prompt raised by an agent mid-task — a multiple-choice
+     * {@link block_type} question, a request for comment or a review
+     * request — 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 {
@@ -132,6 +162,9 @@ namespace ClankerSaddle.Data {
         public int64 task_id { get; set; }
         public string question { get; set; default = ""; }
         public string? answer { get; set; default = null; }
+        public string block_type { get; set; default = "question"; }
+        public string? options { get; set; default = null; }
+        public string? body { get; set; default = null; }
         public DateTime? answered_at { get; set; default = null; }
         public DateTime? created_at { get; set; default = null; }
 
@@ -141,6 +174,9 @@ namespace ClankerSaddle.Data {
                 .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<string>("type", o => o.block_type, (o, v) => o.block_type = v)
+                .column<string>("options", o => o.options, (o, v) => o.options = v)
+                .column<string>("body", o => o.body, (o, v) => o.body = 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);
         }
@@ -197,8 +233,10 @@ namespace ClankerSaddle.Data {
     }
 
     /**
-     * A numbered bash command/output pair captured during a
-     * {@link Session}, stored as the saddle daemon reports it.
+     * A numbered entry captured during a {@link Session} — a bash
+     * command/output pair or a conversation-level event (comment, block,
+     * progress, review, plan, user-comment) — stored as the saddle daemon
+     * reports it.
      */
     public class SessionCommand : Object {
 
@@ -207,6 +245,7 @@ namespace ClankerSaddle.Data {
         public string command_text { get; set; default = ""; }
         public string? output_text { get; set; default = null; }
         public int64 exit_code { get; set; default = 0; }
+        public string entry_type { get; set; default = "command"; }
         public DateTime? started_at { get; set; default = null; }
         public DateTime? completed_at { get; set; default = null; }
         public int64 duration_ms { get; set; default = 0; }
@@ -218,9 +257,30 @@ namespace ClankerSaddle.Data {
                 .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<string>("type", o => o.entry_type, (o, v) => o.entry_type = 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);
         }
     }
+
+    /**
+     * A bearer token authorising one saddle daemon's calls against the
+     * `/api/saddle/*` endpoints, generated at registration.
+     */
+    public class ApiToken : Object {
+
+        public int64 id { get; set; }
+        public int64 saddle_id { get; set; }
+        public string token { get; set; default = ""; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<ApiToken> cfg) {
+            cfg.table("api_tokens")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("saddle_id", o => o.saddle_id, (o, v) => o.saddle_id = v)
+                .column<string>("token", o => o.token, (o, v) => o.token = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
 }

+ 88 - 0
src/Data/Migrations/M0002_Part2.vala

@@ -0,0 +1,88 @@
+using InvercargillSql.Migrations;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Part 2 schema: task plans and branch fields, progress history, typed
+     * blocks (question/rfc/review), quoted comments, typed terminal entries
+     * and per-saddle API tokens.
+     */
+    public class M0002_Part2 : Migration {
+
+        public override string name { get { return "Part 2: plans, progress, typed blocks, tokens"; } }
+        public override uint64 serial { get { return 2; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        public override void up(MigrationBuilder b) {
+            b.alter_table("tasks", (t) => {
+                t.add_column<string>("plan");
+                t.add_column<string>("task_branch");
+                t.add_column<string>("branch_from");
+            });
+
+            b.create_table("task_progress", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .references("tasks", "id")
+                    .indexed();
+                t.column<string>("summary")
+                    .not_null();
+                t.column<DateTime?>("created_at");
+            });
+
+            b.alter_table("task_blocks", (t) => {
+                t.add_column<string>("type")
+                    .default_value<string>("question");
+                t.add_column<string>("options");
+                t.add_column<string>("body");
+            });
+
+            b.alter_table("task_comments", (t) => {
+                t.add_column<string>("quote");
+            });
+
+            b.alter_table("session_commands", (t) => {
+                t.add_column<string>("type")
+                    .default_value<string>("command");
+            });
+
+            b.create_table("api_tokens", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("saddle_id")
+                    .not_null()
+                    .references("saddles", "id")
+                    .indexed();
+                t.column<string>("token")
+                    .not_null()
+                    .unique();
+                t.column<DateTime?>("created_at");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.drop_table("api_tokens");
+            b.alter_table("session_commands", (t) => {
+                t.drop_column("type");
+            });
+            b.alter_table("task_comments", (t) => {
+                t.drop_column("quote");
+            });
+            b.alter_table("task_blocks", (t) => {
+                t.drop_column("body");
+                t.drop_column("options");
+                t.drop_column("type");
+            });
+            b.drop_table("task_progress");
+            b.alter_table("tasks", (t) => {
+                t.drop_column("branch_from");
+                t.drop_column("task_branch");
+                t.drop_column("plan");
+            });
+        }
+    }
+}

+ 34 - 0
src/Json.vala

@@ -35,5 +35,39 @@ namespace ClankerSaddle {
             }
             return new JsonElement.from_elements(arr);
         }
+
+        /**
+         * A JSON array of plain strings (e.g. a block's multiple-choice
+         * options).
+         */
+        public static JsonElement strings(string[] values) throws GLib.Error {
+            var arr = new JsonArray();
+            foreach (var value in values) {
+                arr.add(new JsonElement.from_element(new NativeElement<string>(value)));
+            }
+            return new JsonElement.from_elements(arr);
+        }
+
+        /**
+         * Per-row enrichment hook for {@link list_rich}: after each item
+         * is mapped, the enricher may add array/complex values the plain
+         * GObject mapping cannot serialise (e.g. a block's option list).
+         */
+        public delegate void PropertyEnricher(Object item, PropertyDictionary props) throws GLib.Error;
+
+        /**
+         * Like {@link list}, but every row's property dictionary passes
+         * through `enrich` before serialisation so callers can attach
+         * fields GObjectMapping drops (arrays, nested objects).
+         */
+        public static JsonElement list_rich<T>(Lot<T> items, owned PropertyEnricher enrich) throws GLib.Error {
+            var arr = new JsonArray();
+            foreach (var item in items) {
+                var props = (PropertyDictionary) GObjectMapping.to_properties((Object) item);
+                enrich((Object) item, props);
+                arr.add(new JsonElement.from_properties(props));
+            }
+            return new JsonElement.from_elements(arr);
+        }
     }
 }

+ 47 - 4
src/Services/Dispatcher.vala

@@ -7,7 +7,9 @@ namespace ClankerSaddle.Services {
 
     /**
      * The task dispatcher: a five-second timer that pairs ready tasks with
-     * idle saddles and keeps busy saddles' output in sync.
+     * idle saddles, resumes answered-but-paused tasks on the saddle that
+     * holds their parked workspace, pushes human comments to busy daemons
+     * 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
@@ -60,9 +62,11 @@ namespace ClankerSaddle.Services {
 
         /**
          * 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).
+         * idle saddle, open a session, push the assignment, then resume
+         * answered-but-paused tasks on the saddle that holds their parked
+         * workspace, and 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();
@@ -100,6 +104,7 @@ namespace ClankerSaddle.Services {
                     try {
                         yield saddles.seed_session_cursor(((!)saddle).id, session.id);
                         yield saddles.push_task_to_saddle(((!)saddle).id, task, session.id, projects);
+                        yield saddles.mark_task_comments_delivered(task.id);
                         record_strike(((!)saddle).id, 0);
                         print(@"[ClankerSaddle] Dispatched task $(task.id) to saddle '$(((!)saddle).name)' (session $(session.id))\n");
                     } catch (Error e) {
@@ -109,9 +114,12 @@ namespace ClankerSaddle.Services {
                 }
             }
 
+            yield resume_answered_tasks(tasks, saddles, projects);
+
             foreach (var saddle in yield saddles.list_busy_saddles()) {
                 try {
                     yield saddles.poll_saddle_updates(saddle.id, tasks);
+                    yield saddles.deliver_pending_comments(saddle.id, saddle.current_task_id);
                     record_strike(saddle.id, 0);
                 } catch (Error e) {
                     int count = strike_count(saddle.id) + 1;
@@ -124,6 +132,41 @@ namespace ClankerSaddle.Services {
             }
         }
 
+        /**
+         * Resumes blocked/review-requested tasks whose newest block has
+         * been answered, on the saddle holding their open (parked)
+         * session — the workspace-pause counterpart of the dispatch loop.
+         */
+        private async void resume_answered_tasks(TaskService tasks, SaddleService saddles,
+                ProjectService projects) throws Error {
+            var candidates = new Vector<TaskEntity>();
+            foreach (var status in new string[] { STATUS_BLOCKED, STATUS_REVIEW_REQUESTED }) {
+                foreach (var task in yield tasks.list_tasks(status, null)) {
+                    candidates.add(task);
+                }
+            }
+
+            foreach (var task in candidates) {
+                TaskBlock? newest = null;
+                foreach (var block in yield tasks.list_blocks(task.id)) {
+                    newest = block;
+                }
+                if (newest == null || ((!)newest).answer == null) {
+                    continue;
+                }
+
+                var session = yield tasks.open_session_for_task(task.id);
+                if (session == null) {
+                    continue;
+                }
+                try {
+                    yield saddles.resume_answered_task(task, (!)session, tasks, projects);
+                } catch (Error e) {
+                    warning(@"[ClankerSaddle] Resuming task $(task.id) failed: $(e.message)");
+                }
+            }
+        }
+
         /**
          * Declares a saddle unreachable once its failures accumulated:
          * the task it held returns to the queue and its session closes.

+ 26 - 3
src/Services/SaddlePollState.vala

@@ -6,9 +6,9 @@ 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).
+     * `session_commands` rows), the block answers and human comments
+     * already handed to a saddle (each 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
@@ -18,6 +18,7 @@ namespace ClankerSaddle.Services {
 
         private Dictionary<string, string> last_stored_output_id = new Dictionary<string, string>();
         private HashSet<string> delivered_answers = new HashSet<string>();
+        private HashSet<string> delivered_comments = new HashSet<string>();
         private Mutex state_lock = Mutex();
 
         /** The last output entry id stored for `session_id` (0 when none). */
@@ -57,5 +58,27 @@ namespace ClankerSaddle.Services {
             state_lock.unlock();
             return already;
         }
+
+        /**
+         * Marks `comment_id` as delivered to its saddle; returns false
+         * when it had already been handed over.
+         */
+        public bool mark_comment_delivered(int64 comment_id) {
+            state_lock.lock();
+            bool already = delivered_comments.has(comment_id.to_string());
+            if (!already) {
+                delivered_comments.add(comment_id.to_string());
+            }
+            state_lock.unlock();
+            return !already;
+        }
+
+        /** True when `comment_id` was already handed over. */
+        public bool is_comment_delivered(int64 comment_id) {
+            state_lock.lock();
+            bool already = delivered_comments.has(comment_id.to_string());
+            state_lock.unlock();
+            return already;
+        }
     }
 }

+ 363 - 30
src/Services/SaddleService.vala

@@ -14,6 +14,20 @@ namespace ClankerSaddle.Services {
         public string answer { get; set; default = ""; }
     }
 
+    /** A human comment awaiting delivery to one saddle's conversation. */
+    public class PendingComment : Object {
+        public int64 comment_id { get; set; }
+        public string author { get; set; default = ""; }
+        public string body { get; set; default = ""; }
+        public string quote { get; set; default = ""; }
+    }
+
+    /** A saddle registration plus the API token generated for it. */
+    public class SaddleRegistration : Object {
+        public Saddle saddle { get; set; }
+        public string token { get; set; default = ""; }
+    }
+
     /**
      * The OpenAI-compatible endpoint every saddle assignment is sent to,
      * read from the environment: `SADDLE_LLM_BASE_URL` (required — dispatch
@@ -52,16 +66,72 @@ namespace ClankerSaddle.Services {
         private OrmSession db = inject<OrmSession>();
         private Soup.Session http = new Soup.Session();
         private SaddlePollState poll_state = inject<SaddlePollState>();
+        private StatePulse pulse = inject<StatePulse>();
 
-        /** Registers a saddle container; `host` is host:port of its control server. */
-        public async Saddle register_saddle(string name, string host) throws Error {
+        /**
+         * Registers a saddle container and generates its API token.
+         * `host` is host:port of its control server; the returned
+         * {@link SaddleRegistration} carries the token the daemon must
+         * present as `Authorization: Bearer …` on `/api/saddle/*` calls.
+         */
+        public async SaddleRegistration 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;
+            string token = yield generate_token(saddle.id);
+            yield pulse.bump(0, "saddle registered");
+            return new SaddleRegistration() { saddle = saddle, token = token };
+        }
+
+        /**
+         * Generates and stores a fresh API token for `saddle_id`,
+         * replacing any earlier one on the wire (older rows remain valid
+         * until removed; in practice each saddle holds one).
+         */
+        public async string generate_token(int64 saddle_id) throws Error {
+            var row = new ApiToken() {
+                saddle_id = saddle_id,
+                token = @"saddle-$(Uuid.string_random().replace("-", ""))",
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<ApiToken>(row);
+            return row.token;
+        }
+
+        /**
+         * True when `token` matches a stored {@link ApiToken} row of
+         * `saddle_id` — the credential check for the `/api/saddle/*`
+         * endpoints.
+         */
+        public async bool validate_token(int64 saddle_id, string token) throws Error {
+            if (token.length == 0) {
+                return false;
+            }
+            var rows = yield db.query<ApiToken>()
+                .where(expr("saddle_id == $0", elem<int64?>(saddle_id)))
+                .materialise_async();
+            foreach (var row in rows) {
+                if (row.token == token) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /** The most recent API token issued for `saddle_id`, or null. */
+        public async string? token_for_saddle(int64 saddle_id) throws Error {
+            ApiToken? latest = null;
+            var rows = yield db.query<ApiToken>()
+                .where(expr("saddle_id == $0", elem<int64?>(saddle_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+            foreach (var row in rows) {
+                latest = row;
+            }
+            return latest != null ? ((!)latest).token : null;
         }
 
         /** Removes a saddle registration; tasks it was running are re-queued. */
@@ -75,12 +145,14 @@ namespace ClankerSaddle.Services {
                     .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_WORKING || ((!)task).status == STATUS_BLOCKED
+                        || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
                     ((!)task).status = STATUS_QUEUED;
                     yield db.update_async<TaskEntity>((!)task);
                 }
             }
             yield db.delete_async<Saddle>((!)saddle);
+            yield pulse.bump(0, "saddle removed");
         }
 
         /** Loads the saddle identified by `id`, or null. */
@@ -121,6 +193,7 @@ namespace ClankerSaddle.Services {
             ((!)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);
+            yield pulse.bump(current_task_id ?? 0, @"saddle $(status)");
         }
 
         /** The first saddle with status 'idle', or null when none is free. */
@@ -147,7 +220,8 @@ namespace ClankerSaddle.Services {
                     .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_WORKING || ((!)task).status == STATUS_BLOCKED
+                        || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
                     ((!)task).status = STATUS_QUEUED;
                     yield db.update_async<TaskEntity>((!)task);
                 }
@@ -168,16 +242,19 @@ namespace ClankerSaddle.Services {
             ((!)saddle).current_task_id = 0;
             ((!)saddle).allocated_at = null;
             yield db.update_async<Saddle>((!)saddle);
+            yield pulse.bump(0, "saddle unreachable");
         }
 
         /**
          * 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.
+         * project, co-developed/reference links, branch fields, plan and
+         * recent comments inside the task object, plus any block answers
+         * not yet handed over (a resumed workspace picks its pending
+         * answers up from the assignment itself). 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 {
@@ -216,6 +293,12 @@ namespace ClankerSaddle.Services {
             payload.add_string_value(((!)project).name);
             payload.set_member_name("project_branch");
             payload.add_string_value(((!)project).default_branch);
+            payload.set_member_name("task_branch");
+            payload.add_string_value(task.task_branch ?? "");
+            payload.set_member_name("branch_from");
+            payload.add_string_value(task.branch_from ?? ((!)project).default_branch);
+            payload.set_member_name("plan");
+            payload.add_string_value(task.plan ?? "");
 
             var co_developed = new Vector<Project>();
             var references = new Vector<Project>();
@@ -259,6 +342,22 @@ namespace ClankerSaddle.Services {
                 payload.add_string_value(comment.author);
                 payload.set_member_name("body");
                 payload.add_string_value(comment.body);
+                if (comment.quote != null) {
+                    payload.set_member_name("quote");
+                    payload.add_string_value(((!)comment.quote));
+                }
+                payload.end_object();
+            }
+            payload.end_array();
+
+            payload.set_member_name("answers");
+            payload.begin_array();
+            foreach (var pending in yield take_pending_answers_for_task(task.id)) {
+                payload.begin_object();
+                payload.set_member_name("block_id");
+                payload.add_int_value(pending.block_id);
+                payload.set_member_name("answer");
+                payload.add_string_value(pending.answer);
                 payload.end_object();
             }
             payload.end_array();
@@ -302,13 +401,14 @@ namespace ClankerSaddle.Services {
         /**
          * 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.
+         * projects the agent's conversation-level 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 parks the saddle
+         * (idle, available for other work) until the dispatcher resumes
+         * the task. 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);
@@ -337,12 +437,13 @@ namespace ClankerSaddle.Services {
                     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 ensure_block(tasks, task_id, status);
                     yield tasks.update_task(task_id, null, null, null, STATUS_BLOCKED);
+                    // The daemon parks the task's workspace while blocked,
+                    // freeing the saddle for other work. The blocked task
+                    // plus its open session is the pause record the
+                    // dispatcher resumes from once the block is answered.
+                    yield update_saddle_status(saddle_id, "idle", null);
                     break;
                 case "working":
                     var task = yield tasks.update_task(task_id, null, null, null, STATUS_WORKING);
@@ -382,6 +483,7 @@ namespace ClankerSaddle.Services {
                 ((!)saddle).status = "offline";
             }
             yield db.update_async<Saddle>((!)saddle);
+            yield pulse.bump(0, "saddle status refreshed");
         }
 
         /**
@@ -393,9 +495,18 @@ namespace ClankerSaddle.Services {
             if (saddle == null || ((!)saddle).current_task_id == 0) {
                 return new Vector<PendingAnswer>().to_immutable_buffer();
             }
+            return yield take_pending_answers_for_task(((!)saddle).current_task_id);
+        }
 
+        /**
+         * Answered blocks of `task_id` not yet handed over anywhere;
+         * marks them delivered. Used both by the poll endpoint and by
+         * {@link push_task_to_saddle} (a resumed assignment carries its
+         * pending answers in the payload).
+         */
+        public async ImmutableLot<PendingAnswer> take_pending_answers_for_task(int64 task_id) throws Error {
             var blocks = yield db.query<TaskBlock>()
-                .where(expr("task_id == $0", elem<int64?>(((!)saddle).current_task_id)))
+                .where(expr("task_id == $0", elem<int64?>(task_id)))
                 .materialise_async();
             var rows = new Vector<PendingAnswer>();
             foreach (var block in blocks) {
@@ -413,6 +524,142 @@ namespace ClankerSaddle.Services {
             return rows.to_immutable_buffer();
         }
 
+        /**
+         * Human comments of the saddle's current task not yet delivered to
+         * the daemon; marks them delivered (the poll response hands them
+         * over exactly once).
+         */
+        public async ImmutableLot<PendingComment> take_pending_comments(int64 saddle_id) throws Error {
+            var saddle = yield get_saddle(saddle_id);
+            if (saddle == null || ((!)saddle).current_task_id == 0) {
+                return new Vector<PendingComment>().to_immutable_buffer();
+            }
+            var comments = yield db.query<TaskComment>()
+                .where(expr("task_id == $0", elem<int64?>(((!)saddle).current_task_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+            var rows = new Vector<PendingComment>();
+            foreach (var comment in comments) {
+                if (!comment.author.has_prefix("user:")
+                        || poll_state.is_comment_delivered(comment.id)) {
+                    continue;
+                }
+                poll_state.mark_comment_delivered(comment.id);
+                rows.add(new PendingComment() {
+                    comment_id = comment.id,
+                    author = comment.author,
+                    body = comment.body,
+                    quote = comment.quote ?? ""
+                });
+            }
+            return rows.to_immutable_buffer();
+        }
+
+        /**
+         * Pushes the task's undelivered human comments to the daemon's
+         * `POST /notify` endpoint so they land in the live conversation
+         * without waiting for its next poll. Best effort: a daemon
+         * without the route (HTTP 404/405) keeps the comments pending for
+         * the poll endpoint, and other failures are logged and retried on
+         * the next dispatcher tick.
+         */
+        public async void deliver_pending_comments(int64 saddle_id, int64 task_id) throws Error {
+            var saddle = yield get_saddle(saddle_id);
+            if (saddle == null) {
+                return;
+            }
+            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) {
+                if (!comment.author.has_prefix("user:")
+                        || poll_state.is_comment_delivered(comment.id)) {
+                    continue;
+                }
+                var payload = new Json.Builder();
+                payload.begin_object();
+                payload.set_member_name("type");
+                payload.add_string_value("comment");
+                payload.set_member_name("author");
+                payload.add_string_value(comment.author);
+                payload.set_member_name("body");
+                payload.add_string_value(comment.body);
+                if (comment.quote != null) {
+                    payload.set_member_name("quote");
+                    payload.add_string_value(((!)comment.quote));
+                }
+                payload.end_object();
+
+                Json.Node? root = null;
+                int status_code = yield request_saddle(((!)saddle).host, "POST", "/notify",
+                    (owned) payload, out root);
+                if (status_code == 200 || status_code == 204) {
+                    poll_state.mark_comment_delivered(comment.id);
+                } else if (status_code != 404 && status_code != 405) {
+                    warning(@"[ClankerSaddle] Comment notify to saddle $(saddle_id) returned HTTP $(status_code)");
+                }
+            }
+        }
+
+        /**
+         * Marks the task's human comments as delivered — they were handed
+         * over inside an assignment payload, so neither the poll endpoint
+         * nor `/notify` should repeat them.
+         */
+        public async void mark_task_comments_delivered(int64 task_id) throws Error {
+            var comments = yield db.query<TaskComment>()
+                .where(expr("task_id == $0", elem<int64?>(task_id)))
+                .materialise_async();
+            foreach (var comment in comments) {
+                if (comment.author.has_prefix("user:")) {
+                    poll_state.mark_comment_delivered(comment.id);
+                }
+            }
+        }
+
+        /**
+         * Resumes a task whose block has been answered on the saddle that
+         * holds its open (parked) session. A daemon still waiting in its
+         * answer loop (`state == blocked`) receives the answer via
+         * `POST /answer` and carries on; a parked workspace is re-pushed
+         * via `POST /start` — the assignment carries the pending answers —
+         * and the saddle returns to busy. Returns true when the task was
+         * resumed; false when the saddle is unavailable (busy with other
+         * work or offline).
+         */
+        public async bool resume_answered_task(TaskEntity task, ClankerSaddle.Data.Session session,
+                TaskService tasks, ProjectService projects) throws Error {
+            var saddle = yield get_saddle(session.saddle_id);
+            if (saddle == null || (((!)saddle).status == "offline"
+                    || (((!)saddle).status != "idle" && ((!)saddle).current_task_id != task.id))) {
+                return false;
+            }
+
+            Json.Object status;
+            try {
+                status = yield request_saddle_json(((!)saddle).host, "GET", "/status", null);
+            } catch (Error e) {
+                warning(@"[ClankerSaddle] Resume probe of saddle $(session.saddle_id) failed: $(e.message)");
+                return false;
+            }
+
+            string state = status.has_member("state") ? status.get_string_member("state") : "";
+            if (state == "blocked") {
+                yield deliver_answered_blocks((!)saddle, task.id, string_member(status, "block_question"));
+                yield tasks.update_task(task.id, null, null, null, STATUS_WORKING);
+                yield update_saddle_status(((!)saddle).id, "busy", task.id);
+            } else {
+                yield update_saddle_status(((!)saddle).id, "busy", task.id);
+                yield seed_session_cursor(((!)saddle).id, session.id);
+                yield push_task_to_saddle(((!)saddle).id, task, session.id, projects);
+                yield mark_task_comments_delivered(task.id);
+                yield tasks.update_task(task.id, null, null, null, STATUS_ASSIGNED);
+            }
+            print(@"[ClankerSaddle] Resumed task $(task.id) on saddle '$(((!)saddle).name)'\n");
+            return true;
+        }
+
         /**
          * Stores entries newly reported by the daemon's `GET /output`
          * (a bare JSON array of `{id, command, output, exit_code, type,
@@ -452,6 +699,8 @@ namespace ClankerSaddle.Services {
                         ? 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,
+                    entry_type = string_member(entry, "type").length > 0
+                        ? string_member(entry, "type") : "command",
                     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)
@@ -468,17 +717,23 @@ namespace ClankerSaddle.Services {
         /**
          * 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.
+         * lines create child tasks, `saddle plan`/`saddle
+         * progress-update`/`saddle review` update the plan, append
+         * progress and raise the review banner, 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");
+                string output = string_member(entry, "output");
                 switch (string_member(entry, "type")) {
                     case "comment":
                         string body = strip_quotes(strip_verb(command, "saddle comment"));
+                        if (body.length == 0) {
+                            body = output;
+                        }
                         if (body.length > 0) {
                             yield tasks.add_comment(task_id, "bot", body);
                         }
@@ -490,6 +745,26 @@ namespace ClankerSaddle.Services {
                                 operands.length >= 2 ? operands[1] : null);
                         }
                         break;
+                    case "plan":
+                        string markdown = output.length > 0 ? output
+                            : strip_quotes(strip_verb(command, "saddle plan"));
+                        if (markdown.length > 0) {
+                            yield tasks.update_plan(task_id, markdown);
+                        }
+                        break;
+                    case "progress":
+                        string summary = strip_verb(command, "saddle progress-update");
+                        if (summary.length == 0) {
+                            summary = output;
+                        }
+                        if (summary.length > 0) {
+                            yield tasks.add_progress(task_id, strip_quotes(summary));
+                        }
+                        break;
+                    case "review":
+                        yield tasks.request_review(task_id, output.length > 0 ? output
+                            : strip_quotes(strip_verb(command, "saddle review")));
+                        break;
                     case "done":
                         yield tasks.complete_task(task_id,
                             strip_quotes(strip_verb(command, "saddle done")), null, "done");
@@ -502,12 +777,18 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Raises the daemon's current blocking question as a `task_blocks`
+         * Raises the daemon's current blocking prompt 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.
+         * prompt is already represented. The status object carries the
+         * question plus (from newer daemons) the block type, options and
+         * markdown body.
          */
-        private async void ensure_block(TaskService tasks, int64 task_id, string question) throws Error {
+        private async void ensure_block(TaskService tasks, int64 task_id, Json.Object status) throws Error {
+            string question = string_member(status, "block_question");
+            if (question.length == 0) {
+                return;
+            }
             foreach (var block in yield tasks.list_blocks(task_id)) {
                 if (block.question != question) {
                     continue;
@@ -516,7 +797,10 @@ namespace ClankerSaddle.Services {
                     return;
                 }
             }
-            yield tasks.add_block(task_id, question);
+            yield tasks.add_block(task_id, question,
+                string_member(status, "block_type"),
+                string_array_member(status, "block_options"),
+                optional_string_member(status, "block_body"));
         }
 
         /**
@@ -720,6 +1004,55 @@ namespace ClankerSaddle.Services {
                 ? obj.get_string_member(name) : "";
         }
 
+        /** A nullable string member: null when absent or empty. */
+        private static string? optional_string_member(Json.Object obj, string name) {
+            string value = string_member(obj, name);
+            return value.length > 0 ? value : null;
+        }
+
+        /**
+         * A string-array member: native JSON arrays are read element-wise;
+         * a JSON-encoded string is parsed; anything else is empty.
+         */
+        private static string[] string_array_member(Json.Object obj, string name) {
+            if (!obj.has_member(name)) {
+                return new string[0];
+            }
+            var member = obj.get_member(name);
+            if (member.get_node_type() == Json.NodeType.ARRAY) {
+                var values = new Vector<string>();
+                foreach (var element in member.get_array().get_elements()) {
+                    if (element.get_node_type() == Json.NodeType.VALUE
+                            && element.get_string() != null) {
+                        values.add(element.get_string());
+                    }
+                }
+                return values.to_array();
+            }
+            string raw = string_member(obj, name);
+            if (raw.length == 0) {
+                return new string[0];
+            }
+            try {
+                var parser = new Json.Parser();
+                parser.load_from_data(raw);
+                var root = parser.get_root();
+                if (root != null && root.get_node_type() == Json.NodeType.ARRAY) {
+                    var values = new Vector<string>();
+                    foreach (var element in root.get_array().get_elements()) {
+                        if (element.get_node_type() == Json.NodeType.VALUE
+                                && element.get_string() != null) {
+                            values.add(element.get_string());
+                        }
+                    }
+                    return values.to_array();
+                }
+            } catch (Error e) {
+                warning("[ClankerSaddle] Ignoring malformed options array: %s", e.message);
+            }
+            return new string[0];
+        }
+
         private static DateTime? parse_iso(Json.Object obj, string name) {
             if (!obj.has_member(name)) {
                 return null;

+ 74 - 0
src/Services/StatePulse.vala

@@ -0,0 +1,74 @@
+using Invercargill.DataStructures;
+using Inversion;
+using Statum;
+
+namespace ClankerSaddle.Services {
+
+    /** Public data of the global `pulse` slot. */
+    public class PulsePublic : Object {
+        public int64 version { get; set; }
+        public int64 task_id { get; set; }
+        public string reason { get; set; default = ""; }
+        public string when_display { get; set; default = ""; }
+    }
+
+    /**
+     * Process-wide change signal for Statum-connected browsers: every task
+     * or saddle mutation bumps a version counter on the global `pulse`
+     * slot, which {@link Statum.StateService.update_global} pushes over the
+     * realtime channel to every subscribed page. Pages subscribe via the
+     * entrypoint's `subscribe_global` directive and re-run their sealed
+     * refresh action when the version moves — replacing fixed-interval
+     * polling with event-driven refreshes.
+     *
+     * A singleton by necessity: the services that bump it are
+     * request-scoped, so the counter lives here.
+     */
+    public class StatePulse : Object {
+
+        /** The global slot's type name (wire key `global:pulse`). */
+        public const string SLOT_TYPE = "pulse";
+
+        private StateService state_service = inject<StateService>();
+        private int64 version = 0;
+        private Mutex version_lock = Mutex();
+
+        /**
+         * Increments the version and pushes the new pulse state to every
+         * subscribed channel. `task_id` is 0 for saddle-level changes;
+         * `reason` is a short human-readable change label (not displayed,
+         * handy while debugging).
+         */
+        public async void bump(int64 task_id, string reason) {
+            version_lock.lock();
+            version++;
+            int64 current = version;
+            version_lock.unlock();
+
+            var pub = new PulsePublic() {
+                version = current,
+                task_id = task_id,
+                reason = reason,
+                when_display = new DateTime.now_local().format("%Y-%m-%d %H:%M:%S")
+            };
+            PropertyDictionary props;
+            try {
+                props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            } catch (GLib.Error e) {
+                warning("[ClankerSaddle] Pulse state mapping failed: %s", e.message);
+                return;
+            }
+
+            var state = new State() {
+                type_name = SLOT_TYPE,
+                public_data = props,
+                private_data = new PropertyDictionary()
+            };
+            try {
+                yield state_service.update_global(SLOT_TYPE, state);
+            } catch (GLib.Error e) {
+                warning("[ClankerSaddle] Pulse push failed: %s", e.message);
+            }
+        }
+    }
+}

+ 237 - 33
src/Services/TaskService.vala

@@ -12,18 +12,25 @@ namespace ClankerSaddle.Services {
     public const string STATUS_ASSIGNED = "assigned";
     public const string STATUS_WORKING = "working";
     public const string STATUS_BLOCKED = "blocked";
+    public const string STATUS_REVIEW_REQUESTED = "review_requested";
     public const string STATUS_DONE = "done";
     public const string STATUS_FAILED = "failed";
 
+    /** Valid {@link TaskBlock.block_type} values. */
+    public const string BLOCK_QUESTION = "question";
+    public const string BLOCK_RFC = "rfc";
+    public const string BLOCK_REVIEW = "review";
+
     /**
      * The full task aggregate loaded by {@link TaskService.get_task}: the
-     * task itself plus its comments, blocks, child tasks, dependencies and
-     * session history.
+     * task itself plus its comments, blocks, progress history, 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<TaskProgress> progress { get; set; }
         public ImmutableLot<TaskEntity> subtasks { get; set; }
         public ImmutableLot<TaskDependency> dependencies { get; set; }
         public ImmutableLot<Session> sessions { get; set; }
@@ -31,20 +38,44 @@ namespace ClankerSaddle.Services {
 
     /**
      * 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.
+     * agent interaction primitives (comments, blocks, plans, progress,
+     * reviews, subtasks) and completion. Saddle bookkeeping rows are kept
+     * consistent on every terminal transition.
      */
     public class TaskService : Object {
 
         private OrmSession db = inject<OrmSession>();
+        private StatePulse pulse = inject<StatePulse>();
 
         /**
-         * Inserts a task (status 'queued'). `parent_task_id` and
-         * `description` may be null; priority defaults to 5 (1 is highest).
+         * Inserts a task (status 'queued'). `parent_task_id`,
+         * `description`, `task_branch` and `branch_from` may be null;
+         * `branch_from` prefills from the project's default branch and
+         * `task_branch` is inherited from the parent task when unset;
+         * 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 {
+                int64 priority, bool is_design_task, int64? parent_task_id,
+                string? task_branch = null, string? branch_from = null) throws Error {
+            string resolved_branch_from = branch_from ?? "";
+            if (resolved_branch_from.length == 0) {
+                var project = yield db.query<Project>()
+                    .where(expr("id == $0", elem<int64?>(project_id)))
+                    .first_async();
+                if (project != null) {
+                    resolved_branch_from = ((!)project).default_branch;
+                }
+            }
+            string? resolved_task_branch = task_branch != null && task_branch.length > 0 ? task_branch : null;
+            if (resolved_task_branch == null && parent_task_id != null) {
+                var parent = yield db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(parent_task_id)))
+                    .first_async();
+                if (parent != null && ((!)parent).task_branch != null) {
+                    resolved_task_branch = ((!)parent).task_branch;
+                }
+            }
+
             var task = new TaskEntity() {
                 parent_task_id = parent_task_id != null ? ((!)parent_task_id) : 0,
                 project_id = project_id,
@@ -53,16 +84,19 @@ namespace ClankerSaddle.Services {
                 priority = priority.clamp(1, 9),
                 is_design_task = is_design_task,
                 status = STATUS_QUEUED,
+                task_branch = resolved_task_branch,
+                branch_from = resolved_branch_from.length > 0 ? resolved_branch_from : null,
                 created_at = new DateTime.now_local()
             };
             yield db.insert_async<TaskEntity>(task);
+            yield pulse.bump(task.id, "task created");
             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.
+         * progress, 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>()
@@ -75,6 +109,7 @@ namespace ClankerSaddle.Services {
                 task = (!)task,
                 comments = yield list_comments(id),
                 blocks = yield list_blocks(id),
+                progress = yield list_progress(id),
                 subtasks = yield db.query<TaskEntity>()
                     .where(expr("parent_task_id == $0", elem<int64?>(id)))
                     .order_by(expr("id"))
@@ -119,7 +154,8 @@ namespace ClankerSaddle.Services {
         /**
          * Saves the editable fields of the task identified by `id`; null
          * arguments keep their current values. Returns null when the task
-         * does not exist.
+         * does not exist. The pulse only fires when a value actually
+         * moved, so periodic status refreshes do not spam live pages.
          */        public async TaskEntity? update_task(int64 id, string? title, string? description,
                 int64? priority, string? status) throws Error {
             var task = yield db.query<TaskEntity>()
@@ -128,45 +164,165 @@ namespace ClankerSaddle.Services {
             if (task == null) {
                 return null;
             }
-            if (title != null && title.length > 0) {
-                task.title = title;
+            bool changed = false;
+            if (title != null && title.length > 0 && ((!)task).title != title) {
+                ((!)task).title = title;
+                changed = true;
             }
             if (description != null) {
-                task.description = description.length > 0 ? description : null;
+                string? next_description = description.length > 0 ? description : null;
+                if (((!)task).description != next_description) {
+                    ((!)task).description = next_description;
+                    changed = true;
+                }
             }
             if (priority != null) {
-                task.priority = ((!)priority).clamp(1, 9);
+                int64 clamped = ((!)priority).clamp(1, 9);
+                if (((!)task).priority != clamped) {
+                    ((!)task).priority = clamped;
+                    changed = true;
+                }
             }
-            if (status != null && status.length > 0) {
-                task.status = status;
-                if (status == STATUS_DONE && task.completed_at == null) {
-                    task.completed_at = new DateTime.now_local();
+            if (status != null && status.length > 0 && ((!)task).status != status) {
+                ((!)task).status = status;
+                if (status == STATUS_DONE && ((!)task).completed_at == null) {
+                    ((!)task).completed_at = new DateTime.now_local();
                 }
+                changed = true;
             }
             yield db.update_async<TaskEntity>(task);
+            if (changed) {
+                yield pulse.bump(id, @"task $(status ?? "saved")");
+            }
+            return task;
+        }
+
+        /**
+         * Saves the task's markdown plan (`saddle plan` or the web UI's
+         * plan editor). An empty markdown clears the plan. Returns null
+         * when the task does not exist.
+         */
+        public async TaskEntity? update_plan(int64 id, string markdown) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (task == null) {
+                return null;
+            }
+            ((!)task).plan = markdown.strip().length > 0 ? markdown.strip() : null;
+            yield db.update_async<TaskEntity>((!)task);
+            yield pulse.bump(id, "plan updated");
+            return task;
+        }
+
+        /**
+         * Appends a progress line (`saddle progress-update`) to the task's
+         * history and returns the new row.
+         */
+        public async TaskProgress add_progress(int64 task_id, string summary) throws Error {
+            var row = new TaskProgress() {
+                task_id = task_id,
+                summary = summary,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<TaskProgress>(row);
+            yield pulse.bump(task_id, "progress");
+            return row;
+        }
+
+        /** The task's progress history, newest first. */
+        public async ImmutableLot<TaskProgress> list_progress(int64 task_id) throws Error {
+            return yield db.query<TaskProgress>()
+                .where(expr("task_id == $0", elem<int64?>(task_id)))
+                .order_by_desc(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * Saves the task's branch fields: `task_branch` is the branch the
+         * agent works on, `branch_from` the base it is cut from. Null
+         * arguments keep their current values. Returns null when the task
+         * does not exist.
+         */
+        public async TaskEntity? set_branch_fields(int64 id, string? task_branch,
+                string? branch_from) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (task == null) {
+                return null;
+            }
+            if (task_branch != null && task_branch.length > 0) {
+                ((!)task).task_branch = task_branch;
+            }
+            if (branch_from != null && branch_from.length > 0) {
+                ((!)task).branch_from = branch_from;
+            }
+            yield db.update_async<TaskEntity>((!)task);
+            yield pulse.bump(id, "branch fields saved");
             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 {
+        public async TaskComment add_comment(int64 task_id, string author, string body,
+                string? quote = null) throws Error {
             var comment = new TaskComment() {
                 task_id = task_id,
                 author = author,
                 body = body,
+                quote = quote != null && quote.strip().length > 0 ? quote.strip() : null,
                 created_at = new DateTime.now_local()
             };
             yield db.insert_async<TaskComment>(comment);
+            yield pulse.bump(task_id, "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.
+         * Appends a terminal entry to the task's open session so
+         * conversation-level events (blocks, plans, progress, reviews,
+         * human comments) appear in the streamed log. A no-op when the
+         * task has no open session.
+         */
+        public async void log_session_entry(int64 task_id, string entry_type,
+                string command, string? output) throws Error {
+            var session = yield open_session_for_task(task_id);
+            if (session == null) {
+                return;
+            }
+            var entry = new SessionCommand() {
+                session_id = ((!)session).id,
+                command_text = command,
+                output_text = output,
+                entry_type = entry_type,
+                started_at = new DateTime.now_local()
+            };
+            yield db.insert_async<SessionCommand>(entry);
+        }
+
+        /**
+         * Raises a blocking prompt on a task — `question` (optionally with
+         * multiple-choice `options` and a markdown `body`), `rfc` or
+         * `review` — and flips it to 'blocked' (or 'review_requested')
+         * while it waits for a human answer. Returns the new block.
          */
-        public async int64 add_block(int64 task_id, string question) throws Error {
+        public async TaskBlock add_block(int64 task_id, string question, string block_type = BLOCK_QUESTION,
+                string[]? options = null, string? body = null) throws Error {
+            string options_json = null;
+            if (options != null && options.length > 0) {
+                var array = new Json.Array();
+                foreach (var option in options) {
+                    array.add_string_element(option);
+                }
+                options_json = Json.to_string(new Json.Node(Json.NodeType.ARRAY).init_array(array), false);
+            }
+
             var block = new TaskBlock() {
                 task_id = task_id,
                 question = question,
+                block_type = block_type.length > 0 ? block_type : BLOCK_QUESTION,
+                options = options_json,
+                body = body != null && body.length > 0 ? body : null,
                 created_at = new DateTime.now_local()
             };
             yield db.insert_async<TaskBlock>(block);
@@ -174,17 +330,31 @@ namespace ClankerSaddle.Services {
             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;
+            if (task != null && (((!)task).status == STATUS_ASSIGNED || ((!)task).status == STATUS_WORKING
+                    || ((!)task).status == STATUS_BLOCKED || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
+                ((!)task).status = block.block_type == BLOCK_REVIEW
+                    ? STATUS_REVIEW_REQUESTED : STATUS_BLOCKED;
                 yield db.update_async<TaskEntity>((!)task);
             }
-            return block.id;
+            yield pulse.bump(task_id, "block raised");
+            return block;
+        }
+
+        /**
+         * Records a review request (`saddle review`): a review-type block
+         * carrying the summary markdown, with the task flipped to
+         * 'review_requested' while it waits for the human verdict.
+         */
+        public async TaskBlock request_review(int64 task_id, string summary) throws Error {
+            return yield add_block(task_id, "Review requested", BLOCK_REVIEW, null,
+                summary.length > 0 ? summary : null);
         }
 
         /**
-         * 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.
+         * Records a human's answer to a block. With an open session the
+         * task stays 'blocked'/'review_requested' — the dispatcher resumes
+         * the paused workspace and hands the answer over; without one the
+         * task flips straight back to 'working'.
          */
         public async void answer_block(int64 block_id, string answer) throws Error {
             var block = yield db.query<TaskBlock>()
@@ -202,10 +372,38 @@ namespace ClankerSaddle.Services {
             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);
+            if (task != null && (((!)task).status == STATUS_BLOCKED
+                    || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
+                var session = yield open_session_for_task(((!)block).task_id);
+                if (session == null) {
+                    ((!)task).status = STATUS_WORKING;
+                    yield db.update_async<TaskEntity>((!)task);
+                }
+            }
+            yield pulse.bump(((!)block).task_id, "block answered");
+        }
+
+        /**
+         * The git-host compare URL for the task's branches —
+         * `{git_url}/compare/{branch_from}...{task_branch}` — or "" when
+         * the task has no working branch.
+         */
+        public async string diff_url_for_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).task_branch == null
+                    || ((!)task).task_branch.length == 0) {
+                return "";
+            }
+            var project = yield db.query<Project>()
+                .where(expr("id == $0", elem<int64?>(((!)task).project_id)))
+                .first_async();
+            if (project == null) {
+                return "";
             }
+            string base_branch = ((!)task).branch_from ?? ((!)project).default_branch;
+            return @"$(((!)project).git_url)/compare/$(base_branch)...$(((!)task).task_branch)";
         }
 
         /** Records that `task_id` must not start until `depends_on_task_id` is done. */
@@ -226,6 +424,7 @@ namespace ClankerSaddle.Services {
                 depends_on_task_id = depends_on_task_id
             };
             yield db.insert_async<TaskDependency>(dependency);
+            yield pulse.bump(task_id, "dependency added");
         }
 
         /** Creates a child task under `parent_task_id` (status 'queued'). */
@@ -269,6 +468,7 @@ namespace ClankerSaddle.Services {
 
             yield close_open_sessions(task_id, outcome);
             yield free_saddles_running(task_id);
+            yield pulse.bump(task_id, "task completed");
         }
 
         /**
@@ -294,6 +494,7 @@ namespace ClankerSaddle.Services {
 
             yield close_open_sessions(task_id, "stopped");
             yield free_saddles_running(task_id);
+            yield pulse.bump(task_id, "task cancelled");
         }
 
         /**
@@ -321,6 +522,7 @@ namespace ClankerSaddle.Services {
 
             yield close_open_sessions(task_id, "failed");
             yield free_saddles_running(task_id);
+            yield pulse.bump(task_id, "task failed");
         }
 
         /**
@@ -333,12 +535,14 @@ namespace ClankerSaddle.Services {
                 .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_WORKING || ((!)task).status == STATUS_BLOCKED
+                    || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
                 ((!)task).status = STATUS_QUEUED;
                 yield db.update_async<TaskEntity>((!)task);
             }
             yield close_open_sessions(task_id, "stopped");
             yield free_saddles_running(task_id);
+            yield pulse.bump(task_id, "task requeued");
         }
 
         /**

+ 32 - 0
src/actions/DashboardActions.vala

@@ -0,0 +1,32 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /**
+     * The dashboard's pulse-driven refresh: rebuilds the `dashboard` slot
+     * from current data so live pages show new progress, blocks and
+     * review requests without a reload. Navigates nowhere.
+     */
+    public class RefreshDashboardAction : StatumAction {
+
+        private TaskService task_service = inject<TaskService>();
+        private SaddleService saddle_service = inject<SaddleService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        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;
+            }
+
+            var state = yield DashboardState.build(state_service, task_service,
+                saddle_service, db);
+            return directives().set(state);
+        }
+    }
+}

+ 3 - 2
src/actions/SaddleActions.vala

@@ -34,8 +34,9 @@ namespace ClankerSaddle {
             }
 
             try {
-                var saddle = yield saddles.register_saddle(name, host);
-                return directives().notify("success", @"Saddle '$(saddle.name)' registered")
+                var registration = yield saddles.register_saddle(name, host);
+                return directives().notify("success",
+                        @"Saddle '$(registration.saddle.name)' registered — API token $(registration.token)")
                     .navigate("/saddles");
             } catch (Error e) {
                 return directives().notify("error", @"Could not register the saddle: $(e.message)").navigate("/saddles");

+ 71 - 6
src/actions/TaskActions.vala

@@ -36,7 +36,10 @@ namespace ClankerSaddle {
     /**
      * 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.
+     * parent id support design tasks and manual subtasks. Branch fields
+     * are optional — `branch_from` prefills from the project's default
+     * branch and `task_branch` is inherited from the parent task when
+     * left blank.
      */
     public class CreateTaskAction : StatumAction {
 
@@ -65,7 +68,12 @@ namespace ClankerSaddle {
                 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);
+                    Forms.field(request, "is_design_task") == "on", parent_id,
+                    Forms.optional(request, "task_branch"),
+                    Forms.optional(request, "branch_from"));
+                if (Forms.field(request, "plan").length > 0) {
+                    yield tasks.update_plan(task.id, Forms.field_raw(request, "plan"));
+                }
                 return directives().notify("success", @"Task '$(task.title)' queued")
                     .navigate(@"/tasks/$(task.id)");
             } catch (Error e) {
@@ -105,6 +113,9 @@ namespace ClankerSaddle {
                     request.form != null ? Forms.field(request, "description") : null,
                     priority,
                     Forms.optional(request, "status"));
+                yield tasks.set_branch_fields(task_id,
+                    Forms.optional(request, "task_branch"),
+                    Forms.optional(request, "branch_from"));
                 return directives().notify("success", "Task saved").navigate(@"/tasks/$(task_id)");
             } catch (Error e) {
                 return directives().notify("error", @"Could not save the task: $(e.message)")
@@ -113,7 +124,12 @@ namespace ClankerSaddle {
         }
     }
 
-    /** Appends a comment to a task as the signed-in user. */
+    /**
+     * Appends a comment to a task as the signed-in user. An optional
+     * `quote` field carries text the user highlighted in the terminal log
+     * or an RFC body; the quote is stored with the comment and delivered
+     * to the saddle as a quote block.
+     */
     public class AddCommentAction : SealedTaskAction {
 
         private TaskService tasks = inject<TaskService>();
@@ -127,14 +143,22 @@ namespace ClankerSaddle {
 
             int64 task_id = sealed_task_id();
             string body = Forms.field_raw(request, "body");
+            string quote = Forms.field_raw(request, "quote");
             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)");
+                yield tasks.add_comment(task_id, @"user:$(me.username)", body, quote);
+                yield tasks.log_session_entry(task_id, "user-comment",
+                    @"comment by user:$(me.username)", quote.length > 0 ? "> " + quote.replace("\n", "\n> ") + "\n\n" + body : body);
+                // Terminals post with a return_to so the live view survives
+                // the comment; every other caller lands on the task page.
+                string return_to = Forms.field(request, "return_to");
+                string destination = return_to.length > 0 && return_to.has_prefix("/")
+                    ? return_to : @"/tasks/$(task_id)";
+                return directives().notify("success", "Comment added").navigate(destination);
             } catch (Error e) {
                 return directives().notify("error", @"Could not add the comment: $(e.message)")
                     .navigate(@"/tasks/$(task_id)");
@@ -142,9 +166,42 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Saves the task's markdown plan from the web UI's plan editor (the
+     * human counterpart of the agent's `saddle plan`).
+     */
+    public class UpdatePlanAction : 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.update_plan(task_id, Forms.field_raw(request, "plan"));
+                return directives().notify("success", "Plan saved").navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the plan: $(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.
+     * from the sealed {@link BlockAnswerPrivate} blob. Question blocks
+     * submit either a chosen `option` or the free-text `other` field
+     * (formatted the way the agent reads them); rfc/review blocks and the
+     * plain answer form submit the `answer` textarea.
      */
     public class AnswerBlockAction : TypedStatumAction<BlockAnswerPrivate> {
 
@@ -164,7 +221,15 @@ namespace ClankerSaddle {
                 return directives().navigate("/tasks");
             }
             int64 task_id = ((!)ctx).task_id;
+
+            string option = Forms.field(request, "option");
+            string other = Forms.field_raw(request, "other");
             string answer = Forms.field_raw(request, "answer");
+            if (option == "__other__") {
+                answer = other.strip().length > 0 ? @"Human answered (other): $(other.strip())" : answer;
+            } else if (option.length > 0) {
+                answer = @"Human selected: $(option)";
+            }
             if (answer.length == 0) {
                 return directives().notify("error", "An answer is required").navigate(@"/tasks/$(task_id)");
             }

+ 138 - 25
src/entrypoints/HomeEntrypoint.vala

@@ -1,6 +1,8 @@
 using Invercargill;
 using Invercargill.DataStructures;
 using Invercargill.Expressions;
+using Spry;
+using Spry.Authentication;
 using Statum;
 using ClankerSaddle.Data;
 using ClankerSaddle.Services;
@@ -26,6 +28,35 @@ namespace ClankerSaddle {
         public string text { get; set; default = ""; }
     }
 
+    /** An active task with its latest progress line. */
+    public class DashboardActiveView : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = ""; }
+        public string latest_progress { get; set; default = ""; }
+        public string progress_display { get; set; default = ""; }
+        public string task_branch { get; set; default = ""; }
+    }
+
+    /** A block awaiting a human answer. */
+    public class DashboardBlockView : Object {
+        public int64 id { get; set; }
+        public int64 task_id { get; set; }
+        public string task_title { get; set; default = ""; }
+        public string block_type { get; set; default = "question"; }
+        public string question { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+    }
+
+    /** A pending review request with its diff link. */
+    public class DashboardReviewView : Object {
+        public int64 id { get; set; }
+        public int64 task_id { get; set; }
+        public string task_title { get; set; default = ""; }
+        public string summary { get; set; default = ""; }
+        public string diff_url { get; set; default = ""; }
+    }
+
     /** Public data of the dashboard's `dashboard` slot. */
     public class DashboardPublic : Object {
         public int64 queued_count { get; set; }
@@ -35,23 +66,29 @@ namespace ClankerSaddle {
         public int64 done_count { get; set; }
         public int64 failed_count { get; set; }
         public int64 idle_saddle_count { get; set; }
+        public Statum.Model.ActionDto? refresh { 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.
+     * Builds the `dashboard` slot state shared by the home entrypoint and
+     * the {@link RefreshDashboardAction} pulse refresh, so both hydrate
+     * identical state: queue counters, saddle cards, active tasks with
+     * their latest progress, blocks and reviews awaiting a human, and the
+     * recent activity feed.
      */
-    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 class DashboardState : Object {
 
-        public override async DirectiveBuilder handle() throws GLib.Error {
+        /**
+         * Loads the dashboard aggregate and shapes its PAGE slot. The
+         * `db` session comes from the caller (entrypoint or action scope).
+         */
+        public static async Statum.Slot build(StateService state_service, TaskService tasks,
+                SaddleService saddles, InvercargillSql.Orm.OrmSession db,
+                ActionRegistry? actions = null) throws GLib.Error {
             var pub = new DashboardPublic();
-            foreach (var task in yield task_service.list_tasks(null, null)) {
+            var titles = new Dictionary<string, string>();
+            foreach (var task in yield tasks.list_tasks(null, null)) {
+                titles.set(task.id.to_string(), task.title);
                 switch (task.status) {
                     case STATUS_QUEUED:
                         pub.queued_count++;
@@ -61,6 +98,7 @@ namespace ClankerSaddle {
                         pub.active_count++;
                         break;
                     case STATUS_BLOCKED:
+                    case STATUS_REVIEW_REQUESTED:
                         pub.blocked_count++;
                         break;
                     case STATUS_DONE:
@@ -73,10 +111,10 @@ namespace ClankerSaddle {
                         break;
                 }
             }
-            pub.ready_count = (yield task_service.get_ready_tasks()).length;
+            pub.ready_count = (yield tasks.get_ready_tasks()).length;
 
-            var saddles = new Vector<DashboardSaddleView>();
-            foreach (var saddle in yield saddle_service.list_saddles()) {
+            var saddle_rows = new Vector<DashboardSaddleView>();
+            foreach (var saddle in yield saddles.list_saddles()) {
                 if (saddle.status == "idle") {
                     pub.idle_saddle_count++;
                 }
@@ -93,30 +131,79 @@ namespace ClankerSaddle {
                         .first_async();
                     view.current_task_title = task != null ? ((!)task).title : "";
                 }
-                saddles.add(view);
+                saddle_rows.add(view);
+            }
+
+            var active = new Vector<DashboardActiveView>();
+            var open_blocks = new Vector<DashboardBlockView>();
+            var reviews = new Vector<DashboardReviewView>();
+            foreach (var task in yield tasks.list_tasks(null, null)) {
+                if (task.status == STATUS_ASSIGNED || task.status == STATUS_WORKING
+                        || task.status == STATUS_BLOCKED || task.status == STATUS_REVIEW_REQUESTED) {
+                    var row = new DashboardActiveView() {
+                        id = task.id,
+                        title = task.title,
+                        status = task.status,
+                        task_branch = task.task_branch ?? ""
+                    };
+                    var progress = yield tasks.list_progress(task.id);
+                    if (progress.length > 0) {
+                        row.latest_progress = progress.first().summary;
+                        row.progress_display = format_time(progress.first().created_at);
+                    }
+                    active.add(row);
+                }
+
+                var blocks = yield tasks.list_blocks(task.id);
+                foreach (var block in blocks) {
+                    if (block.answer != null) {
+                        continue;
+                    }
+                    if (block.block_type == BLOCK_REVIEW) {
+                        reviews.add(new DashboardReviewView() {
+                            id = block.id,
+                            task_id = task.id,
+                            task_title = task.title,
+                            summary = (block.body ?? block.question).strip(),
+                            diff_url = yield tasks.diff_url_for_task(task.id)
+                        });
+                    } else {
+                        open_blocks.add(new DashboardBlockView() {
+                            id = block.id,
+                            task_id = task.id,
+                            task_title = task.title,
+                            block_type = block.block_type,
+                            question = block.question,
+                            created_display = format_time(block.created_at)
+                        });
+                    }
+                }
             }
 
-            var activity = yield build_activity();
+            if (actions != null) {
+                pub.refresh = ((!)actions).author<RefreshDashboardAction>("GET");
+            }
+
+            var activity = yield build_activity(db, titles);
 
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
-            props["saddles"] = StateJson.list<DashboardSaddleView>(saddles.to_immutable_buffer());
+            props["saddles"] = StateJson.list<DashboardSaddleView>(saddle_rows.to_immutable_buffer());
             props["activity"] = StateJson.list<DashboardActivityView>(activity.to_immutable_buffer());
+            props["active_tasks"] = StateJson.list<DashboardActiveView>(active.to_immutable_buffer());
+            props["open_blocks"] = StateJson.list<DashboardBlockView>(open_blocks.to_immutable_buffer());
+            props["reviews"] = StateJson.list<DashboardReviewView>(reviews.to_immutable_buffer());
 
-            var slot = state_service.new_slot(Scope.PAGE, new State() {
+            return state_service.new_slot(Scope.PAGE, new Statum.State() {
                 type_name = "dashboard",
                 public_data = props,
                 private_data = new PropertyDictionary()
             });
-            return directives().set(slot);
         }
 
-        private async Vector<DashboardActivityView> build_activity() throws GLib.Error {
+        private static async Vector<DashboardActivityView> build_activity(
+                InvercargillSql.Orm.OrmSession db,
+                Dictionary<string, string> titles) 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",
@@ -157,4 +244,30 @@ namespace ClankerSaddle {
             return time != null ? ((!)time).format("%Y-%m-%d %H:%M") : "";
         }
     }
+
+    /**
+     * Entrypoint for /: the dashboard — queue summary, saddle cards,
+     * active tasks with their latest progress, blocks and review requests
+     * awaiting a human, and a recent activity feed. The page subscribes
+     * to the global pulse and re-runs its sealed refresh action whenever
+     * task or saddle state changes anywhere in the app.
+     */
+    public class HomeEntrypoint : StatumEntrypoint {
+
+        private TaskService task_service = Inversion.inject<TaskService>();
+        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_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var state = yield DashboardState.build(state_service, task_service,
+                saddle_service, db, action_registry);
+            return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
+        }
+    }
 }

+ 4 - 1
src/entrypoints/SaddlesEntrypoint.vala

@@ -9,7 +9,7 @@ using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
-    /** A saddle card on the management page, with its current task. */
+    /** A saddle card on the management page, with its current task and API token. */
     public class SaddleRowView : Object {
         public int64 id { get; set; }
         public string name { get; set; default = ""; }
@@ -18,6 +18,7 @@ namespace ClankerSaddle {
         public int64 current_task_id { get; set; }
         public string current_task_title { get; set; default = ""; }
         public string allocated_display { get; set; default = ""; }
+        public string token { get; set; default = ""; }
         public Statum.Model.ActionDto? unregister { get; set; }
         public Statum.Model.ActionDto? refresh { get; set; }
     }
@@ -56,6 +57,8 @@ namespace ClankerSaddle {
                     allocated_display = saddle.allocated_at != null
                         ? ((!)saddle.allocated_at).format("%Y-%m-%d %H:%M") : ""
                 };
+                string? token = yield saddle_service.token_for_saddle(saddle.id);
+                view.token = token ?? "";
                 if (saddle.current_task_id > 0) {
                     var task = yield db.query<TaskEntity>()
                         .where(expr("id == $0", elem<int64?>(saddle.current_task_id)))

+ 88 - 14
src/entrypoints/TaskDetailEntrypoint.vala

@@ -1,6 +1,5 @@
 using Invercargill;
 using Invercargill.DataStructures;
-using Invercargill.Expressions;
 using Spry;
 using Spry.Authentication;
 using Statum;
@@ -9,19 +8,30 @@ using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
-    /** A comment row in the task detail thread. */
+    /** A comment row in the task detail thread, with its quoted reference. */
     public class CommentView : Object {
         public string author { get; set; default = ""; }
         public string body { get; set; default = ""; }
+        public string quote { 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. */
+    /** One progress line in the task's history. */
+    public class ProgressView : Object {
+        public int64 id { get; set; }
+        public string summary { get; set; default = ""; }
+        public string when_display { get; set; default = ""; }
+    }
+
+    /** A block (question, RFC or review) 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 string block_type { get; set; default = "question"; }
+        public string body { get; set; default = ""; }
+        public string[] options { get; set; }
         public bool answered { get; set; }
         public string created_display { get; set; default = ""; }
         public string answered_display { get; set; default = ""; }
@@ -63,14 +73,22 @@ namespace ClankerSaddle {
         public bool is_design_task { get; set; }
         public string working_branch { get; set; default = ""; }
         public string summary { get; set; default = ""; }
+        public string plan { get; set; default = ""; }
+        public string task_branch { get; set; default = ""; }
+        public string branch_from { get; set; default = ""; }
+        public bool review_pending { get; set; }
+        public string review_summary { get; set; default = ""; }
+        public string review_diff_url { get; set; default = ""; }
         public int64 project_id { get; set; }
         public string project_name { get; set; default = ""; }
+        public string git_url { 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? save_plan { get; set; }
         public Statum.Model.ActionDto? subtask { get; set; }
         public Statum.Model.ActionDto? dependency { get; set; }
         public Statum.Model.ActionDto? complete { get; set; }
@@ -80,8 +98,10 @@ namespace ClankerSaddle {
 
     /**
      * Builds the `task` slot state shared by the task detail entrypoint and
-     * the {@link RefreshTaskDataAction} polling action, so both hydrate
-     * identical state.
+     * the {@link RefreshTaskDataAction} pulse refresh, so both hydrate
+     * identical state: the full task with plan, branch fields and pending
+     * review, plus view rows for comments (with quotes), progress,
+     * blocks (typed, with options), subtasks, dependencies and sessions.
      */
     public class TaskDetailState : Object {
 
@@ -112,8 +132,12 @@ namespace ClankerSaddle {
                 is_design_task = task.is_design_task,
                 working_branch = task.working_branch ?? "",
                 summary = task.summary ?? "",
+                plan = task.plan ?? "",
+                task_branch = task.task_branch ?? "",
+                branch_from = task.branch_from ?? "",
                 project_id = task.project_id,
                 project_name = project != null ? ((!)project).name : "",
+                git_url = project != null ? ((!)project).git_url : "",
                 parent_task_id = task.parent_task_id,
                 created_display = format_time(task.created_at),
                 started_display = format_time(task.started_at),
@@ -123,6 +147,7 @@ namespace ClankerSaddle {
             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.save_plan = actions.author_private<UpdatePlanAction, 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);
@@ -134,26 +159,45 @@ namespace ClankerSaddle {
                 comments.add(new CommentView() {
                     author = comment.author,
                     body = comment.body,
+                    quote = comment.quote ?? "",
                     when_display = format_time(comment.created_at),
                     from_bot = comment.author == "bot"
                 });
             }
 
+            var progress = new Vector<ProgressView>();
+            foreach (var row in ((!)detail).progress) {
+                progress.add(new ProgressView() {
+                    id = row.id,
+                    summary = row.summary,
+                    when_display = format_time(row.created_at)
+                });
+            }
+
             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() {
+                var view = new BlockView() {
                     id = block.id,
                     question = block.question,
                     answer = block.answer ?? "",
+                    block_type = block.block_type,
+                    body = block.body ?? "",
+                    options = parse_options(block.options),
                     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)
-                });
+                };
+                if (!view.answered && view.block_type == BLOCK_REVIEW) {
+                    pub.review_pending = true;
+                    pub.review_summary = view.body.length > 0 ? view.body : view.question;
+                    pub.review_diff_url = yield tasks.diff_url_for_task(task.id);
+                }
+                blocks.add(view);
             }
 
             var subtasks = new Vector<SubtaskView>();
@@ -196,7 +240,11 @@ namespace ClankerSaddle {
 
             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["progress"] = StateJson.list<ProgressView>(progress.to_immutable_buffer());
+            props["blocks"] = StateJson.list_rich<BlockView>(blocks.to_immutable_buffer(),
+                (item, dict) => {
+                    dict["options"] = StateJson.strings(((BlockView) item).options ?? new string[0]);
+                });
             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());
@@ -204,6 +252,31 @@ namespace ClankerSaddle {
             return slot_for(state_service, pub, props);
         }
 
+        /** The stored options JSON (a string array) back into a Vala array. */
+        private static string[] parse_options(string? options_json) {
+            if (options_json == null || ((!)options_json).length == 0) {
+                return new string[0];
+            }
+            try {
+                var parser = new Json.Parser();
+                parser.load_from_data((!)options_json);
+                var root = parser.get_root();
+                if (root == null || root.get_node_type() != Json.NodeType.ARRAY) {
+                    return new string[0];
+                }
+                var values = new Vector<string>();
+                foreach (var element in root.get_array().get_elements()) {
+                    if (element.get_node_type() == Json.NodeType.VALUE
+                            && element.get_string() != null) {
+                        values.add(element.get_string());
+                    }
+                }
+                return values.to_array();
+            } catch (Error e) {
+                return new string[0];
+            }
+        }
+
         private static DependencyView task_brief(TaskDetail? detail, int64 id) {
             if (detail == null) {
                 return new DependencyView() { id = id, title = @"#$(id)", status = "missing" };
@@ -230,11 +303,12 @@ namespace ClankerSaddle {
     }
 
     /**
-     * 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.
+     * Entrypoint for /tasks/{task_id}: the task detail page — plan,
+     * progress, blocks (pending ones carry the answer form), the review
+     * banner, comments thread, subtask tree, dependencies, session
+     * history links and the lifecycle actions. The page subscribes to the
+     * global pulse and re-runs its sealed refresh action whenever task
+     * state changes, so live updates arrive without polling.
      */
     public class TaskDetailEntrypoint : StatumEntrypoint {
 
@@ -252,7 +326,7 @@ namespace ClankerSaddle {
             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);
+            return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }
     }
 }

+ 12 - 31
src/entrypoints/TaskTerminalEntrypoint.vala

@@ -1,6 +1,5 @@
 using Invercargill;
 using Invercargill.DataStructures;
-using Invercargill.Expressions;
 using Spry;
 using Spry.Authentication;
 using Statum;
@@ -9,16 +8,6 @@ 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; }
@@ -32,14 +21,18 @@ namespace ClankerSaddle {
         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; }
+        public string session_state { get; set; default = ""; }
+        public Statum.Model.ActionDto? comment { get; set; }
     }
 
     /**
-     * Entrypoint for /tasks/{task_id}/terminal: the read-only bash session
+     * Entrypoint for /tasks/{task_id}/terminal: the live 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.
+     * sessions to show (the latest when absent). The log itself renders
+     * client-side from the `/terminal-stream/{session_id}` SSE stream —
+     * typed entry cards with expandable output and text-highlight
+     * commenting — so this state carries only the session table and the
+     * sealed comment action.
      */
     public class TaskTerminalEntrypoint : StatumEntrypoint {
 
@@ -85,12 +78,14 @@ namespace ClankerSaddle {
                 selected = sessions.first().id;
             }
 
-            var pub_with_commands = new TerminalPublic() {
+            var pub_with_session = new TerminalPublic() {
                 exists = true,
                 task_id = task_id,
                 task_title = ((!)detail).task.title,
                 session_id = selected
             };
+            var sealed = new TaskActionPrivate() { task_id = task_id };
+            pub_with_session.comment = action_registry.author_private<AddCommentAction, TaskActionPrivate>(sealed);
 
             var session_rows = new Vector<TerminalSessionView>();
             foreach (var session in sessions) {
@@ -101,22 +96,8 @@ namespace ClankerSaddle {
                 });
             }
 
-            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);
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub_with_session);
             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",

+ 11 - 0
src/main.vala

@@ -27,24 +27,30 @@ int main(string[] args) {
         var db = application.container.configure_with<DatabaseConfigurator>();
         db.register_connection(@"sqlite://$(db_path)");
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0002_Part2>();
         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<TaskProgress>(TaskProgress.entity_mapping);
         db.add_entity<Saddle>(Saddle.entity_mapping);
         db.add_entity<Session>(Session.entity_mapping);
         db.add_entity<SessionCommand>(SessionCommand.entity_mapping);
+        db.add_entity<ApiToken>(ApiToken.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<StatePulse>();
         application.add_singleton<Dispatcher>();
 
         var statum = application.configure_with<StatumConfigurator>();
+        statum.global<ClankerSaddle.Services.PulsePublic>(StatePulse.SLOT_TYPE,
+            new ClankerSaddle.Services.PulsePublic());
 
         // spry:pages-begin
         statum.add_page<HomePage, HomeEntrypoint>();
@@ -67,18 +73,21 @@ int main(string[] args) {
         statum.action<CreateTaskAction>();
         statum.action<UpdateTaskAction>();
         statum.action<AddCommentAction>();
+        statum.action<UpdatePlanAction>();
         statum.action<AnswerBlockAction>();
         statum.action<CreateSubtaskAction>();
         statum.action<AddDependencyAction>();
         statum.action<CompleteTaskAction>();
         statum.action<CancelTaskAction>();
         statum.action<RefreshTaskDataAction>();
+        statum.action<RefreshDashboardAction>();
         statum.action<RegisterSaddleAction>();
         statum.action<UnregisterSaddleAction>();
         statum.action<RefreshSaddleStatusAction>();
         // spry:actions-end
 
         // spry:resources-begin
+        statum.add_resource<ClankerSaddle.AppCss>();
         // spry:resources-end
 
         // spry:auth-begin
@@ -88,6 +97,8 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/{id}/update", Method.POST));
         application.add_endpoint<SaddlePollEndpoint>(
             new EndpointRoute("/api/saddle/{id}/poll", Method.GET));
+        application.add_endpoint<ClankerSaddle.Api.TerminalSseEndpoint>(
+            new EndpointRoute("/terminal-stream/{session_id}", Method.GET));
 
         GLib.Idle.add(() => {
             seed_admin.begin(application.container);

+ 429 - 0
src/pages/app.css

@@ -0,0 +1,429 @@
+/* ClankerSaddle application stylesheet. Served precompressed from
+   /_statum/resource/app.css; spacing follows an 8px grid. */
+
+:root {
+    --grid-1: 0.5rem;
+    --grid-2: 1rem;
+    --grid-3: 1.5rem;
+    --grid-4: 2rem;
+    --ink: #1f2328;
+    --ink-muted: #59636e;
+    --line: #d1d9e0;
+    --line-soft: #e4eaef;
+    --surface: #ffffff;
+    --surface-sunken: #f6f8fa;
+    --accent: #0969da;
+    --success: #1a7f37;
+    --warning: #9a6700;
+    --danger: #cf222e;
+    --review: #bf8700;
+    --plan: #8250df;
+    --radius: 8px;
+}
+
+* { box-sizing: border-box; }
+
+body {
+    font-family: system-ui, -apple-system, sans-serif;
+    color: var(--ink);
+    background: var(--surface-sunken);
+    margin: 0 auto;
+    max-width: 64rem;
+    padding: 0 var(--grid-2);
+}
+
+header {
+    display: flex;
+    justify-content: space-between;
+    align-items: baseline;
+    gap: var(--grid-2);
+    border-bottom: 1px solid var(--line);
+    padding: var(--grid-2) 0;
+}
+
+header h1 { font-size: 1.25rem; margin: 0; }
+header nav { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--grid-1); }
+
+main { padding: var(--grid-2) 0 var(--grid-4); }
+
+footer {
+    border-top: 1px solid var(--line);
+    color: var(--ink-muted);
+    font-size: 0.85rem;
+    padding: var(--grid-2) 0;
+}
+
+h2 { margin: var(--grid-1) 0 var(--grid-2); }
+h3 { margin: var(--grid-3) 0 var(--grid-1); }
+
+a { color: var(--accent); }
+
+/* ---- Forms ---- */
+
+input, button, select, textarea { font: inherit; color: inherit; }
+
+input[type="text"], input[type="password"], input[type="email"], input[type="date"],
+input[type="number"], input[type="url"], select, textarea {
+    padding: 0.45rem 0.6rem;
+    border: 1px solid var(--line);
+    border-radius: 6px;
+    background: var(--surface);
+    max-width: 100%;
+}
+
+textarea {
+    width: 100%;
+    min-height: 6rem;
+    resize: vertical;
+    font-family: ui-monospace, "Cascadia Mono", monospace;
+    font-size: 0.9rem;
+    line-height: 1.45;
+}
+
+button {
+    padding: 0.45rem 0.9rem;
+    border: 1px solid var(--line);
+    border-radius: 6px;
+    background: var(--surface-sunken);
+    cursor: pointer;
+}
+
+button:hover { background: var(--line-soft); }
+
+button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+button.approve { background: var(--success); border-color: var(--success); color: #fff; }
+button.reject { background: var(--danger); border-color: var(--danger); color: #fff; }
+
+.form-row {
+    display: flex;
+    flex-direction: column;
+    gap: var(--grid-1);
+    margin: var(--grid-1) 0;
+    max-width: 34rem;
+}
+
+.form-row label { font-weight: 600; font-size: 0.9rem; }
+.form-row input, .form-row select, .form-row textarea { width: 100%; }
+
+form.inline { display: inline-flex; align-items: center; gap: var(--grid-1); margin: var(--grid-1) 0; flex-wrap: wrap; }
+form.inline textarea { min-height: 3rem; }
+
+/* ---- Cards & tables ---- */
+
+.cards {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
+    gap: var(--grid-2);
+    margin: var(--grid-2) 0;
+}
+
+.card {
+    background: var(--surface);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+    padding: var(--grid-2);
+}
+
+.card h3 { margin: 0 0 var(--grid-1); }
+
+table { border-collapse: collapse; width: 100%; background: var(--surface); }
+th, td { border: 1px solid var(--line); padding: 0.5rem 0.75rem; text-align: left; vertical-align: top; }
+th { background: var(--surface-sunken); }
+
+details {
+    background: var(--surface);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+    margin: var(--grid-1) 0;
+    padding: var(--grid-1) var(--grid-2);
+}
+
+summary { cursor: pointer; font-weight: 600; }
+
+/* ---- Badges ---- */
+
+.badge {
+    border-radius: 1em;
+    display: inline-block;
+    font-size: 0.75rem;
+    padding: 0.15rem 0.65rem;
+    vertical-align: middle;
+}
+
+.badge-queued { background: #88843333; color: #4d4d33; }
+.badge-assigned { background: #ddf4ff; color: #0969da; }
+.badge-working { background: #fff8c5; color: var(--warning); }
+.badge-blocked { background: #fff8c5; color: var(--warning); }
+.badge-review_requested { background: #fff1e0; color: #9a4700; }
+.badge-done { background: #dafbe1; color: var(--success); }
+.badge-failed { background: #ffebe9; color: var(--danger); }
+.badge-idle { background: #dafbe1; color: var(--success); }
+.badge-busy { background: #fff8c5; color: var(--warning); }
+.badge-offline { background: var(--line-soft); color: var(--ink-muted); }
+.badge-co-developed { background: #ddf4ff; color: #0969da; }
+.badge-reference { background: var(--line-soft); color: var(--ink-muted); }
+.badge-command { background: var(--line-soft); color: var(--ink-muted); }
+.badge-comment { background: #f0f0f0; color: #6e7781; }
+.badge-block { background: #fff8c5; color: var(--warning); }
+.badge-user-comment { background: #ddf4ff; color: #0969da; }
+.badge-progress { background: #dafbe1; color: var(--success); }
+.badge-review { background: #fff1e0; color: #9a4700; }
+.badge-plan { background: #f3e8ff; color: var(--plan); }
+.badge-question { background: #ddf4ff; color: #0969da; }
+.badge-rfc { background: #f3e8ff; color: var(--plan); }
+
+.muted { color: var(--ink-muted); }
+.error { color: var(--danger); }
+
+/* ---- Markdown content (stm-markdown) ---- */
+
+.md h1 { font-size: 1.35rem; margin: var(--grid-2) 0 var(--grid-1); }
+.md h2 { font-size: 1.2rem; margin: var(--grid-2) 0 var(--grid-1); }
+.md h3 { font-size: 1.05rem; margin: var(--grid-2) 0 var(--grid-1); }
+.md p, .md ul, .md ol { margin: var(--grid-1) 0; }
+.md code {
+    font-family: ui-monospace, "Cascadia Mono", monospace;
+    font-size: 0.85em;
+    background: var(--surface-sunken);
+    border: 1px solid var(--line-soft);
+    border-radius: 4px;
+    padding: 0.1em 0.35em;
+}
+.md pre {
+    background: var(--surface-sunken);
+    border: 1px solid var(--line-soft);
+    border-radius: 6px;
+    padding: var(--grid-1) var(--grid-2);
+    overflow-x: auto;
+}
+.md pre code { border: none; padding: 0; background: none; }
+.md blockquote {
+    border-left: 3px solid var(--line);
+    color: var(--ink-muted);
+    margin: var(--grid-1) 0;
+    padding: 0 var(--grid-2);
+}
+.md hr { border: none; border-top: 1px solid var(--line); margin: var(--grid-2) 0; }
+
+/* ---- Comments ---- */
+
+.comment {
+    background: var(--surface);
+    border: 1px solid var(--line);
+    border-left: 3px solid var(--line);
+    border-radius: var(--radius);
+    margin: var(--grid-1) 0;
+    padding: var(--grid-1) var(--grid-2);
+}
+
+.comment.bot { border-left-color: var(--accent); }
+.comment blockquote {
+    border-left: 3px solid var(--line);
+    color: var(--ink-muted);
+    margin: var(--grid-1) 0;
+    padding: 0 var(--grid-2);
+    white-space: pre-wrap;
+}
+
+/* ---- Blocks (questions, RFCs, reviews) ---- */
+
+.block-card {
+    background: var(--surface);
+    border: 1px solid var(--line);
+    border-left: 4px solid var(--accent);
+    border-radius: var(--radius);
+    margin: var(--grid-2) 0;
+    padding: var(--grid-2);
+}
+
+.block-card.rfc { border-left-color: var(--plan); }
+.block-card.review { border-left-color: var(--review); }
+.block-card.answered { border-left-color: var(--line); opacity: 0.85; }
+
+.block-card .options { display: flex; flex-direction: column; gap: var(--grid-1); margin: var(--grid-1) 0; }
+.block-card .options label { display: flex; gap: var(--grid-1); align-items: baseline; font-weight: normal; }
+.block-card textarea { min-height: 4rem; }
+
+/* ---- Review banner ---- */
+
+.review-banner {
+    background: #fff8eb;
+    border: 1px solid var(--review);
+    border-radius: var(--radius);
+    margin: var(--grid-2) 0;
+    padding: var(--grid-2);
+}
+
+.review-banner h3 { color: #9a4700; margin: 0 0 var(--grid-1); }
+.review-banner .actions { display: flex; gap: var(--grid-1); flex-wrap: wrap; margin-top: var(--grid-1); }
+
+/* ---- Progress ---- */
+
+.progress-latest {
+    background: #dafbe1;
+    border: 1px solid var(--success);
+    border-radius: var(--radius);
+    font-weight: 600;
+    margin: var(--grid-1) 0;
+    padding: var(--grid-1) var(--grid-2);
+}
+
+.progress-history li { margin: var(--grid-1) 0; }
+
+/* ---- Terminal ---- */
+
+.terminal {
+    background: #0d1117;
+    border-radius: var(--radius);
+    color: #c9d1d9;
+    font-family: ui-monospace, "Cascadia Mono", monospace;
+    font-size: 0.85rem;
+    max-height: 40rem;
+    overflow-y: auto;
+    padding: var(--grid-2);
+}
+
+.term-entry {
+    background: #161b22;
+    border: 1px solid #30363d;
+    border-radius: 6px;
+    margin: var(--grid-1) 0;
+    padding: var(--grid-1) var(--grid-2);
+}
+
+.term-entry .term-head {
+    display: flex;
+    align-items: baseline;
+    gap: var(--grid-1);
+    flex-wrap: wrap;
+    color: #8b949e;
+    font-size: 0.8rem;
+}
+
+.term-entry .term-command {
+    color: #79c0ff;
+    font-weight: 600;
+    white-space: pre-wrap;
+    word-break: break-word;
+}
+
+.term-entry pre {
+    border-radius: 6px;
+    margin: var(--grid-1) 0 0;
+    padding: var(--grid-1) var(--grid-2);
+    white-space: pre-wrap;
+    word-break: break-word;
+}
+
+.term-entry .term-output {
+    background: #0d1117;
+    color: #c9d1d9;
+    cursor: pointer;
+}
+
+.term-entry .term-output.collapsed { max-height: 7.5rem; overflow: hidden; position: relative; }
+.term-entry .term-output.collapsed::after {
+    content: "… click to expand";
+    background: linear-gradient(transparent, #161b22 70%);
+    color: #8b949e;
+    display: block;
+    padding: var(--grid-2) var(--grid-2) var(--grid-1);
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+}
+
+.term-entry.type-comment .term-output, .term-entry.type-comment .term-command { color: #8b949e; font-style: italic; }
+.term-entry.type-block { border-color: #d29922; }
+.term-entry.type-block .term-output { color: #e3b341; }
+.term-entry.type-user-comment { border-color: #58a6ff; }
+.term-entry.type-user-comment .term-output, .term-entry.type-user-comment .term-command { color: #79c0ff; }
+.term-entry.type-progress { border-color: #2ea043; }
+.term-entry.type-progress .term-output, .term-entry.type-progress .term-command { color: #56d364; }
+.term-entry.type-review { border-color: #e3b341; }
+.term-entry.type-review .term-output, .term-entry.type-review .term-command { color: #e3b341; }
+.term-entry.type-plan { border-color: #a371f7; }
+.term-entry.type-plan .term-output, .term-entry.type-plan .term-command { color: #bc8cff; }
+
+/* ---- Text-selection comment popover ---- */
+
+.quote-popover {
+    background: var(--surface);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+    box-shadow: 0 4px 16px #0002;
+    padding: var(--grid-2);
+    position: absolute;
+    width: min(26rem, calc(100vw - 3rem));
+    z-index: 40;
+}
+
+.quote-popover blockquote {
+    border-left: 3px solid var(--accent);
+    color: var(--ink-muted);
+    font-family: ui-monospace, monospace;
+    font-size: 0.8rem;
+    margin: 0 0 var(--grid-1);
+    max-height: 6rem;
+    overflow-y: auto;
+    padding: 0 var(--grid-1);
+    white-space: pre-wrap;
+}
+
+.quote-button {
+    background: var(--accent);
+    border: none;
+    border-radius: 6px;
+    color: #fff;
+    font-size: 0.8rem;
+    padding: 0.3rem 0.7rem;
+    position: absolute;
+    z-index: 41;
+}
+
+/* ---- Toasts ---- */
+
+.toast-stack {
+    bottom: var(--grid-2);
+    display: flex;
+    flex-direction: column;
+    gap: var(--grid-1);
+    max-width: min(24rem, calc(100vw - 3rem));
+    position: fixed;
+    right: var(--grid-2);
+    z-index: 100;
+}
+
+.toast {
+    animation: toast-in 0.25s ease-out;
+    background: var(--surface);
+    border-left: 4px solid var(--accent);
+    border-radius: 6px;
+    box-shadow: 0 4px 16px #0003;
+    font-size: 0.9rem;
+    padding: var(--grid-1) var(--grid-2);
+}
+
+.toast.success { border-left-color: var(--success); }
+.toast.error { border-left-color: var(--danger); }
+
+.toast.leaving { animation: toast-out 0.25s ease-in forwards; }
+
+@keyframes toast-in {
+    from { opacity: 0; transform: translateX(1rem); }
+    to { opacity: 1; transform: translateX(0); }
+}
+
+@keyframes toast-out {
+    from { opacity: 1; transform: translateX(0); }
+    to { opacity: 0; transform: translateX(1rem); }
+}
+
+[hidden] { display: none !important; }
+
+@media (max-width: 40rem) {
+    header { flex-direction: column; gap: var(--grid-1); }
+    .cards { grid-template-columns: 1fr; }
+    th, td { padding: 0.4rem 0.5rem; font-size: 0.9rem; }
+}

+ 49 - 0
src/pages/home.html

@@ -3,6 +3,22 @@
 <head>
     <pstm-uri>/</pstm-uri>
     <pstm-template>main</pstm-template>
+    <script>
+        // Live dashboard: the pulse subscription (see the entrypoint)
+        // re-runs the sealed refresh action whenever task or saddle state
+        // changes, so progress, blocks and review requests stream in.
+        if (window.statum) {
+            (function () {
+                'use strict';
+                statum.addStateListener('pulse', function () {
+                    var dashboard = statum.read('dashboard');
+                    if (dashboard && dashboard.refresh) {
+                        statum.act(dashboard.refresh).catch(function () { });
+                    }
+                });
+            })();
+        }
+    </script>
 </head>
 <body>
     <section>
@@ -27,6 +43,39 @@
                 </section>
             </div>
 
+            <h3 stm-if="dashboard.reviews.length > 0">Reviews pending</h3>
+            <div stm-for-review-in="dashboard.reviews" class="review-banner">
+                <h3>Review — <a stm-attribute.href="'/tasks/' + review.task_id" stm-text="review.task_title">task</a></h3>
+                <div class="md" stm-markdown="review.summary">summary</div>
+                <p><a stm-if="review.diff_url.length > 0" stm-attribute.href="review.diff_url"
+                      target="_blank" rel="noopener">compare changes</a></p>
+            </div>
+
+            <h3 stm-if="dashboard.open_blocks.length > 0">Needs an answer</h3>
+            <div class="cards" stm-if="dashboard.open_blocks.length > 0">
+                <section class="card" stm-for-blockview-in="dashboard.open_blocks">
+                    <p><span stm-attribute.class="'badge badge-' + blockview.block_type" stm-text="blockview.block_type">type</span>
+                        <span class="muted" stm-text="blockview.created_display">when</span></p>
+                    <p><strong stm-text="blockview.question">question</strong></p>
+                    <p><a stm-attribute.href="'/tasks/' + blockview.task_id" stm-text="blockview.task_title">task</a></p>
+                </section>
+            </div>
+
+            <h3 stm-if="dashboard.active_tasks.length > 0">Active tasks</h3>
+            <table stm-if="dashboard.active_tasks.length > 0">
+                <thead><tr><th>Task</th><th>Status</th><th>Branch</th><th>Latest progress</th></tr></thead>
+                <tbody>
+                    <tr stm-for-active-in="dashboard.active_tasks">
+                        <td><a stm-attribute.href="'/tasks/' + active.id" stm-text="active.title">task</a></td>
+                        <td><span stm-attribute.class="'badge badge-' + active.status" stm-text="active.status">status</span></td>
+                        <td stm-text="active.task_branch">branch</td>
+                        <td><strong stm-if="active.latest_progress.length > 0" stm-text="active.latest_progress">progress</strong>
+                            <span class="muted" stm-if="active.latest_progress.length > 0" stm-text="active.progress_display">when</span>
+                            <span class="muted" stm-if="active.latest_progress.length == 0">—</span></td>
+                    </tr>
+                </tbody>
+            </table>
+
             <h3>Saddles</h3>
             <div class="cards">
                 <section class="card" stm-for-saddle-in="dashboard.saddles">

+ 3 - 2
src/pages/login.html

@@ -4,7 +4,8 @@
     <pstm-uri>/login</pstm-uri>
     <pstm-template>main</pstm-template>
     <script>
-        // Surface error notifications inline instead of via alert().
+        // Surface error notifications inline instead of as toasts so a
+        // failed login is shown next to the form; other kinds toast.
         if (window.statum) {
             statum.onNotifyHandler = function (kind, message) {
                 var area = document.getElementById('login-error');
@@ -13,7 +14,7 @@
                     area.textContent = message;
                     return;
                 }
-                window.alert(message);
+                if (window.showToast) showToast(message, kind);
             };
         }
     </script>

+ 137 - 42
src/pages/main.html

@@ -6,48 +6,143 @@
     <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>
+    <link rel="stylesheet" pstm-res-href="app.css">
+    <link rel="stm-guard" require="auth != null || location.pathname == '/login'" href="/login">
+    <script>
+        // Shared page plumbing. showToast: slide-in notifications (used in
+        // place of alert). initQuoteComments: registers a container matcher
+        // for the text-highlight -> comment popover — selecting text inside
+        // a matched container shows a floating button, then a popover that
+        // posts the comment with the quoted text via the action the page
+        // supplies.
+        if (window.statum) {
+            (function () {
+                'use strict';
+
+                window.showToast = function (message, kind) {
+                    var stack = document.querySelector('.toast-stack');
+                    if (!stack) {
+                        stack = document.createElement('div');
+                        stack.className = 'toast-stack';
+                        document.body.appendChild(stack);
+                    }
+                    var toast = document.createElement('div');
+                    toast.className = 'toast ' + (kind || '');
+                    toast.textContent = message;
+                    stack.appendChild(toast);
+                    window.setTimeout(function () {
+                        toast.classList.add('leaving');
+                        window.setTimeout(function () { toast.remove(); }, 300);
+                    }, 5000);
+                };
+
+                statum.onNotifyHandler = function (kind, message) {
+                    showToast(message || kind, kind);
+                };
+
+                var quoteHandlers = [];
+                var quoteButton = null;
+                var quotePopover = null;
+                var quotedText = '';
+                var activeHandler = null;
+
+                window.initQuoteComments = function (handler) {
+                    quoteHandlers.push(handler);
+                };
+
+                document.addEventListener('mouseup', function (event) {
+                    if (quotePopover && quotePopover.contains(event.target)) return;
+                    var selection = window.getSelection();
+                    var text = selection ? String(selection.toString()).trim() : '';
+                    activeHandler = matchHandler(event.target);
+                    if (text.length === 0 || !activeHandler) {
+                        hideQuoteUi();
+                        return;
+                    }
+                    quotedText = text;
+                    showQuoteButton(event.pageX, event.pageY);
+                });
+
+                document.addEventListener('mousedown', function (event) {
+                    if (quoteButton && quoteButton.contains(event.target)) return;
+                    if (quotePopover && quotePopover.contains(event.target)) return;
+                    hideQuoteUi();
+                });
+
+                function matchHandler(node) {
+                    for (var i = 0; i < quoteHandlers.length; i++) {
+                        if (quoteHandlers[i].matches(node)) return quoteHandlers[i];
+                    }
+                    return null;
+                }
+
+                function showQuoteButton(x, y) {
+                    hideQuoteUi();
+                    quoteButton = document.createElement('button');
+                    quoteButton.type = 'button';
+                    quoteButton.className = 'quote-button';
+                    quoteButton.textContent = 'Comment on this';
+                    quoteButton.style.left = Math.max(x - 60, 8) + 'px';
+                    quoteButton.style.top = (y - 36) + 'px';
+                    quoteButton.addEventListener('click', function (event) {
+                        event.preventDefault();
+                        showQuotePopover(event.pageX, event.pageY);
+                    });
+                    document.body.appendChild(quoteButton);
+                }
+
+                function showQuotePopover(x, y) {
+                    if (quoteButton) { quoteButton.remove(); quoteButton = null; }
+                    quotePopover = document.createElement('div');
+                    quotePopover.className = 'quote-popover';
+                    quotePopover.style.left = Math.min(x, window.innerWidth - 120) + 'px';
+                    quotePopover.style.top = y + 'px';
+
+                    var quote = document.createElement('blockquote');
+                    quote.textContent = quotedText;
+                    quotePopover.appendChild(quote);
+
+                    var body = document.createElement('textarea');
+                    body.placeholder = 'Your comment on the selected text';
+                    quotePopover.appendChild(body);
+
+                    var actions = document.createElement('div');
+                    actions.className = 'actions';
+                    var submit = document.createElement('button');
+                    submit.type = 'button';
+                    submit.className = 'primary';
+                    submit.textContent = 'Send comment';
+                    submit.addEventListener('click', function () {
+                        var action = activeHandler && activeHandler.action();
+                        if (body.value.trim().length === 0 || !action) return;
+                        statum.act(action, {
+                            body: body.value,
+                            quote: quotedText,
+                            return_to: location.pathname + location.search
+                        });
+                        hideQuoteUi();
+                        if (window.getSelection) window.getSelection().removeAllRanges();
+                    });
+                    var cancel = document.createElement('button');
+                    cancel.type = 'button';
+                    cancel.textContent = 'Cancel';
+                    cancel.addEventListener('click', hideQuoteUi);
+                    actions.appendChild(submit);
+                    actions.appendChild(cancel);
+                    quotePopover.appendChild(actions);
+
+                    document.body.appendChild(quotePopover);
+                    body.focus();
+                }
+
+                function hideQuoteUi() {
+                    if (quoteButton) { quoteButton.remove(); quoteButton = null; }
+                    if (quotePopover) { quotePopover.remove(); quotePopover = null; }
+                    activeHandler = null;
+                }
+            })();
+        }
+    </script>
 </head>
 <body>
     <header>

+ 2 - 0
src/pages/saddles.html

@@ -30,6 +30,8 @@
                         <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>
+                    <p stm-if="saddle.token.length > 0" class="muted">API token:
+                        <code stm-text="saddle.token">token</code></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>

+ 129 - 27
src/pages/task_detail.html

@@ -4,16 +4,32 @@
     <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.
+        // Live updates: the page subscribes to the global pulse (see the
+        // entrypoint); whenever task or saddle state changes anywhere in
+        // the app, the sealed refresh action rehydrates the task slot —
+        // no fixed-interval polling. RFC bodies get the shared
+        // text-highlight -> comment popover (see main.html).
         if (window.statum) {
-            window.setInterval(function () {
-                var task = statum.read('task');
-                if (task && task.refresh) {
-                    statum.act(task.refresh).catch(function () { });
+            (function () {
+                'use strict';
+                statum.addStateListener('pulse', function () {
+                    var task = statum.read('task');
+                    if (task && task.refresh) {
+                        statum.act(task.refresh).catch(function () { });
+                    }
+                });
+                if (window.initQuoteComments) {
+                    window.initQuoteComments({
+                        matches: function (node) {
+                            return node.closest && node.closest('.rfc-body') != null;
+                        },
+                        action: function () {
+                            var task = statum.read('task');
+                            return task && task.comment;
+                        }
+                    });
                 }
-            }, 3000);
+            })();
         }
     </script>
 </head>
@@ -38,7 +54,9 @@
                     <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>
+                    <p stm-if="task.task_branch.length > 0">Branch: <strong stm-text="task.task_branch">branch</strong>
+                        <span class="muted" stm-if="task.branch_from.length > 0"> from <span stm-text="task.branch_from">base</span></span></p>
+                    <p stm-if="task.working_branch.length > 0" class="muted">Working branch: <span stm-text="task.working_branch">branch</span></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>
@@ -47,7 +65,7 @@
 
                 <section class="card">
                     <h3>Description</h3>
-                    <p stm-text="task.description">description</p>
+                    <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>
                     <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>
@@ -59,15 +77,20 @@
                         <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>
+                            <textarea name="description" rows="4" stm-attribute.value="task.description"></textarea></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>Task branch</label>
+                            <input type="text" name="task_branch" stm-attribute.value="task.task_branch" placeholder="task/<id>-<slug>"></div>
+                        <div class="form-row"><label>Branch from</label>
+                            <input type="text" name="branch_from" stm-attribute.value="task.branch_from"></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="review_requested">review_requested</option>
                                 <option value="done">done</option>
                                 <option value="failed">failed</option>
                             </select></div>
@@ -76,34 +99,113 @@
                 </section>
             </div>
 
+            <h3>Plan</h3>
+            <div stm-if="task.plan.length > 0" class="card md" stm-markdown="task.plan">plan</div>
+            <details stm-if="task.plan.length > 0">
+                <summary>Edit plan</summary>
+                <form stm-action="task.save_plan">
+                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                    <div class="form-row"><label for="plan-editor">Plan (markdown)</label>
+                        <textarea id="plan-editor" name="plan" rows="12" stm-attribute.value="task.plan"></textarea></div>
+                    <button type="submit" class="primary">Save plan</button>
+                </form>
+            </details>
+            <details stm-if="task.plan.length == 0">
+                <summary>Write a plan</summary>
+                <form stm-action="task.save_plan">
+                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                    <div class="form-row"><label for="plan-editor">Plan (markdown)</label>
+                        <textarea id="plan-editor" name="plan" rows="12" placeholder="The agent fills this in via `saddle plan` — or write it yourself."></textarea></div>
+                    <button type="submit" class="primary">Save plan</button>
+                </form>
+            </details>
+
+            <h3>Progress</h3>
+            <div stm-if="task.progress.length > 0">
+                <p class="progress-latest" stm-text="task.progress[0].summary">latest</p>
+                <p class="muted" stm-text="'reported ' + task.progress[0].when_display">when</p>
+                <details stm-if="task.progress.length > 1">
+                    <summary>Show history (<span stm-text="task.progress.length">0</span> updates)</summary>
+                    <ul class="progress-history">
+                        <li stm-for-item-in="task.progress">
+                            <span class="muted" stm-text="item.when_display">when</span> —
+                            <span stm-text="item.summary">summary</span>
+                        </li>
+                    </ul>
+                </details>
+            </div>
+            <p stm-if="task.progress.length == 0" class="muted">No progress reports yet — the agent reports via
+                <code>saddle progress-update</code>.</p>
+
             <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>
+            <div stm-for-block-in="task.blocks" stm-key="block.id"
+                 stm-attribute.class="'block-card ' + block.block_type + (block.answered ? ' answered' : '')">
+                <div stm-if="!block.answered && block.block_type == 'review'" class="review-banner">
+                    <h3>Review requested</h3>
+                    <div class="md" stm-markdown="block.body.length > 0 ? block.body : block.question">summary</div>
+                    <p>Branch <strong stm-text="task.task_branch">branch</strong>
+                        <a stm-if="task.review_diff_url.length > 0" stm-attribute.href="task.review_diff_url"
+                           target="_blank" rel="noopener">· compare changes</a></p>
+                    <div class="actions">
+                        <form stm-action="block.answer_action" class="inline">
+                            <input type="hidden" name="answer" value="Human approved the review">
+                            <button type="submit" class="approve">Approve</button>
+                        </form>
+                        <form stm-action="block.answer_action" class="inline">
+                            <textarea name="answer" rows="2" placeholder="What should change?"></textarea>
+                            <button type="submit" class="reject">Request changes</button>
+                        </form>
+                    </div>
+                </div>
+
+                <p><span stm-attribute.class="'badge badge-' + block.block_type" stm-text="block.block_type">type</span>
+                    <strong stm-text="block.question">question</strong>
                     <span class="muted" stm-text="block.created_display">when</span></p>
+
+                <div stm-if="block.body.length > 0 && block.block_type != 'review'" class="md rfc-body"
+                     stm-markdown="block.body">body</div>
+
                 <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 stm-if="!block.answered && block.block_type == 'question'" stm-action="block.answer_action">
+                    <div class="options">
+                        <label stm-for-option-in="block.options" stm-key="option">
+                            <input type="radio" name="option" stm-attribute.value="option">
+                            <span stm-text="option">option</span>
+                        </label>
+                        <label>
+                            <input type="radio" name="option" value="__other__">
+                            <span>Other:</span>
+                            <textarea name="other" rows="2"></textarea>
+                        </label>
+                    </div>
+                    <button type="submit" class="primary">Answer</button>
+                </form>
+
+                <form stm-if="!block.answered && block.block_type != 'question' && block.block_type != 'review'"
+                      stm-action="block.answer_action" class="inline">
+                    <textarea name="answer" rows="3" placeholder="Your reply (delivered to the agent)"></textarea>
+                    <button type="submit" class="primary">Reply</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 stm-for-comment-in="task.comments" 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>
+                <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
+                <div stm-text="comment.body">body</div>
             </div>
             <p stm-if="task.comments.length == 0" class="muted">No comments yet.</p>
-            <form stm-action="task.comment" class="inline">
+            <form stm-action="task.comment">
                 <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>
+                <div class="form-row"><label for="comment-body">Add comment</label>
+                    <textarea id="comment-body" name="body" rows="3" placeholder="A comment for the agent"></textarea></div>
+                <button type="submit" class="primary">Comment</button>
             </form>
 
             <h3>Subtasks</h3>
@@ -124,7 +226,7 @@
                     <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>
+                        <textarea name="description" rows="3"></textarea></div>
                     <button type="submit">Create subtask</button>
                 </form>
             </details>
@@ -166,7 +268,7 @@
             <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="summary" placeholder="Completion summary">
                 <input type="text" name="working_branch" placeholder="Branch (optional)">
                 <button type="submit">Mark done</button>
             </form>

+ 99 - 12
src/pages/task_terminal.html

@@ -3,6 +3,101 @@
 <head>
     <pstm-uri>/tasks/{task_id}/terminal</pstm-uri>
     <pstm-template>main</pstm-template>
+    <script>
+        // Live terminal: one EventSource to /terminal-stream/<session_id>
+        // renders every entry (initial backlog + new arrivals) as a typed
+        // card with collapsible output. Text highlighting inside the log
+        // offers the shared comment popover (see main.html).
+        if (window.statum) {
+            (function () {
+                'use strict';
+
+                var TERMINAL_TYPES = {
+                    'command': 'bash',
+                    'comment': 'llm-comment',
+                    'block': 'block',
+                    'user-comment': 'user-comment',
+                    'progress': 'progress',
+                    'review': 'review',
+                    'plan': 'plan'
+                };
+
+                var connected = false;
+                var commentAction = null;
+
+                statum.addStateListener('terminal', function () {
+                    var state = statum.read('terminal');
+                    if (!connected && state && state.exists && state.session_id > 0) {
+                        commentAction = state.comment;
+                        if (window.initQuoteComments) {
+                            window.initQuoteComments({
+                                matches: function (node) {
+                                    return node.closest && node.closest('#term-log') != null;
+                                },
+                                action: function () { return commentAction; }
+                            });
+                        }
+                        connect(state.session_id);
+                    }
+                });
+
+                function connect(sessionId) {
+                    connected = true;
+                    var log = document.getElementById('term-log');
+                    var source = new EventSource('/terminal-stream/' + sessionId);
+                    var lastId = 0;
+                    source.onmessage = function (event) {
+                        if (event.data === 'ping') return;
+                        var entry = null;
+                        try { entry = JSON.parse(event.data); } catch (e) { return; }
+                        if (!entry || entry.id <= lastId) return;
+                        lastId = entry.id;
+                        var placeholder = log.querySelector('.term-placeholder');
+                        if (placeholder) placeholder.remove();
+                        var nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 48;
+                        log.appendChild(renderEntry(entry));
+                        if (nearBottom) log.scrollTop = log.scrollHeight;
+                    };
+                }
+
+                function renderEntry(entry) {
+                    var type = TERMINAL_TYPES[entry.type] || entry.type || 'command';
+                    var card = document.createElement('div');
+                    card.className = 'term-entry type-' + (entry.type || 'command');
+
+                    var head = document.createElement('div');
+                    head.className = 'term-head';
+                    var badge = document.createElement('span');
+                    badge.className = 'badge badge-' + (entry.type || 'command');
+                    badge.textContent = type;
+                    head.appendChild(badge);
+                    if (entry.id) {
+                        var idLabel = document.createElement('span');
+                        idLabel.textContent = '#' + entry.id;
+                        head.appendChild(idLabel);
+                    }
+                    card.appendChild(head);
+
+                    var command = document.createElement('div');
+                    command.className = 'term-command';
+                    command.textContent = entry.command || '';
+                    card.appendChild(command);
+
+                    if (entry.output && entry.output.length > 0) {
+                        var output = document.createElement('pre');
+                        output.className = 'term-output';
+                        output.textContent = entry.output;
+                        if (entry.output.split('\n').length > 5) output.classList.add('collapsed');
+                        output.addEventListener('click', function () {
+                            output.classList.toggle('collapsed');
+                        });
+                        card.appendChild(output);
+                    }
+                    return card;
+                }
+            })();
+        }
+    </script>
 </head>
 <body>
     <section>
@@ -11,8 +106,7 @@
 
         <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>
+                · session <strong stm-text="terminal.session_id">id</strong></p>
 
             <p stm-if="terminal.sessions.length > 1">Sessions:
                 <span stm-for-session-in="terminal.sessions">
@@ -24,18 +118,11 @@
                        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 class="terminal" stm-if="terminal.sessions.length > 0" id="term-log">
+                <p class="muted term-placeholder" stm-if="terminal.sessions.length > 0">Connecting to the live stream…</p>
             </div>
+            <p stm-if="terminal.sessions.length == 0" class="muted">No sessions recorded for this task yet.</p>
         </div>
     </section>
 </body>

+ 8 - 2
src/pages/tasks.html

@@ -62,12 +62,18 @@
                     <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>
+                        <textarea name="description" rows="4" placeholder="What the agent should achieve"></textarea></div>
+                    <div class="form-row"><label>Plan (markdown, optional)</label>
+                        <textarea name="plan" rows="6" placeholder="The agent fills this in if left empty"></textarea></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>Task branch (optional)</label>
+                        <input type="text" name="task_branch" placeholder="Inherited from the parent task when blank"></div>
+                    <div class="form-row"><label>Branch from (optional)</label>
+                        <input type="text" name="branch_from" placeholder="Prefilled from the project default"></div>
                     <div class="form-row"><label>Design task</label>
                         <input type="checkbox" name="is_design_task"></div>
-                    <button type="submit">Create task</button>
+                    <button type="submit" class="primary">Create task</button>
                 </form>
             </details>
         </div>

BIN
test/__pycache__/integration_test.cpython-314.pyc


+ 165 - 1
test/integration_test.py

@@ -8,6 +8,11 @@ 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.
 
+A fourth section exercises the part 2 surface directly against the
+saddle ingest API: bearer-token auth, typed blocks with options, plan,
+progress and review updates, quoted comments on the poll endpoint, and
+the terminal SSE stream.
+
 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
@@ -32,8 +37,8 @@ Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
 """
 import json
 import os
-import subprocess
 import sqlite3
+import subprocess
 import sys
 import time
 import urllib.error
@@ -158,6 +163,24 @@ def wait_task_status(auth, task_id, status, timeout=90):
                     timeout=timeout, what="task %d -> %s" % (task_id, status))
 
 
+def saddle_api(method, path, token, payload=None):
+    """Raw call against the saddle ingest API; returns (status, json)."""
+    request = urllib.request.Request(WEB + path,
+                                     data=json.dumps(payload or {}).encode(), method=method)
+    request.add_header("Content-Type", "application/json")
+    if token is not None:
+        request.add_header("Authorization", "Bearer " + token)
+    try:
+        with urllib.request.urlopen(request, timeout=15) as response:
+            return response.status, json.loads(response.read().decode() or "{}")
+    except urllib.error.HTTPError as error:
+        body = error.read().decode()
+        try:
+            return error.code, json.loads(body)
+        except ValueError:
+            return error.code, {"raw": body}
+
+
 def start_mock():
     responses = [
         "# Inspecting the checkout\nls -la %s" % DEMO,
@@ -292,6 +315,147 @@ def main():
               "each session carries its own commands (%s)" % per_session)
         db.close()
 
+        print("== part 2 API surface ==")
+        db = sqlite3.connect(DB_PATH)
+
+        saddles_state = slot_public(auth, "/saddles", "saddles")
+        act(auth, saddles_state["register"], {"name": "saddle-e2e-api", "host": "localhost:7199"})
+        api_rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                    if s["name"] == "saddle-e2e-api"]
+        check(len(api_rows) == 1 and api_rows[0]["token"].startswith("saddle-"),
+              "registration returns an API token")
+        api_saddle = int(api_rows[0]["id"])
+        api_token = api_rows[0]["token"]
+
+        tasks_state = slot_public(auth, "/tasks", "tasks")
+        act(auth, tasks_state["create"], {
+            "project_id": "1",
+            "title": "Part 2 API surface",
+            "description": "Typed blocks, plan, progress, review, quoted comments, SSE",
+            "plan": "# Initial plan",
+            "priority": "6",
+            "task_branch": "task/9-api-surface",
+        })
+        api_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                        if t["title"] == "Part 2 API surface"][0])
+        detail = task_state(auth, api_task)
+        check(detail["branch_from"] == "main",
+              "branch_from prefilled from the project default branch")
+        check(detail["task_branch"] == "task/9-api-surface", "task_branch stored")
+
+        # Dispatch bookkeeping without a daemon: the ingest API is under
+        # test, so the session and saddle/task state are staged directly.
+        db.execute("insert into sessions (task_id, saddle_id, started_at)"
+                   " values (?, ?, strftime('%s','now'))", (api_task, api_saddle))
+        db.execute("update saddles set current_task_id = ?, status = 'idle' where id = ?",
+                   (api_task, api_saddle))
+        db.execute("update tasks set status = 'working' where id = ?", (api_task,))
+        db.commit()
+        api_session = db.execute("select max(id) from sessions").fetchone()[0]
+
+        status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, None,
+                               {"type": "comment", "session_id": api_session, "data": {"body": "x"}})
+        check(status == 401, "update without a token is rejected")
+        status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, "saddle-not-a-token",
+                               {"type": "comment", "session_id": api_session, "data": {"body": "x"}})
+        check(status == 401, "update with an invalid token is rejected")
+        status, _ = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, None)
+        check(status == 401, "poll without a token is rejected")
+
+        status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
+                                  {"type": "comment", "session_id": api_session,
+                                   "data": {"body": "Surveyed the module"}})
+        check(status == 200 and body.get("ok"), "comment update accepted with token")
+        status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
+                                  {"type": "block", "session_id": api_session, "data": {
+                                      "question": "Which database?",
+                                      "type": "question",
+                                      "options": ["SQLite", "Postgres"],
+                                      "body": "# Pick one"}})
+        check(status == 200 and body.get("block_id"), "typed block returns block_id")
+        status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
+                               {"type": "plan", "session_id": api_session,
+                                "data": {"plan": "# Plan\n- migrate schemas"}})
+        check(status == 200, "plan update accepted")
+        status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
+                               {"type": "progress", "session_id": api_session,
+                                "data": {"summary": "Migrated the schemas"}})
+        check(status == 200, "progress update accepted")
+        status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
+                               {"type": "review", "session_id": api_session,
+                                "data": {"summary": "Migration **ready** for review"}})
+        check(status == 200, "review update accepted")
+
+        detail = task_state(auth, api_task)
+        check(detail["status"] == "review_requested", "review flips the task status")
+        check([b["options"] for b in detail["blocks"]
+               if b["block_type"] == "question"] == [["SQLite", "Postgres"]],
+              "question block carries its options")
+        check("- migrate schemas" in detail["plan"], "plan visible on the task page")
+        check(detail["progress"][0]["summary"] == "Migrated the schemas",
+              "progress history newest-first")
+        check(detail["review_pending"] and "ready" in detail["review_summary"],
+              "review banner data on the task page")
+        check(detail["review_diff_url"].endswith(
+            "demo.git/compare/main...task/9-api-surface"), "review diff link constructed")
+
+        dash = slot_public(auth, "/", "dashboard")
+        check(any(int(r["task_id"]) == api_task for r in dash["reviews"]),
+              "dashboard lists the pending review")
+        check(any(b["question"] == "Which database?" for b in dash["open_blocks"]),
+              "dashboard lists blocks needing answers")
+
+        question = [b for b in detail["blocks"] if b["block_type"] == "question"][0]
+        act(auth, question["answer_action"], {"option": "SQLite"})
+        check([b["answer"] for b in task_state(auth, api_task)["blocks"]
+              if b["block_type"] == "question"] == ["Human selected: SQLite"],
+              "option answer formatted for the agent")
+        act(auth, detail["comment"],
+            {"body": "Check the migration output", "quote": "ERROR: table demos exists"})
+        status, body = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, api_token)
+        check(any(a["answer"] == "Human selected: SQLite" for a in body.get("answers", [])),
+              "poll hands over the block answer")
+        check(any(c["body"] == "Check the migration output" and "ERROR" in c["quote"]
+                  for c in body.get("comments", [])),
+              "poll hands over the quoted user comment")
+        status, body = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, api_token)
+        check(body.get("answers") == [] and body.get("comments") == [],
+              "poll delivers answers and comments exactly once")
+
+        entry_types = {row[0] for row in db.execute(
+            "select type from session_commands where session_id = ?", (api_session,))}
+        check({"comment", "block", "plan", "progress", "review", "user-comment"} <= entry_types,
+              "conversation events logged as typed terminal entries")
+
+        request = urllib.request.Request(WEB + "/terminal-stream/%d" % api_session)
+        with urllib.request.urlopen(request, timeout=10) as stream:
+            check(stream.headers.get("Content-Type", "").startswith("text/event-stream"),
+                  "terminal stream is text/event-stream")
+            lines = []
+            deadline = time.monotonic() + 5
+            while time.monotonic() < deadline and len(lines) < 40:
+                line = stream.readline().decode().strip()
+                if line:
+                    lines.append(line)
+                    deadline = min(deadline, time.monotonic() + 2)
+            entries = [json.loads(line[6:]) for line in lines
+                       if line.startswith("data: ") and line != "data: ping"]
+            check(len(entries) >= 7, "terminal stream replays the backlog")
+            db.execute("insert into session_commands (session_id, command_text, output_text,"
+                       " type, started_at) values (?, 'sse-live-check', 'ok', 'command',"
+                       " strftime('%s','now'))", (api_session,))
+            db.commit()
+            got_live = False
+            deadline = time.monotonic() + 6
+            while time.monotonic() < deadline and not got_live:
+                line = stream.readline().decode().strip()
+                if line.startswith("data: ") and "sse-live-check" in line:
+                    got_live = True
+            check(got_live, "terminal stream pushes new entries within seconds")
+
+        act(auth, detail["cancel"], {"task_id": str(api_task)})
+        db.close()
+
         print("== quiesce ==")
         check(wait_for(
             lambda: all(int(s["current_task_id"] or 0) == 0 and s["status"] == "idle"