Backport generic session and SDK improvements
This commit is contained in:
1 parent
b034988dd4
commit
80b9cc1547
11 files changed
+2902
-2619
No files matched your search
@@ -2,14 +2,14 @@
|
||||
|
||||
## Project overview
|
||||
|
||||
This is a full-stack web UI for the Pi coding agent. The server runs a real Pi `AgentSession` with system-level tools (bash, file I/O, extensions). The client is a Lit-based SPA using `@mariozechner/pi-web-ui` components. They communicate over a JSON WebSocket protocol defined in `shared/protocol.ts`.
|
||||
This is a full-stack web UI for the Pi coding agent. The server runs a real Pi `AgentSession` with system-level tools (bash, file I/O, extensions). The client is a Lit-based SPA using `@earendil-works/pi-web-ui` components. They communicate over a JSON WebSocket protocol defined in `shared/protocol.ts`.
|
||||
|
||||
## Key conventions
|
||||
|
||||
- **No client framework beyond Lit templates** — the app uses module-level state plus plain Lit `html` templates in `client/main.ts`; call `renderApp()` after state mutation.
|
||||
- **Pi SDK types** come from `@mariozechner/pi-coding-agent` (server) and `@mariozechner/pi-agent-core` / `@mariozechner/pi-ai` (shared types). Do not duplicate SDK types — import them.
|
||||
- **Pi SDK types** come from `@earendil-works/pi-coding-agent` (server) and `@earendil-works/pi-agent-core` / `@earendil-works/pi-ai` (shared types). Do not duplicate SDK types — import them.
|
||||
- **Protocol changes** must update `shared/protocol.ts` (the `ClientMessage` and `ServerMessage` unions), the server handler in `handleClientMessage()`, and the client handler in `handleServerMessage()`.
|
||||
- **CSS** uses Tailwind utility classes in Lit templates plus custom CSS in `client/app.css`. Theme variables come from `@mariozechner/pi-web-ui/app.css` — override them in `app.css` `:root` / `.dark` blocks.
|
||||
- **CSS** uses Tailwind utility classes in Lit templates plus custom CSS in `client/app.css`. Theme variables come from `@earendil-works/pi-web-ui/app.css` — override them in `app.css` `:root` / `.dark` blocks.
|
||||
- The sidebar uses plain CSS transitions. Desktop uses `position: relative` with width animation; mobile uses `position: fixed` with transform.
|
||||
|
||||
## Runtime and security posture
|
||||
|
||||
@@ -16,8 +16,8 @@ Node.js server (Express + ws)
|
||||
System tools (bash, files, skills, extensions)
|
||||
```
|
||||
|
||||
- **Server** (`server/index.ts`): Runs one Pi SDK `AgentSession` per WebSocket client with full system access. Loads auth and model config from `~/.pi/agent`. Streams each client's agent events only to that client. Model discovery comes from the Pi model registry, with optional LiteLLM augmentation.
|
||||
- **Client** (`client/main.ts`): Lit-based UI using `@mariozechner/pi-web-ui` components (`MessageList`, `MessageEditor`, `StreamingMessageContainer`). Communicates exclusively via WebSocket.
|
||||
- **Server** (`server/index.ts`): Runs one Pi SDK `AgentSession` per WebSocket client with full system access. Loads auth and model config from `~/.pi/agent` through `ModelRuntime`. Streams each client's agent events only to that client. Model discovery can use LiteLLM.
|
||||
- **Client** (`client/main.ts`): Lit-based UI using `@earendil-works/pi-web-ui` components (`MessageList`, `MessageEditor`, `StreamingMessageContainer`). Communicates only through WebSocket.
|
||||
- **Protocol** (`shared/protocol.ts`): Typed JSON message definitions for the WebSocket wire format.
|
||||
|
||||
## Setup
|
||||
@@ -36,6 +36,7 @@ npm run build
|
||||
| `PI_WEBUI_TOKEN` | *(unset)* | **Required by default** shared secret for `/api/ws` and the skills editor API. If set, open `/?token=<token>` once on first load; the client stores it in `localStorage` and removes the query parameter from the URL. |
|
||||
| `PI_WEBUI_ALLOW_ANONYMOUS` | `false` | Local development escape hatch only: tokenless mode requires this flag and a loopback `HOST`. Non-loopback/public binding always requires `PI_WEBUI_TOKEN`. |
|
||||
| `PI_WEBUI_ALLOWED_ORIGINS` | same host as request | Additional comma-separated WebSocket `Origin` allowlist values. |
|
||||
| `PI_WEBUI_TITLE` | `Pi Web UI` | Browser title and visible application name. |
|
||||
| `PI_CODING_AGENT_DIR` | `~/.pi/agent` | Pi agent config directory for auth, models, settings, sessions, and shared skills. |
|
||||
| `PI_WEBUI_SKILL_MAX_FILES` | `220` | Max SKILL parse candidates per skill directory scan. |
|
||||
| `PI_WEBUI_SKILL_PARSE_BYTE_BUDGET` | `3000000` | Max total bytes read while scanning skills for a list call. |
|
||||
@@ -77,13 +78,17 @@ HOST=127.0.0.1 PORT=8085 PI_WEBUI_TOKEN=change-me npm start
|
||||
## Features
|
||||
|
||||
- Full Pi agent with system access (bash, read, edit, write, extensions)
|
||||
- Per-browser-client session isolation with persistence and history — create new sessions, rename sessions, search conversations, and browse/resume previous sessions with paged loading
|
||||
- Model switching with scoped/default models first and an optional full model list (Pi model registry plus optional LiteLLM)
|
||||
- Per-browser-client session isolation with persistence and history
|
||||
- Session search, rename, deletion, stable session links, and paged loading
|
||||
- Model switching with scoped/default models first and an optional full model list
|
||||
- Optional scoped-only model selection and alias-only model labels
|
||||
- Configurable thinking level (off, minimal, low, medium, high), with saved session model/thinking restored on resume
|
||||
- Streaming responses with tool execution display and paged conversation loading for long histories
|
||||
- Built-in skills manager at `/skills` for creating/editing private skills under `~/.pi/skills` and viewing shared read-only skills from `$PI_CODING_AGENT_DIR/skills`; private skills with the same name take precedence
|
||||
- Collapsible session sidebar (inline on desktop, overlay on mobile) plus a mobile top-bar controls menu
|
||||
- Configurable thinking level with saved session settings restored on resume
|
||||
- Startup resource summary for the model, session, tools, and advertised skills
|
||||
- Activity and compaction status during long turns
|
||||
- Prompt history through the Up and Down keys
|
||||
- Structured tool-result summaries and paged conversation loading
|
||||
- Built-in skills manager at `/skills` for private and shared skills
|
||||
- Responsive session sidebar and mobile top-bar controls
|
||||
- Dark/light theme toggle with green accent
|
||||
|
||||
## Project structure
|
||||
|
||||
+57
-1
@@ -1,4 +1,4 @@
|
||||
@import "@mariozechner/pi-web-ui/app.css";
|
||||
@import "@earendil-works/pi-web-ui/app.css";
|
||||
|
||||
@font-face {
|
||||
font-family: "B612";
|
||||
@@ -479,6 +479,52 @@ textarea {
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.compaction-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--app-space-2);
|
||||
padding: var(--app-space-2) var(--app-space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.compaction-notice-complete { opacity: 0.82; }
|
||||
|
||||
.compaction-spinner {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 999px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.startup-card {
|
||||
max-width: 52rem;
|
||||
margin: auto;
|
||||
padding: clamp(1rem, 4vw, 2rem);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--app-radius);
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
|
||||
.startup-card h1, .startup-card h2 { margin: 0; }
|
||||
.startup-card > p { margin: var(--app-space-2) 0 var(--app-space-4); color: var(--muted-foreground); }
|
||||
.startup-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--app-space-4); }
|
||||
.startup-section { margin-top: var(--app-space-4); }
|
||||
.startup-section h2 { margin-bottom: var(--app-space-2); font-size: inherit; }
|
||||
.startup-section dl { margin: 0; }
|
||||
.startup-section dl div { display: grid; grid-template-columns: 9rem minmax(0, 1fr); gap: var(--app-space-2); }
|
||||
.startup-section dt { color: var(--muted-foreground); }
|
||||
.startup-section dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.startup-inline-list { line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.startup-inline-item { font-weight: var(--app-font-weight-strong); color: var(--accent-foreground); cursor: help; }
|
||||
.startup-inline-separator { margin: 0 var(--app-space-1); color: var(--muted-foreground); }
|
||||
.startup-muted { color: var(--muted-foreground); }
|
||||
|
||||
.choice-request-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
@@ -805,6 +851,16 @@ textarea {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-panel { width: min(92vw, 340px); max-width: min(92vw, 340px); }
|
||||
|
||||
.session-action-button { opacity: 1; }
|
||||
|
||||
.progressive-tool-header { align-items: flex-start; }
|
||||
|
||||
.startup-grid { grid-template-columns: 1fr; }
|
||||
|
||||
.startup-section dl div { grid-template-columns: 1fr; gap: 0; }
|
||||
|
||||
.topbar-control-menu .relative,
|
||||
.topbar-control-menu .model-button,
|
||||
.topbar-control-menu .thinking-select,
|
||||
|
||||
+137
-38
@@ -2,13 +2,14 @@ import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
|
||||
import "./app.css";
|
||||
import { installMarkdownSecurityPatch } from "./markdown-security-patch.js";
|
||||
import { summarizeToolResultData } from "./tool-result-summary.js";
|
||||
|
||||
import { html, render, nothing } from "lit";
|
||||
import { icon } from "@mariozechner/mini-lit";
|
||||
import { Check, ChevronDown, Pencil, Plus, PanelLeftClose, Menu, X } from "lucide";
|
||||
import { Check, ChevronDown, Pencil, Plus, PanelLeftClose, Menu, Trash2, X } from "lucide";
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
|
||||
import {
|
||||
AssistantMessage,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
MessageEditor,
|
||||
StreamingMessageContainer,
|
||||
formatUsage,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
} from "@earendil-works/pi-web-ui";
|
||||
|
||||
void MessageList;
|
||||
void MessageEditor;
|
||||
@@ -81,32 +82,12 @@ function summarizeToolCall(toolName: string, args: any): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function firstCount(value: any): number | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
if (Array.isArray(value)) return value.length;
|
||||
for (const key of ["items", "rows", "results", "entries"]) {
|
||||
if (Array.isArray(value[key])) return value[key].length;
|
||||
}
|
||||
for (const child of Object.values(value)) {
|
||||
const count = firstCount(child);
|
||||
if (count !== undefined) return count;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function summarizeToolResult(result: ToolResultMessage | undefined): string {
|
||||
if (!result) return "";
|
||||
const text = toolResultText(result);
|
||||
let parsed: any;
|
||||
try { parsed = text ? JSON.parse(text) : result.details; } catch { parsed = result.details; }
|
||||
const count = firstCount(parsed);
|
||||
const parts = [];
|
||||
if (result.isError) parts.push("error");
|
||||
if (typeof count === "number") parts.push(`${count} item${count === 1 ? "" : "s"}`);
|
||||
if (parsed?.page?.has_more) parts.push("more available");
|
||||
if (parsed?.truncated || parsed?.page?.truncated) parts.push("truncated");
|
||||
if (parts.length > 0) return parts.join(" · ");
|
||||
return text ? `${text.length.toLocaleString()} chars` : "no output";
|
||||
return summarizeToolResultData(parsed, result.isError === true, text.length);
|
||||
}
|
||||
|
||||
function allVisibleToolCallIds(): string[] {
|
||||
@@ -230,7 +211,7 @@ function assistantMetadata(message: any): string {
|
||||
}
|
||||
|
||||
function installAssistantMetadataRenderer() {
|
||||
// @mariozechner/pi-web-ui currently has no trailing-metadata slot/hook for
|
||||
// @earendil-works/pi-web-ui currently has no trailing-metadata slot/hook for
|
||||
// assistant messages, so this app overrides the component renderer locally.
|
||||
// Re-audit this copy after pi-web-ui updates, especially around chunk/tool rendering.
|
||||
(AssistantMessage.prototype as any).render = function () {
|
||||
@@ -288,9 +269,19 @@ let showModelDropdown = false;
|
||||
let modelFilter = "";
|
||||
let showAllModels = false;
|
||||
let toolNames: string[] = [];
|
||||
let systemPrompt = "";
|
||||
let currentSessionId = "";
|
||||
let currentSessionName: string | undefined;
|
||||
let currentSessionPath: string | undefined;
|
||||
let currentCwd = "";
|
||||
let appTitle = "Pi Web UI";
|
||||
let compactionActive = false;
|
||||
let compactionMessage = "";
|
||||
let lastCompactionStatus = "";
|
||||
let activityMessage = "";
|
||||
const promptHistory: string[] = [];
|
||||
let promptHistoryIndex: number | undefined;
|
||||
let promptHistoryDraft = "";
|
||||
let errorClearTimer: number | undefined;
|
||||
let sidebarOpen = false;
|
||||
let showMobileControls = false;
|
||||
@@ -330,9 +321,12 @@ function removeTokenFromLocation(params: URLSearchParams) {
|
||||
|
||||
function getWsUrl(): string {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const pageParams = new URLSearchParams(location.search);
|
||||
const sessionId = pageParams.get("session") || pageParams.get("sessionId") || "";
|
||||
const lastPath = localStorage.getItem("pi-webui-session-path") || "";
|
||||
const params = new URLSearchParams();
|
||||
if (lastPath) params.set("sessionPath", lastPath);
|
||||
if (sessionId) params.set("session", sessionId);
|
||||
else if (lastPath) params.set("sessionPath", lastPath);
|
||||
const query = params.toString() ? `?${params.toString()}` : "";
|
||||
return `${proto}//${location.host}/api/ws${query}`;
|
||||
}
|
||||
@@ -482,6 +476,7 @@ function applyStateSync(state: SerializedAgentState) {
|
||||
streamingMessage = state.streamingMessage || null;
|
||||
thinkingLevel = state.thinkingLevel;
|
||||
toolNames = state.tools;
|
||||
systemPrompt = state.systemPrompt;
|
||||
if (state.model) currentModel = state.model;
|
||||
if (state.errorMessage) errorMessage = state.errorMessage;
|
||||
if (currentSessionId && currentSessionId !== state.sessionId) {
|
||||
@@ -493,6 +488,10 @@ function applyStateSync(state: SerializedAgentState) {
|
||||
currentSessionId = state.sessionId;
|
||||
currentSessionName = state.sessionName;
|
||||
currentSessionPath = state.sessionPath;
|
||||
currentCwd = state.cwd || "";
|
||||
appTitle = state.appTitle || "Pi Web UI";
|
||||
document.title = appTitle;
|
||||
updateSessionUrl(currentSessionId);
|
||||
if (currentSessionPath) {
|
||||
localStorage.setItem("pi-webui-session-path", currentSessionPath);
|
||||
}
|
||||
@@ -502,23 +501,44 @@ function applyStateSync(state: SerializedAgentState) {
|
||||
|
||||
function handleAgentEvent(event: any) {
|
||||
switch (event.type) {
|
||||
case "webui_activity":
|
||||
activityMessage = event.active ? (event.message || "Working...") : "";
|
||||
renderApp();
|
||||
break;
|
||||
case "compaction_start":
|
||||
compactionActive = true;
|
||||
activityMessage = "";
|
||||
compactionMessage = event.reason === "manual" ? "Compacting context..." : event.reason === "overflow" ? "Context limit reached. Compacting and retrying..." : "Auto-compacting context...";
|
||||
lastCompactionStatus = compactionMessage;
|
||||
renderApp();
|
||||
break;
|
||||
case "compaction_end":
|
||||
compactionActive = false;
|
||||
lastCompactionStatus = event.errorMessage ? `Compaction failed: ${event.errorMessage}` : event.aborted ? "Compaction was canceled." : event.result ? (event.willRetry ? "Context compacted. Retrying automatically..." : "Context compacted.") : "Compaction was not needed.";
|
||||
if (event.errorMessage) errorMessage = lastCompactionStatus;
|
||||
renderApp();
|
||||
break;
|
||||
case "agent_start":
|
||||
isStreaming = true;
|
||||
activityMessage = "Waiting for the model...";
|
||||
renderApp();
|
||||
break;
|
||||
|
||||
case "agent_end":
|
||||
isStreaming = false;
|
||||
activityMessage = "";
|
||||
streamingMessage = null;
|
||||
updateStreamingContainer(null, false);
|
||||
renderApp();
|
||||
break;
|
||||
|
||||
case "message_start":
|
||||
if (event.message?.role === "assistant") activityMessage = "Receiving response...";
|
||||
renderApp();
|
||||
break;
|
||||
|
||||
case "message_update":
|
||||
activityMessage = "";
|
||||
noteVisibleToolCalls(event.message);
|
||||
streamingMessage = event.message;
|
||||
updateStreamingContainer(event.message, true);
|
||||
@@ -572,6 +592,7 @@ function handleAgentEvent(event: any) {
|
||||
renderApp();
|
||||
break;
|
||||
case "tool_execution_start":
|
||||
activityMessage = `Running ${event.toolName || event.name || event.toolCall?.name || "tool"}...`;
|
||||
if (typeof event.toolCallId === "string" && !seenToolCallIds.has(event.toolCallId)) {
|
||||
collapsePreviousToolCallsFor(event.toolCallId);
|
||||
seenToolCallIds.add(event.toolCallId);
|
||||
@@ -579,7 +600,11 @@ function handleAgentEvent(event: any) {
|
||||
renderApp();
|
||||
break;
|
||||
case "tool_execution_update":
|
||||
activityMessage = `Running ${event.toolName || event.name || event.toolCall?.name || "tool"}...`;
|
||||
renderApp();
|
||||
break;
|
||||
case "tool_execution_end":
|
||||
activityMessage = "Processing tool result...";
|
||||
renderApp();
|
||||
break;
|
||||
}
|
||||
@@ -619,6 +644,11 @@ function scrollMessagesToBottom(force = false) {
|
||||
|
||||
function handleSend(input: string) {
|
||||
if (!input.trim() || isStreaming) return;
|
||||
const prompt = input.trimEnd();
|
||||
if (promptHistory.at(-1) !== prompt) promptHistory.push(prompt);
|
||||
if (promptHistory.length > 200) promptHistory.shift();
|
||||
promptHistoryIndex = undefined;
|
||||
promptHistoryDraft = "";
|
||||
send({ type: "prompt", text: input });
|
||||
|
||||
const editor = document.querySelector("message-editor") as MessageEditor | null;
|
||||
@@ -706,9 +736,26 @@ function toggleMobileControls() {
|
||||
renderApp();
|
||||
}
|
||||
|
||||
function handleLoadSession(sessionPath: string) {
|
||||
if (sessionPath === currentSessionPath) return;
|
||||
send({ type: "loadSession", sessionPath });
|
||||
function updateSessionUrl(sessionId: string) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (sessionId) params.set("session", sessionId);
|
||||
else params.delete("session");
|
||||
params.delete("sessionId");
|
||||
params.delete("sessionPath");
|
||||
history.replaceState(history.state, "", `${location.pathname}${params.size ? `?${params}` : ""}${location.hash}`);
|
||||
}
|
||||
|
||||
function handleLoadSession(session: SessionListItem) {
|
||||
if (session.id === currentSessionId) return;
|
||||
updateSessionUrl(session.id);
|
||||
send({ type: "loadSession", sessionId: session.id });
|
||||
}
|
||||
|
||||
function handleDeleteSession(event: Event, session: SessionListItem) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!confirm(`Delete ${sessionTitle(session)}?`)) return;
|
||||
send({ type: "deleteSession", sessionId: session.id });
|
||||
}
|
||||
|
||||
function startRenameSession(event: Event, session: SessionListItem) {
|
||||
@@ -737,7 +784,7 @@ function submitRenameSession(event: Event, session: SessionListItem) {
|
||||
event.stopPropagation();
|
||||
const name = editingSessionName.trim();
|
||||
if (!name) return cancelRenameSession(event);
|
||||
send({ type: "renameSession", sessionPath: session.path, name });
|
||||
send({ type: "renameSession", sessionId: session.id, name });
|
||||
editingSessionPath = undefined;
|
||||
editingSessionName = "";
|
||||
renderApp();
|
||||
@@ -760,9 +807,23 @@ document.addEventListener("click", (e) => {
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && sidebarOpen) {
|
||||
closeSidebar();
|
||||
if (e.key === "Escape" && sidebarOpen) closeSidebar();
|
||||
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
||||
const textarea = e.target as HTMLTextAreaElement | null;
|
||||
if (!textarea || textarea.tagName !== "TEXTAREA" || isStreaming || promptHistory.length === 0) return;
|
||||
const editor = textarea.closest("message-editor") as MessageEditor | null;
|
||||
if (!editor) return;
|
||||
e.preventDefault();
|
||||
if (promptHistoryIndex === undefined) {
|
||||
promptHistoryDraft = textarea.value;
|
||||
promptHistoryIndex = promptHistory.length;
|
||||
}
|
||||
promptHistoryIndex = e.key === "ArrowUp" ? Math.max(0, promptHistoryIndex - 1) : Math.min(promptHistory.length, promptHistoryIndex + 1);
|
||||
const value = promptHistoryIndex === promptHistory.length ? promptHistoryDraft : promptHistory[promptHistoryIndex];
|
||||
editor.value = value;
|
||||
textarea.value = value;
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
// ── Render ──
|
||||
@@ -815,6 +876,30 @@ function modelMatchesFilter(model: ModelInfo, filter: string): boolean {
|
||||
.some((value) => value.toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
function extractStartupSkills() {
|
||||
const skills = [] as Array<{ name: string; description: string }>;
|
||||
for (const block of systemPrompt.split("<skill>").slice(1)) {
|
||||
const name = /<name>([\s\S]*?)<\/name>/.exec(block)?.[1]?.trim();
|
||||
const description = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1]?.trim() || "";
|
||||
if (name) skills.push({ name, description });
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
function renderStartupInfo() {
|
||||
const skills = extractStartupSkills();
|
||||
const list = (items: Array<{ name: string; description?: string }>, empty: string) => items.length
|
||||
? html`<span class="startup-inline-list">${items.map((item, index) => html`${index ? html`<span class="startup-inline-separator">·</span>` : nothing}<span class="startup-inline-item" title=${item.description || item.name}>${item.name}</span>`)}</span>`
|
||||
: html`<span class="startup-muted">${empty}</span>`;
|
||||
return html`<section class="startup-card">
|
||||
<h1>${appTitle}</h1>
|
||||
<p>Your Pi workspace is ready.</p>
|
||||
<div class="startup-grid"><div class="startup-section"><h2>Model</h2><dl><div><dt>Current</dt><dd>${currentModel ? modelLabel(currentModel) : "No model selected"}</dd></div><div><dt>Thinking</dt><dd>${thinkingLevel}</dd></div></dl></div><div class="startup-section"><h2>Session</h2><dl><div><dt>Working directory</dt><dd>${currentCwd || "Not available"}</dd></div><div><dt>Session</dt><dd>${currentSessionName || currentSessionId || "New"}</dd></div></dl></div></div>
|
||||
<div class="startup-section"><h2>Tools</h2>${list(toolNames.map((name) => ({ name })), "No tools active.")}</div>
|
||||
<div class="startup-section"><h2>Skills</h2>${list(skills, "No skills advertised.")}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
return html`
|
||||
<div class="sidebar-inner h-full flex flex-col">
|
||||
@@ -868,7 +953,7 @@ function renderSidebar() {
|
||||
` : html`
|
||||
<button
|
||||
class="session-item"
|
||||
@click=${() => handleLoadSession(s.path)}
|
||||
@click=${() => handleLoadSession(s)}
|
||||
>
|
||||
<div class="session-item-title">${sessionTitle(s)}</div>
|
||||
${s.name && s.firstMessage ? html`
|
||||
@@ -886,6 +971,13 @@ function renderSidebar() {
|
||||
aria-label="Rename conversation ${sessionTitle(s)}"
|
||||
@click=${(event: Event) => startRenameSession(event, s)}
|
||||
>${icon(Pencil, "sm")}</button>
|
||||
<button
|
||||
class="session-action-button"
|
||||
type="button"
|
||||
title="Delete conversation"
|
||||
aria-label="Delete conversation ${sessionTitle(s)}"
|
||||
@click=${(event: Event) => handleDeleteSession(event, s)}
|
||||
>${icon(Trash2, "sm")}</button>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
@@ -1175,7 +1267,7 @@ function renderApp() {
|
||||
${sidebarOpen ? icon(PanelLeftClose, "sm") : icon(Menu, "sm")}
|
||||
</button>
|
||||
|
||||
<span class="font-semibold text-sm shrink-0 hidden sm:inline">Pi Web UI</span>
|
||||
<span class="font-semibold text-sm shrink-0 hidden sm:inline">${appTitle}</span>
|
||||
|
||||
<span class="status-pill px-1.5 py-0.5 rounded-full shrink-0 ${connected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}">
|
||||
${connected ? "Connected" : "Disconnected"}
|
||||
@@ -1285,11 +1377,18 @@ function renderApp() {
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
${compactionActive || activityMessage || lastCompactionStatus ? html`
|
||||
<div class="compaction-notice ${lastCompactionStatus && !compactionActive && !activityMessage ? "compaction-notice-complete" : ""}" role="status" aria-live="polite">
|
||||
${compactionActive || activityMessage ? html`<span class="compaction-spinner"></span>` : nothing}
|
||||
<span>${compactionActive ? compactionMessage : activityMessage || lastCompactionStatus}</span>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<!-- Messages area -->
|
||||
<div class="flex-1 overflow-y-auto px-3 sm:px-4 py-4" id="messages-scroll" @scroll=${handleMessagesScroll}>
|
||||
${messages.length === 0 && !isStreaming ? html`
|
||||
<div class="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Send a message to start a conversation
|
||||
${messages.length === 0 && !isStreaming && !compactionActive ? html`
|
||||
<div class="max-w-4xl mx-auto">
|
||||
${renderStartupInfo()}
|
||||
</div>
|
||||
` : html`
|
||||
<div class="max-w-4xl mx-auto flex flex-col gap-3">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
function resultCount(value: any): number | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
if (typeof value.count === "number") return value.count;
|
||||
for (const key of ["items", "rows", "shares", "recipes", "entries", "tasks"]) {
|
||||
if (Array.isArray(value[key])) return value[key].length;
|
||||
}
|
||||
if (value.results && typeof value.results === "object" && !Array.isArray(value.results)) {
|
||||
const values = Object.values(value.results).filter((entry) => entry && typeof entry === "object");
|
||||
if (values.length === 1) return resultCount(values[0]);
|
||||
}
|
||||
if (Array.isArray(value.results)) return value.results.length;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function summarizeToolResultData(parsed: any, sdkError: boolean, textLength: number): string {
|
||||
const errorCode = parsed?.error || parsed?.code;
|
||||
const failed = sdkError || parsed?.ok === false || parsed?.type === "error" || Boolean(errorCode);
|
||||
const count = resultCount(parsed);
|
||||
const parts: string[] = [];
|
||||
if (failed) {
|
||||
const code = errorCode ? String(errorCode).replace(/\s+/g, " ").slice(0, 80) : "";
|
||||
parts.push(code ? `error: ${code}` : "error");
|
||||
}
|
||||
if (typeof count === "number") parts.push(`${count} item${count === 1 ? "" : "s"}`);
|
||||
if (parsed?.page?.has_more || parsed?.page?.hasMore) parts.push("more available");
|
||||
if (parsed?.truncated || parsed?.page?.truncated) parts.push("truncated");
|
||||
if (parts.length > 0) return parts.join(" · ");
|
||||
return textLength ? `${textLength.toLocaleString()} chars` : "no output";
|
||||
}
|
||||
Generated
+2339
-2466
File diff suppressed because it is too large.
Load diff
+10
-5
@@ -15,14 +15,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/mini-lit": "^0.2.1",
|
||||
"@mariozechner/pi-agent-core": "^0.66.1",
|
||||
"@mariozechner/pi-ai": "^0.66.1",
|
||||
"@mariozechner/pi-coding-agent": "^0.66.1",
|
||||
"@mariozechner/pi-web-ui": "^0.66.1",
|
||||
"@earendil-works/pi-agent-core": "0.83.0",
|
||||
"@earendil-works/pi-ai": "0.83.0",
|
||||
"@earendil-works/pi-coding-agent": "0.83.0",
|
||||
"@earendil-works/pi-web-ui": "0.75.3",
|
||||
"express": "^5.1.0",
|
||||
"lit": "^3.3.1",
|
||||
"lucide": "^0.544.0",
|
||||
"ws": "^8.18.0"
|
||||
"ws": "8.21.1"
|
||||
},
|
||||
"overrides": {
|
||||
"@protobufjs/utf8": "1.1.2",
|
||||
"protobufjs": "7.6.5",
|
||||
"qs": "6.15.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
|
||||
+226
-96
@@ -7,16 +7,15 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import {
|
||||
AuthStorage,
|
||||
createAgentSession,
|
||||
ModelRegistry,
|
||||
ModelRuntime,
|
||||
SessionManager,
|
||||
defineTool,
|
||||
DefaultResourceLoader,
|
||||
SettingsManager,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
canReadFile,
|
||||
createParsingState,
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
recordRead,
|
||||
type ParsingLimits,
|
||||
} from "./parsing-budget.js";
|
||||
import { Type, type Model, type Api } from "@mariozechner/pi-ai";
|
||||
import { Type, type Model, type Api } from "@earendil-works/pi-ai";
|
||||
import type {
|
||||
ClientMessage,
|
||||
ServerMessage,
|
||||
@@ -50,6 +49,7 @@ const HOST = process.env.HOST || "127.0.0.1";
|
||||
const LITELLM_URL = process.env.LITELLM_URL || "";
|
||||
const LITELLM_TIMEOUT_MS = parseIntEnv("LITELLM_TIMEOUT_MS", 1500);
|
||||
let litellmKey = process.env.LITELLM_KEY || "";
|
||||
const APP_TITLE = (process.env.PI_WEBUI_TITLE || "Pi Web UI").trim().slice(0, 120) || "Pi Web UI";
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
@@ -173,6 +173,8 @@ wss.on("headers", (headers) => {
|
||||
const HOME_DIR = os.homedir();
|
||||
const AGENT_DIR = process.env.PI_CODING_AGENT_DIR || path.join(HOME_DIR, ".pi", "agent");
|
||||
const AGENT_SETTINGS_PATH = path.join(AGENT_DIR, "settings.json");
|
||||
const AGENT_AUTH_PATH = path.join(AGENT_DIR, "auth.json");
|
||||
const AGENT_MODELS_PATH = path.join(AGENT_DIR, "models.json");
|
||||
const USER_SKILLS_DIR = process.env.PI_WEBUI_USER_SKILLS_DIR || path.join(HOME_DIR, ".pi", "skills");
|
||||
const SHARED_SKILLS_DIR = process.env.PI_WEBUI_SHARED_SKILLS_DIR || path.join(AGENT_DIR, "skills");
|
||||
const SESSION_LIST_LIMIT = parseIntEnv("SESSION_LIST_LIMIT", 30);
|
||||
@@ -181,8 +183,7 @@ const MESSAGE_PAGE_LIMIT = parseIntEnv("MESSAGE_PAGE_LIMIT", 60);
|
||||
const MESSAGE_PAGE_MAX_LIMIT = parseIntEnv("MESSAGE_PAGE_MAX_LIMIT", 200);
|
||||
const SESSION_LIST_CACHE_MS = parseIntEnv("SESSION_LIST_CACHE_MS", 10000);
|
||||
|
||||
let authStorage: AuthStorage;
|
||||
let modelRegistry: ModelRegistry;
|
||||
let modelRuntime: ModelRuntime;
|
||||
type ChoiceRequest = {
|
||||
id: string;
|
||||
prompt: string;
|
||||
@@ -195,10 +196,12 @@ type PendingChoiceRequest = ChoiceRequest & {
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
type ClientRuntime = { session: AgentSession; unsubscribe?: () => void; pendingChoices?: Map<string, PendingChoiceRequest> };
|
||||
type ClientRuntime = { session: AgentSession; unsubscribe?: () => void; pendingChoices?: Map<string, PendingChoiceRequest>; refreshSessionsWhenPersisted?: boolean };
|
||||
const clients = new Map<WebSocket, ClientRuntime>();
|
||||
const syntheticModels = new Map<string, Model<Api>>();
|
||||
let modelConfigStamp = "";
|
||||
let modelAuthStamp = "";
|
||||
let modelConfigRefreshPromise: Promise<void> | undefined;
|
||||
let sessionListCache: { expires: number; files: { filePath: string; mtime: Date }[] } | undefined;
|
||||
|
||||
function boolEnv(name: string): boolean {
|
||||
@@ -395,19 +398,28 @@ function fileStamp(filePath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshModelConfigIfChanged() {
|
||||
const nextStamp = [
|
||||
fileStamp(path.join(AGENT_DIR, "auth.json")),
|
||||
fileStamp(path.join(AGENT_DIR, "models.json")),
|
||||
fileStamp(AGENT_SETTINGS_PATH),
|
||||
].join("|");
|
||||
async function refreshModelConfig() {
|
||||
const nextAuthStamp = fileStamp(AGENT_AUTH_PATH);
|
||||
const nextStamp = [nextAuthStamp, fileStamp(AGENT_MODELS_PATH), fileStamp(AGENT_SETTINGS_PATH)].join("|");
|
||||
if (nextStamp === modelConfigStamp) return;
|
||||
if (modelAuthStamp && nextAuthStamp !== modelAuthStamp) {
|
||||
modelRuntime = await ModelRuntime.create({ authPath: AGENT_AUTH_PATH, modelsPath: AGENT_MODELS_PATH });
|
||||
for (const ws of clients.keys()) ws.close(1012, "Model credentials changed");
|
||||
} else {
|
||||
await modelRuntime?.refresh({ allowNetwork: false });
|
||||
}
|
||||
modelAuthStamp = nextAuthStamp;
|
||||
modelConfigStamp = nextStamp;
|
||||
authStorage?.reload();
|
||||
modelRegistry?.refresh();
|
||||
syntheticModels.clear();
|
||||
}
|
||||
|
||||
function refreshModelConfigIfChanged() {
|
||||
if (!modelConfigRefreshPromise) {
|
||||
modelConfigRefreshPromise = refreshModelConfig().finally(() => { modelConfigRefreshPromise = undefined; });
|
||||
}
|
||||
return modelConfigRefreshPromise;
|
||||
}
|
||||
|
||||
function modelToInfo(model: Model<Api>, scoped = false): ModelInfo {
|
||||
return {
|
||||
provider: model.provider,
|
||||
@@ -541,6 +553,8 @@ function serializeState(session: AgentSession, overrides: Partial<Pick<Serialize
|
||||
streamingMessage: hasStreamingMessageOverride ? overrides.streamingMessage : state.streamingMessage,
|
||||
errorMessage: state.errorMessage,
|
||||
tools: session.getActiveToolNames(),
|
||||
cwd: (session as any)._cwd || (session as any).cwd,
|
||||
appTitle: APP_TITLE,
|
||||
sessionId: session.sessionId,
|
||||
sessionName: session.sessionName,
|
||||
sessionPath: session.sessionFile,
|
||||
@@ -718,6 +732,48 @@ function resolveSessionPath(candidate: string): string | undefined {
|
||||
return requestedPath;
|
||||
}
|
||||
|
||||
async function resolveSessionPathById(sessionId: string | undefined): Promise<string | undefined> {
|
||||
if (!sessionId || !/^[A-Za-z0-9_-]{6,128}$/.test(sessionId)) return undefined;
|
||||
const entries = await readDirectoryEntriesLimited(getSessionDir(HOME_DIR), SESSION_DIRECTORY_SCAN_LIMIT);
|
||||
const direct = entries.find((entry) => entry.isFile() && entry.name.endsWith(`_${sessionId}.jsonl`));
|
||||
if (direct) {
|
||||
const filePath = path.join(getSessionDir(HOME_DIR), direct.name);
|
||||
const state = createParsingState();
|
||||
const content = await readSessionHead(filePath, state);
|
||||
const stats = await fs.promises.stat(filePath).catch(() => undefined);
|
||||
if (stats && sessionItemFromContent(filePath, content || "", stats.mtime)?.id === sessionId) return filePath;
|
||||
}
|
||||
const state = createParsingState();
|
||||
for (const { filePath, mtime } of await sessionFiles()) {
|
||||
const content = await readSessionHead(filePath, state);
|
||||
if (sessionItemFromContent(filePath, content || "", mtime)?.id === sessionId) return filePath;
|
||||
if (state.stopReason) break;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function resolveRequestedSessionPath(request: { sessionPath?: string; sessionId?: string }): Promise<string | undefined> {
|
||||
return request.sessionPath ? resolveSessionPath(request.sessionPath) : resolveSessionPathById(request.sessionId);
|
||||
}
|
||||
|
||||
const sessionPathLocks = new Map<string, Promise<void>>();
|
||||
|
||||
async function withSessionPathLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const key = path.resolve(filePath);
|
||||
const previous = sessionPathLocks.get(key) || Promise.resolve();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const tail = previous.then(() => gate);
|
||||
sessionPathLocks.set(key, tail);
|
||||
await previous;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
if (sessionPathLocks.get(key) === tail) sessionPathLocks.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionManagerForResumePath(candidate: string | undefined): SessionManager {
|
||||
if (!candidate) return SessionManager.create(HOME_DIR, getSessionDir(HOME_DIR));
|
||||
const requestedPath = resolveSessionPath(candidate);
|
||||
@@ -784,8 +840,8 @@ function buildSyntheticModel(provider: string, modelId: string): Model<Api> | un
|
||||
const existing = syntheticModels.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const providerModels = modelRegistry.getAll().filter((m) => m.provider === provider);
|
||||
const base = providerModels.find((m) => modelRegistry.hasConfiguredAuth(m)) || providerModels[0];
|
||||
const providerModels = modelRuntime.getModels(provider);
|
||||
const base = providerModels[0];
|
||||
if (!base) return undefined;
|
||||
|
||||
const model: Model<Api> = {
|
||||
@@ -798,10 +854,10 @@ function buildSyntheticModel(provider: string, modelId: string): Model<Api> | un
|
||||
}
|
||||
|
||||
function findConfiguredOrSyntheticModel(provider: string, modelId: string): Model<Api> | undefined {
|
||||
const model = modelRegistry.find(provider, modelId) || syntheticModels.get(modelKey(provider, modelId));
|
||||
if (model && modelRegistry.hasConfiguredAuth(model)) return model;
|
||||
const model = modelRuntime.getModel(provider, modelId) || syntheticModels.get(modelKey(provider, modelId));
|
||||
if (model && modelRuntime.hasConfiguredAuth(provider)) return model;
|
||||
const synthetic = buildSyntheticModel(provider, modelId);
|
||||
return synthetic && modelRegistry.hasConfiguredAuth(synthetic) ? synthetic : undefined;
|
||||
return synthetic && modelRuntime.hasConfiguredAuth(provider) ? synthetic : undefined;
|
||||
}
|
||||
|
||||
function addModelInfo(merged: ModelInfo[], seen: Set<string>, model: Model<Api>, scoped = false) {
|
||||
@@ -836,10 +892,10 @@ function scopedModelKeys(settings: any): Set<string> {
|
||||
}
|
||||
|
||||
async function getRegistryModels(): Promise<ModelInfo[]> {
|
||||
refreshModelConfigIfChanged();
|
||||
await refreshModelConfigIfChanged();
|
||||
const settings = readAgentSettings();
|
||||
const available = await modelRegistry.getAvailable();
|
||||
const configured = available.filter((m) => modelRegistry.hasConfiguredAuth(m));
|
||||
const available = await modelRuntime.getAvailable();
|
||||
const configured = available.filter((m) => modelRuntime.hasConfiguredAuth(m.provider));
|
||||
const source = configured.length > 0 ? configured : available;
|
||||
const merged: ModelInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -946,10 +1002,10 @@ async function resolveInitialWebUiModel(): Promise<Model<Api> | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
const available = await modelRegistry.getAvailable();
|
||||
return available.find((m) => m.provider === "local" && modelRegistry.hasConfiguredAuth(m))
|
||||
|| available.find((m) => m.provider !== "anthropic" && modelRegistry.hasConfiguredAuth(m))
|
||||
|| available.find((m) => modelRegistry.hasConfiguredAuth(m));
|
||||
const available = await modelRuntime.getAvailable();
|
||||
return available.find((m) => m.provider === "local" && modelRuntime.hasConfiguredAuth(m.provider))
|
||||
|| available.find((m) => m.provider !== "anthropic" && modelRuntime.hasConfiguredAuth(m.provider))
|
||||
|| available.find((m) => modelRuntime.hasConfiguredAuth(m.provider));
|
||||
}
|
||||
|
||||
const requestChoiceParams = Type.Object({
|
||||
@@ -996,8 +1052,21 @@ function createRequestChoiceTool(onChoiceRequest: (request: ChoiceRequest, signa
|
||||
});
|
||||
}
|
||||
|
||||
function assertSessionResources(session: AgentSession, resourceLoader: DefaultResourceLoader) {
|
||||
const requiredToolNames = ["read", "bash", "edit", "write", "request-choice"];
|
||||
const activeToolNames = new Set(session.getActiveToolNames());
|
||||
const missingTools = requiredToolNames.filter((name) => !activeToolNames.has(name));
|
||||
if (missingTools.length > 0) throw new Error(`Session startup did not activate these tools: ${missingTools.join(", ")}`);
|
||||
|
||||
const prompt = session.agent.state.systemPrompt;
|
||||
const missingSkills = resourceLoader.getSkills().skills
|
||||
.filter((skill) => !skill.disableModelInvocation && !prompt.includes(`<name>${skill.name}</name>`))
|
||||
.map((skill) => skill.name);
|
||||
if (missingSkills.length > 0) throw new Error(`Session startup did not advertise these skills: ${missingSkills.join(", ")}`);
|
||||
}
|
||||
|
||||
async function createWebUiSession(sessionManager: SessionManager, onChoiceRequest: (request: ChoiceRequest, signal?: AbortSignal) => Promise<string[]>) {
|
||||
refreshModelConfigIfChanged();
|
||||
await refreshModelConfigIfChanged();
|
||||
const restoredModel = restorableSessionModel(sessionManager);
|
||||
const initialModel = restoredModel ? undefined : await resolveInitialWebUiModel();
|
||||
const initialThinkingLevel = restoredModel ? undefined : defaultThinkingLevelFromSettings();
|
||||
@@ -1013,8 +1082,7 @@ async function createWebUiSession(sessionManager: SessionManager, onChoiceReques
|
||||
const result = await createAgentSession({
|
||||
cwd: HOME_DIR,
|
||||
agentDir: AGENT_DIR,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
modelRuntime,
|
||||
sessionManager,
|
||||
model: initialModel,
|
||||
thinkingLevel: initialThinkingLevel,
|
||||
@@ -1023,7 +1091,7 @@ async function createWebUiSession(sessionManager: SessionManager, onChoiceReques
|
||||
resourceLoader,
|
||||
});
|
||||
if (!restoredModel) applyDefaultThinkingLevel(result.session);
|
||||
result.session.setActiveToolsByName([...new Set([...result.session.getActiveToolNames(), ...customTools.map((tool) => tool.name)])]);
|
||||
assertSessionResources(result.session, resourceLoader);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1078,16 +1146,18 @@ function setupSessionEvents(ws: WebSocket, runtime: ClientRuntime) {
|
||||
const safeEvent = safeSerializeEvent(event);
|
||||
send(ws, { type: "agentEvent", event: safeEvent });
|
||||
|
||||
if (event.type === "message_end" || event.type === "turn_end") {
|
||||
clearSessionListCache();
|
||||
if (event.type === "message_end" || event.type === "turn_end") clearSessionListCache();
|
||||
if (event.type === "message_end" && runtime.refreshSessionsWhenPersisted) {
|
||||
setImmediate(() => {
|
||||
if (clients.get(ws) !== runtime || !runtime.session.sessionFile || !fs.existsSync(runtime.session.sessionFile)) return;
|
||||
runtime.refreshSessionsWhenPersisted = false;
|
||||
listRecentSessions().then((sessionPage) => {
|
||||
if (clients.get(ws) === runtime) send(ws, { type: "sessions", sessions: sessionPage.items, currentSessionId: runtime.session.sessionId, offset: sessionPage.offset, limit: sessionPage.limit, total: sessionPage.total, hasMore: sessionPage.hasMore, query: sessionPage.query });
|
||||
}).catch((err) => console.error("Failed to refresh sessions after persistence:", err));
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "agent_start" ||
|
||||
event.type === "agent_end" ||
|
||||
event.type === "message_end" ||
|
||||
event.type === "turn_end"
|
||||
) {
|
||||
if (["agent_start", "agent_end", "message_end", "turn_end", "compaction_end"].includes(event.type)) {
|
||||
const state = event.type === "agent_end"
|
||||
? serializeState(runtime.session, { isStreaming: false, streamingMessage: null })
|
||||
: serializeState(runtime.session);
|
||||
@@ -1108,9 +1178,10 @@ function isClientMessage(value: any): value is ClientMessage {
|
||||
case "setThinkingLevel":
|
||||
return typeof value.level === "string";
|
||||
case "loadSession":
|
||||
return typeof value.sessionPath === "string";
|
||||
case "deleteSession":
|
||||
return typeof value.sessionPath === "string" || typeof value.sessionId === "string";
|
||||
case "renameSession":
|
||||
return typeof value.sessionPath === "string" && typeof value.name === "string" && value.name.length <= 200;
|
||||
return (typeof value.sessionPath === "string" || typeof value.sessionId === "string") && typeof value.name === "string" && value.name.length <= 200;
|
||||
case "choiceResponse":
|
||||
return typeof value.requestId === "string" && value.requestId.length <= 200 && Array.isArray(value.selected) && value.selected.length <= 8 && value.selected.every((item: any) => typeof item === "string" && item.length <= 80);
|
||||
case "getMessages":
|
||||
@@ -1186,7 +1257,7 @@ async function handleClientMessage(ws: WebSocket, msg: ClientMessage) {
|
||||
break;
|
||||
}
|
||||
case "setModel": {
|
||||
refreshModelConfigIfChanged();
|
||||
await refreshModelConfigIfChanged();
|
||||
let model: Model<Api> | undefined;
|
||||
for (const candidate of buildModelLookupCandidates(msg.provider, msg.modelId)) {
|
||||
model = findModel(candidate.provider, candidate.modelId);
|
||||
@@ -1231,7 +1302,10 @@ async function handleClientMessage(ws: WebSocket, msg: ClientMessage) {
|
||||
|
||||
clearPendingChoices(ws, runtime, "Session changed");
|
||||
const { session: newSession } = await createWebUiSession(SessionManager.create(HOME_DIR, getSessionDir(HOME_DIR)), (request, signal) => waitForChoiceResponse(ws, runtime.pendingChoices!, request, signal));
|
||||
const previousSession = runtime.session;
|
||||
runtime.session = newSession;
|
||||
runtime.refreshSessionsWhenPersisted = true;
|
||||
previousSession.dispose();
|
||||
session = runtime.session;
|
||||
clearSessionListCache();
|
||||
setupSessionEvents(ws, runtime);
|
||||
@@ -1251,49 +1325,84 @@ async function handleClientMessage(ws: WebSocket, msg: ClientMessage) {
|
||||
break;
|
||||
}
|
||||
case "renameSession": {
|
||||
const requestedPath = resolveSessionPath(msg.sessionPath);
|
||||
if (!requestedPath || !fs.existsSync(requestedPath)) {
|
||||
const requestedPath = await resolveRequestedSessionPath(msg);
|
||||
if (!requestedPath) {
|
||||
send(ws, { type: "error", message: "Session not found or outside the configured session directory" });
|
||||
return;
|
||||
}
|
||||
const name = msg.name.trim().replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").slice(0, 120);
|
||||
const currentSessionFile = session.sessionFile || "";
|
||||
const renamingCurrent = currentSessionFile !== "" && path.resolve(requestedPath) === path.resolve(currentSessionFile);
|
||||
if (renamingCurrent) {
|
||||
(session as any).setSessionName(name);
|
||||
} else {
|
||||
const manager = SessionManager.open(requestedPath, getSessionDir(HOME_DIR), HOME_DIR);
|
||||
manager.appendSessionInfo(name);
|
||||
}
|
||||
clearSessionListCache();
|
||||
if (renamingCurrent) send(ws, { type: "stateSync", state: serializeState(session) });
|
||||
await withSessionPathLock(requestedPath, async () => {
|
||||
if (!fs.existsSync(requestedPath)) throw new Error("Session not found or outside the configured session directory");
|
||||
const name = msg.name.trim().replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").slice(0, 120);
|
||||
const currentSessionFile = session.sessionFile || "";
|
||||
const renamingCurrent = currentSessionFile !== "" && path.resolve(requestedPath) === path.resolve(currentSessionFile);
|
||||
if (renamingCurrent) {
|
||||
(session as any).setSessionName(name);
|
||||
} else {
|
||||
SessionManager.open(requestedPath, getSessionDir(HOME_DIR), HOME_DIR).appendSessionInfo(name);
|
||||
}
|
||||
clearSessionListCache();
|
||||
if (renamingCurrent) send(ws, { type: "stateSync", state: serializeState(session) });
|
||||
});
|
||||
const sessionPage = await listRecentSessions();
|
||||
send(ws, { type: "sessions", sessions: sessionPage.items, currentSessionId: session.sessionId, offset: sessionPage.offset, limit: sessionPage.limit, total: sessionPage.total, hasMore: sessionPage.hasMore, query: sessionPage.query });
|
||||
break;
|
||||
}
|
||||
case "loadSession": {
|
||||
if (session.isStreaming) {
|
||||
await session.abort();
|
||||
}
|
||||
|
||||
const requestedPath = resolveSessionPath(msg.sessionPath);
|
||||
if (session.isStreaming) await session.abort();
|
||||
const requestedPath = await resolveRequestedSessionPath(msg);
|
||||
if (!requestedPath) {
|
||||
send(ws, { type: "error", message: "Refusing to load session outside the configured session directory" });
|
||||
send(ws, { type: "error", message: "Session not found or outside the configured session directory" });
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedManager = SessionManager.open(requestedPath, getSessionDir(HOME_DIR), HOME_DIR);
|
||||
clearPendingChoices(ws, runtime, "Session changed");
|
||||
const { session: loadedSession } = await createWebUiSession(loadedManager, (request, signal) => waitForChoiceResponse(ws, runtime.pendingChoices!, request, signal));
|
||||
runtime.session = loadedSession;
|
||||
session = runtime.session;
|
||||
clearSessionListCache();
|
||||
setupSessionEvents(ws, runtime);
|
||||
send(ws, { type: "sessionChanged", sessionId: session.sessionId });
|
||||
send(ws, { type: "stateSync", state: serializeState(session) });
|
||||
await withSessionPathLock(requestedPath, async () => {
|
||||
if (!fs.existsSync(requestedPath)) throw new Error("Session not found or outside the configured session directory");
|
||||
const loadedManager = SessionManager.open(requestedPath, getSessionDir(HOME_DIR), HOME_DIR);
|
||||
clearPendingChoices(ws, runtime, "Session changed");
|
||||
const { session: loadedSession } = await createWebUiSession(loadedManager, (request, signal) => waitForChoiceResponse(ws, runtime.pendingChoices!, request, signal));
|
||||
const previousSession = runtime.session;
|
||||
runtime.session = loadedSession;
|
||||
previousSession.dispose();
|
||||
session = runtime.session;
|
||||
clearSessionListCache();
|
||||
setupSessionEvents(ws, runtime);
|
||||
send(ws, { type: "sessionChanged", sessionId: session.sessionId });
|
||||
send(ws, { type: "stateSync", state: serializeState(session) });
|
||||
});
|
||||
console.log(`Loaded session: ${session.sessionId}`);
|
||||
break;
|
||||
}
|
||||
case "deleteSession": {
|
||||
const requestedPath = await resolveRequestedSessionPath(msg);
|
||||
if (!requestedPath) {
|
||||
send(ws, { type: "error", message: "Session not found or outside the configured session directory" });
|
||||
return;
|
||||
}
|
||||
await withSessionPathLock(requestedPath, async () => {
|
||||
if (!fs.existsSync(requestedPath)) throw new Error("Session not found or outside the configured session directory");
|
||||
const canonicalTarget = path.resolve(requestedPath);
|
||||
const usedByAnotherClient = [...clients.entries()].some(([client, other]) => client !== ws && other.session.sessionFile && path.resolve(other.session.sessionFile) === canonicalTarget);
|
||||
if (usedByAnotherClient) throw new Error("Cannot delete a conversation that is active in another client");
|
||||
const deletingCurrent = Boolean(session.sessionFile) && canonicalTarget === path.resolve(session.sessionFile!);
|
||||
if (deletingCurrent && session.isStreaming) await session.abort();
|
||||
await fs.promises.unlink(requestedPath);
|
||||
clearSessionListCache();
|
||||
if (deletingCurrent) {
|
||||
clearPendingChoices(ws, runtime, "Session deleted");
|
||||
const { session: newSession } = await createWebUiSession(SessionManager.create(HOME_DIR, getSessionDir(HOME_DIR)), (request, signal) => waitForChoiceResponse(ws, runtime.pendingChoices!, request, signal));
|
||||
const previousSession = runtime.session;
|
||||
runtime.session = newSession;
|
||||
runtime.refreshSessionsWhenPersisted = true;
|
||||
previousSession.dispose();
|
||||
session = newSession;
|
||||
setupSessionEvents(ws, runtime);
|
||||
send(ws, { type: "sessionChanged", sessionId: session.sessionId });
|
||||
send(ws, { type: "stateSync", state: serializeState(session) });
|
||||
}
|
||||
});
|
||||
const sessionPage = await listRecentSessions();
|
||||
send(ws, { type: "sessions", sessions: sessionPage.items, currentSessionId: session.sessionId, offset: sessionPage.offset, limit: sessionPage.limit, total: sessionPage.total, hasMore: sessionPage.hasMore, query: sessionPage.query });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Error handling ${msg.type}:`, err);
|
||||
@@ -1370,38 +1479,69 @@ async function main() {
|
||||
throw new Error("PI_WEBUI_TOKEN is required. Anonymous mode is allowed only when PI_WEBUI_ALLOW_ANONYMOUS=true and HOST is loopback.");
|
||||
}
|
||||
|
||||
authStorage = AuthStorage.create(path.join(AGENT_DIR, "auth.json"));
|
||||
modelRegistry = ModelRegistry.create(authStorage, path.join(AGENT_DIR, "models.json"));
|
||||
modelRuntime = await ModelRuntime.create({ authPath: AGENT_AUTH_PATH, modelsPath: AGENT_MODELS_PATH });
|
||||
modelAuthStamp = fileStamp(AGENT_AUTH_PATH);
|
||||
modelConfigStamp = [modelAuthStamp, fileStamp(AGENT_MODELS_PATH), fileStamp(AGENT_SETTINGS_PATH)].join("|");
|
||||
setInterval(() => refreshModelConfigIfChanged().catch((err) => console.error("Model configuration refresh failed:", err)), 2000).unref();
|
||||
|
||||
if (!litellmKey) {
|
||||
const key = await modelRegistry.getApiKeyForProvider("ollama");
|
||||
if (key) litellmKey = key;
|
||||
const auth = await modelRuntime.getAuth("ollama");
|
||||
if (auth?.auth.apiKey) litellmKey = auth.auth.apiKey;
|
||||
}
|
||||
|
||||
wss.on("connection", async (ws, request) => {
|
||||
console.log(`Client connected (${clients.size + 1} total)`);
|
||||
let closed = false;
|
||||
let startingSession: AgentSession | undefined;
|
||||
ws.once("close", () => {
|
||||
closed = true;
|
||||
const runtime = clients.get(ws);
|
||||
if (runtime?.unsubscribe) runtime.unsubscribe();
|
||||
if (runtime) clearPendingChoices(ws, runtime, "Client disconnected");
|
||||
const activeSession = runtime?.session || startingSession;
|
||||
if (activeSession) {
|
||||
const aborted = activeSession.isStreaming
|
||||
? activeSession.abort().catch((err: any) => console.error("Abort on disconnect failed:", err))
|
||||
: Promise.resolve();
|
||||
aborted.then(() => activeSession.dispose()).catch((err: any) => console.warn("Session dispose failed:", err));
|
||||
}
|
||||
clients.delete(ws);
|
||||
console.log(`Client disconnected (${clients.size} total)`);
|
||||
});
|
||||
|
||||
try {
|
||||
const url = new URL(request.url || "/api/ws", `http://${request.headers.host || "localhost"}`);
|
||||
const sessionManager = sessionManagerForResumePath(url.searchParams.get("sessionPath") || undefined);
|
||||
const requestedPath = url.searchParams.get("sessionPath") || await resolveSessionPathById(url.searchParams.get("session") || url.searchParams.get("sessionId") || undefined);
|
||||
const pendingChoices = new Map<string, PendingChoiceRequest>();
|
||||
const { session, modelFallbackMessage } = await createWebUiSession(sessionManager, (request, signal) => waitForChoiceResponse(ws, pendingChoices, request, signal));
|
||||
const runtime: ClientRuntime = { session, pendingChoices };
|
||||
clients.set(ws, runtime);
|
||||
|
||||
if (modelFallbackMessage) {
|
||||
console.log("Model fallback:", modelFallbackMessage);
|
||||
}
|
||||
const createRuntime = async () => {
|
||||
const sessionManager = sessionManagerForResumePath(requestedPath || undefined);
|
||||
const result = await createWebUiSession(sessionManager, (request, signal) => waitForChoiceResponse(ws, pendingChoices, request, signal));
|
||||
startingSession = result.session;
|
||||
if (closed) {
|
||||
startingSession.dispose();
|
||||
startingSession = undefined;
|
||||
return undefined;
|
||||
}
|
||||
const runtime: ClientRuntime = { session: startingSession, pendingChoices, refreshSessionsWhenPersisted: !startingSession.sessionFile };
|
||||
clients.set(ws, runtime);
|
||||
startingSession = undefined;
|
||||
return { ...result, runtime };
|
||||
};
|
||||
const created = requestedPath ? await withSessionPathLock(requestedPath, createRuntime) : await createRuntime();
|
||||
if (!created) return;
|
||||
const { session, modelFallbackMessage, runtime } = created;
|
||||
|
||||
if (modelFallbackMessage) console.log("Model fallback:", modelFallbackMessage);
|
||||
console.log(`Client session ${session.sessionId}: ${session.model?.provider}/${session.model?.id}, thinking ${session.thinkingLevel}, tools ${session.getActiveToolNames().join(", ")}`);
|
||||
setupSessionEvents(ws, runtime);
|
||||
|
||||
send(ws, { type: "ready" });
|
||||
send(ws, { type: "stateSync", state: serializeState(session) });
|
||||
} catch (err: any) {
|
||||
console.error("Failed to create client session:", err);
|
||||
send(ws, { type: "error", message: err.message || String(err) });
|
||||
ws.close();
|
||||
if (!closed) {
|
||||
send(ws, { type: "error", message: err.message || String(err) });
|
||||
ws.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1419,16 +1559,6 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
const runtime = clients.get(ws);
|
||||
if (runtime?.unsubscribe) runtime.unsubscribe();
|
||||
if (runtime) clearPendingChoices(ws, runtime, "Client disconnected");
|
||||
if (runtime?.session.isStreaming) {
|
||||
runtime.session.abort().catch((err: any) => console.error("Abort on disconnect failed:", err));
|
||||
}
|
||||
clients.delete(ws);
|
||||
console.log(`Client disconnected (${clients.size} total)`);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
|
||||
+5
-2
@@ -12,8 +12,9 @@ export type ClientMessage =
|
||||
| { type: "newSession" }
|
||||
| { type: "getSessions"; offset?: number; limit?: number; query?: string }
|
||||
| { type: "getMessages"; offset: number; limit?: number }
|
||||
| { type: "loadSession"; sessionPath: string }
|
||||
| { type: "renameSession"; sessionPath: string; name: string }
|
||||
| { type: "loadSession"; sessionPath?: string; sessionId?: string }
|
||||
| { type: "deleteSession"; sessionPath?: string; sessionId?: string }
|
||||
| { type: "renameSession"; sessionPath?: string; sessionId?: string; name: string }
|
||||
| { type: "choiceResponse"; requestId: string; selected: string[] };
|
||||
|
||||
// ── Server → Client messages ──
|
||||
@@ -60,6 +61,8 @@ export interface SerializedAgentState {
|
||||
streamingMessage?: any;
|
||||
errorMessage?: string;
|
||||
tools: string[];
|
||||
cwd?: string;
|
||||
appTitle?: string;
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
sessionPath?: string;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
defineTool,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "@earendil-works/pi-ai";
|
||||
|
||||
const serverSource = fs.readFileSync(fileURLToPath(new URL("../server/index.ts", import.meta.url)), "utf8");
|
||||
const factorySource = serverSource.slice(serverSource.indexOf("async function createWebUiSession"), serverSource.indexOf("function waitForChoiceResponse"));
|
||||
|
||||
test("session factory keeps default tools and request-choice", () => {
|
||||
assert.match(factorySource, /customTools,/);
|
||||
assert.doesNotMatch(factorySource, /\btools\s*:/);
|
||||
assert.doesNotMatch(factorySource, /setActiveToolsByName/);
|
||||
assert.match(factorySource, /assertSessionResources\(result\.session, resourceLoader\)/);
|
||||
});
|
||||
|
||||
test("session activates default and custom tools and advertises skills", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-session-resources-"));
|
||||
try {
|
||||
const cwd = path.join(root, "home");
|
||||
const agentDir = path.join(root, "agent");
|
||||
const skillDir = path.join(agentDir, "skills", "fixture-skill");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: fixture-skill\ndescription: Fixture skill for session resource checks.\n---\n\n# Fixture\n");
|
||||
|
||||
const settingsManager = SettingsManager.inMemory();
|
||||
const resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
|
||||
await resourceLoader.reload();
|
||||
const requestChoice = defineTool({
|
||||
name: "request-choice",
|
||||
label: "request-choice",
|
||||
description: "Return a fixture result.",
|
||||
parameters: Type.Object({}),
|
||||
async execute() {
|
||||
return { content: [{ type: "text", text: "ok" }], details: {} };
|
||||
},
|
||||
});
|
||||
const { session } = await createAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
customTools: [requestChoice],
|
||||
resourceLoader,
|
||||
sessionManager: SessionManager.inMemory(cwd),
|
||||
settingsManager,
|
||||
});
|
||||
for (const name of ["read", "bash", "edit", "write", "request-choice"]) {
|
||||
assert.ok(session.getActiveToolNames().includes(name), `expected active tool: ${name}`);
|
||||
}
|
||||
assert.match(session.agent.state.systemPrompt, /<name>fixture-skill<\/name>/);
|
||||
session.dispose();
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { summarizeToolResultData } from "../client/tool-result-summary.ts";
|
||||
|
||||
test("summarizes generic result counts and pagination", () => {
|
||||
assert.equal(summarizeToolResultData({ items: [{}, {}], page: { has_more: true } }, false, 20), "2 items · more available");
|
||||
assert.equal(summarizeToolResultData({ results: { one: { rows: [{}] } }, truncated: true }, false, 20), "1 item · truncated");
|
||||
});
|
||||
|
||||
test("summarizes errors without exposing an unbounded error code", () => {
|
||||
const code = "x".repeat(100);
|
||||
assert.equal(summarizeToolResultData({ error: code }, false, 0), `error: ${"x".repeat(80)}`);
|
||||
assert.equal(summarizeToolResultData({}, true, 0), "error");
|
||||
});
|
||||
|
||||
test("uses output size only when structured data has no summary", () => {
|
||||
assert.equal(summarizeToolResultData({}, false, 1234), "1,234 chars");
|
||||
assert.equal(summarizeToolResultData({}, false, 0), "no output");
|
||||
});
|
||||
Reference in new issue
Block a user