<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: buhuipao</title>
    <description>The latest articles on DEV Community by buhuipao (@buhuipao).</description>
    <link>https://dev.to/buhuipao</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4052234%2Fdce55ecf-2188-46d8-b010-01ec59b6421a.jpg</url>
      <title>DEV Community: buhuipao</title>
      <link>https://dev.to/buhuipao</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/buhuipao"/>
    <language>en</language>
    <item>
      <title>Reaching your own LAN without port forwarding, public URLs, or a VPN concentrator</title>
      <dc:creator>buhuipao</dc:creator>
      <pubDate>Sat, 05 Sep 2026 05:23:47 +0000</pubDate>
      <link>https://dev.to/buhuipao/reaching-your-own-lan-without-port-forwarding-public-urls-or-a-vpn-concentrator-3oib</link>
      <guid>https://dev.to/buhuipao/reaching-your-own-lan-without-port-forwarding-public-urls-or-a-vpn-concentrator-3oib</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I maintain Lantunnel. It is Apache-2.0 and the source is linked at the end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every self-hoster hits the same wall. The NAS is at home. The GPU box is at the office. Ollama is on the desktop you walked away from. All of them sit behind NAT, and none of them belong on the public internet.&lt;/p&gt;

&lt;p&gt;The usual answers all ask you to give something up. Port forwarding puts a service on the open internet and hopes the auth holds. A public tunnel URL does the same thing with nicer branding. A classic VPN concentrator works, but now every byte between two machines in your own house takes a detour through a box you rent.&lt;/p&gt;

&lt;p&gt;Lantunnel is my attempt at a fourth option: a small private mesh where peers talk &lt;strong&gt;directly&lt;/strong&gt; whenever the network allows it, and where the fallback path still cannot read your traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the system
&lt;/h2&gt;

&lt;p&gt;There are three binaries, and that is the whole thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;lantunnel-client&lt;/code&gt;&lt;/strong&gt; runs on every device that joins. It imports one signed &lt;code&gt;.peer&lt;/code&gt; profile, attaches to the Gateway, and exposes a loopback SOCKS5 proxy on &lt;code&gt;127.0.0.1:1080&lt;/code&gt; plus optional native routes. Ordinary apps reach the tunnel without knowing it exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;lantunnel-gateway&lt;/code&gt;&lt;/strong&gt; is a rendezvous point and NAT-traversal signaler. It holds a tunnel's public &lt;code&gt;.scope&lt;/code&gt; file, helps two peers punch a direct path, and relays sealed bytes when they cannot. It never holds peer private keys and never sees plaintext.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;lantunnel-admin&lt;/code&gt;&lt;/strong&gt; creates the tunnel offline. &lt;code&gt;init-tunnel&lt;/code&gt; makes the owner file and the Gateway's public scope; &lt;code&gt;add-peer&lt;/code&gt; issues one signed profile per device. It talks to nothing — no account, no callback, no network at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direct first, relay only when the network refuses
&lt;/h2&gt;

&lt;p&gt;New flows try a direct peer-to-peer QUIC path with UDP hole punching. Relay is the fallback, not the default. When two machines are on networks that can be punched through, the Gateway drops out of the data path entirely and the traffic is a straight line between the two peers.&lt;/p&gt;

&lt;p&gt;When hole punching fails — symmetric NAT, hostile carrier, corporate egress — the flow falls back to the Gateway. This is where the design has to earn trust, because a relay is exactly the component that could betray you.&lt;/p&gt;

&lt;p&gt;Relayed payloads are sealed with XChaCha20-Poly1305 under keys derived from an X25519 exchange between the two peers. The Gateway forwards bytes it cannot decrypt. It is a dumb pipe by construction, not by policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity is signed, not shared
&lt;/h2&gt;

&lt;p&gt;There is no tunnel password, no group secret, no bearer token to leak into a wiki page.&lt;/p&gt;

&lt;p&gt;Every peer holds its own Ed25519 key, proves possession of it on every attachment, and that key never leaves the machine that generated it. One profile per device — a &lt;code&gt;.peer&lt;/code&gt; is not meant to be copied around. Revoking a laptop means revoking a laptop, not rotating a shared credential across every device you own.&lt;/p&gt;

&lt;p&gt;The access-control list lives on the target machine too. Each client decides what it will serve. Policy sits on the box holding the data, never on the Gateway and never on someone else's server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whole-LAN reach, not per-service holes
&lt;/h2&gt;

&lt;p&gt;A peer can export the private subnets it sits on. One client on the home network makes the NAS, the printer, and the dashboard reachable to the rest of the tunnel — without an inbound firewall rule, a public IP, or a hostname anywhere.&lt;/p&gt;

&lt;p&gt;In practice people use it for game and media streaming (Sunshine/Moonlight, Jellyfin, Plex), private AI and dev tooling (Ollama, Open WebUI, an internal API, a staging box, a database that must never leave the LAN), and ordinary home-office plumbing (NAS, Home Assistant, cameras, SSH).&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosting the Gateway
&lt;/h2&gt;

&lt;p&gt;If you would rather not depend on anyone, the Gateway is in the repository, it is Apache-2.0, and it is not metered.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# On the public Gateway host
lantunnel-gateway init --public-ip &amp;lt;PUBLIC_IP&amp;gt;

# On the trusted owner machine — the Gateway private key never leaves the Gateway host
lantunnel-admin init-tunnel \
  --gateway-transport quic \
  --gateway-ip &amp;lt;PUBLIC_IP&amp;gt; \
  --gateway-port 8443 \
  --gateway-mapping-port 8444 \
  --gateway-cert ./server.crt

lantunnel-admin add-peer --tunnel &amp;lt;tunnel-id&amp;gt;.tunnel --name laptop --output laptop.peer

# Back on the Gateway host: only the public scope moves
mkdir -p state/scopes.d &amp;amp;&amp;amp; cp &amp;lt;tunnel-id&amp;gt;.scope state/scopes.d/
lantunnel-gateway --config configs/gateway.yaml --check-config
lantunnel-gateway --config configs/gateway.yaml

# On each device
lantunnel-client tunnel import ./laptop.peer
lantunnel-client            # desktop UI
lantunnel-client connect '&amp;lt;tunnel_id&amp;gt;'   # same runtime, headless
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Note what crosses machine boundaries: the Gateway's public certificate goes out to the owner machine, and the public &lt;code&gt;.scope&lt;/code&gt; comes back. The tunnel signing key and the Gateway private key each stay where they were generated.&lt;/p&gt;

&lt;p&gt;Re-running &lt;code&gt;init&lt;/code&gt; preserves the existing config, certificate, and key. At the same config path, a changed IP, transport, data port, or mapping port is refused rather than silently replacing the Gateway identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  One binary, UI or headless
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;lantunnel-client&lt;/code&gt; opens a desktop window by default and runs the exact same runtime under &lt;code&gt;--headless&lt;/code&gt; on a server. There is no separate daemon build and no second code path to keep honest. It runs on macOS, Windows, Linux, Android, and iOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you do not want to run a Gateway
&lt;/h2&gt;

&lt;p&gt;There is a hosted option at &lt;a href="https://lantunnel.app/" rel="noopener noreferrer"&gt;lantunnel.app&lt;/a&gt; — one permanent free tunnel per account, unlimited direct peer-to-peer traffic, unlimited LAN devices behind each client, and 5 GB/month of encrypted relay fallback for when direct fails. The self-hosted path in the repository stays fully supported and unmetered either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the details live
&lt;/h2&gt;

&lt;p&gt;The repository has the architecture write-up in &lt;code&gt;CONTEXT.md&lt;/code&gt;, the wire protocol in &lt;code&gt;docs/PROTOCOL.md&lt;/code&gt;, and a full usage guide in &lt;code&gt;docs/USAGE.md&lt;/code&gt; covering LAN exports, access rules, servers, mobile, and troubleshooting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/lantunnel/lantunnel" rel="noopener noreferrer"&gt;github.com/lantunnel/lantunnel&lt;/a&gt; (Apache-2.0)&lt;/p&gt;

&lt;p&gt;I would genuinely like to hear where the NAT traversal falls over for you — the hole-punching path is the part that benefits most from networks I do not have access to.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>networking</category>
      <category>selfhosted</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why I built a control plane for Codex and Claude Code instead of another AI chat UI</title>
      <dc:creator>buhuipao</dc:creator>
      <pubDate>Wed, 29 Jul 2026 02:58:49 +0000</pubDate>
      <link>https://dev.to/buhuipao/why-i-built-a-control-plane-for-codex-and-claude-code-instead-of-another-ai-chat-ui-27hl</link>
      <guid>https://dev.to/buhuipao/why-i-built-a-control-plane-for-codex-and-claude-code-instead-of-another-ai-chat-ui-27hl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; this write-up was drafted with an LLM from my own notes, code,&lt;br&gt;
and design decisions, then reviewed and published by me. The project, the&lt;br&gt;
engineering calls described below, and the code excerpts are my own work on&lt;br&gt;
agent-console.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When you have one coding agent, a terminal tab is a perfectly good interface.&lt;/p&gt;

&lt;p&gt;When you have five or ten of them across several projects, the problem changes. You are no longer having one conversation. You are managing work in different states: one session is still running, one needs approval, one has failed, and another finished ten minutes ago in a tab hidden behind the rest.&lt;/p&gt;

&lt;p&gt;I wanted to solve one specific problem: get a cross-session view without changing how Codex and Claude Code are used.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fbuhuipao%2Fagent-console%2Fmain%2Fdocs%2Fassets%2Fdemo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fbuhuipao%2Fagent-console%2Fmain%2Fdocs%2Fassets%2Fdemo.gif" alt="Agent Console" width="760" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary: do not rebuild the provider UI
&lt;/h2&gt;

&lt;p&gt;Agent Console does not implement a unified chat frontend. It scans persisted provider sessions, groups them by workspace, reduces them to &lt;code&gt;working&lt;/code&gt;, &lt;code&gt;waiting&lt;/code&gt;, &lt;code&gt;idle&lt;/code&gt;, and &lt;code&gt;failed&lt;/code&gt;, and invokes the provider's own resume flow. Once inside a session you are still using the native Codex or Claude Code terminal UI.&lt;/p&gt;

&lt;p&gt;This boundary matters. A unified frontend looks tidy, but it lags behind provider features and creates a second compatibility layer for resume behaviour, keybindings, approval flows, and rendering. The layer above the native UI — discovery, state, alerts, search, navigation — is small enough to keep correct.&lt;/p&gt;

&lt;p&gt;The Rust side is deliberately boring: &lt;code&gt;ratatui&lt;/code&gt; for rendering, &lt;code&gt;portable-pty&lt;/code&gt; for child processes, &lt;code&gt;vt100&lt;/code&gt; for terminal emulation in retained panes, &lt;code&gt;rusqlite&lt;/code&gt; for state, and no async runtime at all. The whole thing is one synchronous loop plus threads that own PTYs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned building it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Discovery is parsing someone else's private format
&lt;/h3&gt;

&lt;p&gt;Neither provider offers an API for "list my sessions". Both persist transcripts, so discovery means reading &lt;code&gt;~/.codex/sessions/**/rollout-*.jsonl&lt;/code&gt; and &lt;code&gt;~/.claude/projects/**/&amp;lt;uuid&amp;gt;.jsonl&lt;/code&gt; and inferring structure from records that were never meant to be a public interface.&lt;/p&gt;

&lt;p&gt;That is fine as an observation channel and terrible as a contract. Every provider release can move a field. The mitigation was to stop branching on the provider anywhere except one table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;ProviderAdapter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AgentKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// True when this file is one of the provider's session transcripts.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// Parse one accepted transcript. `Ok(None)` means "not a usable session".&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// Optional enrichment applied to the provider's parsed sessions.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;enrich&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Discovery then walks the table instead of matching on an enum in five places:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;adapter&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nn"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="nf"&gt;.root&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adapter&lt;/span&gt;&lt;span class="py"&gt;.kind&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;provider_files&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adapter&lt;/span&gt;&lt;span class="py"&gt;.accepts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_cached_files&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enrich&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adapter&lt;/span&gt;&lt;span class="py"&gt;.enrich&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;enrich&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="nf"&gt;.extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain &lt;code&gt;fn&lt;/code&gt; pointers rather than &lt;code&gt;Box&amp;lt;dyn Fn&amp;gt;&lt;/code&gt; keep the table a &lt;code&gt;const&lt;/code&gt;, which means adding a provider is one entry and no allocation. &lt;code&gt;AGENT_CONSOLE_PROVIDERS=codex&lt;/code&gt; narrows the same table at runtime — useful now that both vendors are shipping their own multi-session views and one side may become redundant.&lt;/p&gt;

&lt;p&gt;The failure mode I cared about most: a typo in that variable must not silently produce an empty dashboard. An unrecognised value logs the reason and keeps every provider enabled.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Owning a PTY means deciding who dies with the TUI
&lt;/h3&gt;

&lt;p&gt;The obvious design is to spawn agents from the TUI process. Then closing the dashboard kills every agent, which is exactly the behaviour that makes people keep twelve terminal tabs open instead.&lt;/p&gt;

&lt;p&gt;So managed PTYs live in a separate long-lived daemon. Closing or crashing the TUI detaches; a new TUI reconnects and replays a bounded tail. No tmux dependency, and no forced git worktrees — sessions run in place.&lt;/p&gt;

&lt;p&gt;Running in place means two dashboards could resume the same provider session and corrupt one transcript, so each provider session ID is guarded by a cross-process lease with owner information, safe refusal, and an explicit force-takeover. This is the part where Rust helped least and process hygiene helped most: the type system says nothing about a second process on the same machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Let the model summarise, not decide
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;working&lt;/code&gt;, &lt;code&gt;waiting&lt;/code&gt;, and &lt;code&gt;failed&lt;/code&gt; come from provider hook events and process state, reduced deterministically with a fixed precedence. A model is used only to compress a long conversation into a glanceable task summary, it runs through the session's own provider outside the coding conversation, and it can be turned off entirely with &lt;code&gt;AGENT_CONSOLE_SUMMARIZER=off&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A related detail that only shows up in practice: an alert is a &lt;em&gt;state transition observed while the runtime is live&lt;/em&gt;, not a state. A session that is already waiting when the dashboard starts is not news. Getting that distinction wrong makes the alert list useless within a day.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Terminal emulation is where the time goes
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pty.rs&lt;/code&gt; is 6,600 lines — by far the largest module, and larger than discovery, state, and the whole UI combined. Retained scrollback per pane, mouse reporting for providers that request it, alternate-screen handling, selection and clipboard, resize, and reconnect replay each look small and are not.&lt;/p&gt;

&lt;p&gt;Two concrete lessons. Managed Codex sessions run with &lt;code&gt;--no-alt-screen&lt;/code&gt; so the workspace pane can retain and scroll the transcript in every state. And only three Ctrl chords are actually free once Codex and Claude Code have taken theirs — &lt;code&gt;Ctrl-\&lt;/code&gt;, &lt;code&gt;Ctrl-^&lt;/code&gt;, &lt;code&gt;Ctrl-Q&lt;/code&gt; — so everything else has to be forwarded untouched to the child.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current state
&lt;/h2&gt;

&lt;p&gt;v0.0.12, supporting Codex and Claude Code, with packages for macOS, Linux, and Windows and signed/notarised macOS binaries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;agent-console
agent-console doctor   &lt;span class="c"&gt;# checks providers, hooks, clipboard, daemon, permissions&lt;/span&gt;
agent-console
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Licensed MIT OR Apache-2.0. Source: &lt;a href="https://github.com/buhuipao/agent-console" rel="noopener noreferrer"&gt;https://github.com/buhuipao/agent-console&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is still early. The questions I care about are more fundamental than adding ten provider logos: is the state trustworthy, do alerts actually reduce tab switching, do shells stay attached to the right workspace, and is native resume smooth enough?&lt;/p&gt;

&lt;p&gt;If you run several coding agents every day — what should a cross-session control layer show you, and what should it never take over?&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
