From 475f95ba709a9b852ed245f363efaeb4d9544174 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Thu, 13 Aug 2026 21:34:04 +0200 Subject: [PATCH] feat: add multi-session desktop workspace --- CHANGELOG.md | 3 +- README.md | 8 +- TEST_PLAN.md | 28 +- .../pi-status-bridge/BarWidget.qml | 30 +- noctalia-plugin/pi-status-bridge/Main.qml | 10 +- noctalia-plugin/pi-status-bridge/Panel.qml | 36 - noctalia-plugin/pi-status-bridge/README.md | 12 +- .../pi-status-bridge/manifest.json | 9 +- .../pi-status-bridge/bridge.luau | 75 +- .../pi-status-bridge/picker.luau | 360 --- package.json | 4 +- specs/MULTI_SESSION_ARCHITECTURE.md | 99 + src/bridge/agent-registry.js | 1538 +++++++++--- src/bridge/pi-rpc-adapter.js | 10 +- src/bridge/service.js | 53 +- src/bridge/unix-server.js | 5 +- src/bridge/workspace-store.js | 153 ++ src/client/noctalia-ipc.js | 29 +- src/client/noctalia-relay-cli.js | 32 +- src/client/noctalia-relay.js | 126 +- src/protocol/index.js | 81 +- test/agent-recovery.test.js | 15 +- test/agent-registry.test.js | 58 +- test/bridge-service.test.js | 63 +- test/daemon-and-relay.test.js | 70 +- test/multi-session-registry.test.js | 1101 +++++++++ test/noctalia-ipc.test.js | 31 +- test/noctalia-plugin.test.js | 19 +- test/noctalia-v5-plugin.test.js | 17 +- test/pi-rpc-adapter.test.js | 34 + test/protocol.test.js | 92 + test/subscriptions.test.js | 50 + test/tauri-ui.test.js | 439 +--- test/workspace-store.test.js | 85 + ui/package-lock.json | 1218 +++++++++- ui/package.json | 9 +- ui/src-tauri/Cargo.toml | 2 +- ui/src-tauri/src/bridge.rs | 859 ++++++- ui/src-tauri/src/lib.rs | 181 +- ui/src-tauri/src/ui_state.rs | 440 ++++ ui/src/App.css | 1281 ++++------ ui/src/App.tsx | 2123 ++++------------- ui/src/components/ConversationWorkspace.tsx | 253 ++ ui/src/components/DirectorySidebar.tsx | 28 + ui/src/components/ExtensionDialog.tsx | 58 + ui/src/components/SessionPicker.tsx | 27 + ui/src/components/SessionTabs.tsx | 118 + ui/src/components/components.test.tsx | 155 ++ ui/src/test-setup.ts | 4 + ui/src/workspace/persistence.test.ts | 16 + ui/src/workspace/persistence.ts | 56 + ui/src/workspace/reducer.test.ts | 311 +++ ui/src/workspace/reducer.ts | 507 ++++ ui/src/workspace/types.ts | 232 ++ ui/src/workspace/useWorkspace.ts | 147 ++ ui/vitest.config.ts | 3 + 56 files changed, 8998 insertions(+), 3805 deletions(-) delete mode 100644 noctalia-plugin/pi-status-bridge/Panel.qml delete mode 100644 noctalia-v5-plugin/pi-status-bridge/picker.luau create mode 100644 specs/MULTI_SESSION_ARCHITECTURE.md create mode 100644 src/bridge/workspace-store.js create mode 100644 test/multi-session-registry.test.js create mode 100644 test/workspace-store.test.js create mode 100644 ui/src-tauri/src/ui_state.rs create mode 100644 ui/src/components/ConversationWorkspace.tsx create mode 100644 ui/src/components/DirectorySidebar.tsx create mode 100644 ui/src/components/ExtensionDialog.tsx create mode 100644 ui/src/components/SessionPicker.tsx create mode 100644 ui/src/components/SessionTabs.tsx create mode 100644 ui/src/components/components.test.tsx create mode 100644 ui/src/test-setup.ts create mode 100644 ui/src/workspace/persistence.test.ts create mode 100644 ui/src/workspace/persistence.ts create mode 100644 ui/src/workspace/reducer.test.ts create mode 100644 ui/src/workspace/reducer.ts create mode 100644 ui/src/workspace/types.ts create mode 100644 ui/src/workspace/useWorkspace.ts create mode 100644 ui/vitest.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a80102..4206e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ All notable changes to Pi Status Bridge are documented here. The project follows ### Changed -- Documentation and repository metadata are being established for public collaboration. +- The bridge now supports durable independently live session runtimes grouped by worktree, with a multi-session desktop workspace. +- Noctalia v4/v5 are aggregate status launchers: they report open, working, attention, recovery, and error counts and toggle the desktop UI without controlling sessions. ## [0.1.0] - 2026-07-27 diff --git a/README.md b/README.md index 5335fc8..f8bd473 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > A local Unix-socket bridge and desktop UI for monitoring and controlling persistent [Pi](https://github.com/badlogic/pi-mono) coding-agent sessions without bypassing Pi-owned safety policy. -Pi Status Bridge keeps one Pi RPC agent per selected worktree, exposes a local-only protocol, and provides desktop and Noctalia adapters. It is experimental software: use it on worktrees you control and verify the documented security boundaries before daily use. +Pi Status Bridge keeps independently live Pi session runtimes grouped by worktree, exposes a local-only protocol, and provides desktop and Noctalia adapters. It is experimental software: use it on worktrees you control and verify the documented security boundaries before daily use. ## What it provides @@ -74,12 +74,10 @@ The request payload is the local protocol JSON. See [`DESIGN.md`](DESIGN.md) for ### Noctalia relay ```bash -node src/client/noctalia-relay-cli.js \ - --socket "$PI_STATUS_BRIDGE_SOCKET" \ - --agent +node src/client/noctalia-relay-cli.js --socket "$PI_STATUS_BRIDGE_SOCKET" ``` -Install the adapter that matches your desktop: [`noctalia-plugin/`](noctalia-plugin/) supports v4 and [`noctalia-v5-plugin/`](noctalia-v5-plugin/) supports v5. Both adapters are presentation-only; neither starts Pi nor approves extension requests. +The relay and v4/v5 widgets display an aggregate count of open, working, attention-required, recovering, and failed session runtimes. Clicking the widget starts the bridge when needed and toggles the desktop UI; Noctalia never sends prompts, switches sessions, or approves extension requests. `--agent ` retains the former agent-scoped relay only for external compatibility callers. ## Keep the bridge running in the background (recommended) diff --git a/TEST_PLAN.md b/TEST_PLAN.md index 5b1d6bf..f8cbf99 100644 --- a/TEST_PLAN.md +++ b/TEST_PLAN.md @@ -46,28 +46,14 @@ Prove that the always-running Pi bridge is correctly scoped, recoverable, and us ## Manual Noctalia checklist -### Startup and routing +### Aggregate launcher -- [ ] 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. +- [ ] The widget shows aggregate open, working, attention, recovering, and error counts from `get_workspace_summary`. +- [ ] A background session requiring extension attention increases the aggregate attention count. +- [ ] Clicking the widget starts the bridge when needed and toggles the desktop UI. +- [ ] Noctalia contains no composer, session switcher, or extension-response control. +- [ ] The configured `PI_STATUS_UI_BINARY` and fallback binary both launch with `--toggle`. +- [ ] Restarting Noctalia reconnects to the bridge summary without changing active session runtimes. ## Soak and fault scenarios diff --git a/noctalia-plugin/pi-status-bridge/BarWidget.qml b/noctalia-plugin/pi-status-bridge/BarWidget.qml index 8cbbd51..11e3da7 100644 --- a/noctalia-plugin/pi-status-bridge/BarWidget.qml +++ b/noctalia-plugin/pi-status-bridge/BarWidget.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Layouts import Quickshell +import Quickshell.Io import qs.Commons import qs.Widgets @@ -16,13 +17,31 @@ Item { 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 string projectLabel: pluginApi?.pluginSettings?.projectLabel ?? "0 sessions" 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 + readonly property int workingCount: pluginApi?.pluginSettings?.workingCount ?? 0 + readonly property int recoveringCount: pluginApi?.pluginSettings?.recoveringCount ?? 0 + readonly property int errorCount: pluginApi?.pluginSettings?.errorCount ?? 0 + readonly property string glyph: errorCount > 0 ? "!" : bridgeState === "streaming" ? "◒" : recoveringCount > 0 ? "↻" : attentionCount > 0 ? "●" : "●" + readonly property color stateColor: errorCount > 0 ? Color.mError : attentionCount > 0 ? Color.mPrimary : Color.mOnSurface implicitWidth: content.implicitWidth + Style.marginM * 2 implicitHeight: capsuleHeight + // Keep launch paths configurable for deployments while retaining the local build fallback. + Process { + id: bridgeStartProcess + command: ["systemctl", "--user", "start", "pi-status-bridge.service"] + onExited: function(exitCode) { + if (exitCode === 0) + uiLaunchProcess.running = true + } + } + + Process { + id: uiLaunchProcess + command: ["bash", "-c", "ui=\"${PI_STATUS_UI_BINARY:-$HOME/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui}\"; test -x \"$ui\" && exec setsid -f env TMPDIR=/tmp \"$ui\" --toggle >/tmp/pi-status-ui.log 2>&1"] + } + Rectangle { id: visualCapsule anchors.centerIn: parent @@ -46,10 +65,7 @@ Item { id: mouseArea anchors.fill: parent hoverEnabled: true - onClicked: { - if (pluginApi) - pluginApi.togglePanel(root.screen, visualCapsule) - } + onClicked: bridgeStartProcess.running = true } } } diff --git a/noctalia-plugin/pi-status-bridge/Main.qml b/noctalia-plugin/pi-status-bridge/Main.qml index 212e1cd..63c9754 100644 --- a/noctalia-plugin/pi-status-bridge/Main.qml +++ b/noctalia-plugin/pi-status-bridge/Main.qml @@ -5,21 +5,25 @@ Item { id: root property var pluginApi: null - function setBridgeState(state, projectLabel, attentionCount, detail) { + function setBridgeState(state, projectLabel, attentionCount, detail, openCount, workingCount, recoveringCount, errorCount) { if (!pluginApi) return pluginApi.pluginSettings.bridgeState = state pluginApi.pluginSettings.projectLabel = projectLabel pluginApi.pluginSettings.attentionCount = attentionCount pluginApi.pluginSettings.detail = detail + pluginApi.pluginSettings.openCount = openCount ?? 0 + pluginApi.pluginSettings.workingCount = workingCount ?? 0 + pluginApi.pluginSettings.recoveringCount = recoveringCount ?? 0 + pluginApi.pluginSettings.errorCount = errorCount ?? 0 pluginApi.saveSettings() } IpcHandler { target: "plugin:pi-status-bridge" - function setBridgeState(state, projectLabel, attentionCount, detail) { - root.setBridgeState(state, projectLabel, attentionCount, detail) + function setBridgeState(state, projectLabel, attentionCount, detail, openCount, workingCount, recoveringCount, errorCount) { + root.setBridgeState(state, projectLabel, attentionCount, detail, openCount, workingCount, recoveringCount, errorCount) } } } diff --git a/noctalia-plugin/pi-status-bridge/Panel.qml b/noctalia-plugin/pi-status-bridge/Panel.qml deleted file mode 100644 index e84278d..0000000 --- a/noctalia-plugin/pi-status-bridge/Panel.qml +++ /dev/null @@ -1,36 +0,0 @@ -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 } - } - } -} diff --git a/noctalia-plugin/pi-status-bridge/README.md b/noctalia-plugin/pi-status-bridge/README.md index 8522fd7..fc871f3 100644 --- a/noctalia-plugin/pi-status-bridge/README.md +++ b/noctalia-plugin/pi-status-bridge/README.md @@ -1,14 +1,16 @@ # 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`. +This Noctalia v4 plugin is an aggregate-status **launcher**. It renders the +workspace counts supplied through its `plugin:pi-status-bridge` IPC handler. +Clicking its bar widget starts `pi-status-bridge.service` and toggles the +standalone Pi Status UI. Set `PI_STATUS_UI_BINARY` to override the UI binary; +the packaged local build path is the fallback. 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. +call. The plugin never opens a Noctalia panel, sends prompts, switches +sessions, 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. diff --git a/noctalia-plugin/pi-status-bridge/manifest.json b/noctalia-plugin/pi-status-bridge/manifest.json index 019bc82..ca7576f 100644 --- a/noctalia-plugin/pi-status-bridge/manifest.json +++ b/noctalia-plugin/pi-status-bridge/manifest.json @@ -1,18 +1,17 @@ { "id": "pi-status-bridge", "name": "Pi Status Bridge", - "version": "0.1.0", + "version": "0.2.0", "minNoctaliaVersion": "4.0.0", - "description": "Presentation-only status bar and panel for a local Pi Status Bridge.", + "description": "Aggregate Pi workspace status and launcher for the standalone Pi Status UI.", "entryPoints": { "main": "Main.qml", - "barWidget": "BarWidget.qml", - "panel": "Panel.qml" + "barWidget": "BarWidget.qml" }, "metadata": { "defaultSettings": { "bridgeState": "idle", - "projectLabel": "Home", + "projectLabel": "0 sessions", "attentionCount": 0, "detail": "Bridge not connected" } diff --git a/noctalia-v5-plugin/pi-status-bridge/bridge.luau b/noctalia-v5-plugin/pi-status-bridge/bridge.luau index 2052f43..da8fa2d 100644 --- a/noctalia-v5-plugin/pi-status-bridge/bridge.luau +++ b/noctalia-v5-plugin/pi-status-bridge/bridge.luau @@ -10,6 +10,12 @@ local function ui_binary() return (noctalia.getenv("HOME") or "") .. "/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui" end +local function bridge_client_binary() + local configured = noctalia.getenv("PI_STATUS_BRIDGE_CLIENT") + if configured ~= nil and configured ~= "" then return shell_quote(configured) end + return "pi-status-bridge-client" +end + local function socket_path() local runtime = noctalia.getenv("XDG_RUNTIME_DIR") if runtime == nil or runtime == "" then return nil end @@ -19,10 +25,6 @@ end local bridge_start_in_flight = false local pending_ui_open = false -local function current_agent_id() - return noctalia.state.get("agent_id") -end - local function open_ui() local binary = shell_quote(ui_binary()) local command = "if test -x " .. binary .. "; then setsid -f env TMPDIR=/tmp " @@ -52,67 +54,46 @@ local function start_bridge(open_after_start) barWidget.setTooltip("Could not start pi-status-bridge.service") return end - if pending_ui_open then - pending_ui_open = false - open_ui() - end + if pending_ui_open then pending_ui_open = false; open_ui() end end) 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 - start_bridge(false) - 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) +local function json_count(payload, key) + local value = string.match(payload, '"' .. key .. '":(%d+)') + return tonumber(value) or 0 +end + +local function update_summary(payload) + local open = json_count(payload, "openCount") + local working = json_count(payload, "workingCount") + local attention = json_count(payload, "attentionCount") + local recovering = json_count(payload, "recoveringCount") + local errors = json_count(payload, "errorCount") + local glyph = errors > 0 and "alert-triangle" or attention > 0 and "circle-alert" or working > 0 and "terminal-2" or recovering > 0 and "refresh-cw" or "terminal" + local text = errors > 0 and "Pi needs attention" or working > 0 and "Pi working" or recovering > 0 and "Pi recovering" or "Pi ready" + local detail = open .. " open · " .. working .. " working · " .. attention .. " attention · " .. recovering .. " recovering · " .. errors .. " errors" + barWidget.setGlyph(glyph) + barWidget.setText(text) + barWidget.setTooltip(detail .. "; click to open Pi Status UI") end function update() noctalia.setUpdateInterval(2000) - local agent_id = current_agent_id() local socket = socket_path() - if socket == nil then - start_bridge(false) - 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 socket == nil then start_bridge(false); 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 .. "\"}'" + local command = bridge_client_binary() .. " --socket \"" .. socket .. "\" request '{\"op\":\"get_workspace_summary\"}'" noctalia.runAsync(command, function(result) request_in_flight = false if result.exitCode ~= 0 then - noctalia.state.set("agent_id", nil) start_bridge(false) barWidget.setGlyph("terminal-2") barWidget.setText("Pi reconnecting") - barWidget.setTooltip("Refreshing the Home Pi agent") + barWidget.setTooltip("Refreshing Pi workspace summary") 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") + update_summary(result.stdout) end) end diff --git a/noctalia-v5-plugin/pi-status-bridge/picker.luau b/noctalia-v5-plugin/pi-status-bridge/picker.luau deleted file mode 100644 index 43e203e..0000000 --- a/noctalia-v5-plugin/pi-status-bridge/picker.luau +++ /dev/null @@ -1,360 +0,0 @@ -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 diff --git a/package.json b/package.json index e079a12..a0b74c4 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ "node": ">=20" }, "scripts": { - "test": "node --test test/*.test.js && npm run test:ui", + "test": "node --test test/*.test.js && npm --prefix ui test && 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", + "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/workspace-store.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", "bridge:service:install": "mkdir -p \"$HOME/.config/systemd/user\" && cp systemd/pi-status-bridge.service \"$HOME/.config/systemd/user/\" && systemctl --user daemon-reload && systemctl --user enable --now pi-status-bridge.service", "bridge:service:start": "systemctl --user start pi-status-bridge.service", "bridge:service:restart": "systemctl --user restart pi-status-bridge.service", diff --git a/specs/MULTI_SESSION_ARCHITECTURE.md b/specs/MULTI_SESSION_ARCHITECTURE.md new file mode 100644 index 0000000..9a7a1dd --- /dev/null +++ b/specs/MULTI_SESSION_ARCHITECTURE.md @@ -0,0 +1,99 @@ +# Multi-session workspace architecture + +## Status + +This is the authoritative contract for the bridge-managed multi-session workspace. It supersedes the one-live-agent-per-worktree assumption for new desktop APIs while preserving the legacy v1 surface. + +## Identity + +| Identity | Lifetime | Owner | Purpose | +| --- | --- | --- | --- | +| canonical `worktreePath` | durable | filesystem/bridge | directory group | +| `runtimeId` | durable while its tab is desired open | bridge manifest | open-session slot and UI reconciliation | +| Pi `sessionId` + `sessionPath` | durable history | Pi | saved conversation | +| `agentId` | one child in one daemon generation | bridge | legacy command and event routing | +| `bridgeInstanceId` | one daemon generation | bridge | invalidates stale cursors and agent IDs | + +The UI never persists `agentId`. One canonical session file has at most one live runtime lease. Opening an already-open session returns its existing runtime. + +## Runtime lifecycle + +```text +starting -> idle <-> streaming + | | + +-> recovering -> idle | failed + +-> attention/error +any open state -> closing -> stopped +``` + +Each runtime owns one Pi RPC child, command serialization queue, recovery supervisor, event buffer, extension-attention cache, and summary. Several runtimes may share a worktree and session directory, but never a session file. Same-worktree concurrency is allowed and directory summaries expose open/working counts so clients can warn about filesystem and Git collisions. + +Commands and lifecycle mutations are serialized per runtime. Close removes desired-open intent before stopping the child. A working runtime is aborted first, then stopped with a bounded wait and forced termination fallback. Closing keeps the JSONL history. Forgetting a directory is refused while any of its runtimes remain open. + +## Persistence ownership + +The bridge stores `bridge-workspace-v2.json` beneath the owner-only persistent `sessionRoot`. It is versioned, mode `0600`, and replaced atomically through a temporary file and rename. It records desired-open runtime IDs, canonical worktrees, and Pi session identity. Daemon shutdown preserves records; explicit close removes one. + +On the first v2 migration only the legacy Home runtime is opened. After that migration an explicitly closed Home runtime remains closed and zero live Pi runtimes is valid. Restore launches desired-open runtimes with concurrency two. Six or more open runtimes produces a resource warning but there is no hard cap. + +Missing, moved, corrupt, cross-directory, or duplicate session records are never replaced with a new session. They are retained/reported as failed dormant runtime intent or as a manifest issue. A corrupt whole manifest is reported and starts no runtimes rather than guessing. + +The Tauri UI owns presentation persistence (order, selection, drafts, scroll and last-seen state) in owner-only app data. It must not persist transcripts, extension response values, request IDs, or arbitrary extension payloads. + +Legacy `bridge-agent.json` remains the compatibility default-session pointer. New runtime state is authoritative in the workspace manifest. + +## Additive v1 compatibility API + +Existing `list_agents`, `select_agent`, `list_sessions`, `new_session`, `switch_session`, agent commands, and agent-scoped `subscribe` retain their meanings. `select_agent` resolves a compatible default runtime for that directory. The desktop must not implement tabs with legacy session switching. + +New operations: + +- `get_workspace` +- `get_workspace_summary` +- `create_session_runtime` +- `open_session_runtime` +- `close_session_runtime` +- `list_directory_sessions` +- `get_session_runtime_snapshot` +- `subscribe_workspace` + +All cursors are non-negative integers. + +## Workspace event and replay contract + +`subscribe_workspace` is the preferred aggregate subscription. Every event has a global order and contains: + +```json +{ + "version": "v1", + "bridgeInstanceId": "daemon UUID", + "seq": 42, + "type": "runtime_event", + "agentId": "ephemeral when live", + "data": { + "runtimeId": "durable slot UUID", + "worktreePath": "/canonical/path", + "sessionId": "Pi UUID when known", + "eventType": "tool", + "eventData": {} + } +} +``` + +Replay responses include `bridgeInstanceId`, `firstAvailableSeq`, `latestSeq`, `truncated`, and `events`. Clients reload the workspace and runtime snapshots when the daemon ID changes or replay is truncated. Listener installation precedes replay delivery, preserving replay-then-live ordering. + +## Snapshot and summary contract + +`get_session_runtime_snapshot` addresses one `runtimeId` and returns runtime summary, state, transcript, statistics, commands, models, cached actionable extension requests, `bridgeInstanceId`, and the latest global sequence. A failed dormant runtime returns its identity/error summary without silently starting another conversation. + +Runtime labels prioritize explicit Pi session name, first user prompt, then `New session`. Runtime summaries cache state, queue count, active tool, attention, error/recovery state and last activity. Workspace/directory summaries aggregate open, working, recovering, attention and error counts; Noctalia consumes only the aggregate summary and launches the desktop UI. + +## Safety and recovery + +- The bridge remains the sole Pi process/session/recovery owner. +- Each runtime recovery restarts with its exact leased session path. +- Session identity is refreshed at launch, after legacy session mutation, and after authoritative idle/settled transitions so the first prompt-created file is persisted. +- Background runtimes continue receiving events and extension attention. +- Pending extension requests are scoped to a runtime; another tab cannot answer them. +- A daemon restart changes `bridgeInstanceId` and all `agentId` values while keeping `runtimeId` stable. +- Restore failures surface explicitly. The bridge never substitutes a different session. diff --git a/src/bridge/agent-registry.js b/src/bridge/agent-registry.js index 5e3518b..4730317 100644 --- a/src/bridge/agent-registry.js +++ b/src/bridge/agent-registry.js @@ -12,10 +12,14 @@ import { import path from "node:path"; import { startPiRpcAdapter } from "./pi-rpc-adapter.js"; import { createRecoverySupervisor } from "./recovery-supervisor.js"; +import { createWorkspaceStore } from "./workspace-store.js"; const SESSION_DIRECTORY_MODE = 0o700; const SESSION_REFERENCE_FILE = "bridge-agent.json"; const DEFAULT_EVENT_LIMIT = 1_000; +const DEFAULT_CLOSE_TIMEOUT_MS = 2_000; +const RESTORE_CONCURRENCY = 2; +const RESOURCE_WARNING_COUNT = 6; export class UnknownAgentError extends Error { constructor(agentId) { @@ -24,6 +28,13 @@ export class UnknownAgentError extends Error { } } +export class UnknownRuntimeError extends Error { + constructor(runtimeId) { + super(`unknown session runtime: ${runtimeId}`); + this.name = "UnknownRuntimeError"; + } +} + function sessionDirectoryFor(sessionRoot, worktreePath) { const identity = createHash("sha256") .update(worktreePath) @@ -59,10 +70,7 @@ async function persistSessionReference(sessionDir, sessionPath, worktreePath) { await writeFile( path.join(sessionDir, SESSION_REFERENCE_FILE), `${JSON.stringify({ sessionPath, worktreePath })}\n`, - { - encoding: "utf8", - mode: 0o600, - }, + { encoding: "utf8", mode: 0o600 }, ); } @@ -122,7 +130,13 @@ async function readSessionSummary(sessionPath, currentPath) { } async function listSessionSummaries(sessionDir, currentPath) { - const entries = await readdir(sessionDir, { withFileTypes: true }); + let entries; + try { + entries = await readdir(sessionDir, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } const summaries = await Promise.all( entries .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) @@ -142,56 +156,44 @@ async function listSessionSummaries(sessionDir, currentPath) { .sort((left, right) => right.modified.localeCompare(left.modified)); } -async function listDirectoryCatalog(sessionRoot, agentsByPath, homeWorktree) { - 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", - isHome: worktreePath === homeWorktree, - ...(agent ? { agentId: agent.id } : {}), - }; - }) - .sort((left, right) => left.worktreePath.localeCompare(right.worktreePath)); +function runtimeLabel(runtime) { + return runtime.sessionName || runtime.firstMessage || "New session"; } -function publicAgent(agent) { +function publicAgent(runtime) { return { - id: agent.id, - worktreePath: agent.worktreePath, - sessionDir: agent.sessionDir, - state: agent.state, + id: runtime.agentId, + worktreePath: runtime.worktreePath, + sessionDir: runtime.sessionDir, + state: runtime.state, + runtimeId: runtime.runtimeId, + ...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}), + label: runtimeLabel(runtime), + attention: runtime.attention, + queueCount: runtime.queueCount, + lastActivity: runtime.lastActivity, }; } -function routeCommand(adapter, operation, payload = {}, agentState) { +function publicRuntime(runtime) { + return { + runtimeId: runtime.runtimeId, + worktreePath: runtime.worktreePath, + state: runtime.state, + label: runtimeLabel(runtime), + attention: runtime.attention, + queueCount: runtime.queueCount, + activeTool: runtime.activeTool, + lastActivity: runtime.lastActivity, + openedAt: runtime.openedAt, + ...(runtime.agentId ? { agentId: runtime.agentId } : {}), + ...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}), + ...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}), + ...(runtime.error ? { error: runtime.error } : {}), + }; +} + +function routeCommand(adapter, operation, payload = {}, runtimeState) { switch (operation) { case "prompt": case "steer": @@ -199,7 +201,7 @@ function routeCommand(adapter, operation, payload = {}, agentState) { return adapter.send({ type: operation, message: payload.message }); case "submit_prompt": return adapter.send({ - type: agentState === "streaming" ? "follow_up" : "prompt", + type: runtimeState === "streaming" ? "follow_up" : "prompt", message: payload.message, }); case "abort": @@ -210,6 +212,13 @@ function routeCommand(adapter, operation, payload = {}, agentState) { return adapter.send({ type: operation }); case "get_transcript": return adapter.send({ type: "get_messages" }); + case "compact": + return adapter.send({ + type: "compact", + ...(payload.customInstructions + ? { customInstructions: payload.customInstructions } + : {}), + }); case "set_model": return adapter.send({ type: "set_model", @@ -218,6 +227,8 @@ function routeCommand(adapter, operation, payload = {}, agentState) { }); case "set_thinking_level": return adapter.send({ type: "set_thinking_level", level: payload.level }); + case "set_session_name": + return adapter.send({ type: "set_session_name", name: payload.name }); case "extension_response": adapter.respondToExtension(payload.requestId, payload.response); return Promise.resolve({ @@ -240,6 +251,7 @@ export function createAgentRegistry({ maxRecoveryAttempts, recoveryDelayForAttempt, sleep, + closeTimeoutMs = DEFAULT_CLOSE_TIMEOUT_MS, }) { if (typeof homeWorktree !== "string" || !path.isAbsolute(homeWorktree)) throw new TypeError("homeWorktree must be an absolute path"); @@ -248,306 +260,1076 @@ export function createAgentRegistry({ if (typeof startAdapter !== "function") throw new TypeError("startAdapter must be a function"); - const agentsByPath = new Map(); - const agentsById = new Map(); - const inFlight = new Map(); - const forgettingPaths = new Map(); - const pendingForgetOperations = new Set(); + const bridgeInstanceId = randomUUID(); + const workspaceStore = createWorkspaceStore(sessionRoot); + const runtimesByRuntimeId = new Map(); + const runtimesByAgentId = new Map(); + const runtimesByWorktreePath = new Map(); + const runtimesBySessionPath = new Map(); + const managedWorktrees = new Set(); + const pendingSessionLeases = new Map(); + const creatingByKey = new Map(); + const pendingOpenOperations = new Set(); + const workspaceListeners = new Set(); + const workspaceEvents = []; + let workspaceSequence = 0; + let manifestIssue; let canonicalHomeWorktree; let stopping = false; - async function ensureAgent(worktreePath) { - if (stopping) throw new Error("agent registry is stopping"); - if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath)) - throw new TypeError("worktreePath must be an absolute path"); - const canonicalPath = await realpath(worktreePath); - if (stopping) throw new Error("agent registry is stopping"); - if (forgettingPaths.has(canonicalPath)) - throw new Error("directory is being forgotten"); - 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, - stateVersion: 0, - adapter: undefined, - stopped: false, - recoveryPromise: undefined, - supervisor: undefined, - forgetting: false, - activeOperations: 0, - operationWaiters: new Set(), - }; - 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; - agent.stateVersion += 1; - if (event.data.state === "idle") agent.supervisor.markHealthy(); + function publishWorkspace(type, runtime, data = {}) { + const event = { + version: "v1", + bridgeInstanceId, + seq: ++workspaceSequence, + type, + ...(runtime?.agentId ? { agentId: runtime.agentId } : {}), + data: { + ...(runtime + ? { + runtimeId: runtime.runtimeId, + worktreePath: runtime.worktreePath, + ...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}), } - publish(event.type, event.data); - }, - onError: (error) => { - agent.state = "error"; - agent.stateVersion += 1; - 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"; - agent.stateVersion += 1; - 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"; - agent.stateVersion += 1; - 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); + ...data, + }, + }; + workspaceEvents.push(event); + if (workspaceEvents.length > eventLimit) workspaceEvents.shift(); + for (const listener of workspaceListeners) listener(event); + return event; + } + + function manifest() { + return { + version: 2, + migrated: true, + directories: [...managedWorktrees].sort(), + runtimes: [...runtimesByRuntimeId.values()].map((runtime) => ({ + runtimeId: runtime.runtimeId, + worktreePath: runtime.worktreePath, + ...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}), + ...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}), + ...(runtime.legacyDefault ? { legacyDefault: true } : {}), + openedAt: runtime.openedAt, + })), + }; + } + + async function persistWorkspace() { + await workspaceStore.save(manifest()); + manifestIssue = undefined; + } + + function addWorktreeRuntime(runtime) { + managedWorktrees.add(runtime.worktreePath); + let runtimes = runtimesByWorktreePath.get(runtime.worktreePath); + if (!runtimes) { + runtimes = new Set(); + runtimesByWorktreePath.set(runtime.worktreePath, runtimes); } + runtimes.add(runtime); + } + + function indexRuntime(runtime, { leaseSession = true } = {}) { + if (runtimesByRuntimeId.has(runtime.runtimeId)) + throw new Error(`runtime already exists: ${runtime.runtimeId}`); + if ( + leaseSession && + runtime.sessionPath && + runtimesBySessionPath.has(runtime.sessionPath) + ) + throw new Error("session is already open in another runtime"); + runtimesByRuntimeId.set(runtime.runtimeId, runtime); + if (runtime.agentId) runtimesByAgentId.set(runtime.agentId, runtime); + if (leaseSession && runtime.sessionPath) + runtimesBySessionPath.set(runtime.sessionPath, runtime); + addWorktreeRuntime(runtime); + return runtime; + } + + function unindexRuntime(runtime, { releaseSessionLease = true } = {}) { + runtimesByRuntimeId.delete(runtime.runtimeId); + if (runtime.agentId) runtimesByAgentId.delete(runtime.agentId); + if ( + releaseSessionLease && + runtime.sessionPath && + runtimesBySessionPath.get(runtime.sessionPath) === runtime + ) + runtimesBySessionPath.delete(runtime.sessionPath); + const worktreeRuntimes = runtimesByWorktreePath.get(runtime.worktreePath); + worktreeRuntimes?.delete(runtime); + if (worktreeRuntimes?.size === 0) + runtimesByWorktreePath.delete(runtime.worktreePath); + } + + function getRuntime(runtimeId) { + const runtime = runtimesByRuntimeId.get(runtimeId); + if (!runtime) throw new UnknownRuntimeError(runtimeId); + return runtime; } function getAgent(agentId) { - const agent = agentsById.get(agentId); - if (!agent) throw new UnknownAgentError(agentId); - return agent; + const runtime = runtimesByAgentId.get(agentId); + if (!runtime) throw new UnknownAgentError(agentId); + return runtime; } - async function withAgentOperation(agent, operation) { - if (agent.forgetting) throw new Error("directory is being forgotten"); - agent.activeOperations += 1; + function enqueue(runtime, operation) { + if (stopping) + return Promise.reject(new Error("agent registry is stopping")); + if ( + runtime.state === "closing" || + runtime.state === "stopped" || + runtime.stopped + ) + return Promise.reject(new Error("session runtime is closing")); + // Work admitted before a close request must drain; the close operation + // marks the runtime synchronously so later work is rejected here. + const result = runtime.operationQueue.then(operation); + runtime.operationQueue = result.catch(() => {}); + return result; + } + + function reserveSessionPath(sessionPath, runtime) { + const leased = runtimesBySessionPath.get(sessionPath); + const pending = pendingSessionLeases.get(sessionPath); + if ((leased && leased !== runtime) || (pending && pending !== runtime)) + throw new Error("session is already open in another runtime"); + pendingSessionLeases.set(sessionPath, runtime); + } + + function releaseSessionReservation(sessionPath, runtime) { + if (pendingSessionLeases.get(sessionPath) === runtime) + pendingSessionLeases.delete(sessionPath); + } + + async function ownedSessionPath( + sessionDir, + sessionPath, + { allowMissing = false } = {}, + ) { + const resolvedSessionDir = await realpath(sessionDir); + let resolvedSessionPath; try { - return await operation(); - } finally { - agent.activeOperations -= 1; - if (agent.activeOperations === 0) { - for (const resolve of agent.operationWaiters) resolve(); - agent.operationWaiters.clear(); - } - } - } - - function waitForAgentOperations(agent) { - if (agent.activeOperations === 0) return Promise.resolve(); - return new Promise((resolve) => agent.operationWaiters.add(resolve)); - } - - 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, + resolvedSessionPath = await realpath(sessionPath); + } catch (error) { + if (error?.code !== "ENOENT" || !allowMissing) throw error; + const resolvedParent = await realpath(path.dirname(sessionPath)); + resolvedSessionPath = path.join( + resolvedParent, + path.basename(sessionPath), ); } - } - - 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; - } - - async function forgetDirectory(worktreePath) { - if (stopping) throw new Error("agent registry is stopping"); - if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath)) - throw new TypeError("worktreePath must be an absolute path"); - let finishPendingForget; - const pendingForget = new Promise((resolve) => { - finishPendingForget = resolve; - }); - pendingForgetOperations.add(pendingForget); - try { - let canonicalPath = path.normalize(worktreePath); - if (!agentsByPath.has(canonicalPath)) { - try { - canonicalPath = await realpath(worktreePath); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - } - const homePath = canonicalHomeWorktree ?? (await realpath(homeWorktree)); - if (canonicalPath === homePath) - throw new Error("cannot forget the home directory"); - - const existingForget = forgettingPaths.get(canonicalPath); - if (existingForget) return existingForget; - const forgetting = (async () => { - try { - const creation = inFlight.get(canonicalPath); - if (creation) await creation; - const agent = agentsByPath.get(canonicalPath); - if (agent) { - agent.forgetting = true; - await waitForAgentOperations(agent); - if (agent.state === "streaming") - throw new Error("cannot forget a directory while Pi is working"); - agent.stopped = true; - agent.supervisor.stop(); - await agent.adapter.stop(); - agent.state = "stopped"; - agent.stateVersion += 1; - agent.listeners.clear(); - agentsByPath.delete(canonicalPath); - agentsById.delete(agent.id); - } - const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath); - await rm(path.join(sessionDir, SESSION_REFERENCE_FILE), { - force: true, - }); - return { worktreePath: canonicalPath }; - } catch (error) { - const agent = agentsByPath.get(canonicalPath); - if (agent) agent.forgetting = false; - throw error; - } - })(); - forgettingPaths.set(canonicalPath, forgetting); + async function updateRuntimeIdentity( + runtime, + stateResponse, + { expectedSessionPath, adapter = runtime.adapter } = {}, + ) { + if ( + runtime.stopped || + runtime.state === "closing" || + runtime.state === "stopped" || + runtime.adapter !== adapter || + runtimesByRuntimeId.get(runtime.runtimeId) !== runtime + ) + throw new Error("session runtime is closing"); + const state = stateResponse?.data ?? {}; + const nextPath = state.sessionFile; + let resolved; + if (typeof nextPath === "string" && path.isAbsolute(nextPath)) { try { - return await forgetting; - } finally { - if (forgettingPaths.get(canonicalPath) === forgetting) - forgettingPaths.delete(canonicalPath); + resolved = await ownedSessionPath(runtime.sessionDir, nextPath, { + allowMissing: true, + }); + } catch { + throw new Error("Pi reported a session outside its managed directory"); } - } finally { - pendingForgetOperations.delete(pendingForget); - finishPendingForget(); + } + if (expectedSessionPath && resolved !== expectedSessionPath) + throw new Error("Pi did not resume the requested session"); + if (resolved) { + const leased = runtimesBySessionPath.get(resolved); + const pending = pendingSessionLeases.get(resolved); + if ((leased && leased !== runtime) || (pending && pending !== runtime)) + throw new Error("session is already open in another runtime"); + } + if ( + runtime.stopped || + runtime.state === "closing" || + runtime.adapter !== adapter || + runtimesByRuntimeId.get(runtime.runtimeId) !== runtime + ) + throw new Error("session runtime is closing"); + if (typeof state.sessionName === "string") + runtime.sessionName = state.sessionName; + if (typeof state.sessionId === "string") + runtime.sessionId = state.sessionId; + if (resolved) { + if ( + runtime.sessionPath && + runtime.sessionPath !== resolved && + runtimesBySessionPath.get(runtime.sessionPath) === runtime + ) + runtimesBySessionPath.delete(runtime.sessionPath); + runtime.sessionPath = resolved; + runtimesBySessionPath.set(resolved, runtime); + releaseSessionReservation(resolved, runtime); + if (runtime.legacyDefault) + await persistSessionReference( + runtime.sessionDir, + resolved, + runtime.worktreePath, + ); + } + await persistWorkspace(); + } + + function publishAgent(runtime, type, data) { + const event = { + version: "v1", + seq: runtime.nextSequence++, + type, + agentId: runtime.agentId, + data, + }; + runtime.events.push(event); + if (runtime.events.length > eventLimit) runtime.events.shift(); + for (const listener of runtime.listeners) listener(event); + return event; + } + + function cacheEvent(runtime, type, data) { + runtime.lastActivity = new Date().toISOString(); + const event = data?.event ?? {}; + if (type === "agent_state" && typeof data?.state === "string") { + if (runtime.state === "closing" || runtime.state === "stopped") return; + runtime.state = data.state; + runtime.stateVersion += 1; + if (data.state === "idle") { + runtime.activeTool = undefined; + runtime.supervisor?.markHealthy(); + } + } + if (type === "queue") { + const followUp = Array.isArray(event.followUp) + ? event.followUp.length + : 0; + const steering = Array.isArray(event.steering) + ? event.steering.length + : 0; + runtime.queueCount = + typeof event.pendingMessageCount === "number" + ? event.pendingMessageCount + : followUp + steering; + } + if (type === "tool") { + if (String(event.type ?? "").endsWith("_end")) + runtime.activeTool = undefined; + else if (typeof event.toolName === "string") + runtime.activeTool = event.toolName; + } + if (type === "extension_ui_request") { + const method = event.method; + if (["select", "confirm", "input", "editor"].includes(method)) { + runtime.attention = true; + if (typeof event.id === "string") + runtime.extensions.set(event.id, event); + } + } + const message = event.message; + if ( + !runtime.firstMessage && + message?.role === "user" && + message.content !== undefined + ) + runtime.firstMessage = sessionPreview(message.content); + } + + function createRuntimeRecord({ + runtimeId = randomUUID(), + worktreePath, + sessionDir, + sessionPath, + sessionId, + legacyDefault = false, + openedAt = new Date().toISOString(), + }) { + return { + runtimeId, + agentId: `agent-${randomUUID()}`, + worktreePath, + sessionDir, + sessionPath, + sessionId, + legacyDefault, + openedAt, + state: "starting", + stateVersion: 0, + errorAttention: false, + attention: false, + queueCount: 0, + activeTool: undefined, + lastActivity: openedAt, + events: [], + nextSequence: 1, + listeners: new Set(), + extensions: new Map(), + operationQueue: Promise.resolve(), + adapter: undefined, + supervisor: undefined, + stopped: false, + recoveryPromise: undefined, + }; + } + + async function launch(runtime) { + let adapter; + const expectedSessionPath = runtime.sessionPath; + adapter = startAdapter({ + cwd: runtime.worktreePath, + sessionDir: runtime.sessionDir, + ...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}), + onEvent: (event) => { + if (runtime.adapter !== adapter || runtime.stopped) return; + cacheEvent(runtime, event.type, event.data); + publishAgent(runtime, event.type, event.data); + publishWorkspace("runtime_event", runtime, { + eventType: event.type, + eventData: event.data, + runtime: publicRuntime(runtime), + }); + if (event.type === "agent_state" && event.data?.state === "idle") { + void enqueue(runtime, async () => { + const state = await adapter.send({ type: "get_state" }); + await updateRuntimeIdentity(runtime, state, { adapter }); + }).catch((error) => { + if (runtime.stopped || runtime.state === "closing" || stopping) + return; + runtime.error = { + code: "identity_refresh", + message: error.message, + }; + runtime.errorAttention = true; + runtime.attention = true; + }); + } + }, + onError: (error) => { + if (runtime.adapter !== adapter || runtime.stopped) return; + runtime.state = "error"; + runtime.stateVersion += 1; + runtime.error = { + code: error.code ?? "unknown", + message: error.message, + }; + runtime.errorAttention = true; + runtime.attention = true; + publishAgent(runtime, "agent_state", { + state: "error", + error: runtime.error, + }); + publishWorkspace("runtime_event", runtime, { + eventType: "agent_state", + eventData: { state: "error", error: runtime.error }, + runtime: publicRuntime(runtime), + }); + if (["child_exited", "child_error"].includes(error.code)) { + // Dialog request IDs belong to the dead child and cannot be answered + // after recovery creates a replacement process. + runtime.extensions.clear(); + runtime.recoveryPromise = runtime.supervisor + .handleUnexpectedExit() + .then((nextAdapter) => { + if (!runtime.stopped) { + runtime.state = nextAdapter ? "idle" : "failed"; + runtime.stateVersion += 1; + if (nextAdapter) { + runtime.error = undefined; + runtime.errorAttention = false; + runtime.attention = runtime.extensions.size > 0; + } + publishAgent(runtime, "agent_state", { state: runtime.state }); + publishWorkspace("runtime_recovered", runtime, { + runtime: publicRuntime(runtime), + }); + } + return nextAdapter; + }); + } + }, + }); + runtime.adapter = adapter; + try { + const state = await adapter.send({ type: "get_state" }); + runtime.state = state?.data?.isStreaming ? "streaming" : "idle"; + runtime.stateVersion += 1; + await updateRuntimeIdentity(runtime, state, { + ...(expectedSessionPath ? { expectedSessionPath } : {}), + adapter, + }); + return adapter; + } catch (error) { + runtime.stopped = true; + await adapter.stop().catch(() => {}); + runtime.stopped = false; + if (runtime.adapter === adapter) runtime.adapter = undefined; + throw error; } } + async function openRuntimeInternal( + { + runtimeId = randomUUID(), + worktreePath, + sessionPath, + sessionId, + legacyDefault = false, + openedAt, + }, + { restore = false } = {}, + ) { + if (stopping) throw new Error("agent registry is stopping"); + const canonicalPath = await realpath(worktreePath); + const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath); + await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE }); + await chmod(sessionDir, SESSION_DIRECTORY_MODE); + let ownedPath; + if (sessionPath) { + ownedPath = await ownedSessionPath(sessionDir, sessionPath); + const existing = runtimesBySessionPath.get(ownedPath); + if (existing) { + if (existing.state === "closing" || existing.stopped) + throw new Error("session runtime is closing"); + if (restore && existing.runtimeId !== runtimeId) + throw new Error("session is already open in another runtime"); + return publicRuntime(existing); + } + } + const key = ownedPath ?? `${canonicalPath}:new:${runtimeId}`; + if (creatingByKey.has(key)) { + if (restore) + throw new Error("session is already opening in another runtime"); + return creatingByKey.get(key); + } + if (stopping) throw new Error("agent registry is stopping"); + if (ownedPath) reserveSessionPath(ownedPath, { runtimeId }); + const creation = (async () => { + if (stopping) throw new Error("agent registry is stopping"); + const runtime = createRuntimeRecord({ + runtimeId, + worktreePath: canonicalPath, + sessionDir, + sessionPath: ownedPath, + sessionId, + legacyDefault, + openedAt, + }); + if (stopping) throw new Error("agent registry is stopping"); + indexRuntime(runtime); + if (ownedPath) pendingSessionLeases.set(ownedPath, runtime); + runtime.supervisor = createRecoverySupervisor({ + start: () => launch(runtime), + ...(maxRecoveryAttempts ? { maxAttempts: maxRecoveryAttempts } : {}), + ...(recoveryDelayForAttempt + ? { delayForAttempt: recoveryDelayForAttempt } + : {}), + ...(sleep ? { sleep } : {}), + onState: (recovery) => { + runtime.state = + recovery.state === "recovering" ? "recovering" : runtime.state; + publishAgent(runtime, "recovery", recovery); + publishWorkspace("runtime_recovery", runtime, { recovery }); + }, + }); + try { + await launch(runtime); + await persistWorkspace(); + publishWorkspace("runtime_opened", runtime, { + runtime: publicRuntime(runtime), + }); + return publicRuntime(runtime); + } catch (error) { + if (ownedPath) releaseSessionReservation(ownedPath, runtime); + await runtime.adapter?.stop().catch(() => {}); + if (!restore) { + unindexRuntime(runtime); + throw error; + } + if ( + runtime.sessionPath && + runtimesBySessionPath.get(runtime.sessionPath) === runtime + ) + runtimesBySessionPath.delete(runtime.sessionPath); + // Preserve the manifest's requested path for dormant intent. The + // canonical lease is only meaningful while a child is live. + if (sessionPath) runtime.sessionPath = sessionPath; + if (runtime.agentId) runtimesByAgentId.delete(runtime.agentId); + runtime.agentId = undefined; + runtime.adapter = undefined; + runtime.state = "failed"; + runtime.errorAttention = true; + runtime.attention = true; + runtime.error = { + code: "restore_failed", + message: error instanceof Error ? error.message : String(error), + }; + await persistWorkspace(); + publishWorkspace("runtime_restore_failed", runtime, { + runtime: publicRuntime(runtime), + }); + return publicRuntime(runtime); + } + })(); + creatingByKey.set(key, creation); + try { + return await creation; + } finally { + creatingByKey.delete(key); + if (ownedPath) { + const pending = pendingSessionLeases.get(ownedPath); + if (pending?.runtimeId === runtimeId) + pendingSessionLeases.delete(ownedPath); + } + } + } + + function openRuntime(record, options) { + if (stopping) + return Promise.reject(new Error("agent registry is stopping")); + const operation = openRuntimeInternal(record, options); + pendingOpenOperations.add(operation); + void operation + .finally(() => pendingOpenOperations.delete(operation)) + .catch(() => {}); + return operation; + } + + function retainFailedRestore(record, error) { + const sessionDir = sessionDirectoryFor(sessionRoot, record.worktreePath); + const dormant = createRuntimeRecord({ ...record, sessionDir }); + dormant.agentId = undefined; + dormant.state = "failed"; + dormant.errorAttention = true; + dormant.attention = true; + dormant.error = { + code: "restore_failed", + message: error instanceof Error ? error.message : String(error), + }; + if (!runtimesByRuntimeId.has(dormant.runtimeId)) + indexRuntime(dormant, { leaseSession: false }); + publishWorkspace("runtime_restore_failed", dormant, { + runtime: publicRuntime(dormant), + }); + } + + async function restoreRecords(records) { + // Canonicalize saved-session identities before launching any children. This + // makes alias collisions deterministic and prevents a losing dormant record + // from racing the winner's lease acquisition or manifest persistence. + const prepared = []; + for (const record of records) { + try { + let canonicalSession; + if (record.sessionPath) { + const canonicalWorktree = await realpath(record.worktreePath); + const sessionDir = sessionDirectoryFor( + sessionRoot, + canonicalWorktree, + ); + canonicalSession = await ownedSessionPath( + sessionDir, + record.sessionPath, + ); + } + prepared.push({ record, canonicalSession }); + } catch (error) { + retainFailedRestore(record, error); + } + } + const preferredBySession = new Map(); + for (const entry of prepared) { + if (!entry.canonicalSession) continue; + const current = preferredBySession.get(entry.canonicalSession); + if ( + !current || + (entry.record.sessionPath === entry.canonicalSession && + current.record.sessionPath !== current.canonicalSession) + ) + preferredBySession.set(entry.canonicalSession, entry); + } + const restorable = []; + for (const entry of prepared) { + if ( + entry.canonicalSession && + preferredBySession.get(entry.canonicalSession) !== entry + ) { + retainFailedRestore( + entry.record, + new Error("session is already open in another runtime"), + ); + } else restorable.push(entry.record); + } + + let next = 0; + const workers = []; + const workerCount = Math.min(RESTORE_CONCURRENCY, restorable.length); + for (let workerIndex = 0; workerIndex < workerCount; workerIndex += 1) { + workers.push( + (async () => { + while (next < restorable.length) { + const record = restorable[next++]; + try { + await openRuntime(record, { restore: true }); + } catch (error) { + retainFailedRestore(record, error); + } + } + })(), + ); + } + await Promise.all(workers); + await persistWorkspace(); + } + + async function setLegacyDefault(runtime) { + for (const candidate of runtimesByWorktreePath.get(runtime.worktreePath) ?? + []) + candidate.legacyDefault = candidate === runtime; + runtime.legacyDefault = true; + await persistSessionReference( + runtime.sessionDir, + runtime.sessionPath, + runtime.worktreePath, + ); + } + + function releaseClosedSessionLease(runtime) { + if ( + runtime.sessionPath && + runtimesBySessionPath.get(runtime.sessionPath) === runtime + ) + runtimesBySessionPath.delete(runtime.sessionPath); + } + + async function performClose(runtime, wasWorkingAtRequest) { + const wasWorking = + wasWorkingAtRequest || + runtime.state === "streaming" || + runtime.queueCount > 0; + if (runtime.legacyDefault) { + const replacementDefault = [ + ...(runtimesByWorktreePath.get(runtime.worktreePath) ?? []), + ].find( + (candidate) => + candidate !== runtime && + candidate.agentId && + !candidate.stopped && + candidate.state !== "closing" && + candidate.state !== "stopped", + ); + runtime.legacyDefault = false; + if (replacementDefault) await setLegacyDefault(replacementDefault); + else + await persistSessionReference( + runtime.sessionDir, + undefined, + runtime.worktreePath, + ); + } + // Remove desired-open/UI intent now, but retain the session lease until + // the old child can no longer write to its JSONL file. + unindexRuntime(runtime, { releaseSessionLease: false }); + await persistWorkspace(); + runtime.stopped = true; + runtime.supervisor?.stop(); + let stopConfirmed = true; + if (runtime.adapter) { + if (wasWorking) + await runtime.adapter.send({ type: "abort" }).catch(() => {}); + const stopPromise = Promise.resolve().then(() => runtime.adapter.stop()); + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve("timeout"), closeTimeoutMs); + }); + const stopped = await Promise.race([ + stopPromise.then(() => "stopped"), + timeout, + ]); + clearTimeout(timer); + stopConfirmed = stopped === "stopped"; + if (!stopConfirmed) { + runtime.adapter.child?.kill?.("SIGKILL"); + // A kill request is not exit confirmation. Keep the lease until the + // adapter observes child exit; an unresolved adapter keeps it reserved. + void stopPromise + .then(() => releaseClosedSessionLease(runtime)) + .catch(() => {}); + } + } + if (stopConfirmed) releaseClosedSessionLease(runtime); + runtime.state = "stopped"; + publishWorkspace("runtime_closed", runtime, { + runtimeId: runtime.runtimeId, + worktreePath: runtime.worktreePath, + }); + return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath }; + } + + function closeRuntime(runtimeId) { + const runtime = getRuntime(runtimeId); + if (runtime.state === "closing" || runtime.state === "stopped") + return Promise.reject(new Error("session runtime is closing")); + const wasWorkingAtRequest = + runtime.state === "streaming" || runtime.queueCount > 0; + // Mark synchronously so concurrent default promotion cannot choose a + // runtime whose close has already been requested. Previously accepted work + // still drains before performClose. + runtime.state = "closing"; + publishWorkspace("runtime_closing", runtime, { + runtime: publicRuntime(runtime), + }); + const result = runtime.operationQueue.then(() => + performClose(runtime, wasWorkingAtRequest), + ); + runtime.operationQueue = result.catch(() => {}); + return result; + } + + async function refreshAfterLegacySessionChange( + runtime, + response, + { expectedSessionPath } = {}, + ) { + if (!response?.data?.cancelled) { + const state = await runtime.adapter.send({ type: "get_state" }); + await updateRuntimeIdentity(runtime, state, { + ...(expectedSessionPath ? { expectedSessionPath } : {}), + adapter: runtime.adapter, + }); + } + return response; + } + + async function beginLegacyNewSession(runtime) { + const response = await runtime.adapter.send({ type: "new_session" }); + if (response?.data?.cancelled) return response; + if ( + runtime.sessionPath && + runtimesBySessionPath.get(runtime.sessionPath) === runtime + ) + runtimesBySessionPath.delete(runtime.sessionPath); + runtime.sessionPath = undefined; + runtime.sessionId = undefined; + runtime.sessionName = undefined; + runtime.firstMessage = undefined; + if (runtime.legacyDefault) + await persistSessionReference( + runtime.sessionDir, + undefined, + runtime.worktreePath, + ); + await persistWorkspace(); + const state = await runtime.adapter.send({ type: "get_state" }); + await updateRuntimeIdentity(runtime, state, { adapter: runtime.adapter }); + return response; + } + + function workspaceReplay(cursor = 0) { + if (!Number.isSafeInteger(cursor) || cursor < 0) + throw new TypeError("cursor must be a non-negative integer"); + const firstAvailableSeq = workspaceEvents[0]?.seq ?? workspaceSequence + 1; + return { + bridgeInstanceId, + firstAvailableSeq, + latestSeq: workspaceSequence, + truncated: cursor < firstAvailableSeq - 1, + events: workspaceEvents.filter((event) => event.seq > cursor), + }; + } + + function workspace() { + const grouped = new Map( + [...managedWorktrees].map((worktreePath) => [worktreePath, []]), + ); + for (const runtime of runtimesByRuntimeId.values()) { + let group = grouped.get(runtime.worktreePath); + if (!group) { + group = []; + grouped.set(runtime.worktreePath, group); + } + group.push(publicRuntime(runtime)); + } + return { + bridgeInstanceId, + latestSeq: workspaceSequence, + directories: [...grouped.entries()] + .map(([worktreePath, runtimes]) => ({ + worktreePath, + isHome: worktreePath === canonicalHomeWorktree, + openCount: runtimes.length, + workingCount: runtimes.filter((entry) => entry.state === "streaming") + .length, + attentionCount: runtimes.filter((entry) => entry.attention).length, + recoveringCount: runtimes.filter( + (entry) => entry.state === "recovering", + ).length, + errorCount: runtimes.filter((entry) => + ["error", "failed"].includes(entry.state), + ).length, + runtimes, + })) + .sort((left, right) => + left.worktreePath.localeCompare(right.worktreePath), + ), + ...(manifestIssue ? { issue: manifestIssue } : {}), + }; + } + return { + get bridgeInstanceId() { + return bridgeInstanceId; + }, async start() { - const agent = await ensureAgent(homeWorktree); - canonicalHomeWorktree = agent.worktreePath; - return agent; + canonicalHomeWorktree = await realpath(homeWorktree); + managedWorktrees.add(canonicalHomeWorktree); + const loaded = await workspaceStore.load(); + manifestIssue = loaded.issue; + if (loaded.migrating) { + const sessionDir = sessionDirectoryFor( + sessionRoot, + canonicalHomeWorktree, + ); + await mkdir(sessionDir, { + recursive: true, + mode: SESSION_DIRECTORY_MODE, + }); + const reference = await readSessionReference(sessionDir); + const migrationRecord = { + runtimeId: randomUUID(), + worktreePath: canonicalHomeWorktree, + legacyDefault: true, + ...(reference.sessionPath + ? { sessionPath: reference.sessionPath } + : {}), + }; + if (reference.sessionPath) await restoreRecords([migrationRecord]); + else await openRuntime(migrationRecord); + await persistWorkspace(); + } else if (!loaded.issue) { + for (const worktreePath of loaded.manifest.directories) { + try { + managedWorktrees.add(await realpath(worktreePath)); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + const defaultWorktrees = new Set( + loaded.manifest.runtimes + .filter((record) => record.legacyDefault) + .map((record) => record.worktreePath), + ); + const records = loaded.manifest.runtimes.map((record) => { + if (defaultWorktrees.has(record.worktreePath)) return record; + defaultWorktrees.add(record.worktreePath); + return { ...record, legacyDefault: true }; + }); + await restoreRecords(records); + } + return this.listAgents()[0]; }, async selectWorktree(worktreePath) { - return ensureAgent(worktreePath); + if (stopping) throw new Error("agent registry is stopping"); + const canonicalPath = await realpath(worktreePath); + const liveRuntimes = [ + ...(runtimesByWorktreePath.get(canonicalPath) ?? []), + ].filter((runtime) => runtime.agentId); + const existing = + liveRuntimes.find((runtime) => runtime.legacyDefault) ?? + liveRuntimes[0]; + if (existing) { + if (!existing.legacyDefault) { + await setLegacyDefault(existing); + await persistWorkspace(); + } + return publicAgent(existing); + } + const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath); + await mkdir(sessionDir, { + recursive: true, + mode: SESSION_DIRECTORY_MODE, + }); + const reference = await readSessionReference(sessionDir); + const opened = await openRuntime({ + worktreePath: canonicalPath, + legacyDefault: true, + ...(reference.sessionPath + ? { sessionPath: reference.sessionPath } + : {}), + }); + return publicAgent(getRuntime(opened.runtimeId)); }, + async createSessionRuntime(worktreePath) { + return openRuntime({ worktreePath }); + }, + async openSessionRuntime(worktreePath, sessionPath) { + return openRuntime({ worktreePath, sessionPath }); + }, + closeSessionRuntime: closeRuntime, listAgents() { - return [...agentsById.values()] + return [...runtimesByAgentId.values()] .map(publicAgent) .sort((left, right) => left.worktreePath.localeCompare(right.worktreePath), ); }, async listDirectories() { - return listDirectoryCatalog( - sessionRoot, - agentsByPath, - canonicalHomeWorktree, + let entries = []; + try { + entries = await readdir(sessionRoot, { withFileTypes: true }); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + const references = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + const reference = await readSessionReference( + path.join(sessionRoot, entry.name), + ); + return reference.worktreePath; + }), ); + const paths = new Set([ + ...runtimesByWorktreePath.keys(), + ...references.filter(Boolean), + ]); + return [...paths] + .map((worktreePath) => { + const runtime = [ + ...(runtimesByWorktreePath.get(worktreePath) ?? []), + ].find((entry) => entry.agentId); + return { + worktreePath, + state: runtime?.state ?? "inactive", + isHome: worktreePath === canonicalHomeWorktree, + ...(runtime ? { agentId: runtime.agentId } : {}), + }; + }) + .sort((left, right) => + left.worktreePath.localeCompare(right.worktreePath), + ); + }, + async forgetDirectory(worktreePath) { + let canonicalPath = path.normalize(worktreePath); + try { + canonicalPath = await realpath(worktreePath); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + if (canonicalPath === canonicalHomeWorktree) + throw new Error("cannot forget the home directory"); + if ((runtimesByWorktreePath.get(canonicalPath)?.size ?? 0) > 0) + throw new Error( + "cannot forget a directory while session runtimes are open", + ); + const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath); + await rm(path.join(sessionDir, SESSION_REFERENCE_FILE), { force: true }); + managedWorktrees.delete(canonicalPath); + await persistWorkspace(); + return { worktreePath: canonicalPath }; }, - forgetDirectory, async listSessions(agentId) { - const agent = getAgent(agentId); - return listSessionSummaries(agent.sessionDir, agent.sessionPath); + const runtime = getAgent(agentId); + return listSessionSummaries(runtime.sessionDir, runtime.sessionPath); + }, + async listDirectorySessions(worktreePath) { + const canonicalPath = await realpath(worktreePath); + const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath); + const sessions = await listSessionSummaries(sessionDir); + return sessions.map((session) => { + const openRuntime = runtimesBySessionPath.get(session.path); + return { + ...session, + isCurrent: false, + ...(openRuntime ? { runtimeId: openRuntime.runtimeId } : {}), + }; + }); + }, + getWorkspace: workspace, + getWorkspaceSummary() { + const runtimes = [...runtimesByRuntimeId.values()]; + return { + bridgeInstanceId, + latestSeq: workspaceSequence, + openCount: runtimes.length, + workingCount: runtimes.filter( + (runtime) => runtime.state === "streaming", + ).length, + attentionCount: runtimes.filter((runtime) => runtime.attention).length, + recoveringCount: runtimes.filter( + (runtime) => runtime.state === "recovering", + ).length, + errorCount: runtimes.filter((runtime) => + ["error", "failed"].includes(runtime.state), + ).length, + directoryCount: new Set(runtimes.map((runtime) => runtime.worktreePath)) + .size, + resourceWarning: runtimes.length >= RESOURCE_WARNING_COUNT, + }; + }, + async getSessionRuntimeSnapshot(runtimeId) { + const runtime = getRuntime(runtimeId); + if (!runtime.adapter) + return { + bridgeInstanceId, + latestSeq: workspaceSequence, + runtime: publicRuntime(runtime), + extensions: [...runtime.extensions.values()], + }; + return enqueue(runtime, async () => { + const adapter = runtime.adapter; + const safe = async (command, fallback) => { + try { + return await adapter.send({ type: command }); + } catch { + return fallback; + } + }; + const state = await safe("get_state", { data: {} }); + const stats = await safe("get_session_stats", { data: {} }); + const transcript = await safe("get_messages", { + data: { messages: [] }, + }); + const commands = await safe("get_commands", { data: { commands: [] } }); + const models = await safe("get_available_models", { + data: { models: [] }, + }); + await updateRuntimeIdentity(runtime, state, { adapter }); + return { + bridgeInstanceId, + latestSeq: workspaceSequence, + runtime: publicRuntime(runtime), + state, + stats, + transcript, + commands, + models, + extensions: [...runtime.extensions.values()], + }; + }); }, eventsAfter(agentId, cursor = 0) { if (!Number.isSafeInteger(cursor) || cursor < 0) @@ -557,76 +1339,139 @@ export function createAgentRegistry({ subscribe(agentId, cursor, listener) { if (typeof listener !== "function") throw new TypeError("listener must be a function"); - const agent = getAgent(agentId); + const runtime = getAgent(agentId); const events = this.eventsAfter(agentId, cursor); - agent.listeners.add(listener); + runtime.listeners.add(listener); return { events, - unsubscribe: () => agent.listeners.delete(listener), + unsubscribe: () => runtime.listeners.delete(listener), + }; + }, + workspaceEventsAfter: workspaceReplay, + subscribeWorkspace(cursor, listener) { + if (typeof listener !== "function") + throw new TypeError("listener must be a function"); + const replay = workspaceReplay(cursor); + workspaceListeners.add(listener); + return { + ...replay, + unsubscribe: () => workspaceListeners.delete(listener), }; }, async waitForRecovery(agentId) { return getAgent(agentId).recoveryPromise; }, async retry(agentId) { - const agent = getAgent(agentId); - return withAgentOperation(agent, async () => { - agent.supervisor.markHealthy(); - agent.recoveryPromise = agent.supervisor.handleUnexpectedExit(); - await agent.recoveryPromise; - return publicAgent(agent); + const runtime = getAgent(agentId); + return enqueue(runtime, async () => { + runtime.supervisor.markHealthy(); + runtime.recoveryPromise = runtime.supervisor.handleUnexpectedExit(); + await runtime.recoveryPromise; + return publicAgent(runtime); }); }, async restart(agentId) { - const agent = getAgent(agentId); - return withAgentOperation(agent, async () => { - await agent.adapter.stop(); - agent.supervisor.markHealthy(); - agent.recoveryPromise = agent.supervisor.handleUnexpectedExit(); - await agent.recoveryPromise; - return publicAgent(agent); + const runtime = getAgent(agentId); + return enqueue(runtime, async () => { + const replacedAdapter = runtime.adapter; + // Detach the old child before stopping it so late events cannot mutate + // presentation state that belongs to the replacement generation. + runtime.adapter = undefined; + await replacedAdapter.stop(); + // Dialog request IDs belong to the terminated child and cannot be + // answered by its replacement. + runtime.extensions.clear(); + runtime.attention = runtime.errorAttention; + runtime.supervisor.markHealthy(); + runtime.recoveryPromise = runtime.supervisor.handleUnexpectedExit(); + await runtime.recoveryPromise; + return publicAgent(runtime); }); }, - async route(agentId, operation, payload) { - const agent = getAgent(agentId); - return withAgentOperation(agent, async () => { - if (operation === "switch_session") - return switchSession(agent, payload.sessionPath); - if (operation === "new_session") return newSession(agent); - const previousState = agent.state; - const previousStateVersion = agent.stateVersion; + async route(agentId, operation, payload = {}) { + const runtime = getAgent(agentId); + return enqueue(runtime, async () => { + if (operation === "switch_session") { + if (runtime.state === "streaming") + throw new Error("cannot switch sessions while Pi is working"); + const ownedPath = await ownedSessionPath( + runtime.sessionDir, + payload.sessionPath, + ); + reserveSessionPath(ownedPath, runtime); + try { + const response = await runtime.adapter.send({ + type: "switch_session", + sessionPath: ownedPath, + }); + return await refreshAfterLegacySessionChange(runtime, response); + } finally { + releaseSessionReservation(ownedPath, runtime); + } + } + if (operation === "new_session") { + if (runtime.state === "streaming") + throw new Error("cannot start a session while Pi is working"); + return beginLegacyNewSession(runtime); + } + const previousState = runtime.state; + const previousStateVersion = runtime.stateVersion; const startsWork = operation === "prompt" || (operation === "submit_prompt" && previousState !== "streaming"); - if (startsWork) agent.state = "streaming"; + if (startsWork) { + runtime.state = "streaming"; + if (!runtime.firstMessage && typeof payload.message === "string") + runtime.firstMessage = payload.message; + } try { - return await routeCommand( - agent.adapter, + if ( + operation === "extension_response" && + !runtime.extensions.has(payload.requestId) + ) + throw new Error( + "extension request is not actionable for this runtime", + ); + const response = await routeCommand( + runtime.adapter, operation, payload, previousState, ); + if (operation === "set_session_name") { + runtime.sessionName = payload.name; + await persistWorkspace(); + } + if (operation === "extension_response") { + runtime.extensions.delete(payload.requestId); + runtime.attention = + runtime.errorAttention || runtime.extensions.size > 0; + } + return response; } catch (error) { - if (startsWork && agent.stateVersion === previousStateVersion) - agent.state = previousState; + if ( + startsWork && + runtime.state === "streaming" && + runtime.stateVersion === previousStateVersion + ) + runtime.state = previousState; throw error; } }); }, async stop() { stopping = true; - await Promise.allSettled([...inFlight.values()]); - await Promise.all([...pendingForgetOperations]); - const agents = [...agentsById.values()]; - for (const agent of agents) agent.forgetting = true; - await Promise.all(agents.map(waitForAgentOperations)); + while (pendingOpenOperations.size > 0) + await Promise.allSettled([...pendingOpenOperations]); + const runtimes = [...runtimesByRuntimeId.values()]; + await Promise.all(runtimes.map((runtime) => runtime.operationQueue)); await Promise.all( - agents.map(async (agent) => { - agent.stopped = true; - agent.supervisor.stop(); - await agent.adapter.stop(); - agent.state = "stopped"; - agent.stateVersion += 1; + runtimes.map(async (runtime) => { + runtime.stopped = true; + runtime.supervisor?.stop(); + await runtime.adapter?.stop().catch(() => {}); + runtime.state = "stopped"; + return undefined; }), ); }, @@ -634,3 +1479,4 @@ export function createAgentRegistry({ } export const sessionDirectoryMode = SESSION_DIRECTORY_MODE; +export const sessionDirectoryPath = sessionDirectoryFor; diff --git a/src/bridge/pi-rpc-adapter.js b/src/bridge/pi-rpc-adapter.js index b00d028..428d7f3 100644 --- a/src/bridge/pi-rpc-adapter.js +++ b/src/bridge/pi-rpc-adapter.js @@ -4,6 +4,7 @@ import path from "node:path"; import { StringDecoder } from "node:string_decoder"; export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000; +export const COMPACT_COMMAND_TIMEOUT_MS = 5 * 60_000; export const MAX_PI_RPC_FRAME_BYTES = 1024 * 1024; const supportedCommands = new Set([ @@ -18,6 +19,8 @@ const supportedCommands = new Set([ "get_messages", "get_available_models", "get_commands", + "set_session_name", + "compact", "set_model", "set_thinking_level", ]); @@ -126,6 +129,7 @@ export function startPiRpcAdapter({ onEvent = () => {}, onError = () => {}, commandTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, + compactCommandTimeoutMs = COMPACT_COMMAND_TIMEOUT_MS, maxFrameBytes = MAX_PI_RPC_FRAME_BYTES, }) { assertAbsolutePath(cwd, "cwd"); @@ -316,6 +320,10 @@ export function startPiRpcAdapter({ } const id = `bridge-${randomUUID()}`; + const timeoutMs = + commandInput.type === "compact" + ? compactCommandTimeoutMs + : commandTimeoutMs; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { pending.delete(id); @@ -325,7 +333,7 @@ export function startPiRpcAdapter({ `Pi RPC command timed out: ${commandInput.type}`, ), ); - }, commandTimeoutMs); + }, timeoutMs); timeout.unref?.(); pending.set(id, { resolve, reject, timeout }); try { diff --git a/src/bridge/service.js b/src/bridge/service.js index 710fa7e..f41eaaa 100644 --- a/src/bridge/service.js +++ b/src/bridge/service.js @@ -1,5 +1,9 @@ import path from "node:path"; -import { createAgentRegistry, UnknownAgentError } from "./agent-registry.js"; +import { + createAgentRegistry, + UnknownAgentError, + UnknownRuntimeError, +} from "./agent-registry.js"; import { startPiRpcAdapter } from "./pi-rpc-adapter.js"; import { ProtocolError } from "../protocol/index.js"; import { createRuntimePaths, startBridgeServer } from "./runtime.js"; @@ -32,6 +36,35 @@ export async function startBridgeService({ }; case "forget_directory": return registry.forgetDirectory(request.payload.worktreePath); + case "get_workspace": + return registry.getWorkspace(); + case "get_workspace_summary": + return registry.getWorkspaceSummary(); + case "create_session_runtime": + return { + runtime: await registry.createSessionRuntime( + request.payload.worktreePath, + ), + }; + case "open_session_runtime": + return { + runtime: await registry.openSessionRuntime( + request.payload.worktreePath, + request.payload.sessionPath, + ), + }; + case "close_session_runtime": + return registry.closeSessionRuntime(request.payload.runtimeId); + case "list_directory_sessions": + return { + sessions: await registry.listDirectorySessions( + request.payload.worktreePath, + ), + }; + case "get_session_runtime_snapshot": + return registry.getSessionRuntimeSnapshot(request.payload.runtimeId); + case "subscribe_workspace": + return registry.workspaceEventsAfter(request.payload.cursor ?? 0); case "subscribe": return { events: registry.eventsAfter( @@ -51,6 +84,8 @@ export async function startBridgeService({ } catch (error) { if (error instanceof UnknownAgentError) throw new ProtocolError("unknown_agent", error.message); + if (error instanceof UnknownRuntimeError) + throw new ProtocolError("unknown_runtime", error.message); throw error; } }; @@ -59,6 +94,22 @@ export async function startBridgeService({ runtimeDir, handleRequest: dispatch, subscribe: (request, notify) => { + if (request.op === "subscribe_workspace") { + const subscription = registry.subscribeWorkspace( + request.payload.cursor ?? 0, + notify, + ); + return { + result: { + bridgeInstanceId: subscription.bridgeInstanceId, + firstAvailableSeq: subscription.firstAvailableSeq, + latestSeq: subscription.latestSeq, + truncated: subscription.truncated, + events: subscription.events, + }, + unsubscribe: subscription.unsubscribe, + }; + } const subscription = registry.subscribe( request.agentId, request.payload.cursor ?? 0, diff --git a/src/bridge/unix-server.js b/src/bridge/unix-server.js index 2682ace..131ce70 100644 --- a/src/bridge/unix-server.js +++ b/src/bridge/unix-server.js @@ -88,7 +88,10 @@ export async function startUnixSocketServer({ const normalized = line.endsWith("\r") ? line.slice(0, -1) : line; try { const request = parseRequestFrame(normalized, maxFrameBytes); - if (request.op === "subscribe" && subscribe) { + if ( + (request.op === "subscribe" || request.op === "subscribe_workspace") && + subscribe + ) { await handleSubscription(request); return; } diff --git a/src/bridge/workspace-store.js b/src/bridge/workspace-store.js new file mode 100644 index 0000000..feefdbd --- /dev/null +++ b/src/bridge/workspace-store.js @@ -0,0 +1,153 @@ +import { + chmod, + mkdir, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const WORKSPACE_VERSION = 2; +const WORKSPACE_FILE = "bridge-workspace-v2.json"; +const DIRECTORY_MODE = 0o700; +const FILE_MODE = 0o600; + +function validRuntime(record) { + return ( + record && + typeof record === "object" && + typeof record.runtimeId === "string" && + record.runtimeId.length > 0 && + typeof record.worktreePath === "string" && + path.isAbsolute(record.worktreePath) && + (record.sessionPath === undefined || + (typeof record.sessionPath === "string" && + path.isAbsolute(record.sessionPath))) && + (record.sessionId === undefined || typeof record.sessionId === "string") && + (record.legacyDefault === undefined || + typeof record.legacyDefault === "boolean") + ); +} + +function normalizeManifest(value) { + if ( + !value || + typeof value !== "object" || + value.version !== WORKSPACE_VERSION || + !Array.isArray(value.runtimes) || + !value.runtimes.every(validRuntime) || + (value.directories !== undefined && + (!Array.isArray(value.directories) || + !value.directories.every( + (directory) => + typeof directory === "string" && path.isAbsolute(directory), + ))) + ) + throw new Error("workspace manifest has an unsupported or invalid shape"); + const runtimeIds = new Set(); + const sessionPaths = new Set(); + const directories = new Set(value.directories ?? []); + if (directories.size !== (value.directories ?? []).length) + throw new Error("workspace manifest repeats a directory"); + for (const runtime of value.runtimes) directories.add(runtime.worktreePath); + for (const runtime of value.runtimes) { + if (runtimeIds.has(runtime.runtimeId)) + throw new Error( + `workspace manifest repeats runtime ${runtime.runtimeId}`, + ); + runtimeIds.add(runtime.runtimeId); + if (runtime.sessionPath) { + if (sessionPaths.has(runtime.sessionPath)) + throw new Error( + `workspace manifest repeats session ${runtime.sessionPath}`, + ); + sessionPaths.add(runtime.sessionPath); + } + } + return { + version: WORKSPACE_VERSION, + migrated: value.migrated === true, + directories: [...directories].sort(), + runtimes: value.runtimes.map((runtime) => ({ ...runtime })), + }; +} + +export function createWorkspaceStore(sessionRoot) { + if (typeof sessionRoot !== "string" || !path.isAbsolute(sessionRoot)) + throw new TypeError("sessionRoot must be an absolute path"); + const filePath = path.join(sessionRoot, WORKSPACE_FILE); + let writeQueue = Promise.resolve(); + + async function load() { + await mkdir(sessionRoot, { recursive: true, mode: DIRECTORY_MODE }); + await chmod(sessionRoot, DIRECTORY_MODE); + try { + return { + manifest: normalizeManifest( + JSON.parse(await readFile(filePath, "utf8")), + ), + migrating: false, + }; + } catch (error) { + if (error?.code === "ENOENT") { + return { + manifest: { + version: WORKSPACE_VERSION, + migrated: false, + directories: [], + runtimes: [], + }, + migrating: true, + }; + } + return { + manifest: { + version: WORKSPACE_VERSION, + migrated: true, + directories: [], + runtimes: [], + }, + migrating: false, + issue: { + code: "invalid_workspace_manifest", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + } + + function save(manifest) { + const normalized = normalizeManifest({ ...manifest, migrated: true }); + const operation = writeQueue + .catch(() => {}) + .then(async () => { + await mkdir(sessionRoot, { recursive: true, mode: DIRECTORY_MODE }); + await chmod(sessionRoot, DIRECTORY_MODE); + const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile( + temporaryPath, + `${JSON.stringify(normalized, null, 2)}\n`, + { + encoding: "utf8", + mode: FILE_MODE, + }, + ); + await chmod(temporaryPath, FILE_MODE); + await rename(temporaryPath, filePath); + await chmod(filePath, FILE_MODE); + } finally { + await rm(temporaryPath, { force: true }).catch(() => {}); + } + }); + writeQueue = operation.catch(() => {}); + return operation; + } + + return { filePath, load, save }; +} + +export const workspaceManifestVersion = WORKSPACE_VERSION; +export const workspaceManifestFile = WORKSPACE_FILE; diff --git a/src/client/noctalia-ipc.js b/src/client/noctalia-ipc.js index e54d8b4..e148d0e 100644 --- a/src/client/noctalia-ipc.js +++ b/src/client/noctalia-ipc.js @@ -7,29 +7,16 @@ 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 (!state || typeof state !== "object") throw new TypeError("state is required"); + const { state: bridgeState, projectLabel, attentionCount, detail, openCount = 0, workingCount = 0, recoveringCount = 0, errorCount = 0 } = state; if ( - typeof bridgeState !== "string" || - typeof projectLabel !== "string" || - !Number.isSafeInteger(attentionCount) || + typeof bridgeState !== "string" || typeof projectLabel !== "string" || + ![attentionCount, openCount, workingCount, recoveringCount, errorCount].every(Number.isSafeInteger) || typeof detail !== "string" - ) { - throw new TypeError( - "state must contain string state/projectLabel/detail and integer attentionCount", - ); - } + ) throw new TypeError("state must contain presentation strings and integer counts"); await execute("qs", [ - "-c", - "noctalia-shell", - "ipc", - "call", - "plugin:pi-status-bridge", - "setBridgeState", - bridgeState, - projectLabel, - String(attentionCount), - detail, + "-c", "noctalia-shell", "ipc", "call", "plugin:pi-status-bridge", "setBridgeState", + bridgeState, projectLabel, String(attentionCount), detail, String(openCount), + String(workingCount), String(recoveringCount), String(errorCount), ]); } diff --git a/src/client/noctalia-relay-cli.js b/src/client/noctalia-relay-cli.js index 765910b..91ca147 100755 --- a/src/client/noctalia-relay-cli.js +++ b/src/client/noctalia-relay-cli.js @@ -10,25 +10,23 @@ function flag(args, name) { async function run() { const args = process.argv.slice(2); - const socketPath = - flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET; + 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 --agent ", - ); + if (!socketPath) + throw new Error("Usage: pi-status-bridge-noctalia-relay --socket [--agent ]"); 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}`); + let agent; + if (agentId) { + const agents = await client.request("list_agents"); + 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`), - ); - }, + ...(agent ? { agent } : {}), + onState: (state) => void publishNoctaliaState(state).catch((error) => + process.stderr.write(`${error.message}\n`), + ), }); await relay.start(); await new Promise((resolve) => { @@ -41,8 +39,6 @@ async function run() { } run().catch((error) => { - process.stderr.write( - `${error instanceof Error ? error.message : "Noctalia relay failed"}\n`, - ); + process.stderr.write(`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`); process.exitCode = 1; }); diff --git a/src/client/noctalia-relay.js b/src/client/noctalia-relay.js index 67e376c..6190739 100644 --- a/src/client/noctalia-relay.js +++ b/src/client/noctalia-relay.js @@ -1,58 +1,102 @@ import path from "node:path"; -function detailFor(state) { +function detailForSummary(summary) { + return [ + `${summary.openCount} open`, + `${summary.workingCount} working`, + `${summary.attentionCount} attention`, + `${summary.recoveringCount} recovering`, + `${summary.errorCount} errors`, + ].join(" · "); +} + +function stateForSummary(summary) { + if (summary.errorCount) return "error"; + if (summary.recoveringCount) return "recovering"; + if (summary.workingCount) return "streaming"; + return "idle"; +} + +function summaryState(summary) { + return { + state: stateForSummary(summary), + projectLabel: `${summary.openCount} session${summary.openCount === 1 ? "" : "s"}`, + attentionCount: summary.attentionCount, + detail: detailForSummary(summary), + openCount: summary.openCount, + workingCount: summary.workingCount, + recoveringCount: summary.recoveringCount, + errorCount: summary.errorCount, + }; +} + +function legacyDetail(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"; + 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) +/** + * Relays workspace-wide presentation state to Noctalia. Passing `agent` keeps + * the former agent-scoped behavior for external compatibility callers only. + */ +export function createNoctaliaStateRelay({ client, agent, onState, pollMs = 2_000 }) { + if (!client || typeof client.request !== "function") + throw new TypeError("client must support request"); + if (agent && (!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"); + if (!agent && (!Number.isSafeInteger(pollMs) || pollMs < 250)) + throw new TypeError("pollMs must be an integer of at least 250ms"); + 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(); - }; + let timer; + let stopped = false; + const publishLegacy = (state, attentionCount) => onState({ + state, + projectLabel: path.basename(agent.worktreePath) || agent.worktreePath, + attentionCount, + detail: legacyDetail(state), + openCount: 1, + workingCount: state === "streaming" ? 1 : 0, + recoveringCount: state === "recovering" ? 1 : 0, + errorCount: ["error", "failed"].includes(state) ? 1 : 0, + }); 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); + if (agent) { + if (typeof client.subscribe !== "function") + throw new TypeError("legacy agent relay requires client.subscribe"); + const response = await client.request("get_state", { agentId: agent.id }); + let state = response?.data?.isStreaming ? "streaming" : "idle"; + let attentionCount = 0; + publishLegacy(state, attentionCount); + unsubscribe = await client.subscribe(agent.id, 0, (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); + publishLegacy(state, attentionCount); + }); + return; + } + const refresh = async () => { + try { + const summary = await client.request("get_workspace_summary"); + if (!stopped) onState(summaryState(summary)); + } finally { + if (!stopped) timer = setTimeout(() => void refresh(), pollMs); + } + }; + await refresh(); }, stop() { + stopped = true; + clearTimeout(timer); unsubscribe(); unsubscribe = () => {}; }, diff --git a/src/protocol/index.js b/src/protocol/index.js index 0fdea25..28a2413 100644 --- a/src/protocol/index.js +++ b/src/protocol/index.js @@ -8,6 +8,14 @@ const requestOperations = new Map([ ["list_directories", { agent: false, payload: "none" }], ["select_agent", { agent: false, payload: "worktree" }], ["forget_directory", { agent: false, payload: "worktree" }], + ["get_workspace", { agent: false, payload: "none" }], + ["get_workspace_summary", { agent: false, payload: "none" }], + ["create_session_runtime", { agent: false, payload: "worktree" }], + ["open_session_runtime", { agent: false, payload: "worktreeSession" }], + ["close_session_runtime", { agent: false, payload: "runtime" }], + ["list_directory_sessions", { agent: false, payload: "worktree" }], + ["get_session_runtime_snapshot", { agent: false, payload: "runtime" }], + ["subscribe_workspace", { agent: false, payload: "cursor" }], ["get_state", { agent: true, payload: "none" }], ["get_session_stats", { agent: true, payload: "none" }], ["list_sessions", { agent: true, payload: "none" }], @@ -25,6 +33,8 @@ const requestOperations = new Map([ ["restart", { agent: true, payload: "none" }], ["get_available_models", { agent: true, payload: "none" }], ["get_commands", { agent: true, payload: "none" }], + ["set_session_name", { agent: true, payload: "sessionName" }], + ["compact", { agent: true, payload: "compact" }], ["set_model", { agent: true, payload: "model" }], ["set_thinking_level", { agent: true, payload: "thinking" }], ]); @@ -100,10 +110,10 @@ function assertString(value, field, { maxLength = 4096, pattern } = {}) { 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, - }); + throw new ProtocolError( + "invalid_message", + "payload.cursor must be a non-negative integer", + ); } function validatePayload(kind, value) { @@ -115,6 +125,7 @@ function validatePayload(kind, value) { ); return undefined; } + if (kind === "compact" && value === undefined) return undefined; const payload = assertRecord(value, "payload"); switch (kind) { @@ -137,9 +148,7 @@ function validatePayload(kind, value) { const sessionPath = assertString( payload.sessionPath, "payload.sessionPath", - { - maxLength: 4096, - }, + { maxLength: 4096 }, ); if (!path.isAbsolute(sessionPath)) throw new ProtocolError( @@ -148,6 +157,38 @@ function validatePayload(kind, value) { ); return { sessionPath }; } + case "worktreeSession": { + assertAllowedKeys( + payload, + new Set(["worktreePath", "sessionPath"]), + "payload", + ); + const worktreePath = assertString( + payload.worktreePath, + "payload.worktreePath", + { maxLength: 4096 }, + ); + const sessionPath = assertString( + payload.sessionPath, + "payload.sessionPath", + { maxLength: 4096 }, + ); + if (!path.isAbsolute(worktreePath) || !path.isAbsolute(sessionPath)) + throw new ProtocolError( + "invalid_message", + "payload worktree and session paths must be absolute", + ); + return { worktreePath, sessionPath }; + } + case "runtime": { + assertAllowedKeys(payload, new Set(["runtimeId"]), "payload"); + return { + runtimeId: assertString(payload.runtimeId, "payload.runtimeId", { + maxLength: 128, + pattern: idPattern, + }), + }; + } case "cursor": { assertAllowedKeys(payload, new Set(["cursor"]), "payload"); return { @@ -185,6 +226,32 @@ function validatePayload(kind, value) { response, }; } + case "sessionName": { + assertAllowedKeys(payload, new Set(["name"]), "payload"); + const name = assertString(payload.name, "payload.name", { + maxLength: 4096, + }).trim(); + if (!name) + throw new ProtocolError( + "invalid_message", + "payload.name must not be blank", + ); + return { name }; + } + case "compact": { + assertAllowedKeys(payload, new Set(["customInstructions"]), "payload"); + const customInstructions = assertString( + payload.customInstructions, + "payload.customInstructions", + { maxLength: 32 * 1024 }, + ).trim(); + if (!customInstructions) + throw new ProtocolError( + "invalid_message", + "payload.customInstructions must not be blank", + ); + return { customInstructions }; + } case "model": { assertAllowedKeys(payload, new Set(["provider", "modelId"]), "payload"); return { diff --git a/test/agent-recovery.test.js b/test/agent-recovery.test.js index ba9703a..7a1d1d8 100644 --- a/test/agent-recovery.test.js +++ b/test/agent-recovery.test.js @@ -1,16 +1,25 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp } from "node:fs/promises"; +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"; +import { + createAgentRegistry, + sessionDirectoryPath, +} 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 sessionDir = sessionDirectoryPath(sessionRoot, home); + await mkdir(sessionDir, { recursive: true }); + const sessionFile = join(sessionDir, "pi-session.jsonl"); + await writeFile( + sessionFile, + `${JSON.stringify({ type: "session", version: 3, id: "recovery", cwd: home })}\n`, + ); const calls = []; const registry = createAgentRegistry({ homeWorktree: home, diff --git a/test/agent-registry.test.js b/test/agent-registry.test.js index 1c400d6..ca9bd43 100644 --- a/test/agent-registry.test.js +++ b/test/agent-registry.test.js @@ -134,6 +134,10 @@ test("drains a forget still resolving its canonical path during shutdown", async await stop(); }; + const selectedFeature = registry + .listAgents() + .find((agent) => agent.worktreePath === worktrees.feature); + await registry.closeSessionRuntime(selectedFeature.runtimeId); const forgetting = registry.forgetDirectory(featureAlias); const stopping = registry.stop(); await Promise.all([forgetting, stopping]); @@ -170,6 +174,11 @@ test("forgets a directory without deleting its resumable session history", async `${JSON.stringify({ sessionPath, worktreePath: worktrees.feature })}\n`, ); + await assert.rejects( + registry.forgetDirectory(worktrees.feature), + /runtimes are open/, + ); + await registry.closeSessionRuntime(feature.runtimeId); await registry.forgetDirectory(worktrees.feature); assert.equal(fixture.calls[1].adapter.stopped, true); @@ -227,10 +236,14 @@ test("coordinates forgetting with directory creation and active commands", async const selecting = registry.selectWorktree(worktrees.feature); await featureStarted; - const forgettingDuringCreation = registry.forgetDirectory(worktrees.feature); releaseFeatureCreation(); - await selecting; - await forgettingDuringCreation; + const selectedDuringCreation = await selecting; + await assert.rejects( + registry.forgetDirectory(worktrees.feature), + /runtimes are open/, + ); + await registry.closeSessionRuntime(selectedDuringCreation.runtimeId); + await registry.forgetDirectory(worktrees.feature); assert.equal(fixture.calls[1].adapter.stopped, true); assert.equal( (await registry.listDirectories()).some( @@ -252,15 +265,18 @@ test("coordinates forgetting with directory creation and active commands", async await stopGate; await stop(); }; - const firstForget = registry.forgetDirectory(worktrees.feature); - const secondForget = registry.forgetDirectory(worktrees.feature); - await new Promise((resolve) => setImmediate(resolve)); await assert.rejects( - registry.selectWorktree(worktrees.feature), - /being forgotten/, + registry.forgetDirectory(worktrees.feature), + /runtimes are open/, ); + const closing = registry.closeSessionRuntime( + registry.listAgents().find((agent) => agent.worktreePath === worktrees.feature) + .runtimeId, + ); + await new Promise((resolve) => setImmediate(resolve)); releaseStop(); - await Promise.all([firstForget, secondForget]); + await closing; + await registry.forgetDirectory(worktrees.feature); assert.equal(stopCalls, 1); assert.equal(registry.listAgents().length, 1); @@ -276,15 +292,15 @@ test("coordinates forgetting with directory creation and active commands", async const prompt = registry.route(feature.id, "prompt", { message: "Keep working", }); - const forgettingDuringPrompt = registry.forgetDirectory(worktrees.feature); - await new Promise((resolve) => setImmediate(resolve)); + await assert.rejects( + registry.forgetDirectory(worktrees.feature), + /runtimes are open/, + ); assert.equal(adapter.stopped, false); releasePrompt({ type: "response", success: true }); await prompt; - await assert.rejects(forgettingDuringPrompt, /while Pi is working/); - assert.equal(adapter.stopped, false); - options.onEvent({ type: "agent_state", data: { state: "idle" } }); + await registry.closeSessionRuntime(feature.runtimeId); await registry.forgetDirectory(worktrees.feature); assert.equal(adapter.stopped, true); @@ -307,14 +323,19 @@ test("coordinates forgetting with directory creation and active commands", async }); rejectPrompt(new Error("send failed")); await failedPromptAssertion; + assert.equal( + registry.listAgents().find((entry) => entry.id === failedFeature.id).state, + "streaming", + ); await assert.rejects( registry.forgetDirectory(worktrees.feature), - /while Pi is working/, + /runtimes are open/, ); failedFixture.options.onEvent({ type: "agent_state", data: { state: "idle" }, }); + await registry.closeSessionRuntime(failedFeature.runtimeId); await registry.forgetDirectory(worktrees.feature); await registry.stop(); }); @@ -389,6 +410,7 @@ test("catalogues managed directories and exposes only their sessions", async () }), /ENOENT|belong/, ); + await registry.closeSessionRuntime(feature.runtimeId); await registry.stop(); const restored = createAgentRegistry({ @@ -444,11 +466,17 @@ test("routes commands by explicit agent ID and replays only events after the cur command: "get_commands", success: true, }); + await registry.route(agent.id, "set_session_name", { name: "Release UI" }); + await registry.route(agent.id, "compact", { + customInstructions: "Preserve implementation details", + }); assert.deepEqual(adapter.sent, [ { type: "get_state" }, { type: "prompt", message: "Stay in this worktree" }, { type: "get_session_stats" }, { type: "get_commands" }, + { type: "set_session_name", name: "Release UI" }, + { type: "compact", customInstructions: "Preserve implementation details" }, ]); options.onEvent({ diff --git a/test/bridge-service.test.js b/test/bridge-service.test.js index 7bf7aac..6965b6d 100644 --- a/test/bridge-service.test.js +++ b/test/bridge-service.test.js @@ -18,11 +18,16 @@ function connect(socketPath) { function request(socket, frame) { return new Promise((resolve, reject) => { let buffer = ""; + const onError = (error) => { + socket.off("data", onData); + reject(error); + }; const onData = (chunk) => { buffer += chunk; const newline = buffer.indexOf("\n"); if (newline === -1) return; socket.off("data", onData); + socket.off("error", onError); try { resolve(JSON.parse(buffer.slice(0, newline))); } catch (error) { @@ -30,7 +35,7 @@ function request(socket, frame) { } }; socket.on("data", onData); - socket.once("error", reject); + socket.once("error", onError); socket.write(`${JSON.stringify(frame)}\n`); }); } @@ -89,6 +94,32 @@ test("starts the home agent and dispatches local protocol requests to the select payload: { worktreePath: feature }, }); const featureAgent = selected.result.agent; + const workspace = await request(socket, { + version: "v1", + id: "workspace-1", + op: "get_workspace", + }); + const summary = await request(socket, { + version: "v1", + id: "summary-1", + op: "get_workspace_summary", + }); + assert.equal(typeof workspace.result.bridgeInstanceId, "string"); + assert.equal(workspace.result.directories.length, 2); + assert.equal(summary.result.openCount, 2); + assert.equal(summary.result.directoryCount, 2); + assert.equal(summary.result.resourceWarning, false); + const snapshot = await request(socket, { + version: "v1", + id: "snapshot-1", + op: "get_session_runtime_snapshot", + payload: { runtimeId: featureAgent.runtimeId }, + }); + assert.equal(snapshot.result.runtime.runtimeId, featureAgent.runtimeId); + assert.equal( + snapshot.result.bridgeInstanceId, + workspace.result.bridgeInstanceId, + ); const prompt = await request(socket, { version: "v1", id: "prompt-1", @@ -98,18 +129,48 @@ test("starts the home agent and dispatches local protocol requests to the select }); assert.equal(prompt.ok, true); + const named = await request(socket, { + version: "v1", + id: "name-1", + op: "set_session_name", + agentId: featureAgent.id, + payload: { name: "Feature work" }, + }); + const compacted = await request(socket, { + version: "v1", + id: "compact-1", + op: "compact", + agentId: featureAgent.id, + payload: { customInstructions: "Preserve tests" }, + }); + assert.equal(named.ok, true); + assert.equal(compacted.ok, true); assert.deepEqual( fixture.calls.map(({ options }) => options.cwd), [home, feature], ); assert.deepEqual(fixture.calls[1].adapter.sent, [ { type: "get_state" }, + { type: "get_state" }, + { type: "get_session_stats" }, + { type: "get_messages" }, + { type: "get_commands" }, + { type: "get_available_models" }, { type: "prompt", message: "Use this worktree" }, + { type: "set_session_name", name: "Feature work" }, + { type: "compact", customInstructions: "Preserve tests" }, ]); fixture.calls[1].options.onEvent({ type: "agent_state", data: { state: "idle" }, }); + const closed = await request(socket, { + version: "v1", + id: "close-1", + op: "close_session_runtime", + payload: { runtimeId: featureAgent.runtimeId }, + }); + assert.equal(closed.ok, true); const forgotten = await request(socket, { version: "v1", diff --git a/test/daemon-and-relay.test.js b/test/daemon-and-relay.test.js index be4c4c4..be35291 100644 --- a/test/daemon-and-relay.test.js +++ b/test/daemon-and-relay.test.js @@ -10,64 +10,24 @@ 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(); - } + 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; +test("relays aggregate workspace summary as compact Noctalia state", async () => { 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), + client: { request: async (operation) => { assert.equal(operation, "get_workspace_summary"); return { openCount: 3, workingCount: 1, attentionCount: 2, recoveringCount: 0, errorCount: 1 }; } }, + onState: (state) => updates.push(state), pollMs: 250, }); - - 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", - }, - ]); + await relay.start(); relay.stop(); + assert.deepEqual(updates, [{ state: "error", projectLabel: "3 sessions", attentionCount: 2, detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors", openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1 }]); +}); + +test("retains agent-scoped relay behavior only when explicitly requested", 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" } }); + assert.equal(updates.at(-1).state, "streaming"); assert.equal(updates.at(-1).projectLabel, "feature"); }); diff --git a/test/multi-session-registry.test.js b/test/multi-session-registry.test.js new file mode 100644 index 0000000..80653f3 --- /dev/null +++ b/test/multi-session-registry.test.js @@ -0,0 +1,1101 @@ +import assert from "node:assert/strict"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + createAgentRegistry, + sessionDirectoryPath, +} from "../src/bridge/agent-registry.js"; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "pi-multi-runtime-")); + const home = join(root, "home"); + await mkdir(home); + return { root, home, sessionRoot: join(root, "sessions") }; +} + +function adapterFactory({ stateFor } = {}) { + const calls = []; + return { + calls, + startAdapter(options) { + const adapter = { + sent: [], + stopped: false, + async send(command) { + this.sent.push(command); + if (command.type === "get_state") + return { + type: "response", + success: true, + data: stateFor?.(options, calls.length) ?? {}, + }; + return { type: "response", command: command.type, success: true }; + }, + respondToExtension() {}, + async stop() { + this.stopped = true; + }, + }; + calls.push({ options, adapter }); + return adapter; + }, + }; +} + +async function sessionFile(sessionRoot, worktree, name, id = name) { + const sessionDir = sessionDirectoryPath(sessionRoot, worktree); + await mkdir(sessionDir, { recursive: true }); + const sessionPath = join(sessionDir, `${name}.jsonl`); + await writeFile( + sessionPath, + `${JSON.stringify({ type: "session", version: 3, id, cwd: worktree, timestamp: new Date().toISOString() })}\n`, + ); + return sessionPath; +} + +async function readJson(filePath) { + try { + return JSON.parse(await readFile(filePath, "utf8")); + } catch (error) { + throw new Error(`Could not parse test JSON at ${filePath}`, { + cause: error, + }); + } +} + +test("runs independent same-worktree sessions and deduplicates a session lease", async () => { + const paths = await fixture(); + const firstPath = await sessionFile(paths.sessionRoot, paths.home, "one"); + const secondPath = await sessionFile(paths.sessionRoot, paths.home, "two"); + const adapters = adapterFactory({ + stateFor: (options) => ({ + sessionFile: options.sessionPath, + sessionId: options.sessionPath?.endsWith("one.jsonl") ? "one" : "two", + }), + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + const homeRuntime = registry.getWorkspace().directories[0].runtimes[0]; + if (!homeRuntime.sessionPath) + await registry.closeSessionRuntime(homeRuntime.runtimeId); + const first = await registry.openSessionRuntime(paths.home, firstPath); + const second = await registry.openSessionRuntime(paths.home, secondPath); + const duplicate = await registry.openSessionRuntime(paths.home, firstPath); + + assert.equal(first.runtimeId, duplicate.runtimeId); + assert.notEqual(first.runtimeId, second.runtimeId); + assert.equal(registry.getWorkspaceSummary().openCount, 2); + assert.equal(new Set(registry.listAgents().map((agent) => agent.id)).size, 2); + await Promise.all([ + registry.route(first.agentId, "prompt", { message: "first" }), + registry.route(second.agentId, "prompt", { message: "second" }), + ]); + assert.deepEqual( + adapters.calls.slice(-2).map(({ adapter }) => adapter.sent.at(-1).message), + ["first", "second"], + ); + await registry.stop(); +}); + +test("concurrent new runtime creation never deduplicates empty sessions", async () => { + const paths = await fixture(); + const adapters = adapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + const [first, second] = await Promise.all([ + registry.createSessionRuntime(paths.home), + registry.createSessionRuntime(paths.home), + ]); + assert.notEqual(first.runtimeId, second.runtimeId); + assert.equal(registry.getWorkspaceSummary().openCount, 3); + await registry.stop(); +}); + +test("closing removes desired-open intent, preserves history, and refuses directory forget while open", async () => { + const paths = await fixture(); + const saved = await sessionFile(paths.sessionRoot, paths.home, "saved"); + const feature = join(paths.root, "feature"); + await mkdir(feature); + const featureSession = await sessionFile( + paths.sessionRoot, + feature, + "feature", + ); + const adapters = adapterFactory({ + stateFor: (options) => ({ sessionFile: options.sessionPath }), + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + const runtime = await registry.openSessionRuntime(feature, featureSession); + await assert.rejects(registry.forgetDirectory(feature), /runtimes are open/); + await registry.route(runtime.agentId, "prompt", { + message: "work before close", + }); + await registry.closeSessionRuntime(runtime.runtimeId); + const retainedDirectory = registry + .getWorkspace() + .directories.find((directory) => directory.worktreePath === feature); + assert.equal(retainedDirectory?.openCount, 0); + assert.deepEqual(retainedDirectory?.runtimes, []); + const featureAdapter = adapters.calls.find( + (call) => call.options.cwd === feature, + ).adapter; + assert.deepEqual( + featureAdapter.sent.slice(-2).map((command) => command.type), + ["prompt", "abort"], + ); + await registry.forgetDirectory(feature); + assert.match(await readFile(featureSession, "utf8"), /"id":"feature"/); + assert.equal( + registry + .getWorkspace() + .directories.some((directory) => directory.worktreePath === feature), + false, + ); + assert.match(await readFile(saved, "utf8"), /"id":"saved"/); + await registry.stop(); +}); + +test("restores durable runtime IDs with two-at-a-time concurrency and preserves command isolation", async () => { + const paths = await fixture(); + const firstPath = await sessionFile(paths.sessionRoot, paths.home, "one"); + const secondPath = await sessionFile(paths.sessionRoot, paths.home, "two"); + const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "three"); + const firstAdapters = adapterFactory({ + stateFor: (options) => ({ sessionFile: options.sessionPath }), + }); + const firstRegistry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: firstAdapters.startAdapter, + }); + await firstRegistry.start(); + const initial = firstRegistry.getWorkspace().directories[0].runtimes[0]; + if (!initial.sessionPath) + await firstRegistry.closeSessionRuntime(initial.runtimeId); + const one = await firstRegistry.openSessionRuntime(paths.home, firstPath); + const two = await firstRegistry.openSessionRuntime(paths.home, secondPath); + const three = await firstRegistry.openSessionRuntime(paths.home, thirdPath); + await firstRegistry.stop(); + + let activeStarts = 0; + let maxActiveStarts = 0; + const gates = []; + const restoredAdapters = adapterFactory({ + stateFor: (options) => ({ sessionFile: options.sessionPath }), + }); + const startAdapter = (options) => { + const adapter = restoredAdapters.startAdapter(options); + const send = adapter.send.bind(adapter); + adapter.send = async (command) => { + if (command.type !== "get_state") return send(command); + activeStarts += 1; + maxActiveStarts = Math.max(maxActiveStarts, activeStarts); + await new Promise((resolve) => gates.push(resolve)); + activeStarts -= 1; + return send(command); + }; + return adapter; + }; + const restored = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter, + }); + const starting = restored.start(); + while (gates.length < 2) + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(gates.length, 2); + gates.splice(0).forEach((resolve) => resolve()); + while (gates.length < 1) + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(gates.length, 1); + gates.splice(0).forEach((resolve) => resolve()); + await starting; + const ids = restored + .getWorkspace() + .directories.flatMap((directory) => + directory.runtimes.map((runtime) => runtime.runtimeId), + ); + assert.deepEqual( + new Set(ids), + new Set([one.runtimeId, two.runtimeId, three.runtimeId]), + ); + assert.equal(maxActiveStarts, 2); + assert.ok( + restored + .listAgents() + .every((agent) => ![one.agentId, two.agentId].includes(agent.id)), + ); + await restored.stop(); +}); + +test("serializes close behind an in-flight command and aborts the working runtime", async () => { + const paths = await fixture(); + const adapters = adapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + const runtime = await registry.start(); + const adapter = adapters.calls[0].adapter; + const originalSend = adapter.send.bind(adapter); + let releasePrompt; + const promptGate = new Promise((resolve) => { + releasePrompt = resolve; + }); + adapter.send = async (command) => { + if (command.type === "prompt") { + adapter.sent.push(command); + await promptGate; + return { type: "response", command: "prompt", success: true }; + } + return originalSend(command); + }; + const prompt = registry.route(runtime.id, "prompt", { message: "long work" }); + const closing = registry.closeSessionRuntime(runtime.runtimeId); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(adapter.stopped, false); + releasePrompt(); + await Promise.all([prompt, closing]); + assert.equal(adapter.stopped, true); + assert.deepEqual( + adapter.sent.slice(-2).map((command) => command.type), + ["prompt", "abort"], + ); + await registry.stop(); +}); + +test("forces a child kill and retains its lease until exit is confirmed", async () => { + const paths = await fixture(); + const saved = await sessionFile( + paths.sessionRoot, + paths.home, + "forced-close", + ); + let killed; + let releaseStop; + let blockedSavedAdapter = false; + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + closeTimeoutMs: 5, + startAdapter: (options) => { + const blockStop = options.sessionPath === saved && !blockedSavedAdapter; + if (blockStop) blockedSavedAdapter = true; + return { + child: { kill: (signal) => (killed = signal) }, + send: async (command) => ({ + type: "response", + command: command.type, + success: true, + data: { sessionFile: options.sessionPath }, + }), + respondToExtension() {}, + stop: blockStop + ? () => + new Promise((resolve) => { + releaseStop = resolve; + }) + : async () => {}, + }; + }, + }); + await registry.start(); + const runtime = await registry.openSessionRuntime(paths.home, saved); + await registry.closeSessionRuntime(runtime.runtimeId); + assert.equal(killed, "SIGKILL"); + await assert.rejects( + registry.openSessionRuntime(paths.home, saved), + /session runtime is closing/, + ); + releaseStop(); + await new Promise((resolve) => setImmediate(resolve)); + const reopened = await registry.openSessionRuntime(paths.home, saved); + assert.notEqual(reopened.runtimeId, runtime.runtimeId); + await registry.stop(); +}); + +test("forgets a managed directory after its worktree is removed", async () => { + const paths = await fixture(); + const feature = join(paths.root, "feature"); + await mkdir(feature); + const adapters = adapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + const runtime = await registry.selectWorktree(feature); + await registry.closeSessionRuntime(runtime.runtimeId); + await rm(feature, { recursive: true }); + await registry.forgetDirectory(feature); + assert.equal( + (await registry.listDirectories()).some( + (directory) => directory.worktreePath === feature, + ), + false, + ); + await registry.stop(); +}); + +test("reserves a legacy switch target before either child is instructed", async () => { + const paths = await fixture(); + const target = await sessionFile(paths.sessionRoot, paths.home, "target"); + const adapters = adapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + const first = await registry.start(); + const second = await registry.createSessionRuntime(paths.home); + let releaseSwitch; + const switchGate = new Promise((resolve) => { + releaseSwitch = resolve; + }); + let switched = false; + const firstAdapter = adapters.calls[0].adapter; + const firstSend = firstAdapter.send.bind(firstAdapter); + firstAdapter.send = async (command) => { + if (command.type === "switch_session") { + await switchGate; + switched = true; + } + if (command.type === "get_state" && switched) + return { type: "response", success: true, data: { sessionFile: target } }; + return firstSend(command); + }; + const switching = registry.route(first.id, "switch_session", { + sessionPath: target, + }); + await new Promise((resolve) => setImmediate(resolve)); + await assert.rejects( + registry.route(second.agentId, "switch_session", { sessionPath: target }), + /session is already open/, + ); + assert.equal( + adapters.calls[1].adapter.sent.some( + (command) => command.type === "switch_session", + ), + false, + ); + releaseSwitch(); + await switching; + await registry.stop(); +}); + +test("legacy new session releases old identity and makes its history reopenable", async () => { + const paths = await fixture(); + const saved = await sessionFile( + paths.sessionRoot, + paths.home, + "saved", + "saved-id", + ); + let currentState = { + sessionFile: saved, + sessionId: "saved-id", + sessionName: "Saved", + }; + const adapters = adapterFactory({ stateFor: () => currentState }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + const original = await registry.start(); + const adapter = adapters.calls[0].adapter; + const originalSend = adapter.send.bind(adapter); + adapter.send = async (command) => { + if (command.type === "new_session") currentState = {}; + return originalSend(command); + }; + await registry.route(original.id, "new_session"); + const cleared = registry.getWorkspace().directories[0].runtimes[0]; + assert.equal(cleared.sessionPath, undefined); + assert.equal(cleared.sessionId, undefined); + assert.equal(cleared.label, "New session"); + currentState = { sessionFile: saved, sessionId: "saved-id" }; + const reopened = await registry.openSessionRuntime(paths.home, saved); + assert.notEqual(reopened.runtimeId, original.runtimeId); + await registry.stop(); +}); + +test("persists a first-prompt session when the runtime settles and awaits refresh on shutdown", async () => { + const paths = await fixture(); + const createdPath = await sessionFile( + paths.sessionRoot, + paths.home, + "created", + "created-id", + ); + let stateCalls = 0; + const adapters = adapterFactory({ + stateFor: () => { + stateCalls += 1; + return stateCalls === 1 + ? {} + : { sessionFile: createdPath, sessionId: "created-id" }; + }, + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + const agent = await registry.start(); + await registry.route(agent.id, "prompt", { message: "create a session" }); + adapters.calls[0].options.onEvent({ + type: "agent_state", + data: { state: "idle", event: { type: "agent_settled" } }, + }); + while (!registry.getWorkspace().directories[0].runtimes[0].sessionPath) + await new Promise((resolve) => setImmediate(resolve)); + await registry.stop(); + const manifestText = await readFile( + join(paths.sessionRoot, "bridge-workspace-v2.json"), + "utf8", + ); + assert.match(manifestText, new RegExp(createdPath.replaceAll("/", "\\/"))); +}); + +test("extension responses do not clear unrelated error attention", async () => { + const paths = await fixture(); + const adapters = adapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + const runtime = await registry.start(); + adapters.calls[0].options.onEvent({ + type: "extension_ui_request", + data: { event: { id: "request-1", method: "confirm" } }, + }); + adapters.calls[0].options.onError({ + code: "diagnostic", + message: "needs attention", + }); + await registry.route(runtime.id, "extension_response", { + requestId: "request-1", + response: { confirmed: true }, + }); + const directory = registry.getWorkspace().directories[0]; + assert.equal(directory.attentionCount, 1); + assert.equal(directory.errorCount, 1); + await registry.stop(); +}); + +test("successful recovery clears obsolete child error attention", async () => { + const paths = await fixture(); + const saved = await sessionFile(paths.sessionRoot, paths.home, "saved"); + await writeFile( + join( + sessionDirectoryPath(paths.sessionRoot, paths.home), + "bridge-agent.json", + ), + `${JSON.stringify({ sessionPath: saved, worktreePath: paths.home })}\n`, + ); + const adapters = adapterFactory({ + stateFor: (options) => ({ sessionFile: options.sessionPath }), + }); + let releaseRecovery; + const recoveryGate = new Promise((resolve) => { + releaseRecovery = resolve; + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + sleep: () => recoveryGate, + }); + const runtime = await registry.start(); + adapters.calls[0].options.onError({ code: "child_exited", message: "gone" }); + assert.equal(registry.getWorkspace().directories[0].recoveringCount, 1); + releaseRecovery(); + await registry.waitForRecovery(runtime.id); + const recovered = registry.getWorkspace().directories[0].runtimes[0]; + assert.equal(recovered.state, "idle"); + assert.equal(recovered.attention, false); + assert.equal(recovered.error, undefined); + await registry.stop(); +}); + +test("non-default runtime snapshots do not replace the legacy session pointer", async () => { + const paths = await fixture(); + const firstPath = await sessionFile(paths.sessionRoot, paths.home, "first"); + const secondPath = await sessionFile(paths.sessionRoot, paths.home, "second"); + const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home); + const referencePath = join(sessionDir, "bridge-agent.json"); + await writeFile( + referencePath, + `${JSON.stringify({ sessionPath: firstPath, worktreePath: paths.home })}\n`, + ); + const adapters = adapterFactory({ + stateFor: (options) => ({ sessionFile: options.sessionPath }), + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + const secondary = await registry.openSessionRuntime(paths.home, secondPath); + await registry.getSessionRuntimeSnapshot(secondary.runtimeId); + const reference = await readFile(referencePath, "utf8"); + assert.match(reference, new RegExp(firstPath.replaceAll("/", "\\/"))); + assert.doesNotMatch(reference, new RegExp(secondPath.replaceAll("/", "\\/"))); + await registry.stop(); +}); + +test("retains a corrupt workspace manifest and reports its issue", async () => { + const paths = await fixture(); + await mkdir(paths.sessionRoot, { recursive: true }); + const manifestPath = join(paths.sessionRoot, "bridge-workspace-v2.json"); + await writeFile(manifestPath, "{broken", { mode: 0o600 }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapterFactory().startAdapter, + }); + await registry.start(); + assert.equal( + registry.getWorkspace().issue.code, + "invalid_workspace_manifest", + ); + assert.equal(await readFile(manifestPath, "utf8"), "{broken"); + await registry.stop(); +}); + +test("retains a canonical duplicate restore record as failed dormant intent", async () => { + for (let attempt = 0; attempt < 20; attempt += 1) { + const paths = await fixture(); + const saved = await sessionFile(paths.sessionRoot, paths.home, "saved"); + const alias = join( + sessionDirectoryPath(paths.sessionRoot, paths.home), + "alias.jsonl", + ); + await symlink(saved, alias); + await writeFile( + join(paths.sessionRoot, "bridge-workspace-v2.json"), + `${JSON.stringify({ + version: 2, + migrated: true, + runtimes: [ + { + runtimeId: "runtime-alias", + worktreePath: paths.home, + sessionPath: alias, + }, + { + runtimeId: "runtime-one", + worktreePath: paths.home, + sessionPath: saved, + }, + ], + })}\n`, + { mode: 0o600 }, + ); + const adapters = adapterFactory({ + stateFor: (options) => ({ sessionFile: options.sessionPath }), + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + const runtimes = registry.getWorkspace().directories[0].runtimes; + assert.deepEqual( + new Set(runtimes.map((runtime) => runtime.runtimeId)), + new Set(["runtime-one", "runtime-alias"]), + ); + assert.equal( + runtimes.filter((runtime) => runtime.state === "failed").length, + 1, + JSON.stringify(runtimes), + ); + assert.equal( + runtimes.find((runtime) => runtime.runtimeId === "runtime-one").state, + "idle", + ); + await registry.stop(); + } +}); + +test("stops a restore candidate when Pi substitutes a different session", async () => { + const paths = await fixture(); + const requested = await sessionFile( + paths.sessionRoot, + paths.home, + "requested", + ); + const substituted = await sessionFile( + paths.sessionRoot, + paths.home, + "substituted", + ); + await writeFile( + join(paths.sessionRoot, "bridge-workspace-v2.json"), + `${JSON.stringify({ + version: 2, + migrated: true, + runtimes: [ + { + runtimeId: "runtime-requested", + worktreePath: paths.home, + sessionPath: requested, + }, + ], + })}\n`, + { mode: 0o600 }, + ); + const adapters = adapterFactory({ + stateFor: () => ({ sessionFile: substituted }), + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + const runtime = registry.getWorkspace().directories[0].runtimes[0]; + assert.equal(runtime.state, "failed"); + assert.equal(runtime.sessionPath, requested); + assert.equal(adapters.calls[0].adapter.stopped, true); + await registry.stop(); +}); + +test("shutdown drains a create racing before runtime indexing", async () => { + const paths = await fixture(); + const feature = join(paths.root, "feature"); + await mkdir(feature); + const adapters = adapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + const creating = registry.createSessionRuntime(feature); + const stopping = registry.stop(); + await assert.rejects(creating, /agent registry is stopping/); + await stopping; + assert.equal(adapters.calls.length, 1); + assert.equal(adapters.calls[0].adapter.stopped, true); + assert.equal(registry.listAgents().length, 1); +}); + +test("legacy selection prefers the manifest default and promotes a fallback on close", async () => { + const paths = await fixture(); + const firstPath = await sessionFile( + paths.sessionRoot, + paths.home, + "first", + "first-id", + ); + const secondPath = await sessionFile( + paths.sessionRoot, + paths.home, + "second", + "second-id", + ); + await mkdir(paths.sessionRoot, { recursive: true }); + await writeFile( + join(paths.sessionRoot, "bridge-workspace-v2.json"), + `${JSON.stringify({ + version: 2, + migrated: true, + runtimes: [ + { + runtimeId: "secondary", + worktreePath: paths.home, + sessionPath: secondPath, + }, + { + runtimeId: "default", + worktreePath: paths.home, + sessionPath: firstPath, + legacyDefault: true, + }, + ], + })}\n`, + { mode: 0o600 }, + ); + const adapters = adapterFactory({ + stateFor: (options) => ({ + sessionFile: options.sessionPath, + sessionId: options.sessionPath === firstPath ? "first-id" : "second-id", + }), + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + const selectedDefault = await registry.selectWorktree(paths.home); + assert.equal(selectedDefault.runtimeId, "default"); + await registry.closeSessionRuntime("default"); + const selectedFallback = await registry.selectWorktree(paths.home); + assert.equal(selectedFallback.runtimeId, "secondary"); + const reference = await readJson( + join( + sessionDirectoryPath(paths.sessionRoot, paths.home), + "bridge-agent.json", + ), + ); + assert.equal(reference.sessionPath, secondPath); + await registry.stop(); +}); + +test("concurrent closes never promote a closing runtime as legacy default", async () => { + const paths = await fixture(); + const firstPath = await sessionFile(paths.sessionRoot, paths.home, "first"); + const secondPath = await sessionFile(paths.sessionRoot, paths.home, "second"); + const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "third"); + await mkdir(paths.sessionRoot, { recursive: true }); + await writeFile( + join(paths.sessionRoot, "bridge-workspace-v2.json"), + `${JSON.stringify({ + version: 2, + migrated: true, + runtimes: [ + { + runtimeId: "default", + worktreePath: paths.home, + sessionPath: firstPath, + legacyDefault: true, + }, + { + runtimeId: "closing", + worktreePath: paths.home, + sessionPath: secondPath, + }, + { + runtimeId: "survivor", + worktreePath: paths.home, + sessionPath: thirdPath, + }, + ], + })}\n`, + { mode: 0o600 }, + ); + const adapters = adapterFactory({ + stateFor: (options) => ({ sessionFile: options.sessionPath }), + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + await Promise.all([ + registry.closeSessionRuntime("closing"), + registry.closeSessionRuntime("default"), + ]); + const selected = await registry.selectWorktree(paths.home); + assert.equal(selected.runtimeId, "survivor"); + const manifest = await readJson( + join(paths.sessionRoot, "bridge-workspace-v2.json"), + ); + assert.equal(manifest.runtimes.length, 1); + assert.equal(manifest.runtimes[0].runtimeId, "survivor"); + assert.equal(manifest.runtimes[0].legacyDefault, true); + const reference = await readJson( + join( + sessionDirectoryPath(paths.sessionRoot, paths.home), + "bridge-agent.json", + ), + ); + assert.equal(reference.sessionPath, thirdPath); + await registry.stop(); +}); + +test("closing retains a saved-session lease until the child stops", async () => { + const paths = await fixture(); + const saved = await sessionFile(paths.sessionRoot, paths.home, "saved"); + const adapters = adapterFactory({ + stateFor: (options) => ({ sessionFile: options.sessionPath }), + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + const runtime = await registry.openSessionRuntime(paths.home, saved); + const adapter = adapters.calls.find( + (call) => call.options.sessionPath === saved, + ).adapter; + let releaseStop; + adapter.stop = () => + new Promise((resolve) => { + releaseStop = () => { + adapter.stopped = true; + resolve(); + }; + }); + const closing = registry.closeSessionRuntime(runtime.runtimeId); + while (!releaseStop) await new Promise((resolve) => setImmediate(resolve)); + await assert.rejects( + registry.openSessionRuntime(paths.home, saved), + /session runtime is closing/, + ); + releaseStop(); + await closing; + const reopened = await registry.openSessionRuntime(paths.home, saved); + assert.notEqual(reopened.runtimeId, runtime.runtimeId); + await registry.stop(); +}); + +test("first migration retains a missing legacy session as failed dormant intent", async () => { + const paths = await fixture(); + const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home); + const missing = join(sessionDir, "missing.jsonl"); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + join(sessionDir, "bridge-agent.json"), + `${JSON.stringify({ sessionPath: missing, worktreePath: paths.home })}\n`, + ); + const adapters = adapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + await registry.start(); + const runtime = registry.getWorkspace().directories[0].runtimes[0]; + assert.equal(runtime.state, "failed"); + assert.equal(runtime.sessionPath, missing); + assert.equal(adapters.calls.length, 0); + const manifest = await readJson( + join(paths.sessionRoot, "bridge-workspace-v2.json"), + ); + assert.equal(manifest.runtimes[0].sessionPath, missing); + await registry.stop(); +}); + +test("extension responses are scoped to actionable requests on one runtime", async () => { + const paths = await fixture(); + const adapters = adapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + }); + const first = await registry.start(); + const second = await registry.createSessionRuntime(paths.home); + const replies = [[], []]; + adapters.calls[0].adapter.respondToExtension = (...args) => + replies[0].push(args); + adapters.calls[1].adapter.respondToExtension = (...args) => + replies[1].push(args); + adapters.calls[0].options.onEvent({ + type: "extension_ui_request", + data: { event: { id: "collision", method: "confirm" } }, + }); + await assert.rejects( + registry.route(second.agentId, "extension_response", { + requestId: "collision", + response: { confirmed: true }, + }), + /not actionable for this runtime/, + ); + adapters.calls[1].options.onEvent({ + type: "extension_ui_request", + data: { event: { id: "collision", method: "confirm" } }, + }); + await registry.route(second.agentId, "extension_response", { + requestId: "collision", + response: { confirmed: false }, + }); + await registry.route(first.id, "extension_response", { + requestId: "collision", + response: { confirmed: true }, + }); + assert.equal(replies[0].length, 1); + assert.equal(replies[1].length, 1); + await assert.rejects( + registry.route(first.id, "extension_response", { + requestId: "collision", + response: { confirmed: false }, + }), + /not actionable for this runtime/, + ); + await registry.stop(); +}); + +test("manual restart ignores late extension dialogs from the replaced child", async () => { + const paths = await fixture(); + const adapters = adapterFactory(); + let releaseStop; + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter(options) { + const adapter = adapters.startAdapter(options); + if (adapters.calls.length === 1) adapter.stop = () => stopGate; + return adapter; + }, + sleep: async () => {}, + }); + const runtime = await registry.start(); + adapters.calls[0].options.onEvent({ + type: "extension_ui_request", + data: { event: { id: "restart-request", method: "confirm" } }, + }); + const restarting = registry.restart(runtime.id); + await new Promise((resolve) => setImmediate(resolve)); + adapters.calls[0].options.onEvent({ + type: "extension_ui_request", + data: { event: { id: "late-old-child", method: "confirm" } }, + }); + releaseStop(); + await restarting; + const snapshot = await registry.getSessionRuntimeSnapshot(runtime.runtimeId); + assert.deepEqual(snapshot.extensions, []); + assert.equal(snapshot.runtime.attention, false); + await registry.stop(); +}); + +test("child exit clears extension dialogs that recovery cannot answer", async () => { + const paths = await fixture(); + const adapters = adapterFactory(); + let releaseRecovery; + const recoveryGate = new Promise((resolve) => { + releaseRecovery = resolve; + }); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + sleep: () => recoveryGate, + }); + const runtime = await registry.start(); + adapters.calls[0].options.onEvent({ + type: "extension_ui_request", + data: { event: { id: "dead-request", method: "input" } }, + }); + adapters.calls[0].options.onError({ code: "child_exited", message: "gone" }); + const duringRecovery = await registry.getSessionRuntimeSnapshot( + runtime.runtimeId, + ); + assert.deepEqual(duringRecovery.extensions, []); + releaseRecovery(); + await registry.waitForRecovery(runtime.id); + const recovered = await registry.getSessionRuntimeSnapshot(runtime.runtimeId); + assert.deepEqual(recovered.extensions, []); + assert.equal(recovered.runtime.attention, false); + await registry.stop(); +}); + +test("snapshot rejects results from an adapter replaced during recovery", async () => { + const paths = await fixture(); + const adapters = adapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + sleep: async () => {}, + }); + const runtime = await registry.start(); + const oldAdapter = adapters.calls[0].adapter; + const oldSend = oldAdapter.send.bind(oldAdapter); + let releaseSnapshot; + let snapshotStarted; + const snapshotGate = new Promise((resolve) => { + snapshotStarted = resolve; + }); + let stateCalls = 0; + oldAdapter.send = async (command) => { + if (command.type === "get_state" && ++stateCalls === 1) { + snapshotStarted(); + await new Promise((resolve) => { + releaseSnapshot = resolve; + }); + } + return oldSend(command); + }; + const snapshot = registry.getSessionRuntimeSnapshot(runtime.runtimeId); + await snapshotGate; + adapters.calls[0].options.onError({ code: "child_exited", message: "gone" }); + while (adapters.calls.length < 2) + await new Promise((resolve) => setImmediate(resolve)); + releaseSnapshot(); + await assert.rejects(snapshot, /session runtime is closing/); + await registry.waitForRecovery(runtime.id); + await registry.stop(); +}); + +test("closing the last runtime persists a zero-runtime restart", async () => { + const paths = await fixture(); + const firstAdapters = adapterFactory(); + const first = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: firstAdapters.startAdapter, + }); + const runtime = await first.start(); + await first.closeSessionRuntime(runtime.runtimeId); + await first.stop(); + const restoredAdapters = adapterFactory(); + const restored = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: restoredAdapters.startAdapter, + }); + await restored.start(); + assert.equal(restored.getWorkspaceSummary().openCount, 0); + assert.equal(restoredAdapters.calls.length, 0); + await restored.stop(); +}); + +test("workspace replay is globally ordered and reports truncation boundaries", async () => { + const paths = await fixture(); + const adapters = adapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: paths.home, + sessionRoot: paths.sessionRoot, + startAdapter: adapters.startAdapter, + eventLimit: 2, + }); + await registry.start(); + adapters.calls[0].options.onEvent({ type: "stream", data: { event: {} } }); + adapters.calls[0].options.onEvent({ type: "tool", data: { event: {} } }); + adapters.calls[0].options.onEvent({ type: "queue", data: { event: {} } }); + const replay = registry.workspaceEventsAfter(1); + assert.equal(replay.truncated, true); + assert.equal(registry.workspaceEventsAfter(0).truncated, true); + assert.equal(replay.events.length, 2); + assert.ok(replay.events[0].seq < replay.events[1].seq); + assert.equal(replay.bridgeInstanceId, registry.bridgeInstanceId); + await registry.stop(); +}); diff --git a/test/noctalia-ipc.test.js b/test/noctalia-ipc.test.js index 1b49a6e..b6c15fa 100644 --- a/test/noctalia-ipc.test.js +++ b/test/noctalia-ipc.test.js @@ -2,33 +2,18 @@ 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 () => { +test("publishes aggregate presentation state through Noctalia's documented IPC command", async () => { const calls = []; await publishNoctaliaState( { - state: "streaming", - projectLabel: "feature", - attentionCount: 2, - detail: "Streaming", + state: "streaming", projectLabel: "3 sessions", attentionCount: 2, + detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors", + openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1, }, { 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", - ], - }, - ]); + assert.deepEqual(calls, [{ + command: "qs", + args: ["-c", "noctalia-shell", "ipc", "call", "plugin:pi-status-bridge", "setBridgeState", "streaming", "3 sessions", "2", "3 open · 1 working · 2 attention · 0 recovering · 1 errors", "3", "1", "0", "1"], + }]); }); diff --git a/test/noctalia-plugin.test.js b/test/noctalia-plugin.test.js index c8312a0..82fde8d 100644 --- a/test/noctalia-plugin.test.js +++ b/test/noctalia-plugin.test.js @@ -2,7 +2,7 @@ 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 () => { +test("ships a Noctalia aggregate launcher without a primary panel", async () => { const manifestText = await readFile( "noctalia-plugin/pi-status-bridge/manifest.json", "utf8", @@ -15,9 +15,8 @@ test("ships a Noctalia plugin with compact bar and presentation-only panel entry error instanceof Error ? error.message : "manifest was not valid JSON", ); } - const [bar, panel, main] = await Promise.all([ + const [bar, 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"), ]); @@ -25,10 +24,18 @@ test("ships a Noctalia plugin with compact bar and presentation-only panel entry 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(bar, /systemctl", "--user", "start", "pi-status-bridge\.service/); + assert.match(bar, /PI_STATUS_UI_BINARY/); + assert.match(bar, /pi-status-ui/); + assert.match(bar, /setsid -f env TMPDIR=\/tmp/); + assert.match(bar, /--toggle/); + assert.doesNotMatch(bar, /togglePanel/); + assert.doesNotMatch(bar, /Panel\.qml/); assert.match(main, /IpcHandler/); assert.match(main, /setBridgeState/); + assert.match(main, /openCount/); + assert.match(main, /workingCount/); + assert.match(main, /recoveringCount/); + assert.match(main, /errorCount/); }); diff --git a/test/noctalia-v5-plugin.test.js b/test/noctalia-v5-plugin.test.js index 36f5b71..055f8eb 100644 --- a/test/noctalia-v5-plugin.test.js +++ b/test/noctalia-v5-plugin.test.js @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; import test from "node:test"; -test("ships a v5 Noctalia compatibility launcher instead of a primary panel", async () => { +test("ships a v5 Noctalia aggregate 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"), @@ -19,10 +19,21 @@ test("ships a v5 Noctalia compatibility launcher instead of a primary panel", as assert.doesNotMatch(bridge, /--show/); assert.match(bridge, /pi-status-ui\.log/); assert.doesNotMatch(bridge, /togglePanel/); + assert.match(bridge, /get_workspace_summary/); + assert.match(bridge, /openCount/); + assert.match(bridge, /workingCount/); + assert.match(bridge, /attentionCount/); + assert.match(bridge, /recoveringCount/); + assert.match(bridge, /errorCount/); assert.match(bridge, /Pi reconnecting/); - assert.match(bridge, /discover_home_agent\(socket\)/); + assert.doesNotMatch(bridge, /discover_home_agent/); + assert.doesNotMatch(bridge, /get_state/); assert.match(bridge, /systemctl --user start pi-status-bridge\.service/); assert.match(bridge, /bridge_start_in_flight/); assert.match(bridge, /pending_ui_open/); assert.match(bridge, /Bridge is starting/); + await assert.rejects( + access("noctalia-v5-plugin/pi-status-bridge/picker.luau"), + { code: "ENOENT" }, + ); }); diff --git a/test/pi-rpc-adapter.test.js b/test/pi-rpc-adapter.test.js index 66916d4..f1c8f0d 100644 --- a/test/pi-rpc-adapter.test.js +++ b/test/pi-rpc-adapter.test.js @@ -115,6 +115,40 @@ test("requests Pi session statistics for context and token status", async () => await adapter.stop(); }); +test("forwards native session naming and compaction commands", async () => { + const fixture = createFixture(); + const adapter = startPiRpcAdapter({ + cwd: "/workspace/home", + sessionDir: "/workspace/sessions", + spawnProcess: fixture.spawn, + }); + + const named = adapter.send({ type: "set_session_name", name: "Release UI" }); + const [nameCommand] = fixture.sent(); + assert.equal(nameCommand.type, "set_session_name"); + assert.equal(nameCommand.name, "Release UI"); + fixture.child.stdout.write( + `${JSON.stringify({ type: "response", id: nameCommand.id, command: "set_session_name", success: true })}\n`, + ); + await named; + + const compacted = adapter.send({ + type: "compact", + customInstructions: "Preserve implementation details", + }); + const [, compactCommand] = fixture.sent(); + assert.equal(compactCommand.type, "compact"); + assert.equal( + compactCommand.customInstructions, + "Preserve implementation details", + ); + fixture.child.stdout.write( + `${JSON.stringify({ type: "response", id: compactCommand.id, command: "compact", success: true })}\n`, + ); + await compacted; + await adapter.stop(); +}); + test("forwards extension responses without replacing Pi's request ID", async () => { const fixture = createFixture(); const events = []; diff --git a/test/protocol.test.js b/test/protocol.test.js index 7087aa6..2fc2a83 100644 --- a/test/protocol.test.js +++ b/test/protocol.test.js @@ -108,6 +108,98 @@ test("accepts bridge-managed directory and session operations", () => { ); }); +test("accepts additive multi-session runtime operations and numeric workspace cursors", () => { + const worktreePath = "/home/alex/project"; + const sessionPath = "/home/alex/project/sessions/one.jsonl"; + for (const value of [ + { op: "get_workspace" }, + { op: "get_workspace_summary" }, + { op: "create_session_runtime", payload: { worktreePath } }, + { op: "open_session_runtime", payload: { worktreePath, sessionPath } }, + { op: "close_session_runtime", payload: { runtimeId: "runtime-1" } }, + { op: "list_directory_sessions", payload: { worktreePath } }, + { + op: "get_session_runtime_snapshot", + payload: { runtimeId: "runtime-1" }, + }, + { op: "subscribe_workspace", payload: { cursor: 42 } }, + ]) + assert.equal(parseRequestFrame(request(value)).op, value.op); + assert.throws( + () => + parseRequestFrame( + request({ + op: "subscribe_workspace", + payload: { cursor: "opaque" }, + }), + ), + /cursor must be a non-negative integer/, + ); +}); + +test("accepts native session naming and manual compaction requests", () => { + assert.deepEqual( + parseRequestFrame( + request({ + op: "set_session_name", + agentId: "agent-main", + payload: { name: "Release UI" }, + }), + ), + { + version: "v1", + id: "request-1", + op: "set_session_name", + agentId: "agent-main", + payload: { name: "Release UI" }, + }, + ); + assert.deepEqual( + parseRequestFrame(request({ op: "compact", agentId: "agent-main" })), + { + version: "v1", + id: "request-1", + op: "compact", + agentId: "agent-main", + }, + ); + assert.deepEqual( + parseRequestFrame( + request({ + op: "compact", + agentId: "agent-main", + payload: { customInstructions: "Preserve implementation details" }, + }), + ), + { + version: "v1", + id: "request-1", + op: "compact", + agentId: "agent-main", + payload: { customInstructions: "Preserve implementation details" }, + }, + ); + for (const invalidRequest of [ + request({ + op: "set_session_name", + agentId: "agent-main", + payload: { name: " " }, + }), + request({ + op: "set_session_name", + agentId: "agent-main", + payload: { name: "Valid", extra: true }, + }), + request({ + op: "compact", + agentId: "agent-main", + payload: { customInstructions: " " }, + }), + request({ op: "compact", agentId: "agent-main", payload: { extra: true } }), + ]) + assert.throws(() => parseRequestFrame(invalidRequest), ProtocolError); +}); + test("accepts an agent session-statistics request", () => { assert.deepEqual( parseRequestFrame( diff --git a/test/subscriptions.test.js b/test/subscriptions.test.js index 0e7e21c..dcee433 100644 --- a/test/subscriptions.test.js +++ b/test/subscriptions.test.js @@ -42,6 +42,56 @@ function collectFrames(socket, count) { }); } +test("workspace subscription exposes daemon identity, replay boundaries, and globally ordered live events", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-status-workspace-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, data: {} }), + respondToExtension: () => {}, + stop: async () => {}, + }; + calls.push({ options, adapter }); + return adapter; + }, + }); + const socket = await connect(service.socketPath); + + try { + const frames = collectFrames(socket, 2); + socket.write( + `${JSON.stringify({ version: "v1", id: "workspace-1", op: "subscribe_workspace", payload: { cursor: 0 } })}\n`, + ); + setTimeout(() => { + calls[0].options.onEvent({ + type: "stream", + data: { event: { type: "message_update", delta: "live" } }, + }); + }, 10); + const [replay, live] = await frames; + assert.equal(replay.ok, true); + assert.equal(typeof replay.result.bridgeInstanceId, "string"); + assert.equal(replay.result.truncated, false); + assert.equal(Number.isSafeInteger(replay.result.firstAvailableSeq), true); + assert.equal(Number.isSafeInteger(replay.result.latestSeq), true); + assert.ok(replay.result.events.length >= 1); + assert.equal(live.type, "event"); + assert.equal(live.event.bridgeInstanceId, replay.result.bridgeInstanceId); + assert.equal(live.event.type, "runtime_event"); + assert.equal(live.event.data.eventType, "stream"); + assert.ok(live.event.seq > replay.result.events.at(-1).seq); + } finally { + socket.destroy(); + await service.close(); + } +}); + 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"); diff --git a/test/tauri-ui.test.js b/test/tauri-ui.test.js index f1c9b3e..b91c836 100644 --- a/test/tauri-ui.test.js +++ b/test/tauri-ui.test.js @@ -2,385 +2,84 @@ 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.match(source, /const uiScaleKey/); - assert.match(source, /function clampUiScale/); - assert.match(source, /function updateUiScale/); - assert.match(source, /localStorage\.setItem\(uiScaleKey/); - assert.match(source, /className="scale-settings"/); - assert.match(source, /aria-label="Interface scale"/); - assert.match(source, /aria-label="Decrease interface scale"/); - assert.match(source, /aria-label="Increase interface scale"/); - assert.match(source, /type="range"/); - assert.match(source, /style=\{\{ zoom: uiScale \}\}/); - assert.doesNotMatch( - source, - /className="pi-controls"[\s\S]*aria-label="Interface scale"/, - ); - assert.doesNotMatch(source, /
/); - 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\(scrollTranscriptToLatest\)/); - assert.match(source, /const loadGeneration = useRef\(0\)/); - assert.match( - source, - /const bridgeRetryTimer = useRef\(undefined\)/, - ); - assert.match(source, /const bridgeRetryAttempt = useRef\(0\)/); - assert.match(source, /function scheduleBridgeReconnect/); - assert.match(source, /Bridge unavailable; retrying in/); - assert.match( - source, - /setStatus\(String\(error\)\);\s*scheduleBridgeReconnect\(\);/, - ); - assert.match(source, /bridgeRetryTimer\.current = window\.setTimeout/); - assert.match(source, /void refreshAgents\(preferredPath\)/); - assert.match(source, /bridgeRetryAttempt\.current = 0/); - assert.match( - source, - /const lastEventSequence = useRef>/, - ); - assert.match(source, /const eventReloadInFlight = useRef\(false\)/); - assert.match(source, /const queuedEventReload = useRef/); - assert.match(source, /function reloadAgentAfterEvent\(agentId: string\)/); - assert.match(source, /void reloadAgentAfterEvent\(selectedId\)/); - assert.match(source, /let disposed = false;/); - assert.match(source, /if \(disposed\) stop\(\);/); - 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, - /
{ + const [app, reducer, hook] = await sources; + for (const operation of [ + "create_session_runtime", + "open_session_runtime", + "close_session_runtime", + "list_directory_sessions", + "get_session_runtime_snapshot", + "subscribe_workspace", + ]) + assert.match(`${app}\n${hook}`, new RegExp(operation)); + assert.doesNotMatch(app, /switch_session/); + assert.doesNotMatch(app, /new_session/); + assert.match(reducer, /WorkspaceState\["runtimesById"\]/); + assert.match(reducer, /requestGeneration/); + assert.match(reducer, /loadedThroughSeq/); }); -test("exposes accessible directory, session, notification, and extension controls", async () => { - const [component, styles] = await Promise.all([ - readFile("ui/src/App.tsx", "utf8"), - readFile("ui/src/App.css", "utf8"), +test("keeps presentation persistence allowlisted and agent IDs ephemeral", async () => { + const [, , , persistence] = await sources; + assert.match(persistence, /load_ui_state/); + assert.match(persistence, /save_ui_state/); + assert.match(persistence, /directoryOrder/); + assert.match(persistence, /drafts/); + assert.doesNotMatch(persistence, /agentId/); + assert.doesNotMatch(persistence, /messages/); + assert.doesNotMatch(persistence, /extensions/); +}); + +test("renders directory sidebar, accessible session tabs, warnings, and reduced motion", async () => { + const [app, , , , css] = await sources; + const [sidebar, tabs] = await Promise.all([ + readFile("ui/src/components/DirectorySidebar.tsx", "utf8"), + readFile("ui/src/components/SessionTabs.tsx", "utf8"), ]); - assert.match(component, /aria-expanded=\{view === "settings"\}/); - assert.match( - component, - /aria-label="Managed directories"\s+className="directory-tab-list"\s+role="tablist"/, - ); - assert.match(component, /role="tab"/); - assert.match( - component, - /aria-selected=\{directory\.agentId === selectedId\}/, - ); - assert.match(component, /aria-controls="directory-workspace"/); - assert.match(component, /onKeyDown=\{\(event\) => handleDirectoryTabKeyDown/); - assert.match( - component, - /aria-current=\{session\.isCurrent \? "page" : undefined\}/, - ); - assert.match(component, /role="status" aria-atomic="true"/); - assert.match(component, /aria-hidden="true"/); - assert.match( - component, - /role=\{notice\.notifyType === "error" \? "alert" : "status"\}/, - ); - assert.match(component, /role="dialog"/); - assert.match(component, /aria-modal="true"/); - assert.match(component, /aria-labelledby="extension-title"/); - assert.match(component, /const extensionRef = useRef\(null\)/); - assert.match(component, /function focusableExtensionControls/); - assert.match(component, /event\.key !== "Tab"/); - assert.match(component, /data-initial-focus/); - assert.match(component, /className="extension-backdrop"/); - assert.match(component, /htmlFor="extension-value"/); - assert.match(component, /Close current session and start a new session/); - assert.match(component, /Restart Pi for the selected directory/); - assert.match(component, /window\.confirm\(/); - assert.match(styles, /button:focus-visible/); - assert.match(styles, /\*::before,\n\*::after/); - assert.match(styles, /\.sr-only/); - assert.match(styles, /\.extension-backdrop \{/); - assert.match(styles, /animation-duration: 0\.01ms/); + assert.match(sidebar, /aria-label="Directories"/); + assert.match(tabs, /role="tablist"/); + assert.match(tabs, /role="tab"/); + assert.match(tabs, /aria-selected/); + assert.match(tabs, /ArrowRight/); + assert.match(app, /Multiple sessions are open in the same directory/); + assert.match(app, /Many Pi sessions are open/); + assert.match(css, /prefers-reduced-motion/); + assert.match(css, /grid-template-columns:\s*178px minmax\(0, 1fr\)/); }); -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"), +test("preserves native session naming, compaction, composer, and extension responses", async () => { + const [app] = await sources; + const [conversation, extension] = await Promise.all([ + readFile("ui/src/components/ConversationWorkspace.tsx", "utf8"), + readFile("ui/src/components/ExtensionDialog.tsx", "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/); + assert.match(app, /set_session_name/); + assert.match(app, /compact/); + assert.match(app, /submit_prompt/); + assert.match(app, /respond_to_extension/); + assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/); + assert.match(conversation, /className="todos-pane"/); + assert.match(extension, /role="dialog"/); + assert.match(extension, /aria-modal="true"/); }); -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 handleDirectoryTabKeyDown/); - assert.match(component, /function forgetDirectory/); - assert.match(component, /window\.confirm/); - assert.match(component, /"forget_directory"/); - assert.match(component, /bridgeEvent\.type === "agent_state"/); - assert.match(component, /setDirectories\(\(current\) =>/); - assert.match( - component, - /state: loadedState\.isStreaming \? "streaming" : "idle"/, - ); - assert.match(component, /className="directory-add"/); - assert.match( - component, - /import \{ open \} from "@tauri-apps\/plugin-dialog"/, - ); - assert.match(component, /function browseForFolder/); - assert.match(component, /directory: true/); - assert.match(component, /multiple: false/); - assert.match(component, /Browse…/); - assert.match(component, /const \[isAddingDirectory, setIsAddingDirectory\]/); - assert.match(component, /aria-busy=\{isAddingDirectory\}/); - assert.match(component, /className="directory-add-status" role="status"/); - assert.match(component, /pendingDirectoryPath/); - assert.match(component, /Added \$\{worktreeLabel\(pendingDirectoryPath\)\}/); - assert.match(component, /Add directory/); - assert.doesNotMatch(component, /className="folder-form"/); - assert.doesNotMatch(component, /Remembered folders/); - assert.match(component, /function startNewSession/); - assert.match(component, /function closeCurrentSession/); - assert.match(component, /Use \/resume to reopen it/); - assert.match(component, /function switchSession/); - assert.match(component, /className="session-panel"/); - assert.match(component, /New session/); - assert.match(component, /state\.isStreaming/); - assert.match(styles, /\.directory-add \{/); - assert.match(styles, /\.directory-add-status \{/); - assert.match(styles, /\.directory-add-spinner \{/); - assert.match(styles, /directory-add-spin/); - assert.match(styles, /\.directory-tab \{/); - assert.match(styles, /\.directory-tab-button \{/); - assert.match(styles, /\.directory-close \{/); - 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("offers a lower-center control when the conversation is above its latest message", async () => { - const [component, styles] = await Promise.all([ - readFile("ui/src/App.tsx", "utf8"), - readFile("ui/src/App.css", "utf8"), - ]); - assert.match( - component, - /const \[isTranscriptAtBottom, setIsTranscriptAtBottom\]/, - ); - assert.match(component, /function updateTranscriptScrollPosition/); - assert.match(component, /function scrollTranscriptToLatest/); - assert.match(component, /onScroll=\{updateTranscriptScrollPosition\}/); - assert.match(component, /!isTranscriptAtBottom/); - assert.match(component, /aria-label="Scroll to latest message"/); - assert.match(component, /className="scroll-to-latest"/); - assert.match(component, /scrollTranscriptToLatest\(\);/); - assert.match(styles, /\.transcript-container \{/); - assert.match(styles, /\.scroll-to-latest \{[\s\S]*left: 50%/); - assert.match(styles, /\.scroll-to-latest \{[\s\S]*bottom: 14px/); - assert.match(styles, /\.scroll-to-latest \{[\s\S]*border-radius: 50%/); -}); - -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("keeps an accessible focus treatment for the prompt editor", async () => { - const styles = await readFile("ui/src/App.css", "utf8"); - assert.match(styles, /\.composer textarea:focus \{/); - assert.doesNotMatch(styles, /outline: none/); - assert.match(styles, /border-color: #6f8b56/); - assert.match( - styles, - /\.composer textarea:focus \{[^}]*outline: 2px solid #c4ed8b/, - ); - assert.match(styles, /\.composer textarea:focus \{[^}]*outline-offset: -2px/); - assert.doesNotMatch(styles, /\.composer textarea:focus \{[^}]*box-shadow:/); - assert.match(styles, /textarea:focus-visible/); -}); - -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, /font-size: 18px/); - assert.match(styles, /\.directory-tab-button \{[\s\S]*font-size: 13px/); - assert.match(styles, /\.message pre \{[\s\S]*font-size: 14px/); - assert.match( - styles, - /\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 250px/, - ); - assert.match( - styles, - /\.pi-controls \{[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/, - ); - assert.match(styles, /\.scale-settings \{/); - assert.match( - styles, - /\.scale-adjustment \{[\s\S]*grid-template-columns: auto minmax\(0, 1fr\) auto/, - ); - assert.match(styles, /\.scale-adjustment input\[type="range"\] \{/); - assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/); -}); - -test("configures a larger desktop window and enables the folder dialog", async () => { - const [config, capability, rust] = await Promise.all([ +test("configures native window behavior and folder dialog capabilities", async () => { + const [config, capability, cargo] = await Promise.all([ readFile("ui/src-tauri/tauri.conf.json", "utf8"), readFile("ui/src-tauri/capabilities/default.json", "utf8"), - readFile("ui/src-tauri/src/lib.rs", "utf8"), + readFile("ui/src-tauri/Cargo.toml", "utf8"), ]); - assert.match(config, /"width": 1100/); - assert.match(config, /"height": 840/); - assert.match(config, /"minWidth": 640/); - assert.match(config, /"minHeight": 520/); - assert.match(capability, /"dialog:default"/); - assert.match(rust, /\.plugin\(tauri_plugin_dialog::init\(\)\)/); + assert.match(config, /"width"\s*:\s*1100/); + assert.match(capability, /core:window:allow-start-dragging/); + assert.match(cargo, /tauri-plugin-single-instance/); + assert.match(cargo, /tauri-plugin-dialog/); }); diff --git a/test/workspace-store.test.js b/test/workspace-store.test.js new file mode 100644 index 0000000..7eee9a2 --- /dev/null +++ b/test/workspace-store.test.js @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createWorkspaceStore } from "../src/bridge/workspace-store.js"; + +test("persists an owner-only versioned workspace manifest atomically", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-workspace-store-")); + const store = createWorkspaceStore(join(root, "sessions")); + const first = await store.load(); + assert.equal(first.migrating, true); + + await store.save({ + version: 2, + migrated: true, + runtimes: [ + { + runtimeId: "runtime-1", + worktreePath: join(root, "home"), + sessionPath: join(root, "sessions", "agent", "one.jsonl"), + sessionId: "session-1", + }, + ], + }); + const metadata = await stat(store.filePath); + assert.equal(metadata.mode & 0o777, 0o600); + const restored = (await store.load()).manifest; + assert.equal(restored.runtimes[0].runtimeId, "runtime-1"); + assert.deepEqual(restored.directories, [join(root, "home")]); + assert.equal((await readFile(store.filePath, "utf8")).endsWith("\n"), true); +}); + +test("accepts old manifests and persists managed empty directories", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-workspace-directories-")); + const store = createWorkspaceStore(join(root, "sessions")); + await store.save({ + version: 2, + migrated: true, + directories: [join(root, "empty")], + runtimes: [], + }); + assert.deepEqual((await store.load()).manifest.directories, [ + join(root, "empty"), + ]); + await writeFile( + store.filePath, + JSON.stringify({ version: 2, migrated: true, runtimes: [] }), + { mode: 0o600 }, + ); + assert.deepEqual((await store.load()).manifest.directories, []); +}); + +test("reports a corrupt manifest without treating it as first migration", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-workspace-corrupt-")); + const store = createWorkspaceStore(join(root, "sessions")); + await store.save({ version: 2, migrated: true, runtimes: [] }); + await writeFile(store.filePath, "{broken", { mode: 0o600 }); + + const loaded = await store.load(); + assert.equal(loaded.migrating, false); + assert.equal(loaded.manifest.runtimes.length, 0); + assert.equal(loaded.issue.code, "invalid_workspace_manifest"); +}); + +test("recovers its serialized write queue after a failed save", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-workspace-retry-")); + const sessionRoot = join(root, "sessions"); + const store = createWorkspaceStore(sessionRoot); + await mkdir(sessionRoot); + await mkdir(store.filePath); + await assert.rejects( + store.save({ version: 2, migrated: true, runtimes: [] }), + ); + await rm(store.filePath, { recursive: true }); + await store.save({ version: 2, migrated: true, runtimes: [] }); + assert.deepEqual((await store.load()).manifest.runtimes, []); +}); diff --git a/ui/package-lock.json b/ui/package-lock.json index 17467f0..2d3d1e1 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -17,13 +17,46 @@ }, "devDependencies": { "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.4", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^26.1.0", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^3.2.7" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -258,6 +291,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -306,6 +349,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -1454,6 +1612,104 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.4", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.4.tgz", + "integrity": "sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1499,6 +1755,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1547,6 +1821,176 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", @@ -1594,6 +2038,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001806", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", @@ -1615,6 +2069,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1622,6 +2103,27 @@ "dev": true, "license": "MIT" }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1629,6 +2131,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1647,6 +2163,41 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.396", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", @@ -1654,6 +2205,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -1706,6 +2277,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1749,6 +2340,77 @@ "node": ">=6.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1756,6 +2418,46 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1782,6 +2484,13 @@ "node": ">=6" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1792,6 +2501,37 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1828,6 +2568,43 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1877,6 +2654,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1898,6 +2701,14 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1908,6 +2719,20 @@ "node": ">=0.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1953,6 +2778,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1969,6 +2821,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1979,6 +2838,74 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1996,6 +2923,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -2116,6 +3119,219 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/ui/package.json b/ui/package.json index 66f621e..a27ae0d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,8 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "test": "vitest run", + "test:watch": "vitest", "preview": "vite preview", "tauri": "tauri", "tauri:release": "tauri build --no-bundle" @@ -25,10 +27,15 @@ }, "devDependencies": { "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.4", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^26.1.0", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^3.2.7" } } diff --git a/ui/src-tauri/Cargo.toml b/ui/src-tauri/Cargo.toml index c47bf73..d0328d6 100644 --- a/ui/src-tauri/Cargo.toml +++ b/ui/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] } +tokio = { version = "1", features = ["io-util", "net", "rt", "macros", "time"] } tauri-plugin-dialog = "2" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] diff --git a/ui/src-tauri/src/bridge.rs b/ui/src-tauri/src/bridge.rs index dc1e015..15328e2 100644 --- a/ui/src-tauri/src/bridge.rs +++ b/ui/src-tauri/src/bridge.rs @@ -1,10 +1,12 @@ -use serde::Serialize; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::{json, Value}; use std::env; use std::path::Path; +use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; +use tokio::time::{sleep, Duration}; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -83,6 +85,181 @@ pub async fn request( result_from_response(value) } +async fn typed_request( + socket_path: &str, + operation: &str, + payload: Option, +) -> Result { + let value = request(socket_path, operation, None, payload).await?; + serde_json::from_value(value) + .map_err(|error| format!("Bridge returned an invalid {operation} result: {error}")) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeSummary { + pub runtime_id: String, + pub worktree_path: String, + pub state: String, + pub label: String, + pub attention: bool, + pub queue_count: u64, + pub last_activity: String, + pub opened_at: String, + pub active_tool: Option, + pub agent_id: Option, + pub session_id: Option, + pub session_path: Option, + pub error: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryWorkspace { + pub worktree_path: String, + pub is_home: bool, + pub open_count: u64, + pub working_count: u64, + pub attention_count: u64, + pub recovering_count: u64, + pub error_count: u64, + pub runtimes: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Workspace { + pub bridge_instance_id: String, + pub latest_seq: u64, + pub directories: Vec, + pub issue: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSummary { + pub bridge_instance_id: String, + pub latest_seq: u64, + pub open_count: u64, + pub working_count: u64, + pub attention_count: u64, + pub recovering_count: u64, + pub error_count: u64, + pub directory_count: u64, + pub resource_warning: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RuntimeResult { + pub runtime: RuntimeSummary, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CloseRuntimeResult { + pub runtime_id: String, + pub session_path: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectorySession { + pub path: String, + pub id: String, + pub cwd: String, + pub name: Option, + pub parent_session_path: Option, + pub created: Option, + pub modified: String, + pub message_count: u64, + pub first_message: Option, + pub is_current: bool, + pub runtime_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DirectorySessionsResult { + pub sessions: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeSnapshot { + pub bridge_instance_id: String, + pub latest_seq: u64, + pub runtime: RuntimeSummary, + pub state: Option, + pub stats: Option, + pub transcript: Option, + pub commands: Option, + pub models: Option, + #[serde(default)] + pub extensions: Vec, +} + +pub async fn get_workspace(socket_path: &str) -> Result { + typed_request(socket_path, "get_workspace", None).await +} +pub async fn get_workspace_summary(socket_path: &str) -> Result { + typed_request(socket_path, "get_workspace_summary", None).await +} +pub async fn create_session_runtime( + socket_path: &str, + worktree_path: &str, +) -> Result { + typed_request( + socket_path, + "create_session_runtime", + Some(json!({ "worktreePath": worktree_path })), + ) + .await +} +pub async fn open_session_runtime( + socket_path: &str, + worktree_path: &str, + session_path: &str, +) -> Result { + typed_request( + socket_path, + "open_session_runtime", + Some(json!({ "worktreePath": worktree_path, "sessionPath": session_path })), + ) + .await +} +pub async fn close_session_runtime( + socket_path: &str, + runtime_id: &str, +) -> Result { + typed_request( + socket_path, + "close_session_runtime", + Some(json!({ "runtimeId": runtime_id })), + ) + .await +} +pub async fn list_directory_sessions( + socket_path: &str, + worktree_path: &str, +) -> Result { + typed_request( + socket_path, + "list_directory_sessions", + Some(json!({ "worktreePath": worktree_path })), + ) + .await +} +pub async fn get_session_runtime_snapshot( + socket_path: &str, + runtime_id: &str, +) -> Result { + typed_request( + socket_path, + "get_session_runtime_snapshot", + Some(json!({ "runtimeId": runtime_id })), + ) + .await +} + pub async fn list_agents(socket_path: &str) -> Result { request(socket_path, "list_agents", None, None).await } @@ -246,6 +423,32 @@ pub async fn set_thinking_level( .map(|_| ()) } +pub async fn set_session_name(socket_path: &str, agent_id: &str, name: &str) -> Result<(), String> { + request( + socket_path, + "set_session_name", + Some(agent_id), + Some(json!({ "name": name })), + ) + .await + .map(|_| ()) +} + +pub async fn compact( + socket_path: &str, + agent_id: &str, + custom_instructions: Option<&str>, +) -> Result<(), String> { + request( + socket_path, + "compact", + Some(agent_id), + custom_instructions.map(|instructions| json!({ "customInstructions": instructions })), + ) + .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 @@ -268,6 +471,303 @@ pub async fn respond_to_extension( .map(|_| ()) } +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceEvent { + pub bridge_instance_id: String, + pub seq: u64, + #[serde(flatten)] + pub body: serde_json::Map, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkspaceReplay { + bridge_instance_id: String, + first_available_seq: u64, + latest_seq: u64, + truncated: bool, + events: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum WorkspaceHostEvent { + Connected { + bridge_instance_id: String, + first_available_seq: u64, + latest_seq: u64, + }, + Event { + event: WorkspaceEvent, + }, + ResetRequired { + bridge_instance_id: String, + latest_seq: u64, + reason: String, + }, + Disconnected { + message: String, + retry_in_ms: u64, + }, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SubscriptionCursor { + pub bridge_instance_id: Option, + pub seq: u64, +} + +fn accept_replay( + cursor: &mut SubscriptionCursor, + replay: &WorkspaceReplay, +) -> Vec { + if let Some(expected) = &cursor.bridge_instance_id { + if expected != &replay.bridge_instance_id { + cursor.bridge_instance_id = Some(replay.bridge_instance_id.clone()); + cursor.seq = replay.latest_seq; + return vec![WorkspaceHostEvent::ResetRequired { + bridge_instance_id: replay.bridge_instance_id.clone(), + latest_seq: replay.latest_seq, + reason: "epochChanged".into(), + }]; + } + } + if replay.truncated { + cursor.bridge_instance_id = Some(replay.bridge_instance_id.clone()); + cursor.seq = replay.latest_seq; + return vec![WorkspaceHostEvent::ResetRequired { + bridge_instance_id: replay.bridge_instance_id.clone(), + latest_seq: replay.latest_seq, + reason: "replayTruncated".into(), + }]; + } + cursor.bridge_instance_id = Some(replay.bridge_instance_id.clone()); + let mut output = vec![WorkspaceHostEvent::Connected { + bridge_instance_id: replay.bridge_instance_id.clone(), + first_available_seq: replay.first_available_seq, + latest_seq: replay.latest_seq, + }]; + for event in &replay.events { + if event.bridge_instance_id != replay.bridge_instance_id || event.seq <= cursor.seq { + continue; + } + if event.seq != cursor.seq + 1 { + cursor.seq = replay.latest_seq; + output.push(WorkspaceHostEvent::ResetRequired { + bridge_instance_id: replay.bridge_instance_id.clone(), + latest_seq: replay.latest_seq, + reason: "sequenceGap".into(), + }); + return output; + } + cursor.seq = event.seq; + output.push(WorkspaceHostEvent::Event { + event: event.clone(), + }); + } + if cursor.seq < replay.latest_seq { + cursor.seq = replay.latest_seq; + output.push(WorkspaceHostEvent::ResetRequired { + bridge_instance_id: replay.bridge_instance_id.clone(), + latest_seq: replay.latest_seq, + reason: "sequenceGap".into(), + }); + } + output +} + +fn accept_live( + cursor: &mut SubscriptionCursor, + event: WorkspaceEvent, +) -> Option { + if cursor.bridge_instance_id.as_deref() != Some(&event.bridge_instance_id) { + cursor.bridge_instance_id = Some(event.bridge_instance_id.clone()); + cursor.seq = event.seq; + return Some(WorkspaceHostEvent::ResetRequired { + bridge_instance_id: event.bridge_instance_id, + latest_seq: event.seq, + reason: "epochChanged".into(), + }); + } + if event.seq <= cursor.seq { + return None; + } + if event.seq != cursor.seq + 1 { + cursor.seq = event.seq; + return Some(WorkspaceHostEvent::ResetRequired { + bridge_instance_id: event.bridge_instance_id, + latest_seq: event.seq, + reason: "sequenceGap".into(), + }); + } + cursor.seq = event.seq; + Some(WorkspaceHostEvent::Event { event }) +} + +#[derive(Debug, Default)] +pub struct SubscriptionGeneration(Mutex); + +impl SubscriptionGeneration { + pub fn replace(&self) -> Result { + let mut current = self + .0 + .lock() + .map_err(|_| "Could not replace workspace subscription".to_owned())?; + *current = current.saturating_add(1); + Ok(*current) + } + + pub fn invalidate(&self) -> Result<(), String> { + self.replace().map(|_| ()) + } + + fn run_if_current( + &self, + expected_generation: u64, + operation: impl FnOnce() -> Result, + ) -> Result { + let current = self + .0 + .lock() + .map_err(|_| "Could not inspect workspace subscription".to_owned())?; + if *current != expected_generation { + return Err("Workspace subscription was replaced".to_owned()); + } + operation() + } + + fn is_current(&self, expected_generation: u64) -> Result { + self.0 + .lock() + .map(|current| *current == expected_generation) + .map_err(|_| "Could not inspect workspace subscription".to_owned()) + } +} + +fn retry_delay(attempt: usize) -> u64 { + match attempt { + 0 => 250, + 1 => 500, + 2 => 1000, + _ => 2000, + } +} + +fn emit_workspace( + app: &AppHandle, + generation: &SubscriptionGeneration, + expected_generation: u64, + event: WorkspaceHostEvent, +) -> Result<(), String> { + generation.run_if_current(expected_generation, || { + app.emit("workspace-bridge", event) + .map_err(|error| format!("Could not publish workspace event: {error}")) + }) +} + +async fn subscribe_workspace_once( + socket_path: &str, + cursor: &mut SubscriptionCursor, + app: &AppHandle, + generation: &SubscriptionGeneration, + expected_generation: u64, +) -> Result<(), String> { + let mut reader = connect_and_send( + socket_path, + "subscribe_workspace", + None, + Some(json!({ "cursor": cursor.seq })), + ) + .await?; + let mut handshake = String::new(); + if reader + .read_line(&mut handshake) + .await + .map_err(|error| format!("Bridge subscription failed: {error}"))? + == 0 + { + return Err("Bridge subscription closed before handshake".into()); + } + let replay_value: Value = serde_json::from_str(&handshake) + .map_err(|error| format!("Bridge emitted invalid JSON: {error}"))?; + let replay: WorkspaceReplay = serde_json::from_value(result_from_response(replay_value)?) + .map_err(|error| format!("Bridge returned an invalid workspace replay: {error}"))?; + for event in accept_replay(cursor, &replay) { + emit_workspace(app, generation, expected_generation, event)?; + } + loop { + let mut line = String::new(); + if reader + .read_line(&mut line) + .await + .map_err(|error| format!("Bridge subscription failed: {error}"))? + == 0 + { + return Err("Bridge subscription closed".into()); + } + let envelope: Value = serde_json::from_str(&line) + .map_err(|error| format!("Bridge emitted invalid JSON: {error}"))?; + let event: WorkspaceEvent = serde_json::from_value( + envelope + .get("event") + .cloned() + .ok_or_else(|| "Bridge event envelope is missing event".to_owned())?, + ) + .map_err(|error| format!("Bridge emitted an invalid workspace event: {error}"))?; + if let Some(event) = accept_live(cursor, event) { + emit_workspace(app, generation, expected_generation, event)?; + } + } +} + +pub async fn subscribe_workspace( + socket_path: String, + mut cursor: SubscriptionCursor, + app: AppHandle, + generation: Arc, + expected_generation: u64, +) -> Result<(), String> { + let mut attempt = 0usize; + loop { + if !generation.is_current(expected_generation)? { + return Ok(()); + } + match subscribe_workspace_once( + &socket_path, + &mut cursor, + &app, + &generation, + expected_generation, + ) + .await + { + Ok(()) => attempt = 0, + Err(error) => { + let delay = retry_delay(attempt); + if error == "Workspace subscription was replaced" { + return Ok(()); + } + emit_workspace( + &app, + &generation, + expected_generation, + WorkspaceHostEvent::Disconnected { + message: error, + retry_in_ms: delay, + }, + )?; + sleep(Duration::from_millis(delay)).await; + attempt = (attempt + 1).min(3); + } + } + } +} + pub async fn subscribe( socket_path: String, agent_id: String, @@ -445,4 +945,361 @@ mod tests { server.await.expect("server succeeds"); std::fs::remove_file(path).expect("socket cleanup"); } + + fn runtime(id: &str) -> Value { + json!({ + "runtimeId": id, + "worktreePath": "/tmp/project", + "state": "idle", + "label": "Session", + "attention": false, + "queueCount": 0, + "lastActivity": "2026-01-01T00:00:00Z", + "openedAt": "2026-01-01T00:00:00Z" + }) + } + + async fn assert_typed_frame(operation: &'static str, payload: Value, call: F) + where + F: FnOnce(String) -> Fut, + Fut: std::future::Future>, + { + let path = format!( + "{}/pi-status-ui-frame-{operation}-{}-{}.sock", + env::temp_dir().display(), + std::process::id(), + operation.len() + ); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).expect("listener"); + let expected_payload = payload.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("connection"); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + let frame: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(frame["op"], operation); + assert_eq!( + frame.get("payload").cloned().unwrap_or(Value::Null), + expected_payload + ); + let result = match operation { + "close_session_runtime" => json!({"runtimeId":"r"}), + "list_directory_sessions" => json!({"sessions":[]}), + "get_session_runtime_snapshot" => { + json!({"bridgeInstanceId":"b","latestSeq":0,"runtime":runtime("r"),"extensions":[]}) + } + _ => json!({"runtime":runtime("r")}), + }; + let response = format!("{}\n", json!({"id":"tauri-ui","ok":true,"result":result})); + reader + .get_mut() + .write_all(response.as_bytes()) + .await + .unwrap(); + }); + call(path.clone()).await.unwrap(); + server.await.unwrap(); + std::fs::remove_file(path).unwrap(); + } + + async fn assert_no_payload(operation: &'static str, result: Value, call: F) -> T + where + T: Send + 'static, + F: FnOnce(String) -> Fut, + Fut: std::future::Future>, + { + let path = format!( + "{}/pi-status-ui-empty-{operation}-{}.sock", + env::temp_dir().display(), + std::process::id() + ); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + let frame: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(frame["op"], operation); + assert!(frame.get("payload").is_none()); + let response = format!("{}\n", json!({"id":"tauri-ui","ok":true,"result":result})); + reader + .get_mut() + .write_all(response.as_bytes()) + .await + .unwrap(); + }); + let value = call(path.clone()).await.unwrap(); + server.await.unwrap(); + std::fs::remove_file(path).unwrap(); + value + } + + #[tokio::test] + async fn workspace_wrappers_emit_exact_operations_without_payloads() { + let workspace: Workspace = assert_no_payload( + "get_workspace", + json!({"bridgeInstanceId":"b","latestSeq":0,"directories":[]}), + |path| async move { get_workspace(&path).await }, + ) + .await; + assert_eq!(workspace.bridge_instance_id, "b"); + let summary: WorkspaceSummary = assert_no_payload( + "get_workspace_summary", + json!({"bridgeInstanceId":"b","latestSeq":0,"openCount":0,"workingCount":0,"attentionCount":0,"recoveringCount":0,"errorCount":0,"directoryCount":0,"resourceWarning":false}), + |path| async move { get_workspace_summary(&path).await }, + ) + .await; + assert_eq!(summary.open_count, 0); + } + + #[tokio::test] + async fn runtime_wrappers_emit_exact_operations_and_payloads() { + assert_typed_frame( + "create_session_runtime", + json!({"worktreePath":"/tmp/project"}), + |path| async move { + create_session_runtime(&path, "/tmp/project") + .await + .map(|_| ()) + }, + ) + .await; + assert_typed_frame( + "open_session_runtime", + json!({"worktreePath":"/tmp/project","sessionPath":"/tmp/project/s.jsonl"}), + |path| async move { + open_session_runtime(&path, "/tmp/project", "/tmp/project/s.jsonl") + .await + .map(|_| ()) + }, + ) + .await; + assert_typed_frame( + "close_session_runtime", + json!({"runtimeId":"r"}), + |path| async move { close_session_runtime(&path, "r").await.map(|_| ()) }, + ) + .await; + assert_typed_frame( + "list_directory_sessions", + json!({"worktreePath":"/tmp/project"}), + |path| async move { + list_directory_sessions(&path, "/tmp/project") + .await + .map(|_| ()) + }, + ) + .await; + assert_typed_frame( + "get_session_runtime_snapshot", + json!({"runtimeId":"r"}), + |path| async move { get_session_runtime_snapshot(&path, "r").await.map(|_| ()) }, + ) + .await; + } + + #[test] + fn decodes_multi_runtime_workspace_dormant_snapshot_and_directory_sessions() { + let workspace: Workspace = serde_json::from_value(json!({ + "bridgeInstanceId":"bridge", "latestSeq":4, + "directories":[{"worktreePath":"/tmp/project","isHome":true,"openCount":2,"workingCount":1,"attentionCount":0,"recoveringCount":0,"errorCount":0,"runtimes":[runtime("one"),runtime("two")]}] + })).unwrap(); + assert_eq!(workspace.directories[0].runtimes.len(), 2); + let snapshot: RuntimeSnapshot = serde_json::from_value(json!({"bridgeInstanceId":"bridge","latestSeq":4,"runtime":runtime("failed"),"extensions":[]})).unwrap(); + assert!(snapshot.state.is_none()); + let sessions: DirectorySessionsResult = serde_json::from_value(json!({ + "sessions": [ + {"path":"/tmp/project/one.jsonl","id":"one","cwd":"/tmp/project","modified":"2026-01-01T00:00:00Z","messageCount":2,"isCurrent":false,"runtimeId":"runtime-one"}, + {"path":"/tmp/project/two.jsonl","id":"two","cwd":"/tmp/project","modified":"2026-01-02T00:00:00Z","messageCount":0,"isCurrent":false} + ] + })).unwrap(); + assert_eq!( + sessions.sessions[0].runtime_id.as_deref(), + Some("runtime-one") + ); + assert!(sessions.sessions[1].runtime_id.is_none()); + } + + #[tokio::test] + async fn workspace_subscription_frame_uses_global_cursor_without_agent() { + let path = format!( + "{}/pi-status-ui-workspace-subscribe-{}.sock", + env::temp_dir().display(), + std::process::id() + ); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + let frame: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(frame["op"], "subscribe_workspace"); + assert_eq!(frame["payload"], json!({"cursor":17})); + assert!(frame.get("agentId").is_none()); + }); + let _reader = connect_and_send( + &path, + "subscribe_workspace", + None, + Some(json!({"cursor":17})), + ) + .await + .unwrap(); + server.await.unwrap(); + std::fs::remove_file(path).unwrap(); + } + + fn event(epoch: &str, seq: u64) -> WorkspaceEvent { + serde_json::from_value( + json!({"bridgeInstanceId":epoch,"seq":seq,"type":"runtime_event","data":{}}), + ) + .unwrap() + } + + #[test] + fn workspace_host_events_use_exact_camel_case_json() { + let event = event("bridge-1", 7); + let cases = [ + ( + WorkspaceHostEvent::Connected { + bridge_instance_id: "bridge-1".into(), + first_available_seq: 3, + latest_seq: 7, + }, + json!({ + "kind": "connected", + "bridgeInstanceId": "bridge-1", + "firstAvailableSeq": 3, + "latestSeq": 7 + }), + ), + ( + WorkspaceHostEvent::Event { + event: event.clone(), + }, + json!({ "kind": "event", "event": event }), + ), + ( + WorkspaceHostEvent::ResetRequired { + bridge_instance_id: "bridge-1".into(), + latest_seq: 7, + reason: "sequenceGap".into(), + }, + json!({ + "kind": "resetRequired", + "bridgeInstanceId": "bridge-1", + "latestSeq": 7, + "reason": "sequenceGap" + }), + ), + ( + WorkspaceHostEvent::Disconnected { + message: "offline".into(), + retry_in_ms: 500, + }, + json!({ + "kind": "disconnected", + "message": "offline", + "retryInMs": 500 + }), + ), + ]; + for (host_event, expected) in cases { + assert_eq!(serde_json::to_value(host_event).unwrap(), expected); + } + } + + #[test] + fn replacement_waits_for_an_in_flight_emission_and_rejects_stale_work() { + use std::sync::mpsc; + use std::thread; + use std::time::Duration as StdDuration; + + let generation = Arc::new(SubscriptionGeneration::default()); + let first = generation.replace().unwrap(); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let emitter_generation = Arc::clone(&generation); + let emitter = thread::spawn(move || { + emitter_generation.run_if_current(first, || { + entered_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Ok(()) + }) + }); + entered_rx.recv().unwrap(); + + let replacement_generation = Arc::clone(&generation); + let (replaced_tx, replaced_rx) = mpsc::channel(); + let replacement = thread::spawn(move || { + let next = replacement_generation.replace().unwrap(); + replaced_tx.send(next).unwrap(); + }); + assert!(replaced_rx + .recv_timeout(StdDuration::from_millis(50)) + .is_err()); + release_tx.send(()).unwrap(); + assert!(emitter.join().unwrap().is_ok()); + assert_eq!(replaced_rx.recv().unwrap(), first + 1); + replacement.join().unwrap(); + assert_eq!( + generation.run_if_current(first, || Ok(())), + Err("Workspace subscription was replaced".to_owned()) + ); + assert_eq!( + (0..6).map(retry_delay).collect::>(), + vec![250, 500, 1000, 2000, 2000, 2000] + ); + } + + #[test] + fn replay_and_live_decisions_handle_epochs_truncation_and_gaps() { + let mut cursor = SubscriptionCursor { + bridge_instance_id: Some("a".into()), + seq: 1, + }; + let replay = WorkspaceReplay { + bridge_instance_id: "a".into(), + first_available_seq: 1, + latest_seq: 3, + truncated: false, + events: vec![event("a", 2), event("a", 3)], + }; + assert_eq!(accept_replay(&mut cursor, &replay).len(), 3); + assert_eq!(cursor.seq, 3); + assert!(matches!( + accept_live(&mut cursor, event("a", 4)), + Some(WorkspaceHostEvent::Event { .. }) + )); + assert!(accept_live(&mut cursor, event("a", 4)).is_none()); + assert!( + matches!(accept_live(&mut cursor, event("a",6)), Some(WorkspaceHostEvent::ResetRequired { reason, .. }) if reason == "sequenceGap") + ); + let changed = WorkspaceReplay { + bridge_instance_id: "b".into(), + first_available_seq: 1, + latest_seq: 9, + truncated: false, + events: vec![], + }; + assert!( + matches!(&accept_replay(&mut cursor, &changed)[0], WorkspaceHostEvent::ResetRequired { reason, .. } if reason == "epochChanged") + ); + let truncated = WorkspaceReplay { + bridge_instance_id: "b".into(), + first_available_seq: 10, + latest_seq: 12, + truncated: true, + events: vec![], + }; + assert!( + matches!(&accept_replay(&mut cursor, &truncated)[0], WorkspaceHostEvent::ResetRequired { reason, .. } if reason == "replayTruncated") + ); + } } diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs index 4f3833b..548687e 100644 --- a/ui/src-tauri/src/lib.rs +++ b/ui/src-tauri/src/lib.rs @@ -1,10 +1,29 @@ mod bridge; +mod ui_state; use serde_json::Value; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State}; -struct Subscription(Mutex>>); +struct LegacySubscription(Mutex>>); +struct WorkspaceSubscription { + generation: Arc, + task: Mutex>>, +} + +fn replace_subscription( + task_slot: &Mutex>, + generation: &bridge::SubscriptionGeneration, + create: impl FnOnce(u64) -> T, +) -> Result, String> { + let mut slot = task_slot + .lock() + .map_err(|_| "Could not update workspace subscription".to_owned())?; + let expected_generation = generation.replace()?; + let previous = slot.take(); + *slot = Some(create(expected_generation)); + Ok(previous) +} #[derive(Debug, PartialEq, Eq)] enum WindowAction { @@ -26,6 +45,66 @@ fn socket_path() -> Result { bridge::default_socket_path() } +#[tauri::command] +async fn get_workspace() -> Result { + bridge::get_workspace(&socket_path()?).await +} + +#[tauri::command] +async fn get_workspace_summary() -> Result { + bridge::get_workspace_summary(&socket_path()?).await +} + +#[tauri::command] +async fn create_session_runtime(worktree_path: String) -> Result { + bridge::create_session_runtime(&socket_path()?, &worktree_path).await +} + +#[tauri::command] +async fn open_session_runtime( + worktree_path: String, + session_path: String, +) -> Result { + bridge::open_session_runtime(&socket_path()?, &worktree_path, &session_path).await +} + +#[tauri::command] +async fn close_session_runtime(runtime_id: String) -> Result { + bridge::close_session_runtime(&socket_path()?, &runtime_id).await +} + +#[tauri::command] +async fn list_directory_sessions( + worktree_path: String, +) -> Result { + bridge::list_directory_sessions(&socket_path()?, &worktree_path).await +} + +#[tauri::command] +async fn get_session_runtime_snapshot( + runtime_id: String, +) -> Result { + bridge::get_session_runtime_snapshot(&socket_path()?, &runtime_id).await +} + +#[tauri::command] +fn load_ui_state(app: AppHandle) -> Result { + let directory = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + ui_state::load_from(&directory) +} + +#[tauri::command] +fn save_ui_state(app: AppHandle, state: ui_state::UiStateV1) -> Result<(), String> { + let directory = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + ui_state::save_to(&directory, &state) +} + #[tauri::command] async fn list_agents() -> Result { bridge::list_agents(&socket_path()?).await @@ -96,6 +175,16 @@ async fn set_thinking_level(agent_id: String, level: String) -> Result<(), Strin bridge::set_thinking_level(&socket_path()?, &agent_id, &level).await } +#[tauri::command] +async fn set_session_name(agent_id: String, name: String) -> Result<(), String> { + bridge::set_session_name(&socket_path()?, &agent_id, &name).await +} + +#[tauri::command] +async fn compact(agent_id: String, custom_instructions: Option) -> Result<(), String> { + bridge::compact(&socket_path()?, &agent_id, custom_instructions.as_deref()).await +} + #[tauri::command] async fn respond_to_extension( agent_id: String, @@ -108,7 +197,7 @@ async fn respond_to_extension( #[tauri::command] fn subscribe_agent( app: AppHandle, - subscriptions: State<'_, Subscription>, + subscriptions: State<'_, LegacySubscription>, agent_id: String, cursor: u64, ) -> Result<(), String> { @@ -140,11 +229,65 @@ fn subscribe_agent( Ok(()) } +#[tauri::command] +fn subscribe_workspace( + app: AppHandle, + subscription: State<'_, WorkspaceSubscription>, + cursor: Option, +) -> Result<(), String> { + let socket = socket_path()?; + let event_app = app.clone(); + let generation = Arc::clone(&subscription.generation); + let previous = replace_subscription( + &subscription.task, + &subscription.generation, + move |expected_generation| { + tauri::async_runtime::spawn(async move { + let initial = bridge::SubscriptionCursor { + bridge_instance_id: cursor + .as_ref() + .map(|value| value.bridge_instance_id.clone()), + seq: cursor.map(|value| value.seq).unwrap_or(0), + }; + let _ = bridge::subscribe_workspace( + socket, + initial, + event_app, + generation, + expected_generation, + ) + .await; + }) + }, + )?; + if let Some(task) = previous { + task.abort(); + } + Ok(()) +} + +#[tauri::command] +fn unsubscribe_workspace(subscription: State<'_, WorkspaceSubscription>) -> Result<(), String> { + let mut slot = subscription + .task + .lock() + .map_err(|_| "Could not clear workspace subscription".to_owned())?; + subscription.generation.invalidate()?; + if let Some(task) = slot.take() { + task.abort(); + } + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) - .manage(Subscription(Mutex::new(None))) + .manage(LegacySubscription(Mutex::new(None))) + .manage(WorkspaceSubscription { + generation: Arc::new(bridge::SubscriptionGeneration::default()), + task: Mutex::new(None), + }) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { if let Some(window) = app.get_webview_window("main") { match window_action(&args, window.is_visible().unwrap_or(false)) { @@ -159,6 +302,17 @@ pub fn run() { } })) .invoke_handler(tauri::generate_handler![ + get_workspace, + get_workspace_summary, + create_session_runtime, + open_session_runtime, + close_session_runtime, + list_directory_sessions, + get_session_runtime_snapshot, + load_ui_state, + save_ui_state, + subscribe_workspace, + unsubscribe_workspace, list_agents, list_directories, select_worktree, @@ -173,6 +327,8 @@ pub fn run() { restart, set_model, set_thinking_level, + set_session_name, + compact, respond_to_extension, subscribe_agent ]); @@ -186,6 +342,21 @@ pub fn run() { mod tests { use super::*; + #[test] + fn serialized_subscription_replacement_returns_the_previous_task() { + let tasks = Mutex::new(None::); + let generation = bridge::SubscriptionGeneration::default(); + assert_eq!( + replace_subscription(&tasks, &generation, |current| current).unwrap(), + None + ); + assert_eq!( + replace_subscription(&tasks, &generation, |current| current).unwrap(), + Some(1) + ); + assert_eq!(*tasks.lock().unwrap(), Some(2)); + } + #[test] fn toggle_hides_a_visible_window_and_shows_a_hidden_window() { let toggle = vec!["--toggle".to_owned()]; @@ -193,7 +364,7 @@ mod tests { assert_eq!(window_action(&toggle, true), WindowAction::Hide); assert_eq!(window_action(&toggle, false), WindowAction::ShowAndFocus); assert_eq!( - window_action(&vec!["--show".to_owned()], true), + window_action(&["--show".to_owned()], true), WindowAction::ShowAndFocus ); } diff --git a/ui/src-tauri/src/ui_state.rs b/ui/src-tauri/src/ui_state.rs new file mode 100644 index 0000000..c3480b4 --- /dev/null +++ b/ui/src-tauri/src/ui_state.rs @@ -0,0 +1,440 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const VERSION: u32 = 1; +const MAX_ITEMS: usize = 256; +const MAX_TOTAL_ITEMS: usize = 2048; +const MAX_DRAFT_BYTES: usize = 128 * 1024; +const MAX_TOTAL_DRAFT_BYTES: usize = 1024 * 1024; +const MAX_TOTAL_STRING_BYTES: usize = 1536 * 1024; +const MAX_FILE_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceCursor { + pub bridge_instance_id: String, + pub seq: u64, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ScrollState { + pub at_bottom: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub scroll_top: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LastSeenState { + pub bridge_instance_id: String, + pub seq: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_activity: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UiStateV1 { + pub version: u32, + #[serde(default)] + pub directory_order: Vec, + #[serde(default)] + pub runtime_order_by_directory: BTreeMap>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_runtime_id: Option, + #[serde(default)] + pub drafts: BTreeMap, + #[serde(default)] + pub scroll: BTreeMap, + #[serde(default)] + pub last_seen: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interface_scale: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dismissed_collision_warning: Option, +} + +impl Default for UiStateV1 { + fn default() -> Self { + Self { + version: VERSION, + directory_order: Vec::new(), + runtime_order_by_directory: BTreeMap::new(), + selected_runtime_id: None, + drafts: BTreeMap::new(), + scroll: BTreeMap::new(), + last_seen: BTreeMap::new(), + workspace_cursor: None, + interface_scale: None, + dismissed_collision_warning: None, + } + } +} + +fn bounded(value: &str, field: &str) -> Result<(), String> { + if value.is_empty() || value.len() > 4096 { + Err(format!("{field} must contain 1 to 4096 bytes")) + } else { + Ok(()) + } +} + +pub fn validate(state: &UiStateV1) -> Result<(), String> { + if state.version != VERSION { + return Err(format!("Unsupported UI state version: {}", state.version)); + } + if state.directory_order.len() > MAX_ITEMS + || state.runtime_order_by_directory.len() > MAX_ITEMS + || state.drafts.len() > MAX_ITEMS + || state.scroll.len() > MAX_ITEMS + || state.last_seen.len() > MAX_ITEMS + { + return Err("UI state contains too many entries".to_owned()); + } + let mut total_items = state.directory_order.len() + + state.runtime_order_by_directory.len() + + state.drafts.len() + + state.scroll.len() + + state.last_seen.len(); + let mut total_string_bytes = 0usize; + for path in &state.directory_order { + bounded(path, "directory path")?; + total_string_bytes = total_string_bytes.saturating_add(path.len()); + } + for (path, runtimes) in &state.runtime_order_by_directory { + bounded(path, "directory path")?; + total_string_bytes = total_string_bytes.saturating_add(path.len()); + if runtimes.len() > MAX_ITEMS { + return Err("UI state contains too many runtime entries".to_owned()); + } + total_items = total_items.saturating_add(runtimes.len()); + for runtime_id in runtimes { + bounded(runtime_id, "runtime id")?; + total_string_bytes = total_string_bytes.saturating_add(runtime_id.len()); + } + } + if total_items > MAX_TOTAL_ITEMS { + return Err("UI state contains too many aggregate entries".to_owned()); + } + if let Some(runtime_id) = &state.selected_runtime_id { + bounded(runtime_id, "selected runtime id")?; + total_string_bytes = total_string_bytes.saturating_add(runtime_id.len()); + } + let mut total_draft_bytes = 0usize; + for (runtime_id, draft) in &state.drafts { + bounded(runtime_id, "draft runtime id")?; + if draft.len() > MAX_DRAFT_BYTES { + return Err("A UI draft exceeds the size limit".to_owned()); + } + total_draft_bytes = total_draft_bytes.saturating_add(draft.len()); + total_string_bytes = total_string_bytes + .saturating_add(runtime_id.len()) + .saturating_add(draft.len()); + } + if total_draft_bytes > MAX_TOTAL_DRAFT_BYTES { + return Err("UI drafts exceed the total size limit".to_owned()); + } + for (runtime_id, scroll) in &state.scroll { + bounded(runtime_id, "runtime id")?; + total_string_bytes = total_string_bytes.saturating_add(runtime_id.len()); + if scroll + .scroll_top + .is_some_and(|value| !value.is_finite() || value < 0.0) + { + return Err("Scroll position is invalid".to_owned()); + } + } + for (runtime_id, last_seen) in &state.last_seen { + bounded(runtime_id, "runtime id")?; + bounded( + &last_seen.bridge_instance_id, + "last-seen bridge instance id", + )?; + total_string_bytes = total_string_bytes + .saturating_add(runtime_id.len()) + .saturating_add(last_seen.bridge_instance_id.len()); + if let Some(activity) = &last_seen.last_activity { + bounded(activity, "last activity")?; + total_string_bytes = total_string_bytes.saturating_add(activity.len()); + } + } + if let Some(cursor) = &state.workspace_cursor { + bounded(&cursor.bridge_instance_id, "bridge instance id")?; + total_string_bytes = total_string_bytes.saturating_add(cursor.bridge_instance_id.len()); + } + if total_string_bytes > MAX_TOTAL_STRING_BYTES { + return Err("UI state strings exceed the total size limit".to_owned()); + } + if let Some(scale) = state.interface_scale { + if !scale.is_finite() || !(0.5..=2.0).contains(&scale) { + return Err("Interface scale is outside the supported range".to_owned()); + } + } + Ok(()) +} + +pub fn state_path(app_data_dir: &Path) -> PathBuf { + app_data_dir.join("ui-workspace-v1.json") +} + +pub fn load_from(app_data_dir: &Path) -> Result { + let path = state_path(app_data_dir); + let mut file = match fs::File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(UiStateV1::default()) + } + Err(error) => return Err(format!("Could not read UI state: {error}")), + }; + let metadata = file + .metadata() + .map_err(|error| format!("Could not inspect UI state: {error}"))?; + if metadata.len() > MAX_FILE_BYTES as u64 { + return Err("UI state file exceeds the size limit".to_owned()); + } + let mut content = Vec::with_capacity(metadata.len() as usize); + Read::by_ref(&mut file) + .take((MAX_FILE_BYTES + 1) as u64) + .read_to_end(&mut content) + .map_err(|error| format!("Could not read UI state: {error}"))?; + if content.len() > MAX_FILE_BYTES { + return Err("UI state file exceeds the size limit".to_owned()); + } + let state: UiStateV1 = serde_json::from_slice(&content) + .map_err(|error| format!("UI state is corrupt or unsupported: {error}"))?; + validate(&state)?; + Ok(state) +} + +pub fn save_to(app_data_dir: &Path, state: &UiStateV1) -> Result<(), String> { + validate(state)?; + fs::create_dir_all(app_data_dir) + .map_err(|error| format!("Could not create app data: {error}"))?; + set_mode(app_data_dir, 0o700)?; + let target = state_path(app_data_dir); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let temporary = app_data_dir.join(format!(".ui-workspace-{}-{nonce}.tmp", std::process::id())); + let encoded = serde_json::to_vec_pretty(state).map_err(|error| error.to_string())?; + if encoded.len().saturating_add(1) > MAX_FILE_BYTES { + return Err("UI state file exceeds the size limit".to_owned()); + } + let result = (|| { + let mut file = create_private_file(&temporary)?; + file.write_all(&encoded) + .map_err(|error| format!("Could not write UI state: {error}"))?; + file.write_all(b"\n") + .map_err(|error| format!("Could not write UI state: {error}"))?; + file.sync_all() + .map_err(|error| format!("Could not sync UI state: {error}"))?; + fs::rename(&temporary, &target) + .map_err(|error| format!("Could not replace UI state: {error}"))?; + set_mode(&target, 0o600)?; + sync_directory(app_data_dir)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<(), String> { + let directory = OpenOptions::new() + .read(true) + .open(path) + .map_err(|error| format!("Could not open app data directory for sync: {error}"))?; + directory + .sync_all() + .map_err(|error| format!("Could not sync app data directory: {error}")) +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> Result<(), String> { + Ok(()) +} + +#[cfg(unix)] +fn create_private_file(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(path) + .map_err(|error| format!("Could not create temporary UI state: {error}")) +} + +#[cfg(not(unix))] +fn create_private_file(path: &Path) -> Result { + OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|error| format!("Could not create temporary UI state: {error}")) +} + +#[cfg(unix)] +fn set_mode(path: &Path, mode: u32) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .map_err(|error| format!("Could not secure UI state path: {error}")) +} + +#[cfg(not(unix))] +fn set_mode(_path: &Path, _mode: u32) -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn temp_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "pi-ui-state-{name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[test] + fn owner_only_round_trip_and_missing_default() { + let root = temp_dir("roundtrip"); + assert_eq!(load_from(&root).unwrap(), UiStateV1::default()); + let mut state = UiStateV1 { + selected_runtime_id: Some("runtime-1".into()), + interface_scale: Some(1.1), + ..UiStateV1::default() + }; + state.drafts.insert("runtime-1".into(), "draft".into()); + save_to(&root, &state).unwrap(); + assert_eq!(load_from(&root).unwrap(), state); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&root).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(state_path(&root)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + fs::remove_dir_all(root).unwrap(); + } + + fn temporary_files(root: &Path) -> Vec { + fs::read_dir(root) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.starts_with(".ui-workspace-") && name.ends_with(".tmp") + }) + }) + .collect() + } + + #[test] + fn second_save_replaces_valid_state_without_leaving_temporary_files() { + let root = temp_dir("replace"); + let first = UiStateV1 { + selected_runtime_id: Some("runtime-1".into()), + ..UiStateV1::default() + }; + let second = UiStateV1 { + selected_runtime_id: Some("runtime-2".into()), + interface_scale: Some(1.2), + ..UiStateV1::default() + }; + save_to(&root, &first).unwrap(); + save_to(&root, &second).unwrap(); + assert_eq!(load_from(&root).unwrap(), second); + assert!(temporary_files(&root).is_empty()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_corruption_versions_unknown_fields_and_limits_without_overwrite() { + let root = temp_dir("invalid"); + fs::create_dir_all(&root).unwrap(); + let path = state_path(&root); + for invalid in [ + "{broken", + r#"{"version":2}"#, + r#"{"version":1,"transcript":[]}"#, + ] { + fs::write(&path, invalid).unwrap(); + assert!(load_from(&root).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), invalid); + } + + let baseline = UiStateV1 { + selected_runtime_id: Some("baseline".into()), + ..UiStateV1::default() + }; + save_to(&root, &baseline).unwrap(); + let original = fs::read(&path).unwrap(); + let mut oversized = UiStateV1::default(); + oversized + .drafts + .insert("runtime".into(), "x".repeat(MAX_DRAFT_BYTES + 1)); + assert!(save_to(&root, &oversized).is_err()); + assert_eq!(fs::read(&path).unwrap(), original); + assert!(temporary_files(&root).is_empty()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_aggregate_and_raw_file_size_limits() { + let root = temp_dir("aggregate"); + fs::create_dir_all(&root).unwrap(); + let mut aggregate = UiStateV1::default(); + for directory in 0..9 { + aggregate.runtime_order_by_directory.insert( + format!("/directory-{directory}"), + (0..MAX_ITEMS) + .map(|runtime| format!("runtime-{directory}-{runtime}")) + .collect(), + ); + } + assert!(validate(&aggregate) + .unwrap_err() + .contains("aggregate entries")); + + let path = state_path(&root); + fs::write(&path, vec![b' '; MAX_FILE_BYTES + 1]).unwrap(); + assert!(load_from(&root) + .unwrap_err() + .contains("file exceeds the size limit")); + assert_eq!( + fs::metadata(&path).unwrap().len(), + (MAX_FILE_BYTES + 1) as u64 + ); + assert!(temporary_files(&root).is_empty()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/ui/src/App.css b/ui/src/App.css index b6c8388..9f51ea9 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -1,31 +1,12 @@ -@property --transcript-border-angle { - syntax: ""; - inherits: false; - initial-value: 0deg; -} - :root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; - font-size: 18px; - color: #ebebe8; - background: #101111; + font-size: 14px; + color: #e6e3df; + background: #1c1c1c; } -*, -*::before, -*::after { +* { box-sizing: border-box; } -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} body { margin: 0; min-width: 640px; @@ -33,300 +14,350 @@ body { background: transparent; } button, -textarea, input, +textarea, select { font: inherit; } button { - cursor: pointer; border: 0; - border-radius: 7px; - padding: 9px 14px; - background: #dcecc8; - color: #152012; - font-size: 14px; + border-radius: 5px; + padding: 6px 9px; + background: #f0a347; + color: #25180b; font-weight: 750; + cursor: pointer; } button:hover { - background: #f0ffe0; + background: #ffc170; +} +button:disabled { + opacity: 0.6; + cursor: wait; } button:focus-visible, input:focus-visible, -select:focus-visible, -textarea:focus-visible { - outline: 3px solid #c4ed8b; +textarea:focus-visible, +select:focus-visible { + outline: 3px solid #f0a347; outline-offset: 2px; } - +.quiet { + background: #363b43; + color: #d9dce1; +} +.danger { + background: #59333b; + color: #ffd7db; +} +.muted { + color: #9299a6; +} .app-shell { - position: relative; height: 100vh; - max-height: 100vh; - min-height: 0; overflow: hidden; display: flex; flex-direction: column; - gap: 12px; - padding: 18px; - 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); + gap: 6px; + padding: 9px; + border: 1px solid #4b4b4b; + border-radius: 8px; + background: rgba(34, 34, 34, 0.98); } - .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; + gap: 8px; + cursor: grab; } .workspace-heading, -.status-row, -.composer-actions, -.settings-actions { +.status-row { display: flex; align-items: center; gap: 8px; } -.workspace-heading { - min-width: 0; -} .brand { - flex: 0 0 auto; - color: #b8df84; - font-size: 12px; + color: #f0a347; + font-size: 10px; font-weight: 900; letter-spacing: 0.16em; } -h1 { - min-width: 0; +.workflow-header h1 { margin: 0; - font-size: 20px; - line-height: 1.15; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-size: 16px; } .status-row { - margin-top: 4px; - color: #898f89; - font-size: 13px; - 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; + margin-top: 1px; + color: #aaa6a0; + font-size: 11px; } .state-pill { - flex: 0 0 auto; - border: 1px solid #404740; + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid #555; border-radius: 999px; - padding: 3px 8px; - color: #afb3ad; - background: #202320; - font-size: 12px; - font-weight: 800; + padding: 2px 8px; + font-size: 11px; } .state-pill.working { - border-color: #628b42; - color: #c4ed8b; + color: #ffd39a; + border-color: #f0a347; + background: #4b3420; + box-shadow: + 0 0 0 1px rgba(240, 163, 71, 0.22), + 0 0 10px rgba(240, 163, 71, 0.2); } .state-pill.recovering { - border-color: #8a713d; - color: #f0ce83; + color: #e0c2a1; + border-color: #8d6745; } .state-pill.error { - border-color: #8b4c4c; - color: #ffadad; + color: #ffb4ba; + border-color: #a65b67; } -.workflow-header button, -.workflow-header input, -.workflow-header select { - cursor: pointer; -} -.compact-button { +.working-pulse { + width: 7px; + height: 7px; flex: 0 0 auto; - padding: 7px 9px; + border-radius: 50%; + background: #f0a347; + box-shadow: 0 0 0 0 rgba(240, 163, 71, 0.7); + animation: work-pulse 1.35s ease-out infinite; } - -.directory-tabs, -.directory-tab-list { - display: flex; +.working-banner { + display: inline-flex; align-items: center; - gap: 7px; - min-height: 38px; + gap: 5px; + padding: 2px 6px; + border: 1px solid #b76f26; + border-radius: 4px; + background: #41301f; + color: #ffd39a; + font-weight: 700; } -.directory-tabs { - overflow-x: auto; - padding: 1px 0 3px; -} -.directory-tab-list { - gap: 3px; -} -.agent-caption { - flex: 0 0 auto; - color: #70776f; - font-size: 12px; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -} -.directory-tab { - display: flex; - flex: 0 0 auto; - align-items: stretch; -} -.directory-tab-button { - display: flex; - align-items: center; - gap: 7px; - min-width: 0; - max-width: 220px; - border: 1px solid #303530; - border-right: 0; - border-radius: 7px 0 0 7px; - padding: 7px 10px; - background: #1b1e1b; - color: #aeb4ac; +.workspace-warning { + padding: 6px 8px; + border: 1px solid #8c713c; + border-radius: 5px; + background: #302817; + color: #f2cf86; font-size: 13px; - font-weight: 650; } -.directory-tab-button > span { +.workspace-warning.collision { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border-color: #a85b4d; + background: #35211e; + color: #ffc0b4; +} +.warning-dismiss { + flex: 0 0 auto; + padding: 4px 8px; +} +.workspace-layout { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 178px minmax(0, 1fr); + gap: 6px; +} +.directory-sidebar { + min-height: 0; + overflow: auto; + border: 1px solid #484848; + border-radius: 6px; + background: #292929; + padding: 5px; +} +.sidebar-heading { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + color: #bbb7b0; + text-transform: uppercase; + font-size: 11px; +} +.sidebar-heading button { + padding: 3px 8px; +} +.directory-row { + display: flex; + gap: 2px; + margin-bottom: 2px; +} +.directory-select { + min-width: 0; + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1px 4px; + text-align: left; + background: #353535; + color: #e3dfd8; +} +.directory-select span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.directory-tab-button small { - color: #70776f; - font-size: 11px; - font-weight: 600; +.directory-select small { + grid-row: 2; + color: #9099a7; + font-size: 10px; } -.directory-tab-button.selected { - border-color: #7da856; - background: #222b1d; - color: #ecf7df; +.directory-row.selected .directory-select { + background: #403a33; + color: #fff5e7; + box-shadow: inset 3px 0 #f0a347; } -.directory-tab-button.selected + .directory-close { - border-color: #7da856; - background: #222b1d; +.directory-badges { + display: flex; + gap: 3px; } -.directory-tab-button:only-child { - border-right: 1px solid #303530; - border-radius: 7px; +.directory-badges b { + border-radius: 999px; + padding: 1px 4px; + font-size: 9px; } -.directory-tab-button.selected:only-child { - border-color: #7da856; +.directory-badges .attention { + background: #6f5220; + color: #ffe3a3; } -.directory-close { - min-width: 30px; - border: 1px solid #303530; - border-radius: 0 7px 7px 0; +.directory-badges .error { + background: #672d2d; + color: #ffcaca; +} +.forget-directory { padding: 4px 7px; - background: #1b1e1b; - color: #aeb4ac; - font-size: 18px; - line-height: 1; + background: #2a303a; + color: #b8c1cd; } -.directory-tab:focus-within .directory-tab-button, -.directory-tab:focus-within .directory-close { - border-color: #c4ed8b; +.session-area { + min-height: 0; + display: flex; + flex-direction: column; + gap: 5px; } -.directory-close:hover { - background: #3a2222; - color: #ffc0c0; +.session-strip { + display: block; + min-width: 0; } -.directory-close:disabled { - cursor: not-allowed; - opacity: 0.45; +.session-tab-bar { + display: flex; + align-items: end; + min-width: 0; + border-bottom: 1px solid #55585d; } -.directory-add-toggle { +.session-tabs { + min-width: 0; + flex: 1; + display: flex; + overflow-x: auto; + gap: 2px; + padding-left: 4px; +} +.session-tab { + display: flex; flex: 0 0 auto; - padding: 7px 10px; +} +.session-tab > button[role="tab"] { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + min-width: 124px; + max-width: 190px; + padding: 6px 8px 5px; + border: 1px solid #555; + border-bottom: 0; + border-radius: 8px 8px 0 0; + background: #303136; + color: #d5d7dc; + text-align: left; +} +.session-tab.selected > button[role="tab"] { + border-color: #d88735; + background: #3b3935; + color: #fff5e7; + box-shadow: inset 0 2px #f0a347; +} +.session-tab > button span { + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } +.session-tab small { + grid-row: 2; + color: #788077; + font-size: 10px; +} +.session-tab small.streaming, +.session-tab small.recovering { + color: #ffc170; +} +.close-session { + align-self: stretch; + border: 1px solid #505257; + border-bottom: 0; + border-left: 0; + border-radius: 0 8px 0 0; + padding: 4px 8px; + background: #303136; + color: #b8bbc1; +} +.unread-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #f0a347; +} +.attention-dot { + color: #ffcb6b; +} +.session-tab-actions { + display: flex; + gap: 3px; + padding: 0 0 2px 5px; +} +.session-tab-actions button { + white-space: nowrap; + border: 1px solid #55585d; + border-bottom: 0; + border-radius: 6px 6px 0 0; + padding: 5px 8px; + background: #383a40; + color: #e2e4e8; +} +.session-tab-actions button:hover { + background: #4a4540; + color: #ffc170; +} +.session-tab-actions .new-session { + border-color: #d88735; + background: #4a3827; + color: #ffc170; +} +.session-tab-actions span { + margin-right: 5px; + font-size: 15px; + line-height: 0; +} .directory-add { display: flex; - flex: 0 0 auto; - align-items: stretch; gap: 5px; } .directory-add input { - width: 280px; - padding: 7px 9px; - font-size: 13px; + min-width: 240px; + flex: 1; + padding: 7px; } -.directory-add button { - padding: 7px 10px; -} -.directory-add-status { - display: inline-flex; - align-items: center; - gap: 6px; - color: #c4ed8b; - font-size: 13px; - font-weight: 700; - white-space: nowrap; -} -.directory-add-spinner { - width: 14px; - height: 14px; - border: 2px solid #52703a; - border-top-color: #c4ed8b; - border-radius: 50%; - animation: directory-add-spin 0.75s linear infinite; -} -@keyframes directory-add-spin { - to { - transform: rotate(360deg); - } -} - -.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; + flex: 1; min-height: 0; - overflow: hidden; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 7px; @@ -334,631 +365,327 @@ h1 { .workflow-main { min-height: 0; display: grid; - grid-template-columns: minmax(0, 1fr) 250px; - gap: 8px; -} -.transcript-container { - position: relative; - min-height: 0; -} -.transcript-container .transcript { - height: 100%; -} -.scroll-to-latest { - position: absolute; - z-index: 1; - left: 50%; - bottom: 14px; - width: 40px; - height: 40px; - padding: 0; - border: 1px solid #6f8b56; - border-radius: 50%; - background: #263020; - color: #dcecc8; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); - font-size: 22px; - line-height: 1; - transform: translateX(-50%); -} -.scroll-to-latest:hover { - background: #35452b; + grid-template-columns: minmax(0, 1fr) 190px; + gap: 5px; } .transcript, -.settings { +.todos-pane, +.settings, +.empty-workspace { 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; + border: 1px solid #4a4a4a; + border-radius: 6px; + background: #292929; + padding: 6px; } .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; + border-color: #f0a347; + box-shadow: + inset 3px 0 #f0a347, + 0 0 16px rgba(240, 163, 71, 0.25); } -.transcript.recovering { - border-color: #a8823f; - box-shadow: 0 0 12px rgba(240, 199, 110, 0.12); +.agent-working { + display: flex; + align-items: center; + gap: 8px; + margin: -1px -1px 6px; + padding: 7px 8px; + border: 1px solid #b76f26; + border-radius: 5px; + background: #3d2d1d; + color: #ffd39a; +} +.agent-working > div { + display: grid; + gap: 1px; +} +.agent-working strong { + color: #fff0da; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.agent-working span { + font-size: 12px; +} +.agent-working small { + color: #d8b892; + font-size: 10px; +} +.working-bars { + display: flex; + align-items: center; + gap: 3px; + height: 19px; +} +.working-bars i { + width: 3px; + height: 8px; + border-radius: 3px; + background: #f0a347; + animation: work-bars 0.8s ease-in-out infinite alternate; +} +.working-bars i:nth-child(2) { + animation-delay: 0.18s; +} +.working-bars i:nth-child(3) { + animation-delay: 0.36s; } .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; + margin: 3px 0; + padding: 5px 6px; border: 1px solid transparent; border-radius: 7px; } .message.user { - margin-left: 34px; - border-color: #30435b; - background: #192536; + margin-left: 24px; + border-color: #73532f; + background: #332a21; } .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; + margin-right: 12px; + border-color: #505050; + background: #353535; } .message strong { display: block; margin-bottom: 3px; - color: #b8df84; + color: #c7d0df; 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; - } + color: #ffc170; } .message pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; - font-size: 14px; - line-height: 1.38; - color: #d7dbd5; + font-size: 13px; + line-height: 1.32; } -.muted { - margin: 0; - color: #858b85; - font-size: 14px; +.pending-message { + border-style: dashed; } -.warning { - color: #ffb4aa; - font-weight: 700; +.pending-message strong { + display: flex; + justify-content: space-between; +} +.todos-pane { + background: #292929; +} +.todos-heading { + display: flex; + justify-content: space-between; + color: #e0ddd7; + font-size: 10px; + text-transform: uppercase; +} +.todo-list { + display: grid; + gap: 3px; + margin: 4px 0 0; + padding: 0; + list-style: none; +} +.todo-list li { + display: flex; + justify-content: space-between; + border-bottom: 1px solid #484848; + padding: 3px 0; + font-size: 10px; +} +.todo-list li > div { + display: grid; +} +.todo-list small { + color: #9099a7; +} +.todo-list .completed { + opacity: 0.55; } - .workflow-footer { display: grid; gap: 5px; } .pi-controls { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 7px; - padding: 4px 6px; - border: 1px solid #303730; - border-radius: 7px; - background: #191d19; - color: #8f988e; - font-size: 9px; + grid-template-columns: 1fr 1fr; + gap: 5px; + padding: 4px; + border: 1px solid #4a4a4a; + border-radius: 5px; + background: #303030; } .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; + grid-template-columns: auto 1fr; 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: 64px; - max-height: 140px; - resize: vertical; - font-size: 14px; - line-height: 1.35; -} -.composer textarea:focus { - border-color: #6f8b56; - outline: 2px solid #c4ed8b; - outline-offset: -2px; -} -.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; + display: flex; + align-items: center; + gap: 6px; + color: #8f988e; + font-size: 11px; } -.work-indicator { +.work-dot { width: 7px; height: 7px; border-radius: 50%; - background: #656b65; + background: #687168; } -.work-copy { - display: grid; - min-width: 0; - gap: 1px; +.composer-progress.working { + padding: 4px 6px; + border-left: 3px solid #f0a347; + background: #3b3025; + color: #ffd39a; + font-weight: 700; } -.work-copy strong { - color: #dfe6dc; - font-size: 10px; +.composer-progress.working .work-dot { + background: #f0a347; + box-shadow: 0 0 8px #f0a347; + animation: work-pulse 1.35s ease-out infinite; } -.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 { +.composer { display: flex; - align-items: center; - justify-content: space-between; - color: #c4e89a; - font-size: 11px; + gap: 7px; } -.command-followup-heading button { - padding: 4px 7px; - font-size: 10px; +.composer textarea { + flex: 1; + min-height: 48px; + max-height: 110px; + resize: vertical; + padding: 7px; + border: 1px solid #4a4a4a; + border-radius: 5px; + background: #242424; + color: #f0f4fa; } -.command-choice-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +.composer-actions { + display: flex; + align-items: end; 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: 17px; +.settings, +.empty-workspace { + flex: 1; } .settings-actions { - justify-content: flex-start; -} -.scale-settings { - display: grid; - gap: 8px; - border-top: 1px solid #303830; - padding-top: 10px; -} -.scale-settings-heading { display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; + gap: 7px; } -.scale-settings-heading output { - color: #b8df84; - font-variant-numeric: tabular-nums; - font-weight: 800; -} -.scale-adjustment { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 8px; -} -.scale-adjustment button { - min-width: 32px; +.extension-widget { padding: 5px 8px; - font-size: 16px; - line-height: 1; -} -.scale-adjustment button:disabled { - cursor: not-allowed; - opacity: 0.5; -} -.scale-adjustment input[type="range"] { - min-width: 0; - padding: 0; - accent-color: #b8df84; -} -.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-actions { - display: flex; - flex: 0 0 auto; - gap: 5px; -} -.session-actions button:disabled { - cursor: not-allowed; - opacity: 0.5; -} -.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; + border-left: 3px solid #f0a347; + background: #302a24; + font-size: 12px; } +.modal-backdrop, .extension-backdrop { position: absolute; z-index: 10; inset: 0; display: flex; - align-items: end; + align-items: center; justify-content: center; - padding: 14px; - background: rgba(0, 0, 0, 0.5); + padding: 20px; + background: rgba(0, 0, 0, 0.62); } +.session-picker, .extension { - width: min(100%, 680px); - max-height: min(70vh, 460px); + width: min(680px, 90vw); + max-height: 75vh; 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; + padding: 15px; + border: 1px solid #b76f26; + border-radius: 10px; + background: #2d2e31; } -.extension p { +.picker-heading { + display: flex; + justify-content: space-between; +} +.picker-heading h2, +.extension h2 { margin: 0; - font-size: 12px; } - +.session-list { + display: grid; + gap: 5px; +} +.session-list button { + display: flex; + justify-content: space-between; + text-align: left; +} +.session-list small { + color: #8d97a6; +} +.extension-options { + display: flex; + gap: 7px; +} +.extension textarea { + min-height: 150px; +} +.notification { + padding: 6px; +} +@keyframes work-pulse { + 70% { + box-shadow: 0 0 0 7px rgba(240, 163, 71, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(240, 163, 71, 0); + } +} +@keyframes work-bars { + from { + transform: scaleY(0.55); + opacity: 0.55; + } + to { + transform: scaleY(1.35); + opacity: 1; + } +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} +@media (max-width: 800px) { + .workspace-layout { + grid-template-columns: 150px minmax(0, 1fr); + } + .workflow-main { + grid-template-columns: minmax(0, 1fr) 165px; + } + .session-strip-actions { + flex-direction: column; + } + .session-tab > button[role="tab"] { + min-width: 125px; + } +} @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; - scroll-behavior: auto !important; transition-duration: 0.01ms !important; } } - -@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: repeat(2, minmax(0, 1fr)); - } - .transcript { - padding: 7px 9px; - } - .composer-actions { - gap: 5px; - } - .composer-progress .work-copy span { - display: none; - } - .work-meta { - display: none; - } -} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index abab11c..3df0976 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,1095 +1,400 @@ -import { - type KeyboardEvent as ReactKeyboardEvent, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { open } from "@tauri-apps/plugin-dialog"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import "./App.css"; +import { ConversationWorkspace } from "./components/ConversationWorkspace"; +import { DirectorySidebar } from "./components/DirectorySidebar"; +import { ExtensionDialog } from "./components/ExtensionDialog"; +import { SessionPicker } from "./components/SessionPicker"; +import { SessionTabs } from "./components/SessionTabs"; +import type { + DirectorySession, + DirectoryWorkspace, + Model, + RuntimeView, +} from "./workspace/types"; +import { useWorkspace } from "./workspace/useWorkspace"; -type Agent = { id: string; worktreePath: string; state: string }; -type Directory = { - worktreePath: string; - state: string; - isHome: boolean; - agentId?: string; -}; -type Session = { - path: string; - id: string; - cwd: string; - name?: string; - parentSessionPath?: string; - created?: string; - modified: string; - messageCount: number; - firstMessage?: string; - isCurrent: boolean; -}; -type AgentState = { - isStreaming?: boolean; - thinkingLevel?: string; - sessionName?: string; - sessionId?: string; - messageCount?: number; - pendingMessageCount?: number; - model?: { - provider?: string; - id?: string; - name?: string; - contextWindow?: number; - maxTokens?: number; - }; -}; -type TodoTask = { +const minimumUiScale = 0.9, + maximumUiScale = 1.4; +const clamp = (value: number) => + Math.min(maximumUiScale, Math.max(minimumUiScale, value)); +let submissionId = 0; +type LocalOperation = { id: number; - subject: string; - description?: string; - activeForm?: string; - status: "pending" | "in_progress" | "completed" | "deleted"; -}; -type SessionStats = { - tokens?: { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; - }; - contextUsage?: { - tokens?: number | null; - contextWindow?: number; - percent?: number | null; - }; -}; -type Message = { - role?: string; - toolName?: string; - details?: { tasks?: TodoTask[] }; - content?: - | string - | Array<{ type?: string; text?: string; thinking?: string; name?: string }>; -}; -type Model = { provider?: string; id?: string; name?: string }; -type FollowUpCommand = "model" | "resume" | "thinking"; -type Command = { - name?: string; - description?: string; - followUp?: FollowUpCommand; -}; -type Extension = { - id?: string; - method?: string; - title?: string; - message?: string; - options?: string[]; - placeholder?: string; - initialValue?: string; - text?: string; - notifyType?: "info" | "warning" | "error"; - statusKey?: string; - statusText?: string; - widgetKey?: string; - widgetLines?: string[]; - widgetPlacement?: "aboveEditor" | "belowEditor"; -}; -type ExtensionWidget = { - lines: string[]; - placement: "aboveEditor" | "belowEditor"; -}; -type BridgeEvent = { - agentId?: string; - seq?: number; - type?: string; - data?: { state?: string; event?: Record }; -}; -type WorkProgress = { - phase: "idle" | "working" | "recovering" | "error"; + kind: + | "creating" + | "opening" + | "closing" + | "listing" + | "submitting" + | "command"; detail: string; - tool?: string; - queueCount?: number; - toolCount: number; -}; -type PendingSubmission = { - id: number; - text: string; - userMessageCount: number; - phase: "sending" | "sent"; + runtimeId?: string; }; -const thinkingLevels = [ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -]; -const selectedPathKey = "pi-status-ui.selected-worktree"; -const uiScaleKey = "pi-status-ui.interface-scale"; -const minimumUiScale = 0.9; -const maximumUiScale = 1.4; -const uiScaleStep = 0.05; -const controlCommands: Command[] = [ - { - name: "model", - description: "Choose a configured model", - followUp: "model", - }, - { - name: "thinking", - description: "Choose a thinking level", - followUp: "thinking", - }, - { name: "resume", description: "Choose a saved session", followUp: "resume" }, -]; - -function unpack(value: unknown): Record { - return value && typeof value === "object" && "data" in value - ? ((value as { data: Record }).data ?? {}) - : ((value as Record) ?? {}); -} - -function messageText(message: Message) { - if (typeof message.content === "string") return message.content; - return (message.content ?? []) - .map((part) => { - if (part.type === "text") return part.text ?? ""; - if (part.type === "thinking") return `Thinking: ${part.thinking ?? ""}`; - if (part.type === "toolCall") return `Tool: ${part.name ?? "running"}`; - return ""; - }) - .filter(Boolean) - .join("\n"); -} - -function stripAnsi(value: string) { - return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); -} - -function worktreeLabel(path?: string) { - if (!path) return undefined; - const segments = path.split("/").filter(Boolean); - return segments[segments.length - 1] ?? path; -} - -function formatTokens(value?: number | null) { - if (value === undefined || value === null) return "—"; - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`; - if (value >= 1_000) return `${Math.round(value / 1_000)}k`; - return String(value); -} - -function clampUiScale(value: number) { - return Math.min(maximumUiScale, Math.max(minimumUiScale, value)); -} - -function currentTodos(messages: Message[]) { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if ( - message.role === "toolResult" && - message.toolName === "todo" && - Array.isArray(message.details?.tasks) - ) { - return message.details.tasks - .filter((task) => task.status !== "deleted") - .sort((left, right) => { - const order: Record = { - in_progress: 0, - pending: 1, - completed: 2, - deleted: 3, - }; - return order[left.status] - order[right.status]; - }); - } - } - return []; -} - -function focusableExtensionControls(container: HTMLElement) { - return [ - ...container.querySelectorAll( - 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])', - ), - ]; -} - -function App() { - const [agents, setAgents] = useState([]); - const [directories, setDirectories] = useState([]); - const [sessions, setSessions] = useState([]); - const [selectedId, setSelectedId] = useState(); - const [state, setState] = useState({}); - const [stats, setStats] = useState({}); - const [messages, setMessages] = useState([]); - const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true); - const [pendingSubmissions, setPendingSubmissions] = useState< - PendingSubmission[] - >([]); - const [models, setModels] = useState([]); - const [commands, setCommands] = useState([]); - const [message, setMessage] = useState(""); - const [uiScale, setUiScale] = useState(() => { - const storedScale = Number(localStorage.getItem(uiScaleKey)); - return Number.isFinite(storedScale) ? clampUiScale(storedScale) : 1; - }); - const [folderPath, setFolderPath] = useState(""); - const [addingDirectory, setAddingDirectory] = useState(false); - const [isAddingDirectory, setIsAddingDirectory] = useState(false); - const [pendingDirectoryPath, setPendingDirectoryPath] = useState(); +export default function App() { + const { state, dispatch, refresh, loadSnapshot } = useWorkspace(); const [status, setStatus] = useState("Connecting to Pi Status Bridge…"); const [view, setView] = useState<"conversation" | "settings">("conversation"); - const [commandFollowUp, setCommandFollowUp] = useState(); - const [pendingExtension, setPendingExtension] = useState(); - const [extensionValue, setExtensionValue] = useState(""); - const [extensionTitle, setExtensionTitle] = useState(); - const [extensionNotifications, setExtensionNotifications] = useState< - Extension[] - >([]); - const [extensionStatuses, setExtensionStatuses] = useState< - Record - >({}); - const [extensionWidgets, setExtensionWidgets] = useState< - Record - >({}); - const transcriptRef = useRef(null); - const extensionRef = useRef(null); - const directoryTabRefs = useRef>({}); - const pendingSubmissionId = useRef(0); - const loadGeneration = useRef(0); - const refreshGeneration = useRef(0); - const bridgeRetryTimer = useRef(undefined); - const bridgeRetryAttempt = useRef(0); - const lastEventSequence = useRef>({}); - const eventReloadInFlight = useRef(false); - const queuedEventReload = useRef(undefined); - const [workProgress, setWorkProgress] = useState({ - phase: "idle", - detail: "Ready for your next prompt", - toolCount: 0, - }); - - const selected = agents.find((agent) => agent.id === selectedId); - const selectedDirectoryIndex = directories.findIndex( - (directory) => directory.agentId === selectedId, + const [adding, setAdding] = useState(false); + const [folderPath, setFolderPath] = useState(""); + const [sessions, setSessions] = useState( + undefined, ); - const selectedDirectoryTabId = - selectedDirectoryIndex >= 0 - ? `directory-tab-${selectedDirectoryIndex}` - : undefined; - const filteredCommands = useMemo(() => { - if (!message.startsWith("/")) return []; - const uniqueCommands = [...controlCommands, ...commands].filter( - (command, index, all) => - all.findIndex((candidate) => candidate.name === command.name) === index, - ); - return uniqueCommands - .filter((command) => `/${command.name ?? ""}`.startsWith(message)) - .slice(0, 8); - }, [commands, message]); - const todos = useMemo(() => currentTodos(messages), [messages]); - const contextTokens = stats.contextUsage?.tokens; - const contextWindow = - stats.contextUsage?.contextWindow ?? state.model?.contextWindow; - const contextPercent = stats.contextUsage?.percent; - const contextPercentLabel = - contextPercent === undefined || contextPercent === null - ? undefined - : contextPercent.toFixed(1); - const cacheTokens = - (stats.tokens?.cacheRead ?? 0) + (stats.tokens?.cacheWrite ?? 0); - - async function loadAgent(agentId: string, startSubscription = false) { - const generation = ++loadGeneration.current; - try { - const snapshot = await invoke>("load_agent", { - agentId, - }); - if (generation !== loadGeneration.current) return; - const loadedState = unpack(snapshot.state); - const loadedStats = unpack(snapshot.stats); - const loadedTranscript = unpack(snapshot.transcript); - const loadedCommands = unpack(snapshot.commands); - const loadedModels = unpack(snapshot.models); - setState(loadedState as AgentState); - setDirectories((current) => - current.map((directory) => - directory.agentId === agentId - ? { - ...directory, - state: loadedState.isStreaming ? "streaming" : "idle", - } - : directory, - ), - ); - setStats(loadedStats as SessionStats); - setMessages((loadedTranscript.messages as Message[] | undefined) ?? []); - setCommands((loadedCommands.commands as Command[] | undefined) ?? []); - setModels((loadedModels.models as Model[] | undefined) ?? []); - setStatus(loadedState.isStreaming ? "Pi working" : "Pi ready"); - setWorkProgress( - loadedState.isStreaming - ? (current) => ({ - ...current, - phase: "working", - detail: "Thinking and preparing a response", - }) - : { - phase: "idle", - detail: "Ready for your next prompt", - toolCount: 0, - }, - ); - if (startSubscription) - await invoke("subscribe_agent", { agentId, cursor: 0 }); - } catch (error) { - setStatus(String(error)); - scheduleBridgeReconnect(); - } - } - - function reloadAgentAfterEvent(agentId: string) { - if (eventReloadInFlight.current) { - queuedEventReload.current = agentId; - return; - } - eventReloadInFlight.current = true; - void loadAgent(agentId).finally(() => { - eventReloadInFlight.current = false; - const queuedAgentId = queuedEventReload.current; - queuedEventReload.current = undefined; - if (queuedAgentId) reloadAgentAfterEvent(queuedAgentId); - }); - } - - function scheduleBridgeReconnect(preferredPath?: string) { - if (bridgeRetryTimer.current !== undefined) return; - const delay = Math.min(250 * 2 ** bridgeRetryAttempt.current, 2_000); - bridgeRetryAttempt.current += 1; - setStatus(`Bridge unavailable; retrying in ${delay}ms…`); - bridgeRetryTimer.current = window.setTimeout(() => { - bridgeRetryTimer.current = undefined; - void refreshAgents(preferredPath); - }, delay); - } - - async function refreshAgents( - preferredPath = localStorage.getItem(selectedPathKey) ?? undefined, - ) { - const generation = ++refreshGeneration.current; - try { - const [agentResponse, directoryResponse] = await Promise.all([ - invoke<{ agents: Agent[] }>("list_agents"), - invoke<{ directories: Directory[] }>("list_directories"), - ]); - if (generation !== refreshGeneration.current) return; - if (bridgeRetryTimer.current !== undefined) { - window.clearTimeout(bridgeRetryTimer.current); - bridgeRetryTimer.current = undefined; - } - bridgeRetryAttempt.current = 0; - setAgents(agentResponse.agents); - setDirectories(directoryResponse.directories); - const next = - agentResponse.agents.find( - (agent) => agent.worktreePath === preferredPath, - ) ?? - agentResponse.agents.find((agent) => agent.id === selectedId) ?? - agentResponse.agents[0]; - setSelectedId(next?.id); - if (next) { - localStorage.setItem(selectedPathKey, next.worktreePath); - await loadAgent(next.id, true); - } else setStatus("No Pi agents available"); - } catch (error) { - if (generation !== refreshGeneration.current) return; - scheduleBridgeReconnect(preferredPath); - } - } - - async function loadSessions(agentId: string) { - try { - const response = await invoke<{ sessions: Session[] }>("list_sessions", { - agentId, - }); - setSessions(response.sessions); - } catch (error) { - setStatus(String(error)); - } - } + const selected = state.selectedRuntimeId + ? state.runtimesById[state.selectedRuntimeId] + : undefined; + const directory = state.selectedDirectoryPath + ? state.directoriesByPath[state.selectedDirectoryPath] + : undefined; + const runtimes = useMemo( + () => + (state.runtimeOrderByDirectory[state.selectedDirectoryPath ?? ""] ?? []) + .map((id) => state.runtimesById[id]) + .filter(Boolean), + [ + state.runtimeOrderByDirectory, + state.selectedDirectoryPath, + state.runtimesById, + ], + ); + const previousSelected = useRef(undefined); + const mountedRef = useRef(true); + const operationSequence = useRef(0); + const [operation, setOperation] = useState(); + const [restoreTabFocus, setRestoreTabFocus] = useState(false); + const startOperation = ( + kind: LocalOperation["kind"], + detail: string, + runtimeId?: string, + ) => { + const id = ++operationSequence.current; + setOperation({ id, kind, detail, runtimeId }); + setStatus(detail); + return id; + }; + const finishOperation = (id: number) => + setOperation((current) => (current?.id === id ? undefined : current)); useEffect(() => { - void refreshAgents(); + mountedRef.current = true; return () => { - if (bridgeRetryTimer.current !== undefined) - window.clearTimeout(bridgeRetryTimer.current); + mountedRef.current = false; }; }, []); useEffect(() => { - if ( - !pendingDirectoryPath || - !directories.some( - (directory) => directory.worktreePath === pendingDirectoryPath, - ) - ) - return; - setPendingDirectoryPath(undefined); - setIsAddingDirectory(false); - setAddingDirectory(false); - setStatus(`Added ${worktreeLabel(pendingDirectoryPath)}`); - }, [directories, pendingDirectoryPath]); - - useEffect(() => { - if (selectedId) void loadSessions(selectedId); - else setSessions([]); - }, [selectedId]); - - function updateTranscriptScrollPosition() { - const transcript = transcriptRef.current; - if (!transcript) return; - const isAtBottom = - transcript.scrollHeight - - transcript.scrollTop - - transcript.clientHeight <= - 4; - setIsTranscriptAtBottom((current) => - current === isAtBottom ? current : isAtBottom, + if (!selected || operation) return; + setStatus( + selected.summary.state === "streaming" + ? "Pi working" + : (selected.error ?? "Pi ready"), ); - } - - function scrollTranscriptToLatest() { - const transcript = transcriptRef.current; - if (!transcript) return; - transcript.scrollTop = transcript.scrollHeight; - setIsTranscriptAtBottom(true); - } - - useLayoutEffect(() => { - setIsTranscriptAtBottom(true); - }, [selectedId]); - - useLayoutEffect(() => { - if (!isTranscriptAtBottom) return; - scrollTranscriptToLatest(); - const frame = requestAnimationFrame(scrollTranscriptToLatest); - return () => cancelAnimationFrame(frame); - }, [isTranscriptAtBottom, messages, pendingSubmissions, selectedId]); - - useEffect(() => { - const userMessages = messages.filter((entry) => entry.role === "user"); - setPendingSubmissions((current) => - current.filter((pending) => { - const receivedMessages = userMessages.slice(pending.userMessageCount); - return !receivedMessages.some( - (entry) => messageText(entry) === pending.text, - ); - }), + if (previousSelected.current !== selected.summary.runtimeId) + previousSelected.current = selected.summary.runtimeId; + requestAnimationFrame(() => + dispatch({ type: "markRead", runtimeId: selected.summary.runtimeId }), ); - }, [messages]); + }, [selected, dispatch, operation]); - function toolLabel(event: Record) { - const tool = event.toolName ?? event.tool ?? event.name ?? event.toolCall; - if (typeof tool === "string") return tool; - if ( - tool && - typeof tool === "object" && - "name" in tool && - typeof tool.name === "string" - ) - return tool.name; - return "tool"; - } - - function updateWorkProgress(event: BridgeEvent) { - const data = event.data ?? {}; - const payload = data.event ?? {}; - if (event.type === "agent_state") { - const nextState = data.state; - if (nextState === "streaming") - setWorkProgress((current) => ({ - ...current, - phase: "working", - detail: "Thinking and preparing a response", - })); - else if (nextState === "recovering") - setWorkProgress((current) => ({ - ...current, - phase: "recovering", - detail: "Recovering the Pi session", - })); - else if (nextState === "error") - setWorkProgress((current) => ({ - ...current, - phase: "error", - detail: "Pi needs attention", - })); - else if (nextState === "idle") - setWorkProgress({ - phase: "idle", - detail: "Ready for your next prompt", - toolCount: 0, - }); - return; - } - if (event.type === "tool") { - const eventType = typeof payload.type === "string" ? payload.type : ""; - const label = toolLabel(payload); - setWorkProgress((current) => { - if (current.phase !== "working") return current; - if (eventType.endsWith("_end")) - return { - ...current, - detail: "Continuing after tool result", - tool: undefined, - }; - return { - ...current, - detail: eventType.endsWith("_update") - ? "Tool is reporting progress" - : "Running a tool", - tool: label, - toolCount: current.toolCount + (eventType.endsWith("_start") ? 1 : 0), - }; - }); - return; - } - if (event.type === "queue") { - const queueCount = Array.isArray(payload.queue) - ? payload.queue.length - : typeof payload.pendingMessageCount === "number" - ? payload.pendingMessageCount - : undefined; - setWorkProgress((current) => ({ - ...current, - queueCount, - detail: queueCount - ? `${queueCount} follow-up${queueCount === 1 ? "" : "s"} queued` - : current.detail, - })); - return; - } - if (event.type === "stream") - setWorkProgress((current) => - current.phase === "idle" - ? current - : { - ...current, - phase: "working", - detail: current.tool ? current.detail : "Generating response", - }, + async function createRuntime(path = state.selectedDirectoryPath) { + if (!path || !mountedRef.current) return; + const operationId = startOperation( + "creating", + "Starting a new Pi session…", + ); + try { + const result = await invoke<{ runtime: RuntimeView["summary"] }>( + "create_session_runtime", + { worktreePath: path }, ); - } - - function handleExtension(extension?: Extension) { - if (!extension) return; - switch (extension.method) { - case "select": - case "confirm": - case "input": - case "editor": - setPendingExtension(extension); - setExtensionValue(extension.initialValue ?? extension.text ?? ""); - break; - case "notify": - setExtensionNotifications((current) => - [ - ...current, - { ...extension, message: stripAnsi(extension.message ?? "") }, - ].slice(-4), - ); - break; - case "setStatus": { - const statusKey = extension.statusKey; - if (statusKey) - setExtensionStatuses((current) => { - const next = { ...current }; - if (extension.statusText) - next[statusKey] = stripAnsi(extension.statusText); - else delete next[statusKey]; - return next; - }); - break; - } - case "setWidget": { - const widgetKey = extension.widgetKey; - if (widgetKey) - setExtensionWidgets((current) => { - const next = { ...current }; - if (extension.widgetLines) - next[widgetKey] = { - lines: extension.widgetLines.map(stripAnsi), - placement: extension.widgetPlacement ?? "aboveEditor", - }; - else delete next[widgetKey]; - return next; - }); - break; - } - case "setTitle": - setExtensionTitle( - extension.title ? stripAnsi(extension.title) : undefined, - ); - break; - case "set_editor_text": - setMessage(stripAnsi(extension.text ?? "")); - break; + if (!mountedRef.current) return; + await refresh(); + if (!mountedRef.current) return; + dispatch({ type: "selectRuntime", runtimeId: result.runtime.runtimeId }); + await loadSnapshot(result.runtime.runtimeId); + if (mountedRef.current) setStatus("New session ready"); + } catch (error) { + if (mountedRef.current) + setStatus(`Could not start session: ${String(error)}`); + } finally { + if (mountedRef.current) finishOperation(operationId); } } - - useEffect(() => { - let disposed = false; - let unlisten: (() => void) | undefined; - let unlistenError: (() => void) | undefined; - void listen("bridge-event", (event) => { - const bridgeEvent = event.payload; - if (bridgeEvent.agentId !== selectedId) return; - if ( - bridgeEvent.agentId && - bridgeEvent.seq !== undefined && - bridgeEvent.seq <= (lastEventSequence.current[bridgeEvent.agentId] ?? 0) - ) - return; - if (bridgeEvent.agentId && bridgeEvent.seq !== undefined) - lastEventSequence.current[bridgeEvent.agentId] = bridgeEvent.seq; - if (bridgeEvent.type === "extension_ui_request") { - handleExtension(bridgeEvent.data?.event as Extension | undefined); - } else { - if ( - bridgeEvent.type === "agent_state" && - typeof bridgeEvent.data?.state === "string" - ) { - setDirectories((current) => - current.map((directory) => - directory.agentId === bridgeEvent.agentId - ? { ...directory, state: bridgeEvent.data?.state as string } - : directory, - ), - ); - } - updateWorkProgress(bridgeEvent); - if (selectedId) void reloadAgentAfterEvent(selectedId); - } - }).then((stop) => { - if (disposed) stop(); - else unlisten = stop; - }); - void listen<{ message?: string }>("bridge-error", (event) => { - setStatus( - event.payload.message ?? "Bridge connection lost; reconnecting…", - ); - scheduleBridgeReconnect(); - }).then((stop) => { - if (disposed) stop(); - else unlistenError = stop; - }); - return () => { - disposed = true; - unlisten?.(); - unlistenError?.(); - }; - }, [selectedId]); - - async function chooseAgent(agent: Agent) { - setWorkProgress({ - phase: "idle", - detail: "Loading Pi session", - toolCount: 0, - }); - setSelectedId(agent.id); - localStorage.setItem(selectedPathKey, agent.worktreePath); - await loadAgent(agent.id, true); - } - - async function chooseDirectory(directory: Directory) { - const agent = agents.find( - (candidate) => candidate.id === directory.agentId, - ); - if (agent) await chooseAgent(agent); - else await activateWorktree(directory.worktreePath); - } - - function handleDirectoryTabKeyDown( - event: ReactKeyboardEvent, - index: number, - ) { - let nextIndex: number | undefined; - if (event.key === "ArrowRight") - nextIndex = (index + 1) % directories.length; - else if (event.key === "ArrowLeft") - nextIndex = (index - 1 + directories.length) % directories.length; - else if (event.key === "Home") nextIndex = 0; - else if (event.key === "End") nextIndex = directories.length - 1; - if (nextIndex === undefined) return; - event.preventDefault(); - const nextDirectory = directories[nextIndex]; - directoryTabRefs.current[nextDirectory.worktreePath]?.focus(); - } - - async function forgetDirectory(directory: Directory) { - if (directory.isHome) return; - if (directory.state === "streaming") { - setStatus("Wait for Pi to finish before forgetting this directory"); - return; - } + async function closeRuntime(runtime: RuntimeView) { if ( + runtime.summary.state === "streaming" && !window.confirm( - `Forget ${directory.worktreePath}? Its saved sessions will remain available when you add it again.`, + "Close this working session? Pi will be stopped, but its history is kept.", ) ) return; - try { - const closingIndex = directories.findIndex( - (candidate) => candidate.worktreePath === directory.worktreePath, - ); - const nextFocusPath = - directories[closingIndex + 1]?.worktreePath ?? - directories[closingIndex - 1]?.worktreePath; - await invoke("forget_directory", { - worktreePath: directory.worktreePath, - }); - const wasSelected = directory.agentId === selectedId; - if (wasSelected) localStorage.removeItem(selectedPathKey); - await refreshAgents(wasSelected ? "" : selected?.worktreePath); - window.requestAnimationFrame(() => { - if (nextFocusPath) directoryTabRefs.current[nextFocusPath]?.focus(); - }); - setStatus(`Forgot ${worktreeLabel(directory.worktreePath)}`); - } catch (error) { - setStatus(String(error)); - } - } - - async function startNewSession(successMessage = "Started a new Pi session") { - if (!selectedId) return; - try { - const response = await invoke<{ data?: { cancelled?: boolean } }>( - "new_session", - { agentId: selectedId }, - ); - if (response.data?.cancelled) { - setStatus("New session was cancelled"); - return; - } - await loadAgent(selectedId, true); - await loadSessions(selectedId); - setCommandFollowUp(undefined); - setStatus(successMessage); - } catch (error) { - setStatus(String(error)); - } - } - - async function closeCurrentSession() { - if ( - !window.confirm( - "Close the current session and start a new one? The current history remains available through /resume.", - ) - ) - return; - await startNewSession( - "Closed the current session. Use /resume to reopen it.", + const operationId = startOperation( + "closing", + `Closing ${runtime.summary.label}…`, + runtime.summary.runtimeId, ); - } - - async function restartPi() { - if ( - !window.confirm( - "Restart Pi for the selected directory? Any in-progress response will be interrupted.", - ) - ) - return; - await invokeAgent("restart"); - } - - async function switchSession(session: Session) { - if (!selectedId || session.isCurrent) return; try { - const response = await invoke<{ data?: { cancelled?: boolean } }>( - "switch_session", - { agentId: selectedId, sessionPath: session.path }, + await invoke("close_session_runtime", { + runtimeId: runtime.summary.runtimeId, + }); + if (!mountedRef.current) return; + await refresh(); + if (mountedRef.current) setRestoreTabFocus(true); + } catch (error) { + if (mountedRef.current) + setStatus(`Could not close session: ${String(error)}`); + } finally { + if (mountedRef.current) finishOperation(operationId); + } + } + async function showSessions() { + if (!state.selectedDirectoryPath) return; + const operationId = startOperation("listing", "Loading saved sessions…"); + try { + const result = await invoke<{ sessions: DirectorySession[] }>( + "list_directory_sessions", + { worktreePath: state.selectedDirectoryPath }, ); - if (response.data?.cancelled) { - setStatus("Session switch was cancelled"); - return; - } - await loadAgent(selectedId, true); - await loadSessions(selectedId); - setCommandFollowUp(undefined); - setStatus("Switched Pi session"); + setSessions(result.sessions); } catch (error) { - setStatus(String(error)); + setStatus(`Could not load saved sessions: ${String(error)}`); + } finally { + if (mountedRef.current) finishOperation(operationId); } } - - async function openCommandFollowUp(command: FollowUpCommand) { - if (command === "resume" && state.isStreaming) { - setStatus("Wait for Pi to finish before switching sessions"); + async function openSession(session: DirectorySession) { + if (session.runtimeId) { + dispatch({ type: "selectRuntime", runtimeId: session.runtimeId }); + setSessions(undefined); return; } - if (command === "resume" && selectedId) await loadSessions(selectedId); - setCommandFollowUp(command); - setMessage(""); - } - - async function chooseModel(model: Model) { - if (!model.provider || !model.id) return; - await invokeAgent("set_model", { - provider: model.provider, - modelId: model.id, - }); - setCommandFollowUp(undefined); - } - - async function chooseThinking(level: string) { - await invokeAgent("set_thinking_level", { level }); - setCommandFollowUp(undefined); - } - - function updateUiScale(nextScale: number) { - const scale = clampUiScale(nextScale); - setUiScale(scale); - localStorage.setItem(uiScaleKey, String(scale)); - } - - async function activateWorktree(worktreePath: string) { - setIsAddingDirectory(true); - setPendingDirectoryPath(worktreePath); - setStatus("Adding directory…"); + if (!state.selectedDirectoryPath || !mountedRef.current) return; + const operationId = startOperation("opening", "Opening saved Pi session…"); try { - const result = await invoke<{ agent: Agent }>("select_worktree", { - worktreePath, - }); - const addedPath = result.agent.worktreePath; - localStorage.setItem(selectedPathKey, addedPath); - setPendingDirectoryPath(addedPath); - setFolderPath(""); - setSelectedId(result.agent.id); - await refreshAgents(addedPath); - setView("conversation"); + const result = await invoke<{ runtime: RuntimeView["summary"] }>( + "open_session_runtime", + { + worktreePath: state.selectedDirectoryPath, + sessionPath: session.path, + }, + ); + if (!mountedRef.current) return; + await refresh(); + if (!mountedRef.current) return; + dispatch({ type: "selectRuntime", runtimeId: result.runtime.runtimeId }); + await loadSnapshot(result.runtime.runtimeId); + if (mountedRef.current) setSessions(undefined); } catch (error) { - setPendingDirectoryPath(undefined); - setIsAddingDirectory(false); - setStatus(String(error)); + if (mountedRef.current) + setStatus(`Could not open session: ${String(error)}`); + } finally { + if (mountedRef.current) finishOperation(operationId); } } - - async function browseForFolder() { - try { - const selectedPath = await open({ - directory: true, - multiple: false, - defaultPath: folderPath || undefined, - }); - if (selectedPath) setFolderPath(selectedPath); - } catch (error) { - setStatus(String(error)); - } - } - - async function addFolder() { - const worktreePath = folderPath.trim(); - if (!worktreePath.startsWith("/")) { + async function addDirectory() { + const path = folderPath.trim(); + if (!path.startsWith("/")) { setStatus("Enter an absolute folder path"); return; } - await activateWorktree(worktreePath); - } - - async function submit() { - const text = message.trim(); - if (!selectedId || !text) return; - const nativeCommand = controlCommands.find( - (command) => command.followUp && `/${command.name}` === text, - ); - if (nativeCommand?.followUp) { - await openCommandFollowUp(nativeCommand.followUp); - return; + await createRuntime(path); + if (mountedRef.current) { + setFolderPath(""); + setAdding(false); } - const id = ++pendingSubmissionId.current; - setPendingSubmissions((current) => [ - ...current, - { - id, - text, - userMessageCount: messages.filter((entry) => entry.role === "user") - .length, - phase: "sending", - }, - ]); - setMessage(""); - setStatus("Sending prompt to Pi…"); + } + async function forgetDirectory(entry: DirectoryWorkspace) { + if (!window.confirm(`Forget ${entry.worktreePath}?`)) return; try { - await invoke("submit_prompt", { agentId: selectedId, message: text }); - setPendingSubmissions((current) => - current.map((pending) => - pending.id === id ? { ...pending, phase: "sent" } : pending, - ), - ); - setStatus("Prompt sent to Pi"); + await invoke("forget_directory", { worktreePath: entry.worktreePath }); + await refresh(); } catch (error) { - setPendingSubmissions((current) => - current.filter((pending) => pending.id !== id), - ); - setMessage((current) => current || text); setStatus(String(error)); } } - - async function invokeAgent( + async function agentCommand( command: string, args: Record = {}, + reload = true, + target = selected, + showOperation = true, ) { - if (!selectedId) return; + const runtimeId = target?.summary.runtimeId, + agentId = target?.summary.agentId; + if (!runtimeId || !agentId) { + setStatus("This session is not currently available"); + return false; + } + const operationId = showOperation + ? startOperation("command", "Updating Pi session…", runtimeId) + : undefined; try { - await invoke(command, { agentId: selectedId, ...args }); - await loadAgent(selectedId); + await invoke(command, { agentId, ...args }); + if (reload) await loadSnapshot(runtimeId); + return true; } catch (error) { - setStatus(String(error)); + setStatus(`Pi command failed: ${String(error)}`); + return false; + } finally { + if (mountedRef.current && operationId) finishOperation(operationId); } } - - useEffect(() => { - const handleKeydown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - if (pendingExtension) return; - event.preventDefault(); - event.stopPropagation(); - if (commandFollowUp) { - setCommandFollowUp(undefined); - return; - } - if (addingDirectory) { - setAddingDirectory(false); - return; - } - void getCurrentWindow() - .hide() - .catch((error) => setStatus(String(error))); - }; - window.addEventListener("keydown", handleKeydown, true); - return () => window.removeEventListener("keydown", handleKeydown, true); - }, [addingDirectory, commandFollowUp, pendingExtension]); - - async function respondToExtension(response: Record) { - if (!selectedId || !pendingExtension?.id) return; - try { - await invoke("respond_to_extension", { - agentId: selectedId, - requestId: pendingExtension.id, - response, + async function submit() { + if (!selected) return; + const text = selected.draft.trim(); + if (!text) return; + if (text === "/new") { + dispatch({ + type: "draftChanged", + runtimeId: selected.summary.runtimeId, + draft: "", }); - setPendingExtension(undefined); - setExtensionValue(""); - } catch (error) { - setStatus(String(error)); + await createRuntime(); + return; } - } - - useEffect(() => { - if (!pendingExtension) return; - const dialog = extensionRef.current; - if (!dialog) return; - const controls = focusableExtensionControls(dialog); - const initial = - dialog.querySelector("[data-initial-focus]") ?? controls[0]; - initial?.focus(); - const handleKeydown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - void respondToExtension({ cancelled: true }); + if (text === "/resume") { + dispatch({ + type: "draftChanged", + runtimeId: selected.summary.runtimeId, + draft: "", + }); + await showSessions(); + return; + } + const name = text.match(/^\/name\s+([\s\S]+)$/)?.[1]?.trim(); + if (text === "/name" || name) { + if (!name) { + setStatus("Enter a session name after /name"); return; } - if (event.key !== "Tab") return; - const focusable = focusableExtensionControls(dialog); - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (!first || !last) return; - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - }; - window.addEventListener("keydown", handleKeydown); - return () => window.removeEventListener("keydown", handleKeydown); - }, [pendingExtension]); - - const extensionMethod = pendingExtension?.method ?? ""; - const workLabel = - workProgress.phase === "recovering" - ? "Recovering" - : workProgress.phase === "error" - ? "Needs attention" - : workProgress.phase === "working" - ? "Working" - : "Ready"; - const workspaceLabel = - extensionTitle ?? worktreeLabel(selected?.worktreePath) ?? "Pi"; + await agentCommand("set_session_name", { name }); + dispatch({ + type: "draftChanged", + runtimeId: selected.summary.runtimeId, + draft: "", + }); + return; + } + const compact = text.match(/^\/compact(?:\s+([\s\S]+))?$/); + if (compact) { + await agentCommand("compact", { + customInstructions: compact[1]?.trim() || undefined, + }); + dispatch({ + type: "draftChanged", + runtimeId: selected.summary.runtimeId, + draft: "", + }); + return; + } + const runtimeId = selected.summary.runtimeId, + id = ++submissionId, + operationId = startOperation( + "submitting", + "Sending prompt to Pi…", + selected.summary.runtimeId, + ); + dispatch({ + type: "submissionAdded", + runtimeId, + submission: { + id, + text, + userMessageCount: selected.messages.filter( + (message) => message.role === "user", + ).length, + phase: "sending", + }, + }); + dispatch({ type: "draftChanged", runtimeId, draft: "" }); + const sent = await agentCommand( + "submit_prompt", + { message: text }, + false, + selected, + false, + ); + if (sent) { + dispatch({ type: "submissionSent", runtimeId, id }); + setStatus("Prompt accepted · waiting for Pi…"); + } else { + dispatch({ type: "submissionRemoved", runtimeId, id }); + dispatch({ type: "draftChanged", runtimeId, draft: text }); + } + finishOperation(operationId); + } + async function respond(response: Record) { + const target = selected; + const extension = target?.extensions[0]; + if (!extension?.id) return; + await agentCommand( + "respond_to_extension", + { requestId: extension.id, response }, + true, + target, + ); + } + const closePicker = useCallback(() => setSessions(undefined), []); + const pendingSubmissions = selected?.pendingSubmissions ?? []; + const pendingSubmission = pendingSubmissions[pendingSubmissions.length - 1]; + const displayedStatus = + operation?.detail ?? + (pendingSubmission + ? pendingSubmission.phase === "sending" + ? "Sending prompt to Pi…" + : "Prompt accepted · waiting for Pi…" + : undefined) ?? + (selected?.progress.phase === "working" || + selected?.progress.phase === "recovering" + ? selected.progress.detail + : undefined) ?? + status; + const isWorking = + selected?.progress.phase === "working" || + selected?.progress.phase === "recovering"; + const sameDirectoryCollision = directory && directory.openCount > 1; return ( -
+
{ if ( - event.button !== 0 || - (event.target as HTMLElement).closest( - "button, input, select, textarea", + event.button === 0 && + !(event.target as HTMLElement).closest( + "button,input,select,textarea", ) ) - return; - void getCurrentWindow() - .startDragging() - .catch((error) => setStatus(String(error))); + void getCurrentWindow().startDragging(); }} > -
+
PI -

- {workspaceLabel} -

- - {workLabel} - +

{selected?.summary.label ?? "Pi workspace"}

+ {selected && ( + + {isWorking && ( + + )}
- - {status} + {isWorking && ( + + + )} + + {displayedStatus} - {contextWindow && ( - - )} - {stats.tokens?.total !== undefined && ( - - )} - {Object.values(extensionStatuses).map((entry) => ( + {state.bridge.status} + {Object.values(selected?.extensionStatuses ?? {}).map((entry) => ( {entry} @@ -1097,9 +402,7 @@ function App() {
- -
-
- Directories - {directories.map((directory, index) => { - const isSelected = directory.agentId === selectedId; - return ( -
- - {!directory.isHome && ( - - )} -
- ); - })} + {state.resourceWarning && ( +
+ Many Pi sessions are open. Startup and memory use may be high.
- - {addingDirectory && ( -
{ - event.preventDefault(); - void addFolder(); - }} - > - setFolderPath(event.currentTarget.value)} - placeholder="/absolute/path/to/project" - aria-label="Directory path" - /> - - - {isAddingDirectory && ( - - - )} - - )} -
- - {extensionNotifications.length > 0 && ( -
- {extensionNotifications.map((notice, index) => ( -
- {notice.message ?? "Extension notification"} -
- ))} -
)} - - {view === "settings" ? ( -
-

Session controls

-

- {state.sessionName ?? state.sessionId ?? "Session"} ·{" "} - {state.messageCount ?? 0} messages ·{" "} - {state.pendingMessageCount ?? 0} queued -

-
+ + Multiple sessions are open in the same directory. Concurrent work + can collide in files and Git; separate worktrees are safer. + + + Dismiss + + + )} + {selected?.notifications.map((notice, index) => ( +
+ {notice.message} +
+ ))} +
+ + dispatch({ type: "selectDirectory", worktreePath: path }) + } + onAdd={() => setAdding(true)} + onForget={forgetDirectory} + /> +
+ {adding && ( +
{ + event.preventDefault(); + void addDirectory(); + }} + > - updateUiScale(Number(event.currentTarget.value)) - } - step={uiScaleStep} - type="range" - value={uiScale} + autoFocus + value={folderPath} + onChange={(event) => setFolderPath(event.currentTarget.value)} + placeholder="/absolute/path" /> -
-
-
-
-
-

Sessions

-

- {selected?.worktreePath ?? - "Open a directory to view its sessions"} -

-
-
- - + + + )} + + dispatch({ type: "selectRuntime", runtimeId }) + } + onClose={(runtime) => void closeRuntime(runtime)} + onNew={() => void createRuntime()} + onResume={() => void showSessions()} + busy={ + operation?.kind === "creating" || operation?.kind === "listing" + } + closingRuntimeId={ + operation?.kind === "closing" ? operation.runtimeId : undefined + } + restoreFocus={restoreTabFocus} + onFocusRestored={() => setRestoreTabFocus(false)} + /> + {view === "settings" ? ( +
+

Session controls

+ {selected ? ( + <> +

{selected.summary.worktreePath}

+

+ {selected.state.messageCount ?? 0} messages ·{" "} + {selected.summary.queueCount} queued +

+
+ + +
+ + + ) : ( +

No session selected.

+ )} +
+ ) : selected ? ( + + dispatch({ + type: "draftChanged", + runtimeId: selected.summary.runtimeId, + draft, + }) + } + onSubmit={() => void submit()} + onAbort={() => void agentCommand("abort")} + onScroll={(atBottom, scrollTop) => { + dispatch({ + type: "scrollChanged", + runtimeId: selected.summary.runtimeId, + atBottom, + scrollTop, + }); + if (atBottom) + requestAnimationFrame(() => + dispatch({ + type: "markRead", + runtimeId: selected.summary.runtimeId, + }), + ); + }} + onModel={(model: Model) => + void agentCommand("set_model", { + provider: model.provider, + modelId: model.id, + }) + } + onThinking={(level) => + void agentCommand("set_thinking_level", { level }) + } + isSubmitting={ + operation?.kind === "submitting" && + operation.runtimeId === selected.summary.runtimeId + } + /> + ) : ( +
+

No open sessions

+

+ Start a session in this directory, or add another directory. +

+ {state.selectedDirectoryPath && ( + -
-
- {sessions.length ? ( -
- {sessions.map((session) => ( - - ))} -
- ) : ( -

No saved sessions for this directory.

- )} -
-
- - -
+ )} +
+ )}
- ) : ( - <> -
-
-
-
- {messages.length ? ( - messages.map((entry, index) => ( -
- {entry.role ?? "message"} -
{messageText(entry)}
-
- )) - ) : ( -

No messages yet.

- )} - {pendingSubmissions.map((pending) => ( -
- - You{" "} - - {pending.phase === "sending" - ? "Sending…" - : "Sent · waiting for Pi"} - - -
{pending.text}
-
- ))} -
- {!isTranscriptAtBottom && ( - - )} -
- -
-
-
- - -
-
- {filteredCommands.length > 0 && ( -
- {filteredCommands.map((command) => ( - - ))} -
- )} - {commandFollowUp && ( -
-
- /{commandFollowUp} - -
- {commandFollowUp === "model" ? ( -
- {models.map((model) => ( - - ))} -
- ) : commandFollowUp === "thinking" ? ( -
- {thinkingLevels.map((level) => ( - - ))} -
- ) : ( -
- - {sessions.map((session) => ( - - ))} -
- )} -
- )} - {Object.entries(extensionWidgets) - .filter(([, widget]) => widget.placement === "aboveEditor") - .map(([key, widget]) => ( -
- {widget.lines.map((line, index) => ( -

{line}

- ))} -
- ))} -
{ - event.preventDefault(); - void submit(); - }} - > - -