feat(bridge): establish local pi status bridge

Deliver the initial local bridge, Noctalia v4/v5 adapters, desktop client, service unit, tests, and implementation documentation for persistent Pi status and control.
This commit is contained in:
2026-07-27 14:51:45 +02:00
commit b7fea83ed6
91 changed files with 15029 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+4935
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "pi-status-ui"
version = "0.1.0"
description = "Standalone desktop client for Pi Status Bridge"
authors = ["alex"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "pi_status_ui_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-hide",
"core:window:allow-start-dragging"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+350
View File
@@ -0,0 +1,350 @@
use serde::Serialize;
use serde_json::{json, Value};
use std::env;
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct BridgeRequest<'a> {
version: &'static str,
id: &'a str,
op: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
agent_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
payload: Option<Value>,
}
pub fn default_socket_path() -> Result<String, String> {
env::var("PI_STATUS_BRIDGE_SOCKET")
.or_else(|_| {
env::var("XDG_RUNTIME_DIR")
.map(|runtime| format!("{runtime}/pi-status-bridge/bridge.sock"))
})
.map_err(|_| "Pi Status Bridge socket is unavailable".to_owned())
}
async fn connect_and_send(
socket_path: &str,
operation: &str,
agent_id: Option<&str>,
payload: Option<Value>,
) -> Result<BufReader<UnixStream>, String> {
let mut stream = UnixStream::connect(socket_path)
.await
.map_err(|error| format!("Could not connect to Pi Status Bridge: {error}"))?;
let request = BridgeRequest {
version: "v1",
id: "tauri-ui",
op: operation,
agent_id,
payload,
};
let encoded = serde_json::to_string(&request).map_err(|error| error.to_string())?;
stream
.write_all(format!("{encoded}\n").as_bytes())
.await
.map_err(|error| format!("Could not send bridge request: {error}"))?;
Ok(BufReader::new(stream))
}
fn result_from_response(value: Value) -> Result<Value, String> {
if value.get("ok") != Some(&Value::Bool(true)) {
return Err(value
.get("error")
.and_then(|error| error.get("message"))
.and_then(Value::as_str)
.unwrap_or("Pi Status Bridge rejected the request")
.to_owned());
}
value
.get("result")
.cloned()
.ok_or_else(|| "Pi Status Bridge returned no result".to_owned())
}
pub async fn request(
socket_path: &str,
operation: &str,
agent_id: Option<&str>,
payload: Option<Value>,
) -> Result<Value, String> {
let mut reader = connect_and_send(socket_path, operation, agent_id, payload).await?;
let mut response = String::new();
reader
.read_line(&mut response)
.await
.map_err(|error| format!("Could not read bridge response: {error}"))?;
let value: Value = serde_json::from_str(&response)
.map_err(|error| format!("Bridge returned invalid JSON: {error}"))?;
result_from_response(value)
}
pub async fn list_agents(socket_path: &str) -> Result<Value, String> {
request(socket_path, "list_agents", None, None).await
}
pub async fn list_directories(socket_path: &str) -> Result<Value, String> {
request(socket_path, "list_directories", None, None).await
}
pub async fn select_worktree(socket_path: &str, worktree_path: &str) -> Result<Value, String> {
request(
socket_path,
"select_agent",
None,
Some(json!({ "worktreePath": worktree_path })),
)
.await
}
pub async fn list_sessions(socket_path: &str, agent_id: &str) -> Result<Value, String> {
request(socket_path, "list_sessions", Some(agent_id), None).await
}
pub async fn switch_session(
socket_path: &str,
agent_id: &str,
session_path: &str,
) -> Result<Value, String> {
request(
socket_path,
"switch_session",
Some(agent_id),
Some(json!({ "sessionPath": session_path })),
)
.await
}
pub async fn new_session(socket_path: &str, agent_id: &str) -> Result<Value, String> {
request(socket_path, "new_session", Some(agent_id), None).await
}
pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result<Value, String> {
// Older bridge daemons can still serve the conversation while they await a restart.
let stats = request(socket_path, "get_session_stats", Some(agent_id), None)
.await
.unwrap_or_else(|_| json!({ "data": {} }));
let (state, transcript, commands, models) = tokio::try_join!(
request(socket_path, "get_state", Some(agent_id), None),
request(socket_path, "get_transcript", Some(agent_id), None),
request(socket_path, "get_commands", Some(agent_id), None),
request(socket_path, "get_available_models", Some(agent_id), None),
)?;
Ok(json!({
"state": state,
"stats": stats,
"transcript": transcript,
"commands": commands,
"models": models
}))
}
pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) -> Result<(), String> {
request(
socket_path,
"submit_prompt",
Some(agent_id),
Some(json!({ "message": message })),
)
.await
.map(|_| ())
}
pub async fn abort(socket_path: &str, agent_id: &str) -> Result<(), String> {
request(socket_path, "abort", Some(agent_id), None).await.map(|_| ())
}
pub async fn set_model(
socket_path: &str,
agent_id: &str,
provider: &str,
model_id: &str,
) -> Result<(), String> {
request(
socket_path,
"set_model",
Some(agent_id),
Some(json!({ "provider": provider, "modelId": model_id })),
)
.await
.map(|_| ())
}
pub async fn set_thinking_level(
socket_path: &str,
agent_id: &str,
level: &str,
) -> Result<(), String> {
request(
socket_path,
"set_thinking_level",
Some(agent_id),
Some(json!({ "level": level })),
)
.await
.map(|_| ())
}
pub async fn command(socket_path: &str, agent_id: &str, operation: &str) -> Result<(), String> {
request(socket_path, operation, Some(agent_id), None)
.await
.map(|_| ())
}
pub async fn respond_to_extension(
socket_path: &str,
agent_id: &str,
request_id: &str,
response: Value,
) -> Result<(), String> {
request(
socket_path,
"extension_response",
Some(agent_id),
Some(json!({ "requestId": request_id, "response": response })),
)
.await
.map(|_| ())
}
pub async fn subscribe(
socket_path: String,
agent_id: String,
cursor: u64,
app: AppHandle,
) -> Result<(), String> {
let mut reader = connect_and_send(
&socket_path,
"subscribe",
Some(&agent_id),
Some(json!({ "cursor": cursor })),
)
.await?;
loop {
let mut line = String::new();
let bytes = reader
.read_line(&mut line)
.await
.map_err(|error| format!("Bridge subscription failed: {error}"))?;
if bytes == 0 {
return Err("Bridge subscription closed".to_owned());
}
let value: Value = serde_json::from_str(&line)
.map_err(|error| format!("Bridge emitted invalid JSON: {error}"))?;
if value.get("id") == Some(&Value::String("tauri-ui".to_owned())) {
let result = result_from_response(value)?;
for event in result
.get("events")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
app.emit("bridge-event", event)
.map_err(|error| format!("Could not publish bridge event: {error}"))?;
}
} else if value.get("type") == Some(&Value::String("event".to_owned())) {
if let Some(event) = value.get("event") {
app.emit("bridge-event", event)
.map_err(|error| format!("Could not publish bridge event: {error}"))?;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::UnixListener;
#[tokio::test]
async fn sends_one_jsonl_request_and_unwraps_its_result() {
let path = format!(
"{}/pi-status-ui-{}.sock",
env::temp_dir().display(),
std::process::id()
);
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).expect("listener");
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("connection");
let mut line = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut line).await.expect("request");
let request: Value = serde_json::from_str(&line).expect("JSON request");
assert_eq!(request["op"], "submit_prompt");
assert_eq!(request["agentId"], "agent-1");
assert_eq!(request["payload"]["message"], "Hello Pi");
let mut response = serde_json::to_vec(&json!({
"id": "tauri-ui",
"ok": true,
"result": { "accepted": true }
}))
.expect("response JSON");
response.push(b'\n');
reader
.get_mut()
.write_all(&response)
.await
.expect("response");
});
let result = request(
&path,
"submit_prompt",
Some("agent-1"),
Some(json!({ "message": "Hello Pi" })),
)
.await
.expect("request succeeds");
assert_eq!(result["accepted"], true);
server.await.expect("server succeeds");
std::fs::remove_file(path).expect("socket cleanup");
}
#[tokio::test]
async fn loads_state_history_commands_and_models_as_one_snapshot() {
let path = format!(
"{}/pi-status-ui-load-{}.sock",
env::temp_dir().display(),
std::process::id()
);
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).expect("listener");
let server = tokio::spawn(async move {
for _ in 0..5 {
let (stream, _) = listener.accept().await.expect("connection");
tokio::spawn(async move {
let mut line = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut line).await.expect("request");
let request: Value = serde_json::from_str(&line).expect("JSON request");
assert_eq!(request["agentId"], "agent-1");
let result = match request["op"].as_str() {
Some("get_state") => json!({ "data": { "isStreaming": false } }),
Some("get_session_stats") => json!({
"data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } }
}),
Some("get_transcript") => json!({ "data": { "messages": [] } }),
Some("get_commands") => json!({ "data": { "commands": [] } }),
Some("get_available_models") => json!({ "data": { "models": [] } }),
other => panic!("unexpected operation: {other:?}"),
};
let mut response = serde_json::to_vec(&json!({
"id": "tauri-ui", "ok": true, "result": result
}))
.expect("response JSON");
response.push(b'\n');
reader.get_mut().write_all(&response).await.expect("response");
});
}
});
let snapshot = load_agent(&path, "agent-1").await.expect("snapshot");
assert_eq!(snapshot["state"]["data"]["isStreaming"], false);
assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000);
assert_eq!(snapshot["transcript"]["data"]["messages"], json!([]));
server.await.expect("server succeeds");
std::fs::remove_file(path).expect("socket cleanup");
}
}
+151
View File
@@ -0,0 +1,151 @@
mod bridge;
use serde_json::Value;
use std::sync::Mutex;
use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
struct Subscription(Mutex<Option<JoinHandle<()>>>);
fn socket_path() -> Result<String, String> {
bridge::default_socket_path()
}
#[tauri::command]
async fn list_agents() -> Result<Value, String> {
bridge::list_agents(&socket_path()?).await
}
#[tauri::command]
async fn list_directories() -> Result<Value, String> {
bridge::list_directories(&socket_path()?).await
}
#[tauri::command]
async fn select_worktree(worktree_path: String) -> Result<Value, String> {
bridge::select_worktree(&socket_path()?, &worktree_path).await
}
#[tauri::command]
async fn load_agent(agent_id: String) -> Result<Value, String> {
bridge::load_agent(&socket_path()?, &agent_id).await
}
#[tauri::command]
async fn list_sessions(agent_id: String) -> Result<Value, String> {
bridge::list_sessions(&socket_path()?, &agent_id).await
}
#[tauri::command]
async fn switch_session(agent_id: String, session_path: String) -> Result<Value, String> {
bridge::switch_session(&socket_path()?, &agent_id, &session_path).await
}
#[tauri::command]
async fn new_session(agent_id: String) -> Result<Value, String> {
bridge::new_session(&socket_path()?, &agent_id).await
}
#[tauri::command]
async fn submit_prompt(agent_id: String, message: String) -> Result<(), String> {
bridge::submit_prompt(&socket_path()?, &agent_id, &message).await
}
#[tauri::command]
async fn abort(agent_id: String) -> Result<(), String> {
bridge::abort(&socket_path()?, &agent_id).await
}
#[tauri::command]
async fn retry(agent_id: String) -> Result<(), String> {
bridge::command(&socket_path()?, &agent_id, "retry").await
}
#[tauri::command]
async fn restart(agent_id: String) -> Result<(), String> {
bridge::command(&socket_path()?, &agent_id, "restart").await
}
#[tauri::command]
async fn set_model(agent_id: String, provider: String, model_id: String) -> Result<(), String> {
bridge::set_model(&socket_path()?, &agent_id, &provider, &model_id).await
}
#[tauri::command]
async fn set_thinking_level(agent_id: String, level: String) -> Result<(), String> {
bridge::set_thinking_level(&socket_path()?, &agent_id, &level).await
}
#[tauri::command]
async fn respond_to_extension(
agent_id: String,
request_id: String,
response: Value,
) -> Result<(), String> {
bridge::respond_to_extension(&socket_path()?, &agent_id, &request_id, response).await
}
#[tauri::command]
fn subscribe_agent(
app: AppHandle,
subscriptions: State<'_, Subscription>,
agent_id: String,
cursor: u64,
) -> Result<(), String> {
let socket = socket_path()?;
if let Some(subscription) = subscriptions
.0
.lock()
.map_err(|_| "Could not update bridge subscription".to_owned())?
.take()
{
subscription.abort();
}
let event_app = app.clone();
let task = tauri::async_runtime::spawn(async move {
if let Err(error) = bridge::subscribe(socket, agent_id.clone(), cursor, event_app.clone()).await {
let _ = event_app.emit(
"bridge-error",
serde_json::json!({ "agentId": agent_id, "message": error }),
);
}
});
let mut current_subscription = subscriptions
.0
.lock()
.map_err(|_| "Could not retain bridge subscription".to_owned())?;
*current_subscription = Some(task);
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.manage(Subscription(Mutex::new(None)))
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}))
.invoke_handler(tauri::generate_handler![
list_agents,
list_directories,
select_worktree,
load_agent,
list_sessions,
switch_session,
new_session,
submit_prompt,
abort,
retry,
restart,
set_model,
set_thinking_level,
respond_to_extension,
subscribe_agent
]);
let context = tauri::generate_context!();
if let Err(error) = builder.run(context) {
eprintln!("Pi Status UI exited: {error}");
}
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
pi_status_ui_lib::run()
}
+43
View File
@@ -0,0 +1,43 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Pi Status UI",
"version": "0.1.0",
"identifier": "com.alex.pi-status-ui",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Pi Status UI",
"width": 760,
"height": 620,
"minWidth": 480,
"minHeight": 420,
"center": true,
"decorations": false,
"transparent": true,
"alwaysOnTop": true,
"skipTaskbar": true,
"shadow": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}