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
+18
View File
@@ -0,0 +1,18 @@
# Agent artifacts
/.pi-subagents/
# Dependencies
/node_modules/
# Runtime files
*.log
*.pid
# Local environment configuration
.env
.env.*
!.env.example
# OS and IDE metadata
.DS_Store
.idea/
+101
View File
@@ -0,0 +1,101 @@
# Pi Status Bridge — Design
## Reason for existence
Provide a persistent, local Pi coding-agent experience through a compact status-bar UI without duplicating Pis terminal TUI or weakening tool-owned safety policy.
## Goals
- Keep one Pi RPC agent per project/worktree, plus a home-directory agent started with the bridge.
- Open a chat-like popover by shortcut; accept prompts, steering messages, follow-ups, and structured extension questions.
- Present compact status in Noctalia: state glyph/color, active project, and attention badge.
- Make the bridge reusable by future desktop/status-bar adapters.
- Resume sessions after bounded automatic recovery.
## Non-goals
- Terminal emulation or embedding Pis terminal TUI.
- Rendering rich TUI-only extension overlays.
- Inventing approval policy or interpreting tool permissions.
- A network service, remote control, or adapters beyond Noctalia in v1.
## Architecture
```text
Noctalia bar + popover ─┐
├─ local client helper ─ Unix socket ─ Pi Status Bridge
future host adapter ────┘ │
├─ Agent registry
├─ session/recovery supervisor
└─ pi --mode rpc (one child/worktree)
```
### Bridge
The bridge is the only component that starts, stops, and restarts Pi. It owns:
- Agent registry keyed by canonical project/worktree path.
- A long-lived `pi --mode rpc` child per active worktree.
- Translation of Pi JSONL events into a stable local event model.
- Session resume after child restart, bounded exponential backoff, and terminal error state after exhaustion.
- A per-user Unix-domain socket under `$XDG_RUNTIME_DIR` with restrictive permissions.
- A lock/PID file with stale-owner recovery.
It exposes only local request/response and subscription operations: enumerate/select agents, send `prompt`/`steer`/`follow_up`, abort, read state/transcript, proxy model/thinking commands, and answer extension UI requests.
### Noctalia adapter
The adapter is presentation only:
- Bar: glyph/color, selected project name, attention badge.
- Shortcut: choose the focused worktree when unambiguous; otherwise open a selector defaulted to home.
- Popover: transcript, composer, tool activity, queue state, extension UI requests, agent/session manager, and Pi-proxied model/thinking controls.
- Escape hides the popover only; it never aborts Pi.
The popover always permits explicit project/worktree switching.
## Agent lifecycle
1. Bridge starts and acquires the lock.
2. It creates/resumes the home agent.
3. A focused worktree or explicit selection creates/resumes that worktrees agent.
4. On unexpected child exit, the bridge retries with bounded exponential backoff and resumes the saved Pi session.
5. After retry exhaustion, it publishes an error state and exposes explicit restart/retry controls.
## Interaction contract
| State | Bar | Popover action |
| --- | --- | --- |
| Idle | neutral glyph | send prompt, browse transcript |
| Streaming/tool active | working glyph | stream response/tool progress, steer or abort |
| Queued input | attention badge | inspect queue or send follow-up |
| Extension question/confirmation | attention badge | render request and return response unchanged |
| Failed recovery | error glyph | inspect failure and retry/restart |
## Policy and configuration boundaries
- Extensions/tools decide whether approval is required.
- The bridge forwards only RPC-compatible structured extension requests and responses.
- Pi chooses model and thinking defaults and remains the source of truth.
- The popover may display current Pi values and forward supported changes; it stores no competing defaults or persistence policy.
## Security invariants
- **Never** listen on TCP for v1.
- **Never** allow another user to attach to the socket.
- **Never** map a bridge request to another worktree silently.
- **Never** treat a bridge/UI action as permission to bypass extension-owned approvals.
## Key risks
- Focused-window worktree inference can fail; explicit selection is mandatory.
- TUI-only extension overlays cannot cross the RPC boundary.
- The local client helper must retain same-user-only access.
- Named-session controls must clearly show the selected worktree before acting.
## Verification
```sh
# After implementation: required architecture artifacts exist.
test -f DESIGN.md && test -f IMPLEMENTATION_PLAN.md && test -f TEST_PLAN.md
```
+100
View File
@@ -0,0 +1,100 @@
# Pi Status Bridge — Implementation Plan
## Reason for existence
Turn the approved design into small, verifiable slices while preserving Pi as the source of truth for tool policy and model configuration.
## Milestone 1 — Bridge foundation
1. Create the bridge package and an explicit local protocol schema.
- Define agent ID, worktree path, state, event, extension-request, and command envelopes.
- Define socket path, file modes, lock/PID format, and stale-owner algorithm.
- Verify: schema tests reject malformed messages and cross-worktree requests.
2. Implement single-instance startup and Unix-socket serving.
- Acquire lock atomically; detect a live owner; recover only a proven-stale lock.
- Create the socket in `$XDG_RUNTIME_DIR` with owner-only permissions.
- Verify: a second bridge refuses startup; a different user cannot connect.
3. Implement the Pi RPC child adapter.
- Spawn `pi --mode rpc` with an explicit worktree cwd and session location.
- Correlate command IDs, parse JSONL, normalize state/event payloads, and preserve transcript cursors.
- Verify: a fake Pi RPC fixture exercises prompt, streaming, tool events, queue updates, and extension UI requests.
## Milestone 2 — Agent registry and recovery
1. Implement worktree-keyed agent management.
- Canonicalize paths before registry lookup.
- Create/resume the home agent on bridge startup.
- Create/resume worktree agents only when selected or inferred.
- Verify: separate worktrees never share agent IDs, transcript entries, or outgoing prompts.
2. Implement session persistence and recovery.
- Store the Pi session reference per agent.
- Restart unexpected exits with bounded exponential backoff.
- Resume the prior Pi session; publish failed-recovery state after limits are exhausted.
- Verify: injected crashes resume the expected session and stop retrying at the configured bound.
3. Proxy Pi-owned controls without bridge defaults.
- Read current model/thinking state from Pi.
- Forward supported user changes directly to Pi and refresh displayed state from Pis response/events.
- Verify: bridge restarts do not reapply an independent model or thinking value.
## Milestone 3 — Adapter-facing API and local helper
1. Implement adapter operations.
- Agent/session listing and explicit selection.
- State/transcript subscription and replay after reconnect.
- Prompt, steer, follow-up, abort, and extension UI response forwarding.
- Verify: a scripted local client can reconnect and catch up without duplicate events.
2. Build the local client helper.
- Make it the fallback transport for hosts that cannot open Unix sockets.
- Support only the approved bridge protocol; never start a network listener.
- Verify: helper cannot reach a socket owned by another user.
## Milestone 4 — Noctalia adapter
1. Implement the bar widget.
- Render state glyph/color, selected project, and attention badge.
- Keep streaming/tool detail out of the bar.
- Verify: fixture states map to the agreed compact bar states.
2. Implement shortcut and popover behavior.
- Infer focused worktree when unambiguous.
- Otherwise open a selector defaulted to home.
- Support explicit switching, transcript streaming, composer, queue/tool state, extension forms, session manager, and Pi-proxied controls.
- Escape dismisses the popover only.
- Verify: manual checklist passes on Noctalia.
## Milestone 5 — Hardening and release readiness
1. Add observability and safe failure presentation.
- Structured local logs for agent lifecycle, recovery, socket ownership, and protocol errors.
- Redact prompt and tool payload content by default.
- Verify: failure reports identify the agent/worktree without leaking transcript content.
2. Run the approved readiness gate.
- Execute automated bridge tests, manual Noctalia QA, cross-user socket rejection check, crash/recovery scenarios, and overnight soak.
- Verify: all exit criteria in `TEST_PLAN.md` pass before daily use.
## Dependencies
```text
Foundation → Pi RPC adapter → registry/recovery → adapter API/helper → Noctalia UI → hardening/soak
```
## Never rules
- Never let the bridge decide tool approval policy.
- Never route a prompt to a worktree different from the selected agent.
- Never expose a TCP listener in v1.
- Never persist model or thinking defaults outside Pi.
## Verification
```sh
# Plan completeness: all five planned milestones are present.
grep -c '^## Milestone' IMPLEMENTATION_PLAN.md
# Expected: 5
```
+93
View File
@@ -0,0 +1,93 @@
# Pi Status Bridge — Test Plan / QA Checklist
## Reason for existence
Prove that the always-running Pi bridge is correctly scoped, recoverable, and usable through Noctalia before it becomes part of daily work.
## Automated bridge tests
### Protocol and event normalization
- [ ] Reject malformed, oversized, and unknown local protocol messages.
- [ ] Correlate outgoing RPC commands with replies and errors.
- [ ] Normalize agent, turn, message, tool, queue, retry, and extension UI events.
- [ ] Replay only entries after a reconnect cursor; never duplicate transcript events.
- [ ] Preserve extension request IDs and forward the exact selected/confirmed/input response.
### Agent and worktree isolation
- [ ] Canonical paths map to a single expected agent ID.
- [ ] Different worktrees receive distinct Pi cwd/session references.
- [ ] A prompt, abort, model change, or extension response never targets another selected worktree.
- [ ] Bridge startup creates/resumes only the home agent.
- [ ] Explicit project selection creates/resumes the requested worktree agent.
### Pi configuration proxy
- [ ] Read current model and thinking state from Pi.
- [ ] Forward a user change to Pi using its supported RPC command.
- [ ] Refresh displayed state from Pi, not bridge-held defaults.
- [ ] Restarting the bridge does not overwrite Pis model or thinking behavior.
### Socket and ownership
- [ ] Socket is under `$XDG_RUNTIME_DIR` with owner-only permissions.
- [ ] A second live bridge instance refuses to acquire ownership.
- [ ] A stale lock is recovered only after liveness verification.
- [ ] Different-user access is rejected.
- [ ] The local helper cannot create or expose a TCP listener.
### Recovery
- [ ] Unexpected Pi child exit causes bounded exponential-backoff restart.
- [ ] Recovery resumes the last expected Pi session.
- [ ] Retry exhaustion publishes a failed-recovery state and stops looping.
- [ ] Explicit retry/restart returns the agent to a healthy or clearly failed state.
## Manual Noctalia checklist
### Startup and routing
- [ ] Starting the bridge creates the home agent and shows it in the bar/popover.
- [ ] Shortcut on a known focused worktree opens that worktrees agent.
- [ ] Missing or ambiguous focus opens the selector with home preselected.
- [ ] Popover project/session manager switches agents explicitly and visibly.
- [ ] Escape closes the popover without aborting streaming work.
### Status and conversation
- [ ] Bar shows state glyph/color, active project, and attention badge.
- [ ] Popover streams assistant text and tool activity without blocking the composer.
- [ ] Prompt, steer, follow-up, and abort map to the intended Pi action.
- [ ] Queue changes are visible and do not reorder or lose user messages.
- [ ] Pi model/thinking values display correctly and forwarded changes round-trip from Pi.
### Extension prompts
- [ ] Select, confirm, input, and editor requests render as native popover UI.
- [ ] The response reaches the originating extension unchanged.
- [ ] A pending request produces an attention badge while the popover is closed.
- [ ] TUI-only unsupported custom UI is presented as an explicit unsupported/error state, never silently approved.
## Soak and fault scenarios
- [ ] Run at least one overnight soak with the home agent and a worktree agent active.
- [ ] Inject at least one Pi child crash during streaming and one while idle.
- [ ] Confirm bounded recovery, resumed sessions, no duplicate events, and no orphan children.
- [ ] Restart Noctalia while the bridge remains active; reconnect without losing pending attention state.
- [ ] Verify no socket, lock, or transcript artifacts are world-readable.
## Exit criteria
- All automated cases pass.
- All manual Noctalia checks pass on the target desktop.
- Cross-user socket rejection passes.
- Overnight soak completes without an unbounded restart loop, lost session, incorrect worktree routing, or unhandled extension request.
## Verification
```sh
# Checklist remains complete enough for the agreed readiness gate.
grep -c '^\- \[ \]' TEST_PLAN.md
# Expected: at least 30
```
@@ -0,0 +1,55 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Widgets
Item {
id: root
property var pluginApi: null
property ShellScreen screen
property string widgetId: ""
property string section: ""
property int sectionWidgetIndex: -1
property int sectionWidgetsCount: 0
readonly property string screenName: screen?.name ?? ""
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
readonly property string bridgeState: pluginApi?.pluginSettings?.bridgeState ?? "idle"
readonly property string projectLabel: pluginApi?.pluginSettings?.projectLabel ?? "Home"
readonly property int attentionCount: pluginApi?.pluginSettings?.attentionCount ?? 0
readonly property string glyph: bridgeState === "streaming" ? "◒" : bridgeState === "failed" ? "!" : attentionCount > 0 ? "●" : "●"
readonly property color stateColor: bridgeState === "failed" ? Color.mError : attentionCount > 0 ? Color.mPrimary : Color.mOnSurface
implicitWidth: content.implicitWidth + Style.marginM * 2
implicitHeight: capsuleHeight
Rectangle {
id: visualCapsule
anchors.centerIn: parent
width: root.implicitWidth
height: root.implicitHeight
radius: Style.radiusL
color: mouseArea.containsMouse ? Color.mHover : Style.capsuleColor
border.color: Style.capsuleBorderColor
border.width: Style.capsuleBorderWidth
RowLayout {
id: content
anchors.centerIn: parent
spacing: Style.marginS
Text { text: root.glyph; color: root.stateColor; font.bold: true }
Text { text: root.projectLabel; color: Color.mOnSurface; elide: Text.ElideRight; Layout.maximumWidth: 160 }
Text { visible: root.attentionCount > 0; text: root.attentionCount; color: root.stateColor; font.bold: true }
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (pluginApi)
pluginApi.togglePanel(root.screen, visualCapsule)
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import QtQuick
import Quickshell.Io
Item {
id: root
property var pluginApi: null
function setBridgeState(state, projectLabel, attentionCount, detail) {
if (!pluginApi)
return
pluginApi.pluginSettings.bridgeState = state
pluginApi.pluginSettings.projectLabel = projectLabel
pluginApi.pluginSettings.attentionCount = attentionCount
pluginApi.pluginSettings.detail = detail
pluginApi.saveSettings()
}
IpcHandler {
target: "plugin:pi-status-bridge"
function setBridgeState(state, projectLabel, attentionCount, detail) {
root.setBridgeState(state, projectLabel, attentionCount, detail)
}
}
}
@@ -0,0 +1,36 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
Item {
id: root
property var pluginApi: null
readonly property var geometryPlaceholder: panelContainer
readonly property bool allowAttach: true
property real contentPreferredWidth: 680 * Style.uiScaleRatio
property real contentPreferredHeight: 420 * Style.uiScaleRatio
readonly property string bridgeState: pluginApi?.pluginSettings?.bridgeState ?? "idle"
readonly property string projectLabel: pluginApi?.pluginSettings?.projectLabel ?? "Home"
readonly property string detail: pluginApi?.pluginSettings?.detail ?? "Bridge not connected"
anchors.fill: parent
Rectangle {
id: panelContainer
anchors.fill: parent
color: "transparent"
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
Text { text: "Pi Status Bridge"; color: Color.mOnSurface; font.bold: true; font.pixelSize: 20 }
Text { text: "Project: " + root.projectLabel; color: Color.mOnSurfaceVariant }
Text { text: "State: " + root.bridgeState; color: Color.mOnSurfaceVariant }
Rectangle { Layout.fillWidth: true; height: 1; color: Color.mOutlineVariant }
Text { text: root.detail; color: Color.mOnSurface; wrapMode: Text.Wrap; Layout.fillWidth: true }
Text { text: "Select a project explicitly before sending worktree-scoped actions."; color: Color.mOnSurfaceVariant; wrapMode: Text.Wrap; Layout.fillWidth: true }
Item { Layout.fillHeight: true }
}
}
}
@@ -0,0 +1,14 @@
# Pi Status Bridge Noctalia plugin
This Noctalia v4 plugin is deliberately **presentation-only**. It renders
state supplied through its `plugin:pi-status-bridge` IPC handler and opens its
panel with `pluginApi.togglePanel`.
Use `pi-status-bridge-client` to query the owner-only Unix bridge socket. A
local integration script should translate responses and events into the plugin
IPC handler's `setBridgeState(state, projectLabel, attentionCount, detail)`
call. The plugin never starts Pi, opens TCP, chooses a worktree silently, or
approves extension requests.
Install/register it through Noctalia's normal plugin workflow, then add its
bar widget in Noctalia settings.
@@ -0,0 +1,20 @@
{
"id": "pi-status-bridge",
"name": "Pi Status Bridge",
"version": "0.1.0",
"minNoctaliaVersion": "4.0.0",
"description": "Presentation-only status bar and panel for a local Pi Status Bridge.",
"entryPoints": {
"main": "Main.qml",
"barWidget": "BarWidget.qml",
"panel": "Panel.qml"
},
"metadata": {
"defaultSettings": {
"bridgeState": "idle",
"projectLabel": "Home",
"attentionCount": 0,
"detail": "Bridge not connected"
}
}
}
@@ -0,0 +1,89 @@
local request_in_flight = false
local function shell_quote(value)
return "'" .. string.gsub(value, "'", "'\\\"'\\\"'") .. "'"
end
local function ui_binary()
local configured = noctalia.getenv("PI_STATUS_UI_BINARY")
if configured ~= nil and configured ~= "" then return configured end
return (noctalia.getenv("HOME") or "") .. "/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui"
end
local function socket_path()
local runtime = noctalia.getenv("XDG_RUNTIME_DIR")
if runtime == nil or runtime == "" then return nil end
return runtime .. "/pi-status-bridge/bridge.sock"
end
local function current_agent_id()
return noctalia.state.get("agent_id")
end
local function discover_home_agent(socket)
if request_in_flight then return end
request_in_flight = true
noctalia.runAsync("pi-status-bridge-client --socket \"" .. socket .. "\" request '{\"op\":\"list_agents\"}'", function(result)
request_in_flight = false
if result.exitCode ~= 0 then return end
local home = noctalia.getenv("HOME")
for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do
if worktree == home then
noctalia.state.set("agent_id", id)
noctalia.state.set("project_path", worktree)
return
end
end
end)
end
function update()
noctalia.setUpdateInterval(2000)
local agent_id = current_agent_id()
local socket = socket_path()
if socket == nil then
barWidget.setGlyph("alert-triangle")
barWidget.setText("Pi offline")
barWidget.setTooltip("Bridge socket is unavailable")
return
end
if agent_id == nil then
discover_home_agent(socket)
barWidget.setGlyph("terminal-2")
barWidget.setText("Pi home")
barWidget.setTooltip("Starting the default Home agent; click to open Pi Status UI")
return
end
if request_in_flight then return end
request_in_flight = true
local command = "pi-status-bridge-client --socket \"" .. socket
.. "\" request '{\"op\":\"get_state\",\"agentId\":\"" .. agent_id .. "\"}'"
noctalia.runAsync(command, function(result)
request_in_flight = false
if result.exitCode ~= 0 then
noctalia.state.set("agent_id", nil)
discover_home_agent(socket)
barWidget.setGlyph("terminal-2")
barWidget.setText("Pi reconnecting")
barWidget.setTooltip("Refreshing the Home Pi agent")
return
end
local streaming = string.find(result.stdout, "\"isStreaming\":true", 1, true) ~= nil
barWidget.setGlyph(streaming and "terminal-2" or "terminal")
barWidget.setText(streaming and "Pi working" or "Pi ready")
barWidget.setTooltip(noctalia.state.get("project_path") or "Selected Pi folder")
end)
end
function onClick()
local command = "setsid -f env TMPDIR=/tmp " .. shell_quote(ui_binary())
.. " --show >/tmp/pi-status-ui.log 2>&1"
noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
barWidget.setGlyph("alert-triangle")
barWidget.setText("Pi UI unavailable")
barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY")
end
end)
end
@@ -0,0 +1,360 @@
local agent_id = nil
local agents = {}
local messages = {}
local folder_path = noctalia.getenv("HOME") or ""
local composer = ""
local extension_input = ""
local pending_extension = nil
local status = "Loading Pi agents…"
local request_in_flight = false
local stream_started = false
local view = "output"
local command_suggestions = {}
local models = {}
local model_options = {}
local thinking_options = { "off", "minimal", "low", "medium", "high", "xhigh", "max" }
local thinking_level = "medium"
local session_summary = "Session loading…"
local function socket_path()
local runtime = noctalia.getenv("XDG_RUNTIME_DIR")
if runtime == nil or runtime == "" then return nil end
return runtime .. "/pi-status-bridge/bridge.sock"
end
local function shell_quote(value)
return "'" .. string.gsub(value, "'", "'\"'\"'") .. "'"
end
local function request(op, agent, payload, callback)
local socket = socket_path()
if socket == nil then
status = "Bridge socket is unavailable."
render_panel()
return
end
local body = { op = op }
if agent ~= nil then body.agentId = agent end
if payload ~= nil then body.payload = payload end
local encoded, err = noctalia.json.encode(body)
if encoded == nil then
status = "Could not encode bridge request: " .. tostring(err)
render_panel()
return
end
local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " request " .. shell_quote(encoded)
noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
status = "Bridge request failed: " .. noctalia.string.trim(result.stderr)
render_panel()
return
end
local decoded, decode_error = noctalia.json.decode(result.stdout)
if decoded == nil then
status = "Invalid bridge response: " .. tostring(decode_error)
render_panel()
return
end
callback(decoded)
end)
end
local function message_text(message)
if message == nil then return "" end
if type(message.content) == "string" then return message.content end
if type(message.content) ~= "table" then return "" end
local parts = {}
for _, block in ipairs(message.content) do
if block.type == "text" and block.text ~= nil then table.insert(parts, block.text) end
if block.type == "thinking" and block.thinking ~= nil then table.insert(parts, "Thinking: " .. block.thinking) end
if block.type == "toolCall" and block.name ~= nil then table.insert(parts, "Tool: " .. block.name) end
end
return table.concat(parts, "\n")
end
local function choose_home_agent()
local home = noctalia.getenv("HOME")
for _, agent in ipairs(agents) do
if agent.worktreePath == home then return agent.id end
end
return agents[1] and agents[1].id or nil
end
local function known_agent(id)
for _, agent in ipairs(agents) do
if agent.id == id then return true end
end
return false
end
local function refresh_agents()
request("list_agents", nil, nil, function(result)
agents = result.agents or {}
local remembered_id = agent_id or noctalia.state.get("agent_id")
agent_id = known_agent(remembered_id) and remembered_id or choose_home_agent()
if agent_id ~= nil then noctalia.state.set("agent_id", agent_id) end
status = agent_id and "Pi ready" or "Add a folder to start Pi"
render_panel()
if agent_id ~= nil then
refresh_transcript()
refresh_commands()
refresh_controls()
end
end)
end
function refresh_controls()
if agent_id == nil then return end
request("get_state", agent_id, nil, function(result)
local state = result.data or {}
thinking_level = state.thinkingLevel or thinking_level
session_summary = (state.sessionName or state.sessionId or "Session")
.. "" .. tostring(state.messageCount or 0) .. " messages"
.. "" .. tostring(state.pendingMessageCount or 0) .. " queued"
status = state.isStreaming and "Pi working" or "Pi ready"
request("get_available_models", agent_id, nil, function(models_result)
models = (models_result.data and models_result.data.models) or {}
model_options = {}
for _, model in ipairs(models) do
table.insert(model_options, (model.provider or "") .. "/" .. (model.id or model.name or "model"))
end
render_panel()
end)
end)
end
function refresh_commands()
if agent_id == nil then return end
request("get_commands", agent_id, nil, function(result)
command_suggestions = {}
for _, command in ipairs((result.data and result.data.commands) or {}) do
if command.name ~= nil then table.insert(command_suggestions, "/" .. command.name) end
end
render_panel()
end)
end
function refresh_transcript()
if agent_id == nil or request_in_flight then return end
request_in_flight = true
request("get_transcript", agent_id, nil, function(result)
request_in_flight = false
messages = result.data and result.data.messages or {}
render_panel()
end)
end
local function start_stream()
if stream_started or agent_id == nil then return end
local socket = socket_path()
if socket == nil then return end
stream_started = true
local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " subscribe --agent " .. shell_quote(agent_id)
noctalia.runStream(command, function(line)
local event = noctalia.json.decode(line)
if event == nil then return end
if event.type == "extension_ui_request" then pending_extension = event.data and event.data.event end
if event.type == "agent_state" then status = (event.data and event.data.state) or "Pi state changed" end
if event.type == "queue" then status = "Pi queue updated" end
if event.type == "stream" and event.data and event.data.event and event.data.event.type == "tool_execution_start" then
status = "Tool: " .. tostring(event.data.event.toolName or "running")
end
if event.type == "message_end" or event.type == "agent_state" then refresh_transcript() end
render_panel()
end)
end
local function agent_label(agent)
return agent.worktreePath .. "" .. agent.state
end
function render_panel()
local selected_path = noctalia.state.get("project_path") or "Home"
local children = {
ui.row({ gap = 8, justify = "space_between" }, {
ui.column({ gap = 2 }, {
ui.label({ text = "Pi", fontSize = 22, fontWeight = "bold" }),
ui.label({ text = selected_path .. "" .. status })
}),
ui.button({ text = view == "output" and "Settings" or "Output", glyph = "settings", variant = "ghost", onClick = "onToggleSettings" })
})
}
if view == "settings" then
table.insert(children, ui.label({ text = "Folders and agents", fontWeight = "bold" }))
local choices = {}
for _, agent in ipairs(agents) do table.insert(choices, agent_label(agent)) end
if #choices > 0 then table.insert(children, ui.select({ options = choices, onChange = "onSettingsAgentChange" })) end
table.insert(children, ui.input({ value = folder_path, placeholder = "/absolute/path/to/project", onChange = "onFolderChanged", onSubmit = "onAddFolder" }))
table.insert(children, ui.button({ text = "Add folder", glyph = "folder-plus", variant = "primary", onClick = "onAddFolder" }))
table.insert(children, ui.label({ text = "Adding a folder explicitly starts or resumes its own Pi instance." }))
table.insert(children, ui.label({ text = session_summary, maxLines = 2 }))
table.insert(children, ui.label({ text = "Pi controls", fontWeight = "bold" }))
if #model_options > 0 then table.insert(children, ui.select({ options = model_options, onChange = "onModelChange" })) end
table.insert(children, ui.select({ options = thinking_options, onChange = "onThinkingChange" }))
table.insert(children, ui.row({ gap = 8 }, {
ui.button({ text = "Retry", onClick = "onRetry" }),
ui.button({ text = "Restart Pi", variant = "destructive", onClick = "onRestart" })
}))
else
table.insert(children, ui.label({ text = "Output", fontWeight = "bold" }))
local transcript = {}
for _, message in ipairs(messages) do
local text = message_text(message)
if text ~= "" then table.insert(transcript, ui.label({ text = (message.role or "message") .. ": " .. text, maxLines = 12 })) end
end
if #transcript == 0 then table.insert(transcript, ui.label({ text = "No messages yet." })) end
table.insert(children, ui.scroll({ minHeight = 260, maxHeight = 520 }, transcript))
table.insert(children, ui.input({ value = composer, placeholder = "Message Pi", onChange = "onComposerChanged", onSubmit = "onPrompt" }))
if string.sub(composer, 1, 1) == "/" and #command_suggestions > 0 then
table.insert(children, ui.select({ options = command_suggestions, onChange = "onCommandSuggestion" }))
end
table.insert(children, ui.row({ gap = 8 }, {
ui.button({ text = "Send", variant = "primary", onClick = "onPrompt" }),
ui.button({ text = "Abort", variant = "destructive", onClick = "onAbort" })
}))
end
if pending_extension ~= nil then
local method = pending_extension.method or "input"
table.insert(children, ui.label({ text = "Pi extension request: " .. (pending_extension.title or method), fontWeight = "bold" }))
table.insert(children, ui.label({ text = pending_extension.message or "Choose a response." }))
if method == "select" and pending_extension.options ~= nil then
table.insert(children, ui.select({ options = pending_extension.options, onChange = "onExtensionSelect" }))
elseif method == "confirm" then
table.insert(children, ui.row({ gap = 8 }, {
ui.button({ text = "Confirm", variant = "primary", onClick = "onExtensionConfirm" }),
ui.button({ text = "Decline", onClick = "onExtensionDecline" })
}))
elseif method == "input" or method == "editor" then
table.insert(children, ui.input({ value = extension_input, placeholder = method == "editor" and "Extension editor content" or "Extension response", onChange = "onExtensionInputChanged", onSubmit = "onExtensionSubmit" }))
table.insert(children, ui.button({ text = "Submit", variant = "primary", onClick = "onExtensionSubmit" }))
else
table.insert(children, ui.label({ text = "This extension UI is unsupported in the panel. No response has been approved.", maxLines = 3 }))
end
table.insert(children, ui.button({ text = "Cancel request", variant = "ghost", onClick = "onExtensionCancel" }))
end
panel.render(ui.scroll({ padding = 20, gap = 12 }, children))
end
function onOpen()
panel.setWantsSecondTicks(true)
refresh_agents()
render_panel()
end
function update()
refresh_transcript()
end
function onFolderChanged(value) folder_path = value end
function onComposerChanged(value)
composer = value
if string.sub(composer, 1, 1) == "/" then render_panel() end
end
function onCommandSuggestion(index)
local suggestion = command_suggestions[tonumber(index) + 1]
if suggestion ~= nil then
composer = suggestion .. " "
render_panel()
end
end
function onExtensionInputChanged(value) extension_input = value end
function onSettingsAgentChange(index)
local agent = agents[tonumber(index) + 1]
if agent == nil then return end
agent_id = agent.id
noctalia.state.set("agent_id", agent_id)
noctalia.state.set("project_path", agent.worktreePath)
stream_started = false
start_stream()
refresh_transcript()
end
function onModelChange(index)
local model = models[tonumber(index) + 1]
if model == nil or agent_id == nil then return end
request("set_model", agent_id, { provider = model.provider, modelId = model.id }, function(_) refresh_controls() end)
end
function onThinkingChange(index)
local level = thinking_options[tonumber(index) + 1]
if level == nil or agent_id == nil then return end
request("set_thinking_level", agent_id, { level = level }, function(_) refresh_controls() end)
end
function onRetry()
if agent_id ~= nil then request("retry", agent_id, nil, function(_) refresh_agents() end) end
end
function onRestart()
if agent_id ~= nil then request("restart", agent_id, nil, function(_) refresh_agents() end) end
end
function onAddFolder()
if string.sub(folder_path, 1, 1) ~= "/" then status = "Enter an absolute folder path."; render_panel(); return end
request("select_agent", nil, { worktreePath = folder_path }, function(result)
agent_id = result.agent and result.agent.id or nil
if agent_id == nil then status = "Bridge did not return an agent."; render_panel(); return end
noctalia.state.set("agent_id", agent_id)
noctalia.state.set("project_path", folder_path)
status = "Pi ready for " .. folder_path
stream_started = false
start_stream()
refresh_agents()
end)
end
local function submit_composer()
if agent_id == nil then status = "Choose a folder first."; render_panel(); return end
if composer == "" then return end
request("submit_prompt", agent_id, { message = composer }, function(_)
composer = ""
status = "Prompt queued by Pi"
refresh_transcript()
end)
end
function onPrompt() submit_composer() end
function onAbort() if agent_id ~= nil then request("abort", agent_id, nil, function(_) status = "Aborted"; render_panel() end) end end
local function send_extension_response(response)
if pending_extension == nil or agent_id == nil then return end
request("extension_response", agent_id, { requestId = pending_extension.id, response = response }, function(_)
pending_extension = nil
extension_input = ""
render_panel()
end)
end
function onExtensionSubmit()
send_extension_response({ value = extension_input })
end
function onExtensionSelect(index)
if pending_extension == nil or pending_extension.options == nil then return end
local option = pending_extension.options[tonumber(index) + 1]
if option ~= nil then send_extension_response({ value = option }) end
end
function onExtensionConfirm()
send_extension_response({ confirmed = true })
end
function onExtensionDecline()
send_extension_response({ confirmed = false })
end
function onExtensionCancel()
if pending_extension == nil or agent_id == nil then return end
request("extension_response", agent_id, { requestId = pending_extension.id, response = { cancelled = true } }, function(_)
pending_extension = nil
extension_input = ""
render_panel()
end)
end
function onClosePicker()
panel.close()
end
@@ -0,0 +1,10 @@
id = "alex/pi-status-bridge"
name = "Pi Status Bridge"
version = "0.2.0"
plugin_api = 3
author = "alex"
description = "Compact Pi status and launcher for the standalone Pi Status UI."
[[widget]]
id = "bridge"
entry = "bridge.luau"
+19
View File
@@ -0,0 +1,19 @@
{
"name": "pi-status-bridge",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"pi-status-bridge": "src/bridge/cli.js",
"pi-status-bridge-client": "src/client/cli.js",
"pi-status-bridge-noctalia-relay": "src/client/noctalia-relay-cli.js"
},
"engines": {
"node": ">=20"
},
"scripts": {
"test": "node --test test/*.test.js && npm run test:ui",
"test:ui": "cargo test --manifest-path ui/src-tauri/Cargo.toml",
"check": "node --check src/protocol/index.js && node --check src/client/local-client.js && node --check src/client/noctalia-relay.js && node --check src/client/noctalia-ipc.js && node --check src/client/noctalia-relay-cli.js && node --check src/bridge/daemon.js && node --check src/bridge/cli.js && node --check src/bridge/agent-registry.js && node --check src/bridge/instance-lock.js && node --check src/bridge/pi-rpc-adapter.js && node --check src/bridge/unix-server.js && node --check src/bridge/runtime.js && node --check src/bridge/recovery-supervisor.js && node --check src/bridge/service.js && npm --prefix ui run build"
}
}
+119
View File
@@ -0,0 +1,119 @@
# Full Pi Panel Specification
## Reason for existence
Replace the compact Noctalia status widget with a feature-equivalent local Pi
client panel. The bar remains a compact launcher and status indicator; the
panel is the single interaction surface.
## User flow
1. The user session starts one bridge-managed Home agent for `$HOME`.
2. Clicking the bar opens the **Output** view for the selected agent. Home is
selected by default.
3. Output shows transcript/history, live streaming/tool/queue state, extension
requests, and one composer anchored at the bottom.
4. The composer mirrors the confirmed TUI workflow: submit a normal prompt;
when Pi is busy, Pi queues it as a follow-up. The panel never asks the user
to choose prompt versus steer versus follow-up for ordinary submission.
5. **Settings** is the only location for folder management. Adding an absolute
folder explicitly creates/resumes its own agent, persists it, and selects it.
6. The user may switch among remembered agents from Settings. Previously added
folders do not start at login unless selected; Home is always running.
## Views
### Output (default)
- Header: selected folder, agent health, streaming/queue/recovery status.
- Scrollable transcript: user, assistant, tool result, and visible custom
messages. Thinking content follows Pi visibility policy; it is not exposed by
default merely because the panel exists.
- Tool activity: current tool name/progress while active and tool results in
history after completion.
- Composer: one input and one submit action. It accepts normal text, Pi command
syntax, prompt templates, and skills exactly as Pi RPC `prompt` accepts them.
- Suggestions: when composer input begins with `/`, show Pi-provided
`get_commands` entries. Selecting one inserts it; it does not execute until
submitted.
- Extension requests: select, confirm, input, and editor map to native panel
controls and return the exact request ID/response shape. Unsupported custom
TUI UI renders an explicit unsupported state and never approves anything.
### Settings
- Agent list: Home plus remembered explicit folders, each with state.
- Folder input: requires an absolute path. Adding it invokes `select_agent`;
bridge canonicalization remains authoritative.
- Session controls: current session metadata, retry/restart, and agent health.
- Pi controls: available models, current model, thinking level, and queue modes
are read from Pi and changed only through Pi proxy operations. No independent
bridge defaults or persistence are allowed.
## Bridge/API contract
The panel speaks only through `pi-status-bridge-client` over the owner-only
Unix socket. Required operations are:
| Purpose | Operation |
| --- | --- |
| agents | `list_agents`, `select_agent` |
| live state | `get_state`, `subscribe` |
| history | `get_transcript` |
| composer | `submit_prompt` |
| recovery | `retry`, `restart` |
| Pi controls | `get_available_models`, `set_model`, `set_thinking_level` |
| suggestions | `get_commands` |
| extension UI | `extension_response` |
`submit_prompt` is the only default composer operation. The bridge dispatches
it to Pi `prompt` when idle and Pi `follow_up` while streaming; Pi retains
queue semantics. Direct `steer` and `follow_up` may exist only as explicit
advanced/TUI-parity shortcuts after their exact behavior is verified; they are
not ordinary UI buttons.
## State and persistence
- Bridge persists the Pi session reference per canonical worktree.
- Noctalia persists the remembered folder list and last selected agent in its
plugin data directory, not the plugin installation directory.
- The bridge starts Home; Noctalia never spawns Pi or owns a socket lock.
- Agent IDs are bridge-issued. The panel never invents or maps IDs to another
worktree.
## Safety invariants
- Never expose TCP.
- Never silently select a non-Home folder.
- Never auto-approve extension/tool requests.
- Never replace Pi model, thinking, or queue policy with panel defaults.
- Never treat unsupported custom extension UI as approval.
- Never execute an arbitrary folder path through an unquoted shell command.
## Acceptance scenarios
- **Home startup:** After user login, Home appears as a ready/working agent
without opening the panel.
- **Folder add:** Entering an absolute folder in Settings creates/selects its
distinct agent; Home transcript and prompts remain isolated.
- **Normal prompt:** Submitting the one composer while idle sends one Pi prompt.
- **Busy prompt:** Submitting while Pi is active follows Pi's follow-up queue
behavior without an extra panel decision.
- **History:** Reopening Output replays existing transcript, then receives live
updates without duplicate cursor entries.
- **Command suggestion:** Typing `/` presents `get_commands` results; selection
inserts, not executes, a command.
- **Extension safety:** Confirm/select/input/editor responses preserve request
IDs and values; custom UI shows unsupported.
- **Recovery:** Failed-recovery state is visible with explicit retry/restart.
## Verification
```sh
npm run check && npm test
/usr/bin/noctalia plugins lint ~/.local/share/noctalia/plugins/pi-status-bridge
/usr/bin/noctalia config validate
```
Manual verify: open the panel, select Home, add a folder from Settings, submit a
prompt, close/reopen the panel, and confirm history plus status remain correct.
+140
View File
@@ -0,0 +1,140 @@
# Tauri Pi Client Architecture
## Purpose
Replace the Noctalia panel with a testable, status-bar-agnostic desktop Pi
client. Noctalia becomes an optional status indicator and launcher only.
## Decision
Use a **Tauri v2 desktop application** with a web frontend and a small Rust
host. The bridge remains the only owner of Pi processes, sessions, recovery,
and the owner-only Unix socket.
Tauri is appropriate because its frontend talks to a native host through typed
asynchronous IPC, and its WebDriver support gives the client a real E2E test
surface. Reference: <https://v2.tauri.app/concept/architecture/> and
<https://v2.tauri.app/develop/tests/webdriver/>.
## Boundaries
```text
Pi Status Bridge systemd service
└─ Unix JSONL socket: state, events, commands
└─ Tauri Rust host
├─ validates/serializes bridge requests
├─ owns subscribe connections and emits UI events
└─ exposes typed Tauri commands
└─ React frontend: desktop UI
Optional Noctalia compatibility shim
├─ reads compact state through existing client/bridge protocol
└─ launches or focuses `pi-status-ui --toggle`
```
### Security
- The frontend cannot run shell commands or open sockets.
- Only the Tauri Rust host reads the per-user Unix socket.
- The UI never receives or stores bridge lock tokens.
- No TCP listener is introduced.
- Pi remains authoritative for model, thinking, queues, tool approvals, and
extension request IDs.
## Tauri host API
The host maps the existing JSONL protocol into typed commands. It must reject
unknown operations and preserve agent scoping.
| Command | Bridge operation | Result |
| --- | --- | --- |
| `listAgents` | `list_agents` | current agent summaries |
| `selectWorktree` | `select_agent` | explicitly selected agent |
| `loadAgent` | state, transcript, commands, models | one snapshot |
| `submitPrompt` | `submit_prompt` | Pi-owned prompt/follow-up queueing |
| `abort`, `retry`, `restart` | same | command response |
| `setModel`, `setThinkingLevel` | same | command response |
| `respondToExtension` | `extension_response` | command response |
| `subscribeAgent` | `subscribe` | emits `bridge-event` to this window |
The Rust host owns reconnection and cursor replay. Its view-model event stream
is normalized to:
```ts
type BridgeEvent = {
agentId: string;
seq: number;
kind: "state" | "message" | "tool" | "queue" | "extension" | "recovery";
payload: unknown;
};
```
## Frontend
### Window behavior
- One persistent window; close hides it, quit is explicit.
- `--show`, `--hide`, and `--toggle` are routed to the existing instance using
Tauris single-instance capability.
- The optional Noctalia widget runs `pi-status-ui --toggle`; it never renders
the primary UI or handles bridge commands.
### Screens
1. **Conversation** (default): selected agent, streaming/queue/tool status,
transcript, extension dialog, and one composer.
2. **Agents**: Home plus remembered explicit folders. Selecting a remembered
folder invokes bridge `select_agent`; no folder starts merely because it is
remembered.
3. **Session settings**: model, thinking level, session metadata, retry, and
restart.
### Composer invariant
The frontend has one normal submit action. It invokes `submit_prompt`; it does
not decide between prompt, steering, or follow-up. Slash-command selection
inserts text only.
### Extension invariant
Render Pi `select`, `confirm`, `input`, and `editor` requests. Present any
other method as unsupported without sending an approval.
## Persistence
| Data | Owner |
| --- | --- |
| Pi session/recovery | bridge |
| remembered worktree paths + last selected path | UI app data |
| bridge agent IDs | memory only; invalid after bridge restart |
| compact launcher state | Noctalia only, disposable |
At startup the UI resolves its stored path through `select_agent`; it never
persists or trusts a bridge agent ID.
## Implementation sequence
1. Add `ui/` (React/Vite) and `src-tauri/`; leave current Node bridge intact.
2. Implement Rust JSONL client plus typed command/snapshot contract with unit
tests against a temporary Unix socket server.
3. Implement the conversation store and pure reducer tests for event replay.
4. Build the conversation screen; add WebDriver E2E for initial load,
composer submission, and busy follow-up behavior.
5. Build agent/settings/extension screens and tests.
6. Replace Noctalia panel entry with a small launcher compatibility shim; retain
its compact state indicator.
7. Remove the primary Luau panel only after client E2E and manual desktop checks
pass.
## Acceptance checks
- Launching `pi-status-ui --toggle` opens/focuses one window.
- Restarting the bridge recovers to Home or a saved worktree path; it never
issues a command to a stale agent ID.
- A normal submit, busy submit, model change, and extension response route
through typed host commands only.
- A custom extension UI cannot be approved from the client.
- Noctalia can be disabled/uninstalled while the desktop client remains fully
usable.
- Rust host tests, frontend unit tests, WebDriver E2E, bridge tests, and a
manual desktop run pass.
+27
View File
@@ -0,0 +1,27 @@
feature_name: tauri-pi-desktop-client
problem_statement: >
The current Noctalia panel is not reliably interactive or testable, while
users need a feature-equivalent Pi desktop client that works independently
of any status bar.
constraints:
- The bridge remains the only Pi process/session/recovery owner.
- The Tauri host uses only the local Unix bridge client and never opens TCP.
- Noctalia is an optional compact launcher/status compatibility layer.
- Folder selection is explicit and canonicalized by the bridge.
- Pi owns model, thinking, queue behavior, approvals, and extension policy.
- TUI-only custom extension UI must show unsupported and never auto-approve.
out_of_scope:
- Pixel-identical terminal TUI rendering.
- Arbitrary custom TUI extension component rendering.
- Auto-starting every remembered non-Home folder at login.
key_decisions:
- decision: One default composer
rationale: Mirror the confirmed TUI workflow without bridge-owned prompt/steer decisions.
- decision: Tauri owns the primary UI
rationale: Native host IPC plus frontend/unit/E2E testing is more reliable than a declarative status-bar panel.
- decision: Noctalia is a compatibility shim
rationale: It displays compact status and opens/focuses the standalone client without owning UI behavior.
- decision: Persist remembered folders and last selection
rationale: Remove manual ID/folder setup while preserving explicit selection.
- decision: Home agent is always running
rationale: Provide an immediately available default Pi instance.
+492
View File
@@ -0,0 +1,492 @@
import { createHash, randomUUID } from "node:crypto";
import {
chmod,
mkdir,
readdir,
readFile,
realpath,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { startPiRpcAdapter } from "./pi-rpc-adapter.js";
import { createRecoverySupervisor } from "./recovery-supervisor.js";
const SESSION_DIRECTORY_MODE = 0o700;
const SESSION_REFERENCE_FILE = "bridge-agent.json";
const DEFAULT_EVENT_LIMIT = 1_000;
export class UnknownAgentError extends Error {
constructor(agentId) {
super(`unknown agent: ${agentId}`);
this.name = "UnknownAgentError";
}
}
function sessionDirectoryFor(sessionRoot, worktreePath) {
const identity = createHash("sha256")
.update(worktreePath)
.digest("hex")
.slice(0, 24);
return path.join(sessionRoot, `agent-${identity}`);
}
async function readSessionReference(sessionDir) {
try {
const value = JSON.parse(
await readFile(path.join(sessionDir, SESSION_REFERENCE_FILE), "utf8"),
);
return {
sessionPath:
typeof value?.sessionPath === "string" &&
path.isAbsolute(value.sessionPath)
? value.sessionPath
: undefined,
worktreePath:
typeof value?.worktreePath === "string" &&
path.isAbsolute(value.worktreePath)
? value.worktreePath
: undefined,
};
} catch (error) {
if (error?.code === "ENOENT" || error instanceof SyntaxError) return {};
throw error;
}
}
async function persistSessionReference(sessionDir, sessionPath, worktreePath) {
await writeFile(
path.join(sessionDir, SESSION_REFERENCE_FILE),
`${JSON.stringify({ sessionPath, worktreePath })}\n`,
{
encoding: "utf8",
mode: 0o600,
},
);
}
function sessionPreview(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join(" ");
}
async function readSessionSummary(sessionPath, currentPath) {
const [content, metadata] = await Promise.all([
readFile(sessionPath, "utf8"),
stat(sessionPath),
]);
let header;
let name;
let firstMessage;
let messageCount = 0;
for (const line of content.split("\n")) {
if (!line) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (entry.type === "session") header = entry;
if (entry.type === "session_info" && typeof entry.name === "string")
name = entry.name;
if (entry.type === "message") {
messageCount += 1;
if (!firstMessage && entry.message?.role === "user")
firstMessage = sessionPreview(entry.message.content);
}
}
if (
!header ||
typeof header.id !== "string" ||
typeof header.cwd !== "string"
)
return undefined;
return {
path: sessionPath,
id: header.id,
cwd: header.cwd,
name,
parentSessionPath: header.parentSession,
created: header.timestamp,
modified: metadata.mtime.toISOString(),
messageCount,
firstMessage,
isCurrent: sessionPath === currentPath,
};
}
async function listSessionSummaries(sessionDir, currentPath) {
const entries = await readdir(sessionDir, { withFileTypes: true });
const summaries = await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
.map(async (entry) => {
try {
return await readSessionSummary(
path.join(sessionDir, entry.name),
currentPath,
);
} catch {
return undefined;
}
}),
);
return summaries
.filter(Boolean)
.sort((left, right) => right.modified.localeCompare(left.modified));
}
async function listDirectoryCatalog(sessionRoot, agentsByPath) {
let entries;
try {
entries = await readdir(sessionRoot, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return [];
throw error;
}
const references = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const reference = await readSessionReference(
path.join(sessionRoot, entry.name),
);
if (!reference.worktreePath) return undefined;
return path.basename(
sessionDirectoryFor(sessionRoot, reference.worktreePath),
) === entry.name
? reference.worktreePath
: undefined;
}),
);
const paths = new Set([
...agentsByPath.keys(),
...references.filter(Boolean),
]);
return [...paths]
.map((worktreePath) => {
const agent = agentsByPath.get(worktreePath);
return {
worktreePath,
state: agent?.state ?? "inactive",
...(agent ? { agentId: agent.id } : {}),
};
})
.sort((left, right) => left.worktreePath.localeCompare(right.worktreePath));
}
function publicAgent(agent) {
return {
id: agent.id,
worktreePath: agent.worktreePath,
sessionDir: agent.sessionDir,
state: agent.state,
};
}
function routeCommand(adapter, operation, payload = {}, agentState) {
switch (operation) {
case "prompt":
case "steer":
case "follow_up":
return adapter.send({ type: operation, message: payload.message });
case "submit_prompt":
return adapter.send({
type: agentState === "streaming" ? "follow_up" : "prompt",
message: payload.message,
});
case "abort":
case "get_state":
case "get_session_stats":
case "get_available_models":
case "get_commands":
return adapter.send({ type: operation });
case "get_transcript":
return adapter.send({ type: "get_messages" });
case "set_model":
return adapter.send({
type: "set_model",
provider: payload.provider,
modelId: payload.modelId,
});
case "set_thinking_level":
return adapter.send({ type: "set_thinking_level", level: payload.level });
case "extension_response":
adapter.respondToExtension(payload.requestId, payload.response);
return Promise.resolve({
type: "response",
command: "extension_ui_response",
success: true,
});
default:
return Promise.reject(
new Error(`unsupported agent operation: ${operation}`),
);
}
}
export function createAgentRegistry({
homeWorktree,
sessionRoot,
startAdapter = startPiRpcAdapter,
eventLimit = DEFAULT_EVENT_LIMIT,
maxRecoveryAttempts,
recoveryDelayForAttempt,
sleep,
}) {
if (typeof homeWorktree !== "string" || !path.isAbsolute(homeWorktree))
throw new TypeError("homeWorktree must be an absolute path");
if (typeof sessionRoot !== "string" || !path.isAbsolute(sessionRoot))
throw new TypeError("sessionRoot must be an absolute path");
if (typeof startAdapter !== "function")
throw new TypeError("startAdapter must be a function");
const agentsByPath = new Map();
const agentsById = new Map();
const inFlight = new Map();
async function ensureAgent(worktreePath) {
if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath))
throw new TypeError("worktreePath must be an absolute path");
const canonicalPath = await realpath(worktreePath);
const existing = agentsByPath.get(canonicalPath);
if (existing) return publicAgent(existing);
const creating = inFlight.get(canonicalPath);
if (creating) return creating;
const creation = (async () => {
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath);
await mkdir(sessionDir, {
recursive: true,
mode: SESSION_DIRECTORY_MODE,
});
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
const reference = await readSessionReference(sessionDir);
const agent = {
id: `agent-${randomUUID()}`,
worktreePath: canonicalPath,
sessionDir,
sessionPath: reference.sessionPath,
state: "idle",
events: [],
listeners: new Set(),
nextSequence: 1,
adapter: undefined,
stopped: false,
recoveryPromise: undefined,
supervisor: undefined,
};
const publish = (type, data) => {
const event = {
version: "v1",
seq: agent.nextSequence++,
type,
agentId: agent.id,
data,
};
agent.events.push(event);
if (agent.events.length > eventLimit) agent.events.shift();
for (const listener of agent.listeners) listener(event);
};
const launch = async () => {
const adapter = startAdapter({
cwd: canonicalPath,
sessionDir,
...(agent.sessionPath ? { sessionPath: agent.sessionPath } : {}),
onEvent: (event) => {
if (
event.type === "agent_state" &&
typeof event.data?.state === "string"
) {
agent.state = event.data.state;
if (event.data.state === "idle") agent.supervisor.markHealthy();
}
publish(event.type, event.data);
},
onError: (error) => {
agent.state = "error";
publish("agent_state", {
state: "error",
error: { code: error.code ?? "unknown", message: error.message },
});
if (
!agent.stopped &&
["child_exited", "child_error"].includes(error.code)
) {
agent.recoveryPromise = agent.supervisor
.handleUnexpectedExit()
.then((adapterAfterRecovery) => {
if (agent.stopped) return undefined;
agent.state = adapterAfterRecovery ? "idle" : "failed";
publish("agent_state", { state: agent.state });
return adapterAfterRecovery;
});
}
},
});
agent.adapter = adapter;
const state = await adapter.send({ type: "get_state" });
agent.state = state?.data?.isStreaming ? "streaming" : "idle";
const sessionPath = state?.data?.sessionFile;
if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) {
agent.sessionPath = sessionPath;
await persistSessionReference(sessionDir, sessionPath, canonicalPath);
}
return adapter;
};
agent.supervisor = createRecoverySupervisor({
start: launch,
...(maxRecoveryAttempts ? { maxAttempts: maxRecoveryAttempts } : {}),
...(recoveryDelayForAttempt
? { delayForAttempt: recoveryDelayForAttempt }
: {}),
...(sleep ? { sleep } : {}),
onState: (recovery) => publish("recovery", recovery),
});
await launch();
agentsByPath.set(canonicalPath, agent);
agentsById.set(agent.id, agent);
return publicAgent(agent);
})();
inFlight.set(canonicalPath, creation);
try {
return await creation;
} finally {
inFlight.delete(canonicalPath);
}
}
function getAgent(agentId) {
const agent = agentsById.get(agentId);
if (!agent) throw new UnknownAgentError(agentId);
return agent;
}
async function refreshAgentSessionReference(agent) {
const state = await agent.adapter.send({ type: "get_state" });
const sessionPath = state?.data?.sessionFile;
if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) {
agent.sessionPath = sessionPath;
await persistSessionReference(
agent.sessionDir,
sessionPath,
agent.worktreePath,
);
}
}
async function ownedSessionPath(agent, sessionPath) {
const [resolvedSessionDir, resolvedSessionPath] = await Promise.all([
realpath(agent.sessionDir),
realpath(sessionPath),
]);
const relative = path.relative(resolvedSessionDir, resolvedSessionPath);
if (
relative === "" ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative) ||
!resolvedSessionPath.endsWith(".jsonl")
) {
throw new TypeError("sessionPath must belong to the selected directory");
}
return resolvedSessionPath;
}
async function switchSession(agent, sessionPath) {
if (agent.state === "streaming")
throw new Error("cannot switch sessions while Pi is working");
const ownedPath = await ownedSessionPath(agent, sessionPath);
const response = await agent.adapter.send({
type: "switch_session",
sessionPath: ownedPath,
});
if (!response?.data?.cancelled) await refreshAgentSessionReference(agent);
return response;
}
async function newSession(agent) {
if (agent.state === "streaming")
throw new Error("cannot start a session while Pi is working");
const response = await agent.adapter.send({ type: "new_session" });
if (!response?.data?.cancelled) await refreshAgentSessionReference(agent);
return response;
}
return {
async start() {
return ensureAgent(homeWorktree);
},
async selectWorktree(worktreePath) {
return ensureAgent(worktreePath);
},
listAgents() {
return [...agentsById.values()]
.map(publicAgent)
.sort((left, right) =>
left.worktreePath.localeCompare(right.worktreePath),
);
},
async listDirectories() {
return listDirectoryCatalog(sessionRoot, agentsByPath);
},
async listSessions(agentId) {
const agent = getAgent(agentId);
return listSessionSummaries(agent.sessionDir, agent.sessionPath);
},
eventsAfter(agentId, cursor = 0) {
if (!Number.isSafeInteger(cursor) || cursor < 0)
throw new TypeError("cursor must be a non-negative integer");
return getAgent(agentId).events.filter((event) => event.seq > cursor);
},
subscribe(agentId, cursor, listener) {
if (typeof listener !== "function")
throw new TypeError("listener must be a function");
const agent = getAgent(agentId);
const events = this.eventsAfter(agentId, cursor);
agent.listeners.add(listener);
return {
events,
unsubscribe: () => agent.listeners.delete(listener),
};
},
async waitForRecovery(agentId) {
return getAgent(agentId).recoveryPromise;
},
async retry(agentId) {
const agent = getAgent(agentId);
agent.supervisor.markHealthy();
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
await agent.recoveryPromise;
return publicAgent(agent);
},
async restart(agentId) {
const agent = getAgent(agentId);
await agent.adapter.stop();
return this.retry(agentId);
},
async route(agentId, operation, payload) {
const agent = getAgent(agentId);
if (operation === "switch_session")
return switchSession(agent, payload.sessionPath);
if (operation === "new_session") return newSession(agent);
return routeCommand(agent.adapter, operation, payload, agent.state);
},
async stop() {
await Promise.all(
[...agentsById.values()].map(async (agent) => {
agent.stopped = true;
agent.supervisor.stop();
await agent.adapter.stop();
agent.state = "stopped";
}),
);
},
};
}
export const sessionDirectoryMode = SESSION_DIRECTORY_MODE;
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import { homedir } from "node:os";
import { runBridgeDaemon } from "./daemon.js";
function readFlag(args, name) {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
}
const args = process.argv.slice(2);
runBridgeDaemon({
homeWorktree: readFlag(args, "--worktree") ?? homedir(),
runtimeDir: readFlag(args, "--runtime-dir"),
sessionRoot: readFlag(args, "--session-root"),
}).catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.message : "bridge daemon failed"}\n`,
);
process.exitCode = 1;
});
+26
View File
@@ -0,0 +1,26 @@
import { startBridgeService } from "./service.js";
export async function startBridgeDaemon(options) {
return startBridgeService(options);
}
export async function runBridgeDaemon(options) {
const daemon = await startBridgeDaemon(options);
let stopping = false;
const stop = async () => {
if (stopping) return;
stopping = true;
await daemon.close();
};
process.stdout.write(
`${JSON.stringify({ socketPath: daemon.socketPath })}\n`,
);
await new Promise((resolve) => {
const shutdown = async () => {
await stop();
resolve();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
});
}
+126
View File
@@ -0,0 +1,126 @@
import { randomUUID } from "node:crypto";
import { chmod, open, readFile, unlink } from "node:fs/promises";
const LOCK_MODE = 0o600;
export class BridgeAlreadyRunningError extends Error {
constructor(metadata) {
super(`bridge instance is already running with pid ${metadata.pid}`);
this.name = "BridgeAlreadyRunningError";
this.metadata = metadata;
}
}
export class BridgeLockError extends Error {
constructor(message) {
super(message);
this.name = "BridgeLockError";
}
}
function parseLockMetadata(text, lockPath) {
let metadata;
try {
metadata = JSON.parse(text);
} catch {
throw new BridgeLockError(`lock file is unreadable: ${lockPath}`);
}
if (
metadata === null ||
typeof metadata !== "object" ||
!Number.isSafeInteger(metadata.pid) ||
metadata.pid < 1 ||
typeof metadata.token !== "string" ||
metadata.token.length < 1 ||
typeof metadata.socketPath !== "string" ||
metadata.socketPath.length < 1 ||
typeof metadata.startedAt !== "string" ||
Number.isNaN(Date.parse(metadata.startedAt))
) {
throw new BridgeLockError(`lock file has an invalid format: ${lockPath}`);
}
return metadata;
}
export function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (error?.code === "ESRCH") return false;
if (error?.code === "EPERM") return true;
throw error;
}
}
async function readLockMetadata(lockPath) {
try {
return parseLockMetadata(await readFile(lockPath, "utf8"), lockPath);
} catch (error) {
if (error?.code === "ENOENT") return undefined;
throw error;
}
}
export async function acquireBridgeLock({
lockPath,
socketPath,
pid = process.pid,
now = () => new Date(),
processAlive = isProcessAlive,
}) {
if (!lockPath || !socketPath)
throw new TypeError("lockPath and socketPath are required");
const metadata = {
pid,
socketPath,
startedAt: now().toISOString(),
token: randomUUID(),
};
for (let attempt = 0; attempt < 2; attempt += 1) {
let handle;
let createdLock = false;
try {
handle = await open(lockPath, "wx", LOCK_MODE);
createdLock = true;
await handle.writeFile(`${JSON.stringify(metadata)}\n`, "utf8");
await handle.sync();
await chmod(lockPath, LOCK_MODE);
await handle.close();
return {
metadata,
async release() {
const current = await readLockMetadata(lockPath);
if (current?.token !== metadata.token) return false;
await unlink(lockPath);
return true;
},
};
} catch (error) {
await handle?.close().catch(() => {});
if (createdLock) {
await unlink(lockPath).catch(() => {});
throw error;
}
if (error?.code !== "EEXIST") throw error;
const existing = await readLockMetadata(lockPath);
if (!existing) continue;
if (processAlive(existing.pid))
throw new BridgeAlreadyRunningError(existing);
// Only a lock with a readable PID that has exited may be reclaimed.
await unlink(lockPath).catch((unlinkError) => {
if (unlinkError?.code !== "ENOENT") throw unlinkError;
});
}
}
throw new BridgeLockError(
`could not acquire lock after stale-owner recovery: ${lockPath}`,
);
}
export const lockMode = LOCK_MODE;
+367
View File
@@ -0,0 +1,367 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import path from "node:path";
import { StringDecoder } from "node:string_decoder";
export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
export const MAX_PI_RPC_FRAME_BYTES = 1024 * 1024;
const supportedCommands = new Set([
"prompt",
"steer",
"follow_up",
"abort",
"get_state",
"get_session_stats",
"new_session",
"switch_session",
"get_messages",
"get_available_models",
"get_commands",
"set_model",
"set_thinking_level",
]);
export class PiRpcError extends Error {
constructor(code, message) {
super(message);
this.name = "PiRpcError";
this.code = code;
}
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function assertAbsolutePath(value, field) {
if (typeof value !== "string" || !path.isAbsolute(value)) {
throw new TypeError(`${field} must be an absolute path`);
}
}
function emitSafely(callback, value) {
try {
callback(value);
} catch {
// Adapter observers must not interrupt the RPC reader.
}
}
function normalizePiEvent(event) {
switch (event.type) {
case "agent_start":
return { type: "agent_state", data: { state: "streaming", event } };
case "agent_end":
// A low-level run can be followed by compaction, retry, or queued work.
// Only agent_settled is an authoritative idle transition.
return { type: "transcript", data: { event } };
case "agent_settled":
return { type: "agent_state", data: { state: "idle", event } };
case "turn_start":
case "turn_end":
case "message_start":
case "message_end":
return { type: "transcript", data: { event } };
case "message_update":
return { type: "stream", data: { event } };
case "tool_execution_start":
case "tool_execution_update":
case "tool_execution_end":
return { type: "tool", data: { event } };
case "queue_update":
return { type: "queue", data: { event } };
case "compaction_start":
case "compaction_end":
case "auto_retry_start":
case "auto_retry_end":
return { type: "recovery", data: { event } };
case "extension_ui_request":
return { type: "extension_ui_request", data: { event } };
case "extension_error":
// Extension errors are diagnostics; the Pi process can still be healthy.
return { type: "transcript", data: { event } };
default:
return undefined;
}
}
function parseFrame(line, onError) {
try {
const value = JSON.parse(line);
if (!isRecord(value) || typeof value.type !== "string") {
throw new PiRpcError(
"invalid_message",
"Pi RPC frame must be an object with a type",
);
}
return value;
} catch (error) {
onError(
error instanceof PiRpcError
? error
: new PiRpcError("invalid_json", "Pi RPC emitted invalid JSON"),
);
return undefined;
}
}
function serializeFrame(value) {
try {
return `${JSON.stringify(value)}\n`;
} catch {
throw new PiRpcError(
"invalid_command",
"Pi RPC command must be JSON-serializable",
);
}
}
export function startPiRpcAdapter({
cwd,
sessionDir,
sessionPath,
command = "pi",
spawnProcess = spawn,
onEvent = () => {},
onError = () => {},
commandTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
maxFrameBytes = MAX_PI_RPC_FRAME_BYTES,
}) {
assertAbsolutePath(cwd, "cwd");
assertAbsolutePath(sessionDir, "sessionDir");
if (sessionPath !== undefined) assertAbsolutePath(sessionPath, "sessionPath");
if (typeof spawnProcess !== "function")
throw new TypeError("spawnProcess must be a function");
const child = spawnProcess(
command,
[
"--mode",
"rpc",
"--session-dir",
sessionDir,
...(sessionPath ? ["--session", sessionPath] : []),
],
{
cwd,
stdio: ["pipe", "pipe", "pipe"],
},
);
if (!child?.stdin || !child.stdout || !child.stderr) {
throw new PiRpcError(
"spawn_failed",
"Pi RPC child must expose stdin, stdout, and stderr streams",
);
}
let sequence = 0;
let stdoutBuffer = "";
let closed = false;
let intentionalStop = false;
const decoder = new StringDecoder("utf8");
const pending = new Map();
let resolveExit;
const exited = new Promise((resolve) => {
resolveExit = resolve;
});
const reportError = (error) => emitSafely(onError, error);
const rejectPending = (error) => {
for (const entry of pending.values()) {
clearTimeout(entry.timeout);
entry.reject(error);
}
pending.clear();
};
const finish = ({ code, signal, error }) => {
if (closed) return;
closed = true;
const terminalError =
error ??
new PiRpcError(
"child_exited",
`Pi RPC child exited before responding (code ${code ?? "null"}, signal ${signal ?? "none"})`,
);
rejectPending(terminalError);
if (!intentionalStop) reportError(terminalError);
resolveExit({ code, signal });
};
const handleResponse = (response) => {
if (typeof response.id !== "string") {
reportError(
new PiRpcError(
"uncorrelated_response",
"Pi RPC response did not include an id",
),
);
return;
}
const entry = pending.get(response.id);
if (!entry) {
reportError(
new PiRpcError(
"uncorrelated_response",
`Pi RPC response has no pending command: ${response.id}`,
),
);
return;
}
clearTimeout(entry.timeout);
pending.delete(response.id);
entry.resolve(response);
};
const handleEvent = (event) => {
const normalized = normalizePiEvent(event);
if (!normalized) {
reportError(
new PiRpcError(
"unsupported_event",
`Pi RPC emitted unsupported event: ${event.type}`,
),
);
return;
}
emitSafely(onEvent, { seq: ++sequence, ...normalized });
};
const handleLine = (line) => {
const frame = parseFrame(
line.endsWith("\r") ? line.slice(0, -1) : line,
reportError,
);
if (!frame) return;
if (frame.type === "response") handleResponse(frame);
else handleEvent(frame);
};
child.stdout.on("data", (chunk) => {
if (closed) return;
stdoutBuffer += decoder.write(chunk);
let newlineIndex;
while ((newlineIndex = stdoutBuffer.indexOf("\n")) !== -1) {
const line = stdoutBuffer.slice(0, newlineIndex);
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
handleLine(line);
}
if (Buffer.byteLength(stdoutBuffer, "utf8") > maxFrameBytes) {
reportError(
new PiRpcError(
"frame_too_large",
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
),
);
stdoutBuffer = "";
}
});
child.stdout.on("end", () => {
if (closed) return;
const tail = stdoutBuffer + decoder.end();
stdoutBuffer = "";
if (tail.length > 0)
reportError(
new PiRpcError(
"unterminated_frame",
"Pi RPC stdout ended without an LF-terminated frame",
),
);
});
child.stderr.on("data", () => {});
child.stderr.on("error", (error) =>
reportError(new PiRpcError("stderr_error", error.message)),
);
child.on("error", (error) =>
finish({
code: null,
signal: null,
error: new PiRpcError("child_error", error.message),
}),
);
child.on("exit", (code, signal) => finish({ code, signal }));
function write(value) {
if (closed || child.stdin.destroyed)
throw new PiRpcError("child_exited", "Pi RPC child is not available");
child.stdin.write(serializeFrame(value));
}
return {
child,
get sequence() {
return sequence;
},
send(commandInput) {
if (!isRecord(commandInput) || typeof commandInput.type !== "string") {
return Promise.reject(
new PiRpcError(
"invalid_command",
"Pi RPC command must include a type",
),
);
}
if (Object.hasOwn(commandInput, "id")) {
return Promise.reject(
new PiRpcError(
"invalid_command",
"command IDs are assigned by the adapter",
),
);
}
if (!supportedCommands.has(commandInput.type)) {
return Promise.reject(
new PiRpcError(
"unsupported_command",
`Pi RPC command is not supported: ${commandInput.type}`,
),
);
}
const id = `bridge-${randomUUID()}`;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id);
reject(
new PiRpcError(
"command_timeout",
`Pi RPC command timed out: ${commandInput.type}`,
),
);
}, commandTimeoutMs);
timeout.unref?.();
pending.set(id, { resolve, reject, timeout });
try {
write({ ...commandInput, id });
} catch (error) {
clearTimeout(timeout);
pending.delete(id);
reject(error);
}
});
},
respondToExtension(requestId, response) {
if (
typeof requestId !== "string" ||
requestId.length === 0 ||
!isRecord(response)
) {
throw new PiRpcError(
"invalid_extension_response",
"extension response requires a request id and response object",
);
}
// Pi uses this id to resume the blocked extension dialog; it cannot be replaced by a command ID.
write({ type: "extension_ui_response", id: requestId, ...response });
},
async stop() {
if (closed) return exited;
intentionalStop = true;
rejectPending(
new PiRpcError(
"child_stopped",
"Pi RPC child was stopped by the bridge",
),
);
child.kill("SIGTERM");
return exited;
},
};
}
+76
View File
@@ -0,0 +1,76 @@
export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3;
export function exponentialBackoff(
attempt,
baseDelayMs = 1_000,
maxDelayMs = 30_000,
) {
return Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
}
export function createRecoverySupervisor({
start,
maxAttempts = DEFAULT_MAX_RECOVERY_ATTEMPTS,
delayForAttempt = (attempt) => exponentialBackoff(attempt),
sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)),
onState = () => {},
}) {
if (typeof start !== "function")
throw new TypeError("start must be a function");
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1)
throw new TypeError("maxAttempts must be a positive integer");
let attempts = 0;
let stopped = false;
let recovery;
const emit = (state) => {
try {
onState(state);
} catch {
// Observers cannot interrupt recovery.
}
};
async function recover() {
while (!stopped && attempts < maxAttempts) {
attempts += 1;
const attempt = attempts;
const delayMs = delayForAttempt(attempt);
emit({ state: "recovering", attempt, delayMs });
await sleep(delayMs);
if (stopped) return undefined;
try {
const resource = await start();
if (stopped) return undefined;
emit({ state: "healthy", attempt });
return resource;
} catch (error) {
emit({ state: "recovery_error", attempt, error });
}
}
if (!stopped) emit({ state: "failed", attempt: attempts });
return undefined;
}
return {
get attempts() {
return attempts;
},
async handleUnexpectedExit() {
if (stopped) return undefined;
if (!recovery) {
recovery = recover().finally(() => {
recovery = undefined;
});
}
return recovery;
},
markHealthy() {
attempts = 0;
},
stop() {
stopped = true;
},
};
}
+70
View File
@@ -0,0 +1,70 @@
import { chmod, lstat, mkdir, unlink } from "node:fs/promises";
import path from "node:path";
import { acquireBridgeLock } from "./instance-lock.js";
import { startUnixSocketServer } from "./unix-server.js";
const RUNTIME_DIRECTORY_MODE = 0o700;
export async function createRuntimePaths(
runtimeDir = process.env.XDG_RUNTIME_DIR,
) {
if (!runtimeDir || !path.isAbsolute(runtimeDir)) {
throw new Error("XDG_RUNTIME_DIR must be an absolute path");
}
const directory = path.join(runtimeDir, "pi-status-bridge");
await mkdir(directory, { recursive: true, mode: RUNTIME_DIRECTORY_MODE });
await chmod(directory, RUNTIME_DIRECTORY_MODE);
return {
directory,
socketPath: path.join(directory, "bridge.sock"),
lockPath: path.join(directory, "bridge.lock"),
};
}
async function removeStaleSocket(socketPath) {
try {
const socket = await lstat(socketPath);
if (!socket.isSocket())
throw new Error(`refusing to remove non-socket path: ${socketPath}`);
await unlink(socketPath);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
export async function startBridgeServer({
handleRequest,
subscribe,
runtimeDir,
processAlive,
maxFrameBytes,
}) {
const paths = await createRuntimePaths(runtimeDir);
const lock = await acquireBridgeLock({
lockPath: paths.lockPath,
socketPath: paths.socketPath,
...(processAlive ? { processAlive } : {}),
});
try {
await removeStaleSocket(paths.socketPath);
const socketServer = await startUnixSocketServer({
socketPath: paths.socketPath,
handleRequest,
...(subscribe ? { subscribe } : {}),
...(maxFrameBytes ? { maxFrameBytes } : {}),
});
return {
...paths,
async close() {
await socketServer.close();
await lock.release();
},
};
} catch (error) {
await lock.release();
throw error;
}
}
export const runtimeDirectoryMode = RUNTIME_DIRECTORY_MODE;
+88
View File
@@ -0,0 +1,88 @@
import path from "node:path";
import { createAgentRegistry, UnknownAgentError } from "./agent-registry.js";
import { startPiRpcAdapter } from "./pi-rpc-adapter.js";
import { ProtocolError } from "../protocol/index.js";
import { createRuntimePaths, startBridgeServer } from "./runtime.js";
export async function startBridgeService({
homeWorktree,
runtimeDir,
sessionRoot,
startAdapter = startPiRpcAdapter,
processAlive,
maxFrameBytes,
}) {
const paths = await createRuntimePaths(runtimeDir);
const registry = createAgentRegistry({
homeWorktree,
sessionRoot: sessionRoot ?? path.join(paths.directory, "sessions"),
startAdapter,
});
const dispatch = async (request) => {
try {
switch (request.op) {
case "list_agents":
return { agents: registry.listAgents() };
case "list_directories":
return { directories: await registry.listDirectories() };
case "select_agent":
return {
agent: await registry.selectWorktree(request.payload.worktreePath),
};
case "subscribe":
return {
events: registry.eventsAfter(
request.agentId,
request.payload.cursor ?? 0,
),
};
case "retry":
return { agent: await registry.retry(request.agentId) };
case "restart":
return { agent: await registry.restart(request.agentId) };
case "list_sessions":
return { sessions: await registry.listSessions(request.agentId) };
default:
return registry.route(request.agentId, request.op, request.payload);
}
} catch (error) {
if (error instanceof UnknownAgentError)
throw new ProtocolError("unknown_agent", error.message);
throw error;
}
};
const server = await startBridgeServer({
runtimeDir,
handleRequest: dispatch,
subscribe: (request, notify) => {
const subscription = registry.subscribe(
request.agentId,
request.payload.cursor ?? 0,
notify,
);
return {
result: { events: subscription.events },
unsubscribe: subscription.unsubscribe,
};
},
...(processAlive ? { processAlive } : {}),
...(maxFrameBytes ? { maxFrameBytes } : {}),
});
try {
await registry.start();
} catch (error) {
await server.close();
throw error;
}
return {
socketPath: server.socketPath,
listAgents: () => registry.listAgents(),
async close() {
await registry.stop();
await server.close();
},
};
}
+164
View File
@@ -0,0 +1,164 @@
import { createServer } from "node:net";
import { StringDecoder } from "node:string_decoder";
import { chmod, lstat, unlink } from "node:fs/promises";
import {
MAX_FRAME_BYTES,
ProtocolError,
encodeFrame,
errorResponse,
parseRequestFrame,
successResponse,
} from "../protocol/index.js";
const OWNER_ONLY_MODE = 0o600;
function isMissing(error) {
return error && error.code === "ENOENT";
}
async function assertUnusedSocketPath(socketPath) {
try {
const stats = await lstat(socketPath);
if (stats.isSocket())
throw new Error(`socket path is already in use: ${socketPath}`);
throw new Error(`refusing to replace non-socket path: ${socketPath}`);
} catch (error) {
if (!isMissing(error)) throw error;
}
}
function requestIdFromLine(line) {
try {
const value = JSON.parse(line);
return typeof value?.id === "string" ? value.id : undefined;
} catch {
return undefined;
}
}
function write(socket, frame) {
if (!socket.destroyed) socket.write(encodeFrame(frame));
}
export async function startUnixSocketServer({
socketPath,
handleRequest,
subscribe,
maxFrameBytes = MAX_FRAME_BYTES,
}) {
if (typeof socketPath !== "string" || !socketPath.startsWith("/")) {
throw new Error("socketPath must be an absolute Unix-socket path");
}
if (typeof handleRequest !== "function")
throw new TypeError("handleRequest must be a function");
if (subscribe !== undefined && typeof subscribe !== "function")
throw new TypeError("subscribe must be a function");
await assertUnusedSocketPath(socketPath);
const sockets = new Set();
const server = createServer((socket) => {
sockets.add(socket);
socket.on("error", () => {});
const decoder = new StringDecoder("utf8");
const cleanup = new Set();
let pending = "";
let closedForFrameLimit = false;
socket.on("close", () => {
sockets.delete(socket);
for (const unsubscribe of cleanup) unsubscribe();
cleanup.clear();
});
const handleSubscription = async (request) => {
const queued = [];
let ready = false;
const subscription = await subscribe(request, (event) => {
if (ready) write(socket, { version: "v1", type: "event", event });
else queued.push(event);
});
cleanup.add(subscription.unsubscribe);
write(socket, successResponse(request.id, subscription.result));
ready = true;
for (const event of queued)
write(socket, { version: "v1", type: "event", event });
};
const handleLine = async (line) => {
const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
try {
const request = parseRequestFrame(normalized, maxFrameBytes);
if (request.op === "subscribe" && subscribe) {
await handleSubscription(request);
return;
}
const result = await handleRequest(request);
write(socket, successResponse(request.id, result));
} catch (error) {
write(socket, errorResponse(requestIdFromLine(normalized), error));
}
};
socket.on("data", (chunk) => {
if (closedForFrameLimit) return;
pending += decoder.write(chunk);
let newline;
while ((newline = pending.indexOf("\n")) !== -1) {
const line = pending.slice(0, newline);
pending = pending.slice(newline + 1);
void handleLine(line);
}
if (Buffer.byteLength(pending, "utf8") > maxFrameBytes) {
closedForFrameLimit = true;
write(
socket,
errorResponse(
undefined,
new ProtocolError(
"frame_too_large",
`frame exceeds ${maxFrameBytes} bytes`,
),
),
);
socket.end();
}
});
socket.on("end", () => {
if (closedForFrameLimit) return;
const finalLine = pending + decoder.end();
if (finalLine.length > 0) void handleLine(finalLine);
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, () => {
server.off("error", reject);
resolve();
});
});
try {
await chmod(socketPath, OWNER_ONLY_MODE);
} catch (error) {
await new Promise((resolve) => server.close(resolve));
await unlink(socketPath).catch(() => {});
throw error;
}
return {
socketPath,
async close() {
for (const socket of sockets) socket.destroy();
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
await unlink(socketPath).catch((error) => {
if (!isMissing(error)) throw error;
});
},
};
}
export const socketMode = OWNER_ONLY_MODE;
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env node
import { connectLocalClient } from "./local-client.js";
function usage() {
return [
"Usage:",
" pi-status-bridge-client --socket <path> request '<json-request>'",
" pi-status-bridge-client --socket <path> subscribe --agent <agent-id> [--cursor <seq>]",
].join("\n");
}
function fail(message) {
process.stderr.write(`${message}\n`);
process.exitCode = 1;
}
function flag(args, name) {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
}
async function requestOnce(client, rawRequest) {
let request;
try {
request = JSON.parse(rawRequest);
} catch {
throw new Error("request must be valid JSON");
}
if (
!request ||
typeof request !== "object" ||
Array.isArray(request) ||
typeof request.op !== "string"
) {
throw new Error("request must be an object with an op");
}
const result = await client.request(request.op, {
...(request.agentId ? { agentId: request.agentId } : {}),
...(request.payload === undefined ? {} : { payload: request.payload }),
});
process.stdout.write(`${JSON.stringify(result)}\n`);
}
async function streamSubscription(client, args) {
const agentId = flag(args, "--agent");
const cursorValue = flag(args, "--cursor");
if (!agentId) throw new Error(usage());
const cursor =
cursorValue === undefined ? 0 : Number.parseInt(cursorValue, 10);
if (!Number.isSafeInteger(cursor) || cursor < 0)
throw new Error("cursor must be a non-negative integer");
const unsubscribe = await client.subscribe(agentId, cursor, (event) => {
process.stdout.write(`${JSON.stringify(event)}\n`);
});
await new Promise((resolve) => {
const stop = () => resolve();
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
});
unsubscribe();
}
async function main(args) {
const socketPath =
flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
const remaining = args.filter(
(_, index) => args[index - 1] !== "--socket" && args[index] !== "--socket",
);
if (!socketPath) throw new Error(usage());
const client = await connectLocalClient({ socketPath });
try {
if (remaining[0] === "request" && remaining[1] && remaining.length === 2) {
await requestOnce(client, remaining[1]);
return;
}
if (remaining[0] === "subscribe") {
await streamSubscription(client, remaining.slice(1));
return;
}
throw new Error(usage());
} finally {
await client.close();
}
}
main(process.argv.slice(2)).catch((error) =>
fail(error instanceof Error ? error.message : "client failed"),
);
+166
View File
@@ -0,0 +1,166 @@
import { randomUUID } from "node:crypto";
import { createConnection } from "node:net";
import path from "node:path";
import { StringDecoder } from "node:string_decoder";
import {
MAX_FRAME_BYTES,
ProtocolError,
validateRequest,
} from "../protocol/index.js";
export class LocalClientError extends Error {
constructor(code, message) {
super(message);
this.name = "LocalClientError";
this.code = code;
}
}
function frame(value) {
return `${JSON.stringify(value)}\n`;
}
export async function connectLocalClient({
socketPath,
maxFrameBytes = MAX_FRAME_BYTES,
}) {
if (typeof socketPath !== "string" || !path.isAbsolute(socketPath)) {
throw new TypeError("socketPath must be an absolute Unix-socket path");
}
const socket = await new Promise((resolve, reject) => {
const connection = createConnection(socketPath);
connection.once("connect", () => resolve(connection));
connection.once("error", reject);
});
const decoder = new StringDecoder("utf8");
const pending = new Map();
const subscriptions = new Map();
let buffer = "";
let closed = false;
const rejectPending = (error) => {
for (const { reject } of pending.values()) reject(error);
pending.clear();
};
const dispatchEvent = (event) => {
const subscription = subscriptions.get(event.agentId);
if (!subscription) return;
if (subscription.ready) subscription.listener(event);
else subscription.queued.push(event);
};
const dispatchLine = (line) => {
let message;
try {
message = JSON.parse(line.endsWith("\r") ? line.slice(0, -1) : line);
} catch {
rejectPending(
new LocalClientError("invalid_json", "bridge emitted invalid JSON"),
);
return;
}
if (message?.type === "event" && message.event) {
dispatchEvent(message.event);
return;
}
if (typeof message?.id !== "string") return;
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
if (message.ok) request.resolve(message.result);
else
request.reject(
new LocalClientError(
message.error?.code ?? "bridge_error",
message.error?.message ?? "bridge request failed",
),
);
};
socket.on("data", (chunk) => {
if (closed) return;
buffer += decoder.write(chunk);
let index;
while ((index = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 1);
dispatchLine(line);
}
if (Buffer.byteLength(buffer, "utf8") > maxFrameBytes) {
rejectPending(
new LocalClientError(
"frame_too_large",
`bridge frame exceeds ${maxFrameBytes} bytes`,
),
);
socket.destroy();
}
});
socket.on("error", (error) =>
rejectPending(new LocalClientError("socket_error", error.message)),
);
socket.on("close", () => {
closed = true;
rejectPending(
new LocalClientError("socket_closed", "bridge socket closed"),
);
});
const request = (op, { agentId, payload } = {}) => {
if (closed || socket.destroyed)
return Promise.reject(
new LocalClientError("socket_closed", "bridge socket is closed"),
);
const id = `client-${randomUUID()}`;
let requestFrame;
try {
requestFrame = validateRequest({
version: "v1",
id,
op,
...(agentId ? { agentId } : {}),
...(payload === undefined ? {} : { payload }),
});
} catch (error) {
return Promise.reject(
error instanceof ProtocolError
? error
: new LocalClientError("invalid_request", "invalid bridge request"),
);
}
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
socket.write(frame(requestFrame));
});
};
return {
request,
async subscribe(agentId, cursor, listener) {
if (typeof listener !== "function")
throw new TypeError("listener must be a function");
const subscription = { listener, queued: [], ready: false };
subscriptions.set(agentId, subscription);
try {
const result = await request("subscribe", {
agentId,
payload: cursor === undefined ? {} : { cursor },
});
for (const event of result.events) listener(event);
subscription.ready = true;
for (const event of subscription.queued) listener(event);
subscription.queued = [];
} catch (error) {
subscriptions.delete(agentId);
throw error;
}
return () => subscriptions.delete(agentId);
},
async close() {
if (closed) return;
closed = true;
socket.end();
socket.destroy();
},
};
}
+35
View File
@@ -0,0 +1,35 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export async function publishNoctaliaState(
state,
{ execute = async (command, args) => execFileAsync(command, args) } = {},
) {
if (!state || typeof state !== "object")
throw new TypeError("state is required");
const { state: bridgeState, projectLabel, attentionCount, detail } = state;
if (
typeof bridgeState !== "string" ||
typeof projectLabel !== "string" ||
!Number.isSafeInteger(attentionCount) ||
typeof detail !== "string"
) {
throw new TypeError(
"state must contain string state/projectLabel/detail and integer attentionCount",
);
}
await execute("qs", [
"-c",
"noctalia-shell",
"ipc",
"call",
"plugin:pi-status-bridge",
"setBridgeState",
bridgeState,
projectLabel,
String(attentionCount),
detail,
]);
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
import { connectLocalClient } from "./local-client.js";
import { publishNoctaliaState } from "./noctalia-ipc.js";
import { createNoctaliaStateRelay } from "./noctalia-relay.js";
function flag(args, name) {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
}
async function run() {
const args = process.argv.slice(2);
const socketPath =
flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
const agentId = flag(args, "--agent");
if (!socketPath || !agentId)
throw new Error(
"Usage: pi-status-bridge-noctalia-relay --socket <path> --agent <id>",
);
const client = await connectLocalClient({ socketPath });
const agents = await client.request("list_agents");
const agent = agents.agents.find((candidate) => candidate.id === agentId);
if (!agent) throw new Error(`unknown agent: ${agentId}`);
const relay = createNoctaliaStateRelay({
client,
agent,
onState: (state) => {
void publishNoctaliaState(state).catch((error) =>
process.stderr.write(`${error.message}\n`),
);
},
});
await relay.start();
await new Promise((resolve) => {
const shutdown = () => resolve();
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
});
relay.stop();
await client.close();
}
run().catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`,
);
process.exitCode = 1;
});
+60
View File
@@ -0,0 +1,60 @@
import path from "node:path";
function detailFor(state) {
switch (state) {
case "streaming":
return "Streaming";
case "recovering":
return "Recovering Pi session";
case "failed":
return "Recovery failed";
case "error":
return "Bridge error";
default:
return "Idle";
}
}
export function createNoctaliaStateRelay({ client, agent, onState }) {
if (
!client ||
typeof client.request !== "function" ||
typeof client.subscribe !== "function"
)
throw new TypeError("client must support request and subscribe");
if (!agent?.id || !agent?.worktreePath)
throw new TypeError("agent id and worktreePath are required");
if (typeof onState !== "function")
throw new TypeError("onState must be a function");
let state = "idle";
let attentionCount = 0;
let unsubscribe = () => {};
const projectLabel = path.basename(agent.worktreePath) || agent.worktreePath;
const publish = () =>
onState({ state, projectLabel, attentionCount, detail: detailFor(state) });
const handleEvent = (event) => {
if (event.type === "agent_state" && typeof event.data?.state === "string")
state = event.data.state;
if (event.type === "queue")
attentionCount =
(event.data?.event?.steering?.length ?? 0) +
(event.data?.event?.followUp?.length ?? 0);
if (event.type === "extension_ui_request")
attentionCount = Math.max(attentionCount, 1);
publish();
};
return {
async start() {
const response = await client.request("get_state", { agentId: agent.id });
state = response?.data?.isStreaming ? "streaming" : "idle";
publish();
unsubscribe = await client.subscribe(agent.id, 0, handleEvent);
},
stop() {
unsubscribe();
unsubscribe = () => {};
},
};
}
+341
View File
@@ -0,0 +1,341 @@
import path from "node:path";
export const PROTOCOL_VERSION = "v1";
export const MAX_FRAME_BYTES = 64 * 1024;
const requestOperations = new Map([
["list_agents", { agent: false, payload: "none" }],
["list_directories", { agent: false, payload: "none" }],
["select_agent", { agent: false, payload: "worktree" }],
["get_state", { agent: true, payload: "none" }],
["get_session_stats", { agent: true, payload: "none" }],
["list_sessions", { agent: true, payload: "none" }],
["new_session", { agent: true, payload: "none" }],
["switch_session", { agent: true, payload: "session" }],
["get_transcript", { agent: true, payload: "none" }],
["subscribe", { agent: true, payload: "cursor" }],
["prompt", { agent: true, payload: "message" }],
["submit_prompt", { agent: true, payload: "message" }],
["steer", { agent: true, payload: "message" }],
["follow_up", { agent: true, payload: "message" }],
["abort", { agent: true, payload: "none" }],
["extension_response", { agent: true, payload: "extensionResponse" }],
["retry", { agent: true, payload: "none" }],
["restart", { agent: true, payload: "none" }],
["get_available_models", { agent: true, payload: "none" }],
["get_commands", { agent: true, payload: "none" }],
["set_model", { agent: true, payload: "model" }],
["set_thinking_level", { agent: true, payload: "thinking" }],
]);
const eventTypes = new Set([
"agent_state",
"transcript",
"stream",
"tool",
"queue",
"recovery",
"extension_ui_request",
]);
const thinkingLevels = new Set([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]);
const idPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
export class ProtocolError extends Error {
constructor(code, message) {
super(message);
this.name = "ProtocolError";
this.code = code;
}
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function assertRecord(value, field) {
if (!isRecord(value))
throw new ProtocolError("invalid_message", `${field} must be an object`);
return value;
}
function assertAllowedKeys(value, allowed, field) {
for (const key of Object.keys(value)) {
if (!allowed.has(key))
throw new ProtocolError(
"invalid_message",
`${field}.${key} is not supported`,
);
}
}
function assertString(value, field, { maxLength = 4096, pattern } = {}) {
if (
typeof value !== "string" ||
value.length === 0 ||
value.length > maxLength
) {
throw new ProtocolError(
"invalid_message",
`${field} must be a non-empty string up to ${maxLength} characters`,
);
}
if (pattern && !pattern.test(value))
throw new ProtocolError(
"invalid_message",
`${field} has an invalid format`,
);
return value;
}
function assertOptionalCursor(value) {
if (value === undefined) return undefined;
if (Number.isSafeInteger(value) && value >= 0) return value;
return assertString(value, "payload.cursor", {
maxLength: 256,
pattern: idPattern,
});
}
function validatePayload(kind, value) {
if (kind === "none") {
if (value !== undefined)
throw new ProtocolError(
"invalid_message",
"payload is not allowed for this operation",
);
return undefined;
}
const payload = assertRecord(value, "payload");
switch (kind) {
case "worktree": {
assertAllowedKeys(payload, new Set(["worktreePath"]), "payload");
const worktreePath = assertString(
payload.worktreePath,
"payload.worktreePath",
{ maxLength: 4096 },
);
if (!path.isAbsolute(worktreePath))
throw new ProtocolError(
"invalid_message",
"payload.worktreePath must be absolute",
);
return { worktreePath };
}
case "session": {
assertAllowedKeys(payload, new Set(["sessionPath"]), "payload");
const sessionPath = assertString(
payload.sessionPath,
"payload.sessionPath",
{
maxLength: 4096,
},
);
if (!path.isAbsolute(sessionPath))
throw new ProtocolError(
"invalid_message",
"payload.sessionPath must be absolute",
);
return { sessionPath };
}
case "cursor": {
assertAllowedKeys(payload, new Set(["cursor"]), "payload");
return {
...(payload.cursor === undefined
? {}
: { cursor: assertOptionalCursor(payload.cursor) }),
};
}
case "message": {
assertAllowedKeys(payload, new Set(["message"]), "payload");
return {
message: assertString(payload.message, "payload.message", {
maxLength: 32 * 1024,
}),
};
}
case "extensionResponse": {
assertAllowedKeys(payload, new Set(["requestId", "response"]), "payload");
const response = assertRecord(payload.response, "payload.response");
if (
!Object.hasOwn(response, "value") &&
!Object.hasOwn(response, "confirmed") &&
!Object.hasOwn(response, "cancelled")
) {
throw new ProtocolError(
"invalid_message",
"payload.response must contain value, confirmed, or cancelled",
);
}
return {
requestId: assertString(payload.requestId, "payload.requestId", {
maxLength: 128,
pattern: idPattern,
}),
response,
};
}
case "model": {
assertAllowedKeys(payload, new Set(["provider", "modelId"]), "payload");
return {
provider: assertString(payload.provider, "payload.provider", {
maxLength: 128,
}),
modelId: assertString(payload.modelId, "payload.modelId", {
maxLength: 512,
}),
};
}
case "thinking": {
assertAllowedKeys(payload, new Set(["level"]), "payload");
const level = assertString(payload.level, "payload.level", {
maxLength: 16,
});
if (!thinkingLevels.has(level))
throw new ProtocolError(
"invalid_message",
"payload.level is not supported",
);
return { level };
}
default:
throw new ProtocolError("invalid_message", "unsupported payload shape");
}
}
export function validateRequest(value) {
const request = assertRecord(value, "request");
assertAllowedKeys(
request,
new Set(["version", "id", "op", "agentId", "payload"]),
"request",
);
if (request.version !== PROTOCOL_VERSION) {
throw new ProtocolError(
"unsupported_version",
`version must be ${PROTOCOL_VERSION}`,
);
}
const id = assertString(request.id, "request.id", {
maxLength: 128,
pattern: idPattern,
});
const op = assertString(request.op, "request.op", { maxLength: 64 });
const operation = requestOperations.get(op);
if (!operation)
throw new ProtocolError(
"unsupported_operation",
`operation ${op} is not supported`,
);
let agentId;
if (operation.agent) {
agentId = assertString(request.agentId, "request.agentId", {
maxLength: 128,
pattern: idPattern,
});
} else if (request.agentId !== undefined) {
throw new ProtocolError(
"invalid_message",
"request.agentId is not allowed for this operation",
);
}
const payload = validatePayload(operation.payload, request.payload);
return {
version: PROTOCOL_VERSION,
id,
op,
...(agentId ? { agentId } : {}),
...(payload === undefined ? {} : { payload }),
};
}
export function parseRequestFrame(frame, maxFrameBytes = MAX_FRAME_BYTES) {
const bytes = Buffer.isBuffer(frame)
? frame.length
: Buffer.byteLength(frame, "utf8");
if (bytes > maxFrameBytes)
throw new ProtocolError(
"frame_too_large",
`frame exceeds ${maxFrameBytes} bytes`,
);
const line = String(frame).endsWith("\r")
? String(frame).slice(0, -1)
: String(frame);
if (line.length === 0)
throw new ProtocolError("invalid_message", "frame cannot be empty");
let parsed;
try {
parsed = JSON.parse(line);
} catch {
throw new ProtocolError("invalid_json", "frame must contain valid JSON");
}
return validateRequest(parsed);
}
export function validateEvent(value) {
const event = assertRecord(value, "event");
assertAllowedKeys(
event,
new Set(["version", "seq", "type", "agentId", "data"]),
"event",
);
if (event.version !== PROTOCOL_VERSION)
throw new ProtocolError(
"unsupported_version",
`version must be ${PROTOCOL_VERSION}`,
);
if (!Number.isSafeInteger(event.seq) || event.seq < 1)
throw new ProtocolError(
"invalid_message",
"event.seq must be a positive integer",
);
const type = assertString(event.type, "event.type", { maxLength: 64 });
if (!eventTypes.has(type))
throw new ProtocolError(
"unsupported_event",
`event ${type} is not supported`,
);
return {
version: PROTOCOL_VERSION,
seq: event.seq,
type,
agentId: assertString(event.agentId, "event.agentId", {
maxLength: 128,
pattern: idPattern,
}),
data: assertRecord(event.data, "event.data"),
};
}
export function successResponse(id, result = {}) {
return { version: PROTOCOL_VERSION, id, ok: true, result };
}
export function errorResponse(id, error) {
const code = error instanceof ProtocolError ? error.code : "internal_error";
const message =
error instanceof Error ? error.message : "internal bridge error";
return {
version: PROTOCOL_VERSION,
...(id ? { id } : {}),
ok: false,
error: { code, message },
};
}
export function encodeFrame(value) {
return `${JSON.stringify(value)}\n`;
}
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Pi Status Bridge home agent
After=graphical-session.target
[Service]
Type=simple
ExecStart=/home/alex/.npm-global/bin/pi-status-bridge --session-root %h/.local/state/pi-status-bridge/sessions
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
+46
View File
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createAgentRegistry } from "../src/bridge/agent-registry.js";
test("recovers an exited agent with its last Pi session reference", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-recovery-"));
const home = join(root, "home");
const sessionRoot = join(root, "sessions");
await mkdir(home);
const sessionFile = join(sessionRoot, "pi-session.jsonl");
const calls = [];
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot,
recoveryDelayForAttempt: () => 0,
sleep: async () => {},
startAdapter: (options) => {
const adapter = {
send: async () => ({
type: "response",
success: true,
data: { sessionFile },
}),
respondToExtension: () => {},
stop: async () => {},
};
calls.push({ options, adapter });
return adapter;
},
});
const agent = await registry.start();
calls[0].options.onError({ code: "child_exited", message: "crashed" });
await registry.waitForRecovery(agent.id);
assert.equal(calls.length, 2);
assert.equal(calls[1].options.sessionPath, sessionFile);
assert.deepEqual(
registry.eventsAfter(agent.id, 0).map((event) => event.type),
["agent_state", "recovery", "recovery", "agent_state"],
);
await registry.stop();
});
+240
View File
@@ -0,0 +1,240 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createAgentRegistry } from "../src/bridge/agent-registry.js";
async function createWorktrees() {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-worktrees-"));
const home = join(root, "home");
const feature = join(root, "feature");
await Promise.all([mkdir(home), mkdir(feature)]);
return { root, home, feature, sessionRoot: join(root, "sessions") };
}
function createAdapterFactory() {
const calls = [];
return {
calls,
startAdapter: (options) => {
const adapter = {
sent: [],
extensionResponses: [],
stopped: false,
send(command) {
this.sent.push(command);
return Promise.resolve({
type: "response",
command: command.type,
success: true,
});
},
respondToExtension(requestId, response) {
this.extensionResponses.push({ requestId, response });
},
async stop() {
this.stopped = true;
},
};
calls.push({ options, adapter });
return adapter;
},
};
}
test("starts only the home agent and creates other worktree agents on explicit selection", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: fixture.startAdapter,
});
const home = await registry.start();
assert.equal(registry.listAgents().length, 1);
assert.equal(home.worktreePath, worktrees.home);
const feature = await registry.selectWorktree(worktrees.feature);
const selectedAgain = await registry.selectWorktree(
`${worktrees.feature}/../feature`,
);
assert.equal(feature.id, selectedAgain.id);
assert.notEqual(home.id, feature.id);
assert.deepEqual(
fixture.calls.map(({ options }) => options.cwd),
[worktrees.home, worktrees.feature],
);
assert.notEqual(home.sessionDir, feature.sessionDir);
await registry.stop();
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
});
test("catalogues managed directories and exposes only their sessions", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: fixture.startAdapter,
});
await registry.start();
const feature = await registry.selectWorktree(worktrees.feature);
const olderSession = join(feature.sessionDir, "older.jsonl");
const newestSession = join(feature.sessionDir, "newest.jsonl");
await writeFile(
olderSession,
[
JSON.stringify({
type: "session",
id: "older",
timestamp: "2025-01-01T00:00:00.000Z",
cwd: worktrees.feature,
}),
JSON.stringify({
type: "message",
message: { role: "user", content: "Older task" },
}),
].join("\n"),
);
await writeFile(
newestSession,
[
JSON.stringify({
type: "session",
id: "newest",
timestamp: "2025-01-02T00:00:00.000Z",
cwd: worktrees.feature,
}),
JSON.stringify({ type: "session_info", name: "Newest work" }),
JSON.stringify({
type: "message",
message: { role: "user", content: "Newest task" },
}),
].join("\n"),
);
await writeFile(
join(feature.sessionDir, "bridge-agent.json"),
`${JSON.stringify({ sessionPath: newestSession, worktreePath: worktrees.feature })}\n`,
);
const sessions = await registry.listSessions(feature.id);
assert.deepEqual(
sessions.map(({ id, name, isCurrent }) => ({ id, name, isCurrent })),
[
{ id: "newest", name: "Newest work", isCurrent: false },
{ id: "older", name: undefined, isCurrent: false },
],
);
await registry.route(feature.id, "switch_session", {
sessionPath: newestSession,
});
await registry.route(feature.id, "new_session");
assert.deepEqual(
fixture.calls[1].adapter.sent.map((command) => command.type),
["get_state", "switch_session", "get_state", "new_session", "get_state"],
);
await assert.rejects(
registry.route(feature.id, "switch_session", {
sessionPath: olderSession.replace("older", "../outside"),
}),
/ENOENT|belong/,
);
await registry.stop();
const restored = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: createAdapterFactory().startAdapter,
});
await restored.start();
assert.deepEqual(await restored.listDirectories(), [
{ worktreePath: worktrees.feature, state: "inactive" },
{
worktreePath: worktrees.home,
state: "idle",
agentId: restored.listAgents()[0].id,
},
]);
await restored.stop();
});
test("routes commands by explicit agent ID and replays only events after the cursor", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: fixture.startAdapter,
});
const agent = await registry.start();
const [{ options, adapter }] = fixture.calls;
const response = await registry.route(agent.id, "prompt", {
message: "Stay in this worktree",
});
assert.deepEqual(response, {
type: "response",
command: "prompt",
success: true,
});
const stats = await registry.route(agent.id, "get_session_stats");
assert.deepEqual(stats, {
type: "response",
command: "get_session_stats",
success: true,
});
const commands = await registry.route(agent.id, "get_commands");
assert.deepEqual(commands, {
type: "response",
command: "get_commands",
success: true,
});
assert.deepEqual(adapter.sent, [
{ type: "get_state" },
{ type: "prompt", message: "Stay in this worktree" },
{ type: "get_session_stats" },
{ type: "get_commands" },
]);
options.onEvent({
type: "agent_state",
data: { state: "streaming" },
});
await registry.route(agent.id, "submit_prompt", {
message: "After this turn",
});
assert.deepEqual(adapter.sent.at(-1), {
type: "follow_up",
message: "After this turn",
});
options.onEvent({
type: "stream",
data: { event: { type: "message_update" } },
});
options.onEvent({ type: "queue", data: { event: { type: "queue_update" } } });
assert.deepEqual(
registry.eventsAfter(agent.id, 0).map(({ seq, type }) => ({ seq, type })),
[
{ seq: 1, type: "agent_state" },
{ seq: 2, type: "stream" },
{ seq: 3, type: "queue" },
],
);
assert.deepEqual(
registry.eventsAfter(agent.id, 1).map(({ seq, type }) => ({ seq, type })),
[
{ seq: 2, type: "stream" },
{ seq: 3, type: "queue" },
],
);
await assert.rejects(
registry.route("missing-agent", "abort"),
/unknown agent/,
);
await registry.stop();
});
+113
View File
@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { mkdir, mkdtemp } from "node:fs/promises";
import { createConnection } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { startBridgeService } from "../src/bridge/service.js";
function connect(socketPath) {
return new Promise((resolve, reject) => {
const socket = createConnection(socketPath);
socket.once("connect", () => resolve(socket));
socket.once("error", reject);
});
}
function request(socket, frame) {
return new Promise((resolve, reject) => {
let buffer = "";
const onData = (chunk) => {
buffer += chunk;
const newline = buffer.indexOf("\n");
if (newline === -1) return;
socket.off("data", onData);
try {
resolve(JSON.parse(buffer.slice(0, newline)));
} catch (error) {
reject(error);
}
};
socket.on("data", onData);
socket.once("error", reject);
socket.write(`${JSON.stringify(frame)}\n`);
});
}
function createAdapterFactory() {
const calls = [];
return {
calls,
startAdapter: (options) => {
const adapter = new EventEmitter();
adapter.sent = [];
adapter.send = async (command) => {
adapter.sent.push(command);
return {
type: "response",
command: command.type,
success: true,
data: { command },
};
};
adapter.respondToExtension = () => {};
adapter.stop = async () => {};
calls.push({ options, adapter });
return adapter;
},
};
}
test("starts the home agent and dispatches local protocol requests to the selected worktree", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-service-"));
const home = join(root, "home");
const feature = join(root, "feature");
await Promise.all([mkdir(home), mkdir(feature)]);
const fixture = createAdapterFactory();
const service = await startBridgeService({
homeWorktree: home,
runtimeDir: join(root, "runtime"),
sessionRoot: join(root, "sessions"),
startAdapter: fixture.startAdapter,
});
const socket = await connect(service.socketPath);
try {
const agents = await request(socket, {
version: "v1",
id: "list-1",
op: "list_agents",
});
assert.equal(agents.result.agents.length, 1);
assert.equal(agents.result.agents[0].worktreePath, home);
const selected = await request(socket, {
version: "v1",
id: "select-1",
op: "select_agent",
payload: { worktreePath: feature },
});
const featureAgent = selected.result.agent;
const prompt = await request(socket, {
version: "v1",
id: "prompt-1",
op: "prompt",
agentId: featureAgent.id,
payload: { message: "Use this worktree" },
});
assert.equal(prompt.ok, true);
assert.deepEqual(
fixture.calls.map(({ options }) => options.cwd),
[home, feature],
);
assert.deepEqual(fixture.calls[1].adapter.sent, [
{ type: "get_state" },
{ type: "prompt", message: "Use this worktree" },
]);
} finally {
socket.destroy();
await service.close();
}
});
+45
View File
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp } from "node:fs/promises";
import { promisify } from "node:util";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { startBridgeService } from "../src/bridge/service.js";
const execFileAsync = promisify(execFile);
test("executes an approved bridge request through the local client CLI", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-cli-"));
const home = join(root, "home");
await mkdir(home);
const service = await startBridgeService({
homeWorktree: home,
runtimeDir: join(root, "runtime"),
sessionRoot: join(root, "sessions"),
startAdapter: () => ({
send: async () => ({ type: "response", success: true }),
respondToExtension: () => {},
stop: async () => {},
}),
});
try {
const { stdout } = await execFileAsync(
process.execPath,
[
"src/client/cli.js",
"--socket",
service.socketPath,
"request",
'{"op":"list_agents"}',
],
{ cwd: process.cwd() },
);
const result = JSON.parse(stdout);
assert.equal(result.agents.length, 1);
assert.equal(result.agents[0].worktreePath, home);
} finally {
await service.close();
}
});
+73
View File
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { startBridgeDaemon } from "../src/bridge/daemon.js";
import { createNoctaliaStateRelay } from "../src/client/noctalia-relay.js";
test("starts a bridge daemon around the local bridge service", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-daemon-"));
const home = join(root, "home");
await mkdir(home);
const daemon = await startBridgeDaemon({
homeWorktree: home,
runtimeDir: join(root, "runtime"),
sessionRoot: join(root, "sessions"),
startAdapter: () => ({
send: async () => ({ type: "response", success: true }),
respondToExtension: () => {},
stop: async () => {},
}),
});
try {
assert.match(daemon.socketPath, /bridge\.sock$/);
assert.equal(daemon.listAgents()[0].worktreePath, home);
} finally {
await daemon.close();
}
});
test("relays only the selected agent's bridge state into a presentation update", async () => {
let listener;
const updates = [];
const relay = createNoctaliaStateRelay({
client: {
request: async () => ({ data: { isStreaming: false } }),
subscribe: async (_agentId, _cursor, callback) => {
listener = callback;
return () => {};
},
},
agent: { id: "agent-1", worktreePath: "/worktrees/feature" },
onState: (state) => updates.push(state),
});
await relay.start();
listener({ type: "agent_state", data: { state: "streaming" } });
listener({
type: "queue",
data: { event: { steering: ["focus"], followUp: [] } },
});
assert.deepEqual(updates, [
{
state: "idle",
projectLabel: "feature",
attentionCount: 0,
detail: "Idle",
},
{
state: "streaming",
projectLabel: "feature",
attentionCount: 0,
detail: "Streaming",
},
{
state: "streaming",
projectLabel: "feature",
attentionCount: 1,
detail: "Streaming",
},
]);
});
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
BridgeAlreadyRunningError,
BridgeLockError,
acquireBridgeLock,
lockMode,
} from "../src/bridge/instance-lock.js";
async function paths() {
const directory = await mkdtemp(join(tmpdir(), "pi-status-bridge-lock-"));
return {
lockPath: join(directory, "bridge.lock"),
socketPath: join(directory, "bridge.sock"),
};
}
test("acquires an owner-only lock and releases only its own token", async () => {
const { lockPath, socketPath } = await paths();
const lock = await acquireBridgeLock({
lockPath,
socketPath,
pid: 101,
now: () => new Date("2026-01-02T03:04:05.000Z"),
});
const lockContents = await readFile(lockPath, "utf8");
let metadata;
try {
metadata = JSON.parse(lockContents);
} catch (error) {
assert.fail(
error instanceof Error ? error.message : "lock file was not JSON",
);
}
const lockStats = await stat(lockPath);
assert.equal(metadata.pid, 101);
assert.equal(metadata.socketPath, socketPath);
assert.equal(lockStats.mode & 0o777, 0o600);
assert.equal(lockMode, 0o600);
assert.equal(await lock.release(), true);
assert.equal(await lock.release(), false);
});
test("refuses to replace a live owner", async () => {
const { lockPath, socketPath } = await paths();
await writeFile(
lockPath,
JSON.stringify({
pid: 202,
socketPath,
startedAt: "2026-01-02T03:04:05.000Z",
token: "live-owner",
}),
);
await assert.rejects(
acquireBridgeLock({
lockPath,
socketPath,
pid: 303,
processAlive: (pid) => pid === 202,
}),
BridgeAlreadyRunningError,
);
});
test("recovers only a proven-stale lock", async () => {
const { lockPath, socketPath } = await paths();
await writeFile(
lockPath,
JSON.stringify({
pid: 404,
socketPath,
startedAt: "2026-01-02T03:04:05.000Z",
token: "stale-owner",
}),
);
const lock = await acquireBridgeLock({
lockPath,
socketPath,
pid: 505,
processAlive: () => false,
});
assert.equal(lock.metadata.pid, 505);
await lock.release();
});
test("does not reclaim a malformed lock", async () => {
const { lockPath, socketPath } = await paths();
await writeFile(lockPath, "broken");
await assert.rejects(
acquireBridgeLock({
lockPath,
socketPath,
pid: 606,
processAlive: () => false,
}),
BridgeLockError,
);
});
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { connectLocalClient } from "../src/client/local-client.js";
import { startBridgeService } from "../src/bridge/service.js";
test("uses only the approved local protocol for requests and live event subscriptions", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-client-"));
const home = join(root, "home");
await mkdir(home);
const starts = [];
const service = await startBridgeService({
homeWorktree: home,
runtimeDir: join(root, "runtime"),
sessionRoot: join(root, "sessions"),
startAdapter: (options) => {
const adapter = {
send: async () => ({ type: "response", success: true }),
respondToExtension: () => {},
stop: async () => {},
};
starts.push({ options, adapter });
return adapter;
},
});
const client = await connectLocalClient({ socketPath: service.socketPath });
try {
const agents = await client.request("list_agents");
assert.equal(agents.agents.length, 1);
const events = [];
const unsubscribe = await client.subscribe(
agents.agents[0].id,
0,
(event) => events.push(event),
);
starts[0].options.onEvent({
type: "queue",
data: { event: { type: "queue_update" } },
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(events, [
{
version: "v1",
seq: 1,
type: "queue",
agentId: agents.agents[0].id,
data: { event: { type: "queue_update" } },
},
]);
unsubscribe();
} finally {
await client.close();
await service.close();
}
});
+34
View File
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { publishNoctaliaState } from "../src/client/noctalia-ipc.js";
test("publishes presentation state through Noctalia's documented IPC command", async () => {
const calls = [];
await publishNoctaliaState(
{
state: "streaming",
projectLabel: "feature",
attentionCount: 2,
detail: "Streaming",
},
{ execute: async (command, args) => calls.push({ command, args }) },
);
assert.deepEqual(calls, [
{
command: "qs",
args: [
"-c",
"noctalia-shell",
"ipc",
"call",
"plugin:pi-status-bridge",
"setBridgeState",
"streaming",
"feature",
"2",
"Streaming",
],
},
]);
});
+34
View File
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("ships a Noctalia plugin with compact bar and presentation-only panel entry points", async () => {
const manifestText = await readFile(
"noctalia-plugin/pi-status-bridge/manifest.json",
"utf8",
);
let manifest;
try {
manifest = JSON.parse(manifestText);
} catch (error) {
assert.fail(
error instanceof Error ? error.message : "manifest was not valid JSON",
);
}
const [bar, panel, main] = await Promise.all([
readFile("noctalia-plugin/pi-status-bridge/BarWidget.qml", "utf8"),
readFile("noctalia-plugin/pi-status-bridge/Panel.qml", "utf8"),
readFile("noctalia-plugin/pi-status-bridge/Main.qml", "utf8"),
]);
assert.equal(manifest.id, "pi-status-bridge");
assert.deepEqual(manifest.entryPoints, {
main: "Main.qml",
barWidget: "BarWidget.qml",
panel: "Panel.qml",
});
assert.match(bar, /pluginApi\.togglePanel\(root\.screen, visualCapsule\)/);
assert.match(panel, /allowAttach: true/);
assert.match(main, /IpcHandler/);
assert.match(main, /setBridgeState/);
});
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("ships a v5 Noctalia compatibility launcher instead of a primary panel", async () => {
const [manifest, bridge] = await Promise.all([
readFile("noctalia-v5-plugin/pi-status-bridge/plugin.toml", "utf8"),
readFile("noctalia-v5-plugin/pi-status-bridge/bridge.luau", "utf8"),
]);
assert.doesNotMatch(manifest, /\[\[panel\]\]/);
assert.match(manifest, /entry = "bridge\.luau"/);
assert.match(bridge, /PI_STATUS_UI_BINARY/);
assert.match(bridge, /pi-status-ui/);
assert.match(bridge, /noctalia\.runAsync/);
assert.match(bridge, /setsid -f env TMPDIR=\/tmp/);
assert.match(bridge, /pi-status-ui\.log/);
assert.doesNotMatch(bridge, /togglePanel/);
assert.match(bridge, /Pi reconnecting/);
assert.match(bridge, /discover_home_agent\(socket\)/);
});
+168
View File
@@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import test from "node:test";
import { startPiRpcAdapter } from "../src/bridge/pi-rpc-adapter.js";
class FakePiChild extends EventEmitter {
constructor() {
super();
this.stdin = new PassThrough();
this.stdout = new PassThrough();
this.stderr = new PassThrough();
this.killed = false;
}
kill(signal) {
this.killed = true;
this.emit("exit", null, signal);
return true;
}
}
function parseSent(stdin) {
try {
return stdin
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
} catch (error) {
throw new Error(
`fake child received invalid JSON: ${error instanceof Error ? error.message : "unknown error"}`,
);
}
}
function createFixture() {
const child = new FakePiChild();
const calls = [];
let stdin = "";
child.stdin.on("data", (chunk) => {
stdin += chunk;
});
return {
child,
calls,
spawn: (command, args, options) => {
calls.push({ command, args, options });
return child;
},
sent: () => parseSent(stdin),
};
}
test("starts Pi in RPC mode and correlates a command response", async () => {
const fixture = createFixture();
const events = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
onEvent: (event) => events.push(event),
});
const response = adapter.send({
type: "prompt",
message: "Implement the bridge",
});
const [command] = fixture.sent();
assert.deepEqual(fixture.calls[0], {
command: "pi",
args: ["--mode", "rpc", "--session-dir", "/workspace/sessions"],
options: { cwd: "/workspace/home", stdio: ["pipe", "pipe", "pipe"] },
});
assert.equal(command.type, "prompt");
assert.equal(command.message, "Implement the bridge");
assert.match(command.id, /^bridge-/);
fixture.child.stdout.write('{"type":"agent_start"}\n');
fixture.child.stdout.write(
`${JSON.stringify({ type: "response", id: command.id, command: "prompt", success: true })}\n`,
);
assert.deepEqual(await response, {
type: "response",
id: command.id,
command: "prompt",
success: true,
});
assert.deepEqual(events, [
{
seq: 1,
type: "agent_state",
data: { state: "streaming", event: { type: "agent_start" } },
},
]);
await adapter.stop();
});
test("requests Pi session statistics for context and token status", async () => {
const fixture = createFixture();
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
});
const pending = adapter.send({ type: "get_session_stats" });
const [command] = fixture.sent();
assert.equal(command.type, "get_session_stats");
fixture.child.stdout.write(
`${JSON.stringify({ type: "response", id: command.id, command: "get_session_stats", success: true, data: { contextUsage: { tokens: 32000, contextWindow: 200000 } } })}\n`,
);
const stats = await pending;
assert.equal(stats.data.contextUsage.tokens, 32000);
await adapter.stop();
});
test("forwards extension responses without replacing Pi's request ID", async () => {
const fixture = createFixture();
const events = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
onEvent: (event) => events.push(event),
});
const extensionRequest = {
type: "extension_ui_request",
id: "extension-request-1",
method: "confirm",
title: "Keep U+2028 here",
message: "Approve?",
};
fixture.child.stdout.write(`${JSON.stringify(extensionRequest)}\n`);
adapter.respondToExtension("extension-request-1", { confirmed: false });
assert.deepEqual(events, [
{ seq: 1, type: "extension_ui_request", data: { event: extensionRequest } },
]);
assert.deepEqual(fixture.sent(), [
{
type: "extension_ui_response",
id: "extension-request-1",
confirmed: false,
},
]);
await adapter.stop();
});
test("reports invalid child output and rejects pending commands on child exit", async () => {
const fixture = createFixture();
const errors = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
onError: (error) => errors.push(error),
});
const pending = adapter.send({ type: "get_state" });
fixture.child.stdout.write("not-json\n");
fixture.child.emit("exit", 1, null);
await assert.rejects(pending, /exited before responding/);
assert.equal(errors[0].code, "invalid_json");
await adapter.stop();
});
+96
View File
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import test from "node:test";
import { startPiRpcAdapter } from "../src/bridge/pi-rpc-adapter.js";
function createChild() {
const child = new EventEmitter();
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = (signal) => {
child.emit("exit", null, signal);
return true;
};
return child;
}
test("normalizes streaming, tool, and queue events in child output order", async () => {
const child = createChild();
const events = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: () => child,
onEvent: (event) => events.push(event),
});
const piEvents = [
{
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "Hello" },
},
{
type: "tool_execution_start",
toolCallId: "tool-1",
toolName: "bash",
args: { command: "pwd" },
},
{
type: "tool_execution_end",
toolCallId: "todo-1",
toolName: "todo",
result: {
details: {
tasks: [{ id: 1, subject: "Ship pane", status: "in_progress" }],
},
},
isError: false,
},
{ type: "queue_update", steering: ["Refocus"], followUp: [] },
];
child.stdout.write(JSON.stringify(piEvents[0]).slice(0, 30));
child.stdout.write(
`${JSON.stringify(piEvents[0]).slice(30)}\n${JSON.stringify(piEvents[1])}\n${JSON.stringify(piEvents[2])}\n${JSON.stringify(piEvents[3])}\n`,
);
assert.deepEqual(events, [
{ seq: 1, type: "stream", data: { event: piEvents[0] } },
{ seq: 2, type: "tool", data: { event: piEvents[1] } },
{ seq: 3, type: "tool", data: { event: piEvents[2] } },
{ seq: 4, type: "queue", data: { event: piEvents[3] } },
]);
await adapter.stop();
});
test("marks Pi idle only after it settles and preserves extension errors as diagnostics", async () => {
const child = createChild();
const events = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: () => child,
onEvent: (event) => events.push(event),
});
const piEvents = [
{ type: "agent_start" },
{ type: "agent_end", willRetry: false },
{ type: "agent_settled" },
{ type: "extension_error", error: "non-fatal extension failure" },
];
child.stdout.write(
`${piEvents.map((event) => JSON.stringify(event)).join("\n")}\n`,
);
assert.deepEqual(
events.map((event) => ({ type: event.type, state: event.data.state })),
[
{ type: "agent_state", state: "streaming" },
{ type: "transcript", state: undefined },
{ type: "agent_state", state: "idle" },
{ type: "transcript", state: undefined },
],
);
await adapter.stop();
});
+198
View File
@@ -0,0 +1,198 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MAX_FRAME_BYTES,
ProtocolError,
parseRequestFrame,
validateEvent,
} from "../src/protocol/index.js";
const request = (value) =>
JSON.stringify({ version: "v1", id: "request-1", ...value });
test("accepts a command scoped to its selected agent", () => {
assert.deepEqual(
parseRequestFrame(
request({
op: "prompt",
agentId: "agent-main",
payload: { message: "implement the protocol" },
}),
),
{
version: "v1",
id: "request-1",
op: "prompt",
agentId: "agent-main",
payload: { message: "implement the protocol" },
},
);
});
test("accepts an agent transcript request without a synthetic cursor", () => {
assert.deepEqual(
parseRequestFrame(request({ op: "get_transcript", agentId: "agent-main" })),
{
version: "v1",
id: "request-1",
op: "get_transcript",
agentId: "agent-main",
},
);
});
test("accepts bridge-managed directory and session operations", () => {
assert.deepEqual(parseRequestFrame(request({ op: "list_directories" })), {
version: "v1",
id: "request-1",
op: "list_directories",
});
assert.deepEqual(
parseRequestFrame(request({ op: "list_sessions", agentId: "agent-main" })),
{
version: "v1",
id: "request-1",
op: "list_sessions",
agentId: "agent-main",
},
);
assert.deepEqual(
parseRequestFrame(request({ op: "new_session", agentId: "agent-main" })),
{
version: "v1",
id: "request-1",
op: "new_session",
agentId: "agent-main",
},
);
assert.deepEqual(
parseRequestFrame(
request({
op: "switch_session",
agentId: "agent-main",
payload: { sessionPath: "/sessions/session.jsonl" },
}),
),
{
version: "v1",
id: "request-1",
op: "switch_session",
agentId: "agent-main",
payload: { sessionPath: "/sessions/session.jsonl" },
},
);
assert.throws(
() =>
parseRequestFrame(
request({
op: "switch_session",
agentId: "agent-main",
payload: { sessionPath: "relative.jsonl" },
}),
),
/must be absolute/,
);
});
test("accepts an agent session-statistics request", () => {
assert.deepEqual(
parseRequestFrame(
request({ op: "get_session_stats", agentId: "agent-main" }),
),
{
version: "v1",
id: "request-1",
op: "get_session_stats",
agentId: "agent-main",
},
);
});
test("accepts a default composer submit request", () => {
assert.deepEqual(
parseRequestFrame(
request({
op: "submit_prompt",
agentId: "agent-main",
payload: { message: "Continue" },
}),
),
{
version: "v1",
id: "request-1",
op: "submit_prompt",
agentId: "agent-main",
payload: { message: "Continue" },
},
);
});
test("accepts a selected agent get_commands request", () => {
assert.deepEqual(
parseRequestFrame(request({ op: "get_commands", agentId: "agent-main" })),
{
version: "v1",
id: "request-1",
op: "get_commands",
agentId: "agent-main",
},
);
});
test("rejects malformed, unknown, and oversized frames", () => {
assert.throws(() => parseRequestFrame("not json"), ProtocolError);
assert.throws(
() => parseRequestFrame(request({ op: "teleport" })),
/not supported/,
);
assert.throws(
() => parseRequestFrame("x".repeat(MAX_FRAME_BYTES + 1)),
/frame exceeds/,
);
});
test("rejects cross-worktree routing fields on agent commands", () => {
assert.throws(
() =>
parseRequestFrame(
request({
op: "prompt",
agentId: "agent-main",
payload: { message: "wrong worktree", worktreePath: "/other" },
}),
),
/not supported/,
);
});
test("requires explicit absolute worktree selection", () => {
assert.throws(
() =>
parseRequestFrame(
request({
op: "select_agent",
payload: { worktreePath: "relative/project" },
}),
),
/must be absolute/,
);
});
test("normalizes sequenced bridge events", () => {
assert.deepEqual(
validateEvent({
version: "v1",
seq: 2,
type: "extension_ui_request",
agentId: "agent-main",
data: { id: "extension-1", method: "confirm" },
}),
{
version: "v1",
seq: 2,
type: "extension_ui_request",
agentId: "agent-main",
data: { id: "extension-1", method: "confirm" },
},
);
});
+65
View File
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createRecoverySupervisor } from "../src/bridge/recovery-supervisor.js";
test("restarts after bounded exponential delays and stops after exhaustion", async () => {
const delays = [];
const states = [];
let starts = 0;
const supervisor = createRecoverySupervisor({
start: async () => ({ attempt: ++starts }),
maxAttempts: 2,
delayForAttempt: (attempt) => attempt * 100,
sleep: async (delay) => delays.push(delay),
onState: (state) => states.push(state),
});
assert.deepEqual(await supervisor.handleUnexpectedExit(), { attempt: 1 });
assert.deepEqual(await supervisor.handleUnexpectedExit(), { attempt: 2 });
assert.equal(await supervisor.handleUnexpectedExit(), undefined);
assert.deepEqual(delays, [100, 200]);
assert.deepEqual(
states.map(({ state, attempt }) => ({ state, attempt })),
[
{ state: "recovering", attempt: 1 },
{ state: "healthy", attempt: 1 },
{ state: "recovering", attempt: 2 },
{ state: "healthy", attempt: 2 },
{ state: "failed", attempt: 2 },
],
);
});
test("restarts failed launches within the same bounded recovery budget", async () => {
let starts = 0;
const supervisor = createRecoverySupervisor({
start: async () => {
starts += 1;
if (starts === 1) throw new Error("temporary spawn failure");
return { attempt: starts };
},
maxAttempts: 2,
delayForAttempt: () => 0,
sleep: async () => {},
});
assert.deepEqual(await supervisor.handleUnexpectedExit(), { attempt: 2 });
assert.equal(starts, 2);
});
test("cancels scheduled recovery when stopped", async () => {
let resolveSleep;
const supervisor = createRecoverySupervisor({
start: async () => assert.fail("must not restart after stop"),
delayForAttempt: () => 1,
sleep: () =>
new Promise((resolve) => {
resolveSleep = resolve;
}),
});
const recovering = supervisor.handleUnexpectedExit();
supervisor.stop();
resolveSleep();
assert.equal(await recovering, undefined);
});
+29
View File
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { mkdtemp, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import test from "node:test";
import { BridgeAlreadyRunningError } from "../src/bridge/instance-lock.js";
import {
createRuntimePaths,
startBridgeServer,
} from "../src/bridge/runtime.js";
test("creates a private runtime directory and enforces one live bridge", async () => {
const runtimeDir = await mkdtemp(`${tmpdir()}/pi-status-bridge-runtime-`);
const paths = await createRuntimePaths(runtimeDir);
const runtimeStats = await stat(paths.directory);
assert.equal(runtimeStats.mode & 0o777, 0o700);
const bridge = await startBridgeServer({
runtimeDir,
handleRequest: async () => ({}),
});
try {
await assert.rejects(
startBridgeServer({ runtimeDir, handleRequest: async () => ({}) }),
BridgeAlreadyRunningError,
);
} finally {
await bridge.close();
}
});
+115
View File
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp } from "node:fs/promises";
import { createConnection } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { startBridgeService } from "../src/bridge/service.js";
function connect(socketPath) {
return new Promise((resolve, reject) => {
const socket = createConnection(socketPath);
socket.once("connect", () => resolve(socket));
socket.once("error", reject);
});
}
function collectFrames(socket, count) {
return new Promise((resolve, reject) => {
const frames = [];
let buffer = "";
const onData = (chunk) => {
buffer += chunk;
while (buffer.includes("\n")) {
const index = buffer.indexOf("\n");
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 1);
try {
frames.push(JSON.parse(line));
} catch (error) {
reject(error);
return;
}
if (frames.length === count) {
socket.off("data", onData);
resolve(frames);
return;
}
}
};
socket.on("data", onData);
socket.once("error", reject);
});
}
test("replays then streams subscribed agent events on the same local socket", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-subscribe-"));
const home = join(root, "home");
await mkdir(home);
const calls = [];
const service = await startBridgeService({
homeWorktree: home,
runtimeDir: join(root, "runtime"),
sessionRoot: join(root, "sessions"),
startAdapter: (options) => {
const adapter = {
send: async () => ({ type: "response", success: true }),
respondToExtension: () => {},
stop: async () => {},
};
calls.push({ options, adapter });
return adapter;
},
});
calls[0].options.onEvent({
type: "stream",
data: { event: { type: "message_update", delta: "replayed" } },
});
const socket = await connect(service.socketPath);
try {
const frames = collectFrames(socket, 2);
socket.write(
`${JSON.stringify({ version: "v1", id: "subscribe-1", op: "subscribe", agentId: service.listAgents()[0].id, payload: {} })}\n`,
);
setTimeout(() => {
calls[0].options.onEvent({
type: "stream",
data: { event: { type: "message_update", delta: "live" } },
});
}, 10);
assert.deepEqual(await frames, [
{
version: "v1",
id: "subscribe-1",
ok: true,
result: {
events: [
{
version: "v1",
seq: 1,
type: "stream",
agentId: service.listAgents()[0].id,
data: { event: { type: "message_update", delta: "replayed" } },
},
],
},
},
{
version: "v1",
type: "event",
event: {
version: "v1",
seq: 2,
type: "stream",
agentId: service.listAgents()[0].id,
data: { event: { type: "message_update", delta: "live" } },
},
},
]);
} finally {
socket.destroy();
await service.close();
}
});
+214
View File
@@ -0,0 +1,214 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("renders every Pi RPC extension UI method without replying to fire-and-forget requests", async () => {
const source = await readFile("ui/src/App.tsx", "utf8");
for (const method of [
"select",
"confirm",
"input",
"editor",
"notify",
"setStatus",
"setWidget",
"setTitle",
"set_editor_text",
]) {
assert.match(source, new RegExp(`case "${method}"`));
}
assert.match(source, /setPendingExtension\(extension\)/);
assert.doesNotMatch(source, /This extension UI is unsupported/);
assert.match(source, /function stripAnsi/);
assert.match(source, /stripAnsi\(extension\.statusText\)/);
assert.match(source, /className="pi-controls"/);
assert.match(source, /aria-label="Model and thinking controls"/);
assert.doesNotMatch(source, /<details className="pi-controls">/);
assert.doesNotMatch(
source,
/event\.ctrlKey && event\.key\.toLowerCase\(\) === "c"/,
);
assert.match(source, /getCurrentWindow\(\)\s*\.hide\(\)\s*\.catch/);
assert.match(
source,
/window\.addEventListener\("keydown", handleKeydown, true\)/,
);
assert.match(source, /useLayoutEffect/);
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
assert.match(source, /requestAnimationFrame\(scrollToLatest\)/);
assert.match(source, /const loadGeneration = useRef\(0\)/);
assert.match(
source,
/const lastEventSequence = useRef<Record<string, number>>/,
);
assert.match(source, /generation !== loadGeneration\.current/);
assert.match(source, /current\.phase !== "working"/);
assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/);
assert.match(source, /\.toFixed\(1\)/);
assert.match(source, /formatTokens\(stats\.tokens\.input\)/);
assert.match(source, /formatTokens\(stats\.tokens\.output\)/);
assert.match(source, /formatTokens\(cacheTokens\)/);
assert.match(source, /function updateWorkProgress/);
assert.match(
source,
/className=\{`work-progress composer-progress \$\{workProgress\.phase\}`\}/,
);
assert.match(source, /event\.type === "tool"/);
assert.match(source, /event\.type === "queue"/);
assert.match(source, /composer-progress/);
assert.doesNotMatch(
source,
/<section className=\{`work-progress \$\{workProgress\.phase\}`\}/,
);
});
test("makes the window draggable from the non-interactive header", async () => {
const [component, styles, capability] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
]);
assert.match(component, /startDragging\(\)/);
assert.match(
component,
/closest\(\s*"button, input, select, textarea",?\s*\)/,
);
assert.match(styles, /\.workflow-header \{[\s\S]*cursor: grab/);
assert.match(capability, /core:window:allow-start-dragging/);
});
test("opens local follow-up choices for /model and /resume", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /const controlCommands: Command\[\]/);
assert.match(component, /followUp: "model"/);
assert.match(component, /followUp: "resume"/);
assert.match(component, /followUp: "thinking"/);
assert.match(component, /const nativeCommand = controlCommands\.find/);
assert.match(component, /function chooseThinking/);
assert.match(component, /function openCommandFollowUp/);
assert.match(component, /function chooseModel/);
assert.match(component, /className="command-followup"/);
assert.match(component, /New session/);
assert.match(styles, /\.command-followup \{/);
assert.match(styles, /\.command-choice-list \{/);
});
test("shows bridge-managed directory status and sessions for the opened directory", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(
component,
/invoke<\{ directories: Directory\[\] \}>\("list_directories"\)/,
);
assert.match(
component,
/invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
);
assert.match(component, /function chooseDirectory/);
assert.match(component, /function startNewSession/);
assert.match(component, /function switchSession/);
assert.match(component, /className="session-panel"/);
assert.match(component, /New session/);
assert.match(component, /state\.isStreaming/);
assert.match(styles, /\.session-panel \{/);
assert.match(styles, /\.session-list \{/);
});
test("renders todo-plugin state and compact context/token status", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /function currentTodos/);
assert.match(component, /message\.toolName === "todo"/);
assert.match(component, /className="todos-pane"/);
assert.match(component, /className="workflow-main"/);
assert.match(component, /ctx \{formatTokens\(contextTokens\)\}/);
assert.match(component, /tok \{formatTokens\(stats\.tokens\.total\)\}/);
assert.match(styles, /\.workflow-main \{/);
assert.match(styles, /\.todos-pane \{/);
assert.match(styles, /\.status-metric \{/);
});
test("shows an optimistic prompt until the transcript receives it", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /type PendingSubmission/);
assert.match(
component,
/const \[pendingSubmissions, setPendingSubmissions\]/,
);
assert.match(component, /setStatus\("Sending prompt to Pi…"\)/);
assert.match(component, /phase: "sending"/);
assert.match(component, /phase: "sent"/);
assert.match(component, /Sent · waiting for Pi/);
assert.match(component, /receivedMessages\.some/);
assert.match(component, /pending-message/);
assert.match(styles, /\.pending-message \{/);
assert.match(styles, /\.pending-message\.sending/);
});
test("distinguishes user and assistant messages", async () => {
const styles = await readFile("ui/src/App.css", "utf8");
assert.match(styles, /\.message\.user \{/);
assert.match(styles, /background: #192536/);
assert.match(styles, /\.message\.assistant \{/);
assert.match(styles, /background: #1a241a/);
});
test("uses a subtle focus treatment for the prompt editor", async () => {
const styles = await readFile("ui/src/App.css", "utf8");
assert.match(styles, /\.composer textarea:focus \{/);
assert.match(styles, /outline: none/);
assert.match(styles, /border-color: #6f8b56/);
});
test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => {
const source = await readFile("ui/src/App.tsx", "utf8");
assert.match(source, /onKeyDown=\{\(event\) => \{/);
assert.match(source, /event\.key !== "Enter"/);
assert.match(source, /event\.shiftKey/);
assert.match(source, /event\.nativeEvent\.isComposing/);
assert.match(source, /event\.preventDefault\(\);\s*void submit\(\);/);
assert.match(source, /Enter to send · Shift\+Enter for newline/);
});
test("uses a compact workflow layout that keeps the composer and transcript stable", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /className="workflow-header"/);
assert.match(component, /className="workflow"/);
assert.match(component, /className="workflow-footer"/);
assert.match(component, /className="composer-stack"/);
assert.match(
styles,
/\.workflow \{[\s\S]*grid-template-rows: minmax\(0, 1fr\) auto/,
);
assert.match(styles, /\.transcript,\s*\.settings \{[\s\S]*overflow: auto/);
assert.match(
component,
/className=\{`transcript \$\{pendingExtension \? "with-extension" : ""\} \$\{workProgress\.phase\}`\}/,
);
assert.match(
styles,
/\.transcript\.with-extension \{[\s\S]*padding-bottom: 112px/,
);
assert.match(styles, /\.transcript\.working \{/);
assert.match(styles, /conic-gradient\(/);
assert.match(styles, /transcript-border-orbit/);
assert.match(styles, /prefers-reduced-motion: reduce/);
assert.match(
styles,
/\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 210px/,
);
assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/);
});
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { mkdtemp, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createConnection } from "node:net";
import test from "node:test";
import { startUnixSocketServer } from "../src/bridge/unix-server.js";
function connect(socketPath) {
return new Promise((resolve, reject) => {
const socket = createConnection(socketPath);
socket.once("connect", () => resolve(socket));
socket.once("error", reject);
});
}
function readFrame(socket) {
return new Promise((resolve, reject) => {
let buffer = "";
const onData = (chunk) => {
buffer += chunk;
const newline = buffer.indexOf("\n");
if (newline === -1) return;
socket.off("data", onData);
try {
resolve(JSON.parse(buffer.slice(0, newline)));
} catch (error) {
reject(error);
}
};
socket.on("data", onData);
socket.once("error", reject);
});
}
test("serves validated JSONL requests over an owner-only Unix socket", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-status-bridge-"));
const socketPath = join(directory, "bridge.sock");
const bridge = await startUnixSocketServer({
socketPath,
handleRequest: async (request) => ({ echoedOperation: request.op }),
});
const socket = await connect(socketPath);
try {
const socketStats = await stat(socketPath);
const mode = socketStats.mode & 0o777;
assert.equal(mode, 0o600);
const response = readFrame(socket);
socket.write(
`${JSON.stringify({ version: "v1", id: "request-2", op: "list_agents" })}\r\n`,
);
assert.deepEqual(await response, {
version: "v1",
id: "request-2",
ok: true,
result: { echoedOperation: "list_agents" },
});
} finally {
socket.destroy();
await bridge.close();
}
});
test("returns protocol errors without routing invalid input", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-status-bridge-"));
const socketPath = join(directory, "bridge.sock");
const bridge = await startUnixSocketServer({
socketPath,
handleRequest: async () => ({}),
});
const socket = await connect(socketPath);
try {
const response = readFrame(socket);
socket.write('{"version":"v1","id":"request-3","op":"unknown"}\n');
assert.deepEqual(await response, {
version: "v1",
id: "request-3",
ok: false,
error: {
code: "unsupported_operation",
message: "operation unknown is not supported",
},
});
} finally {
socket.destroy();
await bridge.close();
}
});
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}
+7
View File
@@ -0,0 +1,7 @@
# Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2116
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "pi-status-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"@tauri-apps/cli": "^2"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+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"
]
}
}
+778
View File
@@ -0,0 +1,778 @@
@property --transcript-border-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
:root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: #ebebe8;
background: #101111;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 480px;
min-height: 420px;
background: transparent;
}
button,
textarea,
input,
select {
font: inherit;
}
button {
cursor: pointer;
border: 0;
border-radius: 7px;
padding: 8px 12px;
background: #dcecc8;
color: #152012;
font-size: 12px;
font-weight: 750;
}
button:hover {
background: #f0ffe0;
}
.app-shell {
position: relative;
height: 100vh;
max-height: 100vh;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 9px;
padding: 14px;
border: 1px solid #3b403a;
border-radius: 14px;
background: rgba(18, 20, 19, 0.97);
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.48);
backdrop-filter: blur(18px);
}
.workflow-header {
display: flex;
cursor: grab;
user-select: none;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.workspace-summary {
min-width: 0;
}
.workspace-heading,
.status-row,
.composer-actions,
.settings-actions,
.folder-form {
display: flex;
align-items: center;
gap: 8px;
}
.workspace-heading {
min-width: 0;
}
.brand {
flex: 0 0 auto;
color: #b8df84;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.16em;
}
h1 {
min-width: 0;
margin: 0;
font-size: 16px;
line-height: 1.15;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-row {
margin-top: 3px;
color: #898f89;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
}
.status-row > span {
overflow: hidden;
text-overflow: ellipsis;
}
.extension-status {
color: #b8df84;
}
.status-metric {
flex: 0 0 auto;
border-left: 1px solid #3a413a;
padding-left: 6px;
color: #b9c4ae;
font-variant-numeric: tabular-nums;
}
.state-pill {
flex: 0 0 auto;
border: 1px solid #404740;
border-radius: 999px;
padding: 2px 6px;
color: #afb3ad;
background: #202320;
font-size: 10px;
font-weight: 800;
}
.state-pill.working {
border-color: #628b42;
color: #c4ed8b;
}
.state-pill.recovering {
border-color: #8a713d;
color: #f0ce83;
}
.state-pill.error {
border-color: #8b4c4c;
color: #ffadad;
}
.workflow-header button,
.workflow-header input,
.workflow-header select {
cursor: pointer;
}
.compact-button {
flex: 0 0 auto;
padding: 7px 9px;
}
.agents {
display: flex;
align-items: center;
gap: 6px;
min-height: 30px;
overflow-x: auto;
padding: 1px 0 3px;
}
.agent-caption {
flex: 0 0 auto;
color: #70776f;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.agent {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 180px;
padding: 5px 8px;
border: 1px solid #303530;
background: #1b1e1b;
color: #aeb4ac;
font-size: 11px;
font-weight: 650;
}
.agent > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent small {
color: #70776f;
font-size: 10px;
font-weight: 600;
}
.agent.selected {
border-color: #7da856;
background: #222b1d;
color: #ecf7df;
}
.notifications {
display: grid;
gap: 5px;
}
.notification {
border-left: 3px solid #79b8d8;
border-radius: 5px;
padding: 6px 8px;
background: #1c292e;
color: #cbe7f2;
font-size: 11px;
}
.notification.warning {
border-color: #d3ad54;
background: #332c1a;
color: #ffe4a0;
}
.notification.error {
border-color: #db7777;
background: #332020;
color: #ffc0c0;
}
.workflow {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 7px;
}
.workflow-main {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 210px;
gap: 8px;
}
.transcript,
.settings {
min-height: 0;
overflow: auto;
border: 1px solid #2d332e;
border-radius: 9px;
background: #151716;
}
.transcript {
padding: 8px 11px;
overscroll-behavior: contain;
scroll-padding-bottom: 112px;
}
.transcript.with-extension {
padding-bottom: 112px;
}
.transcript.working {
border-color: transparent;
background:
linear-gradient(#151716, #151716) padding-box,
conic-gradient(
from var(--transcript-border-angle),
#314426,
#b8df84,
#314426 28%,
#314426 72%,
#b8df84,
#314426
)
border-box;
box-shadow: 0 0 14px rgba(151, 207, 104, 0.14);
animation: transcript-border-orbit 2.4s linear infinite;
}
.transcript.recovering {
border-color: #a8823f;
box-shadow: 0 0 12px rgba(240, 199, 110, 0.12);
}
.transcript.error {
border-color: #9d5050;
}
@keyframes transcript-border-orbit {
to {
--transcript-border-angle: 360deg;
}
}
.todos-pane {
min-height: 0;
overflow: auto;
border: 1px solid #303830;
border-radius: 9px;
background: #181c18;
padding: 8px;
}
.todos-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 7px;
color: #dce6d5;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.todos-heading span {
border-radius: 999px;
padding: 1px 6px;
background: #2b3826;
color: #b8df84;
font-size: 10px;
}
.todo-list {
display: grid;
gap: 5px;
margin: 0;
padding: 0;
list-style: none;
}
.todo-list li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: start;
padding: 5px 0;
border-bottom: 1px solid #293029;
}
.todo-list li:last-child {
border-bottom: 0;
}
.todo-list li > div {
display: grid;
gap: 2px;
min-width: 0;
}
.todo-list li strong {
overflow: hidden;
color: #d9dfd5;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-list li small {
color: #7f897e;
font-size: 10px;
}
.todo-list li > span {
color: #9ea69d;
font-size: 9px;
text-transform: capitalize;
}
.todo-list li.in_progress > span {
color: #b8df84;
}
.todo-list li.completed {
opacity: 0.55;
}
.todo-list li.completed strong {
text-decoration: line-through;
}
.message {
margin: 4px 0;
padding: 7px 8px;
border: 1px solid transparent;
border-radius: 7px;
}
.message.user {
margin-left: 34px;
border-color: #30435b;
background: #192536;
}
.message.assistant {
margin-right: 18px;
border-color: #344633;
background: #1a241a;
}
.message.system,
.message.toolResult {
border-bottom: 1px solid #282d28;
border-radius: 0;
}
.message:last-child {
border-bottom: 0;
}
.message strong {
display: block;
margin-bottom: 3px;
color: #b8df84;
font-size: 10px;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.message.user strong {
color: #8cc3ff;
}
.pending-message {
margin: 5px -3px 0;
padding: 7px 8px;
border: 1px dashed #56825e;
border-radius: 7px;
background: #172017;
}
.pending-message strong {
display: flex;
align-items: center;
justify-content: space-between;
}
.pending-message strong span {
color: #b8df84;
font-size: 9px;
letter-spacing: 0.03em;
text-transform: none;
}
.pending-message.sending {
animation: pending-pulse 1.4s ease-in-out infinite;
}
@keyframes pending-pulse {
50% {
border-color: #91ba69;
}
}
.message pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: inherit;
font-size: 12px;
line-height: 1.38;
color: #d7dbd5;
}
.muted {
margin: 0;
color: #858b85;
font-size: 12px;
}
.warning {
color: #ffb4aa;
font-weight: 700;
}
.workflow-footer {
display: grid;
gap: 5px;
}
.pi-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px;
gap: 7px;
padding: 4px 6px;
border: 1px solid #303730;
border-radius: 7px;
background: #191d19;
color: #8f988e;
font-size: 9px;
}
.pi-controls label {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 5px;
align-items: center;
font-weight: 800;
white-space: nowrap;
}
.pi-controls select {
min-width: 0;
padding: 2px 4px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color-scheme: dark;
}
.composer-stack {
display: grid;
gap: 6px;
min-width: 0;
}
.composer {
display: grid;
gap: 5px;
}
.composer > label {
color: #939b92;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
input,
select,
textarea {
width: 100%;
border: 1px solid #3a423a;
border-radius: 7px;
padding: 7px 9px;
color: #e8ebe6;
background: #1a1d1a;
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid #8ebd5f;
outline-offset: 1px;
}
textarea {
min-height: 48px;
max-height: 100px;
resize: vertical;
font-size: 12px;
line-height: 1.35;
}
.composer textarea:focus {
outline: none;
border-color: #6f8b56;
box-shadow: 0 0 0 1px rgba(143, 189, 95, 0.28);
}
.composer-actions {
justify-content: flex-end;
min-width: 0;
}
.composer-actions > button {
flex: 0 0 auto;
}
.work-progress {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
min-width: 0;
border: 1px solid #303630;
border-radius: 7px;
background: #191c19;
font-size: 10px;
}
.composer-progress {
flex: 1 1 auto;
padding: 5px 7px;
}
.work-indicator {
width: 7px;
height: 7px;
border-radius: 50%;
background: #656b65;
}
.work-copy {
display: grid;
min-width: 0;
gap: 1px;
}
.work-copy strong {
color: #dfe6dc;
font-size: 10px;
}
.work-copy span,
.work-meta {
color: #8e968d;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.work-meta {
max-width: 58px;
font-size: 10px;
text-align: right;
}
.work-progress.working {
border-color: #547339;
background: #1a2217;
}
.work-progress.working .work-indicator {
background: #b8df84;
animation: work-pulse 1.2s ease-in-out infinite;
}
.work-progress.recovering .work-indicator {
background: #f0c76e;
animation: work-pulse 1.2s ease-in-out infinite;
}
.work-progress.error .work-indicator {
background: #ff8e8e;
}
@keyframes work-pulse {
50% {
opacity: 0.35;
transform: scale(0.72);
}
}
.quiet {
background: #282d28;
color: #dce1da;
}
.danger {
color: #ffb0b0;
}
.commands {
display: grid;
gap: 4px;
max-height: 80px;
overflow: auto;
}
.command-followup {
display: grid;
gap: 6px;
border: 1px solid #4b6636;
border-radius: 8px;
background: #1b2518;
padding: 7px;
}
.command-followup-heading {
display: flex;
align-items: center;
justify-content: space-between;
color: #c4e89a;
font-size: 11px;
}
.command-followup-heading button {
padding: 4px 7px;
font-size: 10px;
}
.command-choice-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 5px;
max-height: 130px;
overflow: auto;
}
.command-choice-list button {
padding: 6px 8px;
text-align: left;
font-size: 11px;
}
.commands button {
display: grid;
grid-template-columns: 116px minmax(0, 1fr);
gap: 8px;
padding: 5px 8px;
text-align: left;
font-size: 11px;
}
.commands small {
overflow: hidden;
color: #969d95;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.extension-widget {
border-left: 2px solid #9cc76d;
border-radius: 3px;
background: #1b2417;
padding: 5px 7px;
font-size: 11px;
}
.extension-widget p {
margin: 2px 0;
white-space: pre-wrap;
}
.settings {
flex: 1 1 auto;
display: grid;
align-content: start;
gap: 10px;
padding: 12px;
}
h2 {
margin: 0;
font-size: 14px;
}
.folder-form {
align-items: stretch;
}
.remembered {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.settings-actions {
justify-content: flex-start;
}
.session-panel {
display: grid;
gap: 8px;
border-top: 1px solid #303830;
padding-top: 10px;
}
.session-panel-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 8px;
}
.session-panel-heading h2 {
margin-bottom: 3px;
}
.session-panel-heading .muted {
max-width: 460px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-list {
display: grid;
gap: 5px;
max-height: 230px;
overflow: auto;
}
.session {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
width: 100%;
padding: 7px 9px;
text-align: left;
}
.session > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session small {
color: #8f988e;
font-size: 10px;
}
.session.current {
border: 1px solid #769e53;
background: #202b1c;
color: #e7f2de;
cursor: default;
}
.session:disabled {
opacity: 1;
}
.session.quiet:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.extension {
position: absolute;
inset: auto 14px 14px;
max-height: min(70vh, 460px);
overflow: auto;
border: 1px solid #9cc76d;
border-radius: 10px;
background: #1d2818;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.5);
padding: 13px;
display: grid;
gap: 8px;
}
.extension p {
margin: 0;
font-size: 12px;
}
@media (prefers-reduced-motion: reduce) {
.transcript.working {
animation: none;
}
}
@media (max-width: 680px) {
.app-shell {
padding: 11px;
gap: 7px;
border-radius: 10px;
}
.workflow-main {
grid-template-columns: minmax(0, 1fr) 170px;
}
.pi-controls {
grid-template-columns: minmax(0, 1fr) 120px;
}
.transcript {
padding: 7px 9px;
}
.composer-actions {
gap: 5px;
}
.composer-progress .work-copy span {
display: none;
}
.work-meta {
display: none;
}
}
+1290
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+9
View File
@@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));