Forráskód Böngészése

Plan 17 M3: event log UI replaces the terminal view

GET /tasks/<id>/events serves newest-first JSON pages (before/limit,
30 default, more flag); GET /tasks/<id>/events/stream serves the SSE
live view: the initial most-recent-30 oldest-first with ids for
reconnect resume, then per-second cursor walk appending newer rows,
heartbeat pings on quiet seconds.

The /tasks/<id>/log page renders client-side over those endpoints:
turn cards group a session's turn_text + tool_call + tool_result by
payload.turn, results collapse behind summaries carrying exit code,
byte counts and the truncated flag; lifecycle/state/comment/usage
events stream inline; load-more pages backwards when older events
remain; type filters and full-text search run over the loaded window.
All rendering goes through textContent/createElement.

The terminal page, its SSE channel and the yeehaw surfaces are deleted
(the entry stream they read is gone); session rows on the task page
carry the resume notice; goal pages link the event log. Dead terminal
CSS dropped.
clanker 1 hónapja
szülő
commit
b2a6f92b60

+ 0 - 159
src/Api/TerminalSse.vala

@@ -1,159 +0,0 @@
-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", "input"?}` — the entry types
-     * the terminal page renders with per-type styling (command, comment,
-     * block, user-comment, progress, review, plan, shell-reset, failed,
-     * query).
-     *
-     * Sessions with raw PTY rows (`session_raw_log`, the PTY-terminal
-     * feature) stream those instead: `{"seq", "type": "raw", "text"}`
-     * events in seq order, polled the same way. The client renders them
-     * as one unstyled `<pre>` of concatenated chunks.
-     */
-    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);
-
-            bool has_raw = false;
-            try {
-                var raw_probe = yield db.query<SessionRawLog>()
-                    .where(expr("session_id == $0", elem<int64?>(session_id)))
-                    .first_async();
-                has_raw = raw_probe != null;
-            } catch (Error e) {
-                warning(@"[ClankerSaddle] Terminal stream probe for session $(session_id) failed: $(e.message)");
-                return;
-            }
-            if (has_raw) {
-                yield stream_raw_log(session_id, stream);
-                return;
-            }
-
-            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 raw-log variant of the stream loop above. */
-        private async void stream_raw_log(int64 session_id, SseStream stream) {
-            int64 cursor = 0;
-            try {
-                while (!stream.is_closed) {
-                    bool pushed = false;
-                    foreach (var chunk in yield db.query<SessionRawLog>()
-                            .where(expr("session_id == $0 && seq > $1",
-                                elem<int64?>(session_id), elem<int64?>(cursor)))
-                            .order_by(expr("seq"))
-                            .materialise_async()) {
-                        yield stream.send_event(new SseEvent.with_id(
-                            chunk.seq.to_string(), raw_payload(chunk)));
-                        cursor = chunk.seq;
-                        pushed = true;
-                    }
-                    if (!pushed) {
-                        yield stream.send_event(new SseEvent("ping"));
-                    }
-                    yield nap_seconds(1);
-                }
-            } catch (Error e) {
-                if (!stream.is_closed) {
-                    warning(@"[ClankerSaddle] Raw 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);
-            if (entry.piped_input != null && ((!)entry.piped_input).length > 0) {
-                builder.set_member_name("input");
-                builder.add_string_value(((!)entry.piped_input));
-            }
-            builder.end_object();
-            var generator = new Json.Generator();
-            generator.set_root(builder.get_root());
-            return generator.to_data(null);
-        }
-
-        /** A raw chunk as a one-line JSON object payload. */
-        private static string raw_payload(SessionRawLog chunk) {
-            var builder = new Json.Builder();
-            builder.begin_object();
-            builder.set_member_name("seq");
-            builder.add_int_value(chunk.seq);
-            builder.set_member_name("type");
-            builder.add_string_value("raw");
-            builder.set_member_name("text");
-            builder.add_string_value(chunk.chunk);
-            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;
-        }
-    }
-}

+ 45 - 29
src/entrypoints/GoalDetailEntrypoint.vala

@@ -90,7 +90,6 @@ namespace ClankerSaddle {
         public string display { get; set; default = "draft"; }
         public string mode { get; set; default = "implement"; }
         public string creator { get; set; default = "human"; }
-        public bool yeehaw { get; set; default = false; }
         public string updated_display { get; set; default = ""; }
         /** The task's required capability tags (plan 14 B5 row badges). */
         public string[] capabilities { get; set; default = {}; }
@@ -229,6 +228,7 @@ namespace ClankerSaddle {
         public GoalService goals { get; set; }
         public TaskService tasks { get; set; }
         public ProjectService projects { get; set; }
+        public EventService events { get; set; }
         public InvercargillSql.Orm.OrmSession db { get; set; }
         public ActionRegistry? actions { get; set; default = null; }
         public SaddleService? saddles { get; set; default = null; }
@@ -236,12 +236,14 @@ namespace ClankerSaddle {
         public RuleService? rules { get; set; default = null; }
 
         public GoalViewContext(GoalService goals, TaskService tasks,
-                ProjectService projects, InvercargillSql.Orm.OrmSession db,
+                ProjectService projects, EventService events,
+                InvercargillSql.Orm.OrmSession db,
                 ActionRegistry? actions = null, SaddleService? saddles = null,
                 SaddlePollState? polls = null, RuleService? rules = null) {
             this.goals = goals;
             this.tasks = tasks;
             this.projects = projects;
+            this.events = events;
             this.db = db;
             this.actions = actions;
             this.saddles = saddles;
@@ -655,10 +657,8 @@ namespace ClankerSaddle {
                 }
             }
             // The rows consume TaskService.display_map_for directly
-            // (the same computed display the queue and the dashboard
-            // read) plus the batch yeehaw presence set.
+            // (the same computed display the queue and the dashboard read).
             var display_map = yield ctx.tasks.display_map_for(goal_task_rows.to_immutable_buffer());
-            var yeehaw_ids = yield ctx.tasks.yeehaw_task_ids();
             foreach (var task in goal_task_rows) {
                 var task_view = new GoalTaskView() {
                     id = task.id,
@@ -686,7 +686,6 @@ namespace ClankerSaddle {
                 task_view.capabilities = task_caps.to_array();
                 var display = display_map.get_or_default(task.id.to_string());
                 task_view.display = display != null ? ((!)display).status : task.status;
-                task_view.yeehaw = yeehaw_ids.has(task.id.to_string());
                 view.goal_tasks.add(task_view);
                 switch (task.status) {
                     case STATUS_DRAFT:
@@ -816,7 +815,7 @@ namespace ClankerSaddle {
                         // Deep-links this run's terminal view: the
                         // picker's client-side ?session_id=
                         // selection lands on exactly this session.
-                        terminal_url = @"/tasks/$(pub.run_task_id)/terminal?session_id=$(session.id)"
+                        terminal_url = @"/tasks/$(pub.run_task_id)/log"
                     });
                 }
                 // Current activity (task-page parity): the live round's
@@ -849,29 +848,14 @@ namespace ClankerSaddle {
                             }
                         }
                     }
-                    SessionCommand? newest = null;
-                    foreach (var command in yield ctx.db.query<SessionCommand>()
-                            .where(expr("session_id == $0", elem<int64?>(((!)open).id)))
-                            .order_by(expr("id"))
-                            .materialise_async()) {
-                        newest = command;
-                    }
+                    var newest = yield ctx.events.newest_activity(pub.run_task_id);
                     if (newest != null) {
-                        // Plan 13 B4: body-first for conversation-level
-                        // entries — the output (the comment body, the
-                        // failure explanation) leads; only a bare
-                        // command renders its command line, so the
-                        // literal `saddle comment` echo never leaves
-                        // the terminal.
-                        string line = ((!)newest).command_text;
-                        if (((!)newest).entry_type != "command"
-                                && (((!)newest).output_text ?? "").length > 0) {
-                            line = (!)newest.output_text;
-                        }
+                        string line = activity_line_of((!)newest);
                         if (line.length > 0) {
                             pub.current_command = clip_line(line, 200);
-                            pub.current_command_display = format_stamp(((!)newest).started_at);
-                            pub.current_command_running = ((!)newest).completed_at == null;
+                            pub.current_command_display = format_stamp(
+                                ((!)newest).created_at);
+                            pub.current_command_running = true;
                         }
                     }
                 }
@@ -931,6 +915,36 @@ namespace ClankerSaddle {
         }
 
         /** A flattened, clipped activity line (the current-activity display). */
+        /**
+         * One ledger activity event as the goal head's current-activity
+         * line: turn text and progress carry their text, a tool call
+         * names its tool (the run_command command when present), a
+         * tool result summarises as "tool result".
+         */
+        private static string activity_line_of(EventRecord record) {
+            var payload = EventService.payload_of(record);
+            switch (record.kind) {
+                case EVENT_TURN_TEXT:
+                case EVENT_PROGRESS:
+                case EVENT_LIFECYCLE:
+                    return EntryJson.string_member(payload, "text");
+                case EVENT_COMMENT:
+                    return EntryJson.string_member(payload, "body");
+                case EVENT_TOOL_CALL:
+                    string tool = EntryJson.string_member(payload, "tool");
+                    var args = payload.has_member("args")
+                        && payload.get_member("args").get_node_type() == Json.NodeType.OBJECT
+                        ? payload.get_object_member("args") : null;
+                    string command = args != null
+                        ? EntryJson.string_member((!)args, "command") : "";
+                    return command.length > 0 ? @"$(tool): $(command)" : tool;
+                case EVENT_TOOL_RESULT:
+                    return "tool result";
+                default:
+                    return "";
+            }
+        }
+
         private static string clip_line(string text, int limit) {
             string flat = text.replace("\n", " ⏎ ").strip();
             return flat.length > limit ? flat.substring(0, limit) + "…" : flat;
@@ -948,6 +962,7 @@ namespace ClankerSaddle {
         private GoalService goal_service = Inversion.inject<GoalService>();
         private TaskService task_service = Inversion.inject<TaskService>();
         private ProjectService project_service = Inversion.inject<ProjectService>();
+        private EventService event_service = Inversion.inject<EventService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = Inversion.inject<UserService>();
 
@@ -958,7 +973,7 @@ namespace ClankerSaddle {
             }
 
             var ctx = new GoalViewContext(goal_service, task_service, project_service,
-                db, action_registry);
+                event_service, db, action_registry);
             var rows = new JsonArray();
             foreach (var goal in yield goal_service.list_goals()) {
                 var view = yield GoalState.summary_view(goal, ctx);
@@ -1006,6 +1021,7 @@ namespace ClankerSaddle {
         private GoalService goal_service = Inversion.inject<GoalService>();
         private TaskService task_service = Inversion.inject<TaskService>();
         private ProjectService project_service = Inversion.inject<ProjectService>();
+        private EventService event_service = Inversion.inject<EventService>();
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
         private RuleService rule_service = Inversion.inject<RuleService>();
@@ -1020,7 +1036,7 @@ namespace ClankerSaddle {
 
             int64 goal_id = int64.parse(request.route("goal_id") ?? "0");
             var ctx = new GoalViewContext(goal_service, task_service, project_service,
-                db, action_registry, saddle_service, poll_state, rule_service);
+                event_service, db, action_registry, saddle_service, poll_state, rule_service);
             var state = yield GoalState.build(goal_id, ctx, state_service);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }

+ 1 - 10
src/entrypoints/HomeEntrypoint.vala

@@ -37,11 +37,6 @@ namespace ClankerSaddle {
         public int64 goal_id { get; set; }
         public string goal_title { get; set; default = ""; }
         public string text { get; set; default = ""; }
-        /**
-         * The referenced task earned its 🤠 badge — any of its sessions
-         * carries a `saddle yeehaw` entry (derived, nothing stored).
-         */
-        public bool task_yeehaw { get; set; }
     }
 
     /** An active task with its latest progress line. */
@@ -329,9 +324,6 @@ namespace ClankerSaddle {
             }
 
             var activity = new Vector<DashboardActivityView>();
-            // The 🤠 badge rides every timeline task reference: one
-            // batch presence set for the whole activity window.
-            var yeehaw_ids = yield tasks.yeehaw_task_ids();
             foreach (var item in yield timeline.recent_window()) {
                 activity.add(new DashboardActivityView() {
                     kind = item.kind,
@@ -341,8 +333,7 @@ namespace ClankerSaddle {
                     task_title = item.task_title,
                     goal_id = item.goal_id,
                     goal_title = item.goal_title,
-                    text = item.text,
-                    task_yeehaw = yeehaw_ids.has(item.task_id.to_string())
+                    text = item.text
                 });
             }
 

+ 3 - 35
src/entrypoints/TaskDetailEntrypoint.vala

@@ -31,15 +31,6 @@ namespace ClankerSaddle {
         public string when_display { get; set; default = ""; }
     }
 
-    /**
-     * One agent celebration (`saddle yeehaw`) on the task page: the
-     * message plus when it landed, rendered inside the festive card.
-     */
-    public class YeehawView : Object {
-        public string message { get; set; default = ""; }
-        public string when_display { get; set; default = ""; }
-    }
-
     /**
      * A block (question, RFC or review) with its answer state and
      * answer action. A pending review hides its body preview
@@ -92,6 +83,8 @@ namespace ClankerSaddle {
         public string started_display { get; set; default = ""; }
         public string ended_display { get; set; default = ""; }
         public string outcome { get; set; default = ""; }
+        /** The opening brief/resume notice the round first received. */
+        public string notice { get; set; default = ""; }
         public bool open { get; set; }
     }
 
@@ -165,16 +158,6 @@ namespace ClankerSaddle {
         /** The signed-in user holds a "Notify me" pref for this task. */
         public bool notify_me { get; set; }
         public Statum.Model.ActionDto? toggle_notify { get; set; }
-        /**
-         * The task has at least one `saddle yeehaw` entry — the 🤠
-         * badge's presence, derived from the existing
-         * session_commands rows (nothing new is stored).
-         */
-        public bool yeehaw { get; set; }
-        /** The task's uncapped yeehaw count (the card's "+N more" hint). */
-        public int64 yeehaw_total { get; set; }
-        /** Yeehaw entries beyond the card cap (0 within it). */
-        public int64 yeehaw_more { get; set; }
     }
 
     /**
@@ -308,21 +291,6 @@ namespace ClankerSaddle {
                 });
             }
 
-            // The festive yeehaw card (saddle yeehaw): newest entries
-            // first, capped at YEEHAW_CARD_MAX with a "+N more" hint;
-            // pub.yeehaw is the status-card-corner badge's presence.
-            var yeehaw_feed = yield tasks.yeehaw_feed(task.id);
-            pub.yeehaw = yeehaw_feed.total > 0;
-            pub.yeehaw_total = yeehaw_feed.total;
-            pub.yeehaw_more = (yeehaw_feed.total - yeehaw_feed.latest.length).clamp(0, int64.MAX);
-            var yeehaws = new Vector<YeehawView>();
-            foreach (var row in yeehaw_feed.latest) {
-                yeehaws.add(new YeehawView() {
-                    message = row.message,
-                    when_display = format_stamp(row.at)
-                });
-            }
-
             // Newest-first (the ordering sweep): list_blocks itself
             // returns newest-first now, so the rows flow straight
             // through — the blocking sections lead with the most
@@ -463,6 +431,7 @@ namespace ClankerSaddle {
                     started_display = format_stamp(session.started_at),
                     ended_display = format_stamp(session.ended_at),
                     outcome = session.outcome ?? "",
+                    notice = session.resume_notice ?? "",
                     open = session.ended_at == null
                 });
                 if (session.ended_at == null && saddle != null) {
@@ -491,7 +460,6 @@ namespace ClankerSaddle {
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["comments"] = StateJson.list<CommentView>(comments.to_immutable_buffer());
             props["progress"] = StateJson.list<ProgressView>(progress.to_immutable_buffer());
-            props["yeehaws"] = StateJson.list<YeehawView>(yeehaws.to_immutable_buffer());
             props["blocks"] = block_views(blocks);
             props["open_blocks"] = block_views(open_blocks);
             props["answered_blocks"] = block_views(answered_blocks);

+ 0 - 204
src/entrypoints/TaskTerminalEntrypoint.vala

@@ -1,204 +0,0 @@
-using Invercargill;
-using Invercargill.DataStructures;
-using Spry;
-using Spry.Authentication;
-using Statum;
-using ClankerSaddle.Data;
-using ClankerSaddle.Services;
-
-namespace ClankerSaddle {
-
-    /**
-     * One session selector row above the terminal log. The plan-12
-     * carried fix: each row carries its own `raw` flag so the CLIENT
-     * can switch streams (and rendering mode) without a server
-     * round-trip — the page reads `?session_id=` from its own URL
-     * (the Statum entrypoint fetch never carries the page's query
-     * string to the server, which is why only the first session used
-     * to render) and connects the chosen session's SSE stream itself.
-     * Plan 14 B4: rows are NEWEST-first (the dropdown's order) and
-     * carry `resumed`/`notice` — the resume-info panel above the
-     * terminal shows the selected session's notice (the opening brief
-     * framing for the task's first session, the stored resume_notice
-     * for resumed ones).
-     */
-    public class TerminalSessionView : Object {
-        public int64 id { get; set; }
-        public string label { get; set; default = ""; }
-        public bool selected { get; set; }
-        /** This session's raw PTY log exists (renders raw, not typed). */
-        public bool raw { get; set; }
-        /** The session began as a resume (a stored resume_notice). */
-        public bool resumed { get; set; }
-        /**
-         * What the agent first received in this session (markdown):
-         * the opening-brief framing for the task's first session, the
-         * stored resume_notice for later ones, "" when none.
-         */
-        public string notice { get; set; default = ""; }
-    }
-
-    /** Public data of the `terminal` slot. */
-    public class TerminalPublic : Object {
-        public bool exists { get; set; }
-        public int64 task_id { get; set; }
-        public string task_title { get; set; default = ""; }
-        public int64 session_id { get; set; }
-        public string session_state { get; set; default = ""; }
-        public bool has_raw_log { get; set; }
-        public Statum.Model.ActionDto? comment { get; set; }
-    }
-
-    /**
-     * Entrypoint for /tasks/{task_id}/terminal: the live bash session
-     * log — the goal agent's run history included (the goal page's
-     * "Terminal" tab lands here). The `session_id` query parameter
-     * selects which of the task's sessions to show; because the
-     * Statum entrypoint fetch never carries the page's own query
-     * string server-side, the SELECTION is client-side (see
-     * task_terminal.html): the server default is the LATEST session,
-     * the client honours `?session_id=` and reconnects. The log
-     * itself renders client-side from the
-     * `/terminal-stream/{session_id}` SSE stream — one unstyled
-     * `<pre>` of concatenated raw chunks when the session carries raw
-     * PTY rows, the typed entry cards with expandable output and
-     * text-highlight commenting otherwise — so this state carries the
-     * session table (with per-session raw flags) and the sealed
-     * comment action.
-     */
-    public class TaskTerminalEntrypoint : StatumEntrypoint {
-
-        private TaskService task_service = Inversion.inject<TaskService>();
-        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;
-            }
-
-            int64 task_id = int64.parse(request.route("task_id") ?? "0");
-            var detail = yield task_service.get_task(task_id);
-            if (detail == null) {
-                return directives().set_typed<TerminalPublic>("terminal", Scope.PAGE,
-                    new TerminalPublic() { exists = false });
-            }
-
-            var sessions = ((!)detail).sessions;
-            if (sessions.length == 0) {
-                var pub = new TerminalPublic() {
-                    exists = true,
-                    task_id = task_id,
-                    task_title = ((!)detail).task.title
-                };
-                return directives().set_typed<TerminalPublic>("terminal", Scope.PAGE, pub);
-            }
-
-            // The server-side default: the request's session_id when
-            // the entrypoint actually saw one (direct links), else the
-            // LATEST session — the client refines from its own URL.
-            int64 selected = 0;
-            string? requested = null;
-            request.query.try_get_any("session_id", out requested);
-            if (requested != null && requested.length > 0) {
-                selected = int64.parse(requested);
-            }
-            bool found = false;
-            int64 latest = 0;
-            // Sessions arrive newest-first ({@link
-            // TaskService.get_task} since the ordering sweep); the
-            // explicit max keeps the default independent of order.
-            foreach (var session in sessions) {
-                if (session.id > latest) {
-                    latest = session.id;
-                }
-                if (session.id == selected) {
-                    found = true;
-                }
-            }
-            if (!found) {
-                selected = latest;
-            }
-
-            var pub_with_session = new TerminalPublic() {
-                exists = true,
-                task_id = task_id,
-                task_title = ((!)detail).task.title,
-                session_id = selected,
-                has_raw_log = yield task_service.has_raw_log(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>();
-            // Newest first (plan 14 B4): the dropdown leads with the
-            // latest session. The FIRST session (lowest id) shows the
-            // opening-brief framing; later ones their stored
-            // resume_notice.
-            int64 first_session = 0;
-            foreach (var session in sessions) {
-                if (first_session == 0 || session.id < first_session) {
-                    first_session = session.id;
-                }
-            }
-            string opening_notice = yield opening_brief_notice(((!)detail).task);
-            // Newest-first straight through: the sessions arrive
-            // newest-first ({@link TaskService.get_task}) and the
-            // dropdown keeps that order — no reversal anymore.
-            var session_array = sessions.to_array();
-            for (int i = 0; i < session_array.length; i++) {
-                var session = session_array[i];
-                bool resumed = session.resume_notice != null
-                    && ((!)session.resume_notice).length > 0;
-                string notice = session.id == first_session
-                    ? opening_notice : (session.resume_notice ?? "");
-                session_rows.add(new TerminalSessionView() {
-                    id = session.id,
-                    label = @"Session #$(session.id) — started $(format_stamp(session.started_at))"
-                        + (resumed ? " [resumed]" : ""),
-                    selected = session.id == selected,
-                    raw = yield task_service.has_raw_log(session.id),
-                    resumed = resumed,
-                    notice = notice
-                });
-            }
-
-            var props = (PropertyDictionary) GObjectMapping.to_properties(pub_with_session);
-            props["sessions"] = StateJson.list<TerminalSessionView>(session_rows.to_immutable_buffer());
-
-            var slot = state_service.new_slot(Scope.PAGE, new State() {
-                type_name = "terminal",
-                public_data = props,
-                private_data = new PropertyDictionary()
-            });
-            return directives().set(slot);
-        }
-
-
-        /**
-         * The opening-brief framing for a task's FIRST session (plan
-         * 14 B4): what the assignment this session started with asked
-         * for — the goal's brief for a goal agent's run, the task's
-         * mandate otherwise (whichever carries text). "" when neither
-         * does (an empty-state panel).
-         */
-        private async string opening_brief_notice(TaskEntity task) throws GLib.Error {
-            string brief = task.description ?? "";
-            if (task.kind == TASK_KIND_GOAL_RUN && task.goal_id > 0) {
-                var goal = yield db.query<Goal>()
-                    .where(Invercargill.Expressions.expr("id == $0",
-                        Invercargill.Expressions.elem<int64?>(task.goal_id)))
-                    .first_async();
-                if (goal != null && ((!)goal).brief.length > 0) {
-                    brief = ((!)goal).brief;
-                }
-            }
-            if (brief.strip().length == 0) {
-                return "";
-            }
-            return "## Opening brief\n\nThe assignment this session started with:\n\n"
-                + brief;
-        }
-    }
-}

+ 0 - 9
src/entrypoints/TasksEntrypoint.vala

@@ -24,11 +24,6 @@ namespace ClankerSaddle {
         public string[] capabilities { get; set; }
         public bool waiting_for_saddle { get; set; }
         public bool unreachable { get; set; }
-        /**
-         * The task earned its 🤠 badge — any of its sessions carries a
-         * `saddle yeehaw` entry (derived, nothing stored for it).
-         */
-        public bool has_yeehaw { get; set; }
         /** Plan 12: the task's work mode (badge). */
         public string mode { get; set; default = "implement"; }
         /** Plan 12: the task's creator ("human"/"goal"). */
@@ -188,9 +183,6 @@ namespace ClankerSaddle {
                 ((!)names).add(name);
             }
             var unreachable_ids = yield task_service.unreachable_task_ids();
-            // The 🤠 badge's batch presence set: every task with a
-            // yeehaw entry, one DISTINCT query for the whole queue.
-            var yeehaw_ids = yield task_service.yeehaw_task_ids();
             var idle_capability_sets = new Vector<Invercargill.DataStructures.HashSet<string>>();
             foreach (var saddle in yield saddle_service.list_idle_saddles()) {
                 var carried = new Invercargill.DataStructures.HashSet<string>();
@@ -247,7 +239,6 @@ namespace ClankerSaddle {
                     view.waiting_for_saddle = !covered && idle_capability_sets.length > 0;
                 }
                 view.unreachable = unreachable_ids.has(task.id.to_string());
-                view.has_yeehaw = yeehaw_ids.has(task.id.to_string());
                 rows.add(view);
             }
 

+ 74 - 63
src/pages/app.css

@@ -776,69 +776,6 @@ nav .nav-goal { font-weight: 600; }
 
 [hidden] { display: none !important; }
 
-/* Plan 14 B4: the terminal page's resume-info panel — what the agent
-   last received for the selected session (the opening brief for
-   session 1, the stored resume notice for later ones), a 200px
-   vertical-scroll strip above the terminal. */
-.resume-notice-panel {
-    background: var(--goal-surface);
-    border: 1px solid var(--line-soft);
-    border-left: 4px solid var(--goal);
-    border-radius: 6px;
-    margin: 0.75rem 0;
-    max-height: 200px;
-    overflow-y: auto;
-    padding: var(--grid-2);
-}
-
-/* Plain terminal — looking over a colleague's shoulder */
-
-.terminal-plain {
-    background: #1a1a2e;
-    border-radius: 6px;
-    color: #d4d4d4;
-    font-family: "SF Mono", "Fira Code", "Cascadia Code", Menlo, monospace;
-    font-size: 0.875rem;
-    line-height: 1.5;
-    max-height: 75vh;
-    overflow-y: auto;
-    padding: var(--grid-2);
-    white-space: pre-wrap;
-    word-break: break-all;
-}
-
-/* Raw PTY log — the daemon's chunks, byte-exact and unstyled */
-.terminal-raw {
-    margin: 0;
-    white-space: pre;
-    word-break: normal;
-}
-
-/* LLM thinking / bash comments — green */
-.term-think {
-    color: #4ade80;
-}
-
-/* stdin — what the LLM typed (blue) */
-.term-stdin {
-    color: #7dd3fc;
-}
-
-/* stdout — standard terminal white */
-.term-stdout {
-    color: #d4d4d4;
-}
-
-/* stderr — red */
-.term-stderr {
-    color: #f87171;
-}
-
-/* Recoverable rejections (bash -n / max_tokens) — amber, non-fatal */
-.term-rejected {
-    color: #fbbf24;
-}
-
 /* Comment dialog */
 
 dialog#quote-comment-dialog {
@@ -884,3 +821,77 @@ dialog#quote-comment-dialog blockquote {
     background: #f3e8ff;
     color: var(--plan);
 }
+
+/* Plan 17: the event log page (task_log.html) — the unified ledger's
+   human view. Turn cards group a run's turn-family events; tool
+   results collapse behind head+tail summaries with byte counts. */
+.log-toolbar {
+    display: flex;
+    flex-wrap: wrap;
+    gap: var(--grid-1, 0.5rem);
+    align-items: center;
+    margin-bottom: var(--grid-1, 0.5rem);
+}
+.log-toolbar input[type="search"] {
+    flex: 1;
+    min-width: 12rem;
+}
+.log-filter.active {
+    border-color: var(--ink, currentColor);
+    font-weight: 600;
+}
+.log-more {
+    display: block;
+    margin: 0 auto var(--grid-1, 0.5rem);
+}
+.log-feed {
+    display: flex;
+    flex-direction: column;
+    gap: var(--grid-1, 0.5rem);
+    max-height: 72vh;
+    overflow-y: auto;
+    border: 1px solid var(--line-soft, #eee);
+    border-radius: 6px;
+    padding: var(--grid-1, 0.5rem);
+}
+.turn-card {
+    border: 1px solid var(--line-soft, #eee);
+    border-left: 3px solid var(--line, #ccc);
+    border-radius: 6px;
+    padding: 0.4rem 0.6rem;
+}
+.turn-head {
+    margin: 0 0 0.3rem;
+    font-size: 0.85rem;
+}
+.event-row .event-head {
+    margin: 0.2rem 0;
+}
+.event-pre {
+    margin: 0.2rem 0;
+    padding: 0.3rem 0.5rem;
+    background: var(--line-soft, #f6f8fa);
+    border-radius: 4px;
+    overflow-x: auto;
+    font-size: 0.85rem;
+    white-space: pre-wrap;
+    word-break: break-word;
+}
+.event-pre.event-stderr {
+    color: var(--danger, #cf222e);
+}
+.event-result-body {
+    margin-top: 0.3rem;
+}
+.event-kind-turn_text { background: var(--line-soft, #f0f0f0); color: #6e7781; }
+.event-kind-tool_call { background: #ddf4ff; color: #0969da; }
+.event-kind-tool_result { background: var(--line-soft, #f0f0f0); color: #6e7781; }
+.event-kind-progress { background: #dafbe1; color: var(--success, #1a7f37); }
+.event-kind-comment { background: #f0f0f0; color: #6e7781; }
+.event-kind-answer { background: #ddf4ff; color: #0969da; }
+.event-kind-verdict { background: #fff1e0; color: #9a4700; }
+.event-kind-state_change { background: #fff8c5; color: var(--warning, #9a6700); }
+.event-kind-lifecycle { background: #f3e8ff; color: var(--plan, #8250df); }
+.event-kind-usage { background: var(--line-soft, #f0f0f0); color: #6e7781; }
+.event-kind-force_wake { background: #fff8c5; color: var(--warning, #9a6700); }
+.event-kind-cancel { background: #ffebe9; color: var(--danger, #cf222e); }

+ 5 - 6
src/pages/goal_detail.html

@@ -366,7 +366,7 @@
                                    stm-text="goal_task.title">title</a></td>
                             <td><span stm-attribute.class="'badge badge-' + goal_task.status"
                                       stm-text="goal_task.display">status</span>
-                                <span class="badge badge-yeehaw" stm-if="goal_task.yeehaw">🤠</span></td>
+                            </td>
                             <td><span stm-attribute.class="'badge badge-mode-' + goal_task.mode"
                                       stm-text="goal_task.mode">mode</span></td>
                             <td><span stm-for-capability-in="goal_task.capabilities">
@@ -521,13 +521,12 @@
             </section>
 
             <!-- Goal agent runs: every round with its host saddle; the
-                 terminal links open in a new tab (the session picker
-                 lives on the terminal page). -->
+                 event log links open in a new tab. -->
             <section class="card" stm-if="goal.runs.length > 0">
                 <h3>Goal agent runs</h3>
-                <p><a stm-attribute.href="'/tasks/' + goal.run_task_id + '/terminal'"
-                      target="_blank" rel="noopener">Terminal
-                        (session picker)</a> <span class="muted">— run task
+                <p><a stm-attribute.href="'/tasks/' + goal.run_task_id + '/log'"
+                      target="_blank" rel="noopener">Event log</a>
+                    <span class="muted">— run task
                         #<span stm-text="goal.run_task_id">id</span></span></p>
                 <table>
                     <thead><tr><th>Run</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th></tr></thead>

+ 0 - 2
src/pages/home.html

@@ -349,8 +349,6 @@
                                stm-attribute.href="'/tasks/' + item.task_id"
                                stm-text="'→ task #' + item.task_id">task</a></td>
                         <td stm-if="item.goal_id == 0"><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
-                            <span class="badge badge-yeehaw" stm-if="item.task_yeehaw"
-                                  title="The agent celebrated this task (saddle yeehaw)">🤠</span></td>
                         <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>
                             <!-- Comment rows read as footnotes (plan 13
                                  B4): the body leads, muted and smaller. -->

+ 3 - 22
src/pages/task_detail.html

@@ -77,24 +77,6 @@
             <p stm-if="task.progress.length == 0" class="muted">No progress reports yet — the agent reports via
                 <code>saddle progress-update</code>.</p>
 
-            <!-- Agent celebrations (`saddle yeehaw <message>`): the task's
-                 newest entries as one festive card — distinct 🤠 styling,
-                 the message + when, capped at the last 3 with a "+N more"
-                 hint. Absent entirely while the task has none. -->
-            <section class="card yeehaw-card" stm-if="task.yeehaw">
-                <h3>🤠 Yeehaw!</h3>
-                <ul class="yeehaw-list">
-                    <li stm-for-yeehaw-in="task.yeehaws">
-                        <span class="yeehaw-message" stm-text="yeehaw.message">message</span>
-                        <span class="muted" stm-text="yeehaw.when_display">when</span>
-                    </li>
-                </ul>
-                <p class="muted" stm-if="task.yeehaw_more > 0">+<span stm-text="task.yeehaw_more">0</span>
-                    more yeehaw<span stm-text="task.yeehaw_more == 1 ? '' : 's'"></span> on the
-                    <a stm-attribute.href="'/tasks/' + task.id + '/terminal'"
-                       target="_blank" rel="noopener">terminal log</a></p>
-            </section>
-
             <!-- Status + description side by side in a 50/50 grid (plan
                  11 task D), collapsing to stacked
                  status-then-description on small screens. The
@@ -104,8 +86,7 @@
                  non-terminal; completion is agent-only. -->
             <div class="grid-2">
                 <section class="card status-head">
-                    <h3>Status <span class="badge badge-yeehaw status-corner" stm-if="task.yeehaw"
-                          title="The agent celebrated this task (saddle yeehaw)">🤠</span></h3>
+                    <h3>Status</h3>
                     <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span>
                         <span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span>
                         · priority <strong stm-text="task.priority">5</strong></p>
@@ -398,8 +379,8 @@
                         <td stm-text="session.ended_display">ended</td>
                         <td stm-if="session.open"><span class="badge badge-working">open</span></td>
                         <td stm-if="!session.open" stm-text="session.outcome">outcome</td>
-                        <td><a stm-attribute.href="'/tasks/' + task.id + '/terminal?session_id=' + session.id"
-                               target="_blank" rel="noopener">Terminal</a></td>
+                        <td><a stm-attribute.href="'/tasks/' + task.id + '/log'"
+                               target="_blank" rel="noopener">Event log</a></td>
                     </tr>
                     <tr stm-if="task.sessions.length == 0"><td colspan="6" class="muted">No sessions yet.</td></tr>
                 </tbody>

+ 0 - 428
src/pages/task_terminal.html

@@ -1,428 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-    <pstm-uri>/tasks/{task_id}/terminal</pstm-uri>
-    <pstm-template>main</pstm-template>
-    <script>
-        if (window.statum) {
-            (function () {
-                'use strict';
-
-                var connected = false;
-
-                // Free the browser's per-origin SSE budget (HTTP/1.1 caps
-                // ~6 connections): a hidden terminal tab nobody watches
-                // must not starve Statum's realtime channel. Reconnect on
-                // focus — the stream replays and the client dedupes by
-                // seq/id.
-                document.addEventListener('visibilitychange', function () {
-                    if (!activeSource) return;
-                    if (document.visibilityState === 'hidden') {
-                        try { activeSource.close(); } catch (e) { }
-                    } else if (connected) {
-                        try { activeSource.close(); } catch (e) { }
-                        connect(activeSession, activeRaw);
-                    }
-                });
-
-                // The plan-12 session-picker fix: the Statum entrypoint
-                // fetch never carries this page's own ?session_id= to
-                // the server, so the server-side selection was stuck on
-                // its default and only the first-rendered session showed.
-                // The client therefore owns the selection: it reads
-                // ?session_id= from its own URL (falling back to the
-                // server default — the latest session), picks the
-                // matching session row's raw flag, and (re)connects
-                // whenever the choice changes.
-                function requestedSessionId(state) {
-                    var match = /[?&]session_id=(\d+)/.exec(window.location.search || '');
-                    if (match) {
-                        var requested = parseInt(match[1], 10);
-                        var rows = state.sessions || [];
-                        for (var i = 0; i < rows.length; i++) {
-                            if (parseInt(rows[i].id, 10) === requested) return requested;
-                        }
-                    }
-                    return state.session_id;
-                }
-
-                function sessionRaw(state, sessionId) {
-                    var rows = state.sessions || [];
-                    for (var i = 0; i < rows.length; i++) {
-                        if (parseInt(rows[i].id, 10) === parseInt(sessionId, 10)) {
-                            return !!rows[i].raw;
-                        }
-                    }
-                    return !!state.has_raw_log;
-                }
-
-                function highlightPicker(sessionId) {
-                    var select = document.getElementById('session-select');
-                    if (select) select.value = String(parseInt(sessionId, 10));
-                }
-
-                // Plan 14 B4: the dropdown + resume-info panel read the
-                // per-session rows (newest first) from state; the panel
-                // shows the selected session's notice — the opening
-                // brief for the first session, the stored resume_notice
-                // for resumed ones — rendered as markdown.
-                window.terminalSessionNotice = function (state) {
-                    var desired = requestedSessionId(state);
-                    var rows = (state && state.sessions) || [];
-                    for (var i = 0; i < rows.length; i++) {
-                        if (parseInt(rows[i].id, 10) === parseInt(desired, 10)) {
-                            return rows[i].notice || '';
-                        }
-                    }
-                    return '';
-                };
-
-                window.terminalPickSession = function (sessionId) {
-                    var task = statum.read('terminal');
-                    window.location.href = '/tasks/' + (task && task.task_id)
-                        + '/terminal?session_id=' + parseInt(sessionId, 10);
-                };
-
-                statum.addStateListener('terminal', function () {
-                    var state = statum.read('terminal');
-                    if (!state || !state.exists || !(state.session_id > 0)) return;
-                    // Floating "comment on selection" covers both
-                    // terminal flavours: the raw PTY log (#term-raw) and
-                    // the legacy entry log (#term-log).
-                    if (window.initQuoteComments) {
-                        window.initQuoteComments({
-                            matches: function (node) {
-                                if (!node.closest) {
-                                    return false;
-                                }
-                                return node.closest('#term-log') != null
-                                    || node.closest('#term-raw') != null;
-                            },
-                            action: function () { return state.comment; }
-                        });
-                    }
-                    var desired = requestedSessionId(state);
-                    if (!connected || desired !== activeSession) {
-                        connect(desired, sessionRaw(state, desired));
-                        highlightPicker(desired);
-                    }
-                });
-
-                // ANSI-to-HTML for the raw PTY stream (B8): SGR sequences
-                // become styled spans (bold, italic, 8/16/256-colour fg+bg,
-                // reset); every other CSI code is stripped. Chunks may split
-                // an escape sequence mid-code, so partial sequences carry
-                // across events in `carry`. Falls back to plain text when
-                // anything unexpected happens.
-                function makeAnsiRenderer() {
-                    var carry = '';
-                    var state = null;   // {bold, italic, fg, bg}
-                    var CSI = /\x1b\[([0-9;:]*)([A-Za-z])/;
-
-                    // Base 16 tuned for the dark terminal background: the
-                    // stock VGA palette (blue #000080 etc.) is unreadable
-                    // here, so both halves use lifted, dark-theme-friendly
-                    // values (VS Code-style).
-                    var BASE = ['#666666', '#cd3131', '#0dbc79', '#e5e510',
-                                '#2472c8', '#bc3fbc', '#29b8db', '#e5e5e5',
-                                '#8c8c8c', '#f14c4c', '#23d18b', '#f5f543',
-                                '#3b8eea', '#d670d6', '#43d4e8', '#ffffff'];
-                    var LEVELS = [0, 95, 135, 175, 215, 255];
-                    function colour256(n) {
-                        if (n < 16) return BASE[n];
-                        if (n < 232) {
-                            var cube = n - 16;
-                            return 'rgb(' + LEVELS[(cube / 36 | 0) % 6] + ','
-                                + LEVELS[(cube / 6 | 0) % 6] + ','
-                                + LEVELS[cube % 6] + ')';
-                        }
-                        var grey = 8 + 10 * (n - 232);
-                        return 'rgb(' + grey + ',' + grey + ',' + grey + ')';
-                    }
-
-                    function appendStyled(container, text) {
-                        if (!state || text.length === 0) {
-                            container.appendChild(document.createTextNode(text));
-                            return;
-                        }
-                        var span = document.createElement('span');
-                        if (state.bold) span.style.fontWeight = 'bold';
-                        if (state.italic) span.style.fontStyle = 'italic';
-                        if (state.fg != null) span.style.color = colour256(state.fg);
-                        if (state.bg != null) span.style.backgroundColor = colour256(state.bg);
-                        span.textContent = text;
-                        container.appendChild(span);
-                    }
-
-                    function applySgr(params) {
-                        var parts = params.split(';');
-                        for (var i = 0; i < parts.length; i++) {
-                            var inner = parts[i].split(':');
-                            for (var j = 0; j < inner.length; j++) {
-                                if (inner[j] === '') inner[j] = '0';
-                            }
-                            var n = parseInt(inner[0], 10);
-                            if (isNaN(n)) continue;
-                            if (n === 0) {
-                                state = null;
-                            } else if (n === 1) {
-                                state = state || {};
-                                state.bold = true;
-                            } else if (n === 3) {
-                                state = state || {};
-                                state.italic = true;
-                            } else if (n === 22) {
-                                if (state) state.bold = false;
-                            } else if (n === 23) {
-                                if (state) state.italic = false;
-                            } else if (n >= 30 && n <= 37) {
-                                state = state || {};
-                                state.fg = n - 30;
-                            } else if (n === 39) {
-                                if (state) state.fg = null;
-                            } else if (n >= 40 && n <= 47) {
-                                state = state || {};
-                                state.bg = n - 40;
-                            } else if (n === 49) {
-                                if (state) state.bg = null;
-                            } else if (n >= 90 && n <= 97) {
-                                state = state || {};
-                                state.fg = n - 90 + 8;
-                            } else if (n >= 100 && n <= 107) {
-                                state = state || {};
-                                state.bg = n - 100 + 8;
-                            } else if (n === 38 || n === 48) {
-                                var mode = inner[1] !== undefined ? parseInt(inner[1], 10)
-                                    : (parseInt(parts[i + 1], 10) || 5);
-                                var value = inner[2] !== undefined ? parseInt(inner[2], 10)
-                                    : parseInt(parts[i + 2], 10);
-                                if (mode === 5 && !isNaN(value) && value >= 0 && value <= 255) {
-                                    state = state || {};
-                                    if (n === 38) state.fg = value; else state.bg = value;
-                                    i += 2;
-                                } else if (mode === 2) {
-                                    i += 4; // truecolor: skip r;g;b unstyled
-                                } else {
-                                    i += 2; // unsupported form: skip its args
-                                }
-                            }
-                        }
-                    }
-
-                    return {
-                        render: function (text, container) {
-                            try {
-                                carry += text;
-                                while (carry.length > 0) {
-                                    if (carry.charAt(0) !== '\x1b') {
-                                        var next = carry.indexOf('\x1b');
-                                        var plain = next < 0 ? carry : carry.substring(0, next);
-                                        carry = next < 0 ? '' : carry.substring(next);
-                                        appendStyled(container, plain);
-                                        continue;
-                                    }
-                                    var match = CSI.exec(carry);
-                                    if (match === null) {
-                                        // Partial sequence: wait for the next
-                                        // chunk — unless it clearly ran away.
-                                        if (carry.length > 32) {
-                                            carry = carry.substring(1);
-                                        }
-                                        return;
-                                    }
-                                    if (match[2] === 'm') {
-                                        applySgr(match[1]);
-                                    } // every other CSI code: stripped
-                                    carry = carry.substring(match.index + match[0].length);
-                                }
-                            } catch (error) {
-                                // Graceful fallback: never lose the bytes.
-                                container.appendChild(document.createTextNode(text));
-                                carry = '';
-                                state = null;
-                            }
-                        }
-                    };
-                }
-
-                var activeSource = null;
-                var activeSession = null;
-                var activeRaw = false;
-
-                function connect(sessionId, rawMode) {
-                    connected = true;
-                    if (activeSource) {
-                        try { activeSource.close(); } catch (e) { }
-                        activeSource = null;
-                    }
-                    activeSession = sessionId;
-                    activeRaw = rawMode;
-                    // Both log containers live in the DOM (the raw flag
-                    // is per-session and the client owns the selection):
-                    // the active mode shows, the other hides.
-                    var rawLog = document.getElementById('term-raw');
-                    var typedLog = document.getElementById('term-log');
-                    var log = rawMode ? rawLog : typedLog;
-                    if (rawLog) rawLog.style.display = rawMode ? '' : 'none';
-                    if (typedLog) typedLog.style.display = rawMode ? 'none' : '';
-                    if (!log) return;
-                    var placeholder = log.querySelector('.term-placeholder');
-                    if (!placeholder) {
-                        log.textContent = '';
-                        var fresh = document.createElement('span');
-                        fresh.className = 'muted term-placeholder';
-                        fresh.textContent = 'Connecting…';
-                        log.appendChild(fresh);
-                    }
-                    var source = new EventSource('/terminal-stream/' + sessionId);
-                    activeSource = source;
-                    var ansi = rawMode ? makeAnsiRenderer() : null;
-                    var lastId = 0;
-                    var lastSeq = 0;
-                    source.onmessage = function (event) {
-                        if (event.data === 'ping') return;
-                        var entry = null;
-                        try { entry = JSON.parse(event.data); } catch (e) { return; }
-                        if (!entry) return;
-                        if (rawMode) {
-                            // Raw PTY log: one unstyled <pre> of concatenated
-                            // chunks, ANSI-styled client-side (SGR only).
-                            if (entry.type !== 'raw' || entry.seq <= lastSeq) return;
-                            lastSeq = entry.seq;
-                            var ph = log.querySelector('.term-placeholder');
-                            if (ph) ph.remove();
-                            var near = log.scrollHeight - log.scrollTop - log.clientHeight < 48;
-                            ansi.render(entry.text, log);
-                            if (near) log.scrollTop = log.scrollHeight;
-                            return;
-                        }
-                        if (entry.id <= lastId) return;
-                        lastId = entry.id;
-                        var placeholder2 = log.querySelector('.term-placeholder');
-                        if (placeholder2) placeholder2.remove();
-                        var nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 48;
-                        appendEntry(log, entry);
-                        if (nearBottom) log.scrollTop = log.scrollHeight;
-                    };
-                }
-
-                function appendEntry(log, entry) {
-                    // Only render bash entries — llm-comment entries duplicate
-                    // the # lines already present in the bash command_text
-                    var type = entry.type || 'command';
-
-                    // Recoverable rejections (bash -n / max_tokens) — a
-                    // distinct non-fatal style; the agent retries on its own
-                    if (type === 'rejected') {
-                        var ph = log.querySelector('.term-placeholder');
-                        if (ph) ph.remove();
-                        if (entry.command && entry.command.length > 0) {
-                            var script = document.createElement('span');
-                            script.className = 'term-stdin';
-                            script.textContent = '$ ' + entry.command + '\n';
-                            log.appendChild(script);
-                        }
-                        var reason = document.createElement('span');
-                        reason.className = 'term-rejected';
-                        reason.textContent = '[rejected] ' + (entry.output || 'recoverable rejection') + '\n';
-                        log.appendChild(reason);
-                        log.appendChild(document.createTextNode('\n'));
-                        return;
-                    }
-
-                    if (type !== 'bash' && type !== 'command') return;
-
-                    // stdin: the LLM's response sent to bash — render per-line
-                    if (entry.command && entry.command.length > 0) {
-                        var lines = entry.command.split('\n');
-                        for (var i = 0; i < lines.length; i++) {
-                            var line = lines[i];
-                            if (line.length === 0) continue;
-                            var span = document.createElement('span');
-                            if (line.charAt(0) === '#') {
-                                span.className = 'term-think';
-                                span.textContent = line + '\n';
-                            } else {
-                                span.className = 'term-stdin';
-                                span.textContent = '$ ' + line + '\n';
-                            }
-                            log.appendChild(span);
-                        }
-                    }
-
-                    // piped input (saddle query)
-                    if (entry.input && entry.input.length > 0) {
-                        var inp = document.createElement('span');
-                        inp.className = 'term-stdin';
-                        inp.textContent = entry.input + '\n';
-                        log.appendChild(inp);
-                    }
-
-                    // stdout/stderr
-                    if (entry.output && entry.output.length > 0) {
-                        var out = document.createElement('span');
-                        out.className = (entry.exit_code !== 0 && entry.exit_code !== -1)
-                            ? 'term-stderr' : 'term-stdout';
-                        out.textContent = entry.output + '\n';
-                        log.appendChild(out);
-                    }
-
-                    log.appendChild(document.createTextNode('\n'));
-                }
-            })();
-        }
-    </script>
-</head>
-<body>
-    <section>
-        <h2>Terminal — <span stm-text="terminal.task_title">task</span></h2>
-        <p stm-if="terminal != null && !terminal.exists" class="muted">Task not found.</p>
-
-        <div stm-if="terminal != null && terminal.exists">
-            <p><a stm-attribute.href="'/tasks/' + terminal.task_id" stm-text="'Back to task #' + terminal.task_id">task</a>
-                · session <strong stm-text="terminal.session_id">id</strong></p>
-
-            <!-- Plan 14 B4: the session picker is an options dropdown
-                 (newest first); above the terminal, a 200px
-                 vertical-scroll panel shows what the agent last
-                 received for the selected session — the opening brief
-                 for session 1, the stored resume notice for later
-                 ones (markdown, graceful empty state). -->
-            <p stm-if="terminal.sessions.length > 0">Session:
-                <select id="session-select" onchange="window.terminalPickSession(this.value)">
-                    <option stm-for-session-in="terminal.sessions" stm-attribute.value="session.id"
-                             stm-attribute.data-session-id="session.id"
-                             stm-text="session.label">session</option>
-                </select></p>
-
-            <div class="resume-notice-panel" stm-if="terminal.sessions.length > 0">
-                <div class="md" stm-if="window.terminalSessionNotice(terminal).length > 0"
-                     stm-markdown="window.terminalSessionNotice(terminal)">notice</div>
-                <p class="muted" stm-if="window.terminalSessionNotice(terminal).length == 0">
-                    No opening brief or resume notice recorded for this session.</p>
-            </div>
-
-            <!-- Both containers stay in the DOM: the per-session raw
-                 flag drives which one the client shows (see the
-                 picker script above). -->
-            <pre class="terminal-plain terminal-raw" stm-if="terminal.sessions.length > 0" id="term-raw"><span class="muted term-placeholder">Connecting…</span></pre>
-            <div class="terminal-plain" stm-if="terminal.sessions.length > 0" id="term-log" style="display:none">
-                <span class="muted term-placeholder">Connecting…</span>
-            </div>
-            <p stm-if="terminal.sessions.length == 0" class="muted">No sessions recorded for this task yet.</p>
-
-            <dialog id="quote-comment-dialog">
-                <h3>Comment on selected text</h3>
-                <blockquote id="quote-text" class="term-comment-quote"></blockquote>
-                <form stm-action="terminal.comment">
-                    <input type="hidden" name="quote">
-                    <input type="hidden" name="return_to">
-                    <textarea name="body" rows="4" placeholder="Your comment…" style="width:100%; margin:0.5em 0;"></textarea>
-                    <button type="submit" class="primary">Send comment</button>
-                    <button type="button" onclick="document.getElementById('quote-comment-dialog').close()">Cancel</button>
-                </form>
-            </dialog>
-        </div>
-    </section>
-</body>
-</html>

+ 0 - 2
src/pages/tasks.html

@@ -150,8 +150,6 @@
                     <td stm-text="task.id">id</td>
                     <td>
                         <a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
-                        <span class="badge badge-yeehaw" stm-if="task.has_yeehaw"
-                              title="The agent celebrated this task (saddle yeehaw)">🤠</span>
                     </td>
                     <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
                     <td><span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span></td>