Archived
Add paged chat history loading
This commit is contained in:
+4
-4
@@ -34,10 +34,10 @@ app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces"
|
||||
}
|
||||
});
|
||||
|
||||
await registerSessionProxyRoutes(app);
|
||||
registerSessionProxyRoutes(app);
|
||||
|
||||
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other" } }>("/api/files", async (request, reply) => {
|
||||
if (!request.query.cwd) return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
try {
|
||||
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind);
|
||||
} catch (error) {
|
||||
@@ -51,6 +51,6 @@ if (existsSync(clientDist)) {
|
||||
app.setNotFoundHandler((_request, reply) => reply.sendFile("index.html"));
|
||||
}
|
||||
|
||||
const port = Number(process.env.PI_WEB_PORT ?? process.env.PORT ?? 3000);
|
||||
const host = process.env.PI_WEB_HOST ?? "127.0.0.1";
|
||||
const port = Number(process.env["PI_WEB_PORT"] ?? process.env["PORT"] ?? 3000);
|
||||
const host = process.env["PI_WEB_HOST"] ?? "127.0.0.1";
|
||||
await app.listen({ port, host });
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ProjectService {
|
||||
const resolved = await realpath(input.path);
|
||||
const s = await stat(resolved);
|
||||
if (!s.isDirectory()) throw new Error("Project path must be a directory");
|
||||
return this.store.add({ name: input.name, path: resolved });
|
||||
return this.store.add(input.name === undefined ? { path: resolved } : { name: input.name, path: resolved });
|
||||
}
|
||||
|
||||
async requireProject(id: string): Promise<Project> {
|
||||
|
||||
@@ -11,7 +11,9 @@ export class SessionEventHub {
|
||||
this.socketsBySession.set(sessionId, sockets);
|
||||
}
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets?.delete(socket));
|
||||
socket.on("close", () => {
|
||||
sockets.delete(socket);
|
||||
});
|
||||
}
|
||||
|
||||
addGlobal(socket: WebSocket): void {
|
||||
|
||||
@@ -12,12 +12,13 @@ await app.register(fastifyWebsocket);
|
||||
|
||||
const eventHub = new SessionEventHub();
|
||||
const sessions = new PiSessionService(eventHub);
|
||||
await registerSessionRoutes(app, sessions, eventHub);
|
||||
registerSessionRoutes(app, sessions, eventHub);
|
||||
|
||||
const port = process.env.PI_WEB_SESSIOND_PORT ? Number(process.env.PI_WEB_SESSIOND_PORT) : undefined;
|
||||
const host = process.env.PI_WEB_SESSIOND_HOST ?? "127.0.0.1";
|
||||
const portValue = process.env["PI_WEB_SESSIOND_PORT"];
|
||||
const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined;
|
||||
const host = process.env["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1";
|
||||
|
||||
if (port) {
|
||||
if (port !== undefined) {
|
||||
await app.listen({ port, host });
|
||||
} else {
|
||||
const path = sessiondSocketPath();
|
||||
|
||||
@@ -2,9 +2,9 @@ import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export function sessiondSocketPath(): string {
|
||||
return process.env.PI_WEB_SESSIOND_SOCKET ?? join(homedir(), ".pi-web", "sessiond.sock");
|
||||
return process.env["PI_WEB_SESSIOND_SOCKET"] ?? join(homedir(), ".pi-web", "sessiond.sock");
|
||||
}
|
||||
|
||||
export function sessiondHttpUrl(): string | undefined {
|
||||
return process.env.PI_WEB_SESSIOND_URL;
|
||||
return process.env["PI_WEB_SESSIOND_URL"];
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ export class SessionDaemonClient {
|
||||
|
||||
async request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
if (this.baseUrl) return this.requestUrl(method, path, payload);
|
||||
if (this.baseUrl !== undefined && this.baseUrl !== "") return this.requestUrl(method, path, payload);
|
||||
return this.requestSocket(method, path, payload);
|
||||
}
|
||||
|
||||
connectWebSocket(path: string): WebSocket {
|
||||
if (this.baseUrl) {
|
||||
if (this.baseUrl !== undefined && this.baseUrl !== "") {
|
||||
const url = new URL(path, this.baseUrl);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return new WebSocket(url);
|
||||
@@ -22,11 +22,12 @@ export class SessionDaemonClient {
|
||||
}
|
||||
|
||||
private async requestUrl(method: string, path: string, payload?: string) {
|
||||
const response = await fetch(new URL(path, this.baseUrl), {
|
||||
method,
|
||||
headers: payload ? { "content-type": "application/json" } : undefined,
|
||||
body: payload,
|
||||
});
|
||||
const init: RequestInit = { method };
|
||||
if (payload !== undefined && payload !== "") {
|
||||
init.headers = { "content-type": "application/json" };
|
||||
init.body = payload;
|
||||
}
|
||||
const response = await fetch(new URL(path, this.baseUrl), init);
|
||||
return {
|
||||
statusCode: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
@@ -41,13 +42,15 @@ export class SessionDaemonClient {
|
||||
socketPath: this.socketPath,
|
||||
path,
|
||||
method,
|
||||
headers: payload
|
||||
headers: payload !== undefined && payload !== ""
|
||||
? { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }
|
||||
: undefined,
|
||||
},
|
||||
(response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||
const chunks: Uint8Array[] = [];
|
||||
response.on("data", (chunk: Buffer | string) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
response.on("end", () => {
|
||||
resolve({
|
||||
statusCode: response.statusCode ?? 500,
|
||||
@@ -58,7 +61,7 @@ export class SessionDaemonClient {
|
||||
},
|
||||
);
|
||||
request.on("error", reject);
|
||||
if (payload) request.write(payload);
|
||||
if (payload !== undefined && payload !== "") request.write(payload);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,15 +2,17 @@ import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import { WebSocket, type RawData } from "ws";
|
||||
import { SessionDaemonClient } from "./sessionDaemonClient.js";
|
||||
|
||||
export async function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): Promise<void> {
|
||||
export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): void {
|
||||
const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => {
|
||||
try {
|
||||
const upstream = await daemon.request(request.method, stripApiPrefix(request.url), request.body);
|
||||
reply.code(upstream.statusCode);
|
||||
if (upstream.headers["content-type"]) reply.header("content-type", upstream.headers["content-type"]);
|
||||
return upstream.body ? JSON.parse(upstream.body) : undefined;
|
||||
const contentType = upstream.headers["content-type"];
|
||||
if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType);
|
||||
return upstream.body !== "" ? parseJson(upstream.body) : undefined;
|
||||
} catch (error) {
|
||||
requestFailed(reply, error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -36,22 +38,30 @@ export async function registerSessionProxyRoutes(app: FastifyInstance, daemon =
|
||||
}
|
||||
|
||||
function stripApiPrefix(url: string): string {
|
||||
return url.startsWith("/api") ? url.slice(4) || "/" : url;
|
||||
const stripped = url.startsWith("/api") ? url.slice(4) : url;
|
||||
return stripped === "" ? "/" : stripped;
|
||||
}
|
||||
|
||||
function requestFailed(reply: FastifyReply, error: unknown) {
|
||||
function parseJson(text: string): unknown {
|
||||
const value: unknown = JSON.parse(text);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requestFailed(reply: FastifyReply, error: unknown): void {
|
||||
reply.code(502).send({ error: `Session daemon unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
||||
}
|
||||
|
||||
function bridgeSockets(client: WebSocket, upstream: WebSocket): void {
|
||||
client.on("message", (data) => sendIfOpen(upstream, data));
|
||||
upstream.on("message", (data) => sendIfOpen(client, data));
|
||||
client.on("close", () => upstream.close());
|
||||
upstream.on("close", () => client.close());
|
||||
upstream.on("error", () => client.close());
|
||||
client.on("error", () => upstream.close());
|
||||
client.on("message", (data) => { sendIfOpen(upstream, data); });
|
||||
upstream.on("message", (data) => { sendIfOpen(client, data); });
|
||||
client.on("close", () => { upstream.close(); });
|
||||
upstream.on("close", () => { client.close(); });
|
||||
upstream.on("error", () => { client.close(); });
|
||||
client.on("error", () => { upstream.close(); });
|
||||
}
|
||||
|
||||
function sendIfOpen(socket: WebSocket, data: RawData): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(data);
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,18 @@ import {
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
type AgentSession,
|
||||
type AgentSessionRuntime,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import type { ClientCommand, ClientCommandResult, ClientSession, ClientSessionStatus } from "../types.js";
|
||||
import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionStatus } from "../types.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||
import { SessionCommandService } from "./sessionCommandService.js";
|
||||
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||
|
||||
function noop(): void {
|
||||
// Intentionally empty default unsubscribe callback.
|
||||
}
|
||||
|
||||
export class PiSessionService {
|
||||
private readonly active = new Map<string, ActiveSession>();
|
||||
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
|
||||
@@ -26,12 +29,15 @@ export class PiSessionService {
|
||||
private readonly modelRegistry = ModelRegistry.create(this.authStorage);
|
||||
private readonly createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
||||
const services = await createAgentSessionServices({ cwd, agentDir, authStorage: this.authStorage, modelRegistry: this.modelRegistry });
|
||||
const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent });
|
||||
const options = sessionStartEvent === undefined
|
||||
? { services, sessionManager }
|
||||
: { services, sessionManager, sessionStartEvent };
|
||||
const result = await createAgentSessionFromServices(options);
|
||||
return { ...result, services, diagnostics: services.diagnostics };
|
||||
};
|
||||
|
||||
constructor(private readonly events: SessionEventHub) {
|
||||
this.heartbeat = setInterval(() => this.publishHeartbeats(), 2000);
|
||||
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, 2000);
|
||||
this.commandService = new SessionCommandService(
|
||||
(sessionId) => this.getActive(sessionId),
|
||||
(sessionId, text) => this.prompt(sessionId, text),
|
||||
@@ -45,7 +51,7 @@ export class PiSessionService {
|
||||
id: s.id,
|
||||
path: s.path,
|
||||
cwd: s.cwd,
|
||||
name: s.name,
|
||||
...(s.name === undefined ? {} : { name: s.name }),
|
||||
created: s.created.toISOString(),
|
||||
modified: s.modified.toISOString(),
|
||||
messageCount: s.messageCount,
|
||||
@@ -67,9 +73,15 @@ export class PiSessionService {
|
||||
};
|
||||
}
|
||||
|
||||
async messages(sessionId: string): Promise<unknown[]> {
|
||||
async messages(sessionId: string, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
return session.messages;
|
||||
const messages = historyMessages(session);
|
||||
if (page?.before === undefined && page?.limit === undefined) return messages;
|
||||
const total = messages.length;
|
||||
const before = clampInteger(page.before ?? total, 0, total);
|
||||
const limit = clampInteger(page.limit ?? 100, 1, 500);
|
||||
const start = Math.max(0, before - limit);
|
||||
return { messages: messages.slice(start, before), start, total };
|
||||
}
|
||||
|
||||
async status(sessionId: string): Promise<ClientSessionStatus> {
|
||||
@@ -80,7 +92,7 @@ export class PiSessionService {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
const commands: ClientCommand[] = [...BUILTIN_COMMANDS];
|
||||
for (const command of session.extensionRunner.getRegisteredCommands()) {
|
||||
commands.push({ name: command.invocationName, description: command.description, source: "extension" });
|
||||
commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
|
||||
}
|
||||
for (const template of session.promptTemplates) {
|
||||
commands.push({ name: template.name, description: template.description, source: "prompt" });
|
||||
@@ -94,7 +106,7 @@ export class PiSessionService {
|
||||
async prompt(sessionId: string, text: string): Promise<void> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
this.publishActivity(session, "prompt accepted", "active");
|
||||
void session.prompt(text).catch((error) => {
|
||||
void session.prompt(text).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.publishActivity(session, "error", "error", message);
|
||||
this.events.publish(sessionId, { type: "session.error", message });
|
||||
@@ -126,7 +138,7 @@ export class PiSessionService {
|
||||
});
|
||||
this.publishActivity(session, "bash complete", result.exitCode === 0 ? "idle" : "error", command);
|
||||
this.publishStatus(session);
|
||||
}).catch((error) => {
|
||||
}).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.events.publish(session.sessionId, { type: "shell.end", output: message, isError: true });
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
@@ -172,9 +184,12 @@ export class PiSessionService {
|
||||
|
||||
private async create(sessionManager: SessionManager, cwd: string): Promise<ActiveSession> {
|
||||
const runtime = await createAgentSessionRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
|
||||
const active: ActiveSession = { runtime, unsubscribe: () => {} };
|
||||
const active: ActiveSession = { runtime, unsubscribe: noop };
|
||||
this.bindRuntime(active);
|
||||
runtime.setRebindSession(async () => this.bindRuntime(active));
|
||||
runtime.setRebindSession(() => {
|
||||
this.bindRuntime(active);
|
||||
return Promise.resolve();
|
||||
});
|
||||
this.active.set(runtime.session.sessionId, active);
|
||||
this.publishStatus(runtime.session);
|
||||
return active;
|
||||
@@ -214,9 +229,11 @@ export class PiSessionService {
|
||||
return "active";
|
||||
}
|
||||
|
||||
private publishActivityForEvent(session: AgentSession, event: any): void {
|
||||
if (event.type === "agent_start") return this.publishActivity(session, "agent running", "active");
|
||||
if (event.type === "agent_end") {
|
||||
private publishActivityForEvent(session: AgentSession, event: unknown): void {
|
||||
const eventType = getString(event, "type");
|
||||
if (eventType === undefined) return;
|
||||
if (eventType === "agent_start") { this.publishActivity(session, "agent running", "active"); return; }
|
||||
if (eventType === "agent_end") {
|
||||
this.publishActivity(session, "idle", "idle");
|
||||
setTimeout(() => {
|
||||
this.publishActivity(session, "idle", "idle");
|
||||
@@ -224,21 +241,26 @@ export class PiSessionService {
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
if (event.type === "turn_end") return this.publishActivity(session, "turn complete", "active");
|
||||
if (event.type === "message_start") return this.publishActivity(session, "message started", "active");
|
||||
if (event.type === "message_end") return this.publishActivity(session, "message complete", "idle");
|
||||
if (event.type === "message_update") return this.publishActivity(session, "receiving response", "active");
|
||||
if (event.type === "tool_execution_start") return this.publishActivity(session, "running tool", "active", event.toolName);
|
||||
if (event.type === "tool_execution_end") return this.publishActivity(session, event.isError ? "tool failed" : "tool complete", event.isError ? "error" : "active", event.toolName);
|
||||
if (event.type === "bash_execution_start") return this.publishActivity(session, "running bash", "active");
|
||||
if (event.type === "bash_execution_end") return this.publishActivity(session, "bash complete", "active");
|
||||
this.publishActivity(session, event.type.replaceAll("_", " "), "active");
|
||||
if (eventType === "turn_end") { this.publishActivity(session, "turn complete", "active"); return; }
|
||||
if (eventType === "message_start") { this.publishActivity(session, "message started", "active"); return; }
|
||||
if (eventType === "message_end") { this.publishActivity(session, "message complete", "idle"); return; }
|
||||
if (eventType === "message_update") { this.publishActivity(session, "receiving response", "active"); return; }
|
||||
if (eventType === "tool_execution_start") { this.publishActivity(session, "running tool", "active", getString(event, "toolName")); return; }
|
||||
if (eventType === "tool_execution_end") {
|
||||
const isError = getBoolean(event, "isError") === true;
|
||||
this.publishActivity(session, isError ? "tool failed" : "tool complete", isError ? "error" : "active", getString(event, "toolName"));
|
||||
return;
|
||||
}
|
||||
if (eventType === "bash_execution_start") { this.publishActivity(session, "running bash", "active"); return; }
|
||||
if (eventType === "bash_execution_end") { this.publishActivity(session, "bash complete", "active"); return; }
|
||||
this.publishActivity(session, eventType.replaceAll("_", " "), "active");
|
||||
}
|
||||
|
||||
private publishActivity(session: AgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
|
||||
const at = new Date().toISOString();
|
||||
this.activities.set(session.sessionId, { phase, label, detail, at });
|
||||
const activity = { sessionId: session.sessionId, phase, label, detail, at };
|
||||
const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
|
||||
this.activities.set(session.sessionId, stored);
|
||||
const activity = detail === undefined ? { sessionId: session.sessionId, phase, label, at } : { sessionId: session.sessionId, phase, label, detail, at };
|
||||
this.events.publish(session.sessionId, { type: "activity.update", activity });
|
||||
this.events.publishGlobal({ type: "activity.update", activity });
|
||||
}
|
||||
@@ -251,17 +273,23 @@ export class PiSessionService {
|
||||
|
||||
private statusFromSession(session: AgentSession): ClientSessionStatus {
|
||||
const stats = session.getSessionStats();
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
model: session.model
|
||||
? {
|
||||
const model = session.model === undefined
|
||||
? undefined
|
||||
: (() => {
|
||||
const name = getString(session.model, "name");
|
||||
const reasoning = getProperty(session.model, "reasoning");
|
||||
return {
|
||||
provider: session.model.provider,
|
||||
id: session.model.id,
|
||||
name: (session.model as any).name,
|
||||
...(name === undefined ? {} : { name }),
|
||||
contextWindow: session.model.contextWindow,
|
||||
reasoning: (session.model as any).reasoning,
|
||||
}
|
||||
: undefined,
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
};
|
||||
})();
|
||||
const contextUsage = session.getContextUsage();
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
...(model === undefined ? {} : { model }),
|
||||
thinkingLevel: session.thinkingLevel,
|
||||
isStreaming: session.isStreaming,
|
||||
isCompacting: session.isCompacting,
|
||||
@@ -269,33 +297,54 @@ export class PiSessionService {
|
||||
pendingMessageCount: session.pendingMessageCount,
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
contextUsage: session.getContextUsage(),
|
||||
...(contextUsage === undefined ? {} : { contextUsage }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toClientEvent(event: any): unknown {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
|
||||
return { type: "assistant.delta", text: event.assistantMessageEvent.delta };
|
||||
function historyMessages(session: AgentSession): unknown[] {
|
||||
const messages: unknown[] = [];
|
||||
for (const entry of session.sessionManager.getBranch()) {
|
||||
if (entry.type === "message") messages.push(entry.message);
|
||||
else if (entry.type === "custom_message" && entry.display) messages.push({ role: "custom", content: entry.content, customType: entry.customType, details: entry.details });
|
||||
else if (entry.type === "compaction") messages.push({ role: "system", content: `Compacted history:\n\n${entry.summary}` });
|
||||
else if (entry.type === "branch_summary") messages.push({ role: "system", content: `Branch summary:\n\n${entry.summary}` });
|
||||
}
|
||||
if (event.type === "tool_execution_start") {
|
||||
return { type: "tool.start", toolName: event.toolName, toolCallId: event.toolCallId, summary: summarizeToolArgs(event.args) };
|
||||
}
|
||||
if (event.type === "tool_execution_end") {
|
||||
return { type: "tool.end", toolName: event.toolName, toolCallId: event.toolCallId, text: stringifyToolResult(event.result), isError: event.isError };
|
||||
}
|
||||
if (event.type === "agent_start") return { type: "agent.start" };
|
||||
if (event.type === "agent_end") return { type: "agent.end" };
|
||||
if (event.type === "message_end") return { type: "message.end" };
|
||||
return { type: "pi.event", eventType: event.type };
|
||||
return messages;
|
||||
}
|
||||
|
||||
function summarizeToolArgs(args: any): string {
|
||||
if (!args || typeof args !== "object") return args == null ? "" : String(args);
|
||||
if (typeof args.command === "string") return args.command;
|
||||
if (typeof args.path === "string") return args.path;
|
||||
if (typeof args.oldText === "string" && typeof args.newText === "string") return "edit text replacement";
|
||||
if (Array.isArray(args.edits)) return `${args.edits.length} edit${args.edits.length === 1 ? "" : "s"}`;
|
||||
function clampInteger(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return max;
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
|
||||
function toClientEvent(event: unknown): unknown {
|
||||
const eventType = getString(event, "type");
|
||||
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
|
||||
if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
|
||||
return { type: "assistant.delta", text: getString(assistantMessageEvent, "delta") ?? "" };
|
||||
}
|
||||
if (eventType === "tool_execution_start") {
|
||||
return { type: "tool.start", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", summary: summarizeToolArgs(getProperty(event, "args")) };
|
||||
}
|
||||
if (eventType === "tool_execution_end") {
|
||||
return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(getProperty(event, "result")), isError: getBoolean(event, "isError") === true };
|
||||
}
|
||||
if (eventType === "agent_start") return { type: "agent.start" };
|
||||
if (eventType === "agent_end") return { type: "agent.end" };
|
||||
if (eventType === "message_end") return { type: "message.end" };
|
||||
return { type: "pi.event", eventType: eventType ?? "unknown" };
|
||||
}
|
||||
|
||||
function summarizeToolArgs(args: unknown): string {
|
||||
if (!isRecord(args)) return stringifyPrimitive(args);
|
||||
const command = getString(args, "command");
|
||||
if (command !== undefined) return command;
|
||||
const path = getString(args, "path");
|
||||
if (path !== undefined) return path;
|
||||
if (typeof args["oldText"] === "string" && typeof args["newText"] === "string") return "edit text replacement";
|
||||
const edits = args["edits"];
|
||||
if (Array.isArray(edits)) return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`;
|
||||
const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3);
|
||||
return entries.map(([key, value]) => `${key}: ${shortToolValue(value)}`).join(" · ");
|
||||
}
|
||||
@@ -303,18 +352,43 @@ function summarizeToolArgs(args: any): string {
|
||||
function shortToolValue(value: unknown): string {
|
||||
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}…` : value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
|
||||
if (typeof value === "object" && value) return "object";
|
||||
if (Array.isArray(value)) return `${String(value.length)} item${value.length === 1 ? "" : "s"}`;
|
||||
if (typeof value === "object" && value !== null) return "object";
|
||||
return "";
|
||||
}
|
||||
|
||||
function stringifyToolResult(result: unknown): string {
|
||||
if (typeof result === "string") return result;
|
||||
if (Array.isArray(result)) return result.map(stringifyToolResult).filter(Boolean).join("\n");
|
||||
if (result && typeof result === "object") {
|
||||
const text = (result as any).text ?? (result as any).content ?? (result as any).output;
|
||||
if (typeof text === "string") return text;
|
||||
if (Array.isArray(result)) return result.map(stringifyToolResult).filter((text) => text !== "").join("\n");
|
||||
if (isRecord(result)) {
|
||||
const text = getString(result, "text") ?? getString(result, "content") ?? getString(result, "output");
|
||||
if (text !== undefined) return text;
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
return result == null ? "" : String(result);
|
||||
return stringifyPrimitive(result);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getProperty(value: unknown, key: string): unknown {
|
||||
return isRecord(value) ? value[key] : undefined;
|
||||
}
|
||||
|
||||
function getString(value: unknown, key: string): string | undefined {
|
||||
const property = getProperty(value, key);
|
||||
return typeof property === "string" ? property : undefined;
|
||||
}
|
||||
|
||||
function getBoolean(value: unknown, key: string): boolean | undefined {
|
||||
const property = getProperty(value, key);
|
||||
return typeof property === "boolean" ? property : undefined;
|
||||
}
|
||||
|
||||
function stringifyPrimitive(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -44,26 +44,23 @@ export class SessionCommandService {
|
||||
|
||||
async respond(sessionId: string, requestId: string, value: string): Promise<ClientCommandResult> {
|
||||
const pending = this.pendingSelects.get(requestId);
|
||||
if (!pending || pending.sessionId !== sessionId) return { type: "unsupported", message: "Command request expired" };
|
||||
if (pending?.sessionId !== sessionId) return { type: "unsupported", message: "Command request expired" };
|
||||
this.pendingSelects.delete(requestId);
|
||||
|
||||
const active = await this.getActive(sessionId);
|
||||
if (pending.command === "fork") {
|
||||
const result = await active.runtime.fork(value);
|
||||
if (result.cancelled) return { type: "done", message: "Fork cancelled" };
|
||||
return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime) };
|
||||
}
|
||||
return { type: "unsupported", message: "Unsupported command response" };
|
||||
const result = await active.runtime.fork(value);
|
||||
if (result.cancelled) return { type: "done", message: "Fork cancelled" };
|
||||
return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime) };
|
||||
}
|
||||
|
||||
private nameSession(active: ActiveSession, name: string): ClientCommandResult {
|
||||
if (!name) return { type: "unsupported", message: "Usage: /name <session name>" };
|
||||
if (name === "") return { type: "unsupported", message: "Usage: /name <session name>" };
|
||||
active.runtime.session.setSessionName(name);
|
||||
return { type: "done", message: `Session named: ${name}`, session: clientSessionFromRuntime(active.runtime) };
|
||||
}
|
||||
|
||||
private compact(session: AgentSession, instructions: string): ClientCommandResult {
|
||||
void session.compact(instructions || undefined)
|
||||
void session.compact(instructions === "" ? undefined : instructions)
|
||||
.then((result) => {
|
||||
this.events.publish(session.sessionId, {
|
||||
type: "command.output",
|
||||
@@ -71,7 +68,7 @@ export class SessionCommandService {
|
||||
message: formatCompactionResult(result),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.events.publish(session.sessionId, { type: "command.output", level: "error", message: `Compaction failed: ${message}` });
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
@@ -81,7 +78,7 @@ export class SessionCommandService {
|
||||
|
||||
private async clone(active: ActiveSession): Promise<ClientCommandResult> {
|
||||
const leafId = active.runtime.session.sessionManager.getLeafId();
|
||||
if (!leafId) return { type: "unsupported", message: "Cannot clone: no current session entry" };
|
||||
if (leafId === null || leafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" };
|
||||
const result = await active.runtime.fork(leafId, { position: "at" });
|
||||
if (result.cancelled) return { type: "done", message: "Clone cancelled" };
|
||||
return { type: "done", message: "Session cloned", session: clientSessionFromRuntime(active.runtime) };
|
||||
@@ -113,7 +110,7 @@ function clientSessionFromRuntime(runtime: AgentSessionRuntime): ClientSession {
|
||||
id: session.sessionId,
|
||||
path: session.sessionFile ?? "",
|
||||
cwd: runtime.cwd,
|
||||
name: session.sessionName,
|
||||
...(session.sessionName === undefined ? {} : { name: session.sessionName }),
|
||||
created: new Date().toISOString(),
|
||||
modified: new Date().toISOString(),
|
||||
messageCount: session.messages.length,
|
||||
@@ -125,9 +122,9 @@ function formatSessionStats(session: AgentSession): string {
|
||||
const stats = session.getSessionStats();
|
||||
return [
|
||||
`Session: ${stats.sessionId}`,
|
||||
`Messages: ${stats.totalMessages} (${stats.userMessages} user, ${stats.assistantMessages} assistant)`,
|
||||
`Tool calls: ${stats.toolCalls}`,
|
||||
`Tokens: ↑${stats.tokens.input} ↓${stats.tokens.output} total ${stats.tokens.total}`,
|
||||
`Messages: ${String(stats.totalMessages)} (${String(stats.userMessages)} user, ${String(stats.assistantMessages)} assistant)`,
|
||||
`Tool calls: ${String(stats.toolCalls)}`,
|
||||
`Tokens: ↑${String(stats.tokens.input)} ↓${String(stats.tokens.output)} total ${String(stats.tokens.total)}`,
|
||||
`Cost: $${stats.cost.toFixed(4)}`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -135,7 +132,7 @@ function formatSessionStats(session: AgentSession): string {
|
||||
function formatCompactionResult(result: { summary: string; tokensBefore: number }): string {
|
||||
return [
|
||||
"Compaction complete.",
|
||||
`Tokens before: ${result.tokensBefore}`,
|
||||
`Tokens before: ${String(result.tokensBefore)}`,
|
||||
"",
|
||||
result.summary,
|
||||
].join("\n");
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { FastifyInstance } from "fastify";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import type { PiSessionService } from "./piSessionService.js";
|
||||
|
||||
export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): Promise<void> {
|
||||
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
|
||||
app.get<{ Querystring: { cwd?: string } }>(`${prefix}/sessions`, async (request, reply) => {
|
||||
if (!request.query.cwd) return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
return sessions.list(request.query.cwd);
|
||||
});
|
||||
|
||||
@@ -16,9 +16,10 @@ export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSe
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
|
||||
app.get<{ Params: { sessionId: string }; Querystring: { before?: string; limit?: string } }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.messages(request.params.sessionId);
|
||||
const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) };
|
||||
return await sessions.messages(request.params.sessionId, page);
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -79,7 +80,7 @@ export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSe
|
||||
return { aborted: true };
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/stop`, async (request) => {
|
||||
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/stop`, (request) => {
|
||||
sessions.stop(request.params.sessionId);
|
||||
return { stopped: true };
|
||||
});
|
||||
@@ -92,3 +93,13 @@ export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSe
|
||||
eventHub.addGlobal(socket);
|
||||
});
|
||||
}
|
||||
|
||||
function optionalField<T>(key: string, value: T | undefined): Record<string, T> | object {
|
||||
return value === undefined ? {} : { [key]: value };
|
||||
}
|
||||
|
||||
function optionalNumber(value: string | undefined): number | undefined {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,29 @@ interface ProjectFile {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function parseProjectFile(value: unknown): ProjectFile {
|
||||
if (!isRecord(value) || !Array.isArray(value["projects"])) throw new Error("Invalid project file");
|
||||
return { projects: value["projects"].map(parseProject) };
|
||||
}
|
||||
|
||||
function parseProject(value: unknown): Project {
|
||||
if (!isRecord(value)) throw new Error("Invalid project");
|
||||
const id = value["id"];
|
||||
const name = value["name"];
|
||||
const path = value["path"];
|
||||
const createdAt = value["createdAt"];
|
||||
if (typeof id !== "string" || typeof name !== "string" || typeof path !== "string" || typeof createdAt !== "string") throw new Error("Invalid project");
|
||||
return { id, name, path, createdAt };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export class ProjectStore {
|
||||
constructor(private readonly filePath = join(homedir(), ".pi-web", "projects.json")) {}
|
||||
|
||||
@@ -21,9 +44,11 @@ export class ProjectStore {
|
||||
const existing = data.projects.find((p) => p.path === path);
|
||||
if (existing) return existing;
|
||||
|
||||
const trimmedName = input.name?.trim();
|
||||
const leafName = path.split("/").filter((part) => part !== "").at(-1);
|
||||
const project: Project = {
|
||||
id: randomUUID(),
|
||||
name: input.name?.trim() || path.split("/").filter(Boolean).at(-1) || path,
|
||||
name: trimmedName !== undefined && trimmedName !== "" ? trimmedName : leafName ?? path,
|
||||
path,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -38,9 +63,10 @@ export class ProjectStore {
|
||||
|
||||
private async read(): Promise<ProjectFile> {
|
||||
try {
|
||||
return JSON.parse(await readFile(this.filePath, "utf8")) as ProjectFile;
|
||||
} catch (error: any) {
|
||||
if (error?.code === "ENOENT") return { projects: [] };
|
||||
const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
|
||||
return parseProjectFile(value);
|
||||
} catch (error: unknown) {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) return { projects: [] };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ export interface ClientSession {
|
||||
firstMessage: string;
|
||||
}
|
||||
|
||||
export interface ClientMessagePage {
|
||||
messages: unknown[];
|
||||
start: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ClientSessionStatus {
|
||||
sessionId: string;
|
||||
model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown };
|
||||
|
||||
@@ -14,15 +14,18 @@ export class WorkspaceService {
|
||||
const worktrees = await discoverGitWorktrees(project.path);
|
||||
if (worktrees.length === 0) return [this.single(project)];
|
||||
|
||||
return worktrees.map((worktree) => ({
|
||||
id: idFor(`${project.id}:${worktree.path}`),
|
||||
projectId: project.id,
|
||||
path: worktree.path,
|
||||
label: worktree.branch || (worktree.detached ? "detached" : worktree.path.split("/").filter(Boolean).at(-1) || worktree.path),
|
||||
branch: worktree.branch,
|
||||
isMain: worktree.path === project.path,
|
||||
isGitWorktree: true,
|
||||
}));
|
||||
return worktrees.map((worktree) => {
|
||||
const leafName = worktree.path.split("/").filter((part) => part !== "").at(-1);
|
||||
return {
|
||||
id: idFor(`${project.id}:${worktree.path}`),
|
||||
projectId: project.id,
|
||||
path: worktree.path,
|
||||
label: worktree.branch ?? (worktree.detached === true ? "detached" : leafName ?? worktree.path),
|
||||
...(worktree.branch === undefined ? {} : { branch: worktree.branch }),
|
||||
isMain: worktree.path === project.path,
|
||||
isGitWorktree: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private single(project: Project): Workspace {
|
||||
|
||||
Reference in New Issue
Block a user