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.
@@ -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/
|
||||
@@ -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 Pi’s 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 Pi’s 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 worktree’s 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
|
||||
```
|
||||
@@ -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 Pi’s 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
|
||||
```
|
||||
@@ -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 Pi’s 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 worktree’s 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Tauri’s 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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"),
|
||||
);
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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 = () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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" },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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?
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>,
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||