Backport generic session and SDK improvements

This commit is contained in:
udo committed 2026-08-05 12:41:55 +00:00
1 parent b034988dd4
commit 80b9cc1547
11 files changed
+2902 -2619

No files matched your search

+3 -3
View File
@@ -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
+13 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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">
+29
View File
@@ -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";
}
+2339 -2466
View File
File diff suppressed because it is too large. Load diff
+10 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+64
View File
@@ -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 });
}
});
+19
View File
@@ -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");
});